陌上人如玉
公子世无双

10.10 微前端落地

10.10 微前端落地

一句话:微前端解决的是「多个团队/多套技术栈的应用要合成一个站点」的组织问题,而不是技术问题——它的真正难点全在「隔离(JS 沙箱 + 样式隔离) 与 共享(依赖、状态、路由)」这两件事上,而「依赖共享(尤其是 React 版本)」是最容易翻车的地方。

一、先判断:你到底需不需要微前端

⭐⭐⭐ 「该不该上微前端」的判据(不满足就不要上):

✅ 满足以下 2 条以上,才值得考虑:
   ① 【多个团队】各自独立开发/发布,且**发布节奏差异大**(不能等彼此)
   ② 【需要渐进迁移】老系统还在跑(jQuery/Backbone/老 Angular),要**一块块替换**
   ③ 【技术栈异构】不同团队用不同框架(React 18 / Vue 3 / React 19),短期无法统一
   ④ 【独立部署要求强】某个模块需要「随时热修」而不影响其他模块

❌ 以下情况「不该」上微前端(是常见反模式):
   ① 「一个团队、一个技术栈、一个发布节奏」→ 用 monorepo + 模块化就够了(见 10.9)
   ② 「只是想让代码「看起来分开」」→ 用目录/包边界 + lint 约束
   ③ 「为了『解耦』而上」→ ⭐ 微前端**增加**了复杂度(沙箱/通信/隔离/版本),
      只有收益(团队自治/独立部署)大到能覆盖它时才划算
⭐⭐ 微前端的「真实代价」清单(上之前先算这笔账):

   · JS 沙箱:要拦截全局变量污染(且不可能 100% 完美)
   · 样式隔离:全局 CSS 冲突(Shadow DOM 有兼容/生态成本)
   · 依赖共享:⭐ **React 版本不一致会直接崩**(两份 React → Hooks 报错)
   · 通信:跨应用的状态同步(事件总线 / props / 共享 store)
   · 路由:主应用与子应用的路由都要能工作(且前进/后退正确)
   · 部署:多个应用独立部署时的「版本兼容矩阵」(基座升级了子应用能不能不升?)
   · 调试:跨应用断点、跨应用 source map、跨应用埋点
   ⭐ 结论:微前端是「**组织架构的映射**」——如果组织没变,收益就有限。

二、技术方案全景:五种隔离强度

方案 隔离强度 集成度 依赖共享 适合
iframe ⭐⭐⭐⭐⭐(最强:JS/CSS/DOM 全隔离) ⚠️ 最差(路由/弹窗/尺寸/通信都难) ❌ 无法共享(各装各的) 老系统嵌入、第三方页面、强隔离要求
Web Components(Shadow DOM) ⭐⭐⭐⭐(样式/DOM 隔离好,JS 仍共享同一 Window) ⭐⭐ 中 ⭐ 可共享 需要「组件级」隔离、跨框架组件
Module Federation(Rspack/webpack 5) ⭐⭐(模块级隔离,运行时共享依赖) ⭐⭐⭐⭐ 好 ⭐⭐⭐⭐ 运行时共享 同一构建体系下的多应用组合
基座式框架(qiankun / single-spa / wujie) ⭐⭐⭐(HTML entry + 沙箱 + 样式隔离) ⭐⭐⭐ 好 ⚠️ 需配置(externals) ⭐ 老系统渐进迁移、异构技术栈
原生 ESM + import maps ⭐(几乎无隔离) ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 天然共享(同一份 URL) 新项目、统一的现代工具链
⭐⭐⭐ 五种方案的「本质区别」用一句话说清:

   · **iframe**:把子应用装进「另一个浏览器上下文」→ 隔离最强、集成最差
   · **Web Components**:把子应用封进「Shadow DOM」→ 隔样式与 DOM,**但 JS 仍在同一个 window**
   · **Module Federation**:把子应用当「远程模块」加载 → **构建期/运行时的模块共享**,
     ⚠️ 隔离很弱(同一个 window、同样的全局样式)
   · **qiankun 类**:把子应用当「HTML 入口」加载 → **用沙箱做 JS 隔离、用包装做样式隔离**
     ⭐ 是「隔离」与「集成」的折中(所以它最流行)
   · **ESM + import maps**:子应用就是一个「远程 ES 模块」→ 无需构建器集成,
     ⚠️ 几乎没有隔离(全局污染/样式冲突要自己解决)

⭐ Module Federation 与 qiankun 的「派别差异」:
     · MF 偏向「**运行时共享 + 构建体系统一**」(同一个 webpack/rspack 生态)
     · qiankun 偏向「**运行时隔离 + 技术栈无关**」(子应用可以是任何框架/构建器)

三、三个关键技术难点

3.1 JS 沙箱:拦截全局污染

⭐⭐⭐ 沙箱要解决的核心问题:

   子应用 A 执行 `window.$ = jQuery`、`window.__INITIAL_STATE__ = {...}`,
   子应用 B 也这么干 → **互相覆盖**;子应用卸载后「污染还留在 window 上」。

