带你走进微前端👇

5,268 阅读12分钟

前言

由于 Web 应用在逐步取代传统的 PC 软件时,大规模 Web 应用在面对高复杂度和涉及团队成员广下无法同时保证 DX(developer experience)和 UX(user experience) 的困境。传统 Web 应用在开发大规模应用和多研发团队协作时面临问题:单一代码仓库维护难以灵活更新,SPA 模式优势性能好但权限、代码复用、构建时间等方面存在挑战,并且拆分成多个仓库维护解决权限、构建、技术体系等问题,但用户体验割裂,跳转页面影响性能,系统间通信困难,产品权限难统一,建立独立孤岛。

传统的 web 应用存在的问题:

  1. 单页应用模式下,即使只需要访问一个模块,也需要加载所有资源,导致首屏加载缓慢、内存占用高,甚至可能导致项目崩溃。
  2. 发布应用时需要将所有模块一起打包,即使只有很小的改动也需要重新打包整个项目,耗时通常在二十分钟到半个小时之间。
  3. 不同模块开发进度不统一时,无法只发布部分模块。有时必须将一些模块回滚至稳定版本,或者暂时不提交不合并,以确保新版本的稳定运行。
  4. 每个开发小组都需要拉取整个项目的代码才能进行开发调试,即使只负责维护一个模块,本地也会存在大量冗余代码。

什么是微前端

Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. -- Micro Frontends

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

简单的说微前端是前端领域的一种架构方法,其关键点还是解耦与分治, 旨在让前端应用也能像后端服务一样实现分布式部署。通过微前端的模块化和独立部署特性,可以让前端团队像后端团队一样独立开发、测试和部署功能,从而实现整个应用的分布式部署和独立运行。

未使用微服务的项目架构

image

使用微服务的项目架构

image

由此可见,微前端与普通 Web 应用的区别主要在于架构设计、模块化开发、技术栈多样性、部署和维护、以及用户体验和性能优化等方面。微前端架构更加灵活、可维护,并支持多团队协作开发,适用于大规模复杂 Web 应用的开发。选择合适的架构取决于项目需求和团队情况。

微前端架构蕴核心价值

  • 团队自治

在公司中,团队通常根据业务进行划分。在没有微前端的情况下,多个团队共同维护一个项目可能会导致一些冲突,如代码合并和上线时间冲突等。引入微前端后,可以根据业务模块将项目拆分成小模块,每个模块由不同团队独立维护、开发和部署,实现团队自治,减少甚至消除与其他团队的冲突。

  • 兼容老项目

对于古老的或其他巨石项目,公司可能不希望继续使用旧的技术栈进行维护。通过微服务方式拆分项目是一个很好的选择,可以使项目更具可维护性和灵活性。

  • 跨技术栈

当需要为微前端系统新增业务模块时,只需单独创建一个项目。团队可以根据需要选择自己喜欢的技术栈,即使与其他模块采用不同技术栈也不会带来任何问题。这种灵活性使团队可以更好地发挥各自的优势。

image

微前端的缺点

  • 复杂度从代码转向基础设施

  • 整个应用的稳定性和安全性变得更加不可控

  • 具备一定的学习和了解成本

  • 需要建立全面的微前端周边设施,才能充分发挥其架构的优势

    • 调试工具
    • 监控系统
    • 上层 Web 框架
    • 部署平台

微前端架构使用场景

  1. 大规模企业级 Web 应用开发,能够有效管理复杂的项目结构和团队合作。
  2. 跨团队及企业级应用协作开发,不同团队独立开发和部署各自的微前端应用。
  3. 长期收益高于短期收益,微前端架构在长期维护和扩展中具有明显优势。
  4. 不同技术选型的项目,各个子应用可以采用不同的技术栈,灵活性更高。
  5. 单个产品中部分模块需要独立发布、灰度等能力,微前端可以实现精细化的部署。
  6. 微前端的目标并非取代 Iframe,而是更灵活的组织和管理应用。
  7. 应用的来源必须可信,微前端的各个部分应当安全可靠。
  8. 用户体验要求更高,微前端架构有利于优化用户体验和提升应用性能。

欧克,以上我们已经初步了解了微前端的背景以及基本概念,接下来将为大家介绍一些主流的微前端解决方案 👌

微前端的解决方案

