微前端之背景与实践

一个 React 后台管理系统堆了两百多个页面,每次 CI 跑 45 分钟。产品经理想把隔壁团队用 Vue 写的客服系统嵌进来——“用户不要两个入口,要一个。”

这就是微前端要解决的问题。不是”多个团队怎么协作”这种管理学命题,而是你真实会遇到的:构建太慢、技术栈不统一、不同团队互相阻塞发布。

单体前端的尽头

微前端的思路其实不新。2016 年 Micro-Frontends 网站上线时,这个概念已经在前端社区酝酿了一段时间。它的命名和核心思想跟微服务一脉相承:把一个单体应用拆成多个可以独立运行、独立开发、独立部署的单元,然后组合成一个用户感知不到拼缝的整体。

康威定律说的就是这个——你设计出来的系统架构,最终会复刻你团队的组织结构。如果你的团队被拆成了三个业务小组,但代码还堆在一个仓库里,那每次合并冲突都是组织结构在代码层面的投射。

换句话说,微前端不只是在拆代码,是在让代码结构适配你的团队结构。

现代 Web 应用的两难

如果你做过中大型前端项目,大概率经历过这个选择题:

方案 A:一个大仓库,SPA 模式。 所有子应用在一个项目里,开发和调试方便,但构建越来越慢,代码耦合越来越深。改一个公共组件,你不知道会影响多少个页面。

方案 B:拆成多个仓库,MPA 模式。 每个子应用独立部署,但用户在不同子应用之间跳转时,要重新加载整页资源。白屏、状态丢失、用户体验割裂。

这两个方案分别牺牲了开发体验和用户体验。微前端的野心是:既要独立部署的开发体验,又要 SPA 般无缝的用户体验。

微前端到底解决了什么

拆开来看,微前端想达成的是这几个目标:

技术栈无关。 这点太重要了。不是每个团队都会用 React,也不是每个老系统都值得用新框架重写。如果你的后台管理系统里有一个七年前用 Angular.js 1 写的报表模块,微前端让你可以在不碰它源码的情况下,把它嵌进新的 React 主应用里并排运行。

独立部署。 每个子应用有自己的 CI/CD 流水线,上线不用等别的团队。你改你的用户模块,隔壁改他们的订单模块,互不阻塞。

增量升级。 不用一次性重写整个系统。你可以一个模块一个模块地从 jQuery 迁移到 Vue 3,用户根本察觉不到。

但我得说一句实话:这些好处不是免费的。微前端引入了新的复杂度——子应用之间的通信、公共依赖的提取、样式的隔离、路由的分发——每一项都需要额外的工作量。如果你的团队只有三五个人,一个应用还没大到难以维护的程度,引入微前端可能是在给自己找麻烦。

几条技术路线

市面上实现微前端的路子有好几种,它们之间的差异不在”能不能做”,而在”做了之后谁疼”。

iframe:最简单的方案,最深的坑

iframe 天然提供了隔离的运行环境——独立的 window、独立的 CSS、独立的 JS 执行上下文。听起来很完美。

问题在于:iframe 和主页面之间的通信很笨拙(postMessage),URL 状态不同步(子页面里的路由变化不会反映到浏览器地址栏),而且每次加载子页面都要重建整个运行时环境,性能开销不小。

如果你的场景是嵌一个完全独立的、不需要频繁跟主应用交互的页面,iframe 是可以接受的。但如果你想做的是 SPA 般流畅的多模块切换,iframe 会让你的用户体验往后退五年。

Web Components:标准化的方向,但工具链还不成熟

Web Components 提供了浏览器原生支持的组件封装能力,Shadow DOM 天然隔离样式。用纯 Web Components 构建微前端在理论上是干净的——没有任何第三方框架依赖。

但现实中,Web Components 在复杂业务场景下的开发体验还比不上 React 或 Vue。表单处理、状态管理、路由——这些在主流框架里有成熟的生态,到了 Web Components 这边你得自己搭或者找还不够成熟的库。

stencil 这类编译时工具试图弥合这个差距,但至今没成为主流选择。

Module Federation:构建时的魔法

Webpack 5 的 Module Federation 走的是另一条路:在构建时就把不同应用的模块”联邦”到一起,运行时共享依赖。

EMP 是基于 Module Federation 的方案。这条路的好处是共享依赖不需要运行时协商——webpack 在构建阶段就处理好了。但代价是你要把所有子应用绑在同一个构建工具链上,技术栈的自由度实际上受到限制。

框架方案:single-spa 和它的衍生们