⭐ 三种沙箱强度:

   ① 【快照沙箱(Snapshot Sandbox)】
      激活时:遍历 window 所有属性,存一份快照
      运行中:不拦截(子应用直接改 window)
      卸载时:**对比快照,把「新增的属性」删掉、「修改的属性」恢复**
      ✅ 实现简单、兼容性最好
      ⚠️ ⚠️ 致命缺点:**同一时间只能有一个子应用运行**(无法「A 在后台挂着、B 在前台跑」)

   ② 【Proxy 沙箱(Proxy Sandbox)】⭐ 主流做法
      给每个子应用一个「假的 window」(Proxy):
      · `get` → 优先读「子应用自己的记录」,没有再读真 window
      · `set` → 写到「子应用自己的记录」(不写真的 window)
      卸载时:直接丢弃这个假 window 即可
      ✅ **支持「多个子应用同时运行」**(各自有独立作用域)
      ⚠️ 无法拦截「绕过 window 的全局访问」(如 `eval`、`document.cookie`、`top`)

   ③ 【iframe 沙箱】⭐ 隔离最强
      在 iframe 里执行子应用的 JS(原生 `window` 就是隔离的)
      ✅ 真正的隔离;⚠️ 性能与通信成本(跨 iframe 传对象要序列化)
      ⭐ 现代实现(如 wujie)的做法是「**JS 在 iframe 里跑,DOM 渲染到主文档**」
        —— 兼顾「强隔离」与「无 iframe 布局问题」

⚠️ **沙箱永远不是「100% 安全」**:
   它能防「意外的全局污染」,防不了「恶意代码」(子应用可以
   通过 `document.cookie`、`localStorage`、`Function('return this')()`
   拿到真 window)。⭐ 微前端是「团队间的协作约定」,不是「安全边界」。
// ✅ 一个可用的 Proxy 沙箱(简化版,思路与 qiankun 的 proxySandbox 一致)
class ProxySandbox {
  constructor(appName) {
    this.appName = appName;
    this.running = false;
    this.modifiedProps = new Map();          // 记录「被改过的全局属性」,卸载时恢复
    this.addedProps = new Set();             // 记录「新增的属性」,卸载时删除

    const rawWindow = window;
    const fakeWindow = Object.create(null);  // ⭐ 子应用自己的「全局命名空间」

    this.proxy = new Proxy(fakeWindow, {
      set: (_target, key, value) => {
        if (!this.running) {
          // ⭐ 未运行时不允许写(避免「已卸载的子应用改全局」)
          console.warn(`[sandbox:${this.appName}] 沙箱未激活,忽略对 ${String(key)} 的写入`);
          return true;
        }
        // ⭐ 不污染真 window:只写 fakeWindow
        fakeWindow[key] = value;
        return true;
      },

      get: (_target, key) => {
        // ⭐ 优先读「子应用自己的」
        if (key in fakeWindow) return fakeWindow[key];

        // ⭐ 兜底读真 window(这样子应用仍能用 `setTimeout`/`document` 等原生 API)
        const value = rawWindow[key];

        // ⚠️ 关键:如果取到的是「全局对象」(window/self/globalThis),
        //    要返回代理对象,否则子应用 `window.xxx = 1` 就绕过了沙箱
        if (value === rawWindow || value === rawWindow.window || value === rawWindow.self || value === rawWindow.globalThis) {
          return this.proxy;
        }

        // ⭐ 函数要「绑定到真 window」,否则 `window.addEventListener` 会报 Illegal invocation
        if (typeof value === 'function' && !value.prototype) {
          return value.bind(rawWindow);
        }
        return value;
      },

      has: (_target, key) => key in fakeWindow || key in rawWindow,

      deleteProperty: (_target, key) => {
        if (this.running) delete fakeWindow[key];
        return true;
      },

      // ⚠️ 注意:不做 ownKeys 代理(让 `Object.keys(window)` 看起来正常)
      //    但这也意味着 for...in 遍历会看到真 window 的属性
    });

    this.fakeWindow = fakeWindow;
  }

  activate() { this.running = true; }
  deactivate() { this.running = false; }

  /** ⭐ 卸载时清理:这是「快照沙箱」的做法(本实现里也要显式恢复) */
  destroy() {
    this.running = false;
    // 把「显式挂到真 window 上的」(通过非沙箱路径)恢复
    this.modifiedProps.forEach((oldValue, key) => {
      try { window[key] = oldValue; } catch { /* 只读属性 */ }
    });
    this.addedProps.forEach((key) => {
      try { delete window[key]; } catch { /* 忽略 */ }
    });
    this.modifiedProps.clear();
    this.addedProps.clear();
  }
}

3.2 样式隔离:三种做法与代价

⭐⭐⭐ 样式隔离的三种做法:

① 【Shadow DOM(最强隔离)】
   const shadow = host.attachShadow({ mode: 'open' });
   shadow.appendChild(appRoot);
   ✅ 完全的样式隔离(外部样式进不来,内部样式出不去)
   ⚠️ 代价:
     · 「全局样式(如 reset/主题变量)要显式注入到 shadow 里」
     · 「弹窗/浮层要挂到 shadow 内」(否则又出去了,见 1.12)
     · 「部分第三方组件的样式注入方式(document.head)进不了 shadow」
     · 「事件重定向、焦点管理、`assignedElements` 等有学习成本」

② 【运行时给选择器加前缀(qiankun 的 experimentalStyleIsolation)】
   把子应用的 style 内容用正则改写成 `.app-a .foo { ... }`
   ✅ 实现简单、无侵入
   ⚠️ 脆弱:正则处理 CSS 很容易出问题(@media/@supports/`:root`/动态插入的样式)
   ⭐ 更稳的做法:让子应用「自己用 CSS Modules / BEM 前缀 / CSS-in-JS 命名空间」