在研究了大量资料后,以下为解决方案的总结,如有遗漏,欢迎补充~🙂

  1. 现有微前端解决方案:
  • iframe
  • Web Components
  • ESM
  • qiankun
  • EMP
  1. 各解决方案的利弊:
  • iframe可以直接加载其他应用,但无法做到单页导致许多功能无法正常在主应用中展示。
  • web ComponentsESM是浏览器提供给开发者的能力,能在单页中实现微前端,不过后者需要做好代码隔离,并且他们都是浏览器的新特性,都存在兼容性问题,微前端方面的探索也不成熟,只能作为面向未来的微前端手段。
  • qiankun基本上可以称为单页版的 iframe,具有沙箱隔离资源预加载的特点,几乎无可挑剔。
  • EMP作为最年轻微前端解决方案,也是吸收了许多 web 优秀特性才诞生的,它在实现微前端的基础上,扩充了跨应用状态共享跨框架组件调用远程拉取 ts 声明文件动态更新微应用等能力。同时,细心的小伙伴应该已经发现,EMP能做到第三方依赖的共享,使代码尽可能地重复利用,减少加载的内容。

以下表格为各解决方案的总结:

解决方案相对特点缺点
iframe天生隔离样式与脚本、多页不是单页应用,会导致浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用
弹框类的功能无法应用到整个大应用中,只能在对应的窗口内展示
由于可能应用间不是在相同的域内,主应用的 cookie 要透传到根域名都不同的子应用中才能实现免登录效果
每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程,占用大量资源的同时也在极大地消耗资源
iframe 的特性导致搜索引擎无法获取到其中的内容,进而无法实现应用的 seo
Web Components天生隔离样式与脚本无法兼容所有浏览器
ESM远程加载模块无法兼容所有浏览器(但可以通过编译工具解决)
需手动隔离样式(可通过 css module 解决)
qiankunHTML Entry 接入方式-
EMP每个微应用独立部署运行目前无法涵盖所有框架
动态更新微应用
去中心化
跨技术栈组件式调用
按需加载
应用间通信
生成对应技术栈模板
远程拉取 ts 声明文件

由此可见,qiankun 基本上可以称为单页版的 iframe,具有沙箱隔离资源预加载的特点,几乎无可挑剔。稍后,将以 qiankun 为示例,为大家详细介绍微前端的项目架构。

微前端-qiankun 示例

需求:做一个 vue3 的微前端,以 vue3 为主应用,其他技术栈为子应用。

微前端分主应用和子应用,主应用主要是负责整个布局、注册子应用以及路由管理;子应用主要是按照业务拆分的独立的单页应用。

微前端的运行过程包括以下步骤:

  1. 浏览器访问主应用:主应用被下载到浏览器并开始运行。
  2. 主应用注册子应用:设置子应用相关配置,如子应用名称、入口、加载到的 DOM 元素、激活路径等。
  3. 启动主应用。
  4. 加载子应用:浏览器根据子应用入口下载子应用的 HTML 模版。
  5. 解析子应用:框架开始解析子应用的 HTML 模版,包括 DOM、JavaScript 和 CSS 资源。
  6. 加载子应用资源:浏览器开始下载子应用的 JavaScript 和 CSS 资源。
  7. 创建沙箱环境:主应用为子应用创建沙箱环境,以隔离各应用,避免相互干扰。
  8. 挂载子应用:子应用开始运行。
  9. 预加载其他子应用资源:微前端采用预加载技术,在网络空闲时加载其他子应用资源,以确保在唤起时资源已经准备就绪,能够快速运行。

1. 创建一个 vue3.0 的基座

vue create main-app

2. 安装  qiankun

 $ yarn add qiankun # 或者 npm i qiankun -S

3. 创建 micro-app.ts

// 1.要加载的子应用列表
const microApps = [
  {
    id: "",
    name: "pro-oms-web",// 子应用名称
    entry:import.meta.env.MODE === "dev" ? "//localhost:5001" : "/main/omsweb/",//根据环境判断子应用运行地址
    activeRule: "/main/omsweb",//匹配的路由
    cname: "商品中心",
    ename: "oms",
    isShow: true,
    defaultRoute: "",
  },
  {
    id: "",
    name: "pro-wms-web",
    entry:
      import.meta.env.MODE === "dev" ? "//localhost:7001" : "/main/wmsweb/",
    activeRule: "/main/wmsweb",
    cname: "订单中心",
    ename: "wms",
    isShow: true,
    defaultRoute: "",
  },
  {
    id: "",
    name: "pro-tms-web",
    entry:
      import.meta.env.MODE === "dev" ? "//localhost:6001" : "/main/tmsweb/",
    activeRule: "/main/tmsweb",
    cname: "财务中心",
    isShow: true,
    ename: "tms",
    defaultRoute: "",
  },
 ...
];
export default microApps;

4. main.ts 引入 micro-app.ts

import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import { createStore } from "vuex";
import App from "./App.vue";
import { registerMicroApps, start } from "qiankun";
import mainApp from "./main-app";

const app = createApp(App);
const router = createRouter({
    history: createWebHistory(),
    routes: [],
});
const store = createStore({
    state: {},
});

app.use(router);
app.use(store);
app.mount("#app");

