写在文章前,本文章是初学微前端,作者想初步了解微前端的原理做的一次实践,特别声明本文是根据 www.bilibili.com/video/BV1vU... 大佬的教学来进行创建的,其中自己也有些自己踩到的坑,来记录一下
从零搭建一个微前端框架
不依赖 qiankun、Micro-app 等成熟方案,手把手从最原始的方案开始,一步步演进成一个可用的微前端框架。
一、技术栈与架构
先明确整体架构------一个基座 + 两个子应用 + 静态文件服务器:
| 角色 | 技术 | 打包方式 |
|---|---|---|
| 基座(主应用) | Vue 3 + Vite | 正常打包 |
| 子应用 app1 | Vue 3 + Vite | UMD |
| 子应用 app2 | React(CRA) | UMD |
| 静态服务 | Node.js | --- |
为什么要 UMD? 子应用打包成 UMD 后,会把入口挂到 window 上(比如 window.vueapp),基座通过 fetch 拿到 JS 文本后 eval 执行,执行完就能拿到子应用暴露的方法。这是整个框架能跨框架加载子应用的基础。 注意:为什么采用umd,本来我是用的正常的打包,但这样的打包会产生improt等模块文件,在基座中导入时会报错:无法解析。
基座的页面结构很简单------左边侧边栏放导航,右边 #son 容器承载子应用:
html
<div class="wrapper">
<div class="slider-bar">
<nav>
<div><a href="/app1">app1</a></div>
<div><a href="/app2">app2</a></div>
</nav>
</div>
<div class="container">
<div id="son"></div>
</div>
</div>
二、起点------最原始的加载方案
2.1 直接 fetch + eval
最初的思路非常简单:既然 iframe 隔离太死、通信困难,那就干脆不用 iframe,直接用 fetch 把子应用的 JS/CSS 拉到基座里执行。
js
function loadJs(url) {
fetch(url)
.then((res) => res.text())
.then((text) => {
eval(text);
});
}
function loadCss(url) {
fetch(url)
.then((res) => res.text())
.then((text) => {
const styledom = document.createElement('style');
styledom.textContent = text;
document.body.appendChild(styledom);
});
}
然后用一个 pathMap 配置子应用的资源地址,createSonApp 按 URL 路由匹配加载:
js
const pathMap = {
app1: {
js: "http://localhost:3000/app1/index-BEaeQMVv.js",
css: "http://localhost:3000/app1/index-CKGB1dSI.css"
},
app2: {
js: "http://localhost:3000/app2/main.f4618f29.js",
css: "http://localhost:3000/app2/index-BQmE1JZV.css"
}
};
function createSonApp(url) {
const path = url || window.location.pathname;
const appName = path.split("/")[1];
const sourceMap = pathMap[appName];
if (sourceMap) {
loadJs(sourceMap.js);
loadCss(sourceMap.css);
}
}
效果图