③ 【动态 style 标签 + 卸载时移除(最常用)】
   子应用的所有 `<style>` 由框架「接管」:加载时插入、卸载时移除
   ✅ 简单、能解决「卸载后样式残留」这个最常见问题
   ⚠️ 不能解决「同时运行时互相污染」(子应用仍然共享 document 的 CSS 空间)

⭐ 实践组合(多数项目的真实选择):
   ③【动态 style 标签】解决「卸载残留」 + 「CSS 命名前缀约定」解决「同时运行冲突」
   → 比 ② 可靠,比 ① 便宜
// ✅ 动态 style 管理 + 「简单前缀」(用于「同时运行」的场景)
class StyleScope {
  constructor(appName, prefix) {
    this.appName = appName;
    this.prefix = prefix;                  // 如 '[data-app="app-a"]'
    this.styleEls = [];
    this.headObserver = null;
  }

  /** ⭐ 启动时监听 document.head,把「子应用插入的 style」都记下来 */
  start() {
    this.headObserver = new MutationObserver((records) => {
      for (const r of records) {
        r.addedNodes.forEach((node) => {
          if (node.nodeName === 'STYLE') {
            this.styleEls.push(node);
            this.rewrite(node);            // ⚠️ 可选:加前缀
          }
          if (node.nodeName === 'LINK' && node.rel === 'stylesheet') {
            // ⭐ 外链样式也标记(卸载时移除)
            node.dataset.microApp = this.appName;
          }
        });
      }
    });
    this.headObserver.observe(document.head, { childList: true });
  }

  /** ⭐ 给 CSS 内容加前缀(正则版,⚠️ 有边界情况,生产建议用 postcss) */
  rewrite(styleEl) {
    if (!this.prefix || styleEl.textContent == null) return;
    try {
      styleEl.textContent = prefixCss(styleEl.textContent, this.prefix);
    } catch (err) {
      console.warn(`[style:${this.appName}] 加前缀失败,跳过`, err);
    }
  }

  /** ⭐ 卸载:移除所有「这个子应用插入的」样式 */
  stop() {
    this.headObserver?.disconnect();
    this.styleEls.forEach((el) => el.remove());
    this.styleEls = [];
    document.querySelectorAll(`link[data-micro-app="${this.appName}"]`).forEach((el) => el.remove());
  }
}

/** ⚠️ 一个「最小可用」的 CSS 前缀器(真实项目请用 PostCSS 插件) */
function prefixCss(css, prefix) {
  // ⭐ 用「逐块解析」而不是「全局正则替换」,避免改坏字符串/url 里的内容
  let out = '';
  let i = 0;

  while (i < css.length) {
    // 跳过注释
    if (css.startsWith('/*', i)) {
      const end = css.indexOf('*/', i + 2);
      const stop = end === -1 ? css.length : end + 2;
      out += css.slice(i, stop);
      i = stop;
      continue;
    }

    // 找到「选择器 + {」的区域
    const braceIdx = css.indexOf('{', i);
    if (braceIdx === -1) { out += css.slice(i); break; }

    const selectorPart = css.slice(i, braceIdx).trim();

    // ⭐ @media/@supports/@keyframes 这类「at-rule」不加前缀(内部递归处理)
    if (selectorPart.startsWith('@')) {
      // 找到配对的右花括号
      let depth = 0;
      let j = braceIdx;
      for (; j < css.length; j++) {
        if (css[j] === '{') depth++;
        else if (css[j] === '}') { depth--; if (depth === 0) { j++; break; } }
      }
      const inner = css.slice(braceIdx + 1, j - 1);
      const isKeyframes = /@(-\w+-)?keyframes/.test(selectorPart);
      // ⭐ keyframes 内部是「关键帧百分比」,不能再加前缀
      out += `${selectorPart} {${isKeyframes ? inner : prefixCss(inner, prefix)}}`;
      i = j;
      continue;
    }

    // 普通规则:给每个选择器加前缀
    const prefixed = selectorPart
      .split(',')
      .map((sel) => sel.trim())
      .filter(Boolean)
      .map((sel) => {
        // ⭐ :root / html / body 这类「根选择器」要单独处理(映射到作用域容器)
        if (/^(html|body|:root)$/.test(sel)) return prefix;
        return `${prefix} ${sel}`;
      })
      .join(', ');

    const closeIdx = css.indexOf('}', braceIdx + 1);
    const body = closeIdx === -1 ? css.slice(braceIdx) : css.slice(braceIdx, closeIdx + 1);
    out += `${prefixed} ${body}`;
    i = closeIdx === -1 ? css.length : closeIdx + 1;
  }

  return out;
}

3.3 依赖共享:React 版本是硬骨头

⭐⭐⭐⭐ 「两份 React」是微前端最经典的事故(后果是 `Invalid Hook call`):

   场景:主应用用 React 19,子应用用 React 18 且**把自己的 React 打进产物**。
   → 页面上有两份 React 运行时
   → 子应用组件里的 `useState` 调用的是「18 的 dispatcher」,
     而渲染它的是「19 的 reconciler」→ ⭐ **dispatcher 是 null → 报错**

