一个巨型 AngularJS 项目拆成 8 个微前端子应用,我们怎么定的拆分边界和粒度

事情要从 2022 年 6 月的一次线上事故说起。那个承载了公司核心业务 9 年的 AngularJS 1.5 单仓库,在一次看似无害的 DOM 操作后,导致整个订单列表页白屏——不是某个组件报错,是整个页面崩溃。定位下来,根源是某位前端在 directive 里用了一个不安全的 $scope.$apply,触发了脏检查循环风暴。那一刻我们意识到,这个 4200+ 个 .js 文件、180 万行代码的巨兽,已经没人能真正理解它的全貌了。

领导层给了三个月时间,要求在不中断业务的前提下,将系统拆分成可独立部署的子应用。最终我们交付了 8 个微前端子应用,全部跑在 single-spa 上,主应用仍是 AngularJS,但每个子应用都是 Angular 14+。整个迁移花了 14 个月,生产环境没有一次因拆分导致的 P0 事故。

这篇文章是那次迁移的完整复盘——不是讲微前端怎么实现,而是讲那套最难的部分:怎么在一个没人能看清全貌的老系统里,定出合理的拆分边界和粒度。


先定边界再谈粒度:我们从业务能力切入,而不是从代码结构切入

很多团队拆微前端最容易犯的错,是照着目录结构拆。看到 modules/ordermodules/user,就觉得这是天然的边界。我们在第一轮方案评审时就踩了这个坑。当时有人拿出 AngularJS 的模块依赖图,建议按 9 个顶层 module 拆成 9 个子应用,结果光 common 这个 module 就依赖了另外 6 个 module,循环依赖多到根本拆不开。

最后我们换了一个思路:不看代码,看业务。我们找来产品总监、三个业务线负责人,花了两整天时间做了一件事——把所有功能点按"业务能力"归类。业务能力的定义很具体:一个完整的业务能力,是指用户可以不切换到其他上下文就能完成的一组操作

举个例子,"创建订单"是一个业务能力,"查看订单列表"是另一个,"配置商品 SKU"又是另一个。它们虽然在代码层面共享了同一个 OrderModule,但在用户的实际工作流里,这三个操作的执行者、场景、频率完全不同。创建订单是一线销售每天做几百次的操作,配置 SKU 是商品运营每周做几次的操作。

用这个标准,我们最终梳理出 23 个业务能力。接下来就是最难的一步:合并到 8 个。


8 这个数字不是设计出来的,是约束条件逼出来的

我们当时有 7 个前端团队,每个团队 3-5 人。如果拆成 7 个以上的子应用,必然有团队要同时维护两个应用,上下文切换成本太高。如果拆成 7 个以下,又会有团队共享一个子应用,代码归属不清。所以 8 这个数字,本质上是"7 个团队各领一个 + 1 个壳应用"的最小可行方案。

但 23 个业务能力要合并成 8 个子应用,合并的标准是什么?我们定了三个硬指标:

第一,变更频率相关性。 我们拉取了 Git 上过去 18 个月的所有 commit,按文件路径归类到 23 个业务能力,然后计算每对业务能力在同一 commit 中出现次数的占比。如果两个业务能力在 70% 以上的 commit 中一起出现,它们就应该合并到同一个子应用。这个数据帮我们避免了拍脑袋——比如"订单列表"和"订单详情"在代码层面是两个独立的路由,但数据表明它们 89% 的变更都是一起发生的,因为改一个列表字段通常也要改详情展示。最终这俩合到了一个子应用。

第二,数据模型耦合度。 我们让后端团队提供了所有核心 API 的数据模型定义,然后统计了每个业务能力依赖的数据库表。如果两个业务能力共享了超过 40% 的数据库表,拆分到不同子应用就意味着跨应用的数据一致性问题,得不偿失。有个典型例子是"库存查询"和"库存预警",它们共享了 inventory 表的 12 个字段,最终我们决定不拆。

第三,团队匹配。 这个指标比较主观但很重要。我们做了个简单的技能矩阵:每个团队对 23 个业务能力的熟悉程度打分 1-5。然后把合并后的子应用分配给最了解它的团队。如果某个业务能力没有一个团队的熟悉度超过 3,我们就把它合并到技术栈最接近的团队负责的子应用里。

用这三个指标,我们做了三轮迭代,最终确定了 8 个子应用的边界:

子应用 包含的业务能力 负责人数 独立部署频率(月均)
主壳应用 路由、鉴权、全局导航 3 2.1
商品中心 SKU 管理、品类配置、价格策略 4 5.3
订单工作台 订单创建、列表、详情、退款 5 8.7
库存运营 库存查询、预警、调拨 3 3.2
客户管理 会员信息、标签、积分 4 4.1
营销配置 优惠券、活动、推荐规则 4 6.8
数据报表 销售报表、业绩看板 3 2.9
系统设置 权限配置、审批流、日志 3 1.5

粒度控制的三个反直觉决策

边界定了,接下来是粒度。什么叫粒度?就是一个子应用内部的组件拆分到什么程度。我们在这个环节做了三个当时看起来反直觉、后来证明是正确的决策。

第一个:允许适度冗余,不要过早抽公共组件。 微前端最大的误区是"能共享就共享"。我们的订单工作台和库存运营都需要展示商品缩略图,早期有人提出来抽一个全局的 Thumbnail 组件。我们拒绝了。理由是:这两个场景的缩略图虽然视觉上相似,但订单场景需要支持批量加载 50+ 张图、点击跳转订单详情;库存场景只需要单张图、点击放大查看批次号。如果强行抽成一个组件,props 会膨胀到 15 个以上,变成另一个维护噩梦。我们定的规则是:三个以上子应用、在完全相同交互语义下使用的组件,才考虑抽取到壳应用。最终我们只抽了 4 个全局组件:按钮、输入框、弹窗、表格。其他的,各子应用自己维护。

第二个:路由隔离比代码隔离更重要。 有个细节可能很多人没意识到:single-spa 的默认行为是子应用的路由会互相污染。AngularJS 的 $routeProvider 和 Angular 的 RouterModule 如果不做隔离,路由参数可能会被另一个子应用误解析。我们在壳应用里做了一个路由前缀强制规则:每个子应用的所有路由必须带一个固定的 base path,比如 /orders/* 归订单工作台,/inventory/* 归库存运营。壳应用的 Router 里写了 8 条通配规则,匹配到哪个 base path 就 mount 哪个子应用。这 8 条规则就是我们的"硬边界"——任何跨子应用的路由跳转都必须经过壳应用的 navigateToApp() 方法,不允许直接操作 window.location

// 壳应用的路由分发逻辑
const appRoutes: AppRouteConfig[] = [
  { prefix: '/products', appName: 'product-center', matcher: (url) => url.startsWith('/products') },
  { prefix: '/orders', appName: 'order-workbench', matcher: (url) => url.startsWith('/orders') },
  { prefix: '/inventory', appName: 'inventory-ops', matcher: (url) => url.startsWith('/inventory') },
  // ... 其余 5 个
];

export function navigateToApp(url: string) {
  const matched = appRoutes.find(route => route.matcher(url));
  if (!matched) {
    throw new Error(`No app registered for route: ${url}`);
  }
  singleSpa.navigateToUrl(`/${matched.appName}${url}`);
}

第三个:跨应用通信只走事件总线,不走共享状态。 我们评估过用 Redux 全局 store 的方案,很快就否了。原因是 8 个子应用、7 个团队,任何一个人改了全局 state 的结构,其他 6 个团队的应用都可能崩。我们最终用的是 single-spa 内置的 CustomEvent 机制,只传关键的业务事件,不传状态。比如订单创建成功后,订单工作台发出 order:created 事件,携带 { orderId: string, amount: number },库存运营监听到后扣减库存。事件的数据结构只有两个字段,双方团队约定好就不许改。如果需要加字段,只能新增事件类型,比如 order:created:v2


迁移顺序不是技术问题,是风险控制问题

8 个子应用,先迁移哪个?这个问题比拆分边界本身还激烈。前端团队想先迁移商品中心,因为它代码相对独立;产品团队想先迁移订单工作台,因为它出过的事故最多;运维团队想先迁移系统设置,因为它变更频率最低、风险最小。

最后我们选了库存运营。理由很现实:它月均部署 3.2 次,变更频率适中;它的用户是内部仓管人员,总共 47 个人,出了问题影响面可控;它的业务逻辑相对简单,只有查询和预警两个核心流程。如果连它都迁不好,更复杂的订单和商品就别想了。

迁移库存运营花了 6 周。前 3 周做了一件事:把 AngularJS 里的库存相关代码用 Feature Toggle 做流量切换,1% 的用户走新的 Angular 子应用,99% 走老代码。第一周发现了 11 个 bug,都是数据格式不兼容——老的 $http 返回的日期是字符串,新的 Angular HttpClient 自动转成了 Date 对象,导致一堆比较逻辑失效。修完这些之后第四周切到 50%,第五周 100%,第六周删掉老代码。

这个节奏后来成了模板。接下来是客户管理(5 周)、系统设置(4 周)、商品中心(8 周)、营销配置(7 周)、数据报表(6 周)、订单工作台(10 周)。订单工作台放在最后,因为它的业务最复杂、流量最大、出问题影响最直接。迁移它的那 10 周,我们做了 3 轮灰度,每次只切一个子功能——先切订单列表,再切订单详情,最后切退款流程。


迁移过程中的三个技术债,我们选择了不还

14 个月里,我们至少遇到了几十次"要不要趁机重构"的诱惑。最后只做了三个必要的技术升级,其他的全部标记为 debt,留给各团队自己决定。

第一,把 $scope.$watch 全部迁到 RxJS。 这个没法躲,Angular 根本没有 $scope。但迁移策略不是逐行翻译,而是重写——每个原来的 $scope.$watch('model', callback),我们要求开发者重新思考这个 watch 的业务意图,然后用 BehaviorSubjectcombineLatest 实现。这导致迁移初期速度很慢,但后期维护成本明显下降。

第二,统一 HTTP 拦截器。 老的 AngularJS 项目里有 4 套不同的 HTTP 拦截器,分别处理鉴权、错误码转换、日志、请求重试。我们趁迁移统一成一套基于 Angular HttpInterceptor 的管道,所有子应用强制使用。这个决策争议很大,因为意味着迁移时必须改接口调用方式。但如果不统一,8 个子应用各自搞一套,以后联调会疯掉。

第三,干掉所有 $timeout$interval AngularJS 里用 $timeout 延迟执行是常见模式,但 Angular 里直接用 setTimeout 会导致 Zone.js 无法触发变更检测。我们写了一个 ESLint 规则禁止在子应用里使用原生定时器,强制用 NgZone.runOutsideAngular() 包裹或者用 RxJS 的 timer

至于那些"这个 service 写得不好要不要重构"、"这个组件命名不规范要不要改"的需求,全部被我们挡回去了。原则很简单:迁移的目标是让系统可拆分部署,不是让代码变漂亮。代码质量的问题,各团队在迁移完成后自己排优先级。


常见问题

怎么说服业务方接受 14 个月不交付新功能?

我们没有完全停止交付。前 3 个月做了库存、客户、系统设置这三个子应用的迁移,同时各团队保留一名开发处理紧急业务需求。第四个月开始,每个团队用 80% 的时间做迁移、20% 做业务。关键是让业务方看到迁移的价值——库存运营迁移完的第二周,仓管团队提了一个"增加批次号模糊搜索"的需求,以前这种跨模块改动至少要 3 天联调,现在库存团队自己半天就上线了。有了这个案例,业务方从阻力变成了推手。

如果重来一次,会怎么调整拆分方案?

数据报表和营销配置应该合并。我们最初拆开是因为它们属于不同的业务线,但迁移完成后发现,报表子应用 70% 的数据源来自营销配置的促销活动表,导致两个应用之间有大量的事件通信。如果合并,能省掉一套跨应用通信的复杂度。但当时做这个决策时,我们没有考虑到数据依赖这么深——这是个教训:拆分前除了看代码耦合,还要看数据流向。

single-spa 的坑有哪些?

最大的坑是样式隔离。single-spa 默认不隔离 CSS,8 个子应用如果都用 Angular Material,全局样式会互相覆盖。我们的解法是给每个子应用的 angular.json 配置 "extractCss": true,然后在 index.html 里给主容器加一个应用前缀的 class,配合 PostCSS 的 postcss-prefix-selector 插件给每个应用的样式自动加前缀。这套方案跑了一年多,没有出现过样式污染。

AngularJS 和 Angular 并行跑了多久?有没有性能问题?

两个框架并行跑了 14 个月,直到最后一个子应用迁移完才关掉 AngularJS。性能方面,壳应用同时加载 AngularJS 和 Angular 的运行时,初始包体积增加了约 180KB(gzip 后)。这 180KB 的代价我们评估过,在内部管理系统(用户都是 PC 端、Chrome 浏览器、内网环境)完全可接受。如果是 C 端移动端场景,这个方案就不适用了。