1. 为什么我没有选择“推倒重写”,而是给老项目做了一次“架构治理”
我接手维护过一个运营支撑平台,项目代码量从最初的二十几个页面,膨胀到后来的一百多个页面,组件数量超过九百。刚开始还过得去,等到后来的迭代周期,每次改动都像在雷区里行走:npm run dev冷启动要四十秒,生产构建三分多钟,加一个表单字段都得小心翼翼,因为不知道哪个模块隐式依赖了那段逻辑。某次需求评审会上,产品同事半开玩笑地提议:“要不咱们重写吧。”
我当时差点就点头了。但冷静下来想,这个项目已经承载了十几条业务线的日常操作,重写意味着业务梳理、回归测试、数据迁移,光是把现有页面的隐式交互行为理清楚,至少得三个月。而且以我过往的经验,那种“重写会更好”的念头,大概率会在写到一半的时候发现,历史的坑比想象中还要深。于是我们换了一条路:不做大爆炸式的重构,而是启动了一个内部代号为 REA 的架构治理项目。
REA 的全称是 Rapid Engineering Architecture,直译过来是“快速工程架构”。说到底它不是什么新框架,也不是某个商业产品的缩写,更像是一套我们在存量项目上应用的治理方法论。核心目标就一句话:在不冻结业务迭代的前提下,把代码结构、数据流和构建链路重新理顺,让团队的交付速度回到正常水平,同时避免下次再长成一团乱麻。这篇文章我会把整个改造从调研、设计到落地的过程完整记录下来,包括关键选型背后的考量,以及我们实打实踩过的那些坑。
2. 盘点阶段:拖垮这个项目的三个真凶是什么
在动手改之前,必须先搞清楚问题到底出在哪。团队里的声音大概有三类:前端说“代码根本改不动”,产品说“构建太慢,部署一次等老半天”,测试说“回归范围越来越大,每次发版都像考试”。这三类声音其实是三个不同层面的问题被混在了一起。我把它们拆开之后,发现真正拖垮项目的元凶有三个。
2.1 组件库变成了“废品站”
翻遍项目统计了一遍,九百多个组件里,被两个以上页面引用的只有百分之十二左右。大量页面都是各自复制粘贴了一段代码,改改样式就成了“新组件”。光“数据表格”这个语义,项目里就存着二十多份不同的实现。最典型的一次,我们想给公共表格组件加一个空数据插画,结果发现改了之后有六个页面显示异常,因为它们的表格实现根本就不是同一个组件。
这个问题的根源倒不是大家懒得抽公共组件,而是项目里缺少“组件应该处在哪个层级”的约束。没有分层边界,每个人都会按自己当下的理解去写代码,两年下来自然就成了废品站。组件到底该放公共库还是业务域?没有明确规则,最后一定是一锅粥。
2.2 状态层变成了“大超市”
所有接口返回的数据,在项目早期都被塞进同一个全局状态仓库里,页面 A 拿到的数据,页面 B 也能随手访问。一开始看着挺方便,后面就痛苦了。改动一个数据的更新逻辑,你根本不知道还有谁在用;同一个接口在不同页面被调用,状态字段却各自维护一份,经常出现两边数据对不上的情况。仓库里十几个状态文件,每个动辄上千行,写着写着就没有人敢再去碰它们了。
我把这叫作“大超市”模式:什么东西都摆在货架上,看起来琳琅满目,其实找一件东西要逛完整层楼,而且你不知道哪些商品已经过期了。
2.3 路由与权限的“暗结耦合”
权限判断散落在每个页面的初始化逻辑里,一个页面往往要查三四个权限点,命名也不统一,canEdit、editPermission、isAdmin全都有。新同学进来根本不知道要校验哪个字段,于是经常出现“按钮明明存在但点击没反应”的诡异问题,查到最后都是某个权限点漏判了。更麻烦的是,权限判断还和路由守卫、菜单渲染各写了一套,规则一变就要同时改三处。
这三个问题有个共同特征:它们都不是靠“升级某个依赖库”能解决的,而是架构层面缺少分层和边界。所以 REA 的切入点不是换框架,而是补上一套清晰的分层规则,让代码从生成的那一刻起就有明确的归属。
3. REA 的架构分层:三个目录、两条数据流、一个请求出口
在正式动工之前,我把目标架构画在一张图上,然后用最朴素的语言讲给团队听:以后所有业务代码都要能放进三个范围里——core(与业务无关的基础设施)、features(业务功能域)、shared(跨域共享的通用代码)。这个划分并不新鲜,很多大型项目都这么干,但真正把它执行到位的不多。
3.1 目录结构决定归属感
我们在项目里按照业务域切分目录,大致长这样,以 TypeScript + React 的项目为例:
src/ ├── core/ // 基础设施:请求层、权限、路由、通用hooks │ ├── http/ │ ├── auth/ │ └── router/ ├── features/ // 业务功能域,每个域独立成模块 │ ├── order/ │ │ ├── api.ts │ │ ├── components/ │ │ ├── hooks/ │ │ └── store.ts │ ├── payment/ │ └── report/ ├── shared/ // 跨域通用:UI组件、类型、常量 │ ├── components/ │ └── utils/ └── pages/ // 只保留页面级组装,不写业务逻辑这个结构最大的价值是给每一行代码都安排了一个“归属”。业务同学改订单相关功能,只需要进features/order,不关心其他域在干什么;公共组件必须进shared,而且进入之前要经过评审,回答“第二个页面是不是真的会用”。这样一来,新代码不需要纠结放哪,老代码一搬家,问题自然也暴露了。
3.2 两条数据流:服务端状态和客户端状态分开管理
这是 REA 里我认为最重要的一条判断。过去大家习惯把接口数据塞进同一个全局状态仓库,这是很多问题的根源。我把状态分成两类:服务端状态,也就是来自接口的数据,它们可以被缓存、被失效、被多个页面共享;客户端状态,也就是 UI 临时状态,比如弹窗开关、表单草稿、当前选中的标签页。
服务端状态统一交给请求缓存层管理,客户端状态才放进轻量的原子状态库。这样做的收获很直接:同一个 GET 接口的数据,在不同页面之间不需要手动同步,请求层会负责缓存和失效;UI 临时状态也不会因为全局化而污染别的页面。曾经那个经常对不上数据的“大超市”,就这样被拆成了两间独立仓库。
3.3 一个请求出口:所有接口调用都走统一封装
原有的请求方式很乱,有的模块用 axios 实例,有的直接 fetch,有的封装到一半就搁置了。REA 要求所有业务请求必须经过core/http里的统一函数。统一封装的核心价值是集中处理横切关注点,比如登录过期、错误提示、超时重试、请求取消。
// features/order/api.ts import { http } from '@/core/http'; export const fetchOrderList = (params: PageParams) => http.get<PageResult<Order>>('/api/order/list', { params }); export const createOrder = (data: OrderPayload) => http.post<Order>('/api/order', data);统一出口不是为了好看,而是为了后续做拦截器、埋点、切换 mock 环境的时候,只需要改一个文件,而不是全项目搜索替换。我们后来做接口灰度切换和故障降级时,这个设计省了非常大的功夫。
3.4 构建层为什么选 esbuild 预构建加动态 import
构建慢是表面现象,实际有两个原因:依赖解析太慢,以及打包体积太大。我们的项目基于 webpack 5,于是做了两件事。第一,用 esbuild 对node_modules里的第三方依赖做预构建,把依赖解析这种重活从 webpack 的常规构建链路里剥离出去,原理很简单:第三方依赖的变化频率远低于业务代码,没必要每次启动都重新解析一遍。第二,把业务路由全部改成React.lazy动态 import,配合splitChunks做拆包,首屏只加载当前路由依赖的代码。
改完之后,开发服务器冷启动从四十秒降到六秒左右,生产构建从三分多钟降到四十几秒。这个收益极其直观,团队所有人第一天就感受到了变化,后面推其他架构规则时的阻力也小了很多。
4. 渐进式落地:不搞“大爆炸”重写,依靠三个阶段的推进
架构方案设计得再漂亮,真正的难点还在落地。最忌讳的做法是拉一个分支,闷头改三个月,最后合并时发现冲突风暴无法收场。我们采取的是小步快跑,每个阶段都有可验证的产出,每两周都能看到一点进展。
4.1 第一阶段:先建立依赖关系的“扫描报告”
改造前我先用脚本扫描了整个项目的模块依赖关系,生成依赖图。这一步不是跑个脚本就完事,关键在于人工从那堆依赖里挑出被引用最多的顶层公共模块,逐个确认它到底是真正的基础设施,还是混进来的业务代码。比如我们发现“用户头像上传”这个组件被四十多个页面引用,但它本质上属于用户域的业务功能。把它从公共目录挪回features/user之后,核心目录的职责一下就清晰了。
4.2 第二阶段:新代码先按新规则走,老代码不做强制迁移
从 REA 落地那天起,所有新增代码都必须按新的目录归属来写。旧的坏味道代码,我们只做“路过清理”,也就是改到哪个页面,就顺手把那个页面的状态请求方式规范掉。这样做的心理压力小,回归风险也小。为了不让新规则沦为空谈,我在 ESLint 里加了两条自定义规则:禁止业务代码直接访问旧全局状态仓库,禁止在共享组件里直接发起网络请求。违反就报编译错误,靠工具约束而不是靠人的自觉。
4.3 第三阶段:定期“拆弹”,每次只治理一个横切关注点
每隔两周,我会从技术债清单里挑一个主题集中治理,比如“把权限判断从页面收拢到统一 hook”,或者“把所有分页组件收敛到同一个版本”。每个主题都尽量控制在一个开发者一两天能完成的规模,测试也能接受。整个改造持续了四个多月,没有哪一天是全组停摆专门来做重构的,日常业务迭代始终没有停过。
三个阶段的推进逻辑其实很朴素:先定规则,再约束增量,最后清理存量。顺序一旦颠倒,比如一开始就逼大家清理存量代码,大概率会在业务压力和回归风险下不了了之。
5. 改造路上踩过最深的四个坑
任何一次治理都会有意外收获,我把我们踩过的比较深的四个坑写在这里。每一个都是真实发生过的,排查链路写出来,希望能帮你少走弯路。
5.1 路由懒加载导致的“首屏闪烁”
把页面改成React.lazy之后,开发环境一切正常,生产环境却偶尔出现首屏白屏闪一下的问题。我一开始怀疑是加载占位组件的问题,把 fallback 换成普通空节点,问题依然存在。后来打开浏览器的 Performance 面板才发现,主包拆掉后入口文件变得很小,而页面级 chunk 又太大,因为有些第三方依赖被错误地打包进了页面 chunk。加载页面代码之前,浏览器有一段明显的等待空白。解决办法是调整splitChunks策略,把高频第三方库单独分包,同时给路由级 fallback 加了一个极简的骨架占位。这里得到的教训是:懒加载是手段不是银弹,拆包粒度要结合首屏时间和缓存命中率一起权衡,不能拍脑袋。
5.2 请求缓存层的“陈旧数据”事件
统一请求缓存之后,开始一切正常,后来用户反馈一个列表页面在修改数据后,返回列表还是旧值。排查链路是这样的:缓存层默认对 GET 请求做了五分钟缓存,本意是减少重复请求;但同一个接口在 A 页面做了增删改之后,B 页面还在用旧缓存。问题出在失效策略只依赖时间过期,没有和业务写操作挂钩。后来我们加了“写操作后主动失效相关 GET 缓存”的机制,并且允许页面手动传入标记跳过缓存,才算彻底解决。
注意:请求缓存的失效策略,一定要和业务侧的写操作联动。单靠时间过期,在数据一致性要求稍微高一点的场景里基本都会出问题。
5.3 拆包后出现的循环依赖
某次构建完成之后,控制台报了一个很隐蔽的错误:某个模块在初始化阶段访问了 undefined。用 webpack 的循环依赖检测插件查了一下,发现拆包策略让一条新的循环依赖链浮出了水面。原因是一个页面动态 import 了另一个业务域的工具函数,而那个业务域的组件又反过来引用了这个页面的状态模块。解决方式是把那个工具函数下沉到shared,从物理上打破引用环。这个问题的排查价值在于:循环依赖不一定是最初写代码的人制造的,有时是拆包优化把原本深埋的依赖关系重新暴露了出来。
5.4 靠人的代码评审救不了一个坏味道的项目
这是组织层面踩的坑。改造初期的代码走查,我们一次评审提了八十多条 issue,参与评审的同事都很疲惫,写代码的同事也产生了对抗情绪。后来我调整了策略:能用 lint 和自动化测试拦住的问题,绝不上评审会讨论;评审只关注业务逻辑、边界情况和数据流设计。这样调整之后,评审的议题更聚焦了,大家也愿意更频繁地发起评审。这件事提醒我一个道理:质量体系里,工具和流程负责常规拦截,人的注意力要留给真正需要判断力的地方。
6. 四个月后的实际收益和还没有解决的事
改造成果需要用数字说话。我们对比了 REA 落地前后的同一套指标,变化如下:
- 生产构建时间:3 分 12 秒 → 42 秒
- 开发环境冷启动:40 秒 → 6 秒
- 首屏平均资源体积:1.8MB → 1.1MB(gzip 前)
- 组件复用率(两个及以上页面引用):12% → 38%
- 一次发版的平均准备时间:从半天缩短到 1 小时
我想特别说一下组件复用率这个数字。涨到 38% 并不是因为大家变勤快了,而是因为shared目录增加了评审门槛,新组件想进去,必须先回答“第二个页面是不是真的会用”。技术手段管不了人的习惯,但流程和制度可以。
6.1 仍然存在的历史欠账
REA 并没有解决全部问题。比如部分老页面仍然保留着旧的状态仓库写法,我们只是暂时禁止新增数据进入,存量字段还在慢慢消化。再比如自动化测试覆盖率只有百分之四十左右,距离理想的百分之七十还有很大差距。这些都是我承认的欠账,只能排进后续的迭代清单,指望一次治理解决所有问题本来就是不现实的。
6.2 后续可以继续深挖的两个方向
一是把features下的业务域切分得更彻底,让不同小组可以在各自的业务域里独立开发,甚至独立构建部署,这是微前端方向的探索,但前提是确实有独立发布的需求,而不是为了微前端而微前端。二是把 REA 的这套分层、数据流和构建规则沉淀成项目脚手架,让新项目开箱即用。这两件事本质上都是在把 REA 从“这一个项目”复制成“一类项目”的基建能力。
回过头看,这次改造最难的其实不是技术选型,而是让团队相信“改动不会让事情变坏”。REA 从头到尾没有出现过一次合并冲突风暴,就是因为每一步都很小,而且每一步都可以回滚。如果你也在打一个老项目的主意,我的建议很朴素:先定边界,再改增量,最后清存量。保持每两周都能看到一点正向进展,这个项目就能在持续迭代里活下去,而不是在某一次轰轰烈烈的重写里彻底烂尾。