⭐ 解法(按可靠性排序):

   ① 【externals + 统一从主应用取】⭐ 最常用
      子应用构建时把 react/react-dom 设为 external,
      运行时从「主应用挂在 window 上的 React」取:
        子应用: webpack externals: { react: 'React', 'react-dom': 'ReactDOM' }
        主应用: window.React = React; window.ReactDOM = ReactDOM;
      ⚠️ 要求「版本兼容」(18 与 19 的 API 差异要确认;react-dom 的渲染入口也不同)

   ② 【Module Federation 的 shared】
      MF 声明 `shared: { react: { singleton: true, requiredVersion: '^19' } }`
      → ⭐ 运行时会「选一个版本作为单例」共享
      ⚠️ 只在「同一个构建体系(webpack/rspack)」下可行

   ③ 【import maps / 统一 CDN URL】
      所有应用都从「同一个 URL」加载 React(浏览器会缓存同一份模块)
      ⚠️ 需要「原生 ESM」,且「版本升级要一起升」

   ④ 【协议约定「统一 React 大版本」】⭐ 最土但最有效
      在组织层面约定「所有子应用同一 React 大版本」,
      由基座提供、子应用只声明 peer。
      ⭐ 现实中这是最可控的方案——微前端的前提是「愿意协作」。

四、完整实现:一个「最小可用的微前端框架」(250 行)

// micro-fe.js —— 一个 qiankun-lite:HTML entry + Proxy 沙箱 + 样式接管 + 生命周期

/** ============ 1. 注册表 ============ */
const apps = [];

export function registerMicroApps(list) {
  apps.push(...list.map((app) => ({ ...app, status: 'NOT_LOADED' })));
}

/** ============ 2. 路由匹配 ============ */
function matchApps(pathname) {
  return apps.filter((app) => {
    if (typeof app.activeRule === 'string') return pathname.startsWith(app.activeRule);
    if (app.activeRule instanceof RegExp) return app.activeRule.test(pathname);
    if (typeof app.activeRule === 'function') return app.activeRule(pathname);
    return false;
  });
}

/** ============ 3. HTML Entry 加载:从 HTML 里提取 script/style ============ */
async function loadHtmlEntry(entryUrl) {
  const baseUrl = new URL(entryUrl, location.href);
  const html = await fetch(baseUrl.href, { credentials: 'same-origin' }).then((r) => {
    if (!r.ok) throw new Error(`加载子应用失败:${entryUrl} (${r.status})`);
    return r.text();
  });

  const doc = new DOMParser().parseFromString(html, 'text/html');

  /** ⭐ 解析「相对路径」(否则子应用里的 `./main.js` 会以「基座域名」为基准) */
  const resolve = (url) => new URL(url, baseUrl).href;

  const scripts = Array.from(doc.querySelectorAll('script[src]')).map((el) => ({
    src: resolve(el.getAttribute('src')),
    // ⭐ 保留 type(module 脚本要用不同方式执行)
    type: el.getAttribute('type') ?? 'text/javascript',
    async: el.hasAttribute('async'),
  }));

  const inlineScripts = Array.from(doc.querySelectorAll('script:not([src])')).map((el) => ({
    content: el.textContent ?? '',
    type: el.getAttribute('type') ?? 'text/javascript',
  }));

  const styles = Array.from(doc.querySelectorAll('link[rel="stylesheet"]')).map((el) => resolve(el.getAttribute('href')));

  const inlineStyles = Array.from(doc.querySelectorAll('style')).map((el) => el.textContent ?? '');

  // ⭐ 「容器」:子应用要挂到哪里(通常是 body 里的第一个有 id 的元素)
  const containerHtml = doc.body.innerHTML;

  return { scripts, inlineScripts, styles, inlineStyles, containerHtml, baseUrl: baseUrl.href };
}
/** ============ 4. 执行脚本(用沙箱的 proxy 作为全局对象) ============ */
async function execScript(code, sandbox, sourceUrl = '') {
  // ⭐⭐ 用 Function 构造器 + with(proxy):
  //    `with` 让「未声明的变量赋值」落到 proxy 上(而不是真 window)
  //    ⚠️ 严格模式下 `with` 不可用 → 不用 'use strict'
  const fn = new Function(
    'window', 'self', 'globalThis', 'document', 'location', 'history',
    `with (window) { ${code}\n }`
  );

  const proxy = sandbox.proxy;

  // ⚠️ 常见做法是「劫持 location/history 让子应用的路由不跳走」,
  //    这里简化:把 history 包装成「同时改子应用内部状态」
  fn.call(proxy, proxy, proxy, proxy, window.document, window.location, window.history);

  void sourceUrl;
}

/** 也支持「ES module」子应用(用 import() 加载,⚠️ 无法用 with 沙箱) */
async function execModuleScript(src) {
  // ⭐ 只能靠「子应用自己规范」(不改全局),或「在 iframe 里加载」
  await import(/* @vite-ignore */ src);
}

