一、技术难点:微前端的难度,全部来自"几个应用共享同一个 window"
Day08 我们解决了样式串门的问题(命名空间 / scoped / Shadow DOM / @layer 四层隔离),Day37 会站到架构层去谈微前端怎么切、怎么治理。这一篇夹在中间,只回答一个工程问题:把两个独立构建、独立部署的前端应用,塞进同一个页面的同一个 DOM 容器里,运行时到底会发生什么?以及怎么让它变得可控。
先给结论:微前端的难点不在"把 HTML 拉下来塞进 div"------这一步五十行代码就够。难点在于:原本由浏览器为每个页面免费提供的隔离,现在要你在同一个全局环境里手工重造一遍。
浏览器给的隔离是硬件级的:一个 page 一个 JS Realm,一个 Realm 一份 window,两个页面绝无可能互相污染。微前端要在一个 Realm 里模拟出 N 个互不干扰的运行环境,于是所有坑都从"模拟得不完整"这一处漏出来。下面九条难点,前六条我都写了可运行的复现脚本,文中每个数字都是脚本实跑输出。
难点一:JS 沙箱拦的是"写",而"写"有两种,实现代价差一个量级
主流做法只有两种:
快照沙箱(snapshot) :应用激活时遍历 window 的所有属性拍一份快照;卸载时逐一对比,把被改动的属性还原。优点是兼容性最好,老浏览器也能跑。致命缺点是------它真的改在 window 上 。这意味着同一时刻只能有一个子应用处于激活态,多实例场景直接出局(qiankun 的 SnapshotSandbox 就属于这一类,是 legacy 路径)。
代理沙箱(proxy) :给每个应用一个 fakeWindow 对象,用 Proxy 拦截读写,再用 with 做作用域欺骗。写操作落在各自的 fakeWindow 上,宿主 window 从头到尾没被碰过。因此天然支持多实例:N 个应用可以同时挂着、同时活着。本文的实现走这条路。
这里必须先厘清一个容易混淆的点:沙箱隔离的是"写",读是透传的。 子应用必须能读到 console、document、fetch、location,所以 get 陷阱在 fakeWindow 里找不到 key 时会回退到宿主。这意味着沙箱不是安全边界(读得到宿主的一切),它解决的是"两个应用互相踩"的工程问题,不是"运行不受信任代码"的安全问题------两者不要混为一谈,这也是后文"插件市场必须用 iframe"那条场景的根据。
实测场景 A 给出了隔离的直接证据:两个应用同时挂载,各自执行 window.__APP__ = 'A' / window.__APP__ = 'B'、window.sharedCounter = 1 / = 100,读回来各是各的:
css
[A] 同写 window.__APP__ → A 读到 'A',B 读到 'B'
[A] 同写 window.sharedCounter → A=1,B=100
[A] 真实 globalThis 中 __APP__/sharedCounter 存在?false / false
第三行才是关键:真实的宿主全局里根本没有这两个属性 。全局变量全程只活在各自的 fakeWindow 上。
这里有个反直觉的细节,我第一版代码就栽在这上面:代理沙箱对"改写宿主已有属性"的处理其实是投影,不是真改 。实测场景 B1:子应用执行 window.__HOST_CONFIG__ = { theme: 'dark' },子应用内读到 dark,而宿主读到的仍是 light,宿主的对象引用也没有被替换 (宿主引用是否被换掉:false)。原因是 set 陷阱把值写进了 fakeWindow,宿主那侧只是被"影子"盖住了。
这个语义差异很重要,因为它决定了卸载逻辑怎么写:Proxy 沙箱的卸载不需要"还原宿主",只需要丢弃 fakeWindow;真正需要还原的是那些"绕过代理"的改动(见难点三、难点四)。
难点二:with + Proxy 的五个陷阱,每一个都能让隔离静默失效
这是本文最有价值的部分。因为这五个坑都不会报错,只会表现为"上线三个月后偶发一个脏数据"。
陷阱 1:window 这个标识符本身也必须走代理。 with(proxy){} 体内,标识符解析优先命中 with 对象。如果 get 陷阱对 'window' 一路转发到真实宿主,那么子应用里每一句 window.xxx 都写到了真全局上------隔离彻底失效,而且不报错。我第一版跑出来的报错是 TypeError: Cannot set properties of undefined (setting '__APP__'),纯属运气:Node 里宿主没有 window 这个属性。在浏览器里它不会报错 ,因为 window.window === window,get('window') 会安静地返回真 window。
修法是在 get 里对 window / self / globalThis / top / parent 一律返回 proxy 自己:
js
if (key === 'window' || key === 'self' || key === 'globalThis' || key === 'top' || key === 'parent') return proxy;
陷阱 2:Symbol.unscopables 必须返回 undefined。 with 会读这个 Symbol:如果它返回一个非空对象,对象里列出的标识符会被 with 作用域"让"给外层作用域 。这等于给沙箱开了个洞。实测(场景 D1,外层有一个 secret = 'HOST_VALUE'):
ini
[D1] unscopables=undefined → secret 取到 undefined(被沙箱接住,未拿到宿主值)
[D1] unscopables={secret:true} → secret 取到 "HOST_VALUE"(逃到外层,读到宿主值 = 隔离失效)
一个返回值之差,隔离从"生效"变成"完全失效"。
陷阱 3:has 陷阱必须返回 true。 with 用 has 判断"这个标识符归不归我管"。如果返回 false,那么 foo = 1(对未声明变量赋值)会被判定为"不归 with 管",于是走正常路径创建一个真全局变量。这一条最隐蔽,因为单跑一次看起来完全正常,只有在两个应用用了同名变量时才暴露。
陷阱 4:宿主方法交出去之前要绑回宿主。 window.addEventListener、document.createElement 这类方法内部有接收者校验(brand check),裸传出去调用会抛 Illegal invocation。所以 get 陷阱里遇到函数要 .bind(rawWindow)。实测:
csharp
[D] 未绑 this 调用宿主方法 → 抛错:"Illegal invocation"
陷阱 5:'use strict' 与 with 互斥------这是整个方案的能力边界。 实测 new Function("'use strict'; with(window){}") 直接抛 SyntaxError(严格模式禁止 with 语句);with 体内出现 import 语句同样抛 SyntaxError:
javascript
[F] 'use strict' + with 直接构造 → SyntaxError
[F] with 体内出现 import 语句 → 抛 SyntaxError
这条约束的工程含义极大:基于 with 的沙箱只能装载"已被 bundle 成非严格脚本"的子应用 。原生 ESM 子应用(天生严格模式、含 import 语句)在 with 沙箱里根本跑不起来。很多团队踩这个坑的时刻非常具体------给子应用升级到 Vite 默认的原生 ESM 构建的那一天,微前端突然白屏。要么把子应用的构建产物降级成 IIFE/UMD(非严格、可字符串化),要么换方案(后文 iframe / 编译期改写)。
难点三:代理只挡第一层,跨应用共享的对象是漏的
get/set 陷阱只作用于属性访问那一层 。如果宿主把一个对象交给子应用,子应用去改这个对象的嵌套字段,代理完全看不见。
实测场景 B2:宿主设置 window.__HOST_BUS__ = { nested: { size: 12 } },子应用执行 window.__HOST_BUS__.nested.size = 99。这句里的 get('__HOST_BUS__') 返回宿主对象(正常),但紧随其后的 .nested.size = 99 是普通对象操作------没有任何陷阱会被触发:
css
[B2] A 深层改写 window.__HOST_BUS__.nested.size → 宿主读到 99(泄漏发生,代理挡不住第二层)
这条决定了微前端的"跨应用共享契约"必须显式设计,而不能靠"把宿主对象挂到 window 上让子应用自己取":
- 方案 A:冻结。
Object.freeze,嵌套对象也要深层冻结。实测 Scene B3:冻结后子应用深层改写直接抛TypeError(严格模式)或静默失败(非严格),宿主值保持12不变。冻结的额外好处是把"悄悄写坏"变成"立刻报错",故障从三天后的脏数据变成了当场的异常堆栈。 - 方案 B:深拷贝后再给。 子应用改自己的副本,通过事件把意图回传给宿主。缺点是同步语义丢失,且深拷贝大对象有成本。
- 方案 C:不给对象,给 API。 宿主只暴露
bus.emit()/bus.subscribe()这类函数,把状态彻底关在自己手里。这是最稳的一种,也是唯一不依赖"子应用配合"的方案。
难点四:拦得住 window,拦不住副作用------这才是"卸载不干净"的真正根源
沙箱解决了变量隔离,但下面这些东西不在 window 对象上,代理一个也拦不住:
| 副作用 | 不清理的后果 |
|---|---|
setTimeout / setInterval |
应用已卸载但定时器还在跑;回调里访问已移除的 DOM → 报错刷屏;闭包持有整个应用模块 → 内存泄漏 |
addEventListener |
重新挂载又绑一次,同一事件触发 N 次;同样是闭包泄漏 |
| 未取消的请求 / WebSocket / SSE | 卸载后回调写入不存在的容器 |
| 全局单例(埋点、i18n、UI 库主题) | 后加载的应用覆盖前一个的配置,且卸载时不知道该还原成什么 |
实测两组数字。
场景 E(重复挂载的监听叠加,这是卸载不干净最典型的症状):
css
[E] 不清理地挂载两次 → resize 监听 2 个(回调执行 2 次);沙箱内挂载两次 → 1 个
场景 C(定时器泄漏 )------沙箱内注册 setTimeout(30ms) 与 setInterval(20ms),随即卸载,等 70ms:
ini
[C] 挂载后账本:定时器 2 个,监听 1 个,宿主总线监听数 1
[C] unmount(): 清定时器 2 个,摘监听 1 个,宿主总线剩余 0
[C] 70ms 后:沙箱内定时器是否已执行 → 已清理,未执行
[C] 对照组(无沙箱)同样的 setTimeout 是否泄漏到宿主 → __leaked=true
最后一行是对照组:同样的代码不经过沙箱、直接打在真全局上,那个 setTimeout 如期执行了。 一个泄漏、一个没泄漏,差别只在有没有那本账。
所以代理沙箱必须配一本副作用账本 :沙箱在 get 陷阱里把 setTimeout / setInterval / addEventListener 等宿主 API 换成"记账版"(先调真宿主,再把返回的句柄记进本次沙箱的账本),卸载时按账本逐条撤销。这正是 qiankun 要 patch 一大串全局 API 的原因,也是"卸载"能不能做到可逆的分水岭。
难点五:HTML Entry 不是资源清单,它是有序的
子应用的入口是"一个 index.html",它天然有序:样式先于脚本、同步脚本严格按文档顺序、内联脚本夹在中间不能挪位。把 HTML 解析成资源列表时有四个必须处理的点:
<link>是 void 元素(没有闭合标签),<script>/<style>是成对元素。 用同一个正则通吃时必须区分这两种情况。我第一版的正则只覆盖了<link></link>和<link/>两种写法,结果页面上两条<link rel="stylesheet">被整条漏掉------解析出 5 个资源而不是 7 个。这类 bug 特别危险:少加载一个 CSS 不会报错,只会"样式有点不对"。- 相对路径必须按子应用自己的 base 重写 ,否则
./css/reset.css会去主应用的目录请求 → 404。实测./css/reset.css→https://static.example.com/app-a/css/reset.css。 - 顺序语义要分档 :
sync阻塞且严格保序;defer/module也保序,但在文档解析完之后按顺序执行;async不保序 ------子应用的入口脚本绝不能用async,否则它可能在依赖之前执行。 type="importmap"之类的脚本是宿主契约,不属于子应用的执行队列,必须剔除(实测已剔除 ✓)。
难点六:加载时序的三笔账------并行不是免费的
同一份资源图,三种编排策略(实测场景 3):
scss
全串行(逐个下载执行) 100ms
全并行(忽略执行顺序) 40ms
真实模型(样式并行 + defer 并行 + sync 串行) 80ms
"全并行 40ms"看着最优,但这笔钱拿不到手 :同步脚本必须逐条下载并执行,这条链上的顺序是语义要求,不是性能选项。真实模型比全并行多花 40ms,那 40ms 买的是"内联脚本能读到子应用运行时"------实测脚本序列是 shared vendor → ./js/runtime.js → inline <script> → ./js/chunk-page.js,entry 脚本确实排在内联脚本之前。
正确做法是分阶段 :样式并行下载(谁先到都行,但都必须在任何脚本执行前就位)→ 可并行的 defer/module 一起下 → 同步脚本逐条串行。80ms 就是这么来的。这也解释了为什么微前端框架都要自己做资源编排,而不是 <script> 插一个算一个。
难点七:依赖共享------同一个 vendor 只应该下载一次
两个子应用各带一份 React,用户就要下载两份、执行两份、内存里存两份,更麻烦的是存在两个 React 实例(context 不互通、hooks 报 invalid hook call)。实测场景 2:
css
[2] A 首次加载发起网络请求 5 次;B 加载中命中宿主缓存 1 项
[2] B 因共享 vendor 省下的下载时间:20ms(该资源 cost 归零,直接复用宿主已实例化的模块)
代价是版本收敛:共享意味着所有子应用被绑在同一个主版本上。这不只是技术问题,而是组织问题------谁来决定什么时候升级 React,谁来负责通知十几个子应用团队?Day49「依赖治理」会专讲这件事。
难点八:路由冲突------两个应用都想当 URL 的主人
主应用和子应用都用 history 路由时,冲突有两种表现:子应用 pushState('/list/9') 把主应用的路由顶掉了;或者主应用路由变化时,子应用误以为"这是在叫我",于是每个应用都重新渲染一遍。
实测的解法是**"主应用持有路由表,子应用只拿到相对 base 的子路由"**(场景 2):
javascript
/app-dash/overview → app=dashboard base=/app-dash subPath=/overview
/app-order/list/42 → app=order base=/app-order subPath=/list/42
/app-order/ → app=order base=/app-order subPath=/
/app-order-detail/x → app=null base=/ subPath=/app-order-detail/x
两点值得注意:匹配必须最长前缀优先 ,否则 /app-order-detail/x 会被 /app-order 前缀误吃(实测归属 app=null,正确);子应用的 push 要补全前缀 ,/list/9 → /app-order/list/9,detail/7 → /app-order/detail/7;子应用收到全局路由变化时要先做归属判断 (/app-order/list/9 → true,/app-dash/x → false),否则每次路由变化都会惊动所有应用。
难点九:切换竞态------用户连点两次菜单会怎样
这是测试环境测不出来、线上偶发的问题。实测(不 await 卸载就挂载):
scss
[3] 不 await 卸载就挂载 → 顺序 mount(B) → unmount(A)(两个应用短暂共存,容器内容错乱)
新应用先挂上了,旧应用才卸载------两个应用在同一个容器里短暂共存 ,DOM 内容错乱、样式互相覆盖。正确做法是 await 旧应用的 unmount() 之后再 mount() 新应用:
scss
[3] 正确串行切换 → begin(order→settle) → unmount(order) → mount(settle) → end(order→settle)
更狠的是连点。用户手快点了两次菜单,两个切换流程并发执行,先发的请求后返回,于是界面停在了一个用户没选的应用上。实测用自增 token 做守卫:
scss
[3] 连点两次菜单(settle 先返回) → start(order) → start(settle) → commit(settle) → abort(order) ← 已有更新的切换请求
[3] 结果:order 提交=false(被丢弃),settle 提交=true
过期的切换请求在提交前发现"已有更新的请求",自己放弃。这个模式(token 守卫)和 Day23 里"过期提升"是同一个思想的两面:并发环境下,唯一可靠的判据是"我还需不需要",而不是"我有没有做完"。
难点十:iframe 的诱惑------最彻底的隔离,和最断头的体验
绕开上面所有难点的方法只有一条:用 iframe 。每个子应用跑在自己的 iframe 里,天然拥有独立的 Realm、独立的 window、独立的 CSS 世界------不需要 Proxy、不需要 with、不需要副作用账本,原生 ESM 也能直接跑。所以"要不要用 iframe"其实是一个成本转嫁的问题:把 JS 隔离的复杂度换成了集成体验的复杂度。
代价很具体:
- 通信只能
postMessage,异步、结构化克隆(函数传不过去)、跨域要双向校验origin; - 路由不同步:iframe 有自己的 history,浏览器前进/后退按钮、地址栏、分享链接全部对不上主应用;
- UI 被裁剪 :
position: fixed的弹窗、下拉菜单、Toast、遮罩只能在 iframe 内部显示,一旦超出 iframe 边界就被裁掉(这是最常见的"必须放弃 iframe"的原因); - 性能和体积:每次切换都要重新加载整个 iframe 文档,无法做应用级预热复用;
- 跨域 Cookie / 登录态:第三方 Cookie 收紧之后,iframe 形态下的鉴权会额外麻烦。
腾讯的 wujie(无界) 正是针对这个权衡设计的:用 iframe 承载 JS(拿到原生隔离),用 Shadow DOM 承载 CSS,然后把 iframe 的 document 代理到主应用的容器上 ------于是子应用以为自己在一个独立 iframe 里跑,实际渲染位置却在主文档中,position: fixed 和弹窗都正常了。代价是这套 document 代理本身又变得很复杂(要转发大量 DOM API),属于"把复杂度换了个地方"。
难点十一:卸载不等于销毁
"卸载"(unmount)只保证 DOM 被移除、账本被清理。不保证内存被释放。 以下三种引用会让整个子应用的模块图一直存活:
- 宿主侧的缓存(为了二次切换快,很多框架会缓存子应用的 bundle 和导出的生命周期函数)------缓存就是强引用,这是"性能优化"和"内存泄漏"的同一枚硬币;
- 子应用注册到宿主全局单例上的回调(埋点、错误上报、i18n 语言变化);
- 上一次 mount 时被捕获在闭包里的 DOM 节点(detached DOM)。
判断标准是:在 DevTools Memory 里做一次 Heap Snapshot,切换应用后再拍一次,看旧的容器元素是否变成了 detached 但仍被引用 。用 performance.memory 或 CDP 的 HeapProfiler 做不到可靠判断,必须看引用链。
二、完整解法
2.1 五个部件
一个能用的微前端运行时只需要五个部件 + 一本账:
scss
主应用路由表
│
▼
Loader(HTML Entry 解析 + 资源编排 + 依赖共享)
│
▼
Sandbox(JS 隔离 + 副作用账本) ──→ Lifecycle(状态机 + 竞态守卫) ←→ Bus(通信)
下面三段代码零依赖、可直接 node xxx.mjs 运行 ,且都带不变量断言(断言失败即 exit 1)。文中所有量化数字都是它们实跑输出的,不是估算。
2.2 沙箱内核:fakeWindow + Proxy + with
js
function createSandbox(name) {
const addedProps = new Map(); // 子应用新建的全局
const modifiedProps = new Map(); // 子应用影子覆盖了哪些宿主属性
const fakeWindow = Object.create(null);
const listeners = new Map();
const timers = new Set();
// 副作用 API 的补丁:宿主调用被记到本沙箱的账本上
const patched = {
setTimeout(fn, ms, ...rest) { const id = globalThis.setTimeout(fn, ms, ...rest); timers.add(id); return id; },
setInterval(fn, ms, ...rest) { const id = globalThis.setInterval(fn, ms, ...rest); timers.add(id); return id; },
clearTimeout(id) { globalThis.clearTimeout(id); timers.delete(id); },
clearInterval(id) { globalThis.clearInterval(id); timers.delete(id); },
addEventListener(type, fn) { listeners.set(fn, [type, fn]); hostBus.on(type, fn); },
removeEventListener(type, fn) { listeners.delete(fn); hostBus.off(type, fn); },
};
const proxy = new Proxy(fakeWindow, {
// 陷阱2:with 会读 Symbol.unscopables,返回真值 = 标识符逃到外层 = 隔离失效
get(target, key) {
if (key === Symbol.unscopables) return undefined;
// 陷阱1:with 体内的 window 也必须走代理,否则 window.x=1 写穿到真全局
if (key === 'window' || key === 'self' || key === 'globalThis' || key === 'top' || key === 'parent') return proxy;
if (key in patched) return patched[key];
if (key in target) return target[key];
if (modifiedProps.has(key)) return modifiedProps.get(key);
const v = globalThis[key];
// 陷阱4:方法脱离宿主调用会 Illegal invocation,必须绑回宿主
return typeof v === 'function' ? v.bind(globalThis) : v;
},
set(target, key, value) {
if (!(key in globalThis)) addedProps.set(key, value);
else if (!modifiedProps.has(key)) modifiedProps.set(key, globalThis[key]);
target[key] = value;
return true;
},
// 陷阱3:必须说"我这儿有",否则未声明变量赋值会漏到真全局
has() { return true; },
deleteProperty(target, key) { addedProps.delete(key); delete target[key]; return true; },
});
// 陷阱5:不能把 'use strict' 写在 with 外层(严格模式下 with 是语法错误),
// 要严格语义只能在 with 体内再套一个严格 IIFE
function run(code, strict = false) {
const body = strict ? `(function(){'use strict';${code}})()` : code;
const factory = new Function('window', 'self', 'globalThis', `with(window){${body}\n}`);
return factory.call(proxy, proxy, proxy, proxy);
}
function unmount() {
// ① 清定时器:不清就是内存泄漏 + 卸载后仍在跑的逻辑
let clearedTimers = 0;
for (const id of timers) { globalThis.clearTimeout(id); globalThis.clearInterval(id); clearedTimers++; }
timers.clear();
// ② 摘事件监听:不清就是重复绑定,第二次 mount 触发两次
let removedListeners = 0;
for (const [fn, [type]] of listeners) { hostBus.off(type, fn); removedListeners++; }
listeners.clear();
// ③ 丢弃影子覆盖与新增的全局(本来就在 fakeWindow 上,随对象一起被 GC)
modifiedProps.clear(); addedProps.clear();
Object.keys(fakeWindow).forEach(k => delete fakeWindow[k]);
return { clearedTimers, removedListeners };
}
return { proxy, run, unmount, box: { addedProps, modifiedProps, listeners, timers } };
}
五个陷阱全部写在注释里,因为它们正是这段代码和"看起来差不多的错误版本"之间的全部差别------差别只有五行,症状是隔离静默失效。
2.3 HTML Entry 解析:区分 void 元素与成对元素
js
const TAG_RE_SRC = '<(script|style|link)\\b([^>]*?)(/?)>';
function parseAttrs(raw = '') {
const attrs = {};
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
let m;
while ((m = re.exec(raw))) attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
return attrs;
}
function parseEntry(html, baseUrl) {
const resources = [], warnings = [], skipped = [];
// 关键:link 是 void 元素(无闭合标签),script/style 是成对元素。
// 同一个正则通吃必须区分"自闭合"与"有正文",否则 link 会被整条漏掉
const re = new RegExp(TAG_RE_SRC, 'gi');
let m;
while ((m = re.exec(html))) {
const tag = m[1].toLowerCase();
const attrs = parseAttrs(m[2]);
const selfClosed = m[3] === '/';
let inline = '';
if (!selfClosed && tag !== 'link') {
const rest = html.slice(re.lastIndex);
const cm = new RegExp(`</${tag}\\s*>`, 'i').exec(rest);
if (cm) { inline = rest.slice(0, cm.index).trim(); re.lastIndex += cm.index + cm[0].length; }
else warnings.push(`<${tag}> 未闭合,按空内容处理`);
}
if (tag === 'link') {
if ((attrs.rel || '').toLowerCase() !== 'stylesheet') { skipped.push(`link[rel=${attrs.rel || '?'}]`); continue; }
resources.push({ kind: 'style', url: resolveUrl(attrs.href, baseUrl), load: attrs.href });
} else if (tag === 'style') {
resources.push({ kind: 'inline-style', code: inline });
} else {
if (attrs.type === 'importmap') { skipped.push('script[type=importmap]'); continue; } // 宿主契约,不进队列
const mode = attrs.type === 'module' ? 'module'
: ('defer' in attrs ? 'defer' : ('async' in attrs ? 'async' : 'sync'));
if (attrs.src) resources.push({ kind: 'script', url: resolveUrl(attrs.src, baseUrl), load: attrs.src, isEntry: 'entry' in attrs, mode });
else if (inline) resources.push({ kind: 'inline-script', code: inline, isEntry: false, mode: 'sync' });
}
}
return { baseUrl, resources, warnings, skipped };
}
// 相对路径必须按子应用自己的 base 重写,否则 ./css/reset.css 会 404 到主应用目录
function resolveUrl(ref, baseUrl) {
const clean = ref.replace(/^\.\//, '');
if (/^(https?:)?\/\//.test(clean) || clean.startsWith('data:')) return clean;
return `${baseUrl.replace(/\/$/, '')}/${clean}`;
}
实测输出(7 个资源,顺序与文档一致):
less
[1] 解析出资源 7 个:
style https://static.example.com/app-a/css/reset.css
style https://cdn.example.com/theme/dark.css
i-style (inline)
script https://static.example.com/app-a/js/vendor.js [sync]
script https://static.example.com/app-a/js/runtime.js ← entry 标记 [sync]
i-script (inline)
script https://static.example.com/app-a/js/chunk-page.js [defer]
[1] 相对路径重写:./css/reset.css → https://static.example.com/app-a/css/reset.css
[1] importmap 不进入子应用执行队列:已剔除 ✓
[1] 内联脚本位置保序:第 6 个资源
2.4 资源编排:分阶段 + 共享依赖去重
编排规则必须与浏览器语义对齐:样式必须先于所有脚本 (脚本会读 getComputedStyle、会依赖布局完成);sync 脚本严格保序且阻塞 ;defer/module 保序但可并行下载 ;async 不保序(微前端入口脚本应禁用) ;内联脚本夹在文档位置里,顺序不能乱。
js
const HOST_SHARED = new Set(['react', 'react-dom', 'vue', 'lodash']); // 宿主已提供的 externals
const hostCache = new Map(); // 宿主级缓存,跨应用复用
// 同一 URL 只下载一次;命中缓存则 latency=0
function fetchResource(url, latency) {
if (hostCache.has(url)) return { url, latency: 0, cached: true };
hostCache.set(url, true);
return { url, latency, cached: false };
}
function buildPlan(parsed, { latencyFor = () => 20 } = {}) {
const styles = parsed.resources.filter(r => r.kind === 'style' || r.kind === 'inline-style');
const scripts = parsed.resources.filter(r => r.kind === 'script' || r.kind === 'inline-script');
const styleStage = { name: 'style', items: [] };
for (const r of styles) {
if (r.kind === 'inline-style') { styleStage.items.push({ desc: 'inline <style>', cost: 0 }); continue; }
const f = fetchResource(r.url, latencyFor(r, 'style'));
styleStage.items.push({ desc: `style ${r.load}`, cost: f.latency, cached: f.cached });
}
const scriptStage = { name: 'script', items: [] };
for (const r of scripts) {
if (r.kind === 'inline-script') { scriptStage.items.push({ desc: 'inline <script>', cost: 0, mode: 'sync' }); continue; }
const pkg = r.load.split('/').pop().replace(/\.(js|mjs)$/, '');
const shared = HOST_SHARED.has(pkg) || /vendor|common|shared/.test(r.load);
if (shared) {
const wouldBe = latencyFor(r, 'script');
const f = fetchResource(`host:${pkg}`, wouldBe);
scriptStage.items.push({ desc: `shared ${pkg}`, cost: f.latency, saved: f.cached ? wouldBe : 0, mode: r.mode, shared: true });
continue;
}
const f = fetchResource(r.url, latencyFor(r, 'script'));
scriptStage.items.push({ desc: `script ${r.load}`, cost: f.latency, cached: f.cached, mode: r.mode, entry: r.isEntry });
}
// 阶段内可并行(耗时=最慢项),阶段间串行;但 sync 脚本必须逐条下载并执行
const syncItems = scriptStage.items.filter(i => i.mode === 'sync');
const asyncable = scriptStage.items.filter(i => i.mode !== 'sync');
const realTime =
(styleStage.items.length ? Math.max(...styleStage.items.map(i => i.cost)) : 0) +
(asyncable.length ? Math.max(...asyncable.map(i => i.cost)) : 0) +
syncItems.reduce((a, i) => a + i.cost, 0);
return { styleStage, scriptStage, realTime };
}
2.5 通信总线:广播 / 定向 / 卸载自动注销
四个设计决定,每一个都对应一类线上事故:
js
function createBus() {
const subs = new Map(); // name -> Set<handler>
const audit = []; // 事件轨迹,用来排查"事件发了没人收"
const disposed = new Set();
return {
on(name, handler) {
if (disposed.has(name)) throw new Error(`应用 ${name} 已卸载,不允许再注册监听`);
if (!subs.has(name)) subs.set(name, new Set());
subs.get(name).add(handler);
return () => this.off(name, handler);
},
off(name, handler) { subs.get(name)?.delete(handler); },
// 决策①:定向投递。避免"广播出去没人管"的隐式耦合
emitTo(target, type, payload) {
const hs = subs.get(target);
if (!hs || hs.size === 0) { audit.push({ type, target, delivered: 0, dropped: true }); return 0; }
for (const h of hs) h(payload);
audit.push({ type, target, delivered: hs.size, dropped: false });
return hs.size;
},
// 决策②:广播是"扇出",任何一个应用抛错都不能影响其他应用
broadcast(type, payload) {
let delivered = 0, failed = 0;
for (const [name, hs] of subs) {
for (const h of hs) {
try { h(payload); delivered++; }
catch (e) { failed++; audit.push({ type, target: name, error: e.message }); }
}
}
audit.push({ type, target: '*', delivered, failed });
return { delivered, failed };
},
// 决策③:卸载即注销,不做这步 = 已卸载应用仍在响应事件
dispose(name) {
const n = subs.get(name)?.size ?? 0;
subs.delete(name); disposed.add(name);
return n;
},
};
}
实测:
less
[1] broadcast('theme-change','dark') → 投递 4 次,异常 0 次;A/B/C 各收到 1/2/1 条
[1] C 的监听抛错时 → 投递 3 次成功、1 次异常,A/B 不受影响(A=2, B=4)
[1] emitTo('A','refresh') → 只有 A 收到,投递 1 次;B 仍是 4 条
[1] emitTo('Ghost', ...) 定向到一个不存在的应用 → 投递 0 次并记入 audit(供排查"事件发了没人收")
[1] A 卸载:注销 1 个监听后再次广播 → A 收到新增 0 条,bus 剩余监听 3 个
[1] 已卸载应用再注册监听 → 应用 A 已卸载,不允许再注册监听
决策④是 dispose 的实现细节:注销必须由框架来做,而不能依赖子应用在自己的 unmount 里记得 off 。子应用团队会漏、会忘、会在异常分支里提前 return。框架级的注销是唯一可靠的保证。
2.6 生命周期状态机 + 竞态守卫
状态机的作用是用非法迁移断言替代"看起来能跑"。没有状态机时,重复 mount、在 LOADING 中 mount、在 MOUNTED 中再次 MOUNTED 全都不会报错,只在某个巧合的时序下把容器搞乱:
js
const TRANSITIONS = {
NOT_LOADED: ['LOADING'],
LOADING: ['LOADED', 'LOAD_ERROR'],
LOAD_ERROR: ['LOADING'],
LOADED: ['MOUNTING'],
MOUNTING: ['MOUNTED'],
MOUNTED: ['UNMOUNTING'],
UNMOUNTING: ['NOT_MOUNTED'],
NOT_MOUNTED: ['MOUNTING', 'UNLOADING'],
UNLOADING: ['NOT_LOADED'],
};
function to(state, next) {
if (!TRANSITIONS[state].includes(next)) throw new Error(`非法状态迁移 ${state} → ${next}`);
return next;
}
css
[3] order 正常生命周期:NOT_LOADED→LOADING LOADING→LOADED LOADED→MOUNTING MOUNTING→MOUNTED
[3] 已 MOUNTED 的应用再次 MOUNTING → 非法状态迁移 order: MOUNTED → MOUNTING
[3] NOT_LOADED 直接跳 MOUNTED → 非法状态迁移 settle: NOT_LOADED → MOUNTED
再加一个自增 token 守卫,处理"用户连点菜单"的竞态:
js
let currentToken = 0;
async function guardedSwitch(target, doSwitch) {
const token = ++currentToken;
const ok = await doSwitch(); // 真实切换:unmount 旧的 → mount 新的
if (token !== currentToken) return false; // 已有更新的切换请求,丢弃本次结果
return ok;
}
2.7 方案选型
| 方案 | JS 隔离 | CSS 隔离 | 多实例 | 主要代价 |
|---|---|---|---|---|
| qiankun | Proxy 沙箱(with + Proxy,legacy 用快照) |
scoped 改写 或 Shadow DOM(strict 模式) | Proxy 沙箱支持 | 子应用须 bundle 成非严格脚本;共享依赖版本收敛 |
| micro-app | 自定义元素 + with/Proxy 沙箱 |
Shadow DOM / scoped | 支持 | 依赖 CustomElement 与元素隔离的补丁 |
| wujie | iframe(原生隔离) | Shadow DOM + document 代理 | 支持 | document 代理层复杂;iframe 通信/调试成本 |
| Module Federation | 不做隔离(同一 Realm) | 不做隔离 | --- | 它是组合+依赖共享方案,不是沙箱;通常与上面之一配合 |
| 原生 iframe | 原生彻底 | 原生彻底 | 支持 | 路由不同步、弹窗被裁剪、无法预热复用、每次重载 |
选型的判据按优先级排:子应用能否被要求改造成非严格 bundle (不能 → 直接排除 with 系沙箱,走 iframe/wujie)→ 有没有 position: fixed 弹窗需求 (有 → 排除原生 iframe)→ 是否需要多实例同屏 (需要 → 排除快照沙箱)→ 是否已有 webpack 5 构建(有 → Module Federation 做依赖共享很划算)。
2.8 把"卸载干净"变成 CI 断言
线上最难查的微前端 bug 都是"状态残留",而它们全都能被自动化断言捕获。三个必测断言:
js
// ① 全局零污染:挂载前记录宿主 key 集合,卸载后差集必须为空
const before = new Set(Object.keys(globalThis));
await mountApp('order');
await unmountApp('order');
const leaked = Object.keys(globalThis).filter(k => !before.has(k));
assert.deepEqual(leaked, [], `卸载后残留全局变量: ${leaked.join(',')}`);
// ② 副作用归零:定时器与监听账本必须清空
assert.equal(sandbox.timers.size, 0);
assert.equal(sandbox.listeners.size, 0);
// ③ 可逆性:mount → unmount → mount,宿主元素快照必须与首次 mount 完全一致
const snap1 = container.innerHTML;
await unmountApp('order'); await mountApp('order');
assert.equal(container.innerHTML, snap1, '二次挂载结果与首次不一致 = 存在状态残留');
第 ③ 条最有效,因为它是一个闭环断言 :任何残留(多绑一次的监听、没清的 interval、没还原的全局配置)都会让第二次挂载的结果和第一次不一样。Day23 讲过"把性能变成会失败的断言",这里是同一个思路用在正确性上------能自动失败的断言,比能自动通过的测试值钱得多。
三、应用场景
- 中后台的"一个壳 + 多个业务域" :最经典的微前端场景。十几个业务域由不同团队维护、按不同节奏发版,主应用只负责导航、登录态、权限和布局。这种场景下优先考虑 Proxy 沙箱 + 依赖共享(React/Vue 收敛到同一版本),并强制要求子应用 bundle 成非严格产物------把这条写进脚手架和 CI 检查,而不是等白屏了再排查。
- 存量系统渐进式重构 :老 AngularJS 页面边上挂新 React 页面,靠菜单路由逐步替换。这里的关键词是共存周期可能长达两年,所以路由归属判断和全局配置(主题、i18n、埋点)的还原逻辑必须一次做对------否则两年里会出现无数个"切过去再切回来就变了样"的诡异 bug。
- 多租户 / 白标定制 :同一套主应用,不同客户挂不同的定制化子应用。这里共享对象一定要冻结或只给 API,因为第三方团队不会读你的文档(难点三)。
- 需要绝对隔离的第三方插件市场 :用户上传的插件代码必须与主应用隔离。别用 Proxy 沙箱 ------它不是安全边界(逃逸手段太多,
with沙箱连原生 ESM 都装不了)。这种场景只有 iframe + CSP + 独立域名一条路,成本高但这是安全问题,不是体验问题。 - 要不要用微前端的判据 :如果所有子应用可以用同一个仓库、同一个构建、同一次发布解决,那就用 Monorepo(Day36),不要用微前端。微前端解决的是"发布解耦"和"团队自治"这两个组织问题,它引入的运行时复杂度只有在组织收益面前才划算。
四、总结
微前端实现的本质,是用运行时的手段,在共享的全局环境里重建浏览器原本免费提供的隔离,并且接受这种重建必然是不完整的。
完整的实现链条是四层:隔离(Proxy 沙箱:拦 window 的读写)→ 记账(副作用账本:定时器、监听、全局配置,卸载时逐条撤销)→ 编排(HTML Entry 解析 + 分阶段资源加载 + 依赖共享 + 最长前缀路由 + 状态机)→ 守卫(竞态 token、非法迁移断言、CI 卸载断言) 。前置条件是子应用必须能被处理成非严格、可字符串化的 bundle ------这条不满足,整个 with 系方案从第一步就不成立。
五个最值得记住的判据:
- 隔离是否生效,看宿主全局的 key 集合,不看子应用读到了什么。 子应用读到正确值可能只是因为"另一个应用恰好也写了同样的值"。
Symbol.unscopables返回真值 = 隔离完全失效。 一个返回值之差,实测从"被沙箱接住"变成"读到宿主的HOST_VALUE"。- 代理只挡第一层,跨应用共享对象必须冻结或换成 API。 实测
window.__HOST_BUS__.nested.size = 99直接改到宿主。 'use strict'与with互斥 (实测SyntaxError)。这条决定了"用原生 ESM 构建的子应用能不能进with沙箱"------答案是不能,必须换方案。- "卸载干净"要用闭环断言验证 :
mount → unmount → mount的结果必须与首次挂载逐字节一致。任何残留都会让这个断言失败。
架构上收敛成一句话:沙箱决定"变量会不会串",账本决定"卸载是否可逆",编排决定"切换有多快",而这三者都建立在"子应用的构建产物形态"这个前置约束之上。 前置约束不满足(原生 ESM、async 入口、依赖未收敛),后面三层做得再好也白费------这跟 Day23 里"结构错了,粒度和调度都是给错误买单"是同一个道理。
下一篇 Day25 进入渲染管线的性能优化 阶段,从浏览器内核视角看:一次样式变更到底要走完多少步、哪些步骤能跳过、以及为什么"改 class 比改 style 便宜"这件事有一半是错的。
运行环境
Node.js ≥ 16(三段脚本零依赖、可直接运行,均带不变量断言,断言失败即 exit 1):
sandbox-demo.mjs:沙箱内核 + 五个陷阱复现 + 副作用账本 + 泄漏对照组html-entry-loader.mjs:HTML Entry 解析 + 资源编排 + 依赖共享 + 预加载时序(确定性延迟模型,无真实计时器)bus-router-demo.mjs:通信总线 + 路由匹配/push 前缀补全 + 生命周期状态机 + 竞态守卫
浏览器侧示例需 ES2020+(Proxy、with、Symbol.unscopables、可选链;??=/?. 用于沙箱与编排代码)。