微前端源码精讲

第13章 iframe 的复兴:Wujie 与新一代方案

作者 杨艺韬 · 11,603 字 · 发布于 · 更新于

第13章 iframe 的复兴:Wujie 与新一代方案

被判了死刑的技术,往往只是在等待一个正确的使用方式。

本章要点

  • 理解 iframe 方案"死而复生"的技术背景与根本原因
  • 深入 Wujie 的三层架构:WebComponent(渲染层)+ iframe(JS 执行层)+ Proxy(桥接层)
  • 掌握 iframe 通信的现代方案:从 postMessage 到 MessageChannel、BroadcastChannel 的进化
  • 理解 Wujie 的桥接机制:document、location 的 Proxy 代理与 history 的补丁式拦截
  • 学会 iframe 场景下的性能优化:预加载、资源共享与降级策略

本章无界(Wujie)源码以 v2.1.0 为准(packages/wujie-core/src/),乾坤相关片段以 v2.10.16 为准;引用时会标注文件路径,其他版本的行为可能有差异。


2019 年,乾坤横空出世,用 Proxy 沙箱取代 iframe 成为微前端的主流隔离方案。那时候,iframe 几乎被整个社区判了"死刑"——性能差、体验割裂、路由无法同步、弹窗不能居中。每一篇微前端选型文章都会在 iframe 方案旁边画一个大大的叉号。

然而,2022 年腾讯开源了 Wujie。它做了一件所有人都觉得不可能的事情:用 iframe 实现了比 Proxy 沙箱更彻底的 JS 隔离,同时解决了 iframe 大部分传统痛点。

Wujie 的设计哲学是:把 iframe 当作一个隐藏的 JS 执行沙箱,而不是一个可见的渲染容器。子应用的 DOM 不渲染在 iframe 内部,而是通过 Web Components 投射到主应用的真实文档流中。这个看似简单的思路转换,彻底改变了 iframe 的工程价值。

本章将从源码层面深入剖析 Wujie 的架构设计。你会看到它如何将浏览器原生的 iframe 隔离能力、Web Components 的渲染能力和 Proxy 的劫持能力三者融合,打造出一个兼顾隔离性与用户体验的微前端方案。

13.1 为什么 iframe 又回来了

下图对比了 Proxy 沙箱和 iframe 沙箱在隔离能力上的根本差异:

flowchart LR
    subgraph ProxySandbox["Proxy 沙箱 (乾坤)"]
        direction TB
        PS_Win["真实 window"] --> PS_Proxy["Proxy 代理层"]
        PS_Proxy --> PS_Fake["fakeWindow"]
        PS_Note["模拟隔离:\n读时共享,写时隔离\neval/第三方SDK 可逃逸"]
    end
    subgraph IframeSandbox["iframe 沙箱 (Wujie)"]
        direction TB
        IS_Main["主应用 window"] ~~~ IS_Iframe["iframe.contentWindow"]
        IS_Note["原生隔离:\n独立的全局对象与原型链\n(同域下仍可经 window.parent 触达主应用)"]
    end
    ProxySandbox -->|"逃逸风险"| Escape["eval() 逃逸\n第三方 SDK 逃逸\nWorker 逃逸"]
    IframeSandbox -->|"天然隔离"| Safe["独立 realm (V8 Context)\n独立全局对象\n独立原型链"]
    style ProxySandbox fill:#fff3e0,stroke:#e65100
    style IframeSandbox fill:#e8f5e9,stroke:#2e7d32
    style Escape fill:#ffebee,stroke:#c62828
    style Safe fill:#e8f5e9,stroke:#2e7d32

13.1.1 Proxy 沙箱的天花板

第 4 章我们深入分析了乾坤的 Proxy 沙箱机制。它聪明、优雅,但有一个无法回避的根本性限制:Proxy 只能拦截通过代理对象访问的属性,无法拦截对原始 window 对象的直接访问。

// 乾坤 Proxy 沙箱的核心逻辑(简化版)
function createProxySandbox(appName: string) {
  const fakeWindow = Object.create(null);
  const proxy = new Proxy(fakeWindow, {
    get(target, prop) {
      if (prop in target) return target[prop];
      const value = (window as any)[prop];
      return typeof value === 'function' ? value.bind(window) : value;
    },
    set(target, prop, value) {
      target[prop] = value;
      return true;
    },
  });
  return proxy;
}

// 问题场景一:间接 eval / new Function 在全局作用域执行,直接访问真实 window
(0, eval)('window.globalVar = 123'); // 沙箱无法拦截

// 问题场景二:第三方库内部直接写 window.xxx = yyy
// 沙箱只能通过改写 script 的执行上下文来"尽力"拦截

// 问题场景三:with + Proxy 的 Symbol.unscopables 逃逸
// 列在 unscopables 里的名字会被 with 跳过、直接落到真实全局作用域(乾坤出于性能对 Array、Promise 等内置对象这样处理,见 §4.4.3)
// 子应用执行 Array.prototype.myHelper = ... 这类改写,改的是主应用和所有子应用共用的那一份

这些并非理论上的边界情况。在实际生产环境中,大量第三方库(地图 SDK、富文本编辑器、监控 SDK)都会直接操作 window 对象。乾坤为此做了大量的补丁和兼容处理,但本质上是一场无尽的打地鼠游戏。

// Proxy 沙箱在实践中遇到的典型问题清单
interface ProxySandboxIssues {
  // 逃逸问题
  evalEscape: '通过 eval/new Function 执行的代码可能逃逸沙箱';
  scriptTagEscape: '动态创建的 script 标签若不劫持 appendChild 就会在全局作用域执行(乾坤为此专门劫持了它)';
  iframeEscape: '子应用如果自己创建 iframe,其中的代码完全不受沙箱管控';

  // 兼容问题
  thirdPartySDK: '高德地图、百度统计等 SDK 直接操作 window';
  webWorkerContext: 'Worker 线程不受主线程 Proxy 沙箱影响';
  cssVarLeak: 'CSS 变量通过 :root 设置,影响全局文档';

  // 性能问题
  frequentAccess: '高频属性访问(如动画帧中的 requestAnimationFrame)经过 Proxy 有可测量的开销';
  memoryLeak: '沙箱卸载时如果清理不彻底,闭包引用导致内存泄漏';
}

13.1.2 iframe 的天然优势被重新审视

每个 iframe 都有独立的全局对象与 realm(V8 里的 Context)、独立的 document tree 和一整套内置对象原型链;跨站点的 iframe 在 Chrome 的站点隔离下还会被放进独立的渲染进程。不过要先说清一点:Wujie 用的是与主应用同源的 iframe,它和主应用共享同一个进程、同一个事件循环、同一个 JS 堆——隔离的是作用域与原型链,不是进程与安全边界。

与 Proxy 沙箱的"模拟隔离"相比,iframe 提供的是浏览器级别的原生隔离:

const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const iframeWindow = iframe.contentWindow!;

// 完全独立的全局对象
console.log(iframeWindow.window === window);   // false
console.log(iframeWindow.document === document); // false

// 完全独立的 JS 执行上下文
iframeWindow.eval('var x = 1');
console.log(typeof x); // "undefined" —— 主应用完全不受影响

// 完全独立的原型链
console.log(iframeWindow.Array === Array);   // false
console.log(iframeWindow.Object === Object); // false

iframe 的隔离不是"尽力而为",而是"铜墙铁壁"。这是引擎层面的 realm 隔离——同一个浏览器进程中,不同的 iframe 拥有完全独立的 JavaScript 执行环境、独立的全局对象。无论子应用的代码怎样往自己的 window 上写,都不会波及主应用的全局对象。但要补一句:Wujie 的 iframe 与主应用同源,子应用通过 window.parent 仍然能直接触达主应用(无界文档甚至把它列为一种官方通信方式)——所以它挡住的是"无意的污染",不是"恶意的攻击";真正需要安全边界的第三方内容,仍然要用跨域 iframe 加 sandbox 属性。

13.1.3 传统 iframe 的四大缺陷与 Wujie 的破局