/** ============ 5. 加载与挂载 ============ */
async function loadApp(app) {
  if (app.status === 'MOUNTED' || app.status === 'LOADING') return;

  app.status = 'LOADING';

  // ⭐ ① 创建沙箱
  app.sandbox = new ProxySandbox(app.name);

  // ⭐ ② 样式接管
  app.styleScope = new StyleScope(app.name, app.stylePrefix ?? `[data-micro-app="${app.name}"]`);
  app.styleScope.start();

  // ⭐ ③ 创建容器
  const container = document.createElement('div');
  container.dataset.microApp = app.name;
  container.innerHTML = '';                       // 先空着(等 HTML entry 的容器结构)
  app.container = container;

  // ⭐ ④ 加载 HTML entry
  const entry = await loadHtmlEntry(app.entry);
  app.entryData = entry;

  // ⭐ ⑤ 插入样式(inline 的先插,link 的并行加载)
  entry.inlineStyles.forEach((css) => {
    const style = document.createElement('style');
    style.textContent = app.stylePrefix ? prefixCss(css, app.stylePrefix) : css;
    style.dataset.microApp = app.name;
    document.head.appendChild(style);
  });
  await Promise.all(
    entry.styles.map(
      (href) =>
        new Promise((resolve) => {
          const link = document.createElement('link');
          link.rel = 'stylesheet';
          link.href = href;
          link.dataset.microApp = app.name;
          link.onload = link.onerror = () => resolve();
          document.head.appendChild(link);
        })
    )
  );

  // ⭐ ⑥ 插入 DOM 骨架(先放进去,子应用挂载时再往里面渲染)
  container.innerHTML = entry.containerHtml;
  app.root.appendChild(container);

  // ⭐ ⑦ 激活沙箱 → 执行脚本
  app.sandbox.activate();
  try {
    for (const s of entry.inlineScripts) {
      await execScript(s.content, app.sandbox, `${app.entry}#inline`);
    }
    if (entry.scripts.length) {
      // ⭐ 串行加载(保证「依赖顺序」;真实实现要处理 async/module)
      for (const s of entry.scripts) {
        if (s.type === 'module') {
          await execModuleScript(s.src);
          continue;
        }
        const code = await fetch(s.src, { credentials: 'same-origin' }).then((r) => r.text());
        await execScript(code, app.sandbox, s.src);
      }
    }
  } catch (err) {
    app.status = 'LOAD_ERROR';
    console.error(`[micro-fe] 子应用 ${app.name} 加载失败`, err);
    throw err;
  }

  // ⭐ ⑧ 生命周期:bootstrap → mount
  const lifecycle = app.sandbox.proxy;
  if (typeof lifecycle.bootstrap === 'function') await lifecycle.bootstrap();
  if (typeof lifecycle.mount === 'function') {
    await lifecycle.mount({
      container: container.firstElementChild ?? container,   // ⭐ 给子应用「挂载点」
      // ⭐ 基座向子应用传信息(路由、用户、主题…)
      props: { getGlobalState: () => globalState, onGlobalStateChange: subscribeGlobal },
    });
  }

  app.status = 'MOUNTED';
  console.log(`[micro-fe] ${app.name} 已挂载`);
}
/** ============ 6. 卸载 ============ */
async function unloadApp(app) {
  if (app.status !== 'MOUNTED') return;

  const lifecycle = app.sandbox.proxy;
  try {
    if (typeof lifecycle.unmount === 'function') await lifecycle.unmount();
  } catch (err) {
    console.warn(`[micro-fe] ${app.name} unmount 报错`, err);
  }

  // ⭐ 顺序很重要:先「失活沙箱」→ 再「移除 DOM」→ 再「清样式」
  app.sandbox.deactivate();
  app.sandbox.destroy();
  app.container?.remove();
  app.styleScope?.stop();

  app.status = 'NOT_LOADED';
  console.log(`[micro-fe] ${app.name} 已卸载`);
}

/** ============ 7. 路由劫持(让「切换路由」驱动「挂载/卸载」) ============ */
const globalState = { user: null, theme: 'light' };
const stateListeners = new Set();

function subscribeGlobal(fn) {
  stateListeners.add(fn);
  fn(globalState);                                   // ⭐ 立即推一次当前值
  return () => stateListeners.delete(fn);
}

export function setGlobalState(patch) {
  Object.assign(globalState, patch);
  stateListeners.forEach((fn) => fn({ ...globalState }));
}

function patchHistory() {
  const rawPush = history.pushState;
  const rawReplace = history.replaceState;

  history.pushState = function (...args) {
    const ret = rawPush.apply(this, args);
    window.dispatchEvent(new Event('micro-fe:route-change'));
    return ret;
  };
  history.replaceState = function (...args) {
    const ret = rawReplace.apply(this, args);
    window.dispatchEvent(new Event('micro-fe:route-change'));
    return ret;
  };
  window.addEventListener('popstate', () => {
    window.dispatchEvent(new Event('micro-fe:route-change'));
  });
}

async function reroute() {
  const pathname = location.pathname;
  const matched = matchApps(pathname);
  const matchedNames = new Set(matched.map((a) => a.name));

  // ⭐ 卸载「不再匹配」的
  for (const app of apps) {
    if (app.status === 'MOUNTED' && !matchedNames.has(app.name)) {
      await unloadApp(app);
    }
  }

  // ⭐ 挂载「新匹配」的(串行,避免同时操作 window/样式)
  for (const app of matched) {
    await loadApp(app);
  }
}

/** ============ 8. 启动 ============ */
export function start({ root = document.body } = {}) {
  patchHistory();
  apps.forEach((app) => { app.root = root; });
  window.addEventListener('micro-fe:route-change', () => { void reroute(); });
  void reroute();                                     // 首次进入
}
// ✅ 使用:基座应用的入口
import { registerMicroApps, start, setGlobalState } from './micro-fe';

registerMicroApps([
  {
    name: 'app-a',
    entry: 'https://app-a.example.com/',              // ⭐ HTML entry
    activeRule: '/a',                                  // ⭐ 路由前缀
    stylePrefix: '[data-micro-app="app-a"]',           // ⭐ 样式作用域
  },
  {
    name: 'app-b',
    entry: 'https://app-b.example.com/',
    activeRule: (path) => path.startsWith('/b'),
  },
]);

start({ root: document.getElementById('subapp-root') });

// ⭐ 基座向子应用广播(如登录态变化)
setGlobalState({ user: { id: '1', name: 'Ada' } });
// ✅ 子应用侧:暴露生命周期(挂在全局,供基座调用)
let root;

