微前端初探:用 qiankun 2.0 做了两周试点之后的评估
微前端这个词今年在社区里出现的频率高得有点不正常,掘金上隔三差五一篇实践总结,qiankun 六月份发了 2.0,各种分享里"渐进式升级""技术栈无关"的说法听起来都很诱人。但看别人的文章始终隔一层——他们的痛是不是我们的痛、他们没提的坑对我们是不是致命,坐在工位上想不出答案。所以八月上旬我跟组里资深前端申请了两周时间,拿内部一个职责单一、影响范围清楚的报表子系统做了一轮试点,目的不是"上微前端",而是回答一个问题:我们的场景值不值得拆、现在拆的成本有多大。
这篇把试点过程和评估结论都记下来。先说结论免得吊胃口:能跑通,接入比想象中顺,但公共依赖和样式隔离这两块的现状,配上我们并不算尖锐的痛点,撑不起全面推开的决策,最后定的是小范围试点继续观察。
为什么会动拆的念头
先交代痛点,因为微前端是一味药,不先确诊就开药是要出事的。我们的中后台主应用发展到今天,路由表里挂了两百多个页面,横跨商品、订单、营销、报表、权限五六个业务域,仓库是一个,构建是一个 webpack,发版也是一个流水线。真实的痛有三处。
一是发版互相等。营销组的活动配置页改完要上线,得等订单组把手上验到一半的需求验完,不然一起构建出去风险不可控。每周总有一两次"谁在占用预发"的拉扯,QA 也很难做增量回归——改的是营销模块,理论上订单模块不用回归,但打出来的是同一个 bundle,谁敢打包票。
二是构建越来越慢。全量构建已经到七八分钟,dev server 冷启动两分多钟,热更新在改公共模块时也要十几秒。这个问题一部分能靠构建优化缓解,我们也确实做过一轮,但页面数量的增长是刚性的,优化是在跟增长赛跑。
三是技术栈被锁死。主应用是 Vue 2.6 + Element UI,这没什么不好,但今年 Vue 3 都快正式发布了,明后年新业务想用新技术栈,在单体仓库里等于要么整体升级要么永远不升。隔壁团队还有一个历史遗留的 AngularJS 报价系统,业务上一直有诉求要挂进我们的菜单里,现在的方案是 iframe,用户吐槽弹窗只能在 iframe 内部弹、面包屑和主应用对不上。
这三处痛加起来,就是"要不要把主应用拆成一个基座加若干子应用"的动机。但动机成立不等于方案成立,往下就是试点要验证的东西。
为什么是 qiankun,不是裸 single-spa 或者继续 iframe
候选方案摆出来三个。iframe 是现状,隔离性无敌,但体验问题无解:弹窗被框死在 iframe 里、路由状态无法同步到主地址栏、登录态要靠 postMessage 传话,这些都是用户和产品经理实际抱怨过的,不展开。
single-spa 是社区最早的微前端框架,qiankun 就是在它上面包出来的。裸用 single-spa 要自己解决的事情不少:子应用的 JS 入口要手动配置、样式和全局变量的隔离要自己做、资源加载要自己写 loader。qiankun 的卖点恰好是把这几件事包掉了——它用 html entry 的方式加载子应用,子应用把自己的 index.html 地址给基座就行,脚本、样式都从 HTML 里解析出来,还内置了 JS 沙箱和样式隔离。2.0 版本六月份发布,从 changelog 看主要是重写了沙箱、支持了多实例(一个页面同时跑多个子应用),社区反馈也算积极。
选 qiankun 还有个很实际的原因:它是蚂蚁的开源项目,背后有 umi 生态在生产上用着,文档是中文的,issue 区活跃,我们遇到问题至少搜得到同路人。选基础设施的时候,"出了问题能不能找到人问"跟功能本身一样重要。
基座的最小改造
试点的结构是:现有主应用当基座,报表子系统从主仓库里抠出来做成独立应用。先看基座这边要动多少。
安装 qiankun 之后,核心就是注册加启动两步:
1// main-app/src/micro.js 2import { registerMicroApps, start } from 'qiankun'; 3 4registerMicroApps([ 5 { 6 name: 'report-app', 7 entry: process.env.VUE_APP_REPORT_ENTRY, // 开发环境 //localhost:7101,线上是子应用部署地址 8 container: '#subapp-container', 9 activeRule: '/report', 10 props: { 11 // 把登录态和公共信息传下去 12 getToken: () => localStorage.getItem('access_token'), 13 user: store.state.user.info, 14 }, 15 }, 16]); 17 18start({ prefetch: true });
activeRule: '/report' 的意思是浏览器地址匹配到 /report 前缀时,qiankun 去加载并挂载这个子应用。container 是基座页面里留给子应用的 DOM 节点,我把它放在主布局的内容区里,菜单、顶栏、面包屑都还是基座的——这正是 iframe 方案给不了的:子应用长在主应用的布局里,用户感知不到接缝。
基座的路由要给子应用让路。我们主应用是 Vue Router 的 history 模式,需要加一个通配路由兜住 /report 开头的路径,渲染一个只有 <div id="subapp-container"> 的空壳页面,别让 Vue Router 自己按 404 处理了。这里有个小插曲:一开始我把容器 div 写在了带 v-if 的组件里,路由切换时容器还没渲染出来 qiankun 就开始挂载,报"container not found",把 v-if 换成 v-show 保证节点常驻就好了。基座的改动统共一百行上下,比预期少。
子应用的改造清单
子应用这边动的地方多一些,但都是模式化的。qiankun 要求子应用导出三个生命周期函数,打包成 umd 格式让基座能拿到:
1// report-app/src/main.js 2import Vue from 'vue'; 3import App from './App.vue'; 4import createRouter from './router'; 5 6let instance = null; 7 8function render(props = {}) { 9 const { container } = props; 10 const router = createRouter(); 11 instance = new Vue({ 12 router, 13 render: (h) => h(App), 14 }).$mount(container ? container.querySelector('#app') : '#app'); 15} 16 17// 独立访问时(不在 qiankun 环境)直接渲染,保留单独开发调试的能力 18if (!window.__POWERED_BY_QIANKUN__) { 19 render(); 20} 21 22export async function bootstrap() {} 23 24export async function mount(props) { 25 render(props); 26} 27 28export async function unmount() { 29 instance.$destroy(); 30 instance.$el.innerHTML = ''; 31 instance = null; 32}
window.__POWERED_BY_QIANKUN__ 这个判断很关键,它保住了子应用独立启动的能力——开发报表业务的同事不需要起基座,npm run serve 直接开发,和以前没区别。子应用能独立开发、独立部署、独立访问,是微前端和"换了个写法的单体"的分水岭,这个能力丢了拆分就没意义了。
webpack 侧的配套改造有三处。一是输出格式改 umd,让基座能从全局拿到生命周期;二是 dev server 要开 CORS,因为开发时基座在 8080、子应用在 7101,基座去拉子应用的资源是跨域的;三是包名要唯一:
1// report-app/vue.config.js 2const { name } = require('./package.json'); 3 4module.exports = { 5 devServer: { 6 port: 7101, 7 headers: { 'Access-Control-Allow-Origin': '*' }, 8 }, 9 configureWebpack: { 10 output: { 11 library: `${name}-[name]`, 12 libraryTarget: 'umd', 13 jsonpFunction: `webpackJsonp_${name}`, // 多个子应用共存时 jsonp 回调不能撞名 14 }, 15 }, 16};
jsonpFunction 那行是文档里明确要求的,webpack 4 的异步 chunk 靠全局的 jsonp 回调数组衔接,两个子应用都用默认名字就会互相污染。另外子应用的路由要配 base:qiankun 环境下是 /report,独立访问时是 /,用 __POWERED_BY_QIANKUN__ 区分:
1// report-app/src/router/index.js 2import Vue from 'vue'; 3import Router from 'vue-router'; 4import routes from './routes'; 5 6Vue.use(Router); 7 8export default function createRouter() { 9 return new Router({ 10 mode: 'history', 11 base: window.__POWERED_BY_QIANKUN__ ? '/report' : '/', 12 routes, 13 }); 14}
把 router 从模块级单例改成工厂函数不是可有可无的讲究:子应用会被反复挂载卸载,单例 router 上一次挂载残留的导航状态会带进下一次,试点第三天就撞见一次"切走再切回来,页面停在了上次的路由但组件没渲染"的怪象,改成每次 mount 都 new 一个才干净。同理,Vuex 的 store 如果有模块级状态,也要考虑卸载时要不要重置。
静态资源路径是第一个真正卡住我的问题。子应用打包后图片、字体的路径是相对自己域名的,被基座加载后相对的却是基座域名,图全裂。解法是 qiankun 文档给的运行时 publicPath:
1// report-app/src/public-path.js,在 main.js 最顶部引入 2if (window.__POWERED_BY_QIANKUN__) { 3 __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; 4}
qiankun 挂载子应用时会注入它的真实部署地址,webpack 运行时用这个值拼资源路径。这个文件必须在所有 import 之前生效,所以单独抽一个文件放在入口第一行——顺序错了不报错,只是图裂,挺阴的。
加载体验:prefetch、loading 和错误兜底
接入跑通之后,第一轮内部演示就被产品经理抓住一个体验问题:从商品页切到报表页,中间有一秒多的白屏——那是基座在拉子应用的 HTML 和脚本。单体应用时代路由切换是毫秒级的,用户会把这一秒理解成"卡了"。
qiankun 对此有两层缓解。一层是预加载,start({ prefetch: true }) 会在第一个子应用挂载完成后,利用浏览器空闲时间把其它已注册子应用的静态资源提前拉回来,等用户真切过去时资源大概率已经在缓存里。试点只有一个子应用,这层暂时没体现价值,但规划里有多个子应用时是刚需,参数也支持传数组指定"只预取哪几个",避免把低频应用的资源也抢着下载。
另一层要自己做:挂载期间的 loading 状态。qiankun 的 loader 回调会在加载状态变化时通知基座,接到 Vuex 里驱动一个全局进度条就行:
1registerMicroApps( 2 [ 3 { 4 name: 'report-app', 5 // ...同前 6 loader: (loading) => store.commit('app/SET_SUBAPP_LOADING', loading), 7 }, 8 ], 9);
错误兜底用 addGlobalUncaughtErrorHandler。试点期间最常见的失败是开发环境子应用忘了启动,基座拉 entry 直接网络错误,默认表现是控制台报错、容器区一片空白。加了全局错误处理之后,起码能给用户一个"子系统加载失败,点击重试"的页面:
1import { addGlobalUncaughtErrorHandler } from 'qiankun'; 2 3addGlobalUncaughtErrorHandler((event) => { 4 const msg = event.message || ''; 5 if (msg.includes('died in status LOADING_SOURCE_CODE')) { 6 // 子应用资源加载失败,多半是没起服务或者部署地址不对 7 store.commit('app/SET_SUBAPP_ERROR', true); 8 } 9});
died in status XXX 是 qiankun 报错的固定格式,状态机的哪一步挂了写得清清楚楚,排查时先看这个状态名再翻文档,比盲目搜报错文本快。
部署这一段没有文档写得那么轻描淡写
本地跑通只是一半,试点要有说服力就得真部署到测试和预发环境,这一段花的时间超了预期。
子应用独立部署本身简单,报表应用打完包扔到自己的目录,走独立的发布流水线,这正是我们想要的。麻烦在基座怎么找到它。开发、测试、预发、生产四个环境,子应用的 entry 地址都不一样,写死肯定不行,我们的做法是把注册表抽成配置,按环境变量注入,将来子应用多了再考虑挪到配置中心接口下发。
nginx 这边有三处要动。子应用的资源要允许基座域名跨域拉取;子应用自己的 index.html 必须禁缓存,不然发了新版基座还在拉旧的 HTML 入口,静态资源倒是可以放心长缓存,文件名里有 hash;基座的 history 路由要把 /report 前缀也 fallback 到基座的 index.html:
1# 子应用 report.internal.com 2location / { 3 add_header Access-Control-Allow-Origin *; 4 try_files $uri $uri/ /index.html; 5} 6location = /index.html { 7 add_header Cache-Control "no-cache, no-store"; 8 add_header Access-Control-Allow-Origin *; 9} 10 11# 基座 admin.internal.com,/report 是基座路由的一部分,不是反代 12location / { 13 try_files $uri $uri/ /index.html; 14}
跟运维对这份配置时他问了一个好问题:/report 到底该由谁响应?iframe 时代答案是反向代理到子系统,微前端时代答案变了——地址栏的 /report 是基座的路由,子应用的资源从它自己的域名走,两件事彻底分开。这个认知上的转弯值得写进接入文档里,不然每接一个子应用都要跟运维重新解释一遍。
权限和菜单归谁管
还有一块试点前没想到要决策的:权限。单体时代路由权限是一张表,菜单树、路由守卫、按钮权限码都从它派生。拆出去之后,报表的页面路由归子应用了,菜单还挂在基座上,权限判断在哪做?
我们定的边界是:基座管"能不能进",子应用管"进去之后能干什么"。 基座的路由守卫在 /report 前缀上校验用户有没有报表模块的权限,没有就根本不加载子应用;子应用内部的页面级、按钮级权限,由它自己按 props 传下来的用户信息判断。菜单树仍然由基座统一渲染——试过让子应用注册自己的菜单,发现时序上很别扭(菜单要在子应用加载前就渲染出来,可子应用没加载哪来的注册),最后还是回到基座配置里静态声明,子应用只保证路由和菜单声明对得上。这块没有标准答案,但这个分工必须在接入第一个子应用时就定清楚,不然后面每个子应用都会长出自己的一套。
样式和全局变量:第一印象
接下来是所有微前端文章都绕不开的话题:怎么让子应用和基座的样式互不打架。先说样式。qiankun 默认的样式隔离方式是"卸载时移除子应用的 style 节点",也就是说同一时刻只有激活的子应用的样式在页面上,切换应用时把旧样式摘掉。这对"基座 + 单个子应用"的场景基本够用,但有两个现实的破绽。
第一个破绽是基座和子应用同时在场。两边都用 Element UI,版本还不完全一致(基座 2.13,子应用抠出来时顺手升到了 2.14),两份 Element 的全局样式同时挂在 document 上,谁后加载谁生效。试点期间就撞上一处:子应用的 el-table 行高和基座的列表页不一样了,查了半天发现是两个版本的默认样式有细微差异,后加载的子应用样式把基座的表格也带偏了。临时的处理是把基座和子应用的 Element 版本锁成同一个,根上的问题(全局样式并没有真的分开)先记账。
qiankun 2.0 提供了一个实验性的 strictStyleIsolation 开关,把子应用整个包进 Shadow DOM 里,理论上两边样式互不干扰。我试了半个下午就退回来了:Element UI 的弹窗、下拉面板是 append 到 document.body 上的,DOM 逃出了 shadow root,样式却还锁在里面,弹窗直接裸奔成无样式状态。这个问题社区 issue 里一排人在讨论,目前没有省心的解法。Shadow DOM 式的硬隔离和组件库"往 body 上挂弹层"的习惯,在今天是结构性冲突,选微前端方案时要按"样式隔离靠约定而不是靠机制"来做预期。 我们的约定版方案是:子应用所有全局样式加统一前缀,Element 的主题变量两边共用一份。
JS 这边,qiankun 的沙箱默认开着,子应用对 window 的赋值会被代理起来、卸载时还原。试点里它确实兜住了一件事:报表子系统里有个老依赖往 window.moment 上挂东西,切走再切回来没有出现重复初始化的报错。但我对沙箱的理解目前就停留在"它替我挡了一枪"的程度,Proxy 代理具体挡住了哪些操作、哪些逃逸场景兜不住(比如 setInterval 没清、事件监听挂在 document 上),文档语焉不详,社区文章质量参差。沙箱这些细节还没吃透,值得单独花时间研究,这次评估先不把它计入"已验证"的能力清单。
顺带记一个真实踩到的逃逸案例:子应用一个图表组件在 mounted 里往 document.body 上挂了 resize 监听,unmount 时没摘,切回基座后监听器还活着,回调里访问已销毁的 Vue 实例,控制台一片红。沙箱不管 DOM 事件监听,子应用的 unmount 必须把自己在全局挂的东西亲手收干净,这条纪律微前端救不了你。
公共依赖:目前最没有好答案的一块
试点里最让我皱眉的是依赖体积。基座有一份 Vue + Vue Router + Vuex + Element UI + ECharts,报表子应用又打了一份几乎一样的,首次进入报表页要多下载 700 多 KB 的重复依赖。对内网中后台来说不致命,但如果按规划拆成五六个子应用,每个都背一份全家桶,这个账就难看了。
现成的路子是 webpack externals:子应用把 Vue、Element 声明成 external,运行时从基座暴露的全局变量里取。能跑通,但代价是子应用独立访问的能力被打了折——独立开发时得自己在 index.html 里补 CDN script 标签,而且版本被基座锁死,"技术栈无关"退化成"技术栈无关,但依赖版本要跟基座商量"。qiankun 官方对这块的态度也是"没有银弹",issue 里讨论了很久没有定论。
我们试点的选择是先不做共享,接受重复打包,理由是过早引入 externals 会把"子应用独立性"这个最大的收益提前卖掉。听说 webpack 5 的 beta 里有个叫 Module Federation 的新机制,号称能在应用之间直接共享模块,也许是这个问题的正解,但 webpack 5 正式版还没发布,只能先记在观察清单上。
应用间通信倒是没成为问题。qiankun 有 initGlobalState 提供一个发布订阅式的全局状态,我们只用它同步了两件事:登录态失效的广播、主题色切换。试点期间刻意把通信面压到最小——两个应用之间传的状态越多,说明拆分的分工切错了地方,通信方案越强大越容易把这种错误的切法遮过去。 登录态本身没走通信,token 在同域的 localStorage 里,两边的 axios 拦截器各自去读,天然就是通的。
评估结论
两周结束,我把结论整理成三条汇报给了组里。
第一,接入成本可控,收益真实。基座一百行、子应用两百行左右的改造量,报表子系统现在可以独立发版,营销组不用再等订单组。构建时间上,子应用自己两分钟内构建完,主应用抠掉报表模块后也快了近一分钟。
第二,隔离能力不能高估。样式隔离靠版本锁定和命名约定在撑,JS 沙箱有效但作用范围没吃透,公共依赖没有好方案。这三条决定了现在不适合把核心业务域(商品、订单)拆出去——它们和基座的耦合比报表深得多,撞上这类问题的概率和代价都大。
还有一笔容易被漏算的账是协作成本。试点期间报表组的同事要学一套新的启动方式、要理解"独立跑"和"挂在基座里跑"两种形态的差异,QA 的回归用例也要按两种形态各过一遍——弹窗、路由跳转、权限这些在两种形态下行为可能不同。这部分成本是一次性的,但每接一个新子应用都要重付一遍,接入文档写得越细这笔钱越省,我把这两周踩的坑都沉淀进了一份内部接入手册。
第三,节奏上小范围试点、不急于全面推开。报表子应用保持现状运行一个季度,观察线上问题率和团队协作的真实变化;下一个候选是那个 AngularJS 报价系统,它是 iframe 体验问题的直接受害者,也是"技术栈无关"这个卖点唯一的真实检验场。全面拆分的决策押后到明年,等 qiankun 的样式隔离、webpack 5 的模块共享这些变量落定一些再说。
回到开头的问题:我们的场景值不值得拆?两周试下来的答案是"报表这样边界清楚的值得,核心域现在不值得"。社区的热闹归社区,自己的痛点清单和成本清单摆在一起,答案其实不难下——难的是忍住不被"技术栈无关"这类漂亮词带着走。试点应用还在线上跑着,季度末看数据。