既然 iframe 隔离这么好,为什么之前被抛弃了?因为传统用法下的四个致命缺陷:路由状态丢失(刷新后 iframe src 回到初始值)、弹窗无法居中(Modal 只能在 iframe 可视区域内定位)、性能开销(每个 iframe 都要初始化一整套全局对象,内存与启动开销可观)、通信原始(只能通过 postMessage 传递可序列化数据)。

Wujie 的核心洞察可以用一句话概括:iframe 的问题不在于隔离——而在于渲染。把 iframe 的渲染职责剥离出来,只保留它的隔离能力,弹窗、路由这些体验问题就有了解法。

// 传统 iframe:既负责 JS 隔离,也负责 DOM 渲染
// ┌─────────────────────────┐
// │  主应用                   │
// │  ┌───────────────────┐  │
// │  │  iframe            │  │  ← 渲染被困在 iframe 内部
// │  │  子应用 DOM + JS    │  │
// │  └───────────────────┘  │
// └─────────────────────────┘

// Wujie:iframe 只负责 JS 隔离,DOM 通过 WebComponent 渲染
// ┌─────────────────────────┐
// │  主应用                   │
// │  ┌───────────────────┐  │
// │  │  WebComponent      │  │  ← 子应用 DOM 在这里渲染
// │  │  (Shadow DOM)      │  │
// │  └───────────────────┘  │
// │  [hidden iframe]        │  ← JS 在这里执行(用户看不到)
// └─────────────────────────┘

🔥 深度洞察:技术的"第二次机会"

iframe 的回归是技术演进中一个有趣的现象:被宣判死亡的技术,往往不是技术本身有问题,而是当时的使用方式有问题。 jQuery 的核心思想至今影响着所有 DOM 操作库。XML 在配置文件领域依然繁荣。iframe 也是如此——当我们换一种方式使用它,只取隔离之长、避渲染之短,它就从"最差方案"变成了一个有竞争力的方案。这提醒我们:在评估一项技术时,要区分"技术的固有属性"和"使用方式的局限性"。

13.2 Wujie 的架构:WebComponent + iframe + Proxy

13.2.1 架构总览

下图展示了 Wujie 三层架构的数据流和协作方式:

flowchart TB
    subgraph MainApp["主应用文档流"]
        WC["WebComponent 容器\n(Shadow DOM)"]
        WC --> ShadowDOM["子应用 DOM\n在此渲染,弹窗正常居中"]
    end
    subgraph HiddenIframe["隐藏 iframe (display:none)"]
        JSEnv["独立 JS 执行上下文\n浏览器级隔离"]
        JSEnv --> SubAppJS["子应用 JS 代码\n在此执行"]
    end
    subgraph ProxyBridge["Proxy 桥接层"]
        DocProxy["document Proxy\n重定向到 Shadow DOM"]
        LocProxy["location Proxy\n同步主应用 URL"]
        HisProxy["history 补丁\n(pushState/replaceState)"]
    end
    SubAppJS -->|"document.querySelector('#app')"| DocProxy
    DocProxy -->|"重定向到 Shadow DOM"| ShadowDOM
    SubAppJS -->|"location.href"| LocProxy
    LocProxy -->|"读取主应用 URL"| MainApp
    SubAppJS -->|"history.pushState()"| HisProxy
    HisProxy -->|"同步到主应用路由"| MainApp
    style MainApp fill:#e3f2fd,stroke:#1565c0
    style HiddenIframe fill:#fff3e0,stroke:#e65100
    style ProxyBridge fill:#e8f5e9,stroke:#2e7d32

Wujie 的架构由三个核心层组成,每一层都有明确的职责:

interface WujieArchitecture {
  // 第一层:渲染层 —— Web Component + Shadow DOM
  renderLayer: {
    role: '承载子应用的 DOM 渲染';
    technology: 'Custom Element + Shadow DOM';
    benefit: '子应用 DOM 在主应用文档流中,弹窗/滚动行为正常';
  };
  // 第二层:执行层 —— 隐藏 iframe
  executionLayer: {
    role: '子应用 JS 代码的执行沙箱';
    technology: '隐藏的 iframe(srcdoc 空白文档,与主应用同源)';
    benefit: '浏览器级别的 realm 隔离';
  };
  // 第三层:桥接层 —— Proxy 劫持
  bridgeLayer: {
    role: '将 iframe 中的 JS 操作桥接到主应用的 DOM';
    technology: 'Proxy 代理 iframe 的 window/document/location,补丁 history';
    benefit: 'JS 在 iframe 中执行,但操作的是 Shadow DOM 里的 DOM 节点';
  };
}

下图用时序图展示了三层协作的核心数据流,从子应用发起 DOM 操作到用户最终看到渲染结果:

sequenceDiagram
    participant SubJS as 子应用 JS (iframe)
    participant Proxy as Proxy 桥接层
    participant Shadow as Shadow DOM (WebComponent)
    participant Browser as 浏览器渲染引擎
    SubJS->>Proxy: document.querySelector('#app')
    Proxy->>Shadow: 重定向到 shadowRoot.querySelector('#app')
    Shadow-->>Proxy: 返回 Shadow DOM 中的元素
    Proxy-->>SubJS: 返回 DOM 元素引用
    SubJS->>Proxy: element.innerHTML = '<div>内容</div>'
    Proxy->>Shadow: 操作 Shadow DOM 中的真实 DOM
    Shadow->>Browser: DOM 变更触发渲染
    Browser->>Browser: 用户在主应用页面看到子应用内容
    Note over SubJS,Browser: 子应用以为自己在操作独立的 document,实际操作的是 Shadow DOM

三层协作的核心数据流:子应用 JS 在 iframe 中调用 document.querySelector('#app') → Proxy 拦截 document 访问并重定向到 Shadow DOM → Shadow DOM 中的真实 DOM 被操作 → 浏览器渲染 → 用户在主应用页面中看到子应用内容。

13.2.2 隐藏 iframe 的创建