export async function bootstrap() {
  console.log('[app-a] bootstrap');
}

export async function mount({ container, props }) {
  const { createRoot } = await import('react-dom/client');
  const React = await import('react');
  // ⭐ 用 React 19 写法(不再需要 forwardRef 等,见 1.11)
  root = createRoot(container);
  root.render(React.createElement(App, { container, ...props }));
}

export async function unmount() {
  // ⭐ 必须 unmount(否则 React 内部状态与事件监听会残留)
  root?.unmount();
  root = null;
}

// ⚠️ 子应用构建时要把 react/react-dom 设为 external(见「依赖共享」)
⭐⭐⭐ 这个实现里的六个「关键设计」(每一条都对应一个真实难题):

① 【HTML entry 而不是 JS entry】
   子应用「不需要改造成『导出生命周期函数的库』」——
   它的 HTML 里本来就引用了 scripts/styles。
   ⭐ 这让「老应用渐进接入」成为可能(只加几行生命周期的导出)。
   ⚠️ 代价是「要自己解析 HTML + 处理相对路径」(`new URL(src, baseUrl)`)。

② 【Proxy 沙箱 + `with` 语句】
   `with (proxy) { ...code }` 让「未声明的变量赋值」落到 proxy 上(而不是真 window)。
   ⚠️ 严格模式下 `with` 不可用 → ⭐ 这也是「ES module 脚本无法被沙箱包裹」的原因
     (module 代码天然是严格模式)→ 只能靠「子应用自己规范」。

③ 【不污染真 window:只写 fakeWindow】
   `get` 时优先读 fakeWindow,兜底读真 window(并 bind 到真 window——
   否则 `window.addEventListener` 会报 `Illegal invocation`)。
   ⭐ 「多个子应用同时运行」靠的就是「各自一个 fakeWindow」。

④ 【样式的两条控制:动态 style 接管 + 前缀】
   接管解决「卸载残留」;前缀解决「同时运行冲突」。
   ⚠️ 反例:只做一件事——要么卸载后样式残留(只做前缀),
     要么同时运行时互相污染(只做接管)。

⑤ 【路由劫持】
   包装 `history.pushState/replaceState` + 监听 `popstate` →
   统一派发一个自定义事件 → 所有子应用响应。
   ⭐ 这样「子应用自己的路由」也能工作(子应用内部用 `react-router` 的
     `BrowserRouter`,只要它也用 `history.pushState`)。

⑥ 【全局状态用「订阅 + 立即推送」】
   `subscribeGlobal(fn)` 会**立即用当前值调用一次** fn ——
   ⭐ 这让「后挂载的子应用」也能拿到当前状态(否则它会一直等下次变化)。

五、本篇特有的坑

① 用 `window.React` 但子应用把自己的 React 打进产物(两份 React)
   ⚠️ 症状:`Invalid Hook call` / `Cannot read properties of null (reading 'useState')`
   ✅ externals + 统一从基座取(或 MF shared);或组织约定「统一大版本」

② 沙箱「不 bind 函数」(`window.addEventListener` 报 Illegal invocation)
   ✅ `get` 时把函数 bind 到真 window

③ 沙箱「不处理 window.self/window/globalThis 的返回」
   ⚠️ 子应用写 `window.self.xxx = 1` 就绕过了沙箱
   ✅ 返回 proxy 自身(本实现里做了)

④ 用 Proxy 沙箱但「子应用脚本是 ES module」(`with` 不可用)
   ✅ 要么让子应用「传统 script 加载」,要么接受「无沙箱」(靠规范约束)

⑤ 样式加了前缀但「@media/`:root`/动态插入的样式」处理不到位
   ⚠️ 正则改 CSS 极易出错(本实现已尽量逐块解析,但仍建议用 PostCSS)
   ✅ 生产建议:让子应用「自带作用域」(CSS Modules / BEM 前缀 / CSS-in-JS 命名空间)

⑥ 卸载时「只移除 DOM 不移除样式」(样式残留污染后续)
   ✅ 动态 style 接管 + 卸载清理

⑦ 卸载时「不移除 window 上的全局变量」(内存泄漏 + 后续冲突)
   ✅ 沙箱 destroy 时「恢复快照」或「丢弃 fakeWindow」

⑧ 卸载时「没有 unmount React 根」(事件监听/定时器残留)
   ✅ 子应用必须实现 unmount 并调用 `root.unmount()`

⑨ 子应用里「弹窗/浮层挂到 body」(脱离了沙箱容器,样式前缀失效)
   ⚠️ 症状:弹窗没有样式 / z-index 混乱
   ✅ 子应用把浮层挂到「自己的容器」(给 Portal 传 container,见 1.12)
   ⭐ 或基座提供「浮层容器」并告诉子应用

⑩ 子应用的「路由 basename」没设(点链接跳到了基座路由)
   ✅ 子应用用 `basename="/a"`(或基座传 `basePath` 给子应用)

⑪ 子应用「直接改 location.href」(整页刷新,基座状态全丢)
   ✅ 统一用 `history.pushState`(靠路由劫持生效)

⑫ 子应用「静态资源路径」错(相对路径以基座域名解析)
   ⚠️ 症状:图片/字体 404
   ✅ 子应用构建时设 `publicPath` 为绝对地址(自己的域名);
      或基座「在解析 HTML 时把相对路径改写成绝对路径」(本实现里做了)

