☰
React前端工程治理实战:从目录结构到性能优化
2026/10/18 11:48:14 网站建设 项目流程

去年年中,我们内部把前端工程代号统一改成了“REA”,乍一看像某个产品线缩写,其实就是“React Extended Architecture”的简写。起因是团队里新来了三个成员,每人理解一遍项目结构,讲得都不是同一个故事,文档和代码分层也对不上。这次改名表面上是规范代号,实际上逼着我们把整个前端工程从目录、状态管理、组件分层到构建链路重新梳理了一遍。这篇文章就是那次重构后的记录,适合正在做React项目中期治理、或者准备给团队定前端规范的读者参考,我会把踩过的坑和最终采用的方案一并放出来。

1. 从“REA”开始:项目命名的隐喻与工程骨架搭设

1.1 一个代号带出了目录设计

“REA”这个名字定了之后,第一件事就是让目录结构对得起这个名字。我们最终落地的顶层结构是这样的:

rea/ ├── apps/ │ ├── web/ # 面向用户的管理端 │ └── docs/ # 内部组件文档站 ├── packages/ │ ├── ui/ # 基础组件库 │ ├── domain/ # 领域模型/业务对象定义 │ ├── hooks/ # 通用自定义hooks │ └── utils/ # 工具函数集合 ├── configs/ │ ├── vite/ # 各应用共用Vite配置 │ ├── eslint/ # 统一的ESLint配置 │ └── tsconfig/ # TypeScript工程引用配置 └── scripts/ ├── check-circular/ # 循环依赖检测脚本 └── generate-template/ # 页面模板生成脚本

这个结构看起来不稀奇,但此前项目的问题是“约定靠嘴巴传”。同一个业务模块,有人放在pages/order,有人放在views/order,还有人在components/order里建了一堆业务组件,结果三个目录互相引用,改一处要全局搜索。

改成REA结构后,我们把规则压缩成三条,写进文档并让脚手架强制约束:

  • apps只放可运行应用,内部只允许引用packages下的模块,不允许反向依赖。
  • packages/ui是纯展示组件,禁止引入业务接口或路由对象。
  • packages/domain里放纯类型定义和纯函数领域逻辑,不允许引ui和hooks。

这三条规则靠人记必然失效,所以我们写了一个基于eslint-plugin-boundaries的检查规则,commit 之前必须通过。实测下来,新成员照着目录看一遍就能说出依赖方向,评审时也再不用纠结“这个组件该放哪”。

1.2 初始化项目时容易漏的三个基础项

REA这套结构对构建工具也有要求,我们用的是Vite。很多团队把Vite当“启动快的Webpack”用,配置里只配了alias和proxy,其实有几个关键项对长期维护影响很大。

第一,resolve.alias和tsconfig的paths必须同源。我们早期用的是两套写法,Vite里写@指向src,TS里写@指向src但大小写或路径粒度不一致,经常发生在IDE里跳转正常、打包却报错的情况。后来统一改成一个alias.config.ts文件导出配置对象,Vite 和 tsconfig 都引用它,才彻底消掉这类问题。