Wujie 创建 iframe 时有几个看似不起眼的选择:用 srcdoc 塞进一个空白文档,再把它的 URL 改写成主应用的地址(而不是直接 src="about:blank");用 display:none 而不是 visibility:hidden;不放进 shadow DOM,而是直接放进主文档 body。每一个都对应一个具体的工程问题:srcdoc 文档的 origin 按规范继承自嵌入它的页面,所以主应用能访问 contentWindow.document,又不会真的去请求主应用的 HTML(1.0.22 及以前 iframe 的 src 直接指向主应用域名,再抢在加载完成前调用 window.stop(),会多一次请求并可能误跑主应用代码,见 issue #54;1.0.23 起默认改用 Blob URL 空白页,2.0.0 才统一换成 srcdoc);display:none 把 iframe 完全移出渲染树(visibility:hidden 仍占位置);放进主文档,让 iframe 独立于 shadow DOM 的生命周期(避免 shadow 销毁时误毁 iframe)。

Wujie 创建 iframe 的方式非常讲究:

// Wujie 源码:创建隐藏的 iframe 沙箱(精简自 packages/wujie-core/src/iframe.ts)
const SANDBOX_EMPTY_SRCDOC = "<!DOCTYPE html><html><head></head><body></body></html>";

export function iframeGenerator(
  sandbox: WuJie,
  attrs: { [key: string]: any },
  mainHostPath: string,   // 主应用 protocol//host
  appHostPath: string,    // 子应用 protocol//host
  appRoutePath: string    // 子应用 pathname + search + hash
): HTMLIFrameElement {
  // 用户传入的 attrs.src 不再作为初始 src,只在 srcdoc 方案失败时兜底
  const { src: userFallbackSrc, ...restAttrs } = attrs || {};
  const iframe = window.document.createElement("iframe");
  setAttrsToElement(iframe, {
    style: "display: none",
    ...restAttrs,
    name: sandbox.id,
    [WUJIE_DATA_FLAG]: "",          // data-wujie-Flag
    srcdoc: SANDBOX_EMPTY_SRCDOC,   // 空白文档:origin 继承自主应用,且不发网络请求
  });
  window.document.body.appendChild(iframe);   // 挂在主文档 body 上,不进 shadow DOM

  const iframeWindow = iframe.contentWindow;
  patchIframeVariable(iframeWindow, sandbox, appHostPath);   // 注入 __WUJIE / $wujie / __WUJIE_PUBLIC_PATH__
  sandbox.iframeReady = stopIframeLoading(iframe, { fallbackSrc: userFallbackSrc || mainHostPath }).then(() => {
    initIframeDom(iframeWindow, sandbox, mainHostPath, appHostPath);   // 打上 history/window/document/Node 等全部补丁
    if (!isMatchSyncQueryById(iframeWindow.__WUJIE.id)) {
      // 把 iframe 的地址改写成「主应用域名 + 子应用路由」
      iframeWindow.history.replaceState(null, "", mainHostPath + appRoutePath);
    }
  });
  return iframe;
}

两个关键设计决策:第一,iframe 是 display:none 的——它永远不可见,唯一作用是提供独立的 JS 执行上下文。第二,iframe 与主应用同源——srcdoc 文档的 origin 按规范继承自嵌入它的页面,stopIframeLoading 等 iframe 的 load 事件到来后再调一次 document.open()/close(),把 iframe 的 URL 从 about:srcdoc 改写成主应用的 URL,随后 replaceState 到 mainHostPath + appRoutePath——这样 location.origin、history 都和主应用一致。如果 iframe 跨域,主应用将无法访问 contentWindow.document,整个 Proxy 桥接方案就崩塌了。

// 同域 vs 跨域的区别
iframe.src = '/empty.html'; // 同域
iframe.contentWindow.document; // ✅ 可以访问,可以注入代码

iframe.src = 'https://other-domain.com'; // 跨域
iframe.contentWindow.document; // ❌ SecurityError(contentWindow 本身拿得到,但内部一律不可访问)

13.2.3 Web Component 渲染容器

Wujie 使用 Custom Element 和 Shadow DOM 作为子应用的渲染容器:

// Wujie 源码:Web Component 的定义(精简自 packages/wujie-core/src/shadow.ts)
export function defineWujieWebComponent() {
  const customElements = window.customElements;
  if (customElements && !customElements?.get("wujie-app")) {
    class WujieApp extends HTMLElement {
      connectedCallback(): void {
        if (this.shadowRoot) return;
        // open 模式允许外部通过 element.shadowRoot 访问,调试友好
        const shadowRoot = this.attachShadow({ mode: "open" });
        const sandbox = getWujieById(this.getAttribute(WUJIE_APP_ID));   // data-wujie-id
        patchElementEffect(shadowRoot, sandbox.iframe.contentWindow);
        sandbox.shadowRoot = shadowRoot;
      }

      disconnectedCallback(): void {
        // 元素被移出 DOM:保活/单例模式只 unmount,重建模式直接 destroy
        handleWujieAppDisconnect(getWujieById(this.getAttribute(WUJIE_APP_ID)));
      }
    }
    customElements?.define("wujie-app", WujieApp);
  }
}

// 最终的 DOM 结构(renderTemplateToShadowRoot 把整段 <html> 模板塞进 shadowRoot)
// <wujie-app data-wujie-id="order-app" class="wujie_iframe">
//   #shadow-root (open)
//     <html>
//       <div style="position: fixed; ... visibility: hidden"></div>  ← 遮罩占位层
//       <head><style>...子应用的样式(外链 CSS 已被内联成 <style>)...</style></head>
//       <body>...子应用的 DOM...</body>
//     </html>
// </wujie-app>

Shadow DOM 提供 CSS 隔离,与 iframe 的 JS 隔离形成互补。同时 Shadow DOM 在主应用文档流中,子应用的弹窗可以用 position: fixed 相对于浏览器视口定位,滚动行为也与主应用一致。有两个 Shadow DOM 的老问题 Wujie 专门打了补丁(packages/wujie-core/src/sandbox.ts:619 的 patchCssRules)::root 选择器在 shadow 里不生效、会把含 :root 的规则复制一份改写成 :host;@font-face 在 shadow 里不生效、会被复制一份提升到最外层 document.head。

13.2.4 Proxy 桥接:让 JS 和 DOM "跨界"协作

Proxy 在 Wujie 里扮演的角色和在乾坤里不同:乾坤用 Proxy 做"隔离"(拦截对 window 的修改,保护主应用不被污染);Wujie 用 Proxy 做"桥接"(让 iframe 中的 document 操作重定向到 Shadow DOM)。

Wujie 最精妙的设计在桥接层。子应用的 JS 在 iframe 中执行,但需要操作 Shadow DOM 中的 DOM 节点。Proxy 正是这座桥梁:

// Wujie 源码:document 代理(精简自 packages/wujie-core/src/proxy.ts 的 proxyGenerator)
const { proxy: proxyDocument, revoke: revokeDocument } = Proxy.revocable({}, {
  get: function (_fakeDocument, propKey) {
    const document = window.document;
    const { shadowRoot, proxyLocation } = iframe.contentWindow.__WUJIE;
    // iframe 初始化完成但 webcomponent 还没挂上去时跑到了主应用代码,必须中止
    if (!shadowRoot) stopMainAppRun();
    // createElement / createTextNode:仍在 iframe 自己的 document 上创建,再给元素打补丁
    if (propKey === "createElement" || propKey === "createTextNode") { /* ... patchElementEffect(element) */ }
    if (propKey === "documentURI" || propKey === "URL") return (proxyLocation as Location).href;
    // 查询类 API:优先查 shadowRoot,查不到再回退到 iframe 自己的 document
    if (propKey === "getElementById") {
      return new Proxy(shadowRoot.querySelector, {
        apply(target, ctx, args) {
          return (
            target.call(shadowRoot, `[id="${args[0]}"]`) ||
            iframe.contentWindow.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__.call(iframe.contentWindow.document, `#${args[0]}`)
          );
        },
      });
    }
    if (propKey === "querySelector" || propKey === "querySelectorAll") { /* 同上:shadowRoot 优先 */ }
    if (propKey === "documentElement" || propKey === "scrollingElement") return shadowRoot.firstElementChild;
    const { ownerProperties, shadowProperties, shadowMethods, documentProperties, documentMethods } = documentProxyProperties;
    // head / body / activeElement / children 等:从 shadowRoot 取
    if (ownerProperties.concat(shadowProperties).includes(propKey.toString())) return shadowRoot[propKey];
    if (shadowMethods.includes(propKey.toString())) return getTargetValue(shadowRoot, propKey) ?? getTargetValue(document, propKey);
    // readyState / visibilityState / execCommand 等:从主应用 document 取
    if (documentProperties.includes(propKey.toString())) return document[propKey];
    if (documentMethods.includes(propKey.toString())) return getTargetValue(document, propKey);
  },
});

这个 proxyDocument 并不直接替换 iframe.contentWindow.document——packages/wujie-core/src/iframe.ts:508 的 patchDocumentEffect 会把 iframe 内 Document.prototype 上这些属性重定义成 getter(get: () => sandbox.proxyDocument[propKey]),head/body 则直接定义在 iframe 的 document 实例上。哪些属性走 shadowRoot、哪些走主应用 document,全部列在 packages/wujie-core/src/common.ts 的 documentProxyProperties 表里。效果是:子应用执行 document.getElementById('app') 时,实际查的是 Shadow DOM 中的 #app 元素(查不到才回退到 iframe 自己的 document)。子应用对此完全无感——它认为自己在操作一个正常的 document,但所有 DOM 操作都被透明地重定向到了主应用文档流中的 Shadow DOM。

// 桥接效果的完整示意
// 子应用代码(在 iframe 中执行):

// 1. DOM 查询 —— 查的是 Shadow DOM
const app = document.getElementById('app');

// 2. DOM 修改 —— 改的是 Shadow DOM 中的节点
app.innerHTML = '<h1>Hello from Sub App</h1>';

// 3. DOM 追加 —— 追加到 Shadow DOM 的 body
const newDiv = document.createElement('div');
newDiv.textContent = '动态创建的元素';
document.body.appendChild(newDiv);

// 4. 事件绑定 —— 绑定在 Shadow DOM 中的元素上
document.querySelector('.btn')?.addEventListener('click', () => {
  console.log('按钮被点击');
});

// 用户看到的效果:
// 所有内容出现在主应用页面中,而不是隐藏的 iframe 里
// 子应用的代码完全不知道自己被"偷梁换柱"了

13.2.5 location 与 history 的劫持

路由劫持要解决一个看似矛盾的需求:让子应用以为自己在访问 https://order.example.com/list,而实际上它的代码跑在主应用域的空白页里。子应用的代码会用 location.host 拼接口地址、用 history.pushState 做导航——如果这些 API 返回的是 iframe 的真实地址(主应用域名下、但路径已经同步成子应用路径),子应用凡是依赖域名的逻辑就会出错。Wujie 的做法是:用 Proxy 代理 iframe 的 location,让它返回"子应用本应该在的地址";再给 history.pushState/replaceState 打补丁,把子应用路径同步到主应用 URL 上。

路由同步是传统 iframe 方案的最大痛点。Wujie 的思路是:iframe 的路径始终与子应用路径保持一致(只有域名是主应用的)、location 代理只负责把域名"换回来":

// Wujie 源码:location 代理(精简自 packages/wujie-core/src/proxy.ts 的 proxyGenerator)
// urlElement 是用子应用 url 生成的 <a> 元素,mainHostPath / appHostPath 分别是主、子应用的 protocol//host
const { proxy: proxyLocation, revoke: revokeLocation } = Proxy.revocable({}, {
  get: function (_fakeLocation, propKey) {
    const location = iframe.contentWindow.location;
    // host / hostname / protocol / port / origin:返回子应用 url 解析出来的值
    if (propKey === "host" || propKey === "hostname" || propKey === "protocol" || propKey === "port" || propKey === "origin") {
      return urlElement[propKey];
    }
    // href:iframe 真实地址是「主应用域名 + 子应用路径」,把域名换回子应用的
    if (propKey === "href") return location[propKey].replace(mainHostPath, appHostPath);
    if (propKey === "reload") { warn(WUJIE_TIPS_RELOAD_DISABLED); return () => null; }
    if (propKey === "replace") {
      return new Proxy(location[propKey], {
        apply(replace, _ctx, args) { return replace.call(location, args[0]?.replace(appHostPath, mainHostPath)); },
      });
    }
    return getTargetValue(location, propKey);   // pathname / search / hash 直接透传 iframe 的
  },
  set: function (_fakeLocation, propKey, value) {
    // location.href = xxx 视为整页跳转:另起一个可见 iframe 加载目标地址,并把地址推进主应用 history
    if (propKey === "href") return locationHrefSet(iframe, value, appHostPath);
    iframe.contentWindow.location[propKey] = value;
    return true;
  },
});

子应用的代码是怎么拿到这个 proxyLocation 的?packages/wujie-core/src/iframe.ts 的 insertScriptToIframe 在非降级模式下执行每段(非 module)脚本时,会把它包进一个立即执行函数,用形参把 window、self、global、location 四个名字覆盖掉;而 proxyWindow 的 get 拦截器遇到 location 也会返回 proxyLocation:

// packages/wujie-core/src/iframe.ts:978 insertScriptToIframe:内联脚本的包裹
code = `(function(window, self, global, location) {
      ${code}
}).bind(window.__WUJIE.proxy)(
  window.__WUJIE.proxy,
  window.__WUJIE.proxy,
  window.__WUJIE.proxy,
  window.__WUJIE.proxyLocation,
);`;

history 则不是 Proxy、而是直接替换 pushState/replaceState 两个方法:

// Wujie 源码:history 补丁(精简自 packages/wujie-core/src/iframe.ts 的 patchIframeHistory)
function patchIframeHistory(iframeWindow: Window, appHostPath: string, mainHostPath: string): void {
  const history = iframeWindow.history;
  const rawHistoryPushState = history.pushState;
  history.pushState = function (data: any, title: string, url?: string): void {
    const baseUrl =
      mainHostPath + iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;
    // 子应用传来的 url 可能带着子应用域名,换成主应用域名,否则 pushState 会抛跨域错误
    const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl);
    const ignoreFlag = url === undefined;

    rawHistoryPushState.call(history, data, title, ignoreFlag ? undefined : mainUrl);
    if (ignoreFlag) return;
    updateBase(iframeWindow, appHostPath, mainHostPath);   // 同步 iframe 里的 <base href>
    syncUrlToWindow(iframeWindow);                         // 把子应用路径写进主应用 URL 的 query
  };
  // replaceState 同理
}

