微前端源码精讲

第2章 微前端核心问题域

作者 杨艺韬 · 12,869 字 · 发布于 · 更新于

第2章 微前端核心问题域

框架会过时,但问题永存。理解微前端的六大核心问题,比学会任何一个框架都重要。

本章要点

  • 应用加载:运行时动态加载远程代码的三种模式——HTML Entry、JS Entry、Module Federation
  • JS 隔离:从快照沙箱到 Proxy 沙箱,理解全局变量污染的本质与防御策略
  • CSS 隔离:Shadow DOM、Scoped CSS、CSS Modules 三大方案的原理与边界
  • 应用通信:发布订阅、Props 传递、共享状态——微应用间数据传递的架构抉择
  • 路由管理:如何在主应用与多个子应用之间统一管理浏览器路由
  • 依赖共享:如何避免重复加载 React/Vue 等公共库,以及版本冲突的解决策略
  • 六大问题的解决策略谱系:一张表看清 qiankun、single-spa、Module Federation、Wujie、Web Components 各自的解法

本章出现的 qiankun 沙箱与样式代码以 v2.10.16 为准,single-spa 以 v6.0.3 为准,无界以 v2.1.0 为准;片段为可读性做了删减,但类名、方法名和文件路径与原始实现一致,拿别的版本对照可能对不上。


上一章我们从"为什么"出发,理解了微前端架构诞生的必然性。但"为什么需要微前端"只是故事的序章。真正的挑战从你决定采用微前端的那一刻开始——你将立刻面对六个绕不过去的核心问题。

这六个问题,就像建造一座城市必须解决的基础设施问题:供电(应用加载)、安全围墙(JS 隔离)、建筑外观协调(CSS 隔离)、通信网络(应用通信)、交通规划(路由管理)、公共资源调配(依赖共享)。任何一个微前端方案——无论是 qiankun、single-spa、Module Federation 还是 Wujie——本质上都是在用不同的策略回答这六个问题。

本章的目标不是深入任何一个框架的实现细节(那是后续章节的任务),而是建立一张完整的问题地图。当你对问题本身有了深刻理解,再去读源码时,你看到的就不是零散的代码,而是一个个精心设计的解决方案。

2.1 应用加载:如何在运行时动态加载远程代码

让我们先来设身处地感受一下这个问题的原始难度。想象你是一位 2015 年的前端架构师、你的老板说:"我们要做一个中台、让十几个业务团队各自开发自己的子应用、然后在同一个页面里动态拼起来"。你会怎么想?很快你会发现这不是"加一个 script"这么简单——因为浏览器从设计之初、就没有为"多个独立前端应用共存"这种场景准备好任何官方机制。HTML 假设只有一个 document、JavaScript 假设只有一个全局命名空间、CSS 假设所有选择器是全局的。微前端要在这三个"一元化"的假设之上、硬生生长出"多元化"的能力——这才是问题的真正难度所在。这也是为什么微前端从诞生到今天、十年里没有出现一个"大一统"的方案——因为每一种方案、都是在和浏览器的"一元化"假设做某种妥协。

2.1.1 问题的本质

微前端的第一个核心问题,也是最基础的问题:如何把一个远程服务器上的子应用代码,加载到当前页面并执行?

这个问题看似简单——不就是动态插入一个 <script> 标签吗?但当你深入思考,会发现远比想象的复杂:

// 最朴素的远程代码加载
function naiveLoadApp(url: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

// 问题 1:子应用不只有 JS,还有 CSS、HTML 模板、静态资源
// 问题 2:子应用的 JS 可能有多个文件(vendor.js、app.js、chunk.js...)
// 问题 3:这些文件的加载顺序有依赖关系
// 问题 4:子应用的 CSS 可能和主应用冲突
// 问题 5:子应用卸载时,如何清理已加载的资源
// 问题 6:子应用的 publicPath 在远程服务器,相对路径会失效

这就是为什么每个微前端框架都需要一个应用加载器——它远不是一个简单的 script loader。

2.1.2 三种加载模式

业界主流的微前端方案,在应用加载上形成了三种截然不同的模式。

模式一:JS Entry(single-spa 模式)

JS Entry 是最直接的方式:子应用暴露一个 JavaScript 入口文件,主应用加载并调用其中的生命周期函数。

// 子应用入口文件 app-entry.js:必须导出三个生命周期函数
export async function bootstrap(props: AppProps): Promise<void> {
  // 初始化:只在第一次加载时调用
  console.log('子应用 bootstrap');
}

export async function mount(props: AppProps): Promise<void> {
  // 挂载:每次进入子应用路由时调用
  const { container } = props;
  ReactDOM.render(<App />, container.querySelector('#root'));
}

export async function unmount(props: AppProps): Promise<void> {
  // 卸载:每次离开子应用路由时调用
  const { container } = props;
  ReactDOM.unmountComponentAtNode(container.querySelector('#root'));
}

// 主应用注册子应用
import { registerApplication, start } from 'single-spa';

registerApplication({
  name: 'order-app',
  app: () => System.import('https://order.example.com/app-entry.js'),
  activeWhen: '/order',
});
start();

JS Entry 简单直接,一个 URL 对应一个入口文件,加载逻辑完全可控。但代价也很明显——子应用的所有资源(CSS、图片、HTML 模板)都需要通过 JS 来管理,打包配置必须做特殊处理(输出为 UMD/SystemJS 格式),对子应用的侵入性较高。

模式二:HTML Entry(qiankun 模式)

qiankun 的 HTML Entry 是一种更"友好"的加载方式:直接请求子应用的 HTML 页面,然后从中解析出所有的 JS、CSS 资源。

// qiankun 的 HTML Entry 加载流程(简化版)
async function loadAppByHtmlEntry(entry: string) {
  // 第一步:获取子应用的 HTML
  const html = await fetch(entry).then(res => res.text());

  // 第二步:解析 HTML,提取 JS 和 CSS
  const { template, scripts, styles } = parseHTML(html);

  // 第三步:将 HTML 模板和样式插入容器
  const container = document.querySelector('#sub-app-container');
  container.innerHTML = template;
  const styleNodes = await loadStyles(styles);
  styleNodes.forEach(node => container.appendChild(node));

  // 第四步:在沙箱环境中执行 JS
  const appExports = await execScripts(scripts, sandbox);

  return appExports; // { bootstrap, mount, unmount }
}
// 真实实现:import-html-entry 的 importEntry() 返回 { template, execScripts, assetPublicPath, getExternalScripts },
// qiankun 的 src/loader.ts 再调用 execScripts(sandbox.proxy, ...) 拿到生命周期导出

// parseHTML 的核心逻辑
function parseHTML(html: string) {
  const scripts: ScriptEntry[] = [];
  const styles: StyleEntry[] = [];
  // import-html-entry 的 processTpl 用一组正则(不是 DOMParser)提取所有 <script>、<link>、<style>
  // 区分外链脚本和内联脚本,保留执行顺序
  // 返回去掉脚本和样式后的"干净" HTML 模板
  const template = html
    .replace(/<script[\s\S]*?<\/script>/gi, '<!-- script removed -->')
    .replace(/<link[^>]*rel=["']stylesheet["'][^>]*>/gi, '<!-- style removed -->');
  return { template, scripts, styles };
}

HTML Entry 的精妙之处在于:子应用的产物形态不需要改变。 它可以是一个标准的 React/Vue 应用,用 npm run build 构建出标准的 index.html + JS + CSS;子应用只需在入口导出生命周期函数、把打包输出设为 UMD 格式(让 qiankun 能拿到这些导出),HTML 里的脚本、样式由 qiankun 负责加载。这极大降低了子应用的接入成本。

但 HTML Entry 也引入了额外的复杂度:解析 HTML 的可靠性(正则匹配还是 DOM 解析?)、相对路径的补全(子应用的资源路径需要转为绝对路径)、脚本执行顺序的保证。这些看似细节的问题,在 qiankun 的核心依赖 import-html-entry 中有大量的代码来处理。

模式三:Module Federation(编译时声明 + 运行时加载)

Module Federation 走了一条完全不同的路:不是在运行时去解析远程应用的 HTML/JS,而是在编译时声明模块的暴露和消费关系,在运行时按需加载远程模块。

// 子应用(Remote)的 webpack/rspack 配置
new ModuleFederationPlugin({
  name: 'orderApp',
  filename: 'remoteEntry.js',
  exposes: {
    './OrderList': './src/components/OrderList',
    './OrderDetail': './src/pages/OrderDetail',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
});

// 主应用(Host)中使用远程模块——就像使用本地模块一样
const OrderList = React.lazy(() => import('orderApp/OrderList'));

function App() {
  return (
    <React.Suspense fallback={<Loading />}>
      <OrderList />
    </React.Suspense>
  );
}

Module Federation 的颠覆性在于:它让远程模块的消费体验与本地模块几乎一致。 你不需要关心"应用加载"这个概念——你只是 import 了一个模块,它碰巧在远程服务器上。

下图展示了三种加载模式从请求到执行的完整流程对比:

flowchart TD
    subgraph JSEntry["JS Entry (single-spa)"]
        JE1["主应用注册子应用URL"] --> JE2["加载JS入口文件"]
        JE2 --> JE3["调用bootstrap/mount"]
        JE3 --> JE4["子应用自行管理CSS/HTML"]
    end
    subgraph HTMLEntry["HTML Entry (qiankun)"]
        HE1["fetch子应用HTML"] --> HE2["解析HTML提取JS/CSS"]
        HE2 --> HE3["插入模板和样式到容器"]
        HE3 --> HE4["沙箱中执行JS脚本"]
        HE4 --> HE5["调用bootstrap/mount"]
    end
    subgraph MFEntry["Module Federation"]
        MF1["编译时声明exposes/shared"] --> MF2["运行时加载remoteEntry.js"]
        MF2 --> MF3["版本协商shared依赖"]
        MF3 --> MF4["按需加载远程模块"]
        MF4 --> MF5["像本地import一样使用"]
    end
    style JSEntry fill:#fff3e0,stroke:#e65100
    style HTMLEntry fill:#e3f2fd,stroke:#1565c0
    style MFEntry fill:#e8f5e9,stroke:#2e7d32

2.1.3 加载粒度的本质差异

三种模式最核心的差异在于加载粒度:

加载模式 粒度 侵入性 内置隔离 开发体验
JS Entry 应用级 高(需特殊打包格式) 无 中等
HTML Entry 应用级 低(少量打包配置) 内置沙箱 好
Module Federation 模块级 中(改构建配置) 无 优秀

为什么加载粒度如此重要?考虑一个具体的场景:你的商品详情页需要嵌入一个来自"营销团队"的促销活动组件。在 JS Entry/HTML Entry 模式下,你必须加载整个营销子应用,然后在子应用内部导航到该组件——这是一种重量级的操作。而在 Module Federation 模式下,你可以直接 import('marketingApp/PromotionBanner'),只加载那一个组件的代码。

// HTML Entry 模式:必须加载整个子应用
// 主应用需要为营销子应用分配一个容器和路由
registerMicroApps([{
  name: 'marketing-app',
  entry: '//marketing.example.com',
  container: '#marketing-container',
  activeRule: '/marketing',
}]);

// Module Federation 模式:只加载需要的组件
const PromotionBanner = React.lazy(
  () => import('marketingApp/PromotionBanner')
);
// 在商品详情页中直接使用,无需分配路由或容器
function ProductDetail() {
  return (
    <div>
      <ProductInfo />
      <React.Suspense fallback={<Skeleton />}>
        <PromotionBanner productId={productId} />
      </React.Suspense>
      <Reviews />
    </div>
  );
}

💡 深度洞察:加载粒度决定了架构的灵活性

JS Entry 和 HTML Entry 的加载粒度是"应用级"——子应用是最小的部署和加载单位。而 Module Federation 的加载粒度是"模块级"——你可以只加载远程应用的一个组件、一个工具函数。这个看似微小的差异,决定了两种截然不同的架构思路:前者是"把大应用拆成小应用",后者是"让模块跨越应用边界自由组合"。这也是为什么 Module Federation 官方一直把自己定位为"模块共享机制",微前端只是它的用例之一。

2.2 JS 隔离:如何防止全局变量污染

JS 隔离是整个微前端技术栈里最有"学术深度"的议题——因为它涉及到一个看似不可能完成的任务:在一个单进程、单线程、单全局对象的 JavaScript 运行环境里、凭空造出"多个隔离的全局环境"。这就像在一个只有一间教室的学校里、同时开五个不同班级的课——物理上只有一个空间、但要让五组学生互相不打扰。浏览器的 Web Worker、iframe 提供了"真正的隔离"、但它们的代价是通信开销巨大、DOM 无法共享——这在需要实时交互的场景下几乎不可接受。所以大多数微前端框架选择了"虚拟化隔离"——用 Proxy、快照、代理等手段、在同一个 window 对象上"模拟出"多个独立的命名空间。这种"用技术手段弥补语言限制"的做法、目标是在运行时的真实约束里、通过巧妙的设计让开发者享受到"仿佛没有约束"的体验。

2.2.1 问题的本质

假设你的主应用和子应用都使用了一个全局变量 window.__APP_CONFIG__:

// 主应用设置的全局配置
window.__APP_CONFIG__ = {
  theme: 'light',
  apiBaseUrl: 'https://api.main.com',
};

// 子应用加载后,覆盖了同名全局变量
window.__APP_CONFIG__ = {
  theme: 'dark',
  apiBaseUrl: 'https://api.sub.com',
};

// 此时主应用读到的是子应用的配置——灾难开始
// 主应用的 API 请求全部打到了子应用的后端

这不是极端案例。在真实项目中,全局变量污染无处不在:第三方 SDK(window.wx、window.AMap、window.Sentry)、全局事件监听(resize、scroll)、定时器(setInterval、setTimeout)、全局样式副作用(document.body.style.overflow = 'hidden')。

JS 隔离要解决的核心问题是:让每个子应用运行在一个受控的 JavaScript 环境中,使其对全局对象的修改不会影响主应用和其他子应用。

2.2.2 四种隔离策略

策略一:快照沙箱(Snapshot Sandbox)

最朴素但也最可靠的思路:在子应用激活时拍一张全局状态的"快照",失活时恢复这个快照。

// qiankun src/sandbox/snapshotSandbox.ts(删减版)
function iter(obj: typeof window, callbackFn: (prop: any) => void) {
  for (const prop in obj) {
    // clearInterval 的特判是为了兼容问题,见 qiankun #1490
    if (obj.hasOwnProperty(prop) || prop === 'clearInterval') callbackFn(prop);
  }
}

/** 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器 */
export default class SnapshotSandbox implements SandBox {
  proxy: WindowProxy;
  sandboxRunning = true;
  private windowSnapshot!: Window;
  private modifyPropsMap: Record<any, any> = {};

  constructor(name: string) {
    this.name = name;
    this.proxy = window; // 没有代理对象:子应用直接操作真实 window
    this.type = SandBoxType.Snapshot;
  }

  active() {
    // 记录当前快照
    this.windowSnapshot = {} as Window;
    iter(window, (prop) => {
      this.windowSnapshot[prop] = window[prop];
    });
    // 恢复上一次激活期间的变更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      window[p] = this.modifyPropsMap[p];
    });
    this.sandboxRunning = true;
  }

  inactive() {
    this.modifyPropsMap = {};
    iter(window, (prop) => {
      if (window[prop] !== this.windowSnapshot[prop]) {
        // 记录变更,恢复环境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });
    this.sandboxRunning = false;
  }
}

快照沙箱的类比是酒店退房清洁:客人入住前拍一张房间照片(active),客人退房后按照片恢复原样(inactive)。简单、可靠,但有一个致命限制——同一时刻只能有一个"客人"入住,因为它直接操作的是真正的 window 对象。

策略二:Proxy 单实例沙箱(Legacy Sandbox)

使用 ES6 Proxy 拦截对 window 的修改,只记录变化的属性,避免了快照沙箱遍历整个 window 的性能开销。

// qiankun src/sandbox/legacy/sandbox.ts(删减版)
export default class LegacySandbox implements SandBox {
  
  private addedPropsMapInSandbox = new Map<PropertyKey, any>();
  
  private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
  
  private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
  proxy: WindowProxy;
  sandboxRunning = true;

  constructor(name: string, globalContext = window) {
    const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
    const rawWindow = globalContext;
    const fakeWindow = Object.create(null) as Window; // 只是 Proxy 的 target 占位,不存数据

    const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => {
      if (this.sandboxRunning) {
        if (!rawWindow.hasOwnProperty(p)) {
          addedPropsMapInSandbox.set(p, value);
        } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
          // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
          modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
        }
        currentUpdatedPropsValueMap.set(p, value);
        // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
        if (sync2Window) (rawWindow as any)[p] = value;
      }
      return true;
    };

    const proxy = new Proxy(fakeWindow, {
      set: (_: Window, p: PropertyKey, value: any) => setTrap(p, value, (rawWindow as any)[p], true),

      get(_: Window, p: PropertyKey): any {
        // 防止通过 window.window / window.self / window.top 逃逸到真实 window
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') return proxy;
        const value = (rawWindow as any)[p];
        return rebindTarget2Fn(rawWindow, value); // 原生函数绑回真实 window,否则 Illegal invocation
      },

      has: (_: Window, p) => p in rawWindow,
    });
    this.proxy = proxy;
  }

  active() {
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
    }
    this.sandboxRunning = true;
  }

  inactive() {
    // restore global props to initial snapshot
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
    this.sandboxRunning = false;
  }
}

Legacy Sandbox 相比快照沙箱性能显著提升——不需要遍历整个 window,只处理实际变化的属性。但它仍然写入真正的 window(setTrap 里的 sync2Window),所以也是单实例的:多个子应用不能同时激活。 qiankun 2.x 里只有配置了 sandbox: { loose: true } 才会用它(src/sandbox/index.ts 的 createSandboxContainer),否则只要浏览器支持 Proxy 就走下面的 ProxySandbox。

策略三:Proxy 多实例沙箱(Proxy Sandbox)

这是 qiankun 当前的默认沙箱方案,也是最精妙的设计。核心思路:为每个子应用创建一个 fakeWindow,所有对 window 的读写都被代理到 fakeWindow 上。

// qiankun src/sandbox/proxySandbox.ts(删减版)
function createFakeWindow(globalContext: Window, speedy: boolean) {
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;
  // 把 window 上不可配置的属性拷到 fakeWindow——Proxy 的不变量要求:
  // target 上不存在的属性,不能被 getOwnPropertyDescriptor 报告为 non-configurable
  Object.getOwnPropertyNames(globalContext)
    .filter((p) => !Object.getOwnPropertyDescriptor(globalContext, p)?.configurable)
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(globalContext, p)!;
      const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
      // top/parent/self/window 要改成可配置可写,否则 get trap 返回 proxy 时会抛 TypeError
      if (p === 'top' || p === 'parent' || p === 'self' || p === 'window' || (p === 'document' && speedy)) {
        descriptor.configurable = true;
        if (!hasGetter) descriptor.writable = true;
      }
      if (hasGetter) propertiesWithGetter.set(p, true);
      rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
    });
  return { fakeWindow, propertiesWithGetter };
}

export default class ProxySandbox implements SandBox {
  /** window 值变更记录 */
  private updatedValueSet = new Set<PropertyKey>();
  private document = document;
  proxy: WindowProxy;
  sandboxRunning = true;

  active() { this.sandboxRunning = true; }    // 真实实现还维护一个 activeSandboxCount
  inactive() { this.sandboxRunning = false; } // 计数归零时回滚白名单变量(如 System)

  constructor(name: string, globalContext = window, opts?: { speedy: boolean }) {
    const { updatedValueSet } = this;
    const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext, !!opts?.speedy);

    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          // 真实 window 上已有的属性要沿用它的 descriptor,且只有可写的才允许覆盖
          if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
            const { writable, configurable, enumerable, set } = Object.getOwnPropertyDescriptor(globalContext, p)!;
            if (writable || set) {
              Object.defineProperty(target, p, { configurable, enumerable, writable: true, value });
            }
          } else {
            target[p] = value; // 写入 fakeWindow,而不是真正的 window
          }
          updatedValueSet.add(p);
          return true;
        }
        // 沙箱失活后的写入直接忽略:strict mode 下 set 返回 false 会抛 TypeError
        return true;
      },

      get: (target: FakeWindow, p: PropertyKey): any => {
        if (p === Symbol.unscopables) return unscopables;
        // 防止用 window.window / window.self / globalThis 逃逸到真实 window
        if (p === 'window' || p === 'self' || p === 'globalThis') return proxy;
        if (p === 'top' || p === 'parent') {
          // 主应用自己就跑在 iframe 里时,允许这两个属性逃逸
          return globalContext === globalContext.parent ? proxy : (globalContext as any)[p];
        }
        if (p === 'hasOwnProperty') return hasOwnProperty;
        if (p === 'document') return this.document;
        if (p === 'eval') return eval;

        // 优先从 fakeWindow 读,读不到回退到真实 window
        const actualTarget = propertiesWithGetter.has(p) ? globalContext : p in target ? target : globalContext;
        const value = actualTarget[p];
        if (isPropertyFrozen(actualTarget, p)) return value;
        // 非原生全局属性直接返回,省掉一次 bind
        if (!isNativeGlobalProp(p as string) && !useNativeWindowForBindingsProps.has(p)) return value;
        // fetch 等 DOM API 必须绑回原生 window,否则 "Illegal invocation"
        const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
        return rebindTarget2Fn(boundTarget, value);
      },

      has: (target: FakeWindow, p) => p in cachedGlobalObjects || p in target || p in globalContext,

      // 保证子应用里 `window instanceof Window` 成立
      getPrototypeOf: () => Reflect.getPrototypeOf(globalContext),
    });

    this.proxy = proxy;
  }
}

