上个月我们刚把公司里一个跑了六年的 Vue2 老项目接进了 qiankun 微前端,前后折腾了大半个月。这个项目是当初用 Vue2 + Vue CLI 3 起的,业务模块越堆越多,光路由文件就有三千多行,每次发版都牵一发动全身。正好新团队那边要用 Vue3 + Vite 做一套全新的业务系统,两边又需要共用主框架导航、外层鉴权和统一的登录态,微前端这件事终于被提上了日程。
方案选型阶段我没怎么纠结,直接定了 qiankun。单从 Vue2 老项目接入的舒适度来看,qiankun 的几个设计确实是对存量项目最友好的:不需要推倒重来,打包层面的改造量很小,HTML Entry 这套玩法连不怎么懂构建的同事都能理解。这篇文章把整个改造过程里我认为最有价值的思路、核心机制原理、实操步骤和踩过的坑都串一遍,给打算在 Vue2 项目里搞微前端的朋友做个参考。
1. 为什么要把好好的 Vue2 老项目接进 qiankun
1.1 单体应用被撑爆的信号:先确认“要不要拆”
在动手之前,先得确认自己的项目到底是不是真的需要微前端。很多团队看到“微前端”三个字就觉得高级,想跟风搞一套,结果拆完比不拆还痛苦。我的判断标准很简单:当你发现下面这几种情况出现了两个以上,再考虑拆分。
第一,你们有两条或两条以上业务线,各自独立迭代、独立发版,却挤在同一个仓库和同一套部署流程里。比如我们这个项目里,内部管理端和 To C 门户就是两套完全不同的业务,但是共用一套发布管道,内部业务改一行代码也要走整个项目的回归测试。
第二,技术栈升级被卡死。老项目是 Vue2,团队里新人已经写了很久 Vue3,但核心模块不敢动,一升级就是全局联动。我们当时也评估过直接把 Vue2 升到 Vue3,结果发现组件库、第三方插件、内部封装的公共模块全部要跟着动,工期排到明年。
第三,某个模块的访问量或者内存占用已经影响整体稳定性。我们有个在线文档预览模块,基于 vue-office 那套方案做的,渲染起来非常吃内存,经常把整个后台首页的响应速度拖下来。
如果上面这些情况你一条都不占,那微前端对你就是负担。微前端本质上解决的是“一个团队维护多个技术栈、多条业务线、多套部署流程”的组织效率问题,而不是技术炫技。
1.2 微前端方案横向对比:iframe、single-spa、Module Federation 与 qiankun
聊 qiankun 之前,得把它放进微前端方案的坐标系里看看,否则你很难理解为什么选它而不是别的。
iframe 是最容易被拉出来对比的方案。实现成本确实低,隔离性也最好,子应用内部随便折腾都影响不到主应用。但它在真实业务里有两个致命问题:一是刷新、前进后退、弹窗层级和登录态同步这些交互体验会被割裂,二是每个 iframe 都是一套独立的浏览器上下文,内存和连接数开销翻倍。我们最早内部做 POC 的时候拿 iframe 试过一个模块,结果那个页面打开超过半小时,整个后台的响应就开始变卡。问题还不出在业务代码,而是 iframe 本身就是个性能黑洞。
single-spa 是祖师爷级的方案,qiankun 的核心调度能力也是构建在它之上的。但 single-spa 只负责加载和生命周期管理,JS 沙箱、样式隔离这些全都要自己额外实现。换句话说,用 single-spa 你得先成为一名微前端框架开发者,这在项目交付场景里是不可接受的。
webpack Module Federation(MF)又是另一个维度的方案,它解决的是“模块共享”问题,可以做到构建时或运行时共享依赖。但 MF 更适合技术栈统一、构建链路可控的团队,用在多技术栈并存的老项目改造上,对各个子应用的构建工具版本要求很苛刻,Vue CLI 3 这种老构建链路接起来会很痛苦。
qiankun 强在它是站在 single-spa 肩膀上做的开箱即用封装,把沙箱、样式隔离、通信都内置了,而且对子应用的构建要求极低。它允许你继续用 Vue CLI、继续保留原有的 index.html,甚至子应用不需要知道自己是微前端的一部分就能跑。这一点对存量老的 Vue2 项目简直太关键了。
下面是当时我做技术选型时列的简表:
| 方案 | 隔离能力 | 接入改造成本 | 对历史构建链路的兼容性 | 适合场景 |
|---|---|---|---|---|
| iframe | 强 | 极低 | 无要求 | 完全隔离的独立页面,不追求交互统一 |
| single-spa | 无内置 | 高 | 需要改造成 JS Entry | 有精力自行封装团队 |
| Module Federation | 模块级 | 中高 | 要求 webpack5 + 统一构建 | 技术栈统一的新体系 |
| qiankun | 内置 JS 沙箱 + 样式隔离 | 低 | 几乎任意,HTML Entry 直读 | 存量老项目多技术栈并存 |
1.3 qiankun 的三板斧:HTML Entry、JS 沙箱与样式隔离的原理
qiankun 和早期微前端方案最大的区别,是它默认用 HTML Entry 而不是 JS Entry。
所谓 HTML Entry,是说你在主应用里注册子应用时,entry 字段直接填一个 URL,比如http://localhost:7101。qiankun 会在运行时用 fetch 把这个地址对应的 HTML 拉下来,解析出里面的<script>和<link>标签,然后动态加载并执行这些脚本。老项目不需要为了让别人接入而特意改造成“导出 JS 文件”的形式,入口是什么样,微前端框架就直接按什么样去加载。这个设计让 Vue2 老项目的接入成本低了一大截,你甚至都不用改子应用的 index.html 里引的那些全局脚本。
JS 沙箱是 qiankun 做的第二件事。多个子应用挂在同一个页面下,如果你不隔离 window,A 应用给 window 上挂了一个window.__foo,B 应用再挂一个window.__foo,两个应用就直接打架了。qiankun 在单实例模式下默认使用 Proxy 沙箱,它给子应用构造了一个虚拟的 window 对象,子应用对 window 的读写操作都会先经过这个 Proxy。写操作记录在沙箱内部,应用卸载时再把变更过的属性复原。这样能保证子应用之间的全局变量互不干扰,这也是我对 qiankun 最有信心的部分。
不过在沙箱这件事上有一点要提前有心理准备:Proxy 沙箱对通过Object.defineProperty修改全局对象、或者某些第三方 SDK 直接往 document 上塞东西的行为,确实存在漏网之鱼。这点后面在坑的部分会细说。
样式隔离是第三板斧。qiankun 提供了两种模式,一个是严格模式的 Shadow DOM 隔离,这个隔离效果最强,但会让弹窗、下拉这些渲染到 body 上的组件样式失效;另一个是实验性样式隔离,它会在运行时把子应用的所有样式选择器加上div[data-qiankun="应用名"]这样的前缀。实际业务里我们用的就是实验性隔离,配合团队内部的样式命名规范,基本够用。
2. 主应用改造实战:一个 Vue2 壳工程的四个关键步骤
2.1 安装 qiankun 并注册第一个子应用
主应用这里其实就是把原来的 Vue2 项目当成一个“壳”,只负责用户登录、框架布局、菜单权限和子应用调度。安装依赖很简单,一条命令搞定:
npm i qiankun -S接下来在主应用的入口文件里注册子应用。我的建议是单独建一个micro-app.js来管理注册逻辑,不要在main.js里堆一大堆注册代码,后面加子应用的时候会非常乱。
// src/micro-app.js import { registerMicroApps, start, initGlobalState } from 'qiankun'; registerMicroApps([ { name: 'old-vue2-doc', // 子应用唯一标识,不能重复 entry: '//localhost:7101', // 开发环境指向子应用 devServer container: '#micro-container', // 子应用挂载的 DOM 节点 activeRule: '/doc-preview', // 当 URL 匹配到该路径时激活 props: { // 通过 props 下发静态配置 mainBase: '/', appName: '文档预览模块' } } ]); start();这里有几个字段值得啰嗦一下。name不只是标识,它还参与了样式隔离前缀的生成,比如>activeRule: (location) => location.pathname.startsWith('/doc-preview') && location.query.from === 'workbench'
很多教程到这里就完了,但实际开发中主应用还需要处理一件事:给子应用预留路由兜底。因为主应用的路由表里通常没有/doc-preview相关的路由,直接访问这个地址时 Vue Router 会匹配不到组件,页面就白屏。我当时的处理是在主应用路由的 catch-all 规则里,把这类路径转发到布局组件,让布局框架正常渲染,然后由 qiankun 把子应用内容填充到容器里。
2.2 start 参数怎么调:沙箱、预加载与单例模式
start()虽然可以直接调用,但生产环境我建议把所有可选参数显式配置出来,避免不同版本的默认行为差异踩坑:
start({ prefetch: 'all', // 预加载所有子应用,也可设为 ['old-vue2-doc'] sandbox: { experimentalStyleIsolation: true, // 开启实验性样式隔离 proxy: true // 开启 Proxy 沙箱 }, singular: true, // 单实例模式,同一时间只渲染一个子应用 fetch: window.fetch.bind(window) });singular这个参数在 Vue2 老项目场景下建议保持默认的 true。因为老项目里很多全局连接、全局定时器、全局监听都是按“单实例”假设写的,如果开了多实例,同一个子应用被同时挂载多次,这些全局逻辑会直接互相踩踏。网上很多讨论说多实例是 qiankun 的卖点,但那是针对新项目从一开始就按多实例规范来写的场景,不建议在老项目改造时逞强。
prefetch默认是 all,意思是首屏加载完就去后台拉取所有子应用的静态资源。如果你的子应用数量多、首屏网络环境又差,这里可以改成手动指定,或者干脆关掉。我实际测试下来,预加载对用户体验的提升主要体现在子应用首屏速度上,少了 200 到 500 毫秒的加载等待,但代价是主应用首屏会多传输几 MB 的静态资源。怎么取舍,看你们的网络状况。
2.3 主子应用通信:全局状态、props 与兜底方案
微前端架构里最容易被低估的就是通信设计。很多人一开始图省事,直接用window上挂变量,结果子应用一多,根本分不清数据是谁写的。
qiankun 官方提供了一套全局状态机制:
// 主应用初始化全局状态 const actions = initGlobalState({ token: localStorage.getItem('token') || '', userInfo: null }); // 监听状态变化 actions.onGlobalStateChange((state, prev) => { console.log('子应用修改了全局状态', prev, state); }); // 主动更新状态 actions.setGlobalState({ token: 'new-token' });子应用侧可以通过 mount 阶段的 props 拿到这套通信能力:
export async function mount(props) { props.onGlobalStateChange((state, prev) => { // 监听到主应用状态变化 }); props.setGlobalState({ moduleState: 'ready' }); }这套机制解决“主应用下发登录态、子应用上报状态”是够用的。但我在实际改造中发现一个场景它处理得不好:两个子应用之间要直接做业务数据联动。这时候硬用全局状态会非常别扭,因为状态变更需要先上报到主应用,主应用再广播给其他子应用,链路长且难排查。
我的经验是:登录态、权限、用户信息这类基础数据走initGlobalState;子应用之间的业务数据走各自的业务接口,让后端去做数据中轉,前端不要试图通过微前端框架建立一个跨应用状态总线。说白了,微前端是页面组织方案,不是前端状态管理方案,硬把状态耦合揉进去只会把系统搞得更僵。
如果遇到确实需要解耦的场景,我还会用window.dispatchEvent(new CustomEvent('xxx'))这类浏览器原生事件做兜底。虽然听起来很原始,但它在多框架共存的场景下意外地可靠,每个子应用只需要知道自己关心哪个事件名就行。
2.4 路由联动与加载失败的兜底处理
主应用菜单点击跳转子应用,这个链路其实由 activeRule 自动完成了,用户访问/doc-preview/xxx时 qiankun 会自动加载对应子应用。但有一个体验细节容易忽略:子应用内部的二级路由切换时,主应用侧边栏的菜单高亮不会自动跟随。这个一般通过主应用监听路由变化、再根据路径匹配菜单项来解决,代码不复杂,但必须在规划阶段就写好,否则后面加子应用就会出现菜单高亮和内容对不上的体验问题。
qiankun 启动后,容器区域在子应用加载过程中是空白的。我建议在主应用布局的容器节点附近放一个 Loading 组件,然后通过start({ loader })或者手动监听子应用加载中状态来控制它的显隐。这里我用了最简单的方式:在container的 DOM 节点内部预先放一行“模块加载中”的提示,等子应用 mount 完成,qiankun 会自动往容器里填内容,加载提示自然被顶掉。
还有一个必须处理的问题是子应用加载失败。比如子应用服务挂了、静态资源 404,qiankun 只会往控制台抛异常,页面容器会一直空白。我们的做法是在主应用的registerMicroApps生命周期钩子里,监听load失败事件,然后渲染一个统一的错误组件:
registerMicroApps(apps, { beforeLoad: () => {}, beforeMount: () => {}, afterUnmount: () => {}, // 加载失败时的处理目前建议用 try/catch 包住 start,或者监听子应用 entry fetch 的异常 });qiankun 的start()返回的是一个 Promise,有异常会冒泡出来。把start()包一层 try/catch,失败时在主应用里跳转到错误页或提示刷新,效果比干等白屏强得多。
3. Vue2 子应用接入:老项目改造最容易翻车的三个地方
3.1 生命周期导出与独立运行判断
子应用侧的第一步,是把入口文件从“直接创建 Vue 实例”改成“导出生命周期函数”。qiankun 要求子应用必须导出的三个生命周期是bootstrap、mount、unmount。
这里有个容易搞混的点:qiankun 要求的是“导出”,而不是“挂在 window 上”。Vue CLI 默认打包出来的库是 UMD 格式,会把这个导出挂到window上,qiankun 的 HTML Entry 机制是能拿到 window 上的生命周期函数的。但要写成下面的标准结构,兼容性最稳。
// src/main.js import Vue from 'vue'; import App from './App.vue'; import router from './router'; import store from './store'; let instance = null; function render() { instance = new Vue({ router, store, render: h => h(App) }).$mount('#app'); } // 独立运行:直接渲染 if (!window.__POWERED_BY_QIANKUN__) { render(); } // 子应用生命周期 export async function bootstrap() { console.log('vue2 子应用 bootstrap'); } export async function mount(props) { render(); } export async function unmount() { instance.$destroy(); instance = null; // 清干净容器里的 DOM,避免二次挂载时内容重复 const container = document.querySelector('#app'); container && container.innerHTML = ''; }window.__POWERED_BY_QIANKUN__是 qiankun 在沙箱环境注入给子应用的标记,用来区分当前是独立运行还是被微前端加载。这个判断必须放在入口最早的位置,因为有些全局初始化逻辑只在独立运行时要执行,比如开发环境的 mock 数据、独立的登录跳转等。
子应用被反复挂载和卸载时,最容易出问题的是没有执行反初始化。比如unmount阶段只把instance置空了,但全局的事件监听、定时器、第三方实例都还残留着。这里我的经验是:子应用里所有“启动”逻辑都包到mount里,所有“清理”逻辑都包到unmount里,不要在模块顶层做任何会被重复执行的初始化。
3.2 publicPath 与分包输出:静态资源 404 的根源
这一步是 Vue2 旧项目接 qiankun 时翻车率最高的环节,没有之一。
默认情况下,Vue CLI 构建出来的资源路径是绝对路径/js/app.js。子应用被嵌进主应用后,如果子应用部署在子路径比如/doc-preview/下,直接访问/js/app.js会打到主应用的域名根目录去,必然 404。
解决办法是让子应用的 webpack publicPath 变成动态的。官方推荐的方式其一是在入口文件里引入qiankun提供的 setPublicPath:
import { setPublicPath } from 'qiankun'; setPublicPath('/doc-preview/');这种方式会把运行时 publicPath 设置成部署后的子路径前缀。但我自己项目里更推荐直接用 webpack 的动态 publicPath 方案,因为它不依赖 qiankun 导出的方法,独立运行时不也不会被误伤:
// 入口文件最顶部 if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; }__INJECTED_PUBLIC_PATH_BY_QIANKUN__是 qiankun 在加载子应用时注入好的运行时 publicPath,把它赋给__webpack_public_path__,会让所有懒加载 chunk、图片引用的相对基础路径都变成子应用自己的部署路径。这个变量在子应用入口里要尽早赋值,必须在任何 import 其他模块的语句之前执行,webpack 的公共路径计算发生在模块加载最开始。
除了 publicPath,还有一个很隐蔽的分包问题。两个 Vue 子应用如果都是 Vue CLI 默认配置打包,分包的 chunk 命名规则都是chunk-xxxx,主应用同时预加载两个子应用时可能出现 webpack runtime 里的 chunk 命名冲突,表现在浏览器里是加载了 A 应用的 chunk,却被 B 应用拿来执行。我们的应对方案是在子应用的 vue.config.js 里给 chunk 命名加上应用前缀:
module.exports = { productionSourceMap: false, configureWebpack: { output: { chunkFilename: 'old-doc/[name].[contenthash:8].js' } } };这个改动成本极低,但能让多个子应用的产物在浏览器 cache 里井水不犯河水。
另外一个容易被忽略的点是 CSS 里的字体文件、背景图片,它们使用的相对路径同样基于 publicPath 解析。我们项目里有一个 iconfont 图标库,字体文件路径在样式隔离开启后一直报 404,排查到最后就是 publicPath 没覆盖到字体文件。处理方案一个是把字体文件改成绝对 URL 引用,另一个是确保 media 目录一起被打包到子应用部署目录下。
3.3 路由 base、挂载容器与开发环境联调
子应用的 Vue Router 在主应用环境下运行,路由实例的基础路径必须和 activeRule 匹配。比如主应用在/doc-preview激活这个子应用,那子应用的路由 base 也要设置成/doc-preview:
const router = new VueRouter({ mode: 'history', base: window.__POWERED_BY_QIANKUN__ ? '/doc-preview' : '/', routes });如果这个 base 不设置,子应用内部跳转时 URL 就会变成主应用根路径 + 子应用内部路径,activeRule 失配,子应用会被直接卸载掉,表现为“进去页面一闪就回到主应用首页了”。
挂载容器#app在上面的代码里直接用了页面上的节点,但实际运行在微前端环境时,这个节点存在于主应用的 DOM 里。要确保子应用脚本执行时这个节点一定存在,否则$mount('#app')会报找不到目标容器。我们的做法是在主应用布局里把容器节点写死在模板上,不要用 v-if 包裹,否则 qiankun 激活容器时节点还没创建出来,子应用 mount 就会失败。
开发环境联调是另一个高频坑。子应用npm run dev监听localhost:7101,主应用注册时的 entry 也填了这个地址,但你需要给子应用的 devServer 加上 CORS 响应头,否则主应用 fetch 子应用的 HTML 时会因为跨域被浏览器拦截:
// vue.config.js module.exports = { devServer: { port: 7101, headers: { 'Access-Control-Allow-Origin': '*' } } };这里我额外提醒一句:开发环境让子应用单独跑在 7101,主应用跑在另一个端口,两个应用在微前端环境下共享了 localStorage,但主应用和子应用如果你配置了不同的publicPath和 URL 前缀,它们内部存储的键容易互相覆盖。建议从一开始就把存储键规划好命名前缀,比如$old-doc-token、$main-token,避免调试时数据串了怀疑人生。
4. 跑起来之后躲不开的坑:样式、全局变量与部署
4.1 样式隔离的边界:弹窗、字体图标和 body 级 DOM
qiankun 的实验性样式隔离原理,是动态地把子应用里的样式选择器套上一层div[data-qiankun="应用名"]前缀。这意味着只有挂在子应用容器内部的 DOM,才会被这层前缀命中。但 Element UI 这类组件库的 MessageBox、Select 下拉、Tooltip 是直接往 body 上挂节点的,这些节点不在容器内,样式隔离对它们完全不生效,表现就是弹窗样式错乱,或者按钮间距失控。
这个问题有三个层次的处理方法。
假如你能接受少量样式互相渗透,最简单的方案是关掉experimentalStyleIsolation,改为让主应用和子应用都用同一套 element-ui 样式版本。我们项目里主应用和子应用之前就有版本差异,合到一起后样式乱成一团,最后统一把两边的 element-ui 都升级到同一版本,弹窗样式问题少了一大半。
如果必须要隔离,那就要给“弹窗层”单独做一份全局样式。Element UI 组件会渲染到 body 下,如果你给它加一个特殊的应用命名空间,比如通过appendTo属性把弹窗挂载到容器内,那样式隔离前缀就能命中。某些组件不支持 appendTo 的话,就只能手动修改弹窗 DOM 节点,等 render 完后给它加上>location /doc-preview { try_files $uri $uri/ /doc-preview/index.html; } location /doc-preview/index.html { add_header Cache-Control 'no-cache, no-store'; }
子应用里面的 JS、CSS、图片等带 hash 的资源文件则要开长效缓存,这样既保证及时更新又能享受缓存加速。
还有一个刷新 404 的问题。单页应用如果直接刷新一个子路由,比如访问/doc-preview/detail/123,Nginx 找不到这个物理路径会返回 404。上面try_files $uri $uri/ /doc-preview/index.html;的配置会把请求都 fallback 到子应用的 index.html,再由子应用路由接管渲染,这样刷新就不会白屏。
主应用也要注意:如果主应用不是部署在根路径,比如部署在/portal/下,那么主应用路由的 base 以及 qiankun 注册子应用时的 activeRule 都要按这个前缀走,这个容易漏。
5. 常见问题速查表与个人实操体会
5.1 按报错现象快速定位
把这次改造里遇到的高频问题和对应的排查思路整理成了下面这张表,遇到类似情况可以直接照着对。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 子应用一直加载中,容器空白 | entry 域名跨域、子应用 devServer 没开 CORS | 检查 Network 里 entry HTML 请求是否 CORS 报错,给子应用加响应头 |
| 子应用 JS 加载后提示生命周期导出失败 | 入口文件没有正确导出 bootstrap/mount/unmount | 检查入口文件导出,确认使用 webpack UMD 格式 |
| 子应用内部图片、字体 404 | publicPath 未动态化或路径前缀不对 | 在入口最顶部设置__webpack_public_path__或setPublicPath |
| 进入子应用后闪退回主应用首页 | 子应用路由 base 与 activeRule 不匹配 | 确认子应用 base 等于主应用激活路径前缀 |
| 子应用弹窗样式错乱 | 弹窗挂在 body 下,样式隔离前缀未覆盖 | 统一 UI 组件版本或通过 appendTo 让弹窗挂载到容器内 |
| 二次进入子应用报错或页面重复渲染 | unmount 未销毁 Vue 实例和全局监听 | 检查 unmount 中是否执行$destroy()和事件清理 |
| 线上更新半天不生效 | 子应用 index.html 被缓存 | 配置 index.html 不缓存,hash 资源长效缓存 |
| 子应用 A 挂载后主应用埋点失效 | 子应用内脚本覆盖了 window 上的同名全局变量 | 收敛子应用全局变量,公共 SDK 统一由主应用初始化 |
| 两个子应用 chunk 命名冲突 | webpack 分包 chunk 命名未加前缀 | 给分包配置独立chunkFilename命名空间 |
5.2 几句掏心窝的话
这次改造下来,我最大的感受是:微前端接入的技术难度其实不高,真正的复杂度在于周围那些“非技术”的约束。比如你得说服老团队接受一个新的发布流程,得跟每个业务模块的负责人确认拆分的边界,还得处理老代码里各种不规范的全局写法。
如果你正准备在自己的 Vue2 项目里引入 qiankun,我建议按这个顺序推进:先挑一个边界清晰、独立迭代频率高的模块试水(我们当时就选的文档预览模块),验证从主应用注册、子应用改造、部署到联调的完整链路;跑顺之后再逐步把其它模块迁移进来。不要一上来就规划一个宏大无比的全量拆分盘子,那不是微前端改造,那是项目重构,风险完全不是一个量级。
开发规范和回归测试清单要在真正联调之前就定好,尤其是生命周期里所有销毁动作。我在联调阶段有一半的时间都花在“切到另一个子应用再切回来发现页面崩了”的问题上,后来总结的经验就一句话:每一个在 mount 里创建的东西,都要在 unmount 里找到对应的销毁代码。
最后再分享一个小技巧:把子应用统一设计成“裸跑”模式,也就是不依赖主应用环境也能独立运行。这样每个子应用都能单独开 devServer 开发,能单独部署、单独验证,排查问题时也不用总开着主应用,效率会高很多。我们后续接入 Vue3 + Vite 新技术栈的步子能走得稳,靠的就是这个先定好的规则。