// packages/wujie-core/src/sync.ts syncUrlToWindow(精简):sync 开启时写 ?{appName}=/path,否则清掉这个参数
export function syncUrlToWindow(iframeWindow: Window): void {
  const { sync, id, prefix } = iframeWindow.__WUJIE;
  const curUrl = iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;
  // ... prefix 短路径替换 ...
  if (sync) queryMap[id] = curUrl; else delete queryMap[id];
  // ... 拼回 query 后 window.history.replaceState(null, "", newHref)
}

通过这一系列劫持,子应用调用 router.push('/detail/123') 时,内部触发的 history.pushState 先在 iframe 自己的 history 里落一条记录,然后(sync: true 时)主应用 URL 的 query 上会多出一段 ?order-app=%2Fdetail%2F123(即 /detail/123 经 encodeURIComponent 编码);用户刷新页面后,syncUrlToIframe 从这个 query 里读回路径、replaceState 回 iframe,子应用路由状态就恢复了。浏览器前进/后退按钮不需要 Wujie 做任何事——iframe 的 history 本来就和主应用处在同一个 joint session history 里,浏览器会直接作用于它;Wujie 只是监听 iframe 的 popstate/hashchange(syncIframeUrlToWindow)、把变化再同步到主应用 URL。

13.2.6 createElement 与 appendChild 的分流

不同类型的 DOM 节点要放到不同的地方才能正确工作:script 标签要放进 iframe 的 document,才能在 iframe 上下文中执行;style 和 link 标签要放进 Shadow DOM,才能只作用于子应用的渲染区域;普通 DOM 元素(div、span)也要放进 Shadow DOM 参与渲染。Wujie 通过改写 shadowRoot 里 head/body 的 appendChild/insertBefore(packages/wujie-core/src/effect.ts 的 patchRenderEffect),对每一个被插入的节点做类型判断,再分流到正确的目标。

子应用动态创建资源时(<script>、<style>、<link> 标签),Wujie 需要将它们分流到正确的层。注意方向:子应用里的 document.head 经过上一节的代理已经是 shadowRoot 里的 head,所以补丁打在 shadowRoot 的 head/body 上,把不该留在渲染层的 <script> 送回 iframe:

// Wujie 源码:资源分流(精简自 packages/wujie-core/src/effect.ts)
function rewriteAppendOrInsertChild(opts: { rawDOMAppendOrInsertBefore; wujieId: string }) {
  return function appendChildOrInsertBefore(this: HTMLHeadElement | HTMLBodyElement, newChild, refChild?) {
    let element = newChild as any;
    const { rawDOMAppendOrInsertBefore, wujieId } = opts;
    const sandbox = getWujieById(wujieId);
    const { styleSheetElements, replace, fetch, plugins, iframe, lifecycles, proxyLocation, fiber } = sandbox;

    // 只拦 LINK / STYLE / SCRIPT / IFRAME,其他节点原样插进 shadowRoot
    if (!isHijackingTag(element.tagName) || !wujieId) {
      const res = rawDOMAppendOrInsertBefore.call(this, element, refChild);
      patchElementEffect(element, iframe.contentWindow);
      return res;
    }
    const iframeDocument = iframe.contentDocument;
    switch (element.tagName?.toUpperCase()) {
      case "LINK": {
        // 外链样式:用 fetch 拉回来、经 cssLoader 处理后以 <style> 内联进 shadowRoot,原 <link> 位置留一条注释
        /* ... getExternalStyleSheets([{ src: href }]) → placeholderElement.innerHTML = cssLoader(content) ... */
        const comment = iframeDocument.createComment(`dynamic link ${href} replaced by wujie`);
        return rawDOMAppendOrInsertBefore.call(this, comment, refChild);
      }
      case "STYLE": {
        // 内联样式:留在 shadowRoot,内容过一遍 cssLoader,并给元素打补丁(:root/@font-face 修正)
        styleSheetElements.push(element);
        /* ... element.innerHTML = cssLoader(content, "", curUrl) ... */
        return rawDOMAppendOrInsertBefore.call(this, element, refChild);
      }
      case "SCRIPT": {
        // 脚本:拉回内容后交给 insertScriptToIframe,插进 iframe 的 head 里执行;shadowRoot 里只留一条注释
        /* ... getExternalScripts([...]) → sandbox.execQueue.push(() => insertScriptToIframe({...}, iframe.contentWindow, element)) ... */
        const comment = iframeDocument.createComment(`dynamic script ${src} replaced by wujie`);
        return rawDOMAppendOrInsertBefore.call(this, comment, refChild);
      }
      case "IFRAME": {  }
    }
  };
}

// patchRenderEffect:把上面的函数装到 shadowRoot.head / shadowRoot.body 的 appendChild、insertBefore 上,
// 同时改写 head.removeChild / contains,让子应用「删掉自己插的 script」时能在 iframe 里找到对应节点

// 分流规则:
// <script> → iframe(JS 隔离层),且经由 execQueue 串行执行
// <style> → Shadow DOM(渲染层)
// <link rel=stylesheet> → fetch 后转成 <style> 放进 Shadow DOM
// 其他 DOM 操作 → Shadow DOM(渲染层)

13.2.7 降级模式(Degrade Mode)

Wujie 的降级模式是一种能力分级:浏览器缺少它依赖的能力时,Proxy 桥接和 Web Component 都用不了,Wujie 不会报错退出,而是退回到用另一个 iframe 承载 DOM 的工作方式——弹窗不再能覆盖整个页面、滚动体验变差,但功能仍然可用。

先澄清一个常见误解:无界的沙箱 iframe 始终与主应用同源(上一节已经看到,它是 srcdoc 空白文档、URL 被改写成主应用地址),子应用的 HTML/JS/CSS 是主应用用 fetch 拉回来再注入的——所以"子应用部署在别的域名"并不会触发降级,只要求子应用资源允许跨域请求(CORS)。真正触发降级的是浏览器不支持 Proxy 或 CustomElementRegistry(IE 等),或者用户显式传入 degrade: true:

// Wujie 源码:降级判定(精简自 packages/wujie-core/src/utils.ts 与 packages/wujie-core/src/sandbox.ts)
export const wujieSupport = window.Proxy && window.CustomElementRegistry;

// packages/wujie-core/src/sandbox.ts 构造函数
this.degrade = degrade || !wujieSupport;
if (this.degrade) {
  // 降级:用 Object.defineProperty 造一个「假 document / 假 location」,不用 Proxy
  const { proxyDocument, proxyLocation, proxyRevoke } = localGenerator(this.iframe, urlElement, mainHostPath, appHostPath);
  this.proxyDocument = proxyDocument;
  this.proxyLocation = proxyLocation;
} else {
  const { proxyWindow, proxyDocument, proxyLocation, proxyRevoke } = proxyGenerator(this.iframe, urlElement, mainHostPath, appHostPath);
  this.proxy = proxyWindow;
  this.proxyDocument = proxyDocument;
  this.proxyLocation = proxyLocation;
}

// packages/wujie-core/src/sandbox.ts active():降级时用第二个(可见的)iframe 承载 DOM,替代 Web Component
if (this.degrade) {
  const { iframe, container } = initRenderIframeAndContainer(this.id, el ?? iframeBody, this.degradeAttrs);
  this.el = container;
  await renderTemplateToIframe(iframe.contentDocument, this.iframe.contentWindow, this.template);
  this.document = iframe.contentDocument;   // 之后 proxyDocument 的属性都从这个 document 取
  return;
}

也就是说,降级后子应用的 JS 仍然跑在那个隐藏的沙箱 iframe 里(与主应用同源、通信不受影响),只是 DOM 从 Shadow DOM 换成了第二个可见 iframe、document 代理从 Proxy 换成了 Object.defineProperty。正常模式与降级模式的能力对比:

特性 正常模式 降级模式
JS 执行位置 隐藏的沙箱 iframe 隐藏的沙箱 iframe(不变)
DOM 渲染位置 <wujie-app> 的 Shadow DOM 第二个可见的渲染 iframe
document / location 代理 Proxy Object.defineProperty
CSS 隔离 Shadow DOM iframe 原生
弹窗居中 相对视口居中 只在渲染 iframe 内居中
window.location 完整代理(host 等返回子应用的) 无法代理,拿到的是主应用 host,需改用 $wujie.location
通信 / 预加载 / 保活 支持 同样支持,业务代码不用改

🔥 深度洞察:架构设计中的"降级思维"

Wujie 的降级模式体现了一个重要的架构设计原则:不要为 100% 的完美而牺牲 100% 的可用性。 很多开源框架追求架构的纯粹性——如果核心假设不成立就直接报错。Wujie 选择了更务实的路径:正常模式提供最佳体验,降级模式保证基本可用。你的系统不需要在所有条件下都完美——它需要在最优条件下出色,在最差条件下可用。

13.3 iframe 通信的现代方案:MessageChannel、BroadcastChannel

传统 iframe 方案的通信难点,这些年被一批浏览器原生 API 缓解了不少。MessageChannel(HTML 标准里的 Channel Messaging,2010 年前后就已落地)和 BroadcastChannel(Firefox 38、Chrome 54 起支持,Safari 直到 15.4 才补齐)让跨 iframe 通信从"postMessage + 手写路由"变成"原生通道 + 点对点/广播":MessageChannel 提供点对点通道,BroadcastChannel 提供同源内的发布订阅(包括跨标签页),消息走结构化克隆,不必手动 JSON.stringify。

13.3.1 postMessage 的局限性

传统 iframe 通信依赖 window.postMessage,在微前端场景下有明显痛点:

// postMessage 的痛点
// 1. 广播式通信:所有 message 监听器都会收到,需手动过滤
// 2. 无类型安全:event.data 是 any 类型
// 3. 无法建立点对点通道:需在消息体中加 target 字段做路由
// 4. 安全隐患:origin 校验不严格容易被利用

window.addEventListener('message', (event) => {
  if (event.origin !== 'https://main-app.example.com') return;
  const { target, type, payload } = event.data;
  if (target !== 'order-app') return; // 手动过滤,不够优雅
  // 处理消息...
});

13.3.2 MessageChannel:点对点的私有通道

MessageChannel 创建一对关联的端口,只有持有端口的双方才能通信。下面是一个通用方案的示意(不是无界源码——无界正常模式下并不需要 MessageChannel,见 13.3.4):

// 基于 MessageChannel 构建子应用通信管理器(示意)
class MicroAppChannelManager {
  private channels = new Map<string, MessageChannel>();
  private handlers = new Map<string, Map<string, Function>>();

  createChannel(appName: string): MessagePort {
    const channel = new MessageChannel();
    this.channels.set(appName, channel);

    channel.port1.onmessage = (event: MessageEvent) => {
      this.dispatch(appName, event.data);
    };
    channel.port1.start();
    channel.port2.start();
    return channel.port2; // 传递给子应用
  }

  // 通过 postMessage 的 transfer 参数传递端口给 iframe
  transferToIframe(appName: string, iframe: HTMLIFrameElement): void {
    const channel = this.channels.get(appName);
    if (!channel) return;
    // MessagePort 是 Transferable 对象,传递后主应用失去 port2 访问权
    iframe.contentWindow!.postMessage(
      { type: '__PORT_INIT__', appName },
      '*',
      [channel.port2]
    );
  }

