一个基于 Module Federation 的微前端框架,核心 500 行搞定沙箱隔离、CSS 作用域和生命周期管理。
为什么又要造一个微前端框架?
市面上的微前端方案已经不少了------qiankun、micro-app、wujie、Module Federation 原生方案......每个都有自己的设计哲学。但我们在实际生产项目中遇到了几个痛点:
- qiankun 的沙箱性能开销 :每次子应用切换都要完整的
activate/deactivate,对于需要频繁切换的多标签页场景不够丝滑 - CSS 隔离方案的特异性污染:Shadow DOM 太封闭,CSS Modules 需要改造代码,BEM 命名约定靠人工遵守
- 子应用运行时"被污染":大多数方案要求子应用引入框架特定的 SDK,子应用不再纯净
- 路由冲突 :多个子应用共存时,
popstate事件会同时触发所有活跃子应用的路由器
于是我们提取了生产环境中的核心模块,做成了 PavilionMfe------一个运行时只有 5 个包、子应用零依赖的微前端框架。
架构总览
sql
┌──────────────────────────────────────────────────────┐
│ 主应用 (Main App) │
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ Router │ │ EventBus │ │ Log System │ │
│ │ 生命周期 │ │ 跨子应用通信 │ │ 分模块配置 │ │
│ └────┬─────┘ └────┬─────┘ └────────────────┘ │
│ │ │ │
│ ┌────▼─────────────▼───────────────────────────────┐ │
│ │ #pavilion-mfe-container │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ 子应用 A │ │ 子应用 B │ │ 子应用 C │ │ │
│ │ │ (Vue 3) │ │ (React) │ │ (Vue 2) │ │ │
│ │ │ + Sandbox │ │ + Sandbox │ │ + Sandbox │ │ │
│ │ │ +CSS Scope│ │ +CSS Scope│ │ +CSS Scope│ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Module Federation 运行时预加载插件 │
└──────────────────────────────────────────────────────┘
包依赖关系------自底向上的分层设计
scss
bridge (零依赖) sandbox (零依赖) tabs (零依赖)
│ │ │
│ ▼ │
│ router ─────────────────┘
│ │
▼ ▼
runtime (聚合层, MF Remote 共享)
│
▼
vite (Vite 插件, 仅构建时依赖)
设计原则很明确:底层包零依赖 ,上层按需组合。sandbox、bridge、tabs 三个包可以独立使用,router 依赖它们提供完整的路由调度能力,runtime 是聚合层通过 MF Remote 确保单例,vite 仅构建时起作用。
核心设计:用 500 行代码解决四个难题
1. JS 沙箱:栈式副作用追踪
传统的 Proxy 沙箱(如 qiankun)需要为每个子应用创建独立的 window 代理,开销不小。PavilionMfe 的思路不同:不隔离 window,而是追踪和清理副作用。
typescript
// 核心实现:模块级 activeStack + 一次性全局补丁
const activeStack: Sandbox[] = []
let globalsPatched = false
function patchGlobals(): void {
if (globalsPatched) return // 只执行一次
globalsPatched = true
globalThis.setTimeout = ((handler, timeout, ...args) => {
const id = origSetTimeout(handler, timeout, ...args)
const active = activeStack[activeStack.length - 1]
if (active) active._timeouts.add(id) // 归属于栈顶沙箱
return id
}) as any
// 同理拦截 setInterval / addEventListener / removeEventListener
}
核心思路:
activeStack是模块级的数组,记录当前活跃的沙箱栈patchGlobals()只执行一次 ,拦截setTimeout/setInterval/addEventListener/removeEventListener- 每个副作用被分配到栈顶沙箱------天然支持多实例并发
deactivate()时自动清理所有归属于该沙箱的 timers / listeners / globals
typescript
const sandbox = new Sandbox('my-app')
sandbox.activate() // 推入栈顶,开始追踪
// ... 子应用运行,setTimeout/addEventListener 自动追踪
sandbox.deactivate() // 弹出栈,清除 3 个 timer、2 个 interval、5 个 listener
日志输出直观展示清理过程:
ini
[PavilionMfe] sandbox sandbox-deactivate appCode=demo-app timers=3 intervals=1 listeners=2
2. CSS 作用域::where() 的零特异性魔法
CSS 隔离最头疼的是特异性问题 。假设你给子应用加了一个 .app 前缀:
css
/* 加了前缀后特异性变了! */
.pavilion-demo .card { color: red; } /* 特异性 0,2,0 */
.card { color: blue; } /* 特异性 0,1,0 --- 被覆盖了 */
PavilionMfe 的解决方案是使用 :where() 伪类:
css
/* 输入 */
.card { color: red; }
@keyframes fadeIn { from { opacity: 0; } }
/* PostCSS 输出 --- :where() 零特异性 */
:where(.pavilion-mfe-demo-app) .card { color: red; }
@keyframes pavilion-mfe-demo-app-fadeIn { from { opacity: 0; } }
:where() 的关键特性:它包裹的选择器不贡献任何特异性。所以:
- 作用域后的
.card仍然是0,1,0,不会覆盖库的默认样式 - 子应用开发者不需要改变任何 CSS 书写习惯
@keyframes名称也自动前缀化,防止动画名冲突
PostCSS 插件实现只有 130 行,构建时运行,子应用零感知。
3. 路由隔离:popstate 代理拦截
多个子应用共存的场景下,浏览器前进/后退会触发 所有 子应用的 popstate 监听器。PavilionMfe 的做法是代理拦截:
typescript
// sandbox 的 addEventListener 补丁中
if (type === 'popstate' && routeMatcher) {
const appCode = active.appCode
const proxyHandler = (event: Event) => {
// 只有当前子应用的路由匹配时,才触发原 handler
if (routeMatcher(appCode, location.pathname)) {
handler(event)
} else {
// 非活跃子应用:仅记录日志,不触发
console.log('popstate-blocked', appCode, location.pathname)
}
}
active._listeners.push({ target: globalThis, type, handler: proxyHandler })
origAddEventListener(type, proxyHandler)
}
每个子应用的 popstate 监听器被替换为一个代理------只有当 routeMatcher 判定当前路径属于该子应用时,才会真正触发回调。非活跃子应用完全不会收到导航事件,从源头避免路由冲突。
日志中你可以清楚看到拦截过程:
ini
[PavilionMfe] sandbox popstate-blocked appCode=demo-app path=/react/dashboard
4. Keep-Alive 缓存:不销毁框架实例
大型子应用(Element Plus 组件库 + 业务代码)的初始化可能耗时数百毫秒。在多标签页场景下反复销毁重建体验很差。
typescript
const pavilionMfeRouter = createPavilionMfeRouter({
apps: [{
name: 'demo-app',
keepAlive: true, // 开启缓存
// ...
}],
maxCache: 5, // 全局 LRU 驱逐上限
})
缓存策略的精细设计:
- 切换离开 :只隐藏 DOM(
display: none),不销毁框架实例,沙箱也不 deactivate - 切换回来 :
display: block,跳过 mount(),状态完整保留(表单数据、滚动位置等) - LRU 驱逐 :超过
maxCache时,最早缓存的子应用才会完整执行deactivate() + unmount()
状态机增加 CACHED 状态:
scss
MOUNTED → (unmount/离开) → CACHED → (restore/回来) → MOUNTED
子应用的极简契约
子应用只需要导出一个对象------三个生命周期函数,零框架依赖:
typescript
// main.ts --- Vue 3 子应用
export default {
mount: async (ctx, el) => {
const app = createApp(App)
app.use(router)
app.mount(el)
return () => app.unmount() // 返回清理函数
},
unmount: async (ctx, el) => {
el.innerHTML = ''
},
}
// 独立运行时自启动
if (!window.__PAVILION_MFE_ENV__) {
createApp(App).use(router).mount('#app')
}
对于 Vue 2、React 也是同样的模式,只需改变框架调用方式。关键是 mount 返回一个清理函数,框架在合适的时机调用它。
开发者体验
分模块日志
typescript
import { configureLog } from '@pavilion-mfe/router'
configureLog({
modules: {
router: true, // 路由事件 + 子应用生命周期
sandbox: true, // 沙箱激活/停用 + popstate 拦截
preload: true, // MF 远程注册 + 预加载状态
bridge: true, // EventBus emit/subscribe
},
})
输出风格统一、可读性强:
ini
[PavilionMfe] router router-start subApps=3
[PavilionMfe] router sub-app-load appCode=demo-app ms=320
[PavilionMfe] sandbox sandbox-activate appCode=demo-app
[PavilionMfe] router sub-app-mount appCode=demo-app ms=45
路由事件系统
makefile
pavilion-mfe:before-routing → 路由切换前
pavilion-mfe:after-routing → 路由切换完成
pavilion-mfe:sub-app-switch → 活跃子应用变化
pavilion-mfe:sub-app-error → 子应用加载/挂载失败
可以用于埋点、全局加载状态、权限守卫等场景。
注册中心
json
// mfe.json --- 路由 + 构建的单一声明
{
"apps": [
{ "appCode": "demo-app", "routes": ["/demo"], "devPort": 6020 },
{ "appCode": "react-app", "routes": ["/react"], "devPort": 6030 },
{ "appCode": "vue2-app", "routes": ["/vue2"], "devPort": 6040 }
]
}
一份配置同时驱动路由注册、MF 远程模块声明和开发端口分配。
对比其他方案
| 维度 | qiankun | micro-app | wujie | PavilionMfe |
|---|---|---|---|---|
| 沙箱方式 | Proxy 代理 window | 样式+JS 隔离 | iframe 隔离 | 栈式副作用追踪 |
| CSS 隔离 | 实验性沙箱 | Shadow DOM | 自然隔离 | :where() 零特异性 |
| 子应用依赖 | 需要 qiankun 生命周期 |
零依赖 | 零依赖 | 零依赖 |
| 多实例并发 | 有限支持 | 支持 | 支持 | 栈式支持 |
| Keep-Alive | 社区方案 | 内置 | 内置 | 内置 LRU |
| 构建工具 | Webpack 为主 | 任意 | 任意 | Vite + MF |
| 包体积 | 较大 | 中等 | 中等 | ~15KB |
PavilionMfe 的优势在于:
- 子应用完全纯净 :运行时不含任何
@pavilion-mfe/*代码 - 构建工具只支持 Vite:享受 ESM 原生 + MF 的极致性能
- 包体积小:核心 5 个包,底层零依赖
局限:
- 仅支持 Vite 构建(不支持 Webpack)
- Module Federation 的共享依赖配置需要一定学习成本
- 浏览器兼容性依赖
:where()(Chrome 88+)
适用场景
PavilionMfe 特别适合:
- 管理后台类应用:多标签页、频繁切换子应用
- Vite 技术栈团队:和 Vite 生态深度整合
- 多团队协作:子应用独立仓库、独立发布,零耦合
- 多框架混合:同一主应用中运行 Vue 2、Vue 3、React 子应用
项目运行实例


结语
PavilionMfe 的设计哲学是**"子应用不感知框架"**。我们相信好的微前端方案应该像浏览器一样------你不需要知道自己在 iframe 里运行,框架也无权侵入你的代码。
核心 500 行搞定沙箱,130 行搞定 CSS 作用域,300 行搞定路由调度。在追求"小而美"的路上,我们用 :where() 替代了重型的 Shadow DOM,用栈式追踪替代了 Proxy 代理,用 popstate 代理替代了路由劫持。
如果你也在维护一个多团队的管理后台,不妨试试 PavilionMfe。
GitHub: pavilion-mfe License: MIT