很多前端项目写着写着就会变得特别拧巴,组件之间互相传参像抛接力棒,状态散落在各个文件里,改一个全局需求得翻遍整个代码库。我这次复盘的项目代号叫 rea,取自 React Ecosystem Architecture,是我基于某个前端框架生态做的一套工程化架构实践。这个项目要解决的核心问题其实很朴素:当业务模块多到一定程度时,代码结构、状态流动、接口管理、渲染性能这些事不能继续靠自觉维护,而是需要一套稳定、可复制、能落地的组织方式。
rea 不是什么高深的理论框架,它更像是一套我在真实业务里反复打磨出来的“前端工程公约”。它适合正在做中后台系统、复杂表单、多角色权限页面的人参考,也适合团队协作时希望减少沟通成本的前端小组。你不需要有架构师级别的经验,跟着这篇文章把核心思路拆开,就能在自己的项目里逐步引入。
1. 为什么会有 rea:这个项目到底想解决什么问题
1.1 三个让人头疼的痛点
先说一个我踩过无数次的坑。早期做一个数据管理后台时,我的页面组件里塞满了接口请求、数据转换、弹窗逻辑、表格列配置,整个文件一千多行。那时候能跑,但完全不能维护:加一个筛选项要顺藤摸瓜改五六个地方,团队成员之间互相看不懂对方的代码,新人上手两周还在迷路。
这类问题的根源基本可以归结为三点。第一,组件职责边界模糊,同一个组件既负责数据拉取,又负责渲染逻辑,还顺手改了全局状态。第二,状态管理缺设计,所有数据一股脑塞进全局 store,导致页面耦合度极高,任何状态变更都可能引发大面积的渲染震荡。第三,接口层没有收敛,同样的数据请求在三四个组件里各写一遍,参数格式、错误处理逻辑、加载状态的表达完全不一致。
1.2 rea 的定位与设计目标
rea 的目标不是发明一套全新的东西,而是把前端工程里已经被反复验证过的优秀实践组合起来,形成一套可以照着做的模板。它强调三点:单向数据流、分层清晰、约定优先于配置。
在这套架构里,数据流动的路径永远是单向的:接口层拿到原始数据,经过业务逻辑处理后放入全局 store,组件通过选择器读取自己关心的片段,再通过事件触发新的数据请求。这样一来,数据来源可追溯,状态变更可预测,页面渲染的可控性大幅提高。
rea 同时也界定了每类文件的职责边界。页面组件只负责组装业务模块,不直接发请求;通用组件只关注 UI 表现,不感知业务状态;接口层统一封装请求入口,业务层处理数据转换,状态层保存跨组件共享数据。这个约定一旦被团队接受,代码风格会自然收敛,代码评审的压力也会小很多。
2. 技术选型的取舍:为什么是这一套组合
2.1 基础技术栈对比
在选型这件事上,我没有追求最时髦的方案,而是以稳定性和团队可维护性作为第一优先级。下面是 rea 最终选用的技术组合,以及我考虑过的替代方案。
| 模块 | rea 采用方案 | 备选方案 | 取舍理由 |
|---|---|---|---|
| 基础框架 | 某主流组件化框架 | 服务端渲染框架、轻量模板框架 | 生态成熟,组件模型适合中后台复杂交互,周边工具链齐全 |
| 状态管理 | Redux Toolkit | 轻量状态库、组件内置状态 | 全局状态较多时仍是最可控的方案,中间件生态完善 |
| 路由方案 | 路由库 v6 | v5 或传统配置式路由 | 嵌套路由、懒加载、数据路由都更顺手 |
| 请求方案 | 自封装 fetch 工厂 + hooks | axios 全量封装 | 减少依赖,方便统一拦截与取消,结构透明 |
| 构建工具 | Vite | Webpack | 冷启动和热更新体感提升明显,插件生态对现代框架支持好 |
这套组合的核心逻辑是:把强约束的部分交给 Redux Toolkit 这类有明确模式的库,把灵活的部分用自定义 hooks 收敛。这样团队里每个人写出来的数据请求逻辑都长得很像,排查问题时能很快上手。
2.2 设计原则:单向数据流与分层约定
rea 里有一条贯穿始终的原则:业务数据只允许在两层之间流动。接口层负责和服务器通信,得到结果之后提交给状态层;状态层负责保存数据,并通过选择器对外暴露;组件层只允许通过 hooks 读取状态和触发动作。
我把目录结构设计成按照业务域聚合,而不是按照文件类型堆叠。每个业务域下面包含自己的状态切片、接口定义、组件和 hooks,只有真正被多个业务域共享的东西才提升到公共目录。这样做的好处很直接:当你要删掉一个业务模块时,直接删除对应目录就行,不会留下隐藏的碎片依赖。
src/ app/ # 应用初始化、全局 store、路由配置 features/ # 业务域模块,按业务聚合 user/ components/ hooks/ api.ts userSlice.ts order/ components/ hooks/ api.ts orderSlice.ts components/ # 跨域通用组件 hooks/ # 通用 hooks utils/ # 工具函数这个结构在项目初期看起来有点“重”,但一旦业务规模上来,它的优势会特别明显。每个业务域的改动被限制在自己的目录里,不同团队负责不同目录时几乎不会发生冲突,而且代码审查时可以按目录逐层检查,效率高很多。
3. 核心机制:rea 的分层与可控状态流
3.1 基于 reducer 的状态管理
让全局状态“可预测”的关键,是严格控制状态的修改入口。我在 rea 里统一使用 Redux Toolkit 创建状态切片,每个业务域都遵循同一个模式:定义初始状态、定义异步动作、定义状态变更逻辑。
// src/features/user/userSlice.ts import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; export const fetchUserProfile = createAsyncThunk( 'user/fetchProfile', async (userId: string, { rejectWithValue }) => { const res = await api.get(`/users/${userId}`); return res.data; } ); const userSlice = createSlice({ name: 'user', initialState: { profile: null, status: 'idle', error: null, }, reducers: {}, extraReducers: (builder) => { builder .addCase(fetchUserProfile.pending, (state) => { state.status = 'loading'; }) .addCase(fetchUserProfile.fulfilled, (state, action) => { state.status = 'succeeded'; state.profile = action.payload; }) .addCase(fetchUserProfile.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload; }); }, }); export default userSlice.reducer;这种写法的好处是,所有状态变更都被压缩到一个明确的 layer 里,不存在组件里随手state.xxx = yyy的散弹式修改。异步流程的 pending、succeeded、failed 三个阶段被完整建模,任何调用方都能拿到当前请求状态来判断展示逻辑。
3.2 用自定义 hooks 封装数据请求
光有状态层还不够,如果每个组件里都手动写useDispatch加useEffect,很快就会产生大量重复代码。我在 rea 里把数据请求封装成统一的自定义 hooks,让业务组件只需要关心数据本身。
// src/features/user/hooks/useUserProfile.ts import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { fetchUserProfile } from '../userSlice'; import type { AppDispatch, RootState } from '@/app/store'; export function useUserProfile(userId: string) { const dispatch = useDispatch<AppDispatch>(); const profile = useSelector((state: RootState) => state.user.profile); const status = useSelector((state: RootState) => state.user.status); useEffect(() => { dispatch(fetchUserProfile(userId)); }, [userId, dispatch]); return { profile, status }; }组件里使用时就变得非常干净:
function UserProfileCard({ userId }: { userId: string }) { const { profile, status } = useUserProfile(userId); if (status === 'loading') return <Skeleton />; if (status === 'failed') return <ErrorHint />; return <ProfileDetail data={profile} />; }这个封装模式可以直接类比成“把水龙头接好管道”,组件是打开水龙头的人,它不需要关心自来水是从哪个水厂来的,也不关心中间经过了多少根管子,它只管取到水。这种做法让业务代码的表达力大幅提升,读起来像在描述“要什么”,而不是“怎么做”。
3.3 组件分层:页面组件、业务组件、通用组件
rea 对组件做了三个层次的划分,每一层都有严格的依赖方向。页面组件位于最顶层,编排业务布局,掌管路由参数和页面级状态;业务组件处于中间层,和某个业务域绑定,感知业务数据但保持 UI 无关;通用组件在最底层,只接收 props,完全不知道业务的存在。
这种分层的直接收益是测试和复用变得容易。通用组件可以独立维护,样式和交互在任何页面里表现一致;业务组件被限制在自己的业务域内,不会出现一个列表组件同时被三个业务域牵扯的情况。我在实践中发现,只要组件层级的命名和目录保持一致,大部分“这个功能放哪”的问题都可以靠直觉回答。
4. 实操全流程:从空目录到一个可用系统
4.1 初始化与基础配置
这一步我以最常用的初始化方式为例。先创建一个带 TypeScript 模板的项目,然后安装 rea 依赖的核心库。
npm create vite@latest rea-demo -- --template react-ts cd rea-demo npm install npm install @reduxjs/toolkit react-redux react-router-dom安装完成后,第一步不是急着写业务代码,而是先把基础和规范的东西搭建好。创建src/app/store.ts,配置全局 store;创建src/app/router.tsx,配置路由表;创建src/main.tsx,挂载 Provider 和 Router。这个阶段的核心目标是把项目的“骨架”立起来,让后续每个业务域都能按同样的方式接入。
4.2 落地一个业务板块:用户中心
很多教程会一口气把十几个模块全部展开讲,但这不符合实际的迭代节奏。rea 的落地方式是先做透一个业务域,把它当作指导书和标尺,后续新模块照着同样的模式复制。我这里用用户中心举例。
用户中心的业务需求通常是:展示用户基本信息、支持修改资料、显示操作日志。按照 rea 的分层约定,我先在features/user/下建立api.ts、userSlice.ts、components/、hooks/。在api.ts里封装接口方法,在userSlice.ts里定义异步动作,在hooks/里封装数据读取逻辑,最后在页面上组装。
把第一个业务域做完整的意义,不只是一个功能上线了,而是团队终于有了一个“参照物”。后面的人写新模块时不用靠猜,直接看用户中心怎么分文件、怎么处理加载态、怎么定义状态,照着来就行。
4.3 接入状态管理与接口层
接入 Redux Toolkit 时,所有业务域的状态切片都要注册到根 store 里。这里要注意的是,根 store 的职责只负责注册和导出类型,不应该包含任何业务逻辑。
// src/app/store.ts import { configureStore } from '@reduxjs/toolkit'; import userReducer from '@/features/user/userSlice'; import orderReducer from '@/features/order/orderSlice'; export const store = configureStore({ reducer: { user: userReducer, order: orderReducer, }, }); export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;接口层我用了一个简单的 fetch 工厂,统一携带凭证、统一处理超时和错误码。这个工厂提供的请求方法会被业务域的api.ts调用,业务组件永远不直接接触fetch或者请求库。
// src/utils/request.ts export async function request<T>(url: string, options: RequestInit = {}): Promise<T> { const response = await fetch(url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...options.headers, }, ...options, }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new ApiError(error.message || 'Request failed', response.status); } return response.json() as Promise<T>; }这里有一个很值得注意的细节:错误处理必须在接口层统一完成。如果每个业务域各自写 try/catch,错误码的语义会被反复覆盖,排查问题时到处贴日志也找不出根因。统一处理之后,无论哪个模块报错,错误对象的结构都是确定的,监控报警也能直接接入。
4.4 构建、检查与提测
rea 的工程配置里,构建检查和自动化测试是硬性环节。我习惯在每次提交前跑一遍 TypeScript 类型检查、代码规范检查和单测,而不是依赖个人自觉。
{ "scripts": { "dev": "vite", "build": "tsc && vite build", "lint": "eslint src --ext .ts,.tsx", "test": "vitest run" } }很多项目到了提测阶段才发现类型炸了一堆,或者某个模块引入了一个不存在的导出路径,全组人陪着一起修。rea 把这些问题前置到编码阶段,等真正走提测流程时,基础质量已经比较稳固了。这一步看着不起眼,但对团队效率的提升非常明显。
5. 渲染性能优化:rea 里怎么避免卡顿
5.1 选型时的性能考量
渲染性能问题在中后台项目里通常不是单纯靠某个优化技巧解决的,而是从一开始的选型和数据流设计里就埋下伏笔。Redux Toolkit 的依赖收集机制让我可以选择“精确订阅”,也就是组件只关心它真正用到的状态片段。
在 rea 里,这个原则被落实成一条编码约束:useSelector 必须选择最小粒度,禁止一个组件一次性订阅整个 store 对象。我见过很多人直接在组件里写const state = useSelector((s) => s),然后随机取出几十个字段用。这等于告诉框架“我什么数据都关心”,任何一次状态变更都会渲染整个组件树,性能自然就崩了。
5.2 避免状态泄漏与过度订阅
过度订阅是最隐蔽的性能杀手。视觉上没有任何问题,但随着 store 里的数据越来越多,每次 dispatch 引发的渲染范围也在悄悄扩大。我用一个简单的方法来验证组件是否订阅过宽:打开浏览器性能面板,点击一个常规操作,然后观察触发重新渲染的组件数量,数量如果超过预期,八成是订阅粒度出了问题。
// 错误示范:订阅了整个 store const app = useSelector((state) => state); // 正确做法:只选择当前组件需要的数据 const userName = useSelector((state) => state.user.profile?.name); const userAvatar = useSelector((state) => state.user.profile?.avatar);这两个写法在数据量小的时候表现似乎一样,但项目一旦膨胀,性能差距会成倍拉大。排查时用 React DevTools 的 Profiler 看渲染火焰图,能非常直观地看到哪些子组件被“殃及池鱼”。
5.3 记忆化与合并渲染
对纯展示型组件,我会用记忆化包装来避免父组件渲染时连带子组件一起渲染。但这里有个容易走弯的地方:记忆化 API 本身有开销,它适合接受 props 且依赖稳定的组件,不适合频繁接收新对象的组件。如果给一个 props 每次都是新引用的组件套上记忆化,等于白白增加一层比较逻辑。
另一个常用的优化是合并多个 dispatch 调用。在用户提交表单时,我倾向于先处理所有校验和错误提示的本地状态,最后再一次性触发真正提交数据的 dispatch。这样不仅减少全局渲染次数,也能避免中间态导致的界面抖动。
6. 踩坑实录:开发中常见的十个问题与排查
6.1 接口数据未到达时页面闪崩
这个问题非常典型:进入页面后,数据还没回来,组件就尝试读取profile.name,直接抛异常。rea 的解决方式是在业务组件里先判断状态,再决定渲染内容。加载中、失败、成功三个状态必须覆盖完整,不允许“裸奔”访问数据字段。
有些开发者会在状态还没就绪时就渲染组件,然后把空数据检查丢给后端的每个字段,这是把风险从自己手里放走。我的建议是,只要涉及异步请求,页面渲染就必须有状态分支,宁可多写一个 Skeleton 也不要去赌数据一定及时返回。
6.2 登录态刷新后丢失
放在内存里的状态一旦刷新页面就会清空,这是不少人踩过的坑。rea 的做法是把认证信息和用户基本信息同时做持久化,用一个轻量级中间件把需要保留的状态写到本地存储,应用启动时再重新填充到 store 里。
这里有一个容易被忽略的边界:写入本地存储的内容不要包含敏感完整信息,只保留必要的凭证和会话标识。不要图省事把整个 store 序列化到本地,那会造成非常大的安全面和体积浪费。
6.3 定时器与事件监听造成的内存泄漏
在组件里开启定时器或全局事件监听,如果不做清理,用户来回切换路由几次后,内存占用会肉眼可见地飙升。rea 的规则是:凡是添加监听或定时器的副作用,必须在清理函数里移除。
useEffect(() => { const timer = setInterval(refreshToken, 30000); window.addEventListener('resize', handleResize); return () => { clearInterval(timer); window.removeEventListener('resize', handleResize); }; }, []);在代码评审时,我如果看到addEventListener或setInterval后面没有对应的清理逻辑,基本都会打回。这不是洁癖,而是真实项目里最容易累积的技术债。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向与解法 |
|---|---|---|
| 页面不停重复请求接口 | useEffect 依赖数组写错 | 检查依赖中是否包含每次都变化的引用或函数 |
| 修改 store 后视图不更新 | 直接修改了嵌套对象未返回新引用 | 使用不可变更新写法,避免手动赋值嵌套属性 |
| 组件渲染次数异常多 | 选择器返回了新的内联对象 | 改用基础值多次订阅或用记忆化选择器 |
| 路由切换后页面残留旧数据 | store 中数据未重置 | 在路由出口组件卸载时清理对应状态切片 |
| 表单中输入卡顿明显 | 每次按键都触发全局 dispatch | 把受控输入提升为局部状态,提交时再写入 store |
| 生产构建体积过大 | 未拆分路由级代码 | 使用路由懒加载,把第三方库按需引入或预加载 |
| 报错信息堆栈不具可读性 | 接口层未统一错误转换 | 在统一请求工厂中规范错误对象结构 |
| 严格模式下接口被调用两次 | 开发环境严格模式的双调用 | 确认是预期行为,用请求取消区分用户真实动作 |
| 某些老模块构建错误 | 依赖版本锁不一致 | 统一用锁文件安装并加入 CI 检查 |
| 调整一个样式影响了全局 | 通用组件样式未加作用域 | 通用组件使用带前缀的样式命名,避免全局污染 |
这张表是我把夜里有同事来问问题时的高频场景慢慢攒出来的。很多问题看着吓人,排查链路走一遍就会发现,根源都在数据流设计或副作用管理上。
7. 一些体会和扩展方向
这套 rea 架构目前已经在我维护的几个实际项目中跑过两轮完整迭代,整体感受是:前期搭建确实需要多花一点时间,但中后期收益相当可观,尤其是需求频繁变更时,每个业务域的边界足够清晰,改动范围可以被压缩到很小的范围,回归测试也轻松很多。
我最后想分享的小技巧是:架构永远不是一蹴而就的,不要试图第一天就把所有规则定死。我的做法是先做一个业务域作为样板,让团队成员都跑一遍,把觉得别扭的地方记录下来,再开一次小会调整约定。架构是给团队用的,不是挂在文档里供着的,它需要跟着真实业务一起生长。如果你现在正被一堆代码结构问题困扰,不妨试着从一个小模块开始,按 rea 的分层数据流思路重构一遍,感受一下“数据流向清晰”带来的改变。