多实例沙箱的革命性在于:每个子应用拥有自己独立的 fakeWindow。子应用 A 写入 window.foo = 1,实际写入的是 fakeWindowA.foo,完全不影响子应用 B 的 fakeWindowB。这使得多个子应用可以真正同时运行。

策略四:iframe 天然隔离(Wujie 模式)

iframe 是浏览器提供的最彻底的隔离机制——每个 iframe 拥有完全独立的 window、document、location 对象。Wujie 的创新在于分离了 JS 执行环境和 DOM 渲染环境:JS 在与主应用同源的隐藏 iframe 中运行(全局变量天然隔离),DOM 渲染在主应用的 Shadow DOM 中(用户可见且交互正常)。

// wujie packages/wujie-core/src/iframe.ts(删减版):执行侧是一个隐藏的同源 iframe
export function iframeGenerator(sandbox: WuJie, attrs, mainHostPath: string, appHostPath: string, appRoutePath: string) {
  const iframe = window.document.createElement('iframe');
  setAttrsToElement(iframe, {
    style: 'display: none',
    ...attrs,
    name: sandbox.id,
    [WUJIE_DATA_FLAG]: '',
    // 用 srcdoc 起一个空白文档:不发请求,且 origin 继承自主应用,主应用才能 patch 它的 contentDocument
    srcdoc: SANDBOX_EMPTY_SRCDOC,
  });
  window.document.body.appendChild(iframe);

  const iframeWindow = iframe.contentWindow;
  // 变量需要提前注入,在入口函数通过变量防止死循环
  patchIframeVariable(iframeWindow, sandbox, appHostPath);
  // stopIframeLoading 等 srcdoc 文档就位后用 document.open() 把 iframe 的 URL 改写成主应用 URL,
  // 让 location/history 与主应用同源一致
  sandbox.iframeReady = stopIframeLoading(iframe, { fallbackSrc: mainHostPath }).then(() => {
    // 给 iframe 的 document/window 打上一整套 patch,DOM 操作被代理到主应用的 shadowRoot
    initIframeDom(iframeWindow, sandbox, mainHostPath, appHostPath);
    if (!isMatchSyncQueryById(iframeWindow.__WUJIE.id)) {
      iframeWindow.history.replaceState(null, '', mainHostPath + appRoutePath);
    }
  });
  return iframe;
}

