尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

微前端实战:ERP系统渐进式重构与qiankun落地指南

微前端实战:ERP系统渐进式重构与qiankun落地指南 1. 为什么是微前端ERP重构不能按照“推倒重来”的逻辑来先说个背景。我在上一家公司负责一套服务了十多年的ERP系统规模大概是前端仓库单仓70个模块、路由表两千多条、样式文件上万行、直接依赖的npm包一百多个。业务覆盖采购、销售、库存、财务、生产、质量、售后每个模块背后都有相应的业务方。这套系统在当年用了当时比较流行的前后端分离方案但在今天看来已经非常吃力一个采购功能的改动可能要编译五分钟一次发布全量打包产物接近30MB线上验证要跑两轮回归才能放量。重构一个大体量ERP一开始大家都会想到两个方案一是推倒重来二是整体迁移到新框架。我先说结论这两种思路在ERP这种业务复杂度极高的系统里风险都大到几乎不可操作。推倒重来意味着把十年来沉淀的业务规则全部重写一遍期间业务不能停、老系统要继续响应需求两套系统并行维护的成本是双倍的。整体迁移到新框架就更不现实了因为你根本不可能在有限的时间内把下线的一万多个页面全部验证完。所以ERP重构的真正解法不在于“替换掉旧系统”而在于“让旧系统的一部分可以独立演进、独立发布、独立自治”。这正是微前端架构最擅长的场景。我这次说的“微前端实战”不是给你讲概念而是完整记录我们如何把一个单体ERP逐步拆成多个可独立交付的微应用并且保证在拆分过程中业务一天都没有停过。如果你手头也有一套“改不动但必须改”的ERP这篇复盘应该能给你一些实际参考。微前端在业界的定义很讨巧它是一种多个前端应用共享同一个浏览器页面、但彼此技术栈无关、生命周期独立的架构模式。注意最后一个词“生命周期独立”。这意味着每个子应用可以自己决定何时路由、何时加载、何时卸载主框架只负责承载和调度。这套思路放在ERP里对应着一个非常直接的诉求业务模块之间本来就有清晰的边界为什么代码层面要强行耦合在一起2. 拆什么业务边界与菜单级拆分策略2.1 先认清ERP的本质它不是“一个系统”是一堆系统的集合ERP这个名字容易误导人看起来像是一个整体性很强的企业管理系统但实际拆开看它更像是一堆业务系统的集合只不过共享了同一个登录体系、同一套用户权限、同一个数据库和同一套页面框架。采购模块关注供应商、采购单、到货、对账销售模块关注客户、报价、订单、发货、回款库存模块关注出入库、盘点、调拨财务模块关注凭证、发票、付款生产模块关注工单、BOM、工序这些模块除了共用基础数据和互相调用部分接口之外几乎不存在真正意义上的“页面级耦合”。这是一个非常好的微前端拆分前提。因为在微前端领域最怕的是系统之间边界模糊拆完之后数据流和事件流纠缠在一起反而比单体还难维护。ERP恰好相反它的业务边界天然存在甚至菜单树本身就是一张拆分图纸你只需要把“菜单”映射成“子应用”即可。2.2 拆分粒度的选择菜单级而不是页面级拆分的粒度是最容易走极端的地方我见过有的团队把每个页面都拆成独立应用拆完之后光浏览器请求就是几十个JS文件首屏加载慢成灾难也见过有的团队只把主框架外的两三个大模块拆出来其余留在老系统里结果拆分前后体感没区别。我们最后定的原则是以菜单树的二级节点为基本拆分单元按域聚合偶尔按场景聚合。具体来说是这样的采购域下的一级菜单“采购管理”包含采购订单、采购收货、采购退货、供应商协同四个二级菜单这四个二级菜单合并拆成一个“采购中心”子应用销售域同理拆成一个“销售中心”子应用库存、财务、生产各自拆成独立子应用像“个人中心”“系统设置”“用户权限”这类所有业务共用的模块留在主框架中不拆。这种粒度有几个直接的收益。首先每个子应用的大小适中编译时间控制在30秒上下独立发布只需十几秒推一个静态资源其次子应用内部的页面间跳转都是无刷新式的用户体验接近原生单页应用最后子应用之间的边界刚好对应了后端微服务的边界业务归属清晰出了问题也容易定位。2.3 先拆“不痛”的模块再动“核心”的模块还有一个非常关键的经验就是拆分的顺序不能按业务重要性排应该按“风险可控程度”排。我们是先从“质量追溯”这种流程相对独立、受其他模块影响小的模块开始拆的。这个模块有几张列表和详情页路由少接口简单非常适合作为微前端接入的试点。跑通之后再逐步接入采购、销售、库存这种核心链路每一步都有充分验证的时间。反过来如果一开始就拆“订单中心”这种被大量模块依赖的公共业务很容易引发接口稳定性问题、样式回归问题、数据状态不同步问题一开始就把团队信心打没了重构大概率半途而废。3. 技术选型qiankun、module federation、wujie的实际取舍3.1 我们考察过的方案当前微前端技术方案基本分两派。一派是“运行时隔离派”以qiankun和wujie为代表核心思路是在运行时把子应用挂载到主应用的容器节点里通过沙箱机制隔离JS副作用和样式影响另一派是“构建期共享派”以Webpack 5的Module Federation为代表核心思路是在构建时约定全局共享的依赖子应用打包的时候排除公共依赖运行时从主应用加载公共模块。对于一个已经存在多年的ERP项目最重要的是平滑迁移而不是追求最新技术栈所以我们的候选清单里还有iframe方案。iframe的隔离性最好浏览器原生支持但通信麻烦UI同步体验差加载慢整体来说更适合做“完全独立的大模块”不适合做“需要频繁页面级跳转的业务”。3.2 我们为什么最终选择了qiankun综合考虑迁移成本、社区生态、开发熟悉度之后我们用了qiankun。原因有几个对旧项目侵入小不需要改动子应用的构建体系只需要在入口文件里导出几个生命周期钩子沙箱机制成熟JS沙箱能拦截子应用对window的修改样式沙箱能自动给子应用样式加作用域社区案例多网上搜ERP微前端重构绝大多数案例都是基于qiankun的遇到问题能找到参考团队熟悉度最高前后端同学至少都听说过学习成本低。对比一下三种方案的核心指标我整理了一张表指标qiankunModule Federationwujie技术栈隔离支持JS沙箱样式隔离不关心依赖构建约定支持JS沙箱样式隔离旧项目迁移成本低只需改入口文件高需要应用Webpack5联邦配置较低引入成本略高于qiankun部署要求子应用独立部署或和主应用同域部署子应用需要可跨域访问的独立部署子应用需要独立部署运行时加载方式主应用动态加载子应用入口HTML构建期确定依赖和运行时模块预加载内存渲染隔离社区成熟度较成熟案例丰富较成熟但ERP场景案例较少新兴案例正在积累其实后期我们也实验过wujie发现在“动态加载外部子应用并做严格隔离”的场景下它的能力也很强但当时团队已经没有太多精力再去熟悉一套新框架了所以维持qiankun不动。这里不是铺垫太多理论我的建议是如果你的团队已经熟悉了qiankun不需要为了追新而切换到别的方案如果你的团队还没有选型可以看看wujie它在隔离性和预加载上确实有亮点。3.3 主框架的承载方式主框架我们没有单独开发一套管理系统而是从老系统里把布局框架抽出来保留顶部导航、左侧菜单、面包屑、用户信息、消息通知这些公共区域做一个精简版的主应用宿主。它只负责三件事根据用户角色加载对应的菜单配置根据路由匹配结果动态加载对应的子应用入口文件维护全局登录态和全局UI主题。主应用自身的业务代码非常少打包体积大概只有老系统的20%首屏加载速度快很多这也为后续整个系统的体验提升打下基础。4. 运营中心抽离实战共享态、样式隔离与消息通信4.1 从零接入一个新子应用的标准步骤我给一个“标准接入步骤”这是我们在拆分第二个子应用时沉淀下来的流程后面所有子应用接入基本都遵循这套第一步在子应用源码根目录新增public-path.js用于设置webpack的publicPath为动态模式这样才能保证子应用在任意路径下都能正确加载静态资源。第二步修改子应用入口文件导出qiankun要求的三个生命周期钩子bootstrap、mount、unmount。在独立运行模式下入口文件走原来的Vue实例化逻辑在微前端模式下mount时才实例化渲染。第三步在子应用的webpack配置中设置output.library和libraryTarget: umd这是为了让主应用能通过全局变量找到子应用。第四步主应用注册这个子应用配置entry地址一般是子应用的HTML地址、container子应用挂载容器的DOM节点id和activeRule激活路由规则。第五步联调验证先验证子应用独立运行是否正常再验证在主应用中通过菜单跳入子应用是否正常最后验证子应用内部页面跳转、接口请求、退出到主公区是否都正常。看起来是一套固定的流程但真正跑起来你会发现每个子应用都有自己的“脾气”。举一个实际的例子我们的“库存中心”子应用是用Vue3 Element Plus开发的而主应用还是老旧的Vue2版本。qiankun并不会帮你自动处理UI库版本冲突的问题如果你在主应用里已经全局注册了Element UI的按钮、输入框组件而子应用里又用了Element Plus页面渲染出来的组件就可能出现双重样式或事件错乱。4.2 共享态怎么处理子应用不能直接操作主应用全局Store微前端里最容易踩的坑是两个应用之间的“数据共享”。我们在微前端群里见过不少人直接把子应用的Vuex/Redux挂到window上主应用也去读同一份数据当时觉得方便后来线上出了问题排查一天一夜才发现是两个应用同时修改同一份状态互相覆盖了。我们的做法是这样登录态由主应用统一维护通过localStorage写入一个加密的token字段并封装一个跨应用的getToken函数。子应用不直接读window里的token而是从主应用注册好的全局API里获取。这样即使以后token的存储方式变了子应用也不用改。用户信息主应用在全局挂载一个getCurrentUser方法返回当前登录用户的基本信息。子应用如果需要用户信息调用这个方法即可不自己存副本。跨应用公共数据比如“当前选中供应商”“当前仓库ID”这类业务态放在主应用的全局状态中并通过qiankun的initGlobalState机制广播给所有子应用。子应用监听变化即可。这套机制跑了一段时间之后我们总结出一个原则子应用内部的状态藏在应用自己内部跨应用的显式状态全部通过主应用转发禁止子应用之间直接通信。虽然开发的时候会多个两步但线上稳定性提升非常明显。4.3 样式隔离的坑全局样式污染与修复方案样式隔离是微前端重构中最多人踩坑的地方。qiankun默认给子应用开启的是strictStyleIsolation为false也就是不开启真正的Shadow DOM隔离而是采用动态加载和卸载样式表的方式。这意味着当子应用挂载时其样式表里的全局选择器比如直接写在body或html上的样式以及不带头部类名的组件样式是有可能影响到主应用和其他子应用的。我们实际遇到的场景是旧ERP里有一个全局的table样式定义了所有表格的边框和字体大小而采购中心子应用里Element Plus的表格有自己的样式体系。两个样式串在一起页面上出现了双线边框、字体忽大忽小的现象。排查起来又痛苦又费时。最后我们做了两手准备第一在子应用自己的样式入口处加了一个统一的#subapp-purchase .el-table这类前缀约束确保所有子应用样式都被限制在挂载容器内部第二在项目代码里全面排查裸的table、input、button全局标签样式能收敛到命名空间的尽量收敛不能收敛的改用!important显式覆盖并注明注释。经验之谈如果你接手的是一个历史悠久的老ERP大概率会遇到全局样式满屏幕乱飞的状况别指望qiankun的样式沙箱帮你挡住一切规范命名空间才是唯一的长期方案。4.4 消息通信自定义事件与qiankun的GlobalState如何配合ERP系统里不少场景需要跨应用跳转并携带参数。比如在销售订单里打开产品详情页这个详情页在商品中心子应用里再比如从库存模块点击“关联采购单”要跳到采购中心对应的单据详情页。我们最初的做法是主应用注册一个全局的navigation方法子应用通过主应用提供的API跳转并在query参数里携带业务ID和业务类型。这是一种比较朴素的“URL参数路由模式”简单但有效它最大的好处是所有跳转信息都保留在URL中刷新之后依然能定位到正确的页面。后来有一些需要传递“非序列化数据”的场景比如一个对象、一个回调方法URL参数就没法满足了。这时候我们才引入qiankun的initGlobalState。做法是主应用创建全局状态暴露setGlobalState和onGlobalStateChange子应用在跳转前先把数据set到全局状态目标子应用在mount阶段注册onGlobalStateChange监听拿到数据后渲染对应页面。这里有一个容易犯的错onGlobalStateChange回调里做的操作要放在“需要的时候”才执行否则可能会出现这样的现象打开采购中心做其他操作的时候因为全局状态被其他模块修改采购中心里的“当前供应商”突然被切换了用户一脸懵。我们最终的解决方案是在onGlobalStateChange里只更新一个“待处理消息队列”页面自身在初始化或者切换到某个路由时再去消费这个队列。这样既保证了通信的实时性也避免了页面被“无形的外部事件”打扰。5. 老代码迁移路径从单体重构到渐进式切换5.1 迁移节奏的三个阶段老ERP系统不是一个空壳它有大量存量页面和存量逻辑不可能一夜之间全部切到微前端架构。我们实际的迁移节奏分成三个阶段第一阶段1~2个月“改造老系统为可嵌入的主应用”。这一阶段不新增任何业务代码只把老系统的布局框架抽成主应用注册第一个试点子应用“质量追溯”验证整套接入流程和基础能力。第二阶段3~8个月“按域逐步拆离”。每个域独立迭代每拆完一个域就把菜单入口从“老系统内嵌页面”切换为“子应用页面”老系统里对应的路由代码保留一份作为回退方案。这个阶段的核心是保证双轨运行切错了随时可以回滚。第三阶段8~12个月“老系统瘦身”。所有已拆分子应用的业务代码从老仓库中移除只保留主应用和未拆分业务的代码老系统的编译速度和打包体积显著下降。此时老系统本身已经退化为一个“承载框架少量未拆业务”的轻量应用后续可以继续拆或者直接退役。5.2 路由冲突老系统路由与新子应用路由如何兼容这个阶段遇到的最大工程问题是路由冲突。老系统的路由表是集中注册的路由路径类似/purchase/order/list而新子应用内部也定义了自己的路由。两个路由体系如果在同一个history下运行必然出现匹配歧义。最终方案是给每个子应用规划一个统一的路由前缀主应用根据前缀判断是否激活某个子应用子应用内部的路由全部以这个前缀为根。比如采购中心的前缀是/purchase-center那采购订单列表的真实路由就是/purchase-center/order/list。听起来很顺但落地时有很多细节主子应用如果都用createWebHistory需要统一mode否则主应用用history、子应用用hash切换的时候URL会混乱子应用在独立运行时需要用根路径前缀/purchase-center访问这要求子应用的开发服务器也支持history回退主应用在注册子应用路由时activeRule里的匹配规则需要排除掉一些公共页面比如登录页、404页。这些在纯粹开发子应用时都不用考虑只有在微前端接入时才会暴露出来。我建议在拆分前就把路由前缀规范定好写在团队规范文档里否则每个人自己起一套路由后面合到一起非常痛苦。5.3 双轨运行时的灰度发布与回滚方案“拆完直接全量切”是重构的大忌。我们在每个子应用接入后都采用了一套灰度切换策略先在内网环境完整走一遍业务流程包含正常单据和异常单据再让一个核心客户或一个分支区域定向体验仅把该客户所属用户的菜单入口指向子应用其他用户仍然走老系统页面定向体验通过后再按比例放量比如先10%用户再30%、50%最后100%10%用户如果出现线上Bug立即通过配置中心把菜单入口回退到老系统整个过程不需要发版改配置即可。为了保证双轨运行期间体验一致我们在老系统页面和新子应用页面的URL参数层面做了接口兼容设计前端传参和接口名都保持统一。这样回滚的时候用户无感知数据也不会有两条链路。6. 拆分之后的性能与工程化懒加载、依赖去重、发布节奏6.1 子应用体积和首屏优化的实际数据拆分前老系统单次打包产物约为29MB。拆分后主应用首屏需要加载的资源大概7MB包含第三方库和公共样式每个子应用按需加载典型的子应用包体在2~4MB之间。这里有个细节qiankun默认是在“第一次激活子应用时才去加载它的入口HTML和JS资源”也就是说首屏用户只会加载主应用不会加载其他子应用。这对于ERP这类有大量业务模块的系统来说非常划算用户打开首页也明显变快了从原来的3~5秒降到1~2秒。实测的数据场景重构前重构后首屏加载时间约3.8s缓存后约2.5s约1.6s缓存后约0.9s首屏请求大小29MB全量包主应用7MB后续子应用按需加载编译时间全量约5分钟主应用30秒子应用单独编译20~40秒发布时长全量发布约10分钟子应用独立发布约1分钟这些数据在不同机器、不同网络环境下会有差异但总体趋势非常明显微前端拆分不是增加了复杂度反而通过按需加载、独立编译这些机制把原来的“大而慢”变成了“小而快”。6.2 公共依赖去重到底该不该抽公共包维护公共依赖是微前端工程化里的一个选择题。如果每个子应用都把Vue、Element Plus、axios打进去那么用户进入多个子应用时浏览器会重复加载这些库浪费流量也拖慢切换速度。我们的方案是把Vue全家桶包括Vue、Vue Router、Vuex、Axios以及公司自研的业务SDK作为公共依赖在主应用通过externals排除子应用通过externals同样排除然后在主应用的index.html里通过CDN统一引入。这样多个子应用共享一份公共依赖切换子应用时浏览器缓存都能命中。但要不要把Element UI也抽出去我们犹豫了很久。抽出去的好处是包体变小但坏处是如果有的子应用还在用Element UI 2.x有的要升级到Element Plus公共依赖就不好统一了。最终我们选择样式类组件库留在各自子应用里只在主应用保留最基础的布局组件。这算是速度和迁移成本的折中。6.3 发布节奏子应用按域发布主应用按周发布ERP有个现实问题不同业务域的发布窗口不一样。财务域因为涉及月结月中不能随便发版生产域的发布经常要配合车间的生产计划不能在工作日白天直接发布销售域则希望快速迭代每周都能上线新功能。单体架构下所有模块的发布节奏绑在一起任何一个域不能发版其他域也别想动这是一件非常憋屈的事。微前端拆分之后每个子应用由自己的业务团队独立发布主应用只承载框架和公共能力发布频率降到了每周一次甚至两周一次。遇到紧急问题子应用可以直接发布回滚不需要等主应用时间窗口。这个变化带来的团队组织层面收益甚至比技术层面的获益更明显大家不用再互相等待了发布这件事从“全部门排队”变成了“各司其职”。7. 踩坑清单样式污染、全局变量、路由冲突、沙箱机制7.1 qiankun沙箱的边界在哪里qiankun的沙箱机制经常被误解成“全隔离”但实际上它有一块经典的灰色地带非原生对象的全局变量劫持。比如某个子应用里定义了一个window.someVariableqiankun的沙箱理论上会在子应用卸载时把这个变量清理掉但如果这个变量是通过window.x { nestedObj }然后修改nestedObj内部属性的方式写入的沙箱在某些情况下只能拦截到表层内部对象引用仍然有可能泄漏到全局。我们在拆分采购中心时候就遇到过这种问题一个采购列表页面运行完卸载后又进入库存中心页面结果库存页面里莫名其妙多了一个window._purchaseLastQuery变量。排查发现是采购中心的某个组件在beforeDestroy里没有清理定时器而定时器回调里尝试写入一个全局引用被沙箱放过了。这件事让我们意识到不能把沙箱当保险箱子应用里所有对全局变量的写入都应该通过显式API来管理不要一边写一边指望沙箱帮你擦屁股。7.2 路由切换时的卸载遗漏微前端最典型的一个问题从一个子应用切换到另一个子应用时前一个子应用没有被正确卸载DOM残留、事件监听残留、定时器残留。表面现象是切换之后页面上的弹窗还在闪或者控制台一直报“Cannot read property of undefined”。我们写了一个检测脚本在每次切换子应用前通过console.log打印出当前子应用根节点下还有哪些DOM节点和事件监听器。这个脚本在联调阶段帮我们定位了大量卸载不干净的问题后来它被保留下来每次发布前都会跑一遍。值得提醒的是unmount生命周期里要做的事情不能只写“销毁Vue实例”还要把子应用创建的全局定时器、事件总线监听、echarts实例、全局通知组件全部释放掉。这些细节在单独开发子应用时根本不会注意但在多个应用交叉运行的微前端环境里任何一个遗漏都可能造成内存泄漏。7.3 跨子应用跳转时的URL状态丢失我们的销售中心和采购中心经常需要互相跳转而且跳转时要带上“当前组织单位ID”“当前业务员ID”这类上下文参数。如果用query传参当URL很长、嵌套很深时浏览器历史记录会变得又长又乱而且一旦用户刷新页面前面的上下文参数如果依赖前一个页面构造就会丢失。最终我们约定了一套“重定向中心”方案所有跨子应用跳转统一走主应用的一个路由处理函数函数内部拼接标准的路由前缀和目标页面路径并把上下文参数编码后放到query里。目标子应用的页面在初始化时解析query如果发现有上下文参数就自动加载对应数据并恢复页面状态。这套方案已经稳定运行很久了效果很好。但是也要强调这个方案的适用前提是“页面之间传递的数据都是可序列化的”。如果是复杂对象或者函数引用那还是老老实实用GlobalState。8. 演进方向从qiankun存量迁移到模块联邦增量共建文章写到这里很多朋友可能会有疑问既然qiankun这套运行时隔离机制已经能满足需求为什么还需要关注Module Federation我当时的判断和后来的实践是这两者面向的其实是“存量”和“增量”两个阶段。qiankun解决的是“老系统怎么接入新架构”的问题它最适合存量系统的渐进式重构。我们花了近一年时间做的是这件事。但是在老系统稳定下来、新系统开始成规模发展之后新的问题又出现了多个新子应用之间需要共享大量的基础组件和工具函数如果每次都是发布到npm然后各自安装版本同步又成了新的维护负担。Module Federation的价值在这个阶段开始体现。它可以让一个子应用作为“提供方”把“基础组件库”“公共业务组件”和“共享的工具集”直接暴露给另一个子应用运行时从提供方动态拉取。这样公共部分的迭代不再需要发npm包也不需要全部子应用跟着发包只要提供方的服务在线所有消费方都能拿到最新版本。我们目前正在做的是qiankun继续承担老系统的接入和稳定承载Module Federation则用于新系统内部多个业务应用之间的组件共享。两套机制共存其实没问题因为它们解决的是不同层级的问题。主应用通过qiankun动态加载子应用子应用之间通过Module Federation共享模块各司其职。当然这并不是唯一的演进方向。如果你的团队是全新项目从零开始完全可以一上来就用Module Federation做构建期共享架构省掉运行时沙箱那部分的复杂度如果你的业务领域特别强调隔离那wujie在运行时隔离上做得更彻底。选哪种方案要结合你的团队基建和业务现状没有放之四海而皆准的答案。我个人的体会是ERP重构这件事情与其说是一次技术升级不如说是一次系统性基建的“换血”。它不是单纯地把一个老框架换成新框架而是要把“所有模块绑定在一起发布”的积弊逐步解耦成“每个业务域独立演进”的健康状态。微前端只是这个过程中的一种工具真正决定成败的还是拆分边界是否清晰、迁移节奏是否合理、团队是否建立起了新的工程习惯。最后再分享一个小技巧如果你所在的团队对微前端还没有太多把握可以先把“登录态统一”和“菜单动态化”做成主应用的两个核心能力这几乎是所有ERP类系统都具备的基础设施。把这两个能力打通了后续接入任何子应用都会顺畅很多——它们也是整个重构过程中最底层、最不能出错的两个环节。
返回列表