第二,显式区分开发依赖。react、react-dom必须放peerDependencies(如果做的是组件库)或dependencies,但@types/*、vite、eslint全家桶必须放devDependencies。有同事图省事全部塞进dependencies,上 CI 时每次npm ci多装几十兆包,构建时间白白涨了 40%。

第三,从第一天就开启打包产物分析。我们加了rollup-plugin-visualizer,在 package.json 里留一条npm run analyze命令,这样每次发版前跑一次,能直观看到哪些依赖被重复打进包里。这个习惯后期在性能优化章节里帮了大忙。

1.3 别小看脚本工具链

REA 项目里最受好评的其实是scripts/generate-template这个脚本。它做的事情很朴实:运行npm run new:page -- --name=order-detail,自动在对应目录下生成页面入口、类型声明、基础测试文件和一个 README 片段。

为什么要做这个?因为我们在评审中发现,绝大多数“目录不一致”问题,根源不是成员不守规矩,而是“手动新建文件时不知道怎么才算完整”。脚手架把样板代码一次性生成后,成员只需要往里面填业务逻辑,跨模块引用路径也会自动算好。一个看起来不重要的脚本,连续用两个月后,代码风格的一致性明显提升。

2. 状态管理从重到轻:为什么我们最终换掉了全局store方案

2.1 团队真实的场景需求

REA 项目最开始用的是经典的全局状态管理方案,store 里按业务域分模块,每个模块有 actions、mutations、types,另配一个createSelector层做派生数据。

理论上这套方案能覆盖所有场景,但实战里我们发现一个尴尬的错位:这个项目大多数状态的生命周期很短。比如订单列表页的筛选条件、表格当前页码、弹窗开关,这些状态只有单个页面自己关心,却被放到全局 store 里。每加一个页面,store 就膨胀一层,模块间相互 import,最后 store 变成了一个没有边界的杂物间。

我当时做了一个小统计:全局 store 里 80% 的字段只有一个页面在用,真正跨页面共享的只有登录态、权限列表和几个全局偏好设置。数据一旦摆出来,结论就清楚了——我们需要区分“全局共享状态”和“局部状态”,而不是一上来都塞给全局。

2.2 三套方案的对比与选择

方案适用场景团队实际体验最终判断
老牌全局方案中大型复杂应用、需要强时间旅行调试样板代码多,成员大多不喜欢,TS类型推导需要额外封装保留部分模块不迁移
MobX适合响应式粒度很细的场景响应式规则对新人不够直观,容易到处用observable没有试生产,参考价值有限
Zustand轻量局部store与全局store并存API直观,TS支持好,样板代码很少替换首选

Zustand 最终胜出,核心原因是它不逼我们把所有状态都集中到一个 store。你可以给每个页面建独立的小 store,也可以给全局建一个大 store,它只是一个工具,不会在架构上替你做大决定。

具体切换时,我们没有搞“一刀切”重写。策略是:把全局 store 里那些仅单页使用的状态,就近迁移到页面组件的局部 store 中;确实跨页面共享的领域状态,继续保留在全局 store 里,但拆成更小的独立 slice。这样迁移风险被限制在单页面内部,不会出现改了全局状态把其他页面带崩的情况。

2.3 一次又一次“为什么页面变卡了”的排查链路

这次重构过程中最值得记录的,是迁移中段出现的一次线上卡顿排查。

现象:订单列表页滚动时掉帧严重,偶发卡死。当时页面刚完成 Zustand 迁移,大多数人第一反应是“Zustand 性能不行”。我们一开始也怀疑,但冷静下来之后按链路排查了整整一下午。

第一步,先确认是不是无限渲染。打开 React DevTools 的 Profiler,录制一次滚动操作,发现列表项组件每个 tick 都在重渲染,而且渲染时长高达 40ms。但要找根因,得先确认状态是从哪来的。

第二步,检查 store 订阅粒度。我们用的是单个全局 store 订阅orders字段,问题在于 store 里还挂了一个selectFilter函数,每次滚动操作都会修改筛选条件对象,整个orders相关的派生状态全部重新计算。Zustand 虽然可以用 selector 做切片,但如果 selector 每次返回新数组,照样触发下游全部更新。

第三步,定位到具体代码。问题出在一个自定义 hook:它里面对orders做了map排序,而且这个 hook 被列表容器和每个列表项同时调用。列表项拿到的是一个新引用,于是 React 认为每个项的 props 都变了,全列表重渲染。

第四步,修复。列表项不要再订阅排序后的数据,排序逻辑上移到容器层,用useMemo缓存排序结果;列表项直接通过 index 拿原始对象,只在容器持有一个selectedId做选中态高亮。改动量不大,滚动帧稳定回到 60fps。

这个排查过程想说明一个道理:工具本身很少是性能瓶颈,瓶颈多半是使用方式的边界划错了。状态方案可以换,但不做订阅粒度分析,换什么都有可能卡。

2.4 迁移后我们留下来的三条规定

REA 项目迁移到新方案后,执行层思路很朴素:

  • 全局 store 只放登录态、权限、语言偏好等真正跨页面跨路由的状态。
  • 页面内有复杂联动状态时,先用局部 store 包裹,不必强行挂全局。
  • 每次新增全局 store 模块,必须在代码评审里说明“哪些页面会订阅它”,说不清的就不允许加。

这三条规定写起来简单,却是整个状态管理方案的灵魂。规则越少越好执行,团队也再没有出现“这个状态到底该放哪”的争论。

3. 组件分层的真实边界:不靠自觉的四步拆解法

3.1 问题:组件库从“工具库”变成“业务垃圾场”

REA项目里有一个基础组件库packages/ui,最初只有按钮、输入框、弹窗这类纯展示组件。后来业务推进很快,有人图方便把订单卡片直接塞进ui,然后这个卡片组件又依赖了api/order接口和全局路由,导致ui库被业务逻辑污染。某次升级基础组件时构建直接报错,报错信息一直追到业务接口层,这种“基础设施依赖业务”的结构倒挂绝不能留。

3.2 四步拆解法

我们后来在团队内部推了一套四步拆解,用来判断“一个业务需求里的 UI 部分该怎么放”。

第一步,把页面从业务接口中剥离。写组件时先问一句:“这个组件如果换一份假数据,还能不能渲染?”能渲染,就应该往基础组件走;不能,就说明它强依赖业务数据源,只能放业务层。

第二步,把“业务卡片”拆成“样式外壳 + 数据槽位”。以订单卡片为例,外壳部分(布局、状态标签颜色、金额格式化)其实是通用的,可以做成一个OrderCardShell放在ui里,业务数据通过 children 或 props 传入。这样业务页可以自行组合接口数据,但组件库本身保持纯洁。

第三步,用 children 或 render prop 打开扩展点。基础组件不能满足的需求,优先通过扩展点解决,而不是直接改基础组件源码。比如列表组件需要自定义空状态,就在Empty插槽里实现,而不是在组件库里写死“没有订单时显示某种样式”。

第四步,给每个组件标注“请勿引入业务代码”的红线。我们在代码评审里固定检查一条:基础组件文件里是否出现api/、router/、stores/这三个关键词。出现任何一个,直接打回。这类强制检查靠 ESLint 就可以做,效果立竿见影。

这套拆法并不复杂,真正的难点是“让成员觉得合理”。我们配套做了几次 workshop,拿真实需求现场拆解,拆完之后大家发现基础组件库的代码量反而变少了,因为很多重复的业务样式被提取成了可复用外壳。

3.3 受控与非受控:一个容易被忽略的组件边界

组件库维护过程中,另一个容易出问题的边界是受控/非受控模式。

我们最初的输入框组件只支持受控模式,用value和onChange受控。但当页面里需要“默认值 + 用户编辑”时,还要自己维护一个 useState,写起来非常啰嗦。后来参考成熟组件库的做法,支持value受控、defaultValue非受控,还加了一个重置功能。

这个细节看似不算大,但是在微信小程序转React项目过来的成员,普遍对 controlled/uncontrolled 的概念不太熟。我们特意在组件文档里画了一个对照表:

使用方式写法什么时候用
完全受控传value+onChange每次改动都需要联动其他逻辑
默认受控不传value,内部自己管页面只关心提交时取值
半受控传defaultValue+onChange需要初始值,但不希望页面一次次被刷新

这个表格后来被新成员翻看频率很高。可见,规范不一定要多高级,把最基本的概念讲明白才是提升组件库易用性的关键。

4. 性能优化不靠memo堆砌:渲染追踪帮我们找出的三个问题

4.1 先看真实渲染,再谈优化

REA 项目早期有个通病:问“页面为什么卡”时,第一反应是“给组件加 useMemo、memo”。但后来我们发现,很多 memo 加完根本不影响性能,反而让代码难读。于是开始强制一条流程:优化之前先打开 React DevTools Profiler 或手动写一段高亮渲染的代码,看清哪些组件在重复渲染。

具体做法很土但有效:在开发模式下给所有组件包一层RenderTracker,每渲染一次就 console.log 组件名和时间戳;打开页面滚几圈,刷新看日志,哪些组件在“无意义渲染”一目了然。我们自己排查时,发现三个高频问题如下。

4.2 问题一:context 被当“快捷通道”传递状态

REA 项目里有人把theme、user、language全塞进一个大的AppProvider,页面任意一处改设置都会扩散到所有子组件。组件树越大,重渲染范围越广。

优化策略不是去掉 context,而是拆分 provider。一个 context 只承担一种职责,且 context 的 value 必须用useMemo缓存。另外,只有真正需要这些全局信息的叶子组件去订阅,中间层的“转发组件”不订阅,防止整棵子树被拖下水。

4.3 问题二:内联对象导致的“memo失效”

这是最隐蔽的一个坑。给子组件包了 memo 之后,如果父组件 render 时传了一个新的内联函数或内联对象,比如onClick={() => handle(id)},memo 依然失效,因为浅比较会认为 props 变了。

我们统计发现,不少 memo 失效就是这种内联写法引起的。替代方案是:把回调函数用useCallback缓存,同时把点击的对象 id 塞进>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询