微前端边界设计:先想清楚为什么要拆
同一个诉求,两个团队给我的答案完全相反。一个团队说“构建太慢了,拆微前端”,另一个团队说“我们四条业务线共用一个后台壳子,各自发版老是互相等,拆微前端”。前者拆完只会更痛苦,后者拆完确实能松绑——差别不在技术,在于他们要拆的边界压根不是一回事。
所以每次有人来问“我们要不要上微前端”,我第一句话不是“用 qiankun 还是 Module Federation”,而是反问一句:你到底想拆掉哪一种耦合?想清楚这个问题,方案自然就出来了;想不清楚,选哪个框架都是把单体的毛病原样搬到运行时。
一次拆错边界的教训
年初我接手过一个后台系统的微前端改造,说是改造,其实前一版已经拆过一轮了。初衷很合理:四个业务团队共用一个壳,各自独立发布。但拆完之后大家反而更累了。
问题出在边界。主应用和子应用互相调用内部方法,一个子应用改了个函数签名,另一个子应用线上就崩了;样式互相覆盖,A 应用写了个 .btn-primary,B 应用的按钮跟着变色;最离谱的是登录态刷新逻辑,主应用写一份、两个子应用各写一份,token 过期后三份逻辑各刷各的,偶尔还会互相把对方刷出去。
拆是拆了,耦合一点没少,只是从代码仓库层面的耦合,变成了运行时耦合。原来编译期能发现的问题,现在要等到线上多个应用凑在一起才暴露,排查还更难了。这一版返工时我才真正想明白:微前端做的事情是把耦合摆到明面上、强制你给它定契约——耦合总量并没有变少。 如果你不打算定契约,那它带来的只有复杂度。
它解决的是组织问题,不是性能问题
先把一个常见误解掰正:微前端不是拿来优化性能的,多数情况下它反而让首屏更重、加载链路更长。它解决的核心问题是组织协作和独立交付。
值得拆的场景,往往同时满足这么几条:多个团队负责不同业务域、各业务要能独立发布、技术栈有历史差异短期统不了、主应用需要统一登录导航权限布局,而子应用之间业务耦合又不强。反过来,如果只是“页面多”“构建慢”“想追个新架构”,或者所有页面其实还是同一个小团队在维护、子应用之间共享大量状态和组件,那基本都不该拆。
尤其“构建慢”这条,最容易被误诊成架构问题。构建慢先去看构建缓存、依赖预构建、代码分包、CI 机器资源,甚至换个更快的打包器——这些的收益比上微前端直接得多,也不会给你留下运行时的债。我们那个项目当初一部分动机就是“构建要八分钟”,后来光是把 node_modules 缓存和 splitChunks 调对,就降到三分钟,跟微前端半点关系没有。把构建问题升级成运行时架构问题,是我见过最亏的一种技术决策。
按业务域切,不是按路由切
真要拆,第一个容易走偏的地方是切分依据。很多人打开路由表就开始切:
1/user/* 2/order/* 3/marketing/*
看着很整齐,但这只是 URL 的表象。路由是给用户看的导航结构,不等于团队的责任边界。真正该切的是业务域和团队所有权。
我判断一块能不能独立成子应用,会拿几个问题去套:它负责哪个业务域?它的数据模型谁维护?它能不能独立发布、不依赖别人一起上线?它跟其他子应用靠什么契约通信?它自己挂了,主应用怎么降级?
这几个问题里但凡有一个答不上来,就说明边界还没想清楚。特别是“能不能独立发布”这条——如果两个页面每次都得一起上线、频繁共享内部状态、你调我我调你,那它们大概率本来就该在一个子应用里,硬拆成两个只会天天联调。
顺带说一句选型。真到了选框架的环节,iframe、qiankun(single-spa 那套)、Module Federation 各有各的位置,但选它们的依据应该是边界需求,不是新旧。对互不牵连的要求极高、技术栈差异极大、甚至要嵌别人家的系统,iframe 那种把两边彻底隔断反而最省心,代价是通信和体验割裂;同一套技术栈、要共享依赖和路由体验,Module Federation 更顺;介于两者之间、需要沙箱又要统一壳的,qiankun 这类运行时容器更合适。先把前面那几个边界问题答清楚,选型基本是水到渠成的事——框架是边界的实现,不是边界的替代品。
反过来也别拆太碎。微前端不是越细越好,拆得太散,用户一次操作跨了三个子应用,加载、通信、错误边界的复杂度会指数上涨。我的经验值是:一个子应用对应一个能独立立项、独立排期的业务域,比一个团队的规模再小就要警惕了。
主应用只当壳,别懂业务
边界划完,接着要守住的是主应用的职责。主应用应该是个尽量“薄”的壳,只管那些全局的、跨子应用的能力:登录态、全局布局、导航菜单、权限入口、子应用的注册与加载、全局错误兜底、公共监控埋点。
判断主应用有没有变胖,有个很直观的信号——它开始出现这种代码:
1if (appName === 'order' && status === 'refund') { 2 // 主应用里塞进了订单退款的业务判断 3 showRefundBanner() 4}
一旦主应用里长出这种对具体子应用业务细节的分支,边界就开始烂了。主应用越懂业务,子应用就越难独立——因为子应用改个业务逻辑,还得回来改主应用,独立发布直接名存实亡。
子应用之间的通信也要按这个原则克制。优先走 URL 参数、自定义事件、后端数据这类明确契约,别直接 import 对方的内部模块:
1// 子应用广播一个业务事件,不关心谁在听 2window.dispatchEvent( 3 new CustomEvent('order:updated', { 4 detail: { orderId, version: 1 }, 5 }), 6)
但事件这条路也有陷阱。全局事件用着用着,很容易退化成另一个隐形的全局 store——谁都能发、谁都能听,事件名满天飞,到最后没人说得清一个事件到底有几个订阅方。所以事件一定要有命名规范(我们统一 域:动作 的格式)和版本意识,detail 里带上 version,改结构时老版本还能兼容一阵。否则你只是把方法调用的耦合,换成了事件的耦合,一样难维护。
样式隔离要在拆之前就定
微前端线上最高频的一类事故,就是样式污染。子应用 A 图省事写了个宽泛选择器:
1.button { 2 color: red; 3}
结果同一个页面里子应用 B 的按钮也红了。这种问题在本地各自开发时根本发现不了,非得几个应用在生产环境同框才暴露,排查时还容易怀疑到自己头上。
让样式互不串门的手段有好几种,但没一种是白吃的午餐。Shadow DOM 关得最死,可主题变量、弹窗挂载位置、字体、第三方组件库的样式注入全都要重新适配,改造成本很高,我们评估下来只在少数几个独立性极强的挂件上用了它。CSS Modules 对局部样式很友好,但全局 reset 和第三方组件的样式还是漏的。CSS-in-JS、BEM 命名约束也都是这个路数,各有各的盲区。
我们最后落地的是一套“成本可控”的组合约定:子应用禁止写裸的宽泛全局选择器,公共 reset 统一由主应用提供(子应用不许自己再 reset 一遍),所有业务样式必须带命名空间或走模块化。
1/* 至少把作用域锁在应用容器内,比裸 .button 安全得多 */ 2.order-app .button { 3 color: red; 4}
这不是最优雅的方案,但它便宜、可落地、团队能执行下去。样式隔离要是拆完之后才想起来补,几十个子应用的样式早已经互相渗透,回收成本远高于一开始定个约定。
JS 沙箱:被污染的全局比样式更难查
样式污染至少还看得见,JS 层面的全局污染更阴险。子应用往 window 上挂了个变量、改了 Array.prototype、注册了个全局定时器却没在卸载时清掉,切到另一个子应用时这些东西还赖在内存里,行为就开始飘。这类问题的排查体验极差,因为现象和肇事代码往往隔着好几次路由切换。
qiankun 这种框架给的答案是 JS 沙箱:子应用运行时,window 被换成一个 Proxy,子应用对全局的读写其实落在自己的一份影子对象上,卸载时整份丢掉,不留痕迹。原理大致是这样:
1// 沙箱思路示意(真实实现比这复杂得多,要处理 document、原生方法等) 2function createSandbox(rawWindow) { 3 const modified = new Map() // 子应用改动的全局属性都记在这 4 5 const proxy = new Proxy(rawWindow, { 6 get(target, key) { 7 return modified.has(key) ? modified.get(key) : target[key] 8 }, 9 set(target, key, value) { 10 modified.set(key, value) // 写进影子层,不碰真实 window 11 return true 12 }, 13 }) 14 15 return { 16 proxy, 17 unmount() { 18 modified.clear() // 卸载时一把清掉,全局环境复原 19 }, 20 } 21}
但沙箱不是银弹,有几类东西它兜不住:子应用直接操作真实 DOM(往 body 挂了个全局弹窗容器没回收)、注册了不受沙箱管的原生事件监听、或者用了 setInterval 忘了 clearInterval。这些副作用逃在沙箱之外,卸载时不会自动清理。所以就算框架给了沙箱,子应用自己也得有“进出对称”的意识:mount 里加的东西,unmount 里要一一撤掉。我们给子应用定了条硬规矩——任何全局副作用(事件、定时器、DOM 挂载)都必须在 unmount 钩子里成对回收,code review 专门盯这一点。
多实例场景还有个更隐蔽的坑:如果允许两个子应用同时激活(比如一个页面里嵌了两块不同业务),它们的沙箱是相互独立的,但共享的那份 React 单例、共享的全局事件总线却是同一个。子应用之间要通信只能走前面说的契约层,绝不能指望通过沙箱里的全局变量传数据——那份数据出了自己的沙箱谁也拿不到。
路由和加载时机也要对齐
主子应用共用一套浏览器地址栏,路由就成了必须协调的公共资源。谁监听 popstate、子应用内部路由怎么和主应用路由拼、前进后退时哪个应用负责响应——这些不理清,会出现“点了浏览器返回键,主应用导航变了但子应用内容没跟着变”这种割裂感。
常见的分工是主应用管一级路由(决定加载哪个子应用),子应用管自己内部的二级路由。关键是子应用要用带 basename 的路由,把自己的路由空间限制在主应用分配的那段前缀下:
1// 子应用:路由挂在主应用分配的 basename 下,避免抢占全局路由 2function OrderApp({ basename }) { 3 return ( 4 <BrowserRouter basename={basename}> 5 <Routes> 6 <Route path="/list" element={<OrderList />} /> 7 <Route path="/:id" element={<OrderDetail />} /> 8 </Routes> 9 </BrowserRouter> 10 ) 11}
加载时机也要想清楚。子应用是路由切过去才加载(懒加载,省首屏),还是提前预加载(切换更快,但抢带宽)?我们的做法是主流程子应用在主应用空闲时预取,边缘子应用按需加载——用 requestIdleCallback 在浏览器空当里把大概率要用的子应用资源先拉下来,真正切过去时几乎无感。但这也要克制,一股脑全预加载,等于把微前端好不容易拆出来的按需加载优势又还回去了。
依赖共享是双刃剑
拆完自然会想到优化:几个子应用都用了 React、都用了同一套组件库,能不能共享一份,省掉重复加载?Module Federation 这几年成熟了,做共享确实方便。
但共享依赖是把双刃剑。省了体积,换来的是版本耦合。主应用把 React 升到某个版本,所有子应用是不是都兼容?共享的组件库升级后,那些迭代慢的旧子应用会不会样式错乱、API 对不上?我们踩过一次组件库小版本升级导致某个老子应用弹窗错位的事,就是因为它被强制吃了共享的新版本,而它自己根本没跟着测。
所以我现在会把依赖分三档来处理:
1必须共享:React、React DOM 这类要求全局单例的运行时(多份实例会直接出诡异 bug) 2建议统一:组件库、设计 token、监控 SDK(统一体验,但要有版本治理和灰度) 3允许自带:业务工具、小型库、历史兼容依赖(各带各的,省心)
react、react-dom 这种是真必须共享的——一个页面里跑两份 React,hooks 会直接报错。但除此之外,别为了省几十 KB 就把所有依赖强行共享。共享得越多,独立发布的能力就越弱,你会慢慢发现每个子应用升级都得看别人脸色。省下来的那点体积,未必抵得过治理成本。
子应用挂了,主应用不能白屏
最后一块,也是最容易被忽略的:错误隔离。子应用加载失败、运行时报错,主应用不能跟着白屏。分布式系统里“部分失败”是常态,微前端也一样。
子应用的加载至少要有加载中状态、加载失败的兜底、重试入口、错误上报,以及一层运行时错误边界,把子应用的崩溃圈在它自己的容器里,别让它带着整个壳一起挂:
1class MicroAppBoundary extends React.Component { 2 state = { failed: false } 3 4 static getDerivedStateFromError() { 5 return { failed: true } 6 } 7 8 componentDidCatch(error, info) { 9 // 关键:上报要带上子应用身份,否则线上只看到"主应用报错" 10 reportError(error, { 11 app: this.props.appName, 12 version: this.props.appVersion, 13 route: location.pathname, 14 componentStack: info.componentStack, 15 }) 16 } 17 18 render() { 19 if (this.state.failed) { 20 return ( 21 <section className="micro-app-fallback"> 22 <h2>模块加载失败</h2> 23 <p>请刷新或稍后重试,其他功能不受影响。</p> 24 <button onClick={() => this.setState({ failed: false })}>重新加载</button> 25 </section> 26 ) 27 } 28 return this.props.children 29 } 30}
错误上报这块我要特别强调:一定要带上子应用的名称、版本、路由、用户环境。 否则线上监控里只有一条“主应用报错”,你根本不知道是四个子应用里的哪一个、哪个版本出的问题。我们早期就吃过这个亏,一个报错量不小的错误,查了两天才定位到是某个子应用的旧版本没随主应用一起更新。加载失败也要区分是网络问题、资源 404,还是子应用自己 JS 执行报错——降级文案和重试策略并不一样。
独立发布:拆的初衷别在部署这一步丢了
前面反复说微前端的价值是“独立交付”,但独立交付不是拆完代码就自动有的,它落在部署形态上。如果四个子应用最后还是打进主应用一起构建、一起发版,那所谓的独立发布就是句空话——改一个子应用还得触发整包重新上线,团队照样互相等。
真正的独立发布,是每个子应用有自己的构建产物、自己的部署流水线,产物发到各自的 CDN 路径上,主应用运行时按一份注册表去拉。注册表通常长这样,标明每个子应用当前线上应该加载哪个版本的入口:
1{ 2 "order": { 3 "entry": "https://cdn.example.com/order/v2.3.1/index.js", 4 "activeWhen": "/order" 5 }, 6 "marketing": { 7 "entry": "https://cdn.example.com/marketing/v1.8.0/index.js", 8 "activeWhen": "/marketing" 9 } 10}
订单团队发新版,只需要把自己的产物推到 CDN、更新注册表里 order 那一项的版本号,主应用和其他子应用一行代码不动。这才是独立发布的完整闭环。但它也带来一个新问题:版本组合爆炸。主应用某个版本、订单子应用某个版本、共享依赖某个版本,三者的笛卡尔积里总有些组合没人测过。所以注册表更新要能灰度、能一键回滚——出问题时把注册表那一项切回上个版本,比重新构建发布快得多。
跨应用的版本兼容也得有个约定。共享依赖(尤其是组件库、通信协议)升级时,不能假设所有子应用都能立刻跟上,得留一段双版本共存的窗口期。我们踩过一次教训:通信事件的 detail 结构改了字段名,主应用先发了,某个迭代慢的子应用还在读老字段,线上直接对不上。后来所有跨应用契约的变更都强制走“先兼容、再迁移、最后下线旧版”的三步,事件带 version、协议向后兼容,就是为了给慢半拍的子应用留缓冲。
监控要能落到具体应用和版本
最后回到可观测性,这是微前端和单体最不一样的地方。单体应用出错,栈信息直接指向代码;微前端出错,你首先要回答的是“这是哪个子应用、哪个版本、在哪条路由上出的”。如果监控答不上这三个问题,排查就是大海捞针。
所以错误上报、性能埋点、日志,全都要带上子应用的身份标识。我们的做法是在子应用加载时就往监控 SDK 里注入一组维度:
1// 子应用 mount 时注册身份,之后所有上报自动带上这些维度 2monitor.setContext({ 3 app: 'order', 4 appVersion: __ORDER_VERSION__, // 构建时注入的版本号 5 shell: window.__SHELL_VERSION__, // 主应用版本,用来排查组合问题 6})
有了这几个维度,线上监控面板就能按子应用、按版本切片看错误率和性能。一个子应用发了新版本后错误率飙升,一眼就能定位到是它、是哪个版本,直接回滚注册表止血,而不是在“主应用报错”这条笼统的告警里瞎猜。微前端把系统拆成了分布式,监控就得跟着分布式化——能定位到应用和版本的可观测性,是微前端能安心上生产的前提,不是可选项。
拆之前先过一遍这几关
回到开头那个问题:要不要上微前端。真到了决策那一刻,我会拿这么几关去卡——是不是真需要多团队独立发布,业务域边界清不清楚,主应用能不能守住“只当壳”,子应用之间有没有明确的通信契约,样式隔离方案定没定,共享依赖有没有版本治理,子应用挂了能不能降级,监控能不能区分到具体应用和版本。
这些问题答得越顺,说明你拆的是真正的组织边界;答得越含糊,说明你多半只是想拿微前端解决一个本该用别的手段解决的问题。微前端的价值,是让组织边界和技术边界对齐:边界清楚,它能让几个团队真正并行起来;边界不清楚,它只会把单体应用的所有毛病,原封不动地搬到运行时,再附赠一堆新的复杂度。开头那两个团队的差别,说到底就在这一句上。