single-spa 是社区公认的元老级方案。它提供了一个微前端的生命周期管理框架,让你在主应用中注册子应用,根据路由切换来挂载和卸载。

qiankun 是蚂蚁团队在 single-spa 基础上封装的方案,加了 JS 沙箱和样式隔离,并且对 umi 生态友好。

icestark 是阿里飞冰团队的方案,设计思路跟 single-spa 类似,对 React 技术栈更友好。

这三个方案里,qiankun 在国内的社区活跃度和文档完善度是目前最高的。如果你的团队想在两周内把微前端跑起来,qiankun 是我会优先推荐试试的方案。

用 qiankun 把微前端跑起来

下面是一个最小可用的 qiankun 接入流程。我们用 React 做主应用,接入两个 Vue 子应用。

主应用:注册子应用

先创建主应用并安装 qiankun:

1
2
npx create-react-app main
cd main && npm i qiankun

然后在入口文件里注册子应用并启动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
{
name: 'vue1',
entry: '//localhost:8080',
container: '#micro-container',
activeRule: '/vue1',
},
{
name: 'vue2',
entry: '//localhost:8081',
container: '#micro-container',
activeRule: '/vue2',
},
]);

start();

registerMicroApps 的三个关键参数:

  • entry:子应用的入口 HTML 地址,qiankun 会用 import-html-entry 去 fetch 它
  • container:子应用要渲染到主应用里的哪个 DOM 节点
  • activeRule:当浏览器 URL 匹配这个规则时激活该子应用

子应用:导出生命周期

qiankun 通过 import-html-entry 加载子应用,要求子应用在自己的入口文件里导出三个生命周期函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* bootstrap:子应用首次加载时调用一次。
* 适合放应用级别的缓存初始化——这些缓存不会在 unmount 时被销毁。
*/
export async function bootstrap() {
console.log('react app bootstraped');
}

/**
* mount:每次子应用被激活时调用。
* 在这里挂载你的 React/Vue 应用实例。
* props.container 是主应用传入的挂载节点,做了一层隔离。
*/
export async function mount(props) {
ReactDOM.render(
<App />,
props.container
? props.container.querySelector('#root')
: document.getElementById('root')
);
}

/**
* unmount:每次子应用被卸载时调用。
* 务必在这里销毁应用实例,否则会造成内存泄漏。
*/
export async function unmount(props) {
ReactDOM.unmountComponentAtNode(
props.container
? props.container.querySelector('#root')
: document.getElementById('root')
);
}

注意那个 props.container ? ... : ... 的判断——子应用在独立开发时(没有主应用包裹),props.container 是 undefined,需要 fallback 到 document.getElementById('root')。这个细节让你的子应用既能独立运行调试,也能被 qiankun 加载。

打包配置

子应用的 webpack 输出必须是 UMD 格式,qiankun 才能在运行时拿到子应用导出的生命周期函数:

1
2
3
4
5
6
7
8
9
const packageName = require('./package.json').name;

module.exports = {
output: {
library: `${packageName}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${packageName}`,
},
};

jsonpFunction 的命名很重要——如果多个子应用都用了默认的 webpackJsonp,它们的 webpack chunk 加载会互相冲突。

Vue 和 React 子应用的具体接入细节,qiankun 官方文档已经写得很清楚:

拆开看:自己实现一个微前端框架

用框架是一回事,理解框架内部工作原理是另一回事。如果你需要在面试里深入聊微前端,或者你的团队有特殊需求想要一个定制的轻量方案,下面这些核心模块值得了解。

应用注册与路由劫持

所有微前端框架做的第一件事,就是监听路由变化,然后决定激活哪个子应用。

浏览器提供了两种路由模式:historypushState / replaceState)和 hashhashchange 事件)。你需要同时劫持这两者:

  • pushStatereplaceState,包装原生方法,在调用后触发子应用匹配逻辑
  • popstatehashchange,监听事件,同样触发匹配逻辑

匹配逻辑本身很简单:遍历注册的子应用列表,找到 activeRule 与当前路径匹配的应用,卸载当前应用,挂载新应用。

生命周期管理

微前端框架通常定义两套生命周期:

主应用侧:

  • beforeLoad:挂载子应用前,适合做权限校验或 loading 状态
  • mounted:子应用挂载完成后,适合做埋点或全局状态更新
  • unmounted:子应用卸载后,适合做清理工作

子应用侧(由子应用导出):

  • bootstrap:首次加载时调用一次,初始化子应用全局配置
  • mount:每次激活时调用,渲染子应用
  • unmount:每次卸载时调用,销毁子应用实例

