一个 Vue 2 写的后台管理系统跑了两年,产品说新模块要用 React 写,同时老的那十几个页面一个都不能动。这种需求听着像抬杠,但在稍微有点年头的公司里是常态。当时我第一反应是套个 iframe 得了,结果试了半天,弹层居中、刷新丢路由、父子通信,每一样都要单独写补丁。
后来才发现,这类问题社区早就有一整套答案,就是微前端。这篇把我自己啃 single-spa 和 qiankun 时理清的四件事写下来,路由怎么劫持、子应用资源怎么加载、JS 全局环境怎么隔离、样式怎么不互相污染。每一块都有能跑通的代码,也标了我踩过的坑。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 微前端到底在拆什么、合什么,什么情况下不值得上
single-spa做了哪些事、又故意不做哪些事qiankun在single-spa之上补的那两块拼图- 飞冰 icestark 的另一种接入姿势
- CSS 隔离的四条路子,每条在什么场景会失效
- 快照沙箱与 Proxy 沙箱的完整实现,以及各自的边界
- 应用间通信和公共依赖的几种处理方式
# 一、微前端在拆什么,又在合什么
微前端现有的落地方案大致能分三类,自组织模式、基座模式,以及模块加载模式。国内绝大多数团队用的是基座模式,也就是有一个主应用负责路由和调度,若干子应用挂在它下面。

一句话概括,微前端就是把不同功能按不同维度拆成多个子应用,再由一个主应用把它们加载回来。
微前端的核心在于拆,拆完后再合。
拆的动机通常是这三个里的某一个。第一个是技术栈打架,不同团队开发同一个应用,有人要用 React 有人守着 Vue 2,谁也说服不了谁。第二个是发版被绑死,一个仓库十几个模块,任何一个改动都要全量回归、全量发布。第三个是老代码不敢碰,项目里还留着几年前的页面,业务方不给重写预算,但新功能得用新技术写。
那把一个应用划成若干子应用,各自打包成 lib,路径切换时按需加载,是不是就都解决了?子应用独立构建、独立部署,技术栈也不再受主应用限制,协同开发这件事就顺了。
代价也得先说清楚。多一层运行时调度就多一层故障面,样式串了、子应用白屏、路由回退两次,这些排查链路都比普通 SPA 长。如果你的团队对整个系统有完全的话语权,也有人力做统一治理,那老老实实做模块化和 monorepo 可能更划算。上不上微前端,先算这笔账。
关于各家方案的横向对比和选型路径,我在 微前端落地方案对比,qiankun 无界 Module Federation 怎么选 里写得更细,这篇专注讲机制本身。
# 二、从 single-spa 到 qiankun,各自补了什么

时间线其实很短。2018 年 single-spa 出现,它把自己定位成「一个用于前端微服务化的 JavaScript 前端解决方案」,做的事情非常克制,只实现了路由劫持和应用加载,样式隔离和 JS 执行隔离一概不管。
2019 年蚂蚁的 qiankun 基于 single-spa 做二次封装,公式可以写成 single-spa + sandbox + import-html-entry。它补上了资源加载和运行时隔离这两块,接入方式做到了接近 iframe 那种开箱即用的程度。
所以子应用能独立构建、运行时动态加载、主子应用完全解耦并且技术栈无关,靠的不是什么黑魔法,是一份协议。这里提前强调一下,子应用必须导出 bootstrap、mount、unmount 三个方法,这就是协议的全部内容。
顺着上面聊几个大家一定会问的问题。
**这不就是 iframe 吗?**并不是。iframe 的隔离确实最彻底,但它有几个硬伤绕不过去。子应用切了三层路由,用户一刷新就回到首页;iframe 内部弹一个 Modal 想相对整个浏览器窗口居中,你得靠 postMessage 通知父页面画遮罩;每个 iframe 都要重新建一整套浏览器环境,白屏时间省不掉。这几条我都踩过,弹层一多代码就很难维护了。
**应用间怎么通信?**常见的有四种路子,按耦合度从低到高排:基于 URL 传参,能力最弱但最解耦;基于 CustomEvent 做事件广播;基于 props 由主应用直接注入到子应用的 mount;再就是全局变量或者 Redux 这类共享 store。中后台项目我一般只用前三种,共享 store 一旦铺开,子应用就没法独立跑了。
**公共依赖怎么处理?**两条路,一条是 CDN 加 webpack 的 externals,把 React、Vue、UI 库从各自的包里剔出去;另一条是 webpack 5 的联邦模块(Module Federation),构建时就把依赖共享关系约定好。
# 三、路由劫持,single-spa 只干这一件事
要理解 qiankun,先得把地基那层看明白。这一节用 single-spa 从零搭一遍,你会很清楚地看到它缺了什么。
# 3.1 子应用导出生命周期
先建一个 Vue 子应用,用官方适配包 single-spa-vue 生成三个生命周期函数:
vue create spa-vue
npm install single-spa-vue
single-spa-vue 帮你做的事情是把 Vue 实例的创建和销毁包装成 bootstrap、mount、unmount,你不用自己手写这套样板:
// main.js
import singleSpaVue from 'single-spa-vue';
const appOptions = {
el: '#vue',
router,
render: h => h(App)
}
// 在非子应用中正常挂载应用
if(!window.singleSpaNavigate){
delete appOptions.el;
new Vue(appOptions).$mount('#app');
}
const vueLifeCycle = singleSpaVue({
Vue,
appOptions
});
// 子应用必须导出以下生命周期:bootstrap、mount、unmount
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
export default vueLifeCycle;
中间那个 window.singleSpaNavigate 判断是全篇最实用的一行。single-spa 在接管页面时会往 window 上挂这个标记,有它说明当前跑在基座里,就把 el 删掉交给基座决定挂载点;没有它说明是自己 npm run dev 独立跑,直接挂到 #app。日常改子应用的样式和交互,不用每次都把基座起起来。
子应用的路由也得跟着改,base 要和主应用注册时的激活路径对上,否则跳转过去会 404:
// router.js
const router = new VueRouter({
mode: 'history',
base: '/vue', //改变路径配置
routes
})
配好之后子应用独立访问是这个样子:

# 3.2 把子应用打包成库
子应用要被别人加载,就不能只输出一个自己用的 bundle,得输出成 UMD 格式的库,并且挂一个能从 window 上取到的名字:
//vue.config.js
module.exports = {
configureWebpack: {
// 把属性挂载到window上方便父应用调用 window.singleVue.bootstrap/mount/unmount
output: {
library: 'singleVue',
libraryTarget: 'umd'
},
devServer:{
port:10000
}
}
}
打完包之后,浏览器里 window.singleVue 上就能看到那三个生命周期方法:

libraryTarget: 'umd' 这一条不能省。主应用是通过 script 标签把子应用的 JS 拉进来的,只有 UMD 会在浏览器环境下往 window 上挂全局变量,换成 esm 或者 commonjs 主应用什么都拿不到。
# 3.3 主应用注册与激活
主应用先准备好挂载节点:
<div id="nav">
<router-link to="/vue">vue项目router-link>
<div id="vue">div>
div>
然后手动写一个加载 script 的函数,把子应用的产物一个个拉下来:
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import {registerApplication,start} from 'single-spa'
Vue.config.productionTip = false
async function loadScript(url) {
return new Promise((resolve,reject)=>{
let script = document.createElement('script')
script.src = url
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
}
// 注册应用
registerApplication('myVueApp',
async ()=>{
console.info('load')
// singlespa问题
// 加载文件需要自己构建script标签 但是不知道应用有多少个文件
// 样式不隔离
// 全局对象没有js沙箱的机制 比如加载不同的应用 每个应用都用同一个环境
// 先加载公共的
await loadScript('http://localhost:10000/js/chunk-vendors.js')
await loadScript('http://localhost:10000/js/app.js')
return window.singleVue // bootstrap mount unmount
},
// 用户切换到/vue下 我们需要加载刚才定义的子应用
location=>location.pathname.startsWith('/vue'),
)
start()
new Vue({
router,
render: h => h(App)
}).$mount('#app')
跑起来是这样,点击导航时子应用被挂到 #vue 里:

代码注释里那几行就是 single-spa 的全部短板,写在这儿是为了下一节做对照。加载文件要你自己拼 script 标签,可你根本不知道子应用打出来多少个 chunk,文件名带 hash 之后更是没法写死;样式不隔离;全局对象也没有任何沙箱机制,几个子应用轮流往同一个 window 上写东西。
还有一个必须补的动作,动态设置子应用的 publicPath:
if(window.singleSpaNavigate){
__webpack_public_path__ = 'http://localhost:10000/'
}
这行很多人会漏掉。子应用被基座加载时,页面地址是基座的域名,如果不改 publicPath,子应用去请求自己的异步 chunk、图片、字体时全会打到基座域名上,控制台里一片 404。