ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue2项目微前端改造:qiankun落地实践与踩坑指南

Vue2项目微前端改造:qiankun落地实践与踩坑指南 1. 拆分前必须算清的账微前端真正解决的是组织协作问题1.1 别因为“代码太大”就拆这个理由站不住脚微前端这三个字过去两年几乎是前端架构讨论里的默认关键词。但我在落地第一个 qiankun 项目前先带着“决定本身能不能被推翻”的视角做了复盘结论非常明确微前端解决的是组织协作、独立交付、渐进升级这类问题而不是单纯的“代码体积控制”问题。如果某个系统只是单仓库膨胀、想让构建快一点那拆分微前端很可能是负优化。我先说一个真实场景。我接手的那套 Vue2 后台管理系统经历了三个团队、两轮外包交接代码里存在三套风格不完全相同的权限逻辑十几个业务模块共用同一个全局 store发布一次全量回归需要大半天。这种状态下团队之间合代码的冲突已经不是“偶尔疼一下”而是几乎每次合并都要人工协调。再叠加一个刚成立的子团队需要独立迭代某条新业务线这时候微前端的价值就变得非常具体让不同团队能够在自己负责的模块上独立开发、独立构建、独立部署互不阻塞。反过来如果你们就是一个团队维护一个后台总页面量几十个代码体积虽然大但路由模块划分已经做得很干净那我建议不要引入微前端。原因很直接微前端引入的是运行时加载、沙箱隔离、多应用生命周期管理这些复杂机制你会为“治理问题”付出远超代码收益的成本。1.2 拆与不拆的决策清单我通常用一个四问清单来判断项目该不该拆你可以直接拿去做评估团队数量是否大于等于两个且存在明确的业务模块归属各业务模块的发布节奏是否不一致比如一个模块每周发版另一个模块每季度发版是否有多条业务线需要在同一个页面容器中同时呈现或者需要从统一入口进入不同业务系统历史上是否出现过因为一个模块的紧急修复导致其他模块被迫跟着发版阻塞的线上事故如果四个问题里只有第一问是“是”那大概率不该拆。如果是两问以上命中才值得进入正式选型。我见过不少团队把项目“为了微前端而微前端”地拆了最后子应用数量七八个真正长期活跃更新的只有两三个剩下的成了无人维护的遗留模块这种拆分反而把运维成本抬高了。1.3 你必须接受的额外成本微前端不是免费的午餐。拆完之后你会立刻面对原本单页应用里不存在的几个问题本地调试需要同时启动多个应用主应用、子应用、公共组件库环境变量和端口都要约定。沙箱隔离不是万能的JS 沙箱覆盖了主要执行环境但某些第三方脚本、Web Worker、跨域 iframe 仍然存在“漏网之鱼”。构建产物体积会从“一个大包”变成“多个中包”如果公共依赖没有被正确提取用户在访问多个子应用时会重复下载基础代码。团队需要新增一套“应用注册、依赖治理、版本兼容”的流程约定这部分属于长期维护成本。说实话这些成本很难完全规避但如果你拆分前把“治理规则”想清楚它们是可以被控制在可接受范围内的。我说这些不是劝退而是希望你在动手前做好心理预期。2. 从选型到规划基座、子应用与依赖的边界设计2.1 为什么在这个项目里我选了 qiankun微前端技术选型市面上无外乎这几种方案iframe、single-spa、qiankun、wujie。我最初也想过直接用 iframe毕竟 iframe 天然隔离、不用改造构建体系但很快就放弃了。原因非常实际iframe 之间的路由状态同步、全局弹窗和抽屉的跨域通信、以及不同子应用之间的视觉统一处理起来都极其别扭用户感受也差。single-spa 是更底层的方案它只提供应用加载和生命周期管理JS 沙箱和样式隔离都需要自己实现。考虑到我们要接手的代码本身已经比较混乱如果再在上层叠加一堆自研基础能力风险不可控。最终我选了 qiankun原因很朴素它对 single-spa 做了封装提供了开箱即用的 JS 沙箱、HTML 入口加载、样式隔离方案而且对 Vue2 项目的接入路径非常成熟社区里踩坑案例一搜一大把遇到问题能很快找到参考。另外qiankun 的应用切换机制不是 iframe 那样重新建立文档环境而是在同一个文档流里进行微应用的挂载和卸载这对我们那套需要共享全局登录态和系统主题的后台来说更合适。2.2 统一技术栈还是混合技术栈很多人在拆微前端时陷入一个纠结子应用能不能用不同框架我的建议是如果团队不是已经有非常明确的“某框架专家小组”第一天不要搞混合技术栈。我们当时的情况是全家桶都是 Vue2所以我确定了一个原则所有子应用先统一 Vue2主应用也是 Vue2。这样做的理由是新拆出来的子应用要复用公共组件、统一 UI 规范、共享工具函数混合技术栈会把这些原本简单的复用全部变成跨框架通信问题。至于 Vue3 的迁移那是后续渐进的事情等某个模块的代码已经瘦身到可控范围再把那个子应用单独迁到 Vue3qiankun 对子应用具体用什么框架并不敏感它只关心你有没有导出正确的生命周期钩子。这样既保证了当下推进速度又给未来升级留了缝隙。2.3 仓库组织独立仓库还是 monorepo这是我在拆分规划期花时间最多的问题。最终我采用了“主应用独立仓库子应用独立仓库”的多仓模式。每个子应用对应一条独立业务线仓库权限可以精确到团队构建和发布互不牵连没有依赖纠缠。但我也要说多仓不是唯一正确答案。如果团队人数少想通过 monorepo 统一管理公共依赖版本用 pnpm workspace 把主应用和子应用放在同一个大仓库里也是可行的。这种方式的好处是公共组件库的版本升级可以被统一编排坏处是仓库权限难以按团队隔离一旦子应用数量多了CI 配置会变得很绕。从我的经验来看判断标准只有一个你的团队自治能力强不强。如果团队之间本来就存在较弱的协作信任选择多仓如果你想通过技术手段强制约束版本选择 monorepo。两种方案 qiankun 都支持不会有硬性限制。2.4 公共依赖的共享策略拆分后第一个摆在面前的问题就是 Vue、Vue Router、UI 组件库这些公共代码怎么办。我采用的方案是用 externals 把 Vue 和 UI 库抽到主应用全局注入子应用在运行时直接从 window 上拿。这样做有几个好处跨子应用切换时Vue 框架代码不会重复加载整体体积更可控同时所有子应用强制使用同一份 UI 库视觉统一性更好。但这套方案有一个前置条件就是主应用和子应用的公共依赖版本必须保持兼容否则很容易出现某个子应用期望 Vue 2.6而主应用注入的是 2.7导致运行时报错。我们的做法是把这个版本约定写进团队的架构文档里任何升版本都必须经过统一评估不能只改自己仓库。如果你的项目里各个团队对依赖版本控制没什么信心那这个策略就要谨慎另一个稳妥做法是让子应用各自打包自己的依赖接受一部分重复加载的成本。3. 使用 Vue2 从零搭建 qiankun 应用主应用与子应用改造实录3.1 主应用初始化与微应用注册主应用这边我先在项目里安装 qiankunnpm install qiankun然后在入口文件中注册子应用信息。这里直接给出我当时的核心配置import { registerMicroApps, start, initGlobalState } from qiankun registerMicroApps([ { name: biz-order, // 子应用唯一名称不能重复 entry: //localhost:8081, // 子应用入口开发环境指向 dev server container: #subapp-viewport, // 子应用挂载容器 activeRule: /portal/order, // 匹配路由前缀 }, { name: biz-user, entry: //localhost:8082, container: #subapp-viewport, activeRule: /portal/user, }, ]) start({ prefetch: false, sandbox: { experimentalStyleIsolation: true, }, })这里有几个点我要特别解释。entry 配置的是子应用的 HTML 入口地址qiankun 会通过 fetch 去获取这个 HTML然后解析里面的脚本和样式。开发环境下 entry 自然指向子应用的本地 dev server生产环境则指向子应用静态资源服务器上的 index.html。activeRule 是触发子应用挂载的路由规则它和子应用内部路由的 base 必须对应起来不然会出现子应用加载后 404 的情况。container 指向的 DOM 节点是必须真实存在的我习惯在主应用布局组件里放一个对应子应用出口的 divdiv idsubapp-viewport/div这个容器在子应用挂载后会替换成子应用的完整视图。如果某个子应用不是通过路由切换触发的比如一些全局弹窗或工具类应用qiankun 还提供了 loadMicroApp 方法手动加载不过我这边主要用的还是路由型注册方式。3.2 子应用的 webpack 配置调整子应用改造的第一步是调整构建配置。我们的子应用都是基于 Vue CLI 搭建的需要在 vue.config.js 里暴露一个可被 qiankun 识别的 UMD 包const { name } require(./package.json) module.exports { devServer: { port: 8081, headers: { Access-Control-Allow-Origin: *, }, }, configureWebpack: { output: { library: ${name}-[name], libraryTarget: umd, jsonpFunction: webpackJsonp_${name}, }, }, }这里的 libraryTarget: umd 是为了让 webpack 打包出的产物符合 qiankun 加载模块的要求library 名称设成包名模块名是为了避免多个子应用同时存在时 window 上的全局对象互相覆盖。如果你用的是 webpack5比如 Vue CLI 5 创建的项目jsonpFunction 这个字段要改成 chunkLoadingGlobal不然会有运行时加载 chunk 的冲突报错。configureWebpack: { output: { library: ${name}-[name], libraryTarget: umd, chunkLoadingGlobal: webpackJsonp_${name}, }, }另外devServer.headers 里必须加上跨域放行。因为开发环境下 qiankun 主应用是通过 fetch 去拿子应用 HTML 的如果子应用 dev server 不允许跨域加载会直接失败控制台会给出很明确的 fetch 报错。3.3 子应用的入口文件与生命周期导出这是整个子应用改造里最核心的部分。先建一个 public-path.js处理子应用在 qiankun 环境下静态资源路径的自动适配if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ }这个文件要在入口文件的最顶部引入否则后面 import 的代码在计算路径时会用到错误的 publicPath导致子应用里图片、异步 chunk 全部 404。然后是 main.js 的改造。原来直接 new Vue() 挂载的逻辑要改成导出 bootstrap、mount、unmount 三个生命周期钩子import ./public-path import Vue from vue import VueRouter from vue-router import App from ./App.vue import routes from ./router Vue.use(VueRouter) let router null let instance null function render(props {}) { const { container } props router new VueRouter({ base: window.__POWERED_BY_QIANKUN__ ? /portal/order/ : /, mode: history, routes, }) instance new Vue({ router, store, render: (h) h(App), }).$mount(container ? container.querySelector(#app) : #app) } if (!window.__POWERED_BY_QIANKUN__) { // 单独运行时直接渲染 render() } export async function bootstrap() { // 子应用首次加载时调用一次 } export async function mount(props) { render(props) } export async function unmount() { instance.$destroy() instance.$el.innerHTML instance null router null }这里有个容易被忽略的细节container.querySelector(#app)。子应用在 qiankun 环境下挂载时qiankun 会把子应用渲染到主应用提供的容器里这个容器内部结构由子应用自己的模板决定所以必须在容器范围内查找子应用的根节点而不是直接查 document。如果直接写$mount(#app)很可能挂到主应用的某个同名节点上或者因为找不到节点而报错。unmount 里一定要把 router 和 instance 清空。这是我在前几个项目里踩过的地方Vue 实例销毁了但 router 还保留着对其他模块的引用多次切换后内存会一路上涨。3.4 本地联调与多应用启动拆分后的开发体验是必须提前设计的。我把主应用固定跑在 8080子应用分别占用 8081、8082、8083端口号在全团队范围内统一登记。每个子应用的 dev server 都配置了跨域头主应用通过代理转发一部分与后端相关的接口请求。启动方式上我自己习惯写一个根目录的 package.json在 scripts 里用 concurrently 同时拉起主应用和所有子应用{ scripts: { dev: concurrently \npm run dev:main\ \npm run dev:order\ \npm run dev:user\, dev:main: cd main-app npm run serve, dev:order: cd biz-order npm run serve, dev:user: cd biz-user npm run serve } }如果团队人多我还会建议每个人只启动自己关心的子应用而不是全量启动。全量启动虽然省事但对机器内存的压力非常大Vue2 dev server 本身是不太省内存的。4. 状态、路由与样式隔离子应用接入后的三个高频坑4.1 主应用与子应用的通信方案怎么设计微前端架构里通信是最容易“随手乱写”的地方。qiankun 提供了两种主要通信方式一种是注册微应用时通过 props 传数据另一种是 initGlobalState 创建全局状态。props 传参适合传递初始化类数据比如用户基本信息、登录 token、系统主题配置等。在主应用注册时传入registerMicroApps([ { name: biz-order, entry: //localhost:8081, container: #subapp-viewport, activeRule: /portal/order, props: { userInfo: store.state.userInfo, token: store.state.token, }, }, ])子应用在 mount 钩子里通过 props 接收export async function mount(props) { render(props) }全局状态则适合需要跨应用实时同步的数据比如用户切换组织、全局菜单权限变化这类。qiankun 的用法是先初始化全局状态import { initGlobalState } from qiankun const actions initGlobalState({ currentOrg: , userInfo: null, }) actions.onGlobalStateChange((state, prev) { // 主应用内部监听状态变化 }) // 某个时刻需要更新数据时 actions.setGlobalState({ currentOrg: org-123, })子应用内部则是通过从 qiankun 里拿到的 actions 引用做同样的监听和更新。这里我要提醒一句不要把子应用的局部表单状态、组件内部 UI 状态放进全局状态里否则你会收获一个没法维护的“大杂烩状态仓库”。全局状态只放那些跨应用真的有同步需求的数据其余一律留在各自 Vuex 或组件内部。4.2 样式隔离默认不隔离别被骗了qiankun 的 sandbox 参数主要做的是 JS 沙箱样式隔离默认是不开启的。也就是说如果你什么都不配子应用在挂载和卸载时虽然会做一些样式出入栈处理但同一时间如果多个子应用共存或者子应用与主应用都有全局样式冲突依然存在。我在主应用启动时开启了 experimentalStyleIsolation:start({ sandbox: { experimentalStyleIsolation: true, }, })这个配置项的原理是通过把子应用样式改造成带属性选择器的形式类似 Vue 的 scoped让样式作用范围被限制在子应用容器内。它比 strictStyleIsolation 那种基于 Shadow DOM 的方案更稳妥因为 Shadow DOM 会隔离 DOM 结构很多弹窗组件如果渲染到 body 下就会出现找不到内部上下文的问题。但 experimentalStyleIsolation 也不是万能的。如果子应用的样式里大量使用深层的全局选择器比如直接给 body 设置背景或者用了不受属性选择器控制的第三方 UI 组件样式覆盖隔离效果还是会打折。我们的做法是两条腿走路一方面依靠 qiankun 提供的样式隔离另一方面在子应用的项目规范里约定不要写直接作用于 body、html 的全局样式尽量把组件样式收敛到自己的作用域内。4.3 全局变量、定时器与事件监听器的清理这是比样式更容易出事故的地方。子应用里经常会有一些全局资源比如 setInterval 做轮询、window.addEventListener 监听键盘事件、或者通过 new WebSocket 建立的连接。如果这些资源是在 mount 阶段注册的而 unmount 阶段没有手动清理那子应用每次切走再切回来旧的资源和新的资源会叠加并存轻则内存泄漏重则行为异常。我在代码里专门有一个通用的清理函数放进了项目的公共模块里export async function unmount() { if (timer) { clearInterval(timer) timer null } window.removeEventListener(keydown, handler) if (socket) { socket.close() socket null } instance.$destroy() instance.$el.innerHTML instance null router null }这个清理动作建议在子应用改造的第一天就写进去不要等上线后出问题再补。因为线上环境的特征往往是“偶发、难以复现”等运维反馈某次切应用后整体卡顿你很难立刻定位到是哪个全局监听器在捣乱。4.4 子应用内部弹窗和路由的坑Vue2 的弹窗组件很多是通过 append 到 body 下面实现的比如 Element UI 的 Dialog。在 qiankun 环境下如果弹窗内容里包含子应用自己的路由链接或者业务组件它会正常渲染但样式隔离的属性选择器不会作用于 body 下的节点样式会丢失或错乱。这个问题的常见处理方案是手动把弹窗挂载到子应用的容器内比如给 Dialog 指定 aappend-to-body 为 false或者通过 Teleport 的替代方案处理。路由方面需要注意的则是 base 前缀一致性。子应用内部如果用了 this.$router.push(/detail/123) 这样的写法在 qiankun 环境下会自动拼接 base 前缀这本身没有问题。但如果你在子应用里用 window.location.href 跳转会直接跳出子应用的历史管理导致 qiankun 无法拦截跳转整个应用可能变成浏览器原生刷新。架构约定里最好写清楚了子应用内部的跳转一律使用 Vue Router 实例不要直接操作 window.location。5. 构建、部署与运维微前端全链路的最后一公里5.1 独立构建与独立发布拆微前端在本地开发完成之后真正的考验在部署链路。这里我给的建议是每个子应用必须有自己独立的构建任务和发布流水线不能回到“一个发布按钮全部更新”的老路上。否则拆分就白做了。我的操作是给每个子应用配置单独的 CI 流水线构建产物打到独立的对象存储或静态服务器目录产物路径带上版本号或构建号。主应用也会单独构建但主应用发布时不需要重新构建子应用它只是维护了一份“当前应该加载哪个子应用地址”的注册配置。也就是说子应用的入口地址可以被设计成一个不带文件哈希的固定路径例如 https://static.example.com/biz-order/index.html这样即便子应用内部资源文件带有内容哈希入口 URL 是稳定的主应用注册配置就不需要频繁改动。5.2 生产环境 Nginx 路由规则生产环境里最需要小心的就是 Nginx 的 try_files 规则。主应用和子应用处于不同的静态服务路径如果子应用的路由匹配到主应用的重写规则里就会导致子应用刷新时直接 404。我当时的主应用 Nginx 配置大概是这样的server { listen 80; server_name portal.example.com; root /data/www/main-app; index index.html; location / { try_files $uri $uri/ /index.html; } location /portal/ { proxy_pass http://child-server; } }逻辑是主应用自己的页面路由交给 SPA 的 index.html 兜底而所有以 /portal/ 开头的请求都转发给子应用的静态服务器由子应用的独立 Nginx 配置继续处理。子应用那边的配置也有自己的 try_files 兜底规则保证子应用内部的深层路由刷新时不 404。这里最忌讳的是把两个服务的匹配规则混在一起。比如有些同学图省事把子应用的静态目录也放在主应用服务器的根目录下这样做短期内看起来没问题但子应用升级时容易把主应用的静态资源管理搅乱而且两个项目的缓存策略互相影响出了问题很难排查。5.3 线上问题的排查顺序最后分享一套我自己跑通下来的排查方法。微前端架构的线上问题往往比单体应用多一层“加载链路”出问题时不要闷头猜按顺序往下走第一步确认子应用入口地址能否在浏览器独立访问。如果直接访问子应用 index.html 都白屏那是子应用自身问题别扯微前端。第二步确认主应用页面里发起 fetch 子应用 HTML 请求是否成功。打开 Network 面板看主应用访问子应用入口时是否出现跨域报错或 404。第三步确认子应用生命周期是否正常触发。在子应用 bootstrap、mount 里打日志看挂载流程有没有走到。第四步看沙箱隔离是否破坏了运行环境。比如某个子应用依赖了它自己定义的全局变量结果被另一个子应用覆盖这种情况通常表现为“单独跑没事进主应用就有问题”。第五步看样式是否错乱。切换的时候截个图对比必要时临时关掉样式隔离来定位是不是样式作用域引起的。这套顺序我一直保留着每次线上告警都能快速收敛范围。5.4 关于迁移节奏的一点个人体会整个项目从拆分到落地前后花了两周左右但真正让我受益的反而不是具体代码而是节奏控制。我们并没有一次性把所有模块全部拆出来而是先拆了两个边界最清晰、团队归属最明确的业务模块作为试点跑通主应用注册、路由联动、部署发布的完整链路后再逐步把剩余模块迁移进来。这种渐进式拆分的好处是每一步的改动范围都足够小出了问题可以快速定位。而且团队在这个过程中会逐渐形成一套关于命名规范、状态管理、公共依赖的共识后面再迁移新模块时基本是体力活不太容易再踩出新坑。如果你也准备在现有 Vue2 项目里引入 qiankun我建议你也给自己留出试点周期别试图在一个发布窗口内完成全量拆分。
返回列表