什么是微前端

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

微前端借鉴了微服务的架构理念,将一个庞大的前端应用拆分为多个独立灵活的小型应用,每个应用都可以独立开发、独立运行、独立部署,再将这些小型应用联合为一个完整的应用。微前端既可以将多个项目融合为一,又可以减少项目之间的耦合,提升项目扩展性,相比一整块的前端仓库,微前端架构下的前端仓库倾向于更小更灵活。


特性

  • 技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈
  • 独立开发/部署 各个团队之间仓库独立,单独部署,互不依赖
  • 增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性
  • 独立运行时 微应用之间运行时互不依赖,有独立的状态管理
  • 提升效率 应用越庞大,越难以维护,协作效率越低下。微应用可以很好拆分,提升效率

目前可用的微前端方案

微前端的方案目前有以下几种类型:

基于 ​​iframe​​ 完全隔离的方案

作为前端开发,我们对 ​​iframe​​ 已经非常熟悉了,在一个应用中可以独立运行另一个应用。它具有显著的优点:

  1. 非常简单,无需任何改造
  2. 完美隔离,JS、CSS 都是独立的运行环境
  3. 不限制使用,页面上可以放多个​​iframe​​ 来组合业务

当然,缺点也非常突出:

  1. 无法保持路由状态,刷新后路由状态就丢失
  2. 完全的隔离导致与子应用的交互变得极其困难
  3. ​iframe​​ 中的弹窗无法突破其本身
  4. 整个应用全量资源加载,加载太慢

这些显著的缺点也催生了其他方案的产生。

基于 ​​single-spa​​ 路由劫持方案

​single-spa​​ 通过劫持路由的方式来做子应用之间的切换,但接入方式需要融合自身的路由,有一定的局限性。

​qiankun​​​ 孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台。它对 ​​single-spa​​​ 做了一层封装。主要解决了 ​​single-spa​​​ 的一些痛点和不足。通过 ​​import-html-entry​​​ 包解析 ​​HTML​​ 获取资源路径,然后对资源进行解析、加载。

通过对执行环境的修改,它实现了 ​​ JS 沙箱​​​、​​样式隔离​​ 等特性。

京东 ​​micro-app​​ 方案

京东 ​​micro-app​​​ 并没有沿袭 ​​single-spa​​​ 的思路,而是借鉴了 ​​WebComponent​​​ 的思想,通过 ​​CustomElement​​​ 结合自定义的 ​​ShadowDom​​​,将微前端封装成一个类 ​​webComponents​​ 组件,从而实现微前端的组件化渲染。

在 ​​Vite​​ 上使用微前端

我们从 ​​我们从 UmiJS 迁移到了 Vite​​ 之后,微前端也成为了势在必行,当时也调研了很多方案。

为什么没用 ​​qiankun​

​qiankun​​​ 是目前是社区主流微前端方案。它虽然很完善、流行,但最大的问题就是不支持 ​​Vite​​​。它基于 ​​import-html-entry​​​ 解析 HTML 来获取资源,由于 ​​qiankun​​​ 是通过 ​​eval​​​ 来执行这些 ​​js​​​ 的内容,而 ​​Vite​​​ 中的 ​​script​​​ 标签类型是 ​​type="module"​​​,里面包含 ​​import/export​​​ 等模块代码, 所以会报错:不允许在非 ​​type="module"​​​ 的 ​​script​​​ 里面使用 ​​import​​。

退一步实现,我们采用了 ​​single-spa​​​ 的方式,并使用 ​​systemjs​​​ 的方式进行了微前端加载方案,也踩了不少的坑。​​single-spa​​ 没有一个友好的教程来接入,文档虽然多,但大多都在讲概念,当时让人觉得有一种深奥的感觉。

后来看了它的源码发现,这都是些什么……里面大部分代码都是围绕路由劫持而展开的,根本没有文档上那种高大上的感觉。而我们又用不到它路由劫持的功能,那我们为什么要用它?

从组件化的层面来说 ​​single-spa​​ 这种方式实现得一点都不优雅。

  1. 它劫持了路由,与​​react-router​​ 和组件化的思维格格不入
  2. 接入方式一大堆繁杂的配置
  3. 单实例的方案,即同一时刻,只有一个子应用被展示

后来琢磨着 ​​single-spa​​ 的缺点,我们可以自己实现一个组件化的微前端方案。

如何实现一个简单、透明、组件化的方案

通过组件化思维实现一个微应用非常简单:子应用导出一个方法,主应用加载子应用并调用该方法,并传入一个 ​​Element​​ 节点参数,子应用得到该 ​​Element​​ 节点,将本身的组件 ​​appendChild​​ 到 ​​Element​​ 节点上。

【微前端】1228- Vite 微前端实践,实现一个组件化的方案_组件化


类型约定

在此之前我们需要约定一个主应用与子应用之间的一个交互方式。主要通过三个钩子来保证应用的正确执行、更新、和卸载。