// wujie packages/wujie-core/src/shadow.ts(删减版):渲染侧是一个自定义元素 + Shadow DOM
export function defineWujieWebComponent() {
  class WujieApp extends HTMLElement {
    connectedCallback(): void {
      if (this.shadowRoot) return;
      const shadowRoot = this.attachShadow({ mode: 'open' });
      const sandbox = getWujieById(this.getAttribute(WUJIE_APP_ID));
      // 给影子树里的节点补上 iframe 侧的 ownerDocument / baseURI 语义
      patchElementEffect(shadowRoot, sandbox.iframe.contentWindow);
      sandbox.shadowRoot = shadowRoot;
    }
  }
  customElements.define('wujie-app', WujieApp);
}

子应用脚本不是靠 eval 跑起来的:insertScriptToIframe 在 iframe 的 document 里 createElement('script') 再插入,脚本自然运行在 iframe 的全局作用域。另外,早期 v1.x 的 iframe 是用 src = mainHostPath 同源加载再中途 stop();v1.0.23 起默认改用 Blob URL 空白页,浏览器不支持时才回退到加载主应用 URL;v2.0 起改成了 srcdoc 空白文档,同样不必为每个子应用多请求一次主应用 HTML。

下图展示了四种沙箱策略的演进关系与隔离程度:

stateDiagram-v2
    [*] --> 快照沙箱: 最朴素方案
    快照沙箱 --> Proxy单实例沙箱: 优化性能,避免遍历window
    Proxy单实例沙箱 --> Proxy多实例沙箱: 支持多子应用同时运行
    Proxy多实例沙箱 --> iframe天然隔离: 追求更强隔离
    state 快照沙箱 {
        [*] --> 拍摄快照
        拍摄快照 --> 子应用运行
        子应用运行 --> 恢复快照
        恢复快照 --> [*]
    }
    state Proxy多实例沙箱 {
        [*] --> 创建fakeWindow
        创建fakeWindow --> 代理读写到fakeWindow
        代理读写到fakeWindow --> 回退读取rawWindow
    }
    note right of 快照沙箱: 单实例,遍历window开销大
    note right of Proxy单实例沙箱: 单实例,仍写入真实window
    note right of Proxy多实例沙箱: 多实例,每个子应用独立fakeWindow
    note right of iframe天然隔离: 全局对象隔离,独立V8上下文

2.2.3 沙箱的边界:那些隔离不了的东西

无论哪种 Proxy 沙箱方案,都存在无法完美隔离的场景:

const sandboxLimitations = {
  // 1. 原型链污染:子应用修改 Array.prototype 影响所有应用
  //    Proxy 拦截 window 属性,但不拦截原型链
  prototypePollution: 'Array.prototype.flatMap = customImpl',

  // 2. localStorage/sessionStorage:不在 Proxy 拦截范围内
  //    需要额外的 storage 隔离方案(如 namespace 前缀)
  storageConflict: 'localStorage.setItem("user_token", ...)',

  // 3. 全局事件监听器泄漏
  //    qiankun 卸载时的兜底清理只覆盖 setInterval、window 上的监听器、umi 的 g_history 监听
  //    和动态插入的 style/link/script;挂在 document / body 上的监听器、setTimeout,
  //    子应用卸载时忘记清理就会残留
  eventLeaks: 'document.addEventListener("click", handler)',

  // 4. 网络请求拦截
  //    子应用改写 XMLHttpRequest.prototype(监控 SDK 常见做法),本质是原型污染,主应用请求一并受影响
  xhrOverride: 'XMLHttpRequest.prototype.open = wrappedOpen',
};

🔥 深度洞察:完美隔离的不可能定理

在浏览器这个单进程、共享内存的环境中,实现真正完美的 JS 隔离在理论上是不可能的——除非使用 iframe(独立的 V8 上下文)。所有基于 Proxy 的沙箱方案,本质上都是在做"尽力而为的拦截"。拦截得越全面,性能开销越大,边界情况越多。这就是工程中的经典权衡:你要在"隔离程度"和"性能开销/复杂度"之间找到适合业务场景的平衡点。 对于大多数业务场景,qiankun 的 Proxy 沙箱已经够用——那些逃逸场景可以通过规范约束来规避。但如果你的场景对隔离有极致要求(比如运行不可信的第三方代码),iframe 是更安全的选择。

2.3 CSS 隔离:如何防止样式冲突

2.3.1 问题的本质

CSS 是全局的。这句话在单体应用时代已经是痛点,在微前端架构下更是噩梦。

CSS 的全局性、是前端开发者集体记忆里最深的一根刺。从 BEM 命名约定到 OOCSS 到 SMACSS、从 CSS Modules 到 CSS-in-JS、从 Atomic CSS 到 Tailwind——整个前端社区过去十年的大部分精力、都在围绕"如何驯服 CSS 的全局作用域"这件事打转。微前端时代、这个问题从"团队内部"升级到了"团队之间"——每个团队可能都有自己的命名规范、但团队之间的规范不统一;每个团队可能都用了 CSS-in-JS、但框架不同(emotion、styled-components、stitches)、产出的样式也会互相覆盖。所以 CSS 隔离在微前端里、本质上是"把 CSS 的全局语义局部化"的工程努力——要么通过浏览器原生机制(Shadow DOM)、要么通过构建期改写(CSS Modules)、要么通过运行时标签(属性选择器)、要么通过生命周期装卸(dynamic stylesheet)。四种方案、四种不同的"局部化"策略、每一种都在某些维度有优势、在另一些维度有代价——后面的小节我们会逐一拆解它们的实现原理和真实权衡。