registerMicroApps(mainApp, {
    beforeLoad: (app) => {
        console.log("before load app.name====>>>>>", app.name);
    },
    beforeMount: [
        (app) => {
            console.log(
                "[LifeCycle] before mount %c%s",
                "color: green;",
                app.name
            );
        },
    ],
    afterMount: [
        (app) => {
            console.log(
                "[LifeCycle] after mount %c%s",
                "color: green;",
                app.name
            );
        },
    ],
    afterUnmount: [
        (app) => {
            console.log(
                "[LifeCycle] after unmount %c%s",
                "color: green;",
                app.name
            );
        },
    ],
});

start();

5. 配置主应用路由

在 main-app/src/view 文件夹下添加 mian.vue 文件作为入口文件

<template>
  <div id="test-web"></div>
</template>

<script>
import { onMounted } from 'vue';

export default {
  setup() {
    onMounted(() => {
      // Your mounted logic here
    });
  }
};
</script>

<style>
#test-web {
  width: 100%;
  height: 100%;
}
</style>

routes.ts

import { createRouter, createWebHistory } from "vue-router";
import { defineComponent } from "vue";
import HomeView from "../views/HomeView.vue";
const layout = defineComponent(() => import("../views/qiankun/index.vue"));

const routes = [
    {
        path: "/",
        name: "home",
        component: HomeView,
    },
    {
        path: "/about",
        name: "about",
        component: () =>
            import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
    },
    {
        path: "/test-web/*",
        meta: "test-web",
        component: layout,
    },
];

const router = createRouter({
    history: createWebHistory(),
    routes,
});

export default router;

6. 创建子应用

vue create goods-app

7. 修改子应用 main.ts

import { createApp } from "vue";
import { createWebHistory, createRouter } from "vue-router";
import { createStore } from "vuex";
import App from "./App.vue";
import router from "./router";
import store from "./store";

const app = createApp(App);
app.use(router);
app.use(store);

app.config.productionTip = false;

let instance = null;

function render(props = {}) {
    const { container } = props;
    console.log(11111111111111, window.__POWERED_BY_QIANKUN__, "字段值");
    instance = app.mount(
        container ? container.querySelector("#app") : "#app",
        true
    ); //开启沙箱
}

if (!window.__POWERED_BY_QIANKUN__) {
    console.log("独立运行");
    render();
}

function storeTest(props) {
    props.onGlobalStateChange &&
        props.onGlobalStateChange(
            (value, prev) =>
                console.log(
                    `[onGlobalStateChange - ${props.name}]:`,
                    value,
                    prev
                ),
            true
        );
    props.setGlobalState &&
        props.setGlobalState({
            ignore: props.name,
            user: {
                name: props.name,
            },
        });
}

export async function bootstrap() {
    console.log("111111111111 [vue] vue app bootstraped");
}

export async function mount(props) {
    console.log("11111111111 [vue] props from main framework", props);
    storeTest(props);
    render(props);
}

export async function unmount() {
    instance.unmount();
    instance.$el.innerHTML = "";
    instance = null;
}

8. 注册子应用路由

<!--子应用页面代码-->
<template>
  <div class="goods-app">我是子应用页面11</div>
</template>

<style lang="scss" scoped>
.goods-app {
  cursor: pointer;
  background-color: aqua;
}
</style>


<!--router代码-->
import { createRouter, createWebHistory } from 'vue-router';
import { defineAsyncComponent } from 'vue';
import HomeView from '../views/HomeView.vue';

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    component: defineAsyncComponent(() => import(/* webpackChunkName: "about" */ '../views/AboutView.vue'))
  },
  {
    path: '/test',
    name: 'test',
    component: defineAsyncComponent(() => import(/* webpackChunkName: "about" */ '../views/subapp/index.vue'))
  },
  {
    path: '/testtwo',
    name: 'testtwo',
    component: defineAsyncComponent(() => import(/* webpackChunkName: "about" */ '../views/subapp/two.vue'))
  }
];

const router = createRouter({
  history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/test-web/' : '/'),
  routes
});

export default router;


9. 修改 vite.config.ts

import { name } from "./package.json";

module.exports = {
    publicPath: "/", // 打包相对路径
    devServer: {
        port: 8081, // 运行端口号
        headers: {
            "Access-Control-Allow-Origin": "*", // 防止加载时跨域
        },
    },
    chainWebpack: (config) => config.resolve.symlinks(false),
    configureWebpack: {
        output: {
            library: `${name}-[name]`,
            libraryTarget: "umd", // 把微应用打包成 umd 库格式
            // webpack5.0以上版本使用如下字段
            chunkLoadingGlobal: `webpackJsonp_${name}`,
        },
    },
};

10. 修改引用主应用和子应用

主应用的 App.vue 添加如下代码

<script setup lang="ts"></script>