类型定义:

export interface AppConfig {
// 挂载
mount?: (props: unknown) => void;
// 更新
render?: (props: unknown) => ReactNode | void;
// 卸载
unmount?: () void;
}

子应用导出

通过类型的约定,我们可以将子应用导出:​​mount​​​、​​render​​​、​​unmount​​ 为主要钩子。

​React​​ 子应用实现:

export default (container: HTMLElement) => {
let handleRender: (props: AppProps) => void;

// 包裹一个新的组件,用作更新处理
function Main(props: AppProps) {
const [state, setState] = React.useState(props);
// 将 setState 方法提取给 render 函数调用,保持父子应用触发更新
handleRender = setState;
return <App {...state} />;
}

return {
mount(props: AppProps) {
ReactDOM.render(<Main {...props} />, container);
},
render(props: AppProps) {
handleRender?.(props);
},
unmount() {
ReactDOM.unmountComponentAtNode(container);
},
};
};

Vue 子应用实现:

import { createApp } from 'vue';
import App from './App.vue';

export default (container: HTMLElement) => {
// 创建
const app = createApp(App);
return {
mount() {
// 装载
app.mount(container);
},
unmount() {
// 卸载
app.unmount();
},
};
};

主应用实现

​React​​ 实现

其核心代码仅十余行,主要处理与子应用交互 (为了易读性,隐藏了错误处理代码)

export function MicroApp({ entry, ...props }: MicroAppProps) {
// 传递给子应用的节点
const containerRef = useRef<HTMLDivElement>(null);
// 子应用配置
const configRef = useRef<AppConfig>();

useLayoutEffect(() {
import(/* @vite-ignore */ entry).then((res) => {
// 将 div 传给子应用渲染
const config = res.default(containerRef.current);
// 调用子应用的装载方法
config.mount?.(props);
configRef.current = config;
});
return () {
// 调用子应用的卸载方法
configRef.current?.unmount?.();
configRef.current = undefined;
};
}, [entry]);

return <div ref={containerRef}>{configRef.current?.render?.(props)}</div>;
}

完成,现在已经实现了主应用与子应用的装载、更新、卸载的操作。现在,它是一个组件,可以同时渲染出多个不同的子应用,这点就比 ​​single-spa​​ 优雅很多。

entry 子应用地址,当然真实情况会根据 ​​dev​​​ 和 ​​prod​​ 模式给出不同的地址:

<MicroApp className="micro-app" entry="//localhost:3002/src/main.tsx" />

​Vue​​ 实现

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';

const { entry, ...props } = defineProps<{ entry: string }>();
const container = ref<HTMLDivElement | null>(null);
const config = ref();

onMounted(() {
const element = container.value;
import(/* @vite-ignore */ entry).then((res) => {
// 将 div 传给子应用渲染
const config = res.default(element);
// 调用子应用的装载方法
config.mount?.(props);
config.value = config;
});
});

onUnmounted(() {
// 调用子应用的卸载方法
config.value?.unmount?.();
});
</script>

<template>
<div ref="container"></div>
</template>

【微前端】1228- Vite 微前端实践,实现一个组件化的方案_组件化_02


如何让子应用也能独立运行

​single-spa​​​ 等众多方案,都是将一个变量挂载到 ​​window​​​ 上,通过判断该变量是否处于微前端环境,这样很不优雅。在 ​​ESM​​​ 中,我们可以通过 ​​import.meta.url​​ 传入参数来判断:

if (!import.meta.url.includes('microAppEnv')) {
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root'),
);
}

入口导入修改:

// 添加环境参数和当前时间避免被缓存
import(/* @vite-ignore */ `${entry}?microAppEnv&t=${Date.now()}`);

浏览器兼容性

​IE​​​ 浏览器已经逐步退出我们的视野,基于 ​​Vite​​​,我们只需要支持 ​​import​​​ 的特性浏览器就够了。当然,如果考虑 ​​IE​​​ 浏览器的话也不是不可以,很简单:将上面代码的 ​​import​​​ 替换为 ​​System.import​​​ 即 ​​systemjs​​​,也是 ​​single-spa​​ 的所推崇的用法。

浏览器

Chrome

Edge

Firefox

Internet Explorer

Safari

import

61

16

60

No

10.1

Dynamic import

63

79

67

No

11.1

import.meta

64

79

62

No

11.1

模块公用

我们的子组件必须要使用 ​​mount​​​ 、​​unount​​​ 模式吗?答案是不一定,如果我们的技术栈都是 ​​React​​​ 的话。我们的子应用只导出一个 ​​render​​​ 就够了。这样用的就是同一个 ​​React​​​ 来渲染,好处是子应用可以消费父应用的 ​​Provider​​​。但有个前提是两个应用之间的 ​​React​​ 必须为同一个实例,否则就会报错。

我们可以将 ​​react​​​、​​react-dom​​​ 、​​styled-componets​​​ 等常用模块提前打包成 ​​ESM​​ 模块,然后放到文件服务中使用。