⑬ 子应用「跨域」加载(fetch HTML 被 CORS 拦)
   ✅ 子应用服务端配 CORS(`Access-Control-Allow-Origin`);
      ⭐ 或让「基座与子应用同域下的不同路径」(用网关反代)

⑭ 微前端里的「埋点」重复上报(基座与子应用都初始化了一遍 SDK)
   ✅ 埋点 SDK 只在基座初始化,子应用通过「基座提供的接口」上报

⑮ 微前端里的「内存泄漏」难排查(子应用卸载不干净)
   ⭐ 用一个「卸载后检查」的自动化:卸载 → 强制 GC → 看 detached DOM 与监听器数量(见 5.13)

⑯ 「同时运行」多个子应用时,`document.title`、`scroll`、`focus` 互相干扰
   ✅ 明确「谁是当前活动应用」,非活动的「不做副作用」

⑰ 基座升级「子应用必须跟着升」(版本兼容矩阵失控)
   ✅ 基座 API「只加不改」+ 「子应用声明它支持的基座版本」+ 提供兼容层

⑱ 上微前端但「实际上只有一个团队」→ 复杂度全收、收益全无
   ⭐ 这是最常见的问题——先问「组织上真的分开了吗」
// ⑧ 的完整处理(子应用侧必须做的三件事)
let root = null;
let disposers = [];

export async function mount({ container }) {
  const React = await import('react');
  const { createRoot } = await import('react-dom/client');
  root = createRoot(container);

  // ⭐ 收集「需要清理的东西」(订阅/定时器/全局监听)
  disposers.push(() => window.removeEventListener('resize', onResize));
  window.addEventListener('resize', onResize);

  root.render(React.createElement(App));
}

export async function unmount() {
  // ⭐ ① 卸载 React 根
  root?.unmount();
  root = null;
  // ⭐ ② 清理「React 之外」的资源(订阅/定时器/第三方实例)
  disposers.forEach((fn) => { try { fn(); } catch {} });
  disposers = [];
}
// ⑨ 的完整处理(子应用把浮层挂到自己的容器)
// 基座通过 props 传入「浮层容器」,子应用用它做 Portal 目标
export async function mount({ container, props }) {
  const popupContainer = document.createElement('div');
  popupContainer.dataset.microApp = 'app-a';        // ⭐ 带上作用域标记(样式前缀才能命中)
  container.appendChild(popupContainer);

  root.render(React.createElement(App, { popupContainer }));
}

// 子应用内的 Modal 组件:
function Modal({ children }) {
  const { popupContainer } = useAppContext();
  return createPortal(children, popupContainer);    // ⭐ 不挂 body(见 1.12)
}