/* 主应用的样式 */
.header { background-color: #1890ff; height: 64px; }
.btn-primary { background-color: #1890ff; border-radius: 4px; }


.header { background-color: #f5222d; height: 48px; }  /* 覆盖了主应用! */
.btn-primary { background-color: #52c41a; }            /* 覆盖了主应用! */

当子应用的 CSS 被插入到主应用的 <head> 中,由于 CSS 的层叠规则(后加载的覆盖先加载的),子应用的样式会"入侵"主应用的 UI。

2.3.2 四种隔离方案

方案一:Shadow DOM 隔离

浏览器原生提供的 CSS 隔离机制,影子树内的样式不会泄漏到外部,外部样式也不会渗透到内部。

function createShadowContainer(hostElement: HTMLElement): ShadowRoot {
  const shadowRoot = hostElement.attachShadow({ mode: 'open' });
  shadowRoot.innerHTML = `
    <style>
      /* 这些样式被 Shadow DOM 封装,不会影响外部 */
      .header { background-color: #f5222d; height: 48px; }
    </style>
    <div id="sub-app-root"></div>
  `;
  return shadowRoot;
}
// qiankun 中启用:sandbox: { strictStyleIsolation: true }
// 真实实现在 qiankun 的 src/loader.ts 的 createElement:把包裹 div 的 innerHTML 掏空,
// attachShadow({ mode: 'open' }) 之后再塞回 shadow.innerHTML

Shadow DOM 隔离最彻底,但有显著的代价:弹窗组件(Modal/Popover/Tooltip)默认挂载到 document.body,会"逃逸"出 Shadow DOM 而失去样式;事件冒泡行为不同(event.target 会被重定向为 Shadow Host);很多 UI 库假设自己运行在正常的 document 环境中。

方案二:Scoped CSS(动态样式前缀)

qiankun 的另一种方案(sandbox: { experimentalStyleIsolation: true }):为子应用的所有样式规则添加属性选择器前缀。真实实现在 src/sandbox/patchers/css.ts 的 ScopedCSS 类,走的是 CSSOM 而不是正则——把样式文本塞进一个 disabled 的隐藏 <style>,遍历 sheet.cssRules 逐条改写选择器;下面是简化的正则示意:

function scopedCSS(styleText: string, appName: string): string {
  const prefix = `div[data-qiankun="${appName}"]`;
  return styleText.replace(
    /([^{}]+)\{/g,
    (match, selector: string) => {
      if (selector.trim().startsWith('@')) return match; // 跳过 @media 等
      const scopedSelector = selector
        .split(',')
        .map(s => `${prefix} ${s.trim()}`)
        .join(', ');
      return `${scopedSelector} {`;
    }
  );
}
// .header { color: red; }
// → div[data-qiankun="order-app"] .header { color: red; }

兼容性好、对子应用几乎透明。html、body、:root 会被整体替换成前缀(ruleStyle 里的 rootSelectorRE),但 @keyframes、@font-face、@import、@page 规则原样保留不加前缀;动态插入的 <link> 外链样式不支持改写(process 里遇到 LINK 只打一句 warning);挂到 document.body 上的弹层因为不在带 data-qiankun 属性的容器里,前缀根本匹配不到。

方案三:CSS Modules / CSS-in-JS

不是微前端框架的功能,而是子应用自身的样式方案。通过构建工具将类名转为唯一 hash 值,从根本上避免命名冲突。

// CSS Modules
import styles from './OrderList.module.css';
// styles.header === 'header_a1b2c3'(唯一类名)
<div className={styles.header}>订单列表</div>

// CSS-in-JS (styled-components)
const Header = styled.div`background: red; height: 48px;`;
// 生成唯一类名:.sc-aBcDe { background: red; }

优势是不依赖微前端框架,任何环境下都能工作。但要求所有子应用严格遵守规范——一旦有人写了全局 CSS,隔离就被打破。

方案四:动态样式表(Dynamic Stylesheet)

最简单的策略:子应用挂载时加载样式表,卸载时移除。不能防止同时运行的子应用之间的冲突,但可以防止已卸载子应用的样式残留,通常作为其他方案的补充。

💡 最佳实践:最稳妥的做法是双保险——微前端框架层面开启 Scoped CSS 或 Shadow DOM(防御底线),同时要求所有子应用使用 CSS Modules 或 CSS-in-JS(源头治理)。

2.4 应用通信:如何在微应用间传递数据

2.4.1 问题的本质

在单体应用中,模块间通信轻而易举——直接 import、函数调用、共享 Redux store。但在微前端架构下,子应用之间是"隔离"的,通信变得微妙了。

通信问题在微前端里、有一个常被新手忽视的深层陷阱——你既不想让子应用"完全独立"(那样就失去协作能力)、又不想让子应用"过度耦合"(那样就失去独立演化的能力)。这和分布式系统里的"服务间通信"是同一个难题——微服务架构的核心就在于"API 契约足够稳定、但服务内部可以自由演化"。微前端的通信设计、本质上是在问一个更挑剔的问题:如何让子应用之间的"协作"看起来像本地函数调用一样简单、但底层又保证"独立部署、独立版本、独立失败"?这个问题的复杂度、随着子应用数量的增长呈平方级增长——2 个子应用有 1 条通信路径、10 个子应用有 45 条通信路径、50 个子应用就是 1225 条路径。所以微前端通信的核心设计原则永远是"最少知识原则"(Law of Demeter)——不要让子应用知道任何它不需要知道的事情。后续章节我们会看到、主流框架是如何通过 "发布订阅 + 共享状态 + 自定义事件" 这三个模式、把通信的复杂度限制在可控范围内的。

// 微前端中常见的通信场景
const communicationScenarios = {
  userContext: '用户登录后,主应用把 token、角色传递给所有子应用',
  crossAppSync: '购物车子应用添加商品,商品详情子应用需要更新状态',
  childToParent: '子应用通知主应用切换路由、显示全局 loading',
  globalState: '主题色切换、语言切换,所有子应用需要同步更新',
};

通信方案的设计需要在两个维度上做平衡:耦合度和复杂度。耦合度太高,子应用失去独立性;复杂度太高,开发体验下降。

2.4.2 五种通信模式

模式一:Props 传递(父子通信)

registerMicroApps([{
  name: 'order-app',
  entry: '//order.example.com',
  container: '#container',
  activeRule: '/order',
  props: {
    user: { name: 'Yang Yitao', role: 'admin' },
    token: 'eyJhbGciOiJIUzI1NiIs...',
    onNavigate: (path: string) => history.push(path),
    authService: {
      getToken: () => localStorage.getItem('token'),
      refreshToken: () => fetch('/api/refresh-token'),
      logout: () => { /* ... */ },
    },
  },
}]);

// 子应用接收 props
export async function mount(props: any) {
  const { user, token, onNavigate, authService } = props;
  console.log(`当前用户: ${user.name}`);
}

Props 传递的优势是显式依赖、易于调试;类型要靠主子应用约定接口自己补上(qiankun 的 props 在子应用侧就是上面的 any)。劣势是数据只在注册/挂载时由主应用单向注入,子应用想回传只能靠 props 里携带的回调(上面的 onNavigate),没有真正的双向通道。

模式二:发布订阅(全局事件总线)

经典的观察者模式,适合解耦的跨应用通信。

interface MicroFrontendEvents {
  'user:login': { userId: string; token: string };
  'cart:itemAdded': { productId: string; quantity: number };
  'theme:changed': { theme: 'light' | 'dark' };
}

class EventBus<T extends Record<string, any>> {
  private listeners = new Map<keyof T, Set<Function>>();

  on<K extends keyof T>(event: K, cb: (data: T[K]) => void): () => void {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    this.listeners.get(event)!.add(cb);
    return () => { this.listeners.get(event)?.delete(cb); }; // 返回取消函数
  }

  emit<K extends keyof T>(event: K, data: T[K]): void {
    this.listeners.get(event)?.forEach(cb => cb(data));
  }
}

// 子应用 A(购物车):
eventBus.emit('cart:itemAdded', { productId: 'SKU001', quantity: 2 });
// 子应用 B(商品详情):
const unsubscribe = eventBus.on('cart:itemAdded', ({ productId }) => {
  updateCartStatus(productId, true);
});

模式三:全局状态(qiankun initGlobalState)

qiankun 内置的通信方案:简化版的发布订阅 + 状态容器。比纯事件总线多了"状态"概念——新订阅者可获取当前状态快照。

import { initGlobalState } from 'qiankun';

const actions = initGlobalState({ user: null, theme: 'light', locale: 'zh-CN' });
actions.onGlobalStateChange((newState, prevState) => {
  console.log('全局状态变化:', newState, prevState);
});
actions.setGlobalState({ theme: 'dark' });

注意:qiankun 2.10.16 在开发环境调用 initGlobalState 会打印一条警告——globalState 相关 API 将在 3.0 移除(src/globalState.ts:23-25),长期方案要把这一点算进去(第 6 章展开)。

模式四:URL 参数通信

利用 URL 作为通信媒介。天然可持久化、可分享,不需要框架支持。适合页面级导航数据。

模式五:共享运行时状态(Module Federation)

Module Federation 的独特优势:多个远程应用共享同一个 Zustand/Redux 实例。

// shared-store 应用暴露全局 store
new ModuleFederationPlugin({
  name: 'sharedStore',
  exposes: { './store': './src/store' },
  shared: { zustand: { singleton: true } },
});

// 任何消费此远程模块的应用,都共享同一个 store 实例
import { useGlobalStore } from 'sharedStore/store';
function OrderPage() {
  const user = useGlobalStore((s) => s.user);
  return <div>欢迎, {user?.name}</div>;
}

2.4.3 通信模式的选型

通信模式 适用场景 耦合度 复杂度 实时性
Props 传递 主应用 → 子应用的配置数据 中 低 初始化时
事件总线 子应用之间的解耦通信 低 中 实时
全局状态 需要常驻当前值 + 多方订阅的共享数据 中 中 实时
URL 参数 页面级导航数据、可分享的状态 低 低 路由变化时
共享 Store Module Federation 下的深度状态共享 高 高 实时

🔥 深度洞察:通信的"最少知识原则"

微前端通信方案的选择,应该遵循一个核心原则:每个子应用应该只知道它需要知道的最少信息。 Props 传递最显式——子应用明确声明"我需要 user 和 token";事件总线中等显式——子应用声明"我关心 cart:itemAdded 事件";共享 Store 最隐式——子应用可以读取全局状态中的任何字段。当你发现多个子应用频繁共享大量状态时,也许应该反思:它们是否真的应该是独立的子应用?

2.5 路由管理:如何统一管理子应用的路由

2.5.1 问题的本质

浏览器只有一个地址栏,只有一个 history 对象。但在微前端架构中,主应用和每个子应用可能都有自己的路由系统。谁来控制 URL? 用户从 /order/list 导航到 /product/123 时,主应用需要卸载订单子应用、加载商品子应用——但子应用的 React Router 也在监听路由变化,谁先响应?

路由冲突的本质、是"一个资源、多个消费者"的经典并发问题的前端变体——浏览器的 history 对象就是那个被多方争夺的稀缺资源。微前端的路由方案、最终都要回答同一个问题:如何让多个独立的路由系统、在同一个 history 对象上"和平共处"?答案往往是某种形式的"分层路由"——顶层由框架(主应用)负责"选哪个子应用"、子应用内部负责"选哪个页面"、页面内部负责"选哪个视图状态"。这种分层和网络七层模型、操作系统的进程调度分层、甚至城市的行政分级(国家-省-市-区)是同一种认知结构。理解了"分层"这个元模式、你就不会被微前端路由的各种实现细节搞晕——它们万变不离其宗、都是在同一个思想下做出的具体工程选择。

2.5.2 路由拦截机制

所有路由驱动的微前端方案都使用同一个核心技术:路由劫持(Route Hijacking)。

// single-spa src/navigation/navigation-events.js(删减版)
function patchedUpdateState(updateState, methodName) {
  return function () {
    const urlBefore = window.location.href;
    const result = updateState.apply(this, arguments);
    const urlAfter = window.location.href;
    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      // 浏览器不会为 pushState/replaceState 派发 popstate,single-spa 自己造一个,
      // 事件上带 singleSpa=true / singleSpaTrigger='pushState',供子应用识别
      window.dispatchEvent(createPopStateEvent(window.history.state, methodName));
    }
    return result;
  };
}

function urlReroute() {
  reroute([], arguments);
}

export function patchHistoryApi(opts) {
  // 任何路由事件都触发一次应用变更计算
  window.addEventListener('hashchange', urlReroute);
  window.addEventListener('popstate', urlReroute);
  // 同时劫持 addEventListener:子应用注册的 popstate/hashchange 监听先被截留进 capturedEventListeners,
  // 等旧应用卸载完成之后再统一回放(callAllEventListeners)
  window.addEventListener = function (eventName, fn) { /* ... */ };
  window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState');
  window.history.replaceState = patchedUpdateState(window.history.replaceState, 'replaceState');
}

// src/navigation/reroute.js:微前端的路由调度中枢(大幅删减)
export function reroute(pendingPromises = [], eventArguments) {
  if (appChangeUnderway) {
    // 上一轮切换还没结束:本次排队,等它完成后再重新计算
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({ resolve, reject, eventArguments });
    });
  }
  const { appsToUnload, appsToUnmount, appsToLoad, appsToMount } = getAppChanges();
  if (isStarted()) {
    appChangeUnderway = true;
    return performAppChanges(); // 卸载旧应用的同时 load + bootstrap 新应用;卸载全部完成后回放截留的路由事件,并 mount 新应用
  } else {
    return loadApps(); // start() 之前只预加载,不挂载
  }
}

下图展示了微前端路由拦截的完整时序:

sequenceDiagram
    participant User as 用户
    participant Browser as 浏览器
    participant Hijack as 路由劫持层
    participant Framework as 微前端框架(reroute)
    participant OldApp as 旧子应用
    participant NewApp as 新子应用
    User->>Browser: 点击导航链接
    Browser->>Hijack: history.pushState()
    Hijack->>Hijack: 调用原始pushState, 派发人造popstate
    Hijack->>Framework: popstate监听触发reroute()
    Framework->>Framework: getAppChanges()
    Framework->>OldApp: unmount()
    Note over Framework,NewApp: 实际 load/bootstrap 与 unmount 并行,mount 要等 unmount 全部完成
    OldApp-->>Framework: unmount完成
    Framework->>NewApp: load() + bootstrap()
    NewApp-->>Framework: 加载完成
    Framework->>NewApp: mount()
    NewApp-->>Browser: 渲染子应用DOM
    Browser-->>User: 展示新页面(无刷新)

2.5.3 三层路由模型

理解微前端路由管理,关键是区分三个层次:

层次 职责 管理者 示例
第一层:主应用路由 决定哪个子应用激活 微前端框架 /order/* → order-app
第二层:子应用路由 子应用内部页面切换 React Router / Vue Router /order/list → OrderList
第三层:内部路由 局部状态(Tab 切换等) 子应用自己 不反映在 URL 中
// 正确设置子应用路由的 basename
function OrderApp(props: { basename: string }) {
  return (
    <BrowserRouter basename={props.basename || '/order'}>
      <Routes>
        <Route path="/list" element={<OrderList />} />
        <Route path="/detail/:id" element={<OrderDetail />} />
      </Routes>
    </BrowserRouter>
  );
}

Module Federation 的路由管理截然不同——远程组件就是普通的 React 组件,路由完全由 Host 应用统一管理。

// Module Federation 中,路由完全由 Host 管理
const OrderList = React.lazy(() => import('orderApp/OrderList'));
const ProductPage = React.lazy(() => import('productApp/ProductPage'));

function ShellApp() {
  return (
    <BrowserRouter>
      <Suspense fallback={<Loading />}>
        <Routes>
          {}
          <Route path="/order/list" element={<OrderList />} />
          <Route path="/product/:id" element={<ProductPage />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}
// 只有一套路由系统,没有路由冲突问题
// 但远程应用失去了路由自治权,必须由 Host 统一管理

💡 最佳实践:在 qiankun/single-spa 架构下,主应用只管"哪个子应用"这一层(/order/*),子应用自己管内部页面。千万不要让主应用"深入"管理子应用的内部路由——那会重新引入耦合。

2.6 依赖共享:如何避免重复加载公共库

依赖共享是六大问题里最"性价比导向"的一个——其他五个问题解决不好、系统可能直接用不了;但依赖共享解决不好、系统只是"慢"和"重"。但 2026 年的互联网用户、对"慢"和"重"的容忍度、已经比十年前低得多——据 Google 2016 年发布的移动端速度研究(《The Need for Mobile Speed》),53% 的移动端访问会在页面加载超过 3 秒时被放弃。所以依赖共享虽然不是"生存问题"、却是"用户体验的生死线"。微前端的依赖共享面对的根本矛盾是——你希望"共享"来节省加载量、但每个团队可能需要"不同版本的共享依赖"。这个矛盾不可能完美解决、只能在"版本自由、体积最优、运行时兼容"这三个维度中做取舍。

2.6.1 问题的本质

5 个子应用都使用 React 18 和 Ant Design 5。如果每个子应用都打包一份完整依赖,这两个库就要被下载、解析 5 次,而且会有 5 个 React 实例——Context 不共享、Hooks 无法跨应用调用。

但依赖共享远不是"把 React 抽出来"这么简单。核心挑战在于版本冲突:

const versionConflict = {
  'order-app':     { react: '18.2.0', antd: '5.10.0' },
  'product-app':   { react: '18.3.0', antd: '5.12.0' },
  'user-app':      { react: '18.2.0', antd: '4.24.0' },  // 还在用 antd 4
  'marketing-app': { react: '17.0.2', antd: '5.8.0' },   // 还在用 React 17
};
// React 18.2 和 18.3 可以共享吗?(小版本差异,通常可以)
// antd 4 和 antd 5 能共享吗?(大版本不兼容,不能)
// React 17 和 React 18 能共享吗?(有 breaking changes,风险较高)

2.6.2 三种共享策略

策略一:Externals(全局 CDN 加载)

将公共依赖从打包产物中排除,通过 CDN 全局加载。所有子应用被迫使用同一个版本——简单但不灵活。

// webpack.config.js
module.exports = {
  externals: { react: 'React', 'react-dom': 'ReactDOM' },
};
// 主应用 HTML 中加载 CDN 脚本,所有子应用通过 window.React 访问

策略二:Module Federation Shared(运行时版本协商)

Module Federation 的 shared 配置实现了运行时版本协商:

new ModuleFederationPlugin({
  shared: {
    react: {
      singleton: true,            // 只允许一个实例
      requiredVersion: '^18.0.0', // 需要 18.x 版本
      eager: false,               // 异步加载
    },
    antd: {
      requiredVersion: '^5.0.0',  // 不设 singleton,允许多版本共存
    },
  },
});

// 运行时版本协商的工作原理(以 @module-federation/enhanced 的运行时为准):
// 1. 每个应用在 shared scope 中注册自己的依赖版本
// 2. 每个消费方按自己的 requiredVersion 各自挑选,没有一个替所有人拍板的协调者
// 3. singleton 模式:只用一个版本(即使版本不完全匹配也会复用,默认只打警告,strictVersion: true 时报错)
// 4. 非 singleton:已注册的最高版本满足 requiredVersion 就用它,否则按注册顺序找第一个满足的
//    (webpack 内置 ModuleFederationPlugin 的非 singleton 分支是直接取满足条件的最高版本,见第 10 章)

不是构建时决定用哪个版本,而是页面加载时根据所有已注册版本和消费者需求动态决定——这是它相比 Externals 的本质优势。这段协商逻辑在 module-federation/core 的 packages/runtime-core/src/utils/share.ts:354 的 getRegisteredShare 里:它先用 satisfy 校验选出的最高版本,非 singleton 且不满足时再逐个 satisfy(versionKey, requiredVersion) 比对已注册的版本,singleton 与非 singleton 走的是同一个函数的不同分支;比对用的 semver 实现是运行时包自带的(packages/runtime-core/src/utils/semver/),不依赖外部 semver 包。

策略三:Import Maps(浏览器原生模块解析)

Import Maps 已进入 HTML 标准,主流浏览器自 2023 年起全部支持,它允许在运行时控制 ES Module 的解析路径。

<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@18.2.0",
    "react-dom": "https://esm.sh/react-dom@18.2.0",
    "react-dom/client": "https://esm.sh/react-dom@18.2.0/client"
  }
}
</script>
// 子应用的 ES Module 代码
import React from 'react';           // → 解析为 https://esm.sh/react@18.2.0
import ReactDOM from 'react-dom';    // → 解析为 https://esm.sh/react-dom@18.2.0

浏览器原生支持、无需框架,但只支持 ES Module(不支持 UMD/CommonJS)、模块开始解析后再动态追加 Import Map 的能力各浏览器落地进度不一、缺乏版本协商机制(需要手动管理版本)。

2.6.3 依赖分类的最佳实践

const dependencyCategories = {
  mustShare:    'React、ReactDOM —— 多实例导致 Hooks 崩溃,必须 singleton',
  shouldShare:  '大型 UI 库(antd、element-ui)—— 减少体积',
  avoidSharing: '业务工具库 —— 版本敏感,让子应用自己管理',
};

🔥 深度洞察:依赖共享的"不可能三角"

微前端的依赖共享面临一个类似 CAP 定理的"不可能三角":版本自由(每个子应用用自己的版本)、体积最优(不重复加载)、运行时兼容(共享 Context/Hooks)——三者最多只能同时满足两个。Externals 牺牲版本自由来获得体积和兼容性;完全不共享牺牲体积和兼容性来获得版本自由;Module Federation 的 shared 配置是目前最接近"三者兼顾"的方案,但它通过复杂的运行时协商来实现,本身也引入了额外的复杂度。

2.7 六大问题的解决策略谱系

下图展示了各微前端方案在六大问题域上的策略选择:

flowchart LR
    subgraph 隔离优先["隔离优先方案"]
        Wujie["Wujie<br/>iframe天然隔离<br/>Shadow DOM渲染"]
        QK["qiankun<br/>Proxy沙箱<br/>Scoped CSS"]
    end
    subgraph 平衡方案["平衡方案"]
        SSpa["single-spa<br/>路由驱动<br/>隔离交给生态"]
        WC["Web Components<br/>Shadow DOM<br/>浏览器标准"]
    end
    subgraph 共享优先["共享优先方案"]
        MF["Module Federation<br/>构建期声明·运行期协商<br/>无 JS/CSS 隔离"]
    end
    Wujie -->|降低隔离| QK
    QK -->|降低隔离| SSpa
    SSpa -->|降低隔离| WC
    WC -->|降低隔离| MF
    style 隔离优先 fill:#ffebee,stroke:#c62828
    style 平衡方案 fill:#fff3e0,stroke:#e65100
    style 共享优先 fill:#e8f5e9,stroke:#2e7d32

2.7.1 方案横向对比

问题域 qiankun single-spa Module Federation Wujie Web Components
应用加载 HTML Entry(解析 HTML) JS Entry(加载 JS 入口) 编译时声明+运行时加载 iframe+WebComponent Custom Element
JS 隔离 Proxy 沙箱(三代演进) 无内置(需自行实现) 无隔离(共享运行时) iframe 天然隔离 无 JS 隔离
CSS 隔离 Shadow DOM / Scoped CSS 无内置 无内置(依赖子应用) Shadow DOM + iframe Shadow DOM 原生
应用通信 initGlobalState + Props 自定义事件/共享模块 共享模块/Store 实例 Props+事件+window.parent Custom Events
路由管理 基于 single-spa 劫持 pushState/popstate 劫持 Host 统一管理 iframe location 同步 无内置
依赖共享 Externals(手动配置) Externals / SystemJS shared 运行时协商 不共享(天然隔离) 无内置

2.7.2 方案的"性格画像"

const solutionProfiles = {
  qiankun: {
    philosophy: '用运行时 hack 解决一切问题',
    bestFor: '存量项目改造,技术栈异构的团队',
    metaphor: '万能翻译官——让不同语言的人在同一房间交流,但翻译总有不精确的时候',
  },
  singleSpa: {
    philosophy: '只做路由调度,其余交给生态',
    bestFor: '技术能力强、需要深度定制的团队',
    metaphor: '交通信号灯系统——只管红绿灯,道路和车辆不归它管',
  },
  moduleFederation: {
    philosophy: '构建期声明共享、运行期协商版本',
    bestFor: '同一技术栈的团队,追求极致开发体验',
    metaphor: '自由贸易区——各国之间商品自由流通,没有海关检查(沙箱)',
  },
  wujie: {
    philosophy: '用 iframe 的隔离 + WebComponent 的渲染',
    bestFor: '对 JS/CSS 隔离要求极高的场景',
    metaphor: '写字楼的独立办公室——每个公司有自己的空间,但共享大堂和电梯',
  },
  webComponents: {
    philosophy: '浏览器标准就是最好的方案',
    bestFor: '组件级共享(设计系统),长期维护的基础设施',
    metaphor: 'USB 接口标准——任何设备都能插,但功能取决于具体设备',
  },
};

2.7.3 隔离性与灵活性的光谱

六大问题的解决方案可以放在一个"隔离性—灵活性"的光谱上:

隔离性强 ←────────────────────────────────→ 灵活性强

  iframe     Proxy沙箱    Shadow DOM    Scoped CSS    CSS Modules    无隔离
  (Wujie)    (qiankun)    (qiankun)     (qiankun)    (各子应用)    (Module Fed)

  完全隔离     较强隔离      DOM/CSS隔离    CSS前缀隔离   构建时隔离   无隔离
  性能开销大   性能开销中     兼容性一般     兼容性好      需要约定     性能最优
  通信困难     通信可控      通信不影响     通信不影响    通信不影响   通信自由

这不是一个"哪个更好"的排序,而是根据业务需求选择合适位置的光谱。运行不可信的第三方代码?选最左侧的 iframe,并且要与主应用跨域(Wujie 的 iframe 与主应用同源,不构成安全边界)。同一团队的不同模块?选右侧(强灵活性)。

🔥 深度洞察:六大问题的统一视角

回顾本章讨论的六大问题——应用加载、JS 隔离、CSS 隔离、应用通信、路由管理、依赖共享——你会发现它们本质上都在回答同一个问题:在"隔离"和"共享"之间如何找到平衡? 加载是"如何获取别人的代码"(共享的起点);JS 隔离和 CSS 隔离是"如何防止别人的代码影响我"(隔离的手段);通信是"如何主动和别人交换信息"(受控的共享);路由管理是"如何共享浏览器的唯一 URL"(不可避免的共享);依赖共享是"如何共享公共代码以减少浪费"(效率驱动的共享)。微前端架构的核心张力,就是这对永恒的矛盾——隔离与共享。 理解这一点,你在面对任何新方案时,都能迅速抓住它的本质。

本章小结

  • 应用加载有三种模式:JS Entry(轻量直接)、HTML Entry(对子应用友好)、Module Federation(模块级粒度),加载粒度从粗到细
  • JS 隔离从快照沙箱到 Proxy 沙箱再到 iframe 隔离,隔离程度递增但性能和通信代价也在增加;完美的 JS 隔离在浏览器单进程环境中是不可能的
  • CSS 隔离的四种方案——Shadow DOM、Scoped CSS、CSS Modules、Dynamic Stylesheet——各有适用场景,最佳实践是"框架隔离 + 源头治理"双保险
  • 应用通信遵循"最少知识原则":每个子应用应该只知道它需要知道的最少信息
  • 路由管理的核心是三层路由模型:框架层(哪个子应用)、子应用层(哪个页面)、内部层(局部状态)
  • 依赖共享面临"不可能三角":版本自由、体积最优、运行时兼容不可兼得
  • 六大问题的本质是同一对矛盾——隔离与共享的永恒张力

六大问题不是孤立的任务清单,而是互相牵制的系统——任何一个维度的选择都会约束其他维度的可选范围。例如:选 iframe 做 JS 隔离,CSS 隔离几乎免费得到,但应用通信会变得很复杂;选 Module Federation 做依赖共享,加载变成模块级,但 JS 隔离基本被放弃;选 Shadow DOM 做 CSS 隔离,全局主题切换会变得棘手。后续章节进入 qiankun、single-spa、Module Federation 时,可以带着这张"取舍地图"去看每个框架的实现决策。

思考题

  1. 架构分析:本章将微前端的六大问题归结为"隔离与共享的永恒张力"。请选择一个你熟悉的微前端方案(qiankun、Module Federation 或 Wujie),分析它在这六个问题上分别偏向"隔离"还是"共享",并解释这种选择背后的设计哲学。

  2. 方案设计:假设你需要为一个金融行业的企业后台设计微前端架构——3 个团队,其中一个团队维护的是 5 年前用 jQuery 写的老系统。你会选择哪种应用加载模式?哪种 JS 隔离方案?请详细说明你的技术选型和理由。

  3. 深度思考:本章提到"完美的 JS 隔离在浏览器中是不可能的"。请列举至少 3 种 Proxy 沙箱无法拦截的场景,并为每种场景提出一个工程上的规避方案。

  4. 前瞻性讨论:随着浏览器标准的演进(Import Maps、Declarative Shadow DOM、Web Components),你认为未来 3-5 年内,本章讨论的六大问题中,哪些会被浏览器原生能力解决?哪些仍然需要框架层面的方案?请说明你的判断依据。