从原理到手写一个微前端框架
微前端第一课 · 原理 + 手写实现 · Vue3 / React
课程地图
| 模块 | 内容 | 学习产出 |
|---|---|---|
| 背景 | 为什么要用微前端(多系统整合的故事) | 理解微前端的价值 |
| 方案对比 | iframe 为什么不行 | 掌握微前端要解决的痛点 |
| 核心原理 | 打包产物、fetch、eval 三个知识点 | 建立"项目 = 一段可执行的 JS"的心智模型 |
| 实战① | 用 eval 加载远程 JS/CSS(动态组件) | 手写微前端初步形态 |
| 实战② | 路由切换子应用 + 装饰者模式重写 pushState | 手写一个完整的微前端骨架 |
| 预告 | 沙箱:样式隔离、作用域隔离、副作用清理 | 认识微前端的下半场 |
INFO
先记住三个术语(后面会反复用到) · 主应用:提供头部、侧边栏、内容区空 div 的"架子" · 子应用:被加载进内容区的独立系统(Vue / React 都可以) · 动态组件:动态拉取目标 JS 并执行、渲染出内容的组件(微前端的初步形态)
背景:为什么需要微前端(先听故事)
我们通过一段职场情景把微前端的诞生讲透了,完整还原如下:
假设你入职一家公司,第一年独立开发了考勤系统。公司越来越大,你又陆续开发了财务系统、库存管理系统、用户关系管理系统——每一个都是独立项目。
慢慢地,三个痛点出现了:
- 员工在系统之间切来切去:打完卡要看工资,切到财务系统;管客户,又切到 CRM。体验非常割裂。
- 后悔没做成一个大项目:但不可能重写——系统里有 Vue2、Vue3、React,写法完全不同,靠复制粘贴合并根本不现实。
- 代码冲突:如果当初真把十几个前端塞进一个项目,代码天天冲突。
更麻烦的是,公司还要给不同角色开发不同站点:客户站点、领导站点、员工站点。这些站点有公共部分——比如领导要考勤,员工也要考勤,考勤系统必须被多个站点复用。
于是理想方案呼之欲出:
- 做一个整合的大项目,把已有系统像积木一样拼进来,不重复开发;
- 每个系统保持独立开发、独立 git 仓库,哪个大系统需要,直接"接进来"。
INFO
这就是微前端要解决的核心问题:"积木式"整合 + 独立开发。 这也是大厂中后台系统普遍采用微前端架构的原因。
第一版方案:iframe 为什么不行
很多人的第一反应是用 iframe:
主应用只做一个头部 + 侧边栏 + 内容区(iframe)。点侧边栏菜单,就用 iframe 加载对应子系统的网址,子系统就显示在这一块。
这个方案确实"拼"起来了,但用着用着就会发现两个致命问题:
| 问题 | 原因 | 后果 |
|---|---|---|
| 通讯困难 | iframe 和主应用属于不同的 window | 无法像同一个项目那样传递消息、互相改数据,状态共享很难 |
| 登录态不共享 | iframe 是独立加载的在线网站 | 主应用登录了,进每个子系统还要重新登录 |
WARNING
结论:iframe 能"拼",但拼不"通"。它没法让多个系统像一家人那样协作。
核心原理:三个知识点铺垫
在提出新方案之前,先梳理三个已有知识——这正是理解微前端原理的三把钥匙。
知识点 1:打包后的项目到底是什么
Vue / React 项目执行 npm run build 后得到 dist 目录。打开里面的 HTML 你会发现:里面几乎什么都没有,只加载了一段 JS。所有页面内容,都是执行这段 JS 绘制出来的。
INFO
结论:一个前端项目,本质上就是一段可以执行的 JS。
知识点 2:fetch 可以拉取远程的 JS / CSS
子应用打包好放到服务器上,我们就能通过 URL 用 fetch 把它请求下来,拿到完整的 JS 文本内容。
知识点 3:eval 可以把字符串当代码执行
拉下来的 JS 是一段文本,而 eval(text) 能把它当作代码执行。
三个知识点串起来,新方案就诞生了:
TIP
主应用 fetch 子应用的 JS → eval 执行 → 子应用渲染到内容区。 执行 JS 时用的是主应用的 window,所以完美避开了 iframe 的"不同 window"问题。
图:本课实现的微前端整体架构示意
动手第一步:用 eval 加载一个"远程组件"
4.1 准备一个静态服务器
用 Node 起一个最简单的静态服务器(开放跨域),端口 3000,static 目录作为静态资源目录。
// server.js —— 简化实现
const http = require('http')
const fs = require('fs')
const path = require('path')
http.createServer((req, res) => {
// 允许跨域,保证子应用 JS/CSS 能被主应用请求到
res.setHeader('Access-Control-Allow-Origin', '*')
const filePath = path.join(__dirname, 'static', req.url)
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404)
res.end('Not Found')
return
}
const ext = path.extname(filePath)
const mime = {
'.js': 'text/javascript',
'.css': 'text/css',
'.html': 'text/html'
}
res.writeHead(200, { 'Content-Type': mime[ext] || 'text/plain' })
res.end(data)
})
}).listen(3000, () => {
console.log('server running at http://localhost:3000')
})4.2 打包子应用并部署
# 在子应用项目里执行
npm run build把 dist 里打包出的 JS 和 CSS 复制到服务器的 static 目录。然后访问 http://localhost:3000/xxx.js,就能直接拿到 JS 文本。
WARNING
关键点:挂载节点 id 必须一致。 子应用的入口 JS 会把内容挂载到某个固定 id 的 div 上(本节是 #sun)。主应用准备的内容区 div,id 必须和它保持一致,执行子应用 JS 时内容才能渲染进正确位置。
4.3 核心代码:动态加载 JS + CSS
一个最简单的 HTML 演示页:
<div id="app">
<h1>我是主应用</h1>
<div id="sun"><!-- 子应用渲染到这里 --></div>
</div>// 核心逻辑:请求子应用 JS,用 eval 执行
async function loadRemoteComponent() {
// 1. 请求子应用打包出的 JS 文件
const res = await fetch('http://localhost:3000/vue-app.js')
// 2. 把响应流转换成文本(拿到的是字符串)
const jsText = await res.text()
// 3. eval 把字符串当代码执行 → 子应用挂载到 #sun
eval(jsText)
}
// CSS 同理,但不能用 eval,而是创建 style 标签
async function loadRemoteStyle() {
const res = await fetch('http://localhost:3000/vue-app.css')
const cssText = await res.text()
const style = document.createElement('style')
style.textContent = cssText
document.body.appendChild(style)
}执行之后,子应用的页面内容就出现在内容区了——甚至路由都能正常工作。
4.4 为什么这样能避开 iframe 的问题?
因为子应用的 JS 是拿到主应用里执行的。它本质上是"我(主应用)在自己的页面里执行了一段代码,这段代码把我的页面渲染了出来"——所以主应用和子应用共用同一个 window。
共用 window,通讯、状态共享、登录态共享才有了基础(具体怎么做,是后面沙箱要解决的问题)。
4.5 里程碑:动态组件(微前端的初步形态)
做到这一步,你已经"发明"了微前端的初步形态——动态组件:
INFO
动态组件 = 动态地引入目标 JS 代码并执行,渲染出想要的内容。
WARNING
但注意:它只是"加载了一个远程组件",还不算真正的微前端。微前端是很多独立项目集合在一个大项目里。 所以下一步,要加路由,让主应用能随着地址变化切换不同子应用。
进阶:用路由切换子应用
5.1 先明确两个概念
- 主应用:提供侧边栏、头部,以及一个内容区空 div 的架子。
- 子应用:被加载进内容区的系统,可以独立运行、独立调试(这是必须的,因为团队要并行开发)。
5.2 路由切换的基本逻辑
逻辑其实很简单:
INFO
监听地址变化 → 取出路径 → 匹配「哪个地址对应哪个子应用」→ fetch 子应用 JS/CSS → 执行渲染。
5.3 两种跳转方式,两种监听策略
路由变化分两种情况,这是本课最容易卡住的地方:
| 跳转方式 | 特点 | 监听策略 |
|---|---|---|
| 输入 URL 回车 / 原生 a 标签跳转 | 整页重新加载,main.js 重新执行 | 项目初始化时直接读 location.pathname |
| Vue / React 的 history 路由跳转 | 页面不刷新,main.js 不会重新执行 | 监听 popstate(需配合重写 pushState) |
先把第一种搞定:初始化时读取路径,解析出子应用名,加载对应资源。
// APP_MAP:开发者配置的「路径 → 子应用资源」映射
const APP_MAP = {
'app1': {
js: 'http://localhost:3000/vue-app.js',
css: 'http://localhost:3000/vue-app.css'
},
'app2': {
js: 'http://localhost:3000/react-app.js',
css: 'http://localhost:3000/react-app.css'
}
}
// 加载并执行子应用 JS
async function loadJS(url) {
const res = await fetch(url)
const text = await res.text()
eval(text) // 字符串 → 代码
}
// 加载子应用 CSS(创建 style 标签注入)
async function loadCSS(url) {
const res = await fetch(url)
const text = await res.text()
const style = document.createElement('style')
style.textContent = text
document.body.appendChild(style)
}
// 创建子应用
async function createSubApp() {
const path = window.location.pathname
// 子应用可能还有自己的路由,比如 /app1/about
// 所以只取第一段路径作为子应用名
const appName = path.split('/')[1]
const resources = APP_MAP[appName]
if (!resources) return // 主应用里没有这个子应用
await loadJS(resources.js)
await loadCSS(resources.css)
}
// 方式一:直接访问 / 刷新 → 页面重新加载 → 执行一次即可
createSubApp()输入 /app1、/app2,甚至 /app1/about,都能正确加载。但 history 模式路由跳转不刷新页面,createSubApp() 不会再执行——这就要处理第二种方式。
5.4 补课:Vue / React 路由的本质
先搞明白一件事:在 Vue / React 项目里跳转路由(router-link、router.push、router.go),本质都是在调用浏览器的 history.pushState / history.go,不会刷新页面。
而浏览器有一个规范:
WARNING
原生 pushState 不会触发 popstate 事件;只有 history.go(-1)、浏览器前进后退等才会触发 popstate。
所以"监听 popstate"这条路,直接监听是收不到 pushState 跳转通知的。怎么办?——重写 pushState,让它主动触发 popstate。
5.5 装饰者模式重写 pushState
"保留方法原功能,再加上新能力"——这正是设计模式里的装饰者模式。三步走:
- 先存下原方法(保留原功能);
- 重写方法;
- 重写后的方法里:加新功能 + 调用原方法。
// 1. 保留原方法
const originalPushState = window.history.pushState
// 2. 重写:加新功能(触发 popstate)+ 保留原功能(照常跳转)
window.history.pushState = function (a, b, url) {
// 新功能:手动触发 popstate,并把目标地址放进事件 detail
window.dispatchEvent(new CustomEvent('popstate', { detail: { url } }))
// 原功能
originalPushState.call(window.history, a, b, url)
}
// 3. 监听 popstate,收到通知就加载对应子应用
window.addEventListener('popstate', (e) => {
const url = e.detail ? e.detail.url : window.location.pathname
createSubApp(url)
})TIP
自定义事件携带数据,要放在 detail 字段里。
5.6 四个坑与修复
直接跑上面的代码,会发现四个问题,逐个修:
坑 1:popstate 里读 location.pathname 拿到的是旧地址
pushState 跳转的瞬间,事件回调里 location.pathname 还是上一个地址。解决:跳转时把目标地址通过 detail 带过去(上面的代码已经做了)。
坑 2:子应用内部路由跳转会带来完整的 http 地址
子应用自己也有路由(比如切到 /app1/about),主应用收到的可能是完整的 http://.../app1/about。需要归一化成路径:
// 把完整地址转成 pathname
function normalizeUrl(url) {
if (url.startsWith('http')) {
return new URL(url).pathname
}
return url
}坑 3:同一子应用内切路由,重复加载了 JS
从 /app1 切到 /app1/about,是同一个子应用的内部路由,不该重新加载。解决:比较目标子应用名和当前子应用名,相同就直接返回:
async function createSubApp(url) {
const path = normalizeUrl(url || window.location.pathname)
const targetAppName = path.split('/')[1]
const currentAppName = window.location.pathname.split('/')[1]
// 同一子应用内部的路由切换,不重复加载
if (targetAppName === currentAppName) return
const resources = APP_MAP[targetAppName]
if (!resources) return
await loadJS(resources.js)
await loadCSS(resources.css)
}坑 4:Vue / React Router 也劫持了 pushState
Vue / React 的路由库自己也会重写 pushState。如果我们同步地 dispatch popstate,可能在页面跳转完成前就去加载子应用,导致各种奇怪问题。解决:异步触发——先让原页面跳转,再 dispatch:
window.history.pushState = function (a, b, url) {
// 异步触发:先让页面完成跳转,再通知子应用加载
setTimeout(() => {
window.dispatchEvent(new CustomEvent('popstate', { detail: { url } }))
}, 0)
originalPushState.call(window.history, a, b, url)
}到此,路由切换就完整了:既兼容子应用内部路由,又兼容 URL 直达和 pushState 跳转。一个比较完整的微前端骨架搭建完成。
还没完:沙箱 —— 微前端的下半场
"大功告成"了吗?还没有。本节明确指出,目前这套实现还有 4 个问题,这正是下一课"沙箱"要解决的:
| # | 问题 | 现象 |
|---|---|---|
| 1 | 样式冲突 | 加载子应用 2 的 CSS 后,主应用本身的样式被"冲得稀巴烂";子应用之间、子应用与主应用若有相同类名,会互相干扰 |
| 2 | 作用域冲突 | 子应用 1 往 window 挂 a 变量,子应用 2 也挂 a,同一个 window 上互相覆盖 |
| 3 | 状态恢复 | 子应用 1 修改了 window 上的某个原有属性,切到子应用 2 时应恢复原样 |
| 4 | 全局副作用清理 | 子应用挂载的全局监听、setTimeout / setInterval,切走时要卸载,否则影响下一个子应用 |
INFO
所谓沙箱,就是让每个子应用拥有独立的 window 环境——在子应用里怎么改 window,都不影响其他子应用。 这也是 qiankun 等微前端框架核心要解决的问题之一。 下集预告:构建一个简单的沙箱环境。
术语表
| 术语 | 定义 |
|---|---|
| 微前端 | 将多个独立的前端应用整合进一个主应用的架构模式 |
| 主应用 | 提供整体骨架(头部 / 侧边栏 / 内容区),负责加载和切换子应用 |
| 子应用 | 独立开发、独立部署、可独立运行的业务系统 |
| 动态组件 | 动态拉取目标 JS 并执行、渲染出内容的组件(微前端初步形态) |
| popstate | 浏览器历史记录变化时触发的事件 |
| 装饰者模式 | 在不修改原方法的前提下,为其添加新能力的设计模式 |
| 沙箱 | 为子应用提供独立环境,隔离 window、样式与副作用的机制 |
附录:完整代码清单
server.js(静态服务器)
const http = require('http')
const fs = require('fs')
const path = require('path')
http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
const filePath = path.join(__dirname, 'static', req.url)
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404)
res.end('Not Found')
return
}
const ext = path.extname(filePath)
const mime = {
'.js': 'text/javascript',
'.css': 'text/css',
'.html': 'text/html'
}
res.writeHead(200, { 'Content-Type': mime[ext] || 'text/plain' })
res.end(data)
})
}).listen(3000)主应用路由完整版(合并所有修复)
// ========== 1. 资源映射 ==========
const APP_MAP = {
'app1': {
js: 'http://localhost:3000/vue-app.js',
css: 'http://localhost:3000/vue-app.css'
},
'app2': {
js: 'http://localhost:3000/react-app.js',
css: 'http://localhost:3000/react-app.css'
}
}
// ========== 2. 加载工具 ==========
async function loadJS(url) {
const res = await fetch(url)
const text = await res.text()
eval(text)
}
async function loadCSS(url) {
const res = await fetch(url)
const text = await res.text()
const style = document.createElement('style')
style.textContent = text
document.body.appendChild(style)
}
function normalizeUrl(url) {
if (url.startsWith('http')) {
return new URL(url).pathname
}
return url
}
// ========== 3. 创建子应用 ==========
async function createSubApp(url) {
const path = normalizeUrl(url || window.location.pathname)
const targetAppName = path.split('/')[1]
const currentAppName = window.location.pathname.split('/')[1]
if (targetAppName === currentAppName) return // 同一子应用内切路由
const resources = APP_MAP[targetAppName]
if (!resources) return
await loadJS(resources.js)
await loadCSS(resources.css)
}
// ========== 4. 方式一:页面加载 ==========
createSubApp()
// ========== 5. 方式二:重写 pushState + 监听 popstate ==========
const originalPushState = window.history.pushState
window.history.pushState = function (a, b, url) {
setTimeout(() => {
window.dispatchEvent(new CustomEvent('popstate', { detail: { url } }))
}, 0)
originalPushState.call(window.history, a, b, url)
}
window.addEventListener('popstate', (e) => {
const url = e.detail ? e.detail.url : window.location.pathname
createSubApp(url)
})