
一句话:微前端解决的是「多个团队/多套技术栈的应用要合成一个站点」的组织问题,而不是技术问题——它的真正难点全在「隔离(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)
}
六、面试延伸
- 「什么时候该用微前端?」
⭐ 判据是「组织」而不是「技术」:① 多个团队各自独立开发/发布且发布节奏差异大(不能等彼此);② 渐进迁移(老系统在跑,要一块块替换);③ 技术栈异构(React 18 / Vue 3 / React 19 短期无法统一);④ 独立部署要求强(某模块要随时热修)。不该用的反模式:① 一个团队、一个技术栈、一个发布节奏(用 monorepo + 模块化就够,见 10.9);② 「只是想让代码看起来分开」(用目录/包边界 + lint);③ 「为了解耦而上」——⭐ 微前端是增加复杂度的(沙箱/通信/隔离/版本),只有收益(团队自治 + 独立部署)大到能覆盖它时才划算。 -
「微前端有哪些方案?怎么选?」
按「隔离强度」排序:① 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),适合新项目。选型依据:「技术栈是否统一」「是否需要共享依赖」「对隔离的要求」。 -
「JS 沙箱怎么实现?」
三种:① ⭐ 快照沙箱——激活时存window属性快照,卸载时「对比快照,删掉新增的、恢复修改的」;实现简单、兼容好,但 ⚠️ 同一时间只能有一个子应用运行;② ⭐ Proxy 沙箱(主流)——给每个子应用一个「假 window」:get优先读自己的记录、兜底读真 window;set只写自己的记录(不污染真 window)→ ✅ 支持多个子应用同时运行;⚠️ 仍无法拦截「绕过 window 的路径」(eval、document.cookie、top);③ iframe 沙箱——在 iframe 里执行 JS(原生window天然隔离),⭐ 现代实现(如 wujie)的做法是「JS 在 iframe 里跑、DOM 渲染到主文档」以兼顾隔离与体验。⭐ 必须强调:沙箱能防「意外的全局污染」,但防不了恶意代码——微前端是「团队协作约定」,不是「安全边界」。 -
「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 的属性)。 -
「样式隔离怎么做?」
三种:① Shadow DOM(隔离最强)——✅ 完全隔离;⚠️ 代价是「全局样式/主题变量要显式注入」「弹窗要挂进 shadow」「部分第三方组件的样式注入进不去」「事件重定向/焦点有学习成本」;② 运行时加前缀(把style内容改写成.app-a .foo)——✅ 简单无侵入;⚠️ 规则脆弱(@media/:root/动态插入的样式都是坑)→ 生产建议用 PostCSS 插件而不是正则;③ ⭐ 动态<style>标签接管 + 卸载移除——✅ 简单、能解决「卸载后样式残留」这个最常见问题;⚠️ 不能解决「同时运行时互相污染」。实践组合是「③ 解决卸载残留 + CSS 命名前缀约定(CSS Modules / BEM / CSS-in-JS 命名空间)解决同时运行冲突」——比 ② 可靠、比 ① 便宜。 -
「微前端里依赖共享为什么难?」
因为 ⭐ 「两份 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 大版本」——最土但最有效,因为微前端的前提就是「愿意协作」。 -
「微前端的路由怎么协同?」
核心是「劫持 history + 统一派发路由变化事件」:包装history.pushState/replaceState,并监听popstate→ 每次变化都派发一个自定义事件(如route-change)→ 基座据此「匹配/卸载/挂载子应用」。三个配套细节:① ⭐ 子应用自己的路由要能工作——子应用用react-router的BrowserRouter时,必须设basename(如/a),否则「子应用内的链接会跳到基座路由」;② ⭐ 子应用不能直接改location.href(那会整页刷新、基座状态全丢),必须统一走history.pushState;③ 「谁是当前活动应用」要明确——非活动的应用不应该做副作用(改document.title、抢焦点、动scroll)。 -
「微前端的『独立部署』怎么保证不出错?」
四件事:① ⭐ 接口契约——基座给子应用的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。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!