2.2 这个版本的问题
能跑,但到处都是坑:
- JS 全局污染 ------
eval(text)直接在主窗口执行,两个子应用如果有同名变量,后面的会直接覆盖前面的 - CSS 全局污染------样式直接塞进 body,两个子应用的样式互相打架,谁在后面谁赢
- 没有生命周期------加载了就加载了,没办法卸载。子应用里注册的事件监听器、定时器会一直跑,内存泄漏
- 路由不管用 ------
createSonApp只在页面初始化时调一次,点导航切子应用没反应 - hash 文件名 ------Vite/CRA 默认打包带 hash 的文件名(
index-BEaeQMVv.js),每次构建文件名都变,得手动改pathMap
这些问题驱动了后续的演进。
三、容器挂载与路由劫持
3.1 挂载到 #son 容器
首先把 JS 和 CSS 的挂载目标从 document.body 改成基座的 #son 容器:
js
function loadCss(url) {
return fetch(url)
.then((res) => res.text())
.then((text) => {
const styledom = document.createElement('style');
styledom.textContent = text;
document.getElementById("son").appendChild(styledom);
});
}
子应用的 DOM 和样式都被圈在 #son 这个 div 里,不会飞得到处都是。但这里还有一个隐患------CSS 只是挂到了 #son 下,并没有做作用域隔离 。如果子应用里写了 body { margin: 0 } 或者 .header { color: red },照样能穿透到基座,污染全局样式。这个问题留到后面的 CSS 隔离方案去解决。
3.2 路由切换的两种方式
页面导航在浏览器里有两种触发方式:
a 标签跳转(<a href="/app2">)
这种走的是完整的浏览器导航,整个页面会刷新。路由变了,页面重新加载,createSonApp 自然会被重新调用。能用,但体验差------每次切子应用都白屏闪一下。
history.pushState
这是真正的麻烦。pushState 只改地址栏 URL,不会触发 popstate 事件 ,也不会导致页面重新渲染。在微前端框架里,用户点了导航、或者代码里调了 pushState,URL 变了但页面纹丝不动------子应用不会切换。
3.3 重写 pushState
解决办法是 Monkey-patch window.history.pushState:先执行原始逻辑(改 URL),再手动派发一个自定义 popstate 事件,把旧 appName 和新 URL 传过去:
js
function writeFn() {
const originPushState = window.history.pushState;
window.history.pushState = function (a, b, url) {
const nowAppName = window.location.pathname.split("/")[1];
// 先执行原始 pushState,改 URL
originPushState.call(this, a, b, url);
// 用 setTimeout 确保 URL 已经变了,再读取 pathname 派发事件
setTimeout(() => {
window.dispatchEvent(new CustomEvent("popstate", {
detail: { url, nowAppName }
}));
}, 0);
};
}
然后监听这个自定义事件,做子应用的切换:
js
function initEvent() {
window.addEventListener("popstate", (e) => {
let url = e.detail.url;
let nowAppName = e.detail.nowAppName;
// 处理 http 开头的绝对路径
if (url.startsWith("http")) {
url = new URL(url).pathname;
}
// 目标 appName 和当前一样,避免重复加载
const targetAppName = url.split("/")[1];
if (targetAppName === nowAppName) return;
// 卸载旧子应用,加载新子应用
window[nowAppName]?.unmount?.();
createSonApp(url);
});
}
整个路由切换的流程变成了:
scss
pushState("/app2")
→ 地址栏 URL 变成 /app2
→ setTimeout(0) 后 dispatchEvent("popstate", { url, nowAppName })
→ 事件监听器收到,发现 appName 从 app1 变成了 app2
→ unmount app1 → load app2
这里 setTimeout(..., 0) 的作用很关键------必须等 pushState 的原逻辑完全执行完、window.location.pathname 真正变了之后,再去读取和派发事件。不然可能读到的还是旧路径。
四、当前进度与待解决问题
到此为止,我们有了一个能跑但很脆弱的微前端框架:
- 已解决:子应用加载 → 容器隔离 → 路由切换
- 待解决 :
- JS 沙箱(全局变量隔离,避免子应用互相覆盖)
- CSS 隔离(样式作用域,避免子应用样式互相污染)
- 生命周期管理(mount/unmount + 事件/定时器清理)
- 子应用打包规范(UMD 输出,稳定文件名)
下一篇继续从沙箱入手,用 Proxy 伪造 window 实现子应用的 JS 隔离。
五、框架化------抽离 wqd.js
前面所有的函数(loadJs、loadCss、createSonApp、writeFn、initEvent)都是散落在基座代码里的,看起来就是一堆工具函数。为了让这套方案更像一个"框架"而不是"草稿",我们把它们全部收进一个文件 wqd.js,对外暴露一组 API:
js
// wqd.js ------ 一个手搓的微前端框架
const wqd = {
pathMap: {},
timerList: [],
eventList: [],
// 注册子应用
registerApp(pathMap) {
this.pathMap = pathMap;
},
// 启动框架(劫持路由 + 监听事件)
start() {
this._patchSetInterval();
this._patchPushState();
this._initEvent();
this._loadCurrentApp();
},
// 加载子应用
_loadApp(url) {
const path = url || window.location.pathname;
const appName = path.split("/")[1];
const sourceMap = this.pathMap[appName];
if (sourceMap) {
this._loadJs(sourceMap.js, appName);
this._loadCss(sourceMap.css);
}
},
// 卸载子应用
_unmountApp() {
// 清空定时器
this.timerList.forEach((timer) => {
clearInterval(timer);
clearTimeout(timer);
});
this.timerList = [];
// 移除事件监听
this.eventList.forEach((eventObj) => {
window.removeEventListener(eventObj.type, eventObj.fn);
});
this.eventList = [];
// 清空 DOM
const container = document.getElementById("son");
if (container) {
container.innerHTML = '';
}
},
// ------ 以下方法后续详细展开 ------
_patchSetInterval() { /* 第八节 */ },
_patchPushState() { /* 第三节 */ },
_initEvent() { /* 第三节 */ },
_loadCurrentApp() { /* 第二节 */ },
_loadJs(url, name) { /* 第六节 */ },
_loadCss(url) { /* 第七节 */ },
};
基座使用起来就清爽多了:
js
// 基座 main.js
wqd.registerApp({
app1: {
js: "http://localhost:3000/app1/index.js",
css: "http://localhost:3000/app1/index.css"
},
app2: {
js: "http://localhost:3000/app2/main.js",
css: "http://localhost:3000/app2/index.css"
}
});
wqd.start();
框架化的好处不只是代码整洁------后面每个待解决问题(沙箱、CSS 隔离、生命周期管理)都有了明确的归属,往 wqd 对象上加方法就行。
六、JS 沙箱------用 Proxy 伪造 window
第一个待解决的问题就是 JS 全局污染。两个子应用如果都往 window 上写变量,后加载的会把先加载的覆盖掉。解决思路是:给每个子应用一个假的 window,它以为自己写的是全局变量,实际上写的是一个隔离的代理对象。
6.1 设计思路:同名 window 而非 window.app1
业界常见的做法是 window.app1 = { mount, unmount },子应用所有导出挂在一个命名空间下。但这种方式的侵入性比较强------子应用的开发者得知道自己叫 app1,代码里可能要写成 window.app1.xxx。
我们换一个思路:子应用代码里完全不用改,它照样写 window.xxx,但这个 window 不是真的 window,而是一个我们伪造的代理对象。对子应用来说,这个代理对象"长得像" window------它有 window 上的所有属性(通过原型链 fallthrough 读到真实值),但它自己写入的新属性只会存在代理上,不会污染真实 window。
js
function createProxyWindow() {
const realWindow = window;
// 用一个空���象做原型,后面可以往上挂自定义方法
const fakeWindow = Object.create({
addEventListener(type, fn) {
// 记录下注册的事件,方便后续卸载时清理
wqd.eventList.push({ type, fn });
return realWindow.addEventListener.call(this, type, fn);
}
});
return new Proxy(fakeWindow, {
get(target, property) {
// 先从 fakeWindow 上找,找不到再去 realWindow 上找
const value = target[property] || realWindow[property];
// 如果是函数,bind 到 realWindow,避免 "Illegal invocation" 报错
if (typeof value === 'function') {
return value.bind(realWindow);
}
return value;
},
set(target, property, value) {
// 写操作只写到 fakeWindow,不污染真实 window
target[property] = value;
return true;
}
});
}
这里几个设计点很关键:
Object.create({ addEventListener }) 而不是直接 {}。 子应用里调用 window.addEventListener 的时候,我们不希望它和真实 window.addEventListener 行为完全一样------我们想顺便把注册的事件记下来。所以用 Object.create 把自定义的 addEventListener 挂在原型上,这样子应用调用 addEventListener 时走的就是我们的版本,既能正常注册事件,又能悄悄记一笔账。
get 里的 fallthrough 逻辑。 子应用可能需要用到 document、localStorage、fetch 这些真正的全局 API。如果代理上没有,就顺着去 realWindow 上找。这样就实现了"白名单不需要配置"------子应用可以读取任何真实全局属性,但写入的变量全部被代理拦截。
value.bind(realWindow)。 这是踩坑踩出来的。window.setTimeout、window.addEventListener 这类方法如果在代理对象上调用,this 指向不对,浏览器会抛 Illegal invocation。所以所有函数类型属性都 bind(realWindow) 一下,保证 this 正确。
set 只写代理。 子应用里写的 window.myVar = 123 只会落在 fakeWindow 上,真实 window.myVar 不受影响。两个子应用各自有自己的 proxyWindow,井水不犯河水。
6.2 把 proxyWindow 注入子应用代码
有了代理 window,怎么让子应用的代码用上它?办法是把 eval 包在一个 IIFE 里,把 global、window、self 三个参数全部替换成 proxyWindow:
js
function loadJs(url, name) {
return fetch(url)
.then((res) => res.text())
.then((text) => {
const proxyWindow = createProxyWindow();
// 子应用代码在 IIFE 里执行,global/window/self 都被替换为 proxyWindow
(function (global, window, self) {
eval(text);
})(proxyWindow, proxyWindow, proxyWindow);
// 把 UMD 导出桥接到真实 window
if (proxyWindow[name]) {
window[name] = proxyWindow[name];
proxyWindow[name].mount();
}
});
}
来分析一下这行代码干了什么:
javascript
(function (global, window, self) {
eval(text);
})(proxyWindow, proxyWindow, proxyWindow);
子应用的 UMD 打包产物大概是这样的:
js
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
? factory(exports)
: factory((global.app1 = {}));
})(this, function (exports) {
exports.mount = function () { /* ... */ };
exports.unmount = function () { /* ... */ };
});
正常情况下 this 指向真实 window,所以 global.app1 = {} 写在全局上。但现在我们把 global 替换成了 proxyWindow,所以 proxyWindow.app1 = {}。子应用从头到尾操作的都是代理,真实 window 毫发无损。
6.3 一个踩坑:mount 方法无法透传
按上面的逻辑,子应用 mount/unmount 等生命周期方法应该都存在 proxyWindow[name] 上。但基座的路由切换代码里,卸载旧子应用时:
js
window[nowAppName]?.unmount?.(); // 从真实 window 上读------读不到!
问题来了:window[name] 是真实 window,proxyWindow[name] 是代理 window,两者不互通。卸载旧子应用时从真实 window 读不到 unmount,自然就报错了。
所以加了这行桥接代码:
js
if (proxyWindow[name]) {
window[name] = proxyWindow[name]; // 桥接到真实 window
proxyWindow[name].mount(); // 挂载子应用
}
这确实是一个补丁,不够优雅。更理想的做法是基座内部的切换逻辑也统一从代理对象上读写,而不混用真实 window。但在这个阶段,先让它跑通------后面重构生命周期管理时会一并解决。
七、CSS 隔离------给样式加上作用域
前面提到 loadCss 虽然把样式塞进了 #son 容器,但并没有做作用域隔离。子应用里写 body { margin: 0 } 照样穿透到基座。CSS 隔离要解决的本质问题是:让子应用的每一行样式只作用于它自己的 DOM 范围。
7.1 把 CSS 插入 #son 下
首先修正 loadCss 的目标容器------从 document.body 改为 #son:
js
function loadCss(url) {
return fetch(url)
.then((res) => res.text())
.then((text) => {
const styledom = document.createElement('style');
styledom.textContent = text;
document.getElementById("son").appendChild(styledom);
});
}
但这只解决了"样式标签不飞得到处都是"的问题。假设子应用的 CSS 里有一行:
css
body { margin: 0; }
<style> 标签虽然在 #son 里,但 body 选择器能命中页面上的任何 <body>------包括基座自己的。样式依然会穿透,污染全局。
再比如:
css
.header { color: red; }
两个子应用如果都有 .header 类名,后加载的会覆盖前一个的样式。这跟 JS 全局变量的污染本质上是同一个问题------大家都在同一个全局作用域里打架。
7.2 思路:给选择器加 #son 前缀
解决方式很朴素------在子应用的 CSS 里,给每一个选择器 前面拼上 #son:
css
/* 原始 */
body { margin: 0; }
.header { color: red; }
/* 处理后 */
#son body { margin: 0; }
#son .header { color: red; }
这样一来,样式被限制在 #son 容器内生效。两个子应用各自有自己的 #son 范围,互不干扰。
怎么把原始 CSS 文本里的每个选择器自动加上前缀?正则替换。
7.3 scopeCSSWithParentClass------正则加工 CSS 文本
js
function scopeCSSWithParentClass(cssContent, parentClass) {
if (!parentClass || !cssContent) return cssContent;
const normalizedParent = parentClass;
return cssContent.replace(
/([^{}]+)(?=\s*\{)/g,
(selectorsBlock) => {
const selectors = selectorsBlock.split(',').map(s => s.trim());
const scopedSelectors = selectors.map(selector => {
// 跳过 @keyframes 动画定义
if (selector.startsWith('@keyframes') || selector.includes('@keyframes')) {
return selector;
}
// 跳过 @media、@supports 等规则块
// 跳过 :root、html、body 等全局选择器
if (selector.startsWith('@') ||
selector.includes(':root') ||
selector.includes('html') ||
selector.includes('body')) {
return selector;
}
// 给普通选择器加上父容器前缀
return scopeSingleSelector(selector, normalizedParent);
});
return scopedSelectors.join(', ');
}
);
}
function scopeSingleSelector(selector, parentClass) {
return `${parentClass} ${selector}`;
}
核心逻辑分三步:
第一步,正则匹配选择器块。 /([^{}]+)(?=\s*\{)/g 匹配的是 { 之前的选择器部分。比如对 body { margin: 0 } .header { color: red },它会先后匹配到 body 和 .header。
第二步,处理逗号分隔的多选择器。 CSS 里经常有 h1, h2, h3 { font-weight: bold } 这种写法,需要按逗号拆开,每个选择器单独加前缀,再拼回去。
第三步,跳过不需要加前缀的特殊规则。 不是所有 { 前面的东西都是普通选择器:
@keyframes:动画定义,不能加前缀,否则@keyframes #son slideIn直接无效@media、@supports:条件规则块,前缀应该加在里面的选择器上,而不是@规则本身:root、html、body:这些全局选择器加了前缀意义不大(#son body依然能命中基座的body),跳过处理
7.4 集成到 loadCss
最终 loadCss 变成这样:
js
function loadCss(url) {
return fetch(url)
.then((res) => res.text())
.then((text) => {
// 给每个选择器加上 #son 前缀,实现样式隔离
const scopedCSS = scopeCSSWithParentClass(text, '#son');
const styledom = document.createElement('style');
styledom.textContent = scopedCSS;
document.getElementById("son").appendChild(styledom);
});
}
子应用的 CSS 拉下来 → 正则处理一遍 → 插入 #son 容器。两个子应用的样式都在各自的 #son 作用域里,互不打架,也不污染基座。
7.5 这个方案的局限性
得诚实:纯正则方案不是完美的。它有几个已知的边界情况:
- 多层嵌套的 @media / @supports :
@media screen { .foo { color: red } }里的.foo不会被加前缀,因为正则只匹配了@media screen这个外层 block - CSS-in-JS 动态插入 :如果子应用运行时动态创建
<style>标签,这些样式不会被我们的正则处理 - 优先级问题 :
#son .header的优先级比.header高,如果基座有全局样式、子应用想覆盖,可能需要调整选择器权重
但对手搓框架来说,这个方案已经能解决大多数实际问题了。
八、定时器清理------拦截 setInterval / setTimeout
8.1 问题:基座看不见子应用的定时器
子应用里写了 setInterval 或 setTimeout,这些定时器的引用只有子应用自己知道。一旦子应用被卸载,DOM 没了、变量销毁了,但定时器还在跑------回调里试图操作已经不存在的东西,轻则报错,重则内存泄漏。
基座理论上可以要求子应用在 unmount 里自己清理,但这太依赖子应用开发者的自觉性。一个靠谱的框架应该做到:不管你子应用写不写清理代码,我都能帮你清干净。
8.2 和 addEventListener 同样的套路
前面在 createProxyWindow 里,我们已经干过类似的事了------覆盖 addEventListener,每次注册事件时把 { type, fn } 记到 eventList 里。
setInterval 和 setTimeout 同理:覆盖这两个方法,每次调用时把返回的 timer ID 记下来:
js
function _patchSetInterval() {
const originSetInterval = window.setInterval;
const originSetTimeout = window.setTimeout;
// 覆盖 setInterval
window.setInterval = function (fn, time) {
const timer = originSetInterval.call(this, fn, time);
wqd.timerList.push(timer);
return timer;
};
// 覆盖 setTimeout
window.setTimeout = function (fn, time) {
const timer = originSetTimeout.call(this, fn, time);
wqd.timerList.push(timer);
return timer;
};
}
注意这里覆盖的是真实 window 上的方法,而不是 proxyWindow。原因很简单------定时器是浏览器层面的能力,setInterval 必须在真实的全局对象上调用才能正常工作。
8.3 卸载时统一清理
有了 timerList 和之前积累的 eventList,卸载子应用的逻辑就出来了------遍历、清空:
js
_unmountApp() {
// 清空所有定时器
this.timerList.forEach((timer) => {
clearInterval(timer);
clearTimeout(timer); // 拿不准是 interval 还是 timeout,两个都调
});
this.timerList = [];
// 移除所有事件监听
this.eventList.forEach((eventObj) => {
window.removeEventListener(eventObj.type, eventObj.fn);
});
this.eventList = [];
// 清空子应用 DOM
const container = document.getElementById("son");
if (container) {
container.innerHTML = '';
}
}
这里 clearInterval 和 clearTimeout 两个都调了------因为我们把 interval 和 timeout 的 ID 都塞在同一个 timerList 里。事实上浏览器内部这两个方法是互通的,都调也不会有副作用。
8.4 完整的卸载流程
把路由切换和定时器/事件清理串起来:
js
window.addEventListener("popstate", (e) => {
const targetAppName = e.detail.url.split("/")[1];
const nowAppName = e.detail.nowAppName;
if (targetAppName === nowAppName) return;
// 1. 卸载旧的(清定时器 → 清事件 → 清 DOM)
wqd._unmountApp();
// 2. 加载新的
wqd._loadApp(e.detail.url);
});
卸载的完整链条:清定时器 → 清事件监听 → 清 DOM。反过来,加载子应用时,定时器和事件监听会通过被重写的 setInterval / addEventListener 自动记录,下次卸载时一并清理。
8.5 这个方案的盲区
和 CSS 隔离的正则方案一样,拦截 setInterval / setTimeout 也有覆盖不到的情况:
requestAnimationFrame:如果子应用用了 rAF,需要按同样套路拦截window.requestAnimationFrame,卸载时调cancelAnimationFramePromise/async/await:异步 Promise 链无法"取消",如果微任务里引用了已销毁的 DOM,还是可能报错。这个问题在微前端领域至今没有完美方案,一般靠子应用防御性编程(检查 DOM 是否存在)- 第三方库自己管理定时器 :比如 Lodash 的
_.debounce内部用的setTimeout,会被我们的拦截覆盖,因为最终还是调了全局的setTimeout。但如果库用的是自己维护的定时器系统,就抓不到了
总的来说,拦截 + 记录 + 批量清理这条思路已经能覆盖绝大多数场景。
九、总结
至此,我们从一个最简单的 fetch + eval 开始,一步步搭建了一个具备核心能力的微前端框架:
| 能力 | 实现方式 |
|---|---|
| 子应用加载 | fetch + eval(IIFE 注入 proxyWindow) |
| 路由切换 | Monkey-patch pushState + CustomEvent |
| JS 沙箱 | Proxy 伪造 window,写隔离、读 fallthrough |
| CSS 隔离 | 正则给选择器加 #son 前缀 |
| 事件清理 | 覆盖 addEventListener 记录事件,卸载时批量 removeEventListener |
| 定时器清理 | 覆盖 setInterval/setTimeout 记录 ID,卸载时批量 clear |
| 框架化 | 抽离 wqd.js,统一 API(registerApp / start) |
它当然比不上 qiankun 的完善程度------没有子应用预加载、没有应用间通信、没有错误边界、没有 Shadow DOM 隔离------但正因为手搓,所以每一个设计决策背后的"为什么"都看得清清楚楚。
微前端的核心问题就三个:加载、隔离、通信。这篇解决了前两个,通信留给后续吧。