<template>
  <el-config-provider namespace="eh">
    <router-view></router-view>
  </el-config-provider>
</template>

<script setup lang="tsx">
import { ElConfigProvider } from "element-plus";


import { onBeforeMount } from "vue";
import { useDictStore } from "@/stores/modules/dict";

const dictData = useDictStore();

onBeforeMount(() => {
  // 获取前置数据
});
</script>

项目目录

image

常见问题

如何独立运行微应用?

if (!window.qiankunStarted) {
    //判断是否为qiankun环境下运行
    window.qiankunStarted = true;
    start({});
}

微应用之间如何跳转?

微应用之间的跳转,或者微应用跳主应用页面,直接使用微应用的路由实例是不行的,如 react-router 的 Link 组件或 vue 的 router-link,原因是微应用的路由实例跳转都基于路由的  base。有这几种办法可以跳转:

  1. history.pushState()mdn 用法介绍
  2. 直接使用原生 a 标签写完整地址,如:<a href="http://localhost:8080/app1">app1</a>
  3. 修改 location href 跳转,如:window.location.href = 'http://localhost:8080/app1'

如何同时激活两个微应用?

微应用何时被激活完全取决于你的  activeRule  配置,比如下面的例子里,我们将  reactApp  和  react15App  的  activeRule  逻辑设置成一致的:

registerMicroApps([
  // 自定义 activeRule
  { name: 'reactApp', entry: '//localhost:7100', container, activeRule: () => isReactApp() },
  { name: 'react15App', entry: '//localhost:7102', container, activeRule: () => isReactApp() },
  { name: 'vueApp', entry: '//localhost:7101', container, activeRule: () => isVueApp() },
]);


start({ singular: false });

当在  start  方法中配置好  singular: false  后,只要  isReactApp()  返回  true  时,reactApp  和  react15App  将会同时被 mount。

页面上不能同时显示多个依赖于路由的微应用,因为浏览器只有一个 url,如果有多个依赖路由的微应用同时被激活,那么必定会导致其中一个 404。

如何解决拉取微应用 entry 时 cookie 未携带的问题

因为拉取微应用 entry 的请求都是跨域的,所以当你的微应用是依赖 cookie (如登陆鉴权)的情况下,你需要通过自定义 fetch 的方式,开启 fetch 的 cors 模式:

  • 如果你是通过  registerMicroApps  加载微应用的,你需要在 start 方法里配置自定义 fetch,如:

    import { start } from 'qiankun';
    
    
    start({
      fetch(url, ...args) {
        // 给指定的微应用 entry 开启跨域请求
        if (url === 'http://app.alipay.com/entry.html') {
          return window.fetch(url, {
            ...args,
            mode: 'cors',
            credentials: 'include',
          });
        }
    
    
        return window.fetch(url, ...args);
      },
    });
    
  • 如果你是通过  loadMicroApp  加载微应用的,你需要在调用时配置自定义 fetch,如:

    import { loadMicroApp } from 'qiankun';
    
    
    loadMicroApp(app, {
      fetch(url, ...args) {
        // 给指定的微应用 entry 开启跨域请求
        if (url === 'http://app.alipay.com/entry.html') {
          return window.fetch(url, {
            ...args,
            mode: 'cors',
            credentials: 'include',
          });
        }
    
    
        return window.fetch(url, ...args);
      },
    });
    
    • 如果你是通过  umi plugin  来使用 qiankun 的,那么你只需要给对应的微应用开启 credentials 配置即可:
    export default {
      qiankun: {
        master: {
          apps: [
            {
              name: 'app',
              entry: '//app.alipay.com/entry.html',
    +         credentials: true,
            }
          ]
        }
      }
    }
    

如何解决子应用给 window 对象添加事件处理函数不生效的问题

由于子应用访问的 window 对象是被 qiankun 代理后的对象,因此直接给 window 对象添加事件处理函数是无效的,可以通过 addEventListener 给 window 添加事件监听器来解决该问题:

window.addEventListener('eventName', eventHandler);

当然,市面上目前比较流行的微前端框架有两种,不仅阿里基于 single-spa 的 qiankun,还有很多,比如京东基于 web-component 的 micro-app,大家有兴趣的话可以点赞收藏,后续会持续更新~

结语

综上所述,随着 Web 应用逐渐替代传统 PC 软件,我们面临着开发大规模应用和协作团队时的挑战。 在面对这些挑战时,我们需要综合考虑团队协作、开发体验和用户体验之间的平衡。通过合理的架构设计、技术选型和团队协作机制,我们可以克服这些困难,打造出既具有良好性能又提供流畅用户体验的大规模 Web 应用。愿我们在不断探索和实践的过程中,开拓出更加优秀和创新的解决方案,为 Web 应用发展贡献力量。

作者:洞窝-重阳