资讯动态

React生态前端架构演进:领域建模、状态管理及Hooks实践

发布时间:2026/10/20 4:03:56 来源:尧图企业网站定制
1. 项目源起为什么我会拿rea当项目代号说实话rea这个代号你要是问我一万个做前端的人可能九千九百个都会愣一下。它不是read打错了也不是什么英文缩写的高深含义至少一开始不是。我在一次内部项目评审会上临时需要一个短代号来指代React Ecosystem Architecture——就是基于React生态的前端架构演进方案随手在便签上写了rea三个字母。结果这个代号就这么传开了后续文档、代码分支、变更单里全是rea想改都改不回来。项目本身解决的问题很实在团队里那个跑了三年的中后台系统页面越加越多代码越写越乱一个按钮的状态可能散落在五个组件里改一个需求牵一发动全身。线上版本每次发布都像拆炸弹。加上后来团队规模从三个人扩到十几个人新同学看代码像看天书能用就行的代价终于开始反噬。我们决定做一次彻底的架构梳理把散落的逻辑收拢起来把状态管理的模式固定下来。rea这个名字本身就是个让我记住这次经历的锚点一个随口起的代号最后承载了一次几十个文件的重构。这篇博文我就把整个过程中的核心思路、技术选型、踩坑实录都写下来包括那些在别处看不到的细节。适合谁看如果你正在维护一个规模不小的前端项目或者准备带着团队做一次架构升级再或者你是个想从前端业务代码里抬起头、理解架构到底是什么的新人这篇内容应该能帮你少走不少弯路。2. 整体设计与思路拆解不是重构是重新建模很多人一听架构演进就以为是要重写代码其实完全不是。我们最后改动的代码量虽然不小但真正花心思的地方在于重新回答一个问题一个业务功能从数据到页面到底应该经过哪些层次2.1 从页面思维切换到领域思维旧项目最大的问题是所有逻辑都以页面为组织单位。每个页面文件夹里放着组件、请求、类型、工具函数页面之间如果共享逻辑就复制粘贴一份或者抽个common文件然后common文件越滚越大谁也不敢删。我们做的第一个转变是引入领域目录domain的概念。比如订单是一个领域用户是一个领域权限是一个领域。页面的唯一职责是从领域层拿数据、调用领域层暴露的操作本身不关心数据从哪来、怎么缓存、怎么更新。领域层内部再分模型、服务、状态三个子层。这个设计借鉴了后端分层的思路但在前端落地需要做取舍。我们不能把后端的DDD领域驱动设计全套搬过来那套东西前端hold不住。我们只取了一个核心思想业务规则应该和展示逻辑分离。举个例子订单列表的筛选条件旧代码里可能分别在列表页、详情页、导出功能里各写一份字段还不一样。重构后筛选条件就是领域层的一个纯函数输入原始数据输出过滤后的结果所有页面调用同一个函数逻辑自然就一致了。2.2 状态管理的三权分立状态管理是前端架构躲不开的话题。我们试过把全局状态全部塞进单一store后来发现某个模块的state更新会触发不相关组件重渲染也试过完全不用状态管理库全靠组件props传递结果嵌套深了以后大家在props里传回调传得想骂人。最终落地的方案是三权分立服务端数据状态Server State交给专门的请求层管理客户端UI状态UI State留在组件内部跨组件共享的领域状态Domain State才进全局store。这个划分的好处是每个状态都有明确的管辖范围。请求层负责缓存、重试、请求撤销组件只管自己内部的开合、选中、滚动位置这些纯UI的东西全局store里只放那些真正被多个不相关组件共享的业务数据。分工清晰之后新同学看代码的时候看到一个useState就知道是UI临时状态看到一个领域store就知道这是跨页面共享的数据定位问题的速度能快一半。2.3 为什么最终的目录结构长这样重构后的目录结构我们是严格按依赖方向组织的src/ app/ # 应用初始化、路由、全局配置 domains/ # 领域层模型、服务、状态 order/ model.ts # 类型定义、纯函数 service.ts # API请求、缓存策略 store.ts # 领域状态的全局store components/ # 纯展示组件不依赖业务 pages/ # 页面组件编排领域能力 shared/ # 跨领域的通用工具依赖方向是pages依赖domainsdomains不依赖pagescomponents不依赖任何业务逻辑shared是最后兜底的地方谁也不准反过来依赖shared里的东西。这条规则我们用lint强制卡住违反就报错。目录只是一种物理表达真正重要的是依赖方向一旦依赖方向乱了目录再漂亮也是摆设。3. 核心细节解析与实操要点3.1 领域建模的两把尺子建模这件事听起来抽象做起来其实有两把很实用的尺子可以量。第一把尺子叫如果把这个功能从页面里拿走页面需要额外了解多少业务细节我们建模订单取消这个动作时旧代码去了五个页面找相关逻辑有的页面是先改状态再发请求有的是先发请求再本地更新还有的直接不处理。重构后的领域模型把取消订单定义成一个完整的状态机流转包含前置校验订单是否允许取消、请求发送、成功/失败处理、缓存更新。页面调用时只需要一行await cancelOrder(orderId)。如果页面调完以后还要自己补一堆业务判断说明模型设计得不够。第二把尺子是字段归属。同样一个订单状态字段在旧代码里可能同时存在于订单对象、订单列表项、订单流程日志等好几个地方而且命名还不一样有叫status的有叫state的有叫order_status的。我们做的第一件事就是统一命名并消灭重复字段。每个领域模型只在自己的边界内定义字段别的领域要使用通过关联关系获取绝不复制一份属性过来。3.2 服务层的缓存与失效策略服务层是整个架构里最容易被低估的部分。旧项目里的一个典型场景是从列表页进入详情页再返回列表页会重新请求接口因为大家不知道怎么安全地复用旧数据。重构后我们用请求层统一管理缓存核心参数是TTL缓存存活时间和失效策略。对于不同业务数据TTL取值差别很大订单状态、库存这类实时性要求高的TTL设置为0每次都走网络用户昵称、头像这类弱实时数据TTL可以放宽到5分钟甚至更长配置项这类几乎不变的数据走首次请求成功后长期缓存后台推送主动失效的策略。失效策略上我们定了两条硬规则。第一任何写操作成功后必须失效相关联的读缓存比如提交订单成功以后订单列表的缓存必须清掉。第二一个请求失败时如果当前有未过期的缓存优先展示缓存并静默重试而不是直接报错。这个策略上线以后弱网环境下的页面可用性明显提升用户几乎感知不到网络抖动。3.3 Hooks封装把复杂留给自己在React生态里做架构升级Hooks是我们的主要载体。重构前代码里的useEffect散落各处一个页面上动辄六七个useEffect互相之间还有依赖关系排查问题的时候根本理不清谁先谁后。重构后我们约定所有有副作用的行为统一收敛到领域服务层提供的自定义Hooks里。页面组件里要么调用这些Hooks要么就是纯渲染逻辑。比如订单列表这个页面功能归纳成三块订单列表查询、订单取消、订单导出。对应三个HooksuseOrderList、useCancelOrder、useExportOrders。开发一个页面的时候拿这几个Hooks拼装业务就行。封装Hooks有个必须注意的细节返回函数的引用必须稳定。我们用useCallback包住所有返回的函数依赖数组严格控制。这个细节当时没注意导致好几次子组件的memo优化完全不生效——父组件每次渲染子组件拿到的回调都是新引用触发不必要的重渲染性能问题藏得很深。封装Hooks有个必须注意的细节内部状态更新用useReducer而不是useState因为业务状态相互关联useState批量更新会带来渲染中间态而useReducer是原子更新的调试时还配合了时间旅行工具单步回溯状态变化的成本降了很多。4. 实操过程与核心环节实现4.1 第一阶段盘点与基线建立任何重构最怕一上来就动手。我们花了整整两周做存量盘点不写业务代码只做梳理。盘点的核心产出物是一张现状地图每个页面用了哪些API、哪些全局状态、哪些公共组件。我们用脚本扫描了所有页面的import语句把依赖关系自动画出来这里用的简单的依赖图工具不是那种复杂的架构分析平台够用就行。盘点结果让所有人都沉默了全项目共有87个API调用其中31个是重复的——同一个接口在多个页面各自封装了一遍全局状态有23个key但真正被跨页面使用的只有4个。有了账单才好规划。我们按照影响力排序把APIs按使用方数量×变更频率分成三类高频共用先治理、低频共用顺带治理、页面独用暂不处理。这个优先级决策决定了后续动作。项目改造是有成本的没有优先级就是在裸奔。4.2 第二阶段骨架搭建与单一页面试点骨架搭建走的是大爆炸保守的折中路线一次性把领域层的目录结构、请求层、状态管理工具链搭好但业务迁移选择一个业务最典型、但规模适中的模块先行试点。选择试点模块的标准一是业务复杂度足够覆盖大多数场景二是这个模块的开发成员对业务熟悉能很快反馈问题。我们最终选了订单模块涵盖了列表、详情、取消、导出、审批流等典型场景覆盖面足够。试点阶段我们做了一次影子模式验证。所谓影子模式就是新旧代码同时跑线上流量走旧代码新代码在测试环境接收相同的请求数据对比处理结果是否一致。这个模式当时花了一周搭建但效果极好——我们靠它发现了好几处新旧逻辑不一致的地方最典型的是订单取消时清不清空选择状态这个细节新旧实现行为不同如果没有影子对比这个差异上线后必然变成bug。4.3 第三阶段批量迁移与节奏把控试点跑通后进入批量迁移期。这时候最大的挑战不是技术而是节奏。我们定了一条硬性规则每个迭代两周最多迁移两个页面每个页面必须完成四件事才能算结束——功能自测通过、核心链路回归通过、测试用例补全、写出迁移说明。任何一条不完成宁可顺延不带病合并。这里要特别说明一下迁移说明这件事。我们要求每个页面的迁移说明里必须写清楚三件事改了什么、为什么这样改、有什么坑。这份文档不为什么评审就是给未来的自己和其他同事留的。后来有次排查性能问题靠的就是别人写的迁移说明里一句这个页面依赖了全局缓存的订单状态注意不要随便改缓存策略省了至少半天。迁移过程中最常遇到的阻力是需求穿插。业务方不会因为你在重构就停止提需求。我们的应对策略是迁移中的页面新需求一律走新架构实现未迁移的页面紧急需求照旧实现但代码合并前必须登记到欠账清单。等迁移到这个页面时清单里的欠账一并处理。4.4 关键代码请求层的缓存实现请求层是整个架构的基石这里放一段我们落地的缓存策略核心代码简化版interface CacheEntryT { data: T; timestamp: number; } class RequestCache { private cache new Mapstring, CacheEntryunknown(); private inflight new Mapstring, Promiseunknown(); async requestT(key: string, fetcher: () PromiseT, ttl: number): PromiseT { const entry this.cache.get(key); if (entry Date.now() - entry.timestamp ttl) { return entry.data as T; } // 防止同一个key并发请求打穿 if (this.inflight.has(key)) { return this.inflight.get(key) as PromiseT; } const promise fetcher() .then((data) { this.cache.set(key, { data, timestamp: Date.now() }); return data; }) .finally(() { this.inflight.delete(key); }); this.inflight.set(key, promise); return promise; } invalidate(key: string) { this.cache.delete(key); } }这段代码核心就两个点TTL判断和inflight去重。TTL判断保证了缓存不会无限期使用inflight去重则是防止用户快速切换页面时同一个接口被并发请求多次。这两个机制加上之前的写操作成功后失效缓存基本覆盖了日常开发中的所有场景。4.5 性能基线与对比数据架构升级说不看数据都是耍流氓。我们在改造前和改造后分别采集了一组性能基线同一台测试机、同一个页面、同样的一批模拟数据。改造前的数据订单列表页首次渲染需要1.8秒其中接口串行请求占了0.9秒一个页面居然发了7个串行接口因为一个接口的返回值是另一个接口的入参页面上的组件重渲染次数在交互过程中平均每秒14次。改造后的数据首次渲染降到0.9秒核心接口通过并行请求优化后耗时0.4秒组件重渲染次数降到每秒2次以内。JavaScript执行时长从改造前的3200毫秒降到800毫秒。这些数字不算惊艳但在老旧中后台项目里体感提升是明显的。还有一个容易被忽略的收益包体体积。重构前同样的功能代码分散在各页面构建后的bundle里去重后冗余代码占比23%。统一收拢到领域层后这部分冗余被编译器自动消除首屏CPU执行时间降低了近30%。5. 常见问题与排查技巧实录5.1 渲染死循环useEffect依赖数组的隐形炸弹在封装Hooks时我们曾经反复遇到渲染死循环。场景是这样的某个Hook需要在数据变化时触发一个副作用于是自然的写法是useEffect(() { // 做一些事情 }, [someData]);如果someData是从store里取出来的、每次都返回新引用的对象哪怕内容完全一样这个useEffect也会因为引用变化而反复执行。React不比较对象内容只比较引用地址。排查这类问题我们总结了一套组合拳打开浏览器的性能监视器看看CPU是不是飙到100%在useEffect入口打上日志打印依赖值变化前后的内容如果是对象引用的问题用useMemo对依赖对象做一层内容缓存只有内容成员变化时引用才变。这一条经验是血泪换来的。后来我们定了一个团队规范useEffect依赖数组里不允许直接放从store里取出的对象必须经过useMemo处理。5.2 状态提升失控全局store里出现了不该有的东西三权分立的原则虽然定了但落地的时候还是会有失控的时候。有同学为了图省事把一个页面的loading状态放到了全局store里理由是这样测试的时候方便断言。这个决定带来的后果是整个应用里所有组件每次dispatch都会触发这个loading状态的订阅者更新而订阅者又关联了一个大组件树导致一个无关操作的loading切换引发了全屏重渲染。排查过程很有意思。我们用性能面板看到了一个非常显著的重渲染大坑但不知道是哪一层引起的。做法是二分法注释组件树每次注释一半看性能是否恢复最终定位到了这个隐藏的全局loading。解决方式也很简单把loading状态收回到组件内部用useState管理。store里后来加了一条约定只有跨页面且需要持久化/共享的数据才允许进store页面内部UI状态一律禁止。这条约定还加了代码评审的checklist每次merge前专门检查。5.3 迁移时遇到的幽灵依赖批量迁移阶段遇到了一个特别诡异的问题某个页面在测试环境一切正常但上线后偶尔出现白屏。排查了很久最后发现问题出在一个看似无关的公共组件上。那个公共组件内部通过import引用了一个业务工具函数但这个工具函数在旧架构里是从某个页面文件的副作用里隐式初始化某段配置的。页面A先加载时初始化了配置页面B后加载时使用了配置。表面上看B的代码是完整的实际上隐式依赖A的加载顺序。我们把这种依赖叫做幽灵依赖——代码里没有任何import关系但运行时确实存在依赖。这类问题最难查因为它只会在特定加载顺序下触发。我们当时的排查方法把所有页面改为动态import后在浏览器里手动调整加载顺序复现最终定位问题后把这段隐式依赖改成了显式配置模块该模块在任何页面加载时都会自动初始化。5.4 测试测了个寂寞覆盖率好看不等于测试有效重构过程中我们补了一大堆测试但早期的测试几乎都是自嗨为了覆盖率而测。有一个组件测试写了60%的分支覆盖但核心的业务规则分支完全没测到——因为业务规则藏在组件内部的复杂if-else里测试只是简单渲染了默认状态。后来我们换了一个思路不测组件DOM形态重点测领域模型的纯函数。因为纯函数输入输出明确测试成本低且能真实反映业务规则。组件层只做少量冒烟测试确保关键交互能触发正确的领域方法调用。这个转变让测试的维护成本降低了很多。业务规则变更时改的永远是领域模型的测试组件样式调整时几乎不动测试。测试真正起到了保护业务逻辑的作用而不是为了报告上的数字好看。5.5 常见问题速查表问题现象根因排查方法解决方式页面卡顿CPU飙高useEffect依赖了每次渲染都重建的对象性能监视器依赖值日志useMemo稳定依赖引用某状态更新引发全屏重渲染不应该共享的状态被放入了全局store二分法注释组件树定位收回到组件内useState管理上线后偶发白屏幽灵依赖导致加载顺序问题调整动态import加载顺序复现抽出显式的初始化模块测试通过但线上行为异常业务规则在组件内部没被测试覆盖检查测试用例是否覆盖核心分支测试重心移到领域模型纯函数修改一个API的字段所有调用方都要改没有统一的数据适配层搜索所有调用方审计字段依赖服务层做字段映射页面不直接接触响应体6. 工具选型解析6.1 状态管理库的选型对比我们在选状态管理库的时候做过一轮认真对比没有无脑选社区里最火的方案。对比维度包括学习成本、调试能力、TypeScript支持、包体体积。方案学习成本调试能力TS支持包体体积结论原生useReducerContext低一般尚可0适合极简场景某主流状态库A中强有时间旅行调试好~20KB团队已熟悉最终选择某轻量状态库B低一般好~5KB适合小项目生态较弱最终我们选了团队里已经有使用经验的主流状态库A。选型不只是看技术优劣还要看团队熟悉度和生态成熟度。换一个新的轻量库虽然包体体积更小但培训成本、周边工具链都要重来在这个节点上不划算。6.2 请求层的轮子要不要自己造这个问题在团队内争论了很久。市场上有很多成熟的请求库比如某知名数据请求库功能很完善。但考虑到我们的缓存策略和三权分立模型有定制需求最终决定在现有请求库的插件机制之上做封装而不是直接引入重型解决方案。封装出来的是一个很薄的一层大概300行代码核心就是把请求库的能力和我们的缓存策略、错误处理策略集成在一起。这轮子自己造不难难点在于设计好接口——让业务调用方感知不到请求层的存在只知道从服务层拿到一个Promise成功就继续失败就走统一错误处理流程。6.3 静态检查与目录规则目录结构能长期保持不乱靠的不是自觉是检查工具。我们配置了两层检查第一层是eslint的自定义规则主要校验依赖方向比如禁止pages直接使用shared以外的底层模块禁止components里出现业务域名。第二层是在CI流水线里跑一个目录结构检查脚本如果发现新import违反了架构约定构建直接失败。这个机制的建立非常值。架构文档写得再清楚不如一条机器规则管用。后来定了一条规矩架构规则必须代码化不允许只存在PPT里。7. 经验复盘与扩展方向7.1 复盘做对了什么做错了什么如果重新来一次有几点我可以更早做。做得对的影子模式验证这个投入产出比极高领域模型优先于一切技术选型先想清楚建模再选工具架构规则代码化靠机器约束而不是靠人自觉。做得不够的试点的范围选得偏小导致后面在大规模迁移阶段暴露了一些骨架设计上的问题比如某些领域的边界划分和业务不完全匹配返工了一个模块。如果当时试点覆盖两个以上领域骨架会更稳固。还有一个我特别想分享的教训文档不是写给别人看的是写给未来的自己。迁移过程中我们产出的每一份说明后来都成了排查问题的线索库。尤其是那些这个模块的缓存策略为什么要这么定和这两处逻辑为什么要合并的记录价值极大。代码会重构人也会离职但记录里的决策逻辑能一直留在项目里。7.2 这个架构还能往哪个方向扩展现在的架构主要解决了中后台系统的组织和维护问题。如果后续要继续演进我在实际操作中看到的两个方向比较有价值。一是微前端的接入。我们现在按领域分层的目录结构天然兼容微前端的模块划分——每个领域就是独立子应用的候选者。如果业务规模继续扩大领域层可以平滑拆分为独立部署的微应用而页面层通过统一壳子集成。二是领域层复用能力的抽象。目前多个领域虽然落地了三权分立但请求层和缓存层的实现是一样的。未来可以把这些公共机制抽成更通用的底层能力包新领域接入成本更低。这些方向目前都还没有实际落地只是基于现有结构调整时的自然延伸。如果你也在走类似的路我的建议是先抓好手头的架构优化不要一上来就想微前端、分布式那套。地基没打好楼上盖啥都心慌。7.3 我个人的体会整个项目做完我最深的感受是所谓架构升级技术上的难度远没有组织和认知上的难度大。React生态、TypeScript、状态管理库、请求缓存这些东西任何一个中级开发都能在两周内学会。真正难的是让十几个人在同一套心智模型下写代码而且持续三个月不跑偏。靠什么靠的是把架构规范变成代码规则把决策过程记录成文档把迁移节奏管理成产品迭代。再有就是一定要有个试点跑通的过程让大家建立信心没有信心重构这种事情做到一半所有人都想逃跑。给准备动手做类似事情的朋友一个最务实的建议不要在项目最忙的时候启动重构也不要在项目最闲的时候启动重构——前者没时间后者没需求。最好的时机是业务刚好告一段落、但代码库已经因为需求堆积开始变痛的时候。那个窗口期通常不长看见它抓住它就够了。

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

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

免费获取报价 →
↑