资源加载:不只是 fetch 一个 HTML

qiankun 的 import-html-entry 做的事比看起来多:

  1. fetch 子应用的入口 HTML
  2. 从中提取所有的 <script><link> 标签
  3. 依次执行 JS 代码(注意是依次执行,因为可能有依赖顺序)
  4. 收集子应用导出的生命周期函数

有一个容易忽略的点:子应用的 JS 里可能有 document.createElement('script') 动态插入的脚本,这些也需要被拦截和处理。

JS 沙箱:每个子应用活在自己的世界里

如果多个子应用都在 window 上挂全局变量,冲突不可避免。JS 沙箱解决的就是这个问题。

核心思路是用 Proxy 为每个子应用创建一个假的 window 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
export class ProxySandbox {
proxy: any
running = false

constructor() {
// 创建一个空对象作为子应用的"window"
const fakeWindow = Object.create(null)
const proxy = new Proxy(fakeWindow, {
set: (target: any, p: string, value: any) => {
// 只在子应用激活时才允许写入
if (this.running) {
target[p] = value
}
return true
},
get(target: any, p: string): any {
// 让子应用把 proxy 当作 window/self/globalThis
switch (p) {
case 'window':
case 'self':
case 'globalThis':
return proxy
}
// 如果 fakeWindow 上没有,去真实 window 上找
if (
!window.hasOwnProperty.call(target, p) &&
window.hasOwnProperty(p)
) {
const value = window[p]
// 如果是函数,bind 到真实 window 保证调用正确
if (typeof value === 'function') return value.bind(window)
return value
}
return target[p]
},
has() {
return true
},
})
this.proxy = proxy
}

active() {
this.running = true
}

inactive() {
this.running = false
}
}

几个设计决策值得注意:

  1. running 开关:子应用被卸载后,不能再往 fakeWindow 写数据。这防止了一个已卸载的子应用(比如还有未清理的定时器回调)污染全局状态。

  2. has() 永远返回 true:这让子应用里 'document' in window 这种检查不会报错,同时配合 get 中的 fallback 逻辑,让子应用感知不到自己运行在沙箱里。

  3. 函数类型的属性 bind 到真实 window:比如 addEventListener,如果不 bind,函数内部的 this 会指向 fakeWindow,导致事件监听失效。

样式隔离

除了 JS 沙箱,样式隔离是另一个大课题。常见方案有:

  • Shadow DOM:天然的样式隔离,但子应用里的第三方组件(比如 antd 的弹窗挂载到 body)会逃逸
  • CSS Modules / CSS-in-JS:在构建阶段加前缀,但要求子应用配合
  • 运行时前缀:qiankun 的实验性功能,动态给所有样式选择器加前缀

老实说,样式隔离是微前端里最不优雅的部分,至今没有一个方案能同时兼顾隔离性和兼容性。

预加载

在用户浏览当前子应用时,提前加载下一个可能访问的子应用的资源。这个策略可以显著减少切换子应用时的白屏时间——尤其是那些打包体积比较大的子应用。

什么时候不该用微前端

微前端解决的是组织层面的问题,但它引入的技术复杂度是实实在在的。以下情况我建议多想想:

  • 团队不到 10 人,应用还在快速迭代阶段。 微前端带来的额外配置和调试成本可能比它解决的问题还多。
  • 子应用之间有大量共享状态。 微前端的理想场景是业务领域边界清晰,如果两个”子应用”实际上是紧密耦合的,强行拆开只会增加通信开销。
  • 对首屏性能要求极高。 微前端通常需要在运行时动态加载子应用,首屏会比单体 SPA 多一次额外的资源请求。

我的判断标准很简单:如果你没有因为多团队协作而痛苦,就不要引入为多团队协作而设计的架构。

从哪里开始

如果你想在项目里尝试微前端,我的建议是:

  1. 从一个低风险的子应用开始。 别一上来就动核心业务。找一个相对独立的功能模块——比如后台的”系统设置”或”操作日志”——用微前端的方式接入。
  2. 先把 CI/CD 流水线跑通。 独立部署是微前端的核心价值,如果上线还要手动协调,那你只是把代码拆开了,没得到真正的好处。
  3. 读 qiankun 源码。 代码量不大,核心逻辑就几千行。理解它怎么处理路由劫持和沙箱,会让你在遇到诡异 bug 时少走很多弯路。

微前端不是什么高深的技术,它是一种工程上的取舍:用运行时的额外复杂度,换组织层面的并行效率。理解了这一点,你就知道什么时候该拿起它,什么时候该放下。