六、面试延伸

  1. 「什么时候该用微前端?」
    ⭐ 判据是「组织」而不是「技术」:① 多个团队各自独立开发/发布且发布节奏差异大(不能等彼此);② 渐进迁移(老系统在跑,要一块块替换);③ 技术栈异构(React 18 / Vue 3 / React 19 短期无法统一);④ 独立部署要求强(某模块要随时热修)。不该用的反模式:① 一个团队、一个技术栈、一个发布节奏(用 monorepo + 模块化就够,见 10.9);② 「只是想让代码看起来分开」(用目录/包边界 + lint);③ 「为了解耦而上」——⭐ 微前端是增加复杂度的(沙箱/通信/隔离/版本),只有收益(团队自治 + 独立部署)大到能覆盖它时才划算。

  2. 「微前端有哪些方案?怎么选?」
    按「隔离强度」排序:① iframe——隔离最强(JS/CSS/DOM 全隔离),但集成度最差(路由/弹窗/尺寸/通信都难);② Web Components + Shadow DOM——隔样式与 DOM,但 JS 仍在同一个 window;③ qiankun / single-spa / wujie——「HTML entry + 沙箱 + 样式隔离」,⭐ 隔离与集成的折中(所以最流行),适合「异构技术栈 + 渐进迁移」;④ Module Federation——「远程模块 + 运行时共享依赖」,⭐ 适合「同一构建体系(webpack/rspack)下的多应用组合」,隔离很弱;⑤ 原生 ESM + import maps——几乎无隔离,但依赖天然共享(同一份 URL),适合新项目。选型依据:「技术栈是否统一」「是否需要共享依赖」「对隔离的要求」。

  3. 「JS 沙箱怎么实现?」
    三种:① ⭐ 快照沙箱——激活时存 window 属性快照,卸载时「对比快照,删掉新增的、恢复修改的」;实现简单、兼容好,但 ⚠️ 同一时间只能有一个子应用运行;② ⭐ Proxy 沙箱(主流)——给每个子应用一个「假 window」:get 优先读自己的记录、兜底读真 window;set 只写自己的记录(不污染真 window)→ ✅ 支持多个子应用同时运行;⚠️ 仍无法拦截「绕过 window 的路径」(eval、document.cookie、top);③ iframe 沙箱——在 iframe 里执行 JS(原生 window 天然隔离),⭐ 现代实现(如 wujie)的做法是「JS 在 iframe 里跑、DOM 渲染到主文档」以兼顾隔离与体验。⭐ 必须强调:沙箱能防「意外的全局污染」,但防不了恶意代码——微前端是「团队协作约定」,不是「安全边界」。

  4. 「Proxy 沙箱里有哪些细节容易出错?」
    四个:① ⭐ 函数要 bind 到真 window——get 时若返回的是原生函数(如 addEventListener)而不 bind,子应用调用它会报 Illegal invocation;② ⭐ window.self/window/globalThis/top 要返回 proxy 自身——否则子应用写 window.self.xxx = 1 就绕过了沙箱;③ with 语句在严格模式下不可用 → ⭐ 「ES module 子应用的脚本无法被沙箱包裹」(module 代码天然是严格模式)→ 只能用「传统 script + new Function + with」,或接受「无沙箱」;④ deleteProperty / has / ownKeys 的行为要一起考虑(Object.keys(window) 看起来正常,但 for...in 会看到真 window 的属性)。

  5. 「样式隔离怎么做?」
    三种:① Shadow DOM(隔离最强)——✅ 完全隔离;⚠️ 代价是「全局样式/主题变量要显式注入」「弹窗要挂进 shadow」「部分第三方组件的样式注入进不去」「事件重定向/焦点有学习成本」;② 运行时加前缀(把 style 内容改写成 .app-a .foo)——✅ 简单无侵入;⚠️ 规则脆弱(@media/:root/动态插入的样式都是坑)→ 生产建议用 PostCSS 插件而不是正则;③ ⭐ 动态 <style> 标签接管 + 卸载移除——✅ 简单、能解决「卸载后样式残留」这个最常见问题;⚠️ 不能解决「同时运行时互相污染」。实践组合是「③ 解决卸载残留 + CSS 命名前缀约定(CSS Modules / BEM / CSS-in-JS 命名空间)解决同时运行冲突」——比 ② 可靠、比 ① 便宜。

  6. 「微前端里依赖共享为什么难?」
    因为 ⭐ 「两份 React」会直接崩:如果子应用把自己的 React 打进产物,页面上就有两份运行时——子应用组件里的 useState 走的是「自己那份 React 的 dispatcher」,而渲染它的是「基座的 reconciler」→ dispatcher 为 null → Invalid Hook call。解法(按可靠性):① ⭐ externals + 统一从基座取(子应用构建时 external: { react: 'React' },基座把 window.React 挂上);② Module Federation 的 shared: { react: { singleton: true } }(只在同一构建体系下可行);③ import maps / 统一 CDN URL(浏览器缓存同一份模块);④ ⭐ 组织约定「统一 React 大版本」——最土但最有效,因为微前端的前提就是「愿意协作」。

  7. 「微前端的路由怎么协同?」
    核心是「劫持 history + 统一派发路由变化事件」:包装 history.pushState/replaceState,并监听 popstate → 每次变化都派发一个自定义事件(如 route-change)→ 基座据此「匹配/卸载/挂载子应用」。三个配套细节:① ⭐ 子应用自己的路由要能工作——子应用用 react-router 的 BrowserRouter 时,必须设 basename(如 /a),否则「子应用内的链接会跳到基座路由」;② ⭐ 子应用不能直接改 location.href(那会整页刷新、基座状态全丢),必须统一走 history.pushState;③ 「谁是当前活动应用」要明确——非活动的应用不应该做副作用(改 document.title、抢焦点、动 scroll)。

  8. 「微前端的『独立部署』怎么保证不出错?」
    四件事:① ⭐ 接口契约——基座给子应用的 props(路由信息、用户、主题、浮层容器)要有稳定契约,且「只加不改」(新增 prop 是向后兼容的,改语义不是,见 1.11);② ⭐ 版本兼容矩阵——子应用声明「它支持的基座版本范围」,基座升级时能判断「哪些子应用需要跟着升」;③ 灰度与回滚——每个子应用有「独立版本 + 独立回滚」,⭐ 最好给「按用户/百分比灰度」的能力(见 8.9);④ 联调环境——⭐ 提供一个「能把任意子应用版本组合起来」的环境(否则「A 的新版本 + B 的旧版本」这类问题只在线上暴露)。另外「子应用加载失败」必须降级(显示错误块 + 重试,而不是白屏)。

一句话速记

微前端的判据是组织性的(多团队独立发布 / 渐进迁移 / 技术栈异构 / 强独立部署),不是技术性的——一个团队一个栈就不该上(用 monorepo 即可);方案按隔离强度排:iframe(最强、集成最差)→ Web Components/Shadow DOM(隔样式与 DOM,JS 仍共享 window)→ qiankun 类(HTML entry + 沙箱 + 样式隔离,⭐ 折中所以最流行)→ Module Federation(运行时共享依赖,⚠️ 隔离弱)→ ESM + import maps(几乎无隔离、依赖天然共享);三个技术难点:① JS 沙箱(快照沙箱「同一时间只能跑一个」vs ⭐ Proxy 沙箱「每个子应用一个假 window」支持并行,⚠️ 必须「bind 函数到真 window」「self/window 返回 proxy 自身」「ES module 无法用 with 包裹」;⭐ 沙箱防的是「意外污染」不是恶意代码)、② 样式隔离(Shadow DOM 最强但生态成本高 / 正则加前缀脆弱 / ⭐ 动态 style 接管 + 命名前缀约定是最实用组合)、③ 依赖共享(⭐ 两份 React → Invalid Hook call,解法是 externals + 统一从基座取 / MF shared / 组织约定统一大版本);路由靠「劫持 history + 派发路由事件」,且子应用要有 basename、不能改 location.href;独立部署要「接口契约只加不改 + 版本兼容矩阵 + 灰度回滚 + 组合联调环境」;⭐ 子应用的 mount/unmount 必须成对清理(root.unmount() + 订阅/定时器),浮层要挂到「自己的容器」而不是 body。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.10 微前端落地

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