微前端实现

一、技术难点:微前端的难度,全部来自"几个应用共享同一个 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 解析成资源列表时有四个必须处理的点:

  1. <link> 是 void 元素(没有闭合标签),<script>/<style> 是成对元素。 用同一个正则通吃时必须区分这两种情况。我第一版的正则只覆盖了 <link></link> 和 <link/> 两种写法,结果页面上两条 <link rel="stylesheet"> 被整条漏掉------解析出 5 个资源而不是 7 个。这类 bug 特别危险:少加载一个 CSS 不会报错,只会"样式有点不对"。
  2. 相对路径必须按子应用自己的 base 重写 ,否则 ./css/reset.css 会去主应用的目录请求 → 404。实测 ./css/reset.css → https://static.example.com/app-a/css/reset.css。
  3. 顺序语义要分档 :sync 阻塞且严格保序;defer/module 也保序,但在文档解析完之后按顺序执行;async 不保序 ------子应用的入口脚本绝不能用 async,否则它可能在依赖之前执行。
  4. 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 被移除、账本被清理。不保证内存被释放。 以下三种引用会让整个子应用的模块图一直存活:

  1. 宿主侧的缓存(为了二次切换快,很多框架会缓存子应用的 bundle 和导出的生命周期函数)------缓存就是强引用,这是"性能优化"和"内存泄漏"的同一枚硬币;
  2. 子应用注册到宿主全局单例上的回调(埋点、错误上报、i18n 语言变化);
  3. 上一次 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 系方案从第一步就不成立。

五个最值得记住的判据:

  1. 隔离是否生效,看宿主全局的 key 集合,不看子应用读到了什么。 子应用读到正确值可能只是因为"另一个应用恰好也写了同样的值"。
  2. Symbol.unscopables 返回真值 = 隔离完全失效。 一个返回值之差,实测从"被沙箱接住"变成"读到宿主的 HOST_VALUE"。
  3. 代理只挡第一层,跨应用共享对象必须冻结或换成 API。 实测 window.__HOST_BUS__.nested.size = 99 直接改到宿主。
  4. 'use strict' 与 with 互斥 (实测 SyntaxError)。这条决定了"用原生 ESM 构建的子应用能不能进 with 沙箱"------答案是不能,必须换方案。
  5. "卸载干净"要用闭环断言验证 :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、可选链;??=/?. 用于沙箱与编排代码)。

相关推荐
Chrischaan1 小时前
做 AI 塔罗小程序时,我为什么不让大模型自己抽牌:一次上线复盘
前端
骑着蜗牛撵大象3272 小时前
LangChain 工具调用从声明到闭环:给智能体接上真实的手脚
前端
数据掘金2 小时前
百度统计后台页面数据异常?从手动调用时机开始排查
前端
hunteritself2 小时前
10 层 4K 实时剪辑,国产工作站进入广电制作核心
大数据·前端·人工智能·深度学习·transformer
踩着两条虫2 小时前
我在浏览器里“盖”了一个村:Vue + Three.js 大屏开发踩坑记
前端·vue.js·three.js
mbshqqb2 小时前
TypeScript基础教程之同步生成器
前端·javascript·typescript
嘟嘟同学和妮妮同学3 小时前
【升级】一个中国历史可视化站:皇帝时间线梳理、古代思想流派、诗词鉴赏 (2)
前端·html
暗夜行者之光3 小时前
LangGraph + MCP 实战:构建"自然语言查数据"的 ChatBI Agent
前端·后端
一tiao咸鱼3 小时前
还在手动海投?我做了个 AI 求职助手,自动筛选 BOSS 职位再精准投递
前端·后端·面试