
最近“real”这个词在圈子里被说得很频繁意思是真实、不玩虚的。手头这个内部项目代号就叫“rea”全称可以展开成 React Ecosystem Architecture虽然名字取的是简称顺口但真正把这套体系从头到尾折腾一遍之后我发现里面沉淀出来的思路倒是非常值得拿出来分享。如果你正好在做中后台项目或者准备重构一个已经乱成一团的旧前端这篇文章应该能帮你省掉不少自己摸索的时间。很多前端团队手里都攒着一堆“历史包袱”组件库风格不统一、全局状态散落得到处都是、接口请求各写各的、页面一多就明显卡顿。REA这套实践想做的事情其实很朴素就是用一套统一的分层结构和约定把组件、状态、请求、路由、性能这几个老大难问题一次性理顺让项目在快速迭代的同时还能保持体面。下面我按实际搭建的顺序来聊从设计思路讲到落地的每个关键环节最后把踩过的坑也一起列出来。1. 整体设计与思路拆解1.1 为什么会走到这套架构最开始催生这套架构的是一个典型的业务扩张场景。那时候团队同时维护好几个业务模块每个模块都是独立页面看起来互不干扰实际上底层逻辑高度重叠都需要用户信息、都要根据权限渲染菜单、都要做列表查询和数据筛选。结果就是同一个功能在不同模块里被实现了四五遍样式长得很像但完全不能复用每次需求变更都要把所有副本找出来挨个改。我打过一个比方这就好比一个厨房里配了五套刀具每套刀都长得差不多但谁都不知道哪把是切熟食的、哪把是切生肉的。平时凑合用没问题一旦赶上饭点大批量出餐整个灶台就会陷入混乱。REA要解决的就是这个“厨房标准化”的问题——把刀架、菜板、调料区全部规划好让所有人到同一个位置拿指定的工具。所以架构设计的第一个出发点不是追逐新东西而是先把“什么东西应该放在哪”这个问题回答清楚。换句话说这更像是一次整理术而不是一次技术升维。理清边界之后新功能开发会明显变快交接成本也会大幅降低。1.2 三个核心设计原则第一个原则叫分层清晰、不越界。页面只负责组合组件组件只负责渲染自己那块的职责状态库只管理跨模块共享的数据请求层只做接口通信。听起来像废话但实际项目里最容易出现的就是页面里直接写请求逻辑、组件里私自改全局状态、工具函数里偷偷调接口这类“跨层污染”。REA在编码规范层面直接卡死这种写法Code Review 看到越界代码就会被打回。第二个原则是数据流要单向。所有状态变化都必须从触发动作开始经过明确的方法更新存储再通过订阅关系反馈到视图。禁止在组件里直接给全局对象赋值也禁止用多层对象嵌套把一个 store 撑到上千行。单向数据流带来的最大好处是可追踪页面出问题的时候沿着数据流向往回查通常十分钟内就能定位到根因。第三个原则是约定优于配置。列表页、表单页、详情页这些高频场景团队内部直接给出模板新页面从模板复制出来再改业务字段就行不需要每次重新搭建筛选区、表格区、分页区。这种做法的代价是一开始要花时间做模板收益却很长期新同学入职第三天就能上手提交需求也不用反复开会对齐页面风格。1.3 技术选型背后的取舍技术选型这部分我不想搞得像“全家桶”崇拜更推荐按问题找方案。构建工具选了社区里认可度最高的方案做中后台不需要在构建上花太多心思关键是生态成熟、报错信息清晰、热更新体验流畅。框架层面坚持函数式组件加 Hooks配合 TypeScript 约束类型这个组合在类型推导和业务复杂度之间算是目前体验最好的平衡。状态管理需要单独说。以前团队用过那种全局 store 管所有东西的方案后来发现非必要全局状态反而制造了隐式依赖。REA里只保留了三个全局 store 的名额用户信息、权限配置、应用级偏好设置。其余数据一律就近放在组件内部或者服务端页面之间要共享数据就通过请求去拿不额外造出一层全局缓存。路由设计采用的是单层路由加动态注册的模式权限菜单由后端返回的配置驱动。这样既能实现按需加载又能保证菜单和路由表始终同步。最后在请求层统一封装了 Axios 实例拦截器处理鉴权、错误提示和重复请求取消这些细节到后面实操部分再展开。2. 核心细节解析与实操要点2.1 目录结构长这样模块分层通过目录结构来落地我们从零搭的时候用的结构大致是这样的src/ ├── api/ │ ├── request.ts │ └── modules/ ├── assets/ ├── components/ │ ├── basic/ │ ├── business/ │ └── page/ ├── hooks/ ├── layouts/ ├── pages/ ├── stores/ ├── styles/ ├── types/ └── utils/这里最需要注意的是 components 里面又拆了三层。basic 放的是那些纯展示、无业务逻辑的原子组件比如按钮、输入框、弹窗business 放的是和业务场景绑定的组合组件比如用户选择器、部门树、时间范围快捷筛选page 放的是只在一个页面里使用的局部组件不进全局注册目录。这个三层拆法解决了一个很现实的痛点全局组件库一旦膨胀所有人都往里塞东西最终会变成一个谁也不敢动的巨型怪物。REA规定只有 basic 和 business 两层可以全局复用page 层组件直接放在对应页面目录下面页面重构时直接删掉不会污染全局。api 目录下按业务模块建子目录每个子目录一个文件专门写当前模块的请求函数禁止在页面组件里直接写 axios。2.2 组件的边界怎么划写组件最常遇到的困惑是“这个组件应该拆多细”。REA里给的判断标准很简单如果一段 JSX 或者一组逻辑在未来有超过一半的概率会被另一个地方复用就值得拆出来如果只是当前页面用得到就先放在页面目录里等第二次出现再提升到 business 层。组件内部还要注意区分“受控”和“非受控”的边界。凡是需要父组件感知数据状态的统一走受控模式也就是 value 和 onChange 都由外部传入凡是内部状态对外部无感知的就保持非受控。很多 bug 都源于这个边界没划清楚子组件内部自己存了一份数据父组件刷新后又传了新的 props两边对不上视图自然表现异常。命名的规矩也很重要我习惯用“容器组件 展示组件”的说法来约束职责容器组件负责取数据、维护状态、组织交互逻辑名字后缀叫 Page 或者 Container展示组件只负责把数据渲染成 UI后缀叫 View 或者 List。这个命名约定配合目录结构基本能让任何人快速定位到有问题的那一层。2.3 状态管理多一分都是灾难状态管理我用一句很直白的话来总结能用 URL 参数表达的不要放 store能用组件 props 传递的不要放 store只有跨模块甚至跨页面频繁共享的数据才值得放 store。REA 的全局 store 通常只有三个左右每个都非常精简。以用户信息为例大致是这样写的import { create } from zustand; interface UserState { user: UserInfo | null; permissions: string[]; fetchUser: () Promisevoid; } export const useUserStore createUserState((set) ({ user: null, permissions: [], fetchUser: async () { const user await getUserInfo(); const permissions await getPermissions(); set({ user, permissions }); }, }));注意这里 store 里只放了数据读取和更新动作没有任何业务逻辑也没有在组件外部调用 store 方法。这样做是为了保证状态变化路径唯一页面挂载时通过 useEffect 触发 fetchUser拿到数据后 set 进去其他组件通过订阅同一个 hook 读取数据。哪天用户状态出现异常顺着调用链往回查一定能在某个组件的 effect 或事件回调里找到源头。还有一类容易被忽略的状态是“服务端缓存状态”。比如详情页打开后需要展示用户上次保存的信息很多人会顺手存进全局 store结果就是数据可能过期、多端不同步、刷新后丢失。REA 的原则是服务端数据尽量保持无状态需要时直接请求让后端成为唯一数据源。这样虽然在请求次数上不是最省的但一致性永远有保证。2.4 性能优化是省出来的性能优化的核心不是“用了什么黑魔法”而是“少做无用功”。中后台页面最容易出现的性能问题就是无意义的重复渲染父组件一更新整棵子树全部重新执行哪怕子组件 props 根本没变。React 生态里最多的优化手段无非那几个memo 包裹纯展示组件、useMemo 缓存复杂计算结果、useCallback 稳定回调引用。但在 REA 里我会特别强调一个使用边界不要无脑给所有组件套 memo。如果一个组件本身渲染很轻量或者它经常随着父组件更新而必须更新套上 memo 反而增加比较开销。真正值得 memo 的是那些 props 引用变化频繁、渲染成本高、且往往不需要跟着父组件一起更新的组件。React 18 之后又多了一个很好用的特性useDeferredValue。它适合用在不那么紧急的派生值上比如列表页的模糊搜索。输入关键字时先让输入框快速响应列表过滤结果延迟一点出现视觉上输入不会卡顿。用法大概是这样的const [keyword, setKeyword] useState(); const deferredKeyword useDeferredValue(keyword); const isReady deferredKeyword keyword; // 列表加载只用 deferredKeyword useEffect(() { fetchList({ keyword: deferredKeyword }); }, [deferredKeyword]);这个 API 的底层依赖并发特性不需要开发者手动处理定时器实现思路比防抖更优雅。需要注意的一点是useDeferredValue 不会让业务逻辑变快它只是把非紧急更新的优先级降下来让输入框这类高优先级交互保持流畅。所以它适合渲染成本可控的场景如果列表本身要遍历上万条数据该做虚拟滚动还是得做。我用 React DevTools 的 Profiler 录制过几次页面操作重点观察 flamegraph 里哪些组件被频繁高亮。绝大多数情况下性能瓶颈都集中在某个大列表或者某个包装层过深的组件树上。找到瓶颈之后再做针对性优化比盲目套 memo 有效得多。3. 实操过程与核心环节实现3.1 初始化与基础配置搭建过程第一步是初始化工程。我们用的是社区里成熟的构建工具链初始化命令跑完之后立刻补齐三样东西路径别名、代码规范、环境变量类型。路径别名把所有 import 的../写成/这样移动文件时不用大改引用路径心智负担小很多。代码规范方面我们直接用了 ESLint 加 Prettier 的组合提交前强约束。这里有一件值得分享的事不要把 lint 配置搞得太复杂规则一多开发体验会变得很痛苦。REA 只保留了几个分类的强规则未使用变量报错、禁止 any、import 顺序、hooks 依赖检查。这些规则能在早期拦下大量低级问题同时不会让人天天被红波浪线打扰。环境变量在 TypeScript 里要做类型声明不然每次取值都是字符串业务代码里转换类型写得很啰嗦。我们在 types 目录下专门给环境变量定义了一个接口然后在编译入口声明全局类型。这样后续新增环境变量时编译器能直接提示缺失项避免上线后才发现某个配置没传。3.2 一个能扛住真实业务的请求模块请求模块是整个工程的地基写不好后面所有页面都会跟着难受。REA 的请求层基于社区常用的 HTTP 客户端做二次封装统一处理 baseURL、超时时间、鉴权头、响应拦截、错误提示和重复请求取消。核心代码大概长这样import axios from axios; import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from axios; const service: AxiosInstance axios.create({ baseURL: /api, timeout: 10_000, }); // 请求拦截注入鉴权信息 service.interceptors.request.use((config) { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截统一处理结构 service.interceptors.response.use( (response) { const res response.data; if (res.code ! 0) { showError(res.message); return Promise.reject(new Error(res.message)); } return res.data; }, (error) { if (error.response?.status 401) { redirectToLogin(); } else { showError(error.message || 请求失败); } return Promise.reject(error); } ); export function getT(url: string, params?: Recordstring, unknown) { return service.getT, T(url, { params }); } export function postT(url: string, data?: unknown) { return service.postT, T(url, data); }这里最关键的设计点是响应拦截器直接 return res.data而不是 return response 对象。这样组件里拿到的就是业务数据不需要每次调用.data.data这种层层解包。另一个细节是错误提示统一在拦截器里做页面代码就不用到处写 try catch 加 toast 了只需要在确实需要特殊处理的地方单独捕获异常。我在实际项目里还加了一个“重复请求取消”的逻辑。场景是用户连续点击多次提交按钮或者列表筛选条件快速变化上一次请求还没返回下一次请求就发出了。如果后端不处理幂等极容易出现数据错乱。解决方案是维护一个请求队列相同 url 和参数的新请求发出时取消掉还没返回的旧请求。实现不复杂但在交互体验上改善得非常明显。3.3 通用列表页的实现套路中后台页面最常见的形态就是“查询条件加表格加分页”我们直接把整个流程沉淀成了一个页面模板。模板里的查询组件、表格列配置、分页逻辑全部用统一组件拼接业务方只需要往模板里填写筛选字段和列定义。列表页的状态组织值得仔细设计REA 里采用一个对象集中管理查询参数const [query, setQuery] useState({ page: 1, pageSize: 10, keyword: , status: undefined, }); const [list, setList] useStateItem[]([]); const [total, setTotal] useState(0); const [loading, setLoading] useState(false); const fetchList useCallback(async () { setLoading(true); try { const data await getItems(query); setList(data.items); setTotal(data.total); } finally { setLoading(false); } }, [query]); useEffect(() { fetchList(); }, [fetchList]);这段代码看起来平淡无奇但它体现了一个容易被忽略的原则查询参数的更新一定要通过 setState 生成新的对象而不是直接修改老对象。因为 useEffect 的依赖比较的是引用只有产生新引用才会触发重新请求。很多同事在这个地方踩坑直接改了 query 对象里的字段结果 effect 不执行表格永远不刷新。分页组件有一个默认行为页数变化时要把页面滚动条拉回表格顶部不然用户翻到第二页还停在原来滚动的深度观感非常奇怪。模板层面直接把这件事放到一个公共回调里每一页类型都不用重复写。3.4 构建与部署的底层细节构建阶段的优化经常被拖到最后才做实际上从工程初始化开始就要考虑。REA 在构建配置里做了三件事环境变量区分、代码分包、静态资源缓存策略。环境变量区分很简单就是开发、测试、生产三套 env 文件构建时根据命令行参数加载对应配置。代码分包用动态 import 实现了路由级懒加载用户访问到哪个页面才加载哪个页面的 chunk。中后台系统页面多如果不分包首屏加载的一个 JS 包可能就有两三兆这在内网环境下也能明显感觉到卡顿。静态资源缓存策略直接决定发版本时用户的旧缓存会不会捣乱。我们的做法是给带 hash 的静态文件设置长缓存给入口 HTML 设置 no-cache。这样新版本发布后用户刷新页面就会拿到新的 HTMLHTML 里引用的新资源带有新 hash自然绕过旧缓存。这个机制一旦做好发版后基本不会出现“用户看到旧界面点不进去”之类的诡异问题。4. 常见问题与排查技巧实录4.1 状态更新了但视图不刷新这类问题排在遭遇频率第一名。先检查是不是直接修改了 state 内部的嵌套对象尤其是那种从接口拿回来、直接塞进 state 的数组或对象。如果代码里写的是list[0].name xxReact 是感知不到变化的因为引用没变。正确的做法永远是生成一个新引用比如const newList list.map(...)或者用扩展运算符重新构造对象。还有一个常见原因是更新被包在错误的作用域里比如在微任务回调里更新了一个已经被卸载的组件引用的状态虽然大概率能改到但控制台已经警告了。遇到视图不刷新第一步不要怀疑框架先打开 React DevTools 看看对应组件 props 到底变没变变了就查渲染逻辑没变就查状态来源。4.2 页面狂闪或者卡顿重点检查这几个地方狂闪通常是因为 key 写得不稳定React 在列表渲染时没有找到稳定标识只能销毁重建 DOM。最典型的错误是用数组下标当 key特别是列表支持删除、排序、筛选的时候。下标一旦变动组件内部状态会串台输入框里的内容可能出现张冠李戴。解决方式是使用业务主键或者专门生成的唯一 ID 作为 key。卡顿则多半和渲染范围过大有关。一个组件更新把整棵页面子树都拖下水。排查套路是打开 Profiler录制一段交互观察火焰图里高亮的范围。如果发现某个表格组件每次键入都全量重渲染就给它包一层 memo并把表头列定义抽成稳定的常量避免每次渲染都新建数组导致 memo 失效。还有一类卡顿来自图片和大量 DOM 节点。中后台表格一页显示 50 条甚至 100 条数据很正常再加上每行里还有操作按钮和嵌套信息整体节点数会非常多。这种情况下优先考虑开启虚拟滚动让可视区域外元素不渲染这是最立竿见影的手段。4.3 竞态、卸载、泄漏三兄弟竞态问题是异步请求场景里的常客。典型现象是用户在搜索框里快速输入前面的请求比后面的慢结果后发出的先返回表格里最终展示的是旧关键字的结果。解决办法有很多种最简单的是在 effect 里用一个 flag 记录当前请求是否过期只有最新一次请求返回时才允许写入 state。useEffect(() { let isActive true; fetchList({ keyword: deferredKeyword }).then((data) { if (isActive) { setList(data.items); } }); return () { isActive false; }; }, [deferredKeyword]);卸载后的内存泄漏也经常和 setState 挂钩。组件已经卸载了但异步请求的回调还在飞回来之后调用 setState 虽然不影响功能但会造成告警严重时在单页应用里累积资源占用。上面这个 isActive 方案同时把这个问题解决了组件卸载时把 flag 置为 false回调里就不会再 setState。4.4 包体比想象中大怎么办中后台项目上线前打开构建产物报告看到一个 chunk 超过 1 MB 是很正常的事。这时候先别慌按顺序排查三件事是不是某个组件库被整个打包进去了、是不是有很多页面引用了同一个大依赖、是不是图表库没有按需加载。组件库方面现在主流方案都支持按需引入直接配置插件就行把用得到的组件单独打包。图表库体积普遍很大如果只在个别页面使用一定要通过动态 import 拆出去。还有一种容易被忽略的情况import 了一个工具函数结果它背后带着一整个模块依赖。用构建分析插件看一下依赖图找到那些体积异常的点再决定是找轻量替代方案还是分包处理。5. 写在最后的实操心得这套 REA 并不是什么天才设计它更像是把很多零散的工程经验拧成了一条线。真正让我觉得值得推广的是它对于“边界感”的坚持组件边界、状态边界、请求边界、环境边界都清清楚楚。边界清楚之后团队协作效率提升是水到渠成的事。如果你要从零开始搭一套类似的东西我个人的建议是先小范围试点不要一上来就全局迁移。挑一个正在开发的业务模块按照这套分层重写一遍跑通之后再逐步铺开。中途肯定会遇到“这个组件以前没有模板”“这个状态不知道放哪”的纠结每次纠结的时候回到那三个原则里去选答案基本不会跑偏。最后分享一个小细节REA 这个代号用了很久之后我们内部反而养成了拿它当口头禅的习惯比如“这个写法太 REA 了”代表结构清晰、边界清楚。一个项目能沉淀出方法还能让团队形成共同语言我觉得就算是做成功了。做技术的人都知道最难的不是写出能跑的代码而是让大多数人都能写出不会给别人添乱的代码。