壳子、通信与公共包: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 解决的是「安装与版本」,不是「逻辑归属」。

维度workspacesfile: 软链
声明方式根 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 / wujiecustomElement/iframe,隔离强高props + 事件中样式冲突重;需适配生命周期
monorepo 单仓构建期共享,无运行时隔离低直接 import中发版统一;失去独立发布
iframe + postMessage最强隔离,几乎不共享高postMessage低老系统/第三方;UX 成本高

踩坑与经验

  1. 先对齐三处路径:publicPath、路由前缀、activeRule,最好让它们从同一个环境变量推导。
  2. 样式隔离不要指望默认值。engine 的 fixStyleBug 和账号中心的 qiankun-head patch 都是运行时补丁,能收敛到构建期就收敛。
  3. 通信通路越少越好。props 传静态上下文,全局状态传少量会变的数据,存储只做跨页面持久化;不要同时用三套传同一份权限。
  4. 老壳没有注册 qiankun 错误钩子,也没有 addGlobalUncaughtErrorHandler,子应用 entry 挂了就是白屏;上线前的探活和监控要单独补。
  5. 共享包要区分「源码复用」和「运行时共享」。前者用 file: 或 workspaces 都行,后者必须上 externals 或 Module Federation;我们目前只做到了前者。

小结

壳子本质上是一份契约:路由怎么拼、状态怎么传、样式怎么隔离、失败怎么兜底。qiankun 把挂载这件事做了,但契约的细节仍要自己写。若重来一次,我会先把通信收敛成 props 加一份最小全局状态,再决定公共包用 workspaces 还是 file:;至于要不要换 Module Federation 或 wujie,先看能不能接受「升级壳的构建链」和「统一依赖版本」这两个前提。