资讯动态

React前端工程治理实战:从目录结构到性能优化

发布时间:2026/10/18 11:48:22 来源:尧图企业网站定制
去年年中我们内部把前端工程代号统一改成了“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里写指向srcTS里写指向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 -- --nameorder-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 的概念不太熟。我们特意在组件文档里画了一个对照表使用方式写法什么时候用完全受控传valueonChange每次改动都需要联动其他逻辑默认受控不传value内部自己管页面只关心提交时取值半受控传defaultValueonChange需要初始值但不希望页面一次次被刷新这个表格后来被新成员翻看频率很高。可见规范不一定要多高级把最基本的概念讲明白才是提升组件库易用性的关键。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 塞进>

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