更改 ​​Vite​​​ 配置添加 ​​alias​​:

defineConfig({
resolve: {
alias: {
react: '//localhost:8000/react@17.js',
'react-dom': '//localhost:8000/react-dom@17.js',
},
},
});

这样就能愉快地使用同一份 ​​React​​​ 代码了。还能抽离出主应用和子应用之间的公用模块,让应用总体积更小。当然如果没上 ​​http2​​ 的话,就需要考虑颗粒度的问题了。

在线 ​​CDN​​ 方案:https://esm.sh

还有个 ​​importmap​​ 方案,兼容性不太好,但未来是趋势:

<script type="importmap">
{
"imports": {
"react": "//localhost:8000/react@17.js"
}
}
</script>

父子通信

组件式微应用,可以传递参数而通信,完全就是 ​​React​​ 组件通信的模型。

资源路径

在 ​​Vite​​​ 的 ​​dev​​ 模式中,子应用里面静态资源一般会这样引入:

import logo from './images/logo.svg';

<img src={logo} />;

图片的路径:​​/basename/src/logo.svg​​​,在主应用显示就会 404。因为该路径只是存在于子应用。我们需要配合 ​​URL​​​ 模块使用,这样路径前面会带上 ​​origin​​ 前缀:

const logoURL = new URL(logo, import.meta.url);

<img src={logoURL.href} />;

当然这样使用比较繁琐,我们可以将其封装为一个 ​​Vite​​ 插件自动处理该场景。

路由同步

项目使用 ​​react-router​​​,那么它可能会存在路由不同步的问题,因为不是同一个 ​​react-router​​ 实例。即路由之间出现不联动的现象。

在 ​​react-router​​​ 支持自定义 ​​history​​ 库,我们可以创建:

import { createBrowserHistory } from 'history';

export const history = createBrowserHistory();

// 主应用:路由入口
<HistoryRouter history={history}>{children}</HistoryRouter>;

// 主应用:传递给子应用
<Route
path="/child-app/*"
element={<MicroApp entry="//localhost:3002/src/main.tsx" history={history} />}
/>;

// 子应用:路由入口
<HistoryRouter basename="/child-app" history={history}>
{children}
</HistoryRouter>;

最终子应用使用同一份 ​​history​​​ 模块。当然这不是唯一的实现,也不是优雅的方式,我们可以将路由实例 ​​navigate​​ 传递给子应用,这样也能实现路由的交互。

注意:子应用的 ​​basename​​​ 必须与主应用的 ​​path​​​ 名称保持一致。这里还需要修改 ​​Vite​​​ 的配置 ​​base​​ 字段:

export default defineConfig({
base: '/child-app/',
server: {
port: 3002,
},
plugins: [react()],
});

JS 沙箱

因为沙箱在 ​​ESM​​​ 下不支持,因为无法动态改变执行环境中模块 ​​window​​ 对象,也无法注入新的全局对象。

一般 ​​React​​​、​​Vue​​ 项目也很少修改全局变量,做好代码规范检查才是最主要的。

CSS 样式隔离

自动 ​​CSS​​​ 样式隔离是有代价的,一般我们建议子应用使用不同的 ​​CSS​​​ 前缀,再配合 ​​CSS Modules​​ 基本上能实现需求。

打包部署

部署可以根据子应用的 ​​base​​​ 放置在不同的目录,并将名称对应。配置好 ​​nginx​​​ 转发规则就可以了。我们可以将子应用统一路由前缀,便于 ​​nginx​​ 将主应用区分开并配置通用规则。

比如将主应用放置在 ​​system​​​ 目录,子应用放置在 ​​app-​​ 开头的目录:

location ~ ^\/app-.*(\..+)$ {
root /usr/share/nginx/html;
}

location / {
try_files $uri $uri/ /index.html;
root /usr/share/nginx/html/system;
index index.html index.htm;
}

优点

1. 简单 核心不足 100 行代码,无需多余的文档

2. 灵活 通过约定的方式接入,也可以渐进增强

3. 透明 无任何劫持方案,更多逻辑透明性

4. 组件化 组件化的渲染及参数通信

5. 基于 ESM 支持 Vite,面向未来

6. 向下兼容 可选 SystemJS 方案,兼容低版本浏览器

有示例吗

示例代码在 ​​Github​​​,感兴趣的朋友可以 ​​clone​​ 下来学习。由于我们的技术栈是 ​​React​​,所以这里示例的主应用的实现用的是 ​​React​​ 。

  • 微前端组件(React):https://github.com/MinJieLiu/micro-app
  • 微前端示例:https://github.com/MinJieLiu/micro-app-demo

结语

微前端的方案适合团队场景的最好,打造一个团队能掌控的方案尤为重要。

参考资料:

  1. ​https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/import.meta​
  2. ​https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/import​


【微前端】1228- Vite 微前端实践,实现一个组件化的方案_组件化_03