壳子、通信与公共包:qiankun 微前端的几笔现实账
介绍
qiankun 壳子的注册与生命周期、六种通信通路的取舍,以及 workspaces 与 file 软链两种公共包方案。
为什么还要一个壳子
老平台是 hash 路由的 Vue 2 单体。拆成子应用后,如果每个应用都自带登录、菜单和权限,用户从设备页跳到告警页就得重新登录一次。壳子的职责只有四件事:统一登录与权限、统一菜单和 hash 路由、把子应用挂到同一个容器、把已登录的上下文传下去。不是「为了微前端而微前端」。
壳的 App.vue 只有一个 <router-view /> 和一个 <div id="appContainer" />。入口把主应用的 store、router 直接塞进 propData:
const propData = { store, router }
initMicro(propData)
startMicro()
initMicro 把 propData 合并进每个注册项的 props,再 registerMicroApps(apps);startMicro 只开了两个开关:
start({
prefetch: false, // 不预加载,省首屏请求
singular: true, // 同一时刻只保留一个子应用实例
})
singular: true 适合「一个菜单一个应用」的页面级拆分。壳没有配 sandbox,走 qiankun 默认沙箱;样式隔离也没开 experimentalStyleIsolation,这是后面要补的坑。
注册、激活与双模式生命周期
老壳路由仍是 hash 模式(mode: 'history' 那行还是注释),所以注册项按 hash 激活:
const getActiveRule = (hash) => (location) => location.hash.startsWith(hash)
{
name: 'dmp_engine',
entry: process.env.NODE_ENV === 'production'
? `${microFixObj.engine}/`
: '//localhost:9889',
container: '#appContainer',
activeRule: getActiveRule(`#${microFixObj.engine}/`),
}
开发环境 entry 直连端口,生产环境走同源部署目录,容器固定为壳里的 #appContainer。子应用要同时支持独立打开和被挂载,入口必须分流:
if (!window.__POWERED_BY_QIANKUN__) {
render()
}
export async function mount(props) {
props.onGlobalStateChange((state) => { /* 同步权限等状态 */ }, true)
await store.commit('user/SET_PARENTSTOREINFO', props.store.getters)
await generateMenu(props.store.getters.menu)
render(props)
}
export async function unmount() {
instance.$destroy()
instance.$el.innerHTML = ''
instance = null
}
路由前缀也要跟着环境切换:独立运行时是 /,被挂载时是 VUE_APP_MICRO_PREFIX。publicPath、路由前缀、壳里的 activeRule 这三处必须一致;任何一处漂移,都会变成「页面进了、静态资源 404」或「资源正常、路由不激活」。账号中心那套是 history 路由,激活规则改成 location.href.indexOf('/dmp-system') !== -1,比 hash 匹配宽松,也更容易被 query 参数误命中。
样式与公共依赖
老壳没开样式隔离,engine 只能用 fixStyleBug() 自己补:路由切换后,qiankun 容器里的 <style> 数量一旦变少,就把之前保存的完整列表重新 append 回去。账号中心则显式配了 strictStyleIsolation: false、experimentalStyleIsolation: true,并在 beforeMount 时 patch qiankun-head 的 appendChild,绕开 “Wrapper element” 报错;afterUnmount 再清理泄漏到 document.head 的 style/link。两套做法都说明:样式隔离能收敛到构建期就收敛,否则就得接受运行时补丁的成本。
公共依赖没有走 externals。壳和 engine 各自打包 Vue 2.6.10 + Element UI 2.13.0,版本只是人工对齐;apps/ 下已经升到 2.6.14 / 2.15.14。所谓「公共依赖」在这里只是「约定相同版本」,运行时仍是多份实例。
通信的六条通路
| 方式 | 方向 | 适用场景 | 风险 |
|---|---|---|---|
| qiankun props | 父→子 | 传 store、router 等启动上下文 | 耦合主应用内部结构 |
| initGlobalState | 父↔子 | 权限、用户、License 等小状态 | 时序、监听清理 |
| window 事件/全局变量 | 父↔任意 | 兼容老 iframe 页面 | 隐式约定、易失效 |
| Vue EventBus | 应用内 | 组件间通知 | 各应用各一个实例,跨应用无效 |
| localStorage/Cookie | 跨应用持久化 | token、菜单上下文、系统名 | 脏数据、键冲突 |
| URL 跳转 | 子→子 | 跨应用页面跳转 | 丢失内存态 |
props 是最直接的一条:主应用把整个 Vuex store 和 router 传下去,engine 读 props.store.getters,把父级组织信息合并进自己的 store,再用父级菜单生成路由。代价是子应用必须知道父应用的 store 结构;process-app 还声明了 props.eventBus,但主应用从来没传,属于无效参数。
全局状态在老壳里基本是预留:initGlobalState({ opt: '' }) 只做了日志,engine 把 $setGlobalState 挂到原型上却没有调用点。账号中心把它用起来了:基座在权限、用户、License 加载完成后调用 pushGlobalState({ permissionList }),子应用在 mount 里用 onGlobalStateChange(cb, true) 立即拿一次快照。这里有个真实的不一致——基座传入的 props 是扁平的 { userInfo, permissionList, licenseInfo },子应用 render 却从 props.store 解构,永远拿到空对象;真正生效的是全局状态回调,props.store 那段是死代码。
window 级通路是历史遗留:壳里定义 window.changeRoute(to, ...) 写入 window.routePath 并派发 getRoutePath,engine 的路由守卫调用时却没传参数,routePath 恒为 undefined;iframe 页虽然读了 window.parent.routePath,实际地址却是按 $route.fullPath 现拼的。它能跑,但不要再往上加新依赖。
EventBus 只存在于单个应用内部:engine 和 process-app 都是 Vue.prototype.$EventBus = new Vue()。跨应用没有共享事件总线,也没有共享 store;子应用之间跳转靠 URL,数据靠 localStorage 和 Cookie 兜底。代码里也没有显式 offGlobalStateChange,是否随 unmount 自动清理,未在仓库中验证。
公共包:workspaces 与 file 软链
apps/ 用的是源码软链:每个应用声明 "common": "file:../common",安装后 node_modules/common 指向公共目录,webpack 直接编译公共源码。改一处、所有应用重新构建后生效;不需要发包,但每个应用都要单独安装,依赖关系也不在版本号里体现。
dmp-uni-account 换成了 npm workspaces:
{
"private": true,
"workspaces": ["dmp-base", "dmp-system", "packages/*"]
}
dmp-base 是基座,dmp-system 是系统管理子应用,packages/dmp-shared 是共享包。两边都写 "@dmp/shared": "1.0.0",npm 把它软链到 packages/dmp-shared;共享包声明 vue、element-ui 为 peerDependencies,内容是 SvgIcon、ConfirmDialog、FormDialog、MiniSwitch、GwMessage、GwPagination、图标和样式。
但请求与鉴权并没有抽进去:dmp-base 有 utils/request.js 和 utils/auth.js,dmp-system 只有自己的 utils/request.js,token 键 dmp_token 靠两边约定。workspaces 解决的是「安装与版本」,不是「逻辑归属」。
| 维度 | workspaces | file: 软链 |
|---|---|---|
| 声明方式 | 根 workspaces + 包名版本 | 每个应用 file:../common |
| 安装 | 根目录一次 | 每个应用各自安装 |
| 复用边界 | 可拆多包、可声明 peer | 一个 common 包,靠目录约定 |
| 发布 | 可独立版本化 | 无需发布 |
| 运行时共享 | 否,仍各自打包 | 否,仍各自打包 |
| 适用 | 多包、需要边界 | 快速收口、单仓协作 |
不用 qiankun 的替代方案
先说前提:老壳是 Vue CLI 3.6 + Vue 2.6.10(webpack 4),apps/ 是 Vue CLI 5 + Vue 2.6.14(webpack 5),子应用独立部署在 /dmp-html/ 下的各自目录。任何替代方案都绕不开这些现实。
Module Federation 适合组件/页面级共享。子应用作为 remote 暴露模块,宿主运行时加载,shared 可以把 Vue、Element UI 变成单例。但它要求宿主先有 webpack 5——老壳得升级或另起新壳;每个 remote 都要配 ModuleFederationPlugin 和运行时 publicPath;Vue 2 的 SFC 需要消费端有 vue-loader,或者只暴露编译后的组件。它不提供 JS/CSS 沙箱,样式冲突仍要自己处理。
micro-app / wujie 走 Web Component + iframe/shadow DOM,样式隔离天然更好,通信是 props + 自定义事件。子应用主体代码基本不动,但要替换 qiankun 的生命周期适配和 publicPath 注入;壳的重写量不大,主要成本在回归 iframe 内的路由、弹窗层级和全屏。
退回单仓或 iframe:monorepo + 路由级模块拆分适合发版节奏统一的小团队,代价是失去独立部署;iframe + postMessage 隔离最彻底、技术耦合最低,但路由、弹窗、尺寸同步都要自己维护,适合嵌入第三方或老系统(现有 testIframe 就是这类场景)。
| 方案 | 隔离与共享 | 与独立部署契合度 | 通信 | 迁移成本 | 适用与风险 |
|---|---|---|---|---|---|
| qiankun(现状) | JS 沙箱,CSS 需补丁;依赖各自打包 | 高 | props / 全局状态 / URL | 低 | 存量多、需统一壳 |
| Module Federation | 运行时共享依赖,无 DOM 隔离 | 高 | 共享模块 / 事件 | 中高 | 组件复用;需 webpack 5 与版本治理 |
| micro-app / wujie | customElement/iframe,隔离强 | 高 | props + 事件 | 中 | 样式冲突重;需适配生命周期 |
| monorepo 单仓 | 构建期共享,无运行时隔离 | 低 | 直接 import | 中 | 发版统一;失去独立发布 |
| iframe + postMessage | 最强隔离,几乎不共享 | 高 | postMessage | 低 | 老系统/第三方;UX 成本高 |
踩坑与经验
- 先对齐三处路径:
publicPath、路由前缀、activeRule,最好让它们从同一个环境变量推导。 - 样式隔离不要指望默认值。engine 的
fixStyleBug和账号中心的qiankun-headpatch 都是运行时补丁,能收敛到构建期就收敛。 - 通信通路越少越好。props 传静态上下文,全局状态传少量会变的数据,存储只做跨页面持久化;不要同时用三套传同一份权限。
- 老壳没有注册 qiankun 错误钩子,也没有
addGlobalUncaughtErrorHandler,子应用 entry 挂了就是白屏;上线前的探活和监控要单独补。 - 共享包要区分「源码复用」和「运行时共享」。前者用
file:或 workspaces 都行,后者必须上externals或 Module Federation;我们目前只做到了前者。
小结
壳子本质上是一份契约:路由怎么拼、状态怎么传、样式怎么隔离、失败怎么兜底。qiankun 把挂载这件事做了,但契约的细节仍要自己写。若重来一次,我会先把通信收敛成 props 加一份最小全局状态,再决定公共包用 workspaces 还是 file:;至于要不要换 Module Federation 或 wujie,先看能不能接受「升级壳的构建链」和「统一依赖版本」这两个前提。