  send(appName: string, message: any): void {
    this.channels.get(appName)?.port1.postMessage(message);
  }

  on(appName: string, type: string, handler: Function): void {
    if (!this.handlers.has(appName)) this.handlers.set(appName, new Map());
    this.handlers.get(appName)!.set(type, handler);
  }

  private dispatch(appName: string, data: any): void {
    this.handlers.get(appName)?.get(data.type)?.(data.payload);
  }
}

13.3.3 BroadcastChannel:一对多的发布-订阅

当需要一条消息通知所有子应用时(用户登出、主题切换),BroadcastChannel 是更好的选择:

// 同名 channel 的所有实例都能互相通信
// 主应用
const bc = new BroadcastChannel('micro-fe-bus');
bc.postMessage({ type: 'THEME_CHANGE', payload: { theme: 'dark' } });

// 子应用 A(iframe 中)
const bcA = new BroadcastChannel('micro-fe-bus');
bcA.onmessage = (event) => {
  if (event.data.type === 'THEME_CHANGE') applyTheme(event.data.payload.theme);
};

// BroadcastChannel 特点:
// 1. 同源限制:只有同源页面才能通信
// 2. 自动广播:所有订阅者都会收到
// 3. 跨标签页:甚至可以跨浏览器标签页通信

在微前端场景中,完整的通信方案通常组合使用三种机制:

// 分层通信策略
class MicroFeBus {
  private channelManager: MicroAppChannelManager;
  private broadcastChannel: BroadcastChannel;
  private eventHandlers = new Map<string, Set<Function>>();

  constructor(namespace: string = 'wujie-bus') {
    this.channelManager = new MicroAppChannelManager();
    this.broadcastChannel = new BroadcastChannel(namespace);
    this.broadcastChannel.onmessage = (event: MessageEvent) => {
      this.emit(event.data.type, event.data.payload);
    };
  }

  sendTo(appName: string, type: string, payload: any): void {
    this.channelManager.send(appName, { type, payload });
  }

  broadcast(type: string, payload: any): void {
    this.broadcastChannel.postMessage({ type, payload });
  }

  on(type: string, handler: Function): () => void {
    if (!this.eventHandlers.has(type)) this.eventHandlers.set(type, new Set());
    this.eventHandlers.get(type)!.add(handler);
    return () => { this.eventHandlers.get(type)?.delete(handler); };
  }

  private emit(type: string, payload: any): void {
    this.eventHandlers.get(type)?.forEach((handler) => handler(payload));
  }
}

13.3.4 Wujie 正常模式的通信优势

在 Wujie 下(降级与否都一样),通信比上面的方案更直接。因为沙箱 iframe 与主应用同源,双方可以直接互访 window。无界文档列出了三种官方通信方式——props、window 直接访问、EventBus:

// 方式一:props 注入——startApp 的 props 选项会挂到子应用的 window.$wujie.props 上
startApp({
  name: 'order-app',
  url: 'https://order.example.com/',
  el: '#container',
  props: {
    user: { id: 123, name: '杨艺韬' },
    theme: 'dark',
    // postMessage 做不到的:传递函数引用
    showGlobalModal: (config: ModalConfig) => mainApp.modal.show(config),
  },
});
// 子应用
const { user, showGlobalModal } = window.$wujie.props;

// 方式二:同源 iframe,直接互访 window
window.parent.xxx;                                                       // 子应用访问主应用
document.querySelector('iframe[name="order-app"]').contentWindow.xxx;   // 主应用访问子应用

第三种方式是无界自带的事件总线(packages/wujie-core/src/event.ts)。它的设计有一个细节值得注意:每个应用(主应用、各个子应用)各自持有一个 EventBus 实例,但所有实例的监听表都存在同一张全局 appEventObjMap 里;$emit 时遍历所有应用的监听表,所以任何一方发出的事件,任何一方都能收到——去中心化、不需要指定接收者:

// Wujie 源码:事件总线(精简自 packages/wujie-core/src/event.ts)
export class EventBus {
  private id: string;
  private eventObj: EventObj;   // { [event]: Function[] },按应用 id 存进全局 appEventObjMap

  constructor(id: string) {
    this.id = id;
    this.$clear();
    if (!appEventObjMap.get(this.id)) appEventObjMap.set(this.id, {});
    this.eventObj = appEventObjMap.get(this.id);
  }

  public $on(event: string, fn: Function): EventBus {
    const cbs = this.eventObj[event];
    if (!cbs) { this.eventObj[event] = [fn]; return this; }
    if (!cbs.includes(fn)) cbs.push(fn);
    return this;
  }

  public $once(event: string, fn: Function): void {
    const on = function (...args: Array<any>) { this.$off(event, on); fn(...args); }.bind(this);
    this.$on(event, on);
  }

  public $off(event: string, fn: Function): EventBus { /* 从 cbs 里 splice 掉 fn */ return this; }

  // 发送事件:遍历「所有」应用的监听表,主应用、任意子应用都能收到
  public $emit(event: string, ...args: Array<any>): EventBus {
    let cbs = [];
    appEventObjMap.forEach((eventObj) => { if (eventObj[event]) cbs = cbs.concat(eventObj[event]); });
    try { for (let i = 0, l = cbs.length; i < l; i++) cbs[i](...args); } catch (e) { error(e); }
    return this;
  }

  public $clear(): EventBus { /* 清空当前 id 的所有监听;非保活子应用 unmount 时会被调用 */ return this; }
  // 另有 $onAll / $offAll:监听所有事件,回调第一个参数是事件名
}

// 主应用:import { bus } from "wujie";子应用:window.$wujie.bus
bus.$on('order:created', (orderId: string) => {
  bus.$emit('cart:refresh');
});

🔥 深度洞察:通信方案的选择不在于"最新",而在于"最匹配"

MessageChannel 和 BroadcastChannel 是较新的 API,但不意味着它们在所有场景下都优于 postMessage。在 Wujie 下,直接的对象引用传递比任何消息通道都高效——没有序列化开销、支持函数传递、同步执行;降级模式也不例外,因为沙箱 iframe 始终同源。只有当你嵌入的是一个真正跨域的普通 iframe(无界之外的场景)时,才需要回退到 MessageChannel/postMessage。选择通信方案的首要原则不是技术的新旧,而是约束条件允许什么。

13.4 iframe 的性能优化:预加载、资源共享

13.4.1 iframe 的性能瓶颈分析

即使在 Wujie 的架构下,iframe 依然有不可忽视的性能开销。我们可以将其分为三个层面:

interface IframePerformanceCost {
  // 第一层:创建开销
  creation: {
    contextInit: '每个 iframe 都要初始化一套独立的全局对象与 realm,是一次性开销';
    documentParsing: '空白页的 document 也需要解析和构建';
    memoryBaseline: '独立上下文有基线内存占用,每多一个子应用就多一份';
  };

  // 第二层:资源加载开销
  resourceLoading: {
    htmlFetch: '获取子应用的 HTML 入口文件';
    cssFetch: '获取并解析子应用的所有 CSS';
    jsFetch: '获取并执行子应用的所有 JS';
    duplicateResources: '多个子应用可能重复加载 React、Vue 等公共依赖';
  };

  // 第三层:运行时开销
  runtime: {
    proxyOverhead: 'Proxy 劫持在高频 DOM 操作下的微小延迟';
    domBridge: 'DOM 操作从 iframe 桥接到 Shadow DOM 的额外开销';
    eventDispatch: '事件在 Shadow DOM 边界上的传播处理';
  };
}

理解了开销的来源,我们才能对症下药。

13.4.2 预加载:空闲时间的利用

requestIdleCallback 让你把不紧急、但最终要做的任务安排在浏览器空闲时间执行,不抢占用户交互的帧预算。Wujie 用它做 iframe 预加载,让子应用的初始化工作在用户看不见的时间里完成,用户真正切过去时 iframe 已经就绪。Vue 3.5 异步组件的 hydrateOnIdle 惰性水合也是这一类思路。要区分开的是 React 的 concurrent rendering——它不属于"等空闲"这一类:React 的 Scheduler 从不用 requestIdleCallback,而是用 MessageChannel 宏任务、每约 5ms 无条件让出主线程(见《React 19 内核探秘》第 4 章)。

Wujie 利用 requestIdleCallback 在浏览器空闲时提前创建子应用的 iframe 和加载资源:

// Wujie 源码:预加载(精简自 packages/wujie-core/src/index.ts 的 preloadApp)
// packages/wujie-core/src/utils.ts:export const requestIdleCallback = window.requestIdleCallback || ((cb) => setTimeout(cb, 1));
export function preloadApp(preOptions: preOptions): void {
  requestIdleCallback((): void | Promise<void> => {
    // 已经有实例,或主应用 url 上带着该子应用的同步参数(大概率是刷新/分享链接),不预加载
    if (getWujieById(preOptions.name) || isMatchSyncQueryById(preOptions.name)) return;
    const options = mergeOptions({ ...preOptions }, getOptionsById(preOptions.name));
    const { name, url, html, props, alive, replace, fetch, exec, attrs, degradeAttrs, fiber, degrade, prefix, plugins, lifecycles } = options;

    // 注意:沙箱实例一创建,隐藏 iframe 就已经插进主文档了(构造函数里调用 iframeGenerator)
    const sandbox = new WuJie({ name, url, attrs, degradeAttrs, fiber, degrade, plugins, lifecycles });
    if (sandbox.preload) return sandbox.preload;
    const runPreload = async () => {
      sandbox.lifecycles?.beforeLoad?.(sandbox.iframe.contentWindow);
      // 第一步:拉 HTML,解析出 script / style 列表(packages/wujie-core/src/entry.ts 的 importHTML,结果按 url 缓存)
      const { template, getExternalScripts, getExternalStyleSheets } = await importHTML({
        url, html, opts: { fetch: fetch || window.fetch, plugins: sandbox.plugins, loadError: sandbox.lifecycles.loadError, fiber },
      });
      // 第二步:拉 CSS 并内联进模板,把模板渲染进 shadowRoot(此时 <wujie-app> 暂时挂在 iframe 自己的 body 里)
      const processedHtml = await processCssLoader(sandbox, template, getExternalStyleSheets);
      await sandbox.active({ url, props, prefix, alive, template: processedHtml, fetch, replace });
      // 第三步:exec 为 true 就真的执行脚本(「预执行」),否则只把脚本拉回缓存(「预加载」)
      if (exec) {
        await sandbox.start(getExternalScripts);
      } else {
        await getExternalScripts();
      }
    };
    sandbox.preload = runPreload();
  });
}

// 基于路由的预加载策略(用户侧代码)
function smartPreload(currentRoute: string): void {
  const preloadMap: Record<string, preOptions[]> = {
    '/': [
      { name: 'product-app', url: '/product/', exec: true },
      { name: 'order-app', url: '/order/' },
    ],
    '/product': [
      { name: 'order-app', url: '/order/', exec: true },
      { name: 'cart-app', url: '/cart/' },
    ],
  };
  preloadMap[currentRoute]?.forEach((options) => preloadApp(options));
}

无界文档(docs/api/preloadApp.md)把这两档区分得很清楚:exec: false(默认)叫预加载——只把静态资源拉进内存;exec: true 叫预执行——子应用提前渲染好。预执行可能阻塞主线程,所以 fiber 选项(默认 true)会让每一段脚本都放进 requestIdleCallback 里执行、脚本之间通过 execQueue 串行衔接。

requestIdleCallback 让预加载的启动推迟到浏览器空闲时,尽量不和当前页面的交互抢时间(它管的只是启动时机:之后的 fetch 回调、active 渲染都在普通任务里继续执行):

// 浏览器的一帧(16.67ms @ 60fps)
// ┌─────────────────────────────────────────────────┐
// │ Input │ JS │ Layout │ Paint │ Composite │ Idle  │
// │ events│    │        │       │           │ Time  │
// └─────────────────────────────────────────────────┘
//                                             ↑
//                                    requestIdleCallback
//                                    在这里执行预加载
//
// 如果主要任务提前完成,剩余的空闲时间用于预加载
// 如果这一帧很忙,预加载推迟到下一帧的空闲时间
// 但回调一旦开始就会执行到底,单个任务过长仍会阻塞交互

13.4.3 资源共享与缓存

Wujie 的资源缓存让多个子应用共用的公共依赖(React、Vue、lodash 等)只下载一次。它省的主要是网络;缓存的是脚本文本,每个子应用的 iframe 仍会各自执行一份,想在内存层面也只留一份,要靠下文的"应用共享"做法。缓存也带来一致性和失效问题:子应用发版后,旧的缓存条目要能被及时淘汰。

多个子应用加载相同 URL 的公共依赖(React、Vue 等)时,无界的资源缓存可以避免重复请求。packages/wujie-core/src/entry.ts 里有三张模块级缓存表,缓存的是 fetch 的 Promise 而不是结果——这样并发请求同一个 URL 也只会发一次:

// Wujie 源码:资源缓存(精简自 packages/wujie-core/src/entry.ts)
export const styleCache: Record<string, any> = {};
export const scriptCache: Record<string, any> = {};
export const embedHTMLCache: Record<string, any> = {};

const fetchAssets = (src: string, cache: Object, fetch, cssFlag?: boolean, loadError?) =>
  cache[src] ||
  (cache[src] = fetch(src)
    .then((response) => {
      // 4xx / 5xx 视为失败:清掉缓存位,css 返回空串,js 抛错
      if (response.status >= 400) {
        cache[src] = null;
        /* ... error(...) / loadError?.(src, ...) ... */
      }
      return response.text();
    })
    .catch((e) => { cache[src] = null; loadError?.(src, e); return ""; }));

// v2.0.0 起对外导出 clearAssetsCache(host?)(packages/wujie-core/src/entry.ts:48):不传全清,传 host 前缀则只清该子应用的缓存

更进一步,可以让子应用直接复用主应用已经加载的公共库。无界文档"应用共享"一节给的做法是:主应用把包挂到自己的 window 上、用 jsBeforeLoaders 插件在子应用脚本执行前把它从 window.parent 拷过去、子应用打包时把这个包 externals 掉:

// 主应用 index.js
import lodash from 'lodash';
window.lodash = lodash;

// 加载子应用时注入插件:在子应用任何脚本执行之前先跑这段代码(跑在子应用的 iframe 里)
startApp({
  name: 'A',
  url: 'xxxxx',
  el: '#container',
  plugins: [{ jsBeforeLoaders: [{ content: 'window.lodash = window.parent.lodash' }] }],
});

// 子应用 webpack:externals: { lodash: { root: 'lodash', commonjs: 'lodash', commonjs2: 'lodash', amd: 'lodash' } }

// 注意事项(无界文档原话的意思):
// 1. 共享的前提是主应用和沙箱 iframe 同源、可以共享内存
// 2. 对于组件库这样有副作用的第三方包,可能无法共享
// 笔者建议:优先共享纯函数式的工具库(lodash、dayjs 等)

13.4.4 保活模式(Keep-Alive)

保活模式和 Vue 的 <keep-alive>、浏览器的 BFCache 是同一类思路:用户从 A 应用切到 B 应用时,A 不真正销毁,只是被暂停,切回来时直接激活。这是以空间换时间:代价是长期占用内存,收益是切换更快,适合用户在几个应用之间频繁切换的场景(比如仪表盘、工作台)。它也要求子应用配合:没正确清理的事件监听会累积成内存泄漏,被保留的状态可能过期,保留下来的定时器也还在运行。

无界文档把子应用的运行方式分成三种:保活模式(alive: true)、单例模式(alive 为 false 但子应用做了生命周期改造、暴露了 window.__WUJIE_MOUNT/__WUJIE_UNMOUNT)、重建模式(两者都没有)。对于频繁切换的子应用,保活模式保留完整状态,避免每次切换都重新初始化。这三种模式的分岔点就在 startApp 开头:

// Wujie 源码:三种运行模式的分岔(精简自 packages/wujie-core/src/index.ts 的 startApp)
export async function startApp(startOptions: startOptions): Promise<Function | void> {
  const sandbox = getWujieById(startOptions.name);
  // ... mergeOptions ...
  // 已经初始化过的应用,快速渲染
  if (sandbox) {
    const iframeWindow = sandbox.iframe.contentWindow;
    if (alive) {
      // 保活:只把内存里的 <wujie-app> 重新挂回容器,不重建 iframe、不重跑 JS
      await sandbox.active({ url, sync, prefix, el, props, alive, fetch, replace });
      // 预加载过但没预执行的情况,补一次 start
      if (!sandbox.execFlag) { /* ... await sandbox.start(getExternalScripts) ... */ }
      sandbox.lifecycles?.activated?.(iframeWindow);
      return () => sandbox.destroy();
    } else if (isFunction(iframeWindow.__WUJIE_MOUNT)) {
      // 单例:复用 iframe(JS 只执行过一次),先 unmount 再重新 __WUJIE_MOUNT 一个新实例
      await sandbox.unmount();
      await sandbox.active({ url, sync, prefix, el, props, alive, fetch, replace });
      sandbox.rebuildStyleSheets();   // 动态样式要重新挂回 shadowRoot.head
      sandbox.lifecycles?.beforeMount?.(iframeWindow);
      iframeWindow.__WUJIE_MOUNT();
      sandbox.lifecycles?.afterMount?.(iframeWindow);
      sandbox.mountFlag = true;
      return () => sandbox.destroy();
    } else {
      // 重建:销毁旧实例,下面走完整流程
      await sandbox.destroy();
    }
  }
  // 首次:new WuJie → importHTML → processCssLoader → active → start
}

// packages/wujie-core/src/shadow.ts:<wujie-app> 被移出 DOM 时(disconnectedCallback)
export function handleWujieAppDisconnect(sandbox: Wujie | null | undefined): void {
  if (!sandbox) return;
  const isRebuildMode = !sandbox.alive && !isFunction(sandbox.iframe?.contentWindow?.__WUJIE_MOUNT);
  // 保活 / 单例只 unmount(保活时触发 deactivated 生命周期),重建模式直接 destroy
  isRebuildMode ? sandbox.destroy() : sandbox.unmount();
}

// 收益与代价:
// 切换成本:重建模式要重新拉资源、重建 iframe、重跑 JS;保活只是一次 DOM 挂载
// 用户状态:重建/单例丢失 → 保活完整保留(表单、滚动位置、iframe 内的路由)
// 代价:iframe 与 shadowRoot 持续占用内存;保活子应用切走后仍会响应 bus 事件
// 适用:高频切换的核心子应用,如标签页模式

13.4.5 与其他新一代方案的对比

Wujie 并非唯一重新审视 iframe 的方案。同一时期,社区还有多个相关方案:

方案 核心机制 JS 隔离 渲染位置 独特之处
Wujie(腾讯) Custom Element + 隐藏 iframe + Proxy iframe 原生 Shadow DOM 最早把 iframe 当纯 JS 沙箱用的方案之一
micro-app(京东) Custom Element + with/Proxy 沙箱(1.0 起可切换 iframe 沙箱) with/Proxy 模拟,或 iframe <micro-app> 元素内的普通 DOM(默认用属性前缀做 CSS 作用域) 像写 iframe 一样只写一个标签
Garfish(字节) Proxy(vm)沙箱 / 快照沙箱 + HTML/JS Entry Proxy 模拟 DOM 直接挂载(strictIsolation 可开 Shadow DOM) 插件化内核,多实例同屏

关键差异:Wujie 从第一天起就把 iframe 当作 JS 沙箱、Shadow DOM 当作渲染层;micro-app 默认是 with + Proxy 沙箱,从 1.0.0-beta.0(2023 年 3 月)起也提供了可切换的 iframe 沙箱;Garfish 仍是 Proxy/快照沙箱。这意味着 Wujie 在 JS 隔离的完整性上有先天优势,代价是每个子应用多一个 iframe 的启动开销,子应用资源必须允许主应用跨域 fetch(CORS)、路由要靠 URL query 同步。

// 选择建议
function chooseScheme(scenario: {
  isolationRequirement: 'strict' | 'moderate' | 'loose';
  teamSize: 'small' | 'medium' | 'large';
  existingStack: string;
}): string {
  const { isolationRequirement, teamSize } = scenario;

  if (isolationRequirement === 'strict') {
    return 'Wujie —— 需要彻底的 JS 隔离,能接受 iframe 的启动开销与 CORS 要求';
  }

  if (isolationRequirement === 'moderate' && teamSize === 'large') {
    return 'Garfish —— 企业级方案,插件生态丰富';
  }

  if (teamSize === 'small') {
    return 'micro-app —— API 简洁,上手成本低';
  }

  return '根据具体约束条件综合评估';
}

🔥 深度洞察:技术选型中的"不可能三角"

微前端方案面临一个类似于 CAP 定理的"不可能三角":隔离强度、运行性能、开发体验——三者很难同时达到最优。 iframe 方案(Wujie)优先保证隔离,在性能和开发体验上做了妥协;Module Federation 选择了极致性能和 DX,在隔离上做了妥协;Proxy 沙箱方案(乾坤)在三者之间取了折中。理解这个三角关系,比记住任何一个方案的 API 都重要——它帮助你在面对新方案时快速判断其取舍。

本章小结

  • iframe 的"复兴"不是技术的倒退,而是使用方式的进化:将 iframe 从渲染容器降格为 JS 执行沙箱,保留其 realm 级隔离的天然优势,规避其体验缺陷
  • Wujie 的三层架构——WebComponent(渲染)+ iframe(执行)+ Proxy(桥接)——是一个精妙的职责分离设计:每一层只做自己最擅长的事
  • 桥接层的核心对象:window/document 代理(DOM 操作重定向到 Shadow DOM)、location 代理(域名映射回子应用真实地址)、history 补丁(路由同步到主应用 URL 的 query)、shadowRoot 上 head/body 的 appendChild/insertBefore(资源分流)
  • 降级模式是务实的架构设计:正常模式最佳体验,浏览器不支持 Proxy/Custom Elements(或显式 degrade: true)时退回"沙箱 iframe + 渲染 iframe"的双 iframe 方案保证基本可用
  • iframe 通信三种方案各有适用场景:直接引用(同域最优)、MessageChannel(点对点)、BroadcastChannel(一对多广播)
  • 性能优化三板斧:预加载(requestIdleCallback + 智能预测)、资源缓存(去重 + 共享)、保活模式(以空间换时间)

思考题

  1. 架构理解:Wujie 将 iframe 用作隐藏的 JS 执行沙箱,而非可见的渲染容器。这种"职责分离"的设计思想,在其他技术领域是否有类似的案例?请举例分析。

  2. 源码分析:Wujie 的 Proxy 桥接层需要劫持 iframe 的 document 上的大量属性和方法。请思考:有哪些 DOM API 是难以完美劫持的?它们可能导致什么兼容性问题?

  3. 方案对比:在第 4 章我们分析了乾坤的 Proxy 沙箱,在本章我们分析了 Wujie 的 iframe 沙箱。请从隔离完整性、性能开销、兼容性、开发体验四个维度,系统对比这两种隔离方案。

  4. 性能优化:假设你的微前端应用有 8 个子应用,用户在一次会话中平均访问其中 3 个。请设计一个预加载策略,在保证首屏性能的前提下,最大化后续子应用的切换速度。

  5. 开放讨论:随着浏览器对 Web Components、Import Maps、Shadow DOM 的支持越来越完善,你认为 Wujie 这种"曲线救国"的方案会长期存在,还是会被更原生的方案取代?