旧前端项目不重写:架构治理实战与构建提速 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.getPageResultOrder(/api/order/list, { params }); export const createOrder (data: OrderPayload) http.postOrder(/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.1MBgzip 前组件复用率两个及以上页面引用12% → 38%一次发版的平均准备时间从半天缩短到 1 小时我想特别说一下组件复用率这个数字。涨到 38% 并不是因为大家变勤快了而是因为shared目录增加了评审门槛新组件想进去必须先回答“第二个页面是不是真的会用”。技术手段管不了人的习惯但流程和制度可以。6.1 仍然存在的历史欠账REA 并没有解决全部问题。比如部分老页面仍然保留着旧的状态仓库写法我们只是暂时禁止新增数据进入存量字段还在慢慢消化。再比如自动化测试覆盖率只有百分之四十左右距离理想的百分之七十还有很大差距。这些都是我承认的欠账只能排进后续的迭代清单指望一次治理解决所有问题本来就是不现实的。6.2 后续可以继续深挖的两个方向一是把features下的业务域切分得更彻底让不同小组可以在各自的业务域里独立开发甚至独立构建部署这是微前端方向的探索但前提是确实有独立发布的需求而不是为了微前端而微前端。二是把 REA 的这套分层、数据流和构建规则沉淀成项目脚手架让新项目开箱即用。这两件事本质上都是在把 REA 从“这一个项目”复制成“一类项目”的基建能力。回过头看这次改造最难的其实不是技术选型而是让团队相信“改动不会让事情变坏”。REA 从头到尾没有出现过一次合并冲突风暴就是因为每一步都很小而且每一步都可以回滚。如果你也在打一个老项目的主意我的建议很朴素先定边界再改增量最后清存量。保持每两周都能看到一点正向进展这个项目就能在持续迭代里活下去而不是在某一次轰轰烈烈的重写里彻底烂尾。