
“rea”这个代号乍一看像是谁在键盘上随手敲出来的三个字母。其实它来自我们一次前端架构重构的提案文档完整拼写是 Reusable Architecture后来群里讨论时被缩成了 rea就这么一直沿用了下来。这篇想说的事情不是一个开源框架的介绍而是实实在在的后台管理系统前端改造过程为什么我们要做这次重构、目录怎么划、组件库怎么沉淀、权限怎么落地以及事后踩过的那些坑花了多久才把情况理顺。如果你所在的团队也在维护一个历史包袱很重的后台系统或者正准备做一次技术升级那么这篇东西应该能给你一点参考。我不敢说这里每一条都是标准答案但都是我们真实跑过线上、出了问题后又回头修过的方案。1. 为什么会有“rea”老后台系统压下来的三座大山1.1 复制粘贴成风的代码库和一行改动引发的连环报错我们手上的系统并不算小粗略统计有四十多个业务页面分布在订单、客户、财务、权限等好几个模块里。系统最早是几个团队轮流接手做的代码风格非常不统一。有的页面用 class 组件有的用函数组件有的连状态管理都没接直接在组件内部用 setState 硬撑。最让人头疼的是使用者之间几乎没有复用关系。举个例子一个带搜索条件的表格弹窗几乎每个业务模块里都有自己的版本。有人用 Modal 套 Table有人用 Drawer 套 Form还有人干脆自己写了一个浮层组件。不同版本之间的交互细节差异很大有的弹窗默认宽度 520有的 800有的遮罩不可点击关闭有的又可以。表面上看这只是一个“样式不统一”的问题实际上每次产品提需求只要涉及搜索条件或导出按钮就得挨个页面去改改漏一个就是线上事故。这种状态持续到一次大版本迭代我们准备把订单管理里的列表字段做一次重命名。原本以为只是改一个接口字段没想到搜索、导出、详情、批量操作、权限判断五处地方都引用了同一个旧字段而每处代码的来源还不一样。最终我们花了整整两天排查引用关系才把因为字段不一致导致的报错清干净。就是从那时候起团队内部开始认真讨论一件事这个系统再不梳理边界后面每加一个功能都会更痛苦。1.2 重构的初衷不是“换框架”而是“换思维”很多人一听到重构第一反应就是换框架、换版本。我们一开始也讨论过要不要直接上 React 18、换一套新的路由和状态库。但冷静下来后所有人都有一个共识框架升级解决不了“代码没有复用”和“模块之间边界模糊”的问题。我们真正想要的是三件事让通用能力沉淀下来组件、请求方法、通用函数都在一个地方维护。让业务模块之间尽量减少直接依赖A 模块不要随便 import B 模块的内部文件。让新来的同事能快速定位“这个功能应该改哪个文件”。基于这三个目标我们才提出了重新梳理整体架构的想法而不是简单地把代码从一个版本迁移到另一个版本。REA 这个名字其实就是这轮讨论的产物。1.3 rea 的三层含义不只是缩写项目代号定下来之后我们还顺着它把目标拆成了三层第一层 RReusable所有能被两个以上业务复用的东西全部下沉到共享层。第二层 EEnterprise这是一套面向企业后台的长周期系统不是一次性活动页需要有清晰的生命周期管理。第三层 AArchitecture架构本身不是一堆配置文件而是一套“约定”从目录结构到命名规范再到调用关系都有明确的规矩。后来我们在新人文档里写的就是这句话进入这个仓库先看目录结构再读共享层代码最后才看业务页面。只要顺序对了至少能少踩一半坑。2. 目录与分层设计先画好边界再谈功能实现2.1 按业务域划分而不是按技术类型堆目录老系统最大的结构问题是全仓库只有顶层几个文件夹components、utils、services、pages。看起来分工明确实际用起来非常混乱。pages 文件夹里长啥样的文件都有components 里放了一个又一个没人引用的废弃组件services 里则是接口文件和类型定义混在一起。我们在 rea 项目里做的最大的改变是把“按技术类型分目录”改成了“按业务域分目录共享层独立”。大致结构是这样src/ app/ # 应用入口、路由表、全局配置 modules/ # 业务模块每个模块内部自带 pages/components/services order/ pages/ components/ services/ constants/ customer/ pages/ components/ services/ constants/ shared/ # 跨模块共享内容 ui/ # 基础组件库 hooks/ # 通用 hooks utils/ request/ # 请求层封装 types/ # 全局公共类型这样做的理由很简单一个业务功能从接口请求到页面渲染链路尽量收拢在同一个模块里。改订单的时候只需要在 modules/order 下找文件不用再从全局 pages 跳到全局 services 再跳到全局 components。你可能会问如果两个模块要共用某个业务组件怎么办我们的答案是先放进各自模块里观察等第三个模块也用到的时候再提升到 shared 层。不要一上来就把所有东西都抽到顶层过早抽象同样是一种坏味道。2.2 共享层的边界什么该进什么不该进共享层不是垃圾桶不是什么都能往里丢。我们对 shared 目录约定了几个硬性要求shared/ui 只放不感知业务的基础组件比如 Button、Input、Modal、Table 的二次封装组件内部不允许直接调用业务接口。shared/hooks 只放通用逻辑比如防抖、节流、分页加载、权限判断这类与具体业务无关的东西。shared/request 只做请求封装和错误处理里面不写任何具体的接口地址也不写某个模块独有的转换逻辑。shared/utils 只放纯函数比如日期格式化、金额单位转换、树形数据扁平化。一旦某个文件开始出现具体的业务字段名比如“订单状态映射”“客户等级计算”那它就必须搬回对应的业务模块。这条边界我们执行得比较严格至少在审查代码的时候都会先看一眼 new file 放在了哪里。2.3 一个业务操作从点击到落地的调用链为了让大家能直观地理解这套分层我们画了一条每次新需求都必须遵循的调用链页面组件只负责渲染和交互。交互触发的业务逻辑写在本模块的 services 或 hooks 里。services 里的接口函数统一走 shared/request 的封装实例。接口返回的数据在模块内做映射转换再交给页面显示。跨模块的数据共享必须通过全局状态或路由参数禁止直接 import 另一个模块的组件内部状态。这套链路看起来简单但真正执行下来之后很多“隐性耦合”就暴露出来了。比如以前有人会在订单列表页直接 import 客户模块的某个详情组件这在新的结构里是过不了代码审查的。因为跨模块的引用意味着两个模块的发布和改动被绑在一起任何一边改了接口字段另一边可能就悄悄挂掉。3. 组件库落地的关键决策从“能用”到“好用”3.1 基础组件的接口设计受控与非受控的切换后台系统里最常用的基础组件其实是 Table、Form、Modal 这套组合。项目初期我们直接从第三方的组件库搬了一层封装但用了一段时间就发现过度封装会让组件失去灵活性。最典型的问题是表单类组件的“受控”与“非受控”。比如封装一个 Input如果直接接管它的 value却不允许外部传 onChange 覆盖那在复杂表单里就会出现“组件自己认为状态更新了页面却没跟着变”的情况。后来我们统一了封装原则对外暴露的接口里value 和 onChange 必须同时支持受控和非受控两种用法。实现方式借鉴了很多成熟的组件库思路如果外部传了 value 和 onChange组件内部就不再维护自己的状态完全由外部控制。如果外部只传 defaultValue组件内部自己管理状态但变更后仍会触发 onChange 通知外部。这个原则听起来简单但它避免了很多“这个组件在别的页面能用在我这里就不受控”的玄学问题。还有一个细节值得单独提一下所有基础组件的样式变量不允许出现魔法数字。弹窗默认宽度、表格行高、字体大小都统一从主题配置里读取。这样后续做深色主题或大字版适配时不需要翻箱倒柜地改每个组件。3.2 业务组件的抽象时机三个用户、两次复用基础组件好界定业务组件就容易走极端。有些人一个表格页面写了三千行有些人把每个按钮都包成一个组件。我们的经验是一个业务组件至少要满足“三个用户、两次复用”的条件才值得从业务页面里抽出来。所谓“三个用户”是指这个组件的潜在使用者不止一个页面。比如订单详情里的收货地址卡片订单列表、订单详情、售后流程都要展示这就是一个典型的业务组件。而如果是某个报表页面里特有的图表配置即便代码再长也建议先留在页面里等第二个地方真的需要了再抽。业务组件还有一个共同点它们通常需要接收“数据源”和“回调行为”但不需要知道自己被用在哪个业务模块。我们会约定业务组件的 props 里只出现它自己需要的字段不允许直接透传一整个接口返回的大对象。这么做的好处是组件自身逻辑清晰也更容易写测试。3.3 权限控制的落地路由守卫、状态识别、初始渲染三层配合后台系统绕不开权限。rea 项目里我们没有引入特别复杂的权限框架而是把权限判断拆成三层。第一层是路由守卫根据当前用户的角色判断哪些路由可以进入。第二层是组件内部判断针对按钮级别的权限我们封装了一个权限判断的通用方法在渲染前检查。第三层是初始渲染控制对于默认隐藏的列或操作按钮要在数据还没返回时就已经确定可见性避免刷新页面时闪一下再消失。具体来说角色与权限的对应关系会先由后端返回我们直接把这份权限表放在全局状态里。前端拿到路由地址时在路由守卫里做一次匹配进入页面后每个按钮通过权限编码去查全局表。整个判断是同步完成的不需要页面渲染后再等接口。这样一个三层组合的好处是既不会在 URL 层面泄露无权访问的页面也不会在 UI 层面出现按钮闪烁的问题。权限判断逻辑集中在一处新增一种角色时只需要维护权限表而不是去每个页面改 if 判断。4. 踩坑实录四类最典型的疑难问题与完整排查链路4.1 跨模块数据共享引发的“幽灵更新”有段时间我们接了一个需求客户模块要展示最近五笔订单摘要。开发同学很自然地想在订单模块的全局状态里加一个“最近订单列表”然后在客户模块里去订阅它。上线初期一切正常直到订单列表页做了一个筛选联动客户模块里的摘要数据开始不定期地闪动、消失。排查的时候我们一开始以为是接口缓存问题反复清了缓存都没用。后来在状态管理调试工具里观察才发现全局状态里存的“最近订单列表”被订单模块的筛选逻辑无意中覆盖了。根源在于这个全局状态被多个模块共用但更新它的入口没有做数据来源隔离。最后的修法其实很简单订单摘要和订单列表虽然是同类数据但它们的语义和使用场景完全不同不应该共用同一个全局状态节点。我们把“最近订单摘要”从全局状态里拆出去由客户模块自行请求不再经由订单模块写入。这个坑给我们留下了一条硬性规则全局状态里只放真正需要跨模块共享的、业务语义高度统一的公共数据比如用户信息、权限表、全局配置模块内数据一律留在模块内管理。4.2 构建产物体积失控从 3.2MB 到 1.5MB 的压缩过程重构接近尾声时我们发现打包产物体积比老系统还要大首屏脚本接近 3.2MB。一开始怀疑是 code splitting 没开检查配置后却发现路由懒加载都加了。后来用体积分析工具逐个包扫描才找到几个问题。最大的问题出在“看似局部、实则全局”的导入。有人把整个第三方组件库直接 import 到 shared/ui 的入口文件里导致每个页面都带上了全量组件库。另一个问题是工具函数库明明只需要用的几个函数却引入了整个包。处理的方式是两步第一步把第三方组件库的引入改成按需引入而不是全量挂在 shared/ui 入口。第二步把所有 import 大工具的代码改成只引用具体子路径同时开启构建阶段的体积警告阈值。这两步手术后产物从 3.2MB 降到了 1.5MB 左右。核心思路倒不是什么高深技巧就是时刻盯住“入口文件不引入不该有的东西”这条原则。4.3 路由懒加载被“完整加载”破除动态 import 失效之谜另一个困扰了很久的问题明明每个路由都用动态 import 写了懒加载首屏也做了按需加载可页面切换时居然还是会出现一次性的巨大脚本下载。查到最后才发现问题出在一段公共代码里。某位同事为了图省事在一个公共组件里直接动态引入了这个模块的整个目录列表这类写法会被构建工具识别成一个“完整的异步边界”于是原本多个应该分开加载的路由被合并到了一个文件里。只要一个路由被访问整包都被拉下来了。修法并不复杂把那个公共组件里的整目录动态导入改成具体的文件路径引用同时把路由配置里的 component 字段严格写成单文件动态导入。验证方式很简单切到任何一个页面打开网络面板看脚本加载情况应该只出现当前页面所属的 chunk而不是一个巨大的合并包。4.4 样式作用域下的“全局污染”后台系统的样式问题也很考验人。我们用过局部作用域的写法但某个业务模块里总会出现“这个按钮在这个页面正常到了另一个页面就多了一条阴影”这类情况。顺着选择器去查发现是有人为了让某个弹出层能覆盖在某些容器之上写了一条针对全局类名的样式。局部作用域只对组件根节点类名做了处理不代表组件内部不能写全局选择器。一旦写了这种全局选择器它就能穿透到任意页面。排查这种问题我们用了一个笨但有效的方法在浏览器里检查元素沿着样式面板逐条看来源文件。凡是来源不是当前模块文件的规则全部挑出来重点核查。后来我们在编码规范里加了一条禁令业务模块内部禁止手写全局类名选择器确实需要对弹出层做覆盖定位时统一使用共享层提供的定位组件来完成。5. 从 rea 沉淀下来的一些经验与实用清单5.1 文档先行先写组件说明再写组件代码如果你问我在这次重构里最值得推荐的一件事我会说“文档先行”。这里不是指那种写一大堆设计文档然后束之高阁的文档而是每个组件、每个 hooks、每个共享函数先想清楚“它解决什么问题、参数是什么、返回值是什么”再动手写实现。我们的组件库目录里每个基础组件都有一个简短的说明文件内容包括使用场景、props 表格、一个最小示例。业务组件的说明可以短一点但至少要写明“在哪个模块里被使用”避免组件变成无主物。这样做的好处一是写代码的时候思路更清楚二是新人接手时不用靠猜。我们是吃了足够多的亏之后才养成这个习惯的早期有好几个组件连作者自己都忘了为什么参数要这样设计。5.2 一套可复用的检查清单随着 rea 项目逐步稳定我们沉淀了一份后台系统前端开发的检查清单。虽然不是标准但对团队实际执行很有帮助检查项期望结果新文件归属业务代码进入 modules 对应模块通用代码进入 shared跨模块引用不存在业务模块之间直接 import 内部文件基础组件接口受控与非受控用法都已支持业务组件复用至少满足“三个用户、两次复用”再抽象全局状态只存全局公共数据模块数据留在模块内脚本体积每次构建后检查产物是否超出预期阈值权限判断路由、按钮、初始渲染三层都已覆盖样式作用域业务模块内无全局类名选择器这份清单正好覆盖了前面几节里提到的所有问题点。版本迭代时我们会在提测前过一遍省去了不少线上救火的环节。5.3 一点个人体会写到这里我想说点不那么“工程”的东西。代码结构重整这件事难度从来不在技术本身而在面对历史代码时的取舍。你可以把所有页面推倒重写但业务不能停接口不能变线上风险还在。所以哪怕项目代号叫 rea实际执行时也并没有那么“壮烈”更多时候是一小步一小步地迁移、验证、再迁移。我个人的体会是先解决“边界问题”再处理“实现问题”。只要部门划分清楚、调用关系理顺页面写得难看一点都容易改反过来如果边界一团糟哪怕每个组件都写得漂亮整体依然难以维护。这个结构后来还被我们移植到了其他几个内部项目里。当然不是照搬文件目录而是把它背后的思路带过去共享层独立、业务模块自治、全局状态最小化。如果你现在也在纠结要不要做重构我的建议不是马上开始写代码而是先把当前项目里“复制粘贴最多、改动最频繁”的三个场景找出来对着这篇里的思路问自己一下这些东西如果重新组织应该放在哪里想清楚这个问题代码怎么落基本上就有方向了。