去年年中,团队接了一个内部项目,代号就叫"rea"。组里新来的同学看到这名字还愣了一下,其实就是 React 前端应用的缩写——项目要覆盖数据报表、任务分派、权限管理几大块,我们用 React 的缩写顺手起了个代号,简单好记,沟通方便。这个项目从技术选型到上线交付,前后折腾了两个月,踩过的坑和沉淀下来的经验相当典型,值得拿出来单独写一篇。
这篇文章适合三类人看:准备从零搭建 React 中后台项目的同学;已经能写页面但总感觉工程化"差口气"的开发者;以及单纯想知道真实项目里那些"文档里没写"的坑和应对方式的从业者。下面我按照实际推进的顺序来讲,从需求拆解到代码落地,再到上线前的体验打磨,全程都是可以直接复用的做法。
1. 需求拆解与整体设计思路
1.1 代号"rea"背后的真实诉求
先把这个项目到底要做什么说清楚。表面看,需求方只提了三个模块:数据报表、任务分派、账号管理。听起来不复杂,但往深聊就会发现压力集中在这几个点上:报表页面要支持多维度筛选和导出,任务分派涉及组织架构树和人员选择,账号管理要区分不同权限角色。这根本不是一个静态展示页,而是一个交互深度、状态流转、权限控制全都要有的中后台应用。
我拿到需求后,最关注的不是技术栈,而是三个问题。第一,项目生命周期有多长,这决定了工程化投入值不值得;第二,使用者的浏览器环境,决定了要不要做兼容;第三,后端接口的成熟度,决定了前端要不要提前做数据兜底。跟需求方逐一确认的结果是:项目至少维护两年以上,使用群体是公司内部员工,浏览器以 Chrome 为主,后端接口边开发边联调。这三个结论基本圈定了后续技术方案的大方向。
这里想提醒一句:很多同学拿到需求第一反应是"用哪个框架",其实顺序应该反过来。先搞清楚使用场景和条件约束,再定框架和工具,否则容易出现"技术选型看着很酷、用起来浑身难受"的局面。rea 项目最终选 React,不是因为它最花哨,而是因为生态和团队现有经验最匹配——组里没有一个人需要从零学起,这就是一笔很大的隐性成本节约。
1.2 技术选型:React 生态里的取舍
技术选型我做过简单对比。当时可选方案大概有三类:Vue 2/3、React 当前版本、用原生方式加轻量工具。团队本来就是 React 技术栈,而且项目里大量用到表格、动态表单、状态联动这类场景,Vue 不是不行,但代价是团队要切换心智模型。原生方案直接排除,因为中后台的复杂度决定了没有框架约束,后期必然失控。
在 React 内部还要继续做选择:用类组件还是函数组件,全局状态用自带方案还是引入第三方库,构建工具用官方脚手架还是自定义工程。我的建议是不要一步到位引入一堆东西。rea 项目最终的做法是:React 18 加函数组件和 Hooks,全局状态用 Context 加 useReducer,服务端数据统一走请求封装层,路由用 React Router v6,构建工具直接走成熟脚手架,然后按需自定义。
为什么这么组合?核心逻辑是"每一层只解决一个问题"。React 只负责视图渲染,路由只负责页面切换和数据加载时机,状态管理只负责跨页面共享的状态,请求层只负责数据获取和错误处理。哪一层出了问题,都能快速定位。很多项目乱掉,恰恰是因为所有逻辑都堆在组件里,页面一多谁也改不动。
1.3 目录结构与工程规范
目录结构这块,很多人不重视,觉得代码能跑就行。但 rea 项目上线后被要求连续新增了好几次功能,如果没有一开始的目录规划,改起来会非常痛苦。我用的是偏"按业务模块划分"的结构,而不是按文件类型划分:
src/ api/ # 接口定义与请求封装 assets/ # 静态资源 components/ # 公共组件 hooks/ # 自定义 Hooks layouts/ # 页面布局 pages/ # 业务页面模块 routers/ # 路由配置 store/ # 全局状态 utils/ # 工具函数按业务模块划分的意思是:每个页面目录下,可以有自己的子组件、自己的样式、自己的 Hooks,而不是把所有的按钮都堆到 components 里。公共组件只放真正被两个以上业务模块复用的东西,比如通用表格、弹窗、分页器。这样每个功能的代码都聚在一起,改动时不用跨目录到处找,要删除某个功能时也能整目录删干净。
工程规范上我定了三条硬性要求:代码必须走 lint,提交前必须过类型检查,每个新页面必须有一份简单的自测清单。不需要特别复杂的配置,哪怕只是基本规则,只要能强制执行,就能挡住大部分低级问题。这三条看着简单,rea 项目能压住两周的上线周期,靠的就是它们一直在运转。
2. 核心模块设计与关键实现
2.1 数据请求层的封装思路
前端项目里最容易出乱的地方就是接口请求。每个页面各自 fetch 一遍,错误处理不一致,loading 状态满天飞,联调时改一个公共请求头要追几十个文件。rea 项目的第一步,就是把请求层统一收口。
我基于原生 fetch 封装了一个基础请求函数,没有重复造轮子,也没有立刻引入重型请求库。封装的重点放在四个位置:统一的 baseURL 和超时控制、统一的请求头注入、统一的错误码处理、统一的返回结构处理。页面里只需要关注业务数据,其余细节全部由请求层兜住。
const request = async (url, options = {}) => { const token = getToken(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 10000); try { const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', Authorization: token ? `Bearer ${token}` : '', ...options.headers, }, signal: controller.signal, }); const data = await response.json(); if (data.code !== 0) { notifyError(data.message); return Promise.reject(data); } return data.data; } finally { clearTimeout(timer); } };这段代码很简单,却解决了我平时最头疼的几个问题:接口超时是有边界的,不会一直转圈;登录失效可以由统一逻辑拦截跳转;后端返回非零 code 时能全局弹出错误提示。到了项目后期,我们在基础层之上又扩展了文件上传和并发节流,都没有改动任何业务代码。统一封装的价值,在这个阶段体现得最明显。
2.2 状态管理:Context 与 useReducer 的组合
rea 项目的全局状态其实不多,主要三类:用户信息、权限角色、当前选中的组织节点。这类状态的特点是跨页面共享但更新频率低,完全不需要把 Redux 全家桶这种重型方案拉进来。我用 Context 加 useReducer 做了一个轻量全局仓库,代码量不大,但足够支撑整个项目跑完。
const AppStateContext = createContext(null); const initialState = { user: null, roles: [], orgNode: null, }; function appReducer(state, action) { switch (action.type) { case 'SET_USER': return { ...state, user: action.payload }; case 'SET_ORG_NODE': return { ...state, orgNode: action.payload }; default: return state; } } export function AppStateProvider({ children }) { const [state, dispatch] = useReducer(appReducer, initialState); return ( <AppStateContext.Provider value={{ state, dispatch }}> {children} </AppStateContext.Provider> ); }这里有一个很关键的取舍:不是所有接口数据都要放进全局状态。很多人习惯把请求结果直接 setState 到全局 store 里,结果状态越滚越大,页面重新进入时还容易拿到脏数据。我的原则是:只有多个页面都要读、而且需要保持一致性的数据才进全局;单页面的临时数据留在组件本地就够了。这个原则在后续维护阶段帮了大忙。
2.3 路由与权限控制
权限控制在很多项目里是后期才补的,补的时候非常痛苦。rea 项目从一开始就把权限和路由绑在了一起。思路是:后端登录后返回当前用户的角色列表,前端根据角色列表动态生成路由表,没有权限的页面从一开始就不进入路由配置。
const allRoutes = [ { path: '/report', element: <ReportPage />, roles: ['admin', 'operator'] }, { path: '/task', element: <TaskPage />, roles: ['admin'] }, { path: '/account', element: <AccountPage />, roles: ['admin'] }, ]; const accessibleRoutes = allRoutes.filter( (route) => route.roles.some((role) => currentRoles.includes(role)) );路由层面的权限拦截只是第一道门,按钮级别的权限我放在另一个自定义 Hook 里处理,页面需要时自己调用。至少这样不会出现"菜单里看不到入口,但直接输 URL 还能访问"的尴尬情况。另外,路由切换时加一个简单的加载占位,配合权限校验的异步请求,整体体验会顺畅很多。
3. 实操过程:从初始化到核心功能落地
3.1 环境准备与脚手架初始化
实操部分,我从零开始带大家走一遍。首先是环境版本:Node 18 以上,包管理器用 npm 或 pnpm 都行,我个人偏爱 pnpm,安装速度快、磁盘占用小。项目初始化直接使用社区维护度最高的脚手架,生成之后立刻跑一遍启动命令,确认基础流程是通的,然后再做定制改造。
pnpm create vite react-ts-app --template react-ts cd react-ts-app pnpm install pnpm dev为什么用 Vite 而不是更老牌的构建方案?两个原因:第一,开发服务器的启动速度是秒级的,冷启动不需要再等几十秒;第二,背后依赖的构建链路,在中后台项目里足够快且稳定。当然 Vite 也有配套的注意事项:生产构建走的是另外一套编译器链路,个别冷门的 node_modules 依赖可能需要配置 optimizeDeps 才能正常编译。这类问题我会在后面的问题排查部分详细展开。
3.2 开发环境配置:代码规范与 Mock
项目初始化之后的第一件事,不是写页面,而是把代码规范和接口 Mock 做好。我见过太多项目写到一半才开始补 lint 配置,结果历史债务根本改不完。所以从一开始就要把 ESLint、Prettier、TypeScript 的最小配置立起来,哪怕只是默认规则,只要让编辑器实时提示,就能避免大量低级错误。
接口 Mock 这块,我选了一个比较朴素的方案:在工程里起一个简单的本地服务,模拟后端返回的 JSON。没有引入很重的 Mock 平台,因为项目中期后端接口就出来了,Mock 数据只需要支撑最开始的页面联调。关键是 Mock 的接口路径要和后端预先约定保持一致,这样切换真实接口时只需要改一个环境变量,页面代码一行都不用动。
3.3 落地一个核心页面:筛选+列表+详情
这里以报表模块的"任务明细列表"页面为例,完整展示一个核心页面是如何从数据请求走到交互闭环的。这个页面的需求非常典型:顶部筛选区,中间表格区,行内操作有查看详情和导出,底部还要支持分页。
页面组件的结构我是这样拆的:筛选区提炼成一个独立子组件,因为筛选条件在列表页和导出弹窗里都要复用;表格区用一层通用表格组件包住,列配置通过 props 传入;详情用抽屉而不是新页面承载,因为用户希望在不离开列表上下文的情况下连续查看多条记录。
function TaskListPage() { const [query, setQuery] = useState(defaultQuery); const [tableData, setTableData] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const fetchList = useCallback(async (params = query) => { setLoading(true); try { const res = await fetchTaskList(params); setTableData(res.rows); setTotal(res.total); } finally { setLoading(false); } }, [query]); useEffect(() => { fetchList(query); }, [fetchList, query]); return ( <div className="task-list-page"> <FilterPanel onSearch={(values) => { setQuery({ ...query, ...values, page: 1 }); }} /> <CommonTable columns={taskColumns} dataSource={tableData} loading={loading} pagination={{ current: query.page, pageSize: query.pageSize, total, onChange: (page, pageSize) => setQuery({ ...query, page, pageSize }), }} onViewDetail={(row) => openDrawer(row.id)} /> <TaskDetailDrawer open={drawerVisible} taskId={currentTaskId} onClose={() => setDrawerVisible(false)} /> </div> ); }这个页面的核心逻辑不复杂,但有几个细节值得单独强调。筛选条件变化时必须把页码重置为 1,否则会出现"当前停在第五页,一筛选却只剩两页数据"的怪象;详情抽屉通常还要继续调接口拿详情数据,所以要同时处理加载态和错误态;导出操作需要把当前筛选条件一起传过去,否则导出的数据和页面上看到的内容对不上,这种问题最容易在验收时被当场抓包。
4. 性能优化与体验打磨
4.1 渲染性能:三个 API 的正确用法
中后台页面的数据量一大,渲染性能问题就会暴露。常见现象是:输入框打字掉帧、表格翻页有延迟、浏览器内存持续上涨。rea 项目里,我重点盯了几个优化点。
useMemo 用在计算成本较高的派生数据上,典型场景是列表过滤和排序;useCallback 用在需要稳定函数引用的地方,避免子组件因为父组件重复渲染而跟着重渲染;React.memo 用来包裹那些 props 没变但重渲染成本高的子组件。但这里必须强调一个反直觉的结论:不要到处套用这些 API。每个缓存都有内存代价,滥用 memo 反而会让组件状态更难维护。我的经验是,先用性能分析工具确认真实渲染热点,再针对性动手优化,而不是盲目铺一层缓存。
4.2 打包体积优化
打包体积是上线前的必修课。rea 项目初期打完包超过 2MB,首屏加载速度确实不太好看。优化手段按优先级走了一遍:先用路由懒加载把页面拆成小块,让首屏只加载必要代码;再检查第三方依赖,把一些工具库改成按需引入;最后把公共依赖抽离出来,利用浏览器缓存降低重复访问的加载成本。
# 构建前先看体积分布 pnpm build pnpm exec vite preview其实九成的体积问题都出在依赖上。一个图表库就可能占掉整个包的近三分之一,如果只是为了画几个基础图表,完全可以考虑自己写轻量组件,或者使用按需注册的方式引入。另外,压缩代码时也要关注 gzip 之后的大小,很多图片资源的压缩空间比代码本身还大,上线前别忘了统一压一遍。
4.3 首屏与交互体验
性能不光是体积问题,体验同样关键。路由懒加载之后,首屏会出现短暂白屏期,我为此加了一个全局加载占位;按钮点击后如果触发异步请求,按钮要进入 loading 状态防止重复提交;表格加载新数据时保留上一页的数据,避免每次刷新都闪一下空屏。这些细节并不复杂,但用户感知非常明显。
有意思的是,这些体验优化后来被产品同事专门提出来表扬过。可见性能优化不能止步于开发者工具里的漂亮数字,最终要落在用户的真实感受上。
5. 常见问题与排查实录
5.1 典型报错与解决方案速查
项目开发过程中,我整理过一份排查速查表,这里挑几条出现频率最高、最有代表性的分享出来。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 依赖安装后启动即报错 | Node 版本不匹配 | 统一用版本管理工具切到项目指定版本 |
| 页面白屏但控制台无报错 | 路由路径配置错误 | 检查路由 path 与目录结构对应关系 |
| 接口请求被重复发送 | useEffect 依赖数组不完整 | 补全依赖或使用 ref 稳定函数引用 |
| 构建时提示内存溢出 | 构建进程内存不足 | 设置 NODE_OPTIONS 加大内存上限 |
| 表格刷新后勾选状态丢失 | 勾选状态未提升到合适层级 | 把选中态放到列表父组件或全局管理 |
| 中文内容乱码 | HTML 未声明字符编码 | 在入口 HTML 中补全 charset 声明 |
表格里的每一行,都是 rea 项目里真实撞过的。尤其是 useEffect 依赖数组这个坑,几乎是每个新人的必经之路。我的排查手法是:先加日志确认函数是否被频繁重建,再检查依赖里有没有对象或数组字面量导致的引用变化,定位之后改写方式就清晰了。
5.2 状态不同步与"脏数据"问题
典型场景是这样的:账号管理员在列表页改了某个用户的角色权限,然后跳进详情页确认,再返回列表页发现列表还是旧数据。根源在于列表页的状态在重新挂载时没有被重置,或者接口数据被错误地缓存在了全局 store 中。
我的处理方案是:列表页的数据只放在本地状态,不做全局缓存;页面卸载时清空筛选条件和分页状态;对于确实需要保留现场的场景,再考虑显式加缓存,并同时提供一个醒目的刷新按钮。很多时候"数据不见了"并不是真的丢失,而是缓存逻辑和显示逻辑在打架。这个场景下的最优解往往是最简单的方案,不要提前设计复杂的缓存策略。
5.3 样式与兼容性细坑
中后台项目一般不太担心浏览器兼容性,但有几个细坑值得留意。表格列固定时需要处理好阴影层级,不然滚动时会看到奇怪的叠影;弹窗组件在低分辨率屏幕上要能正常滚动,否则按钮被挤出视口,用户点不到"确定";表单校验的触发时机最好放在提交阶段而不是输入阶段,否则用户刚打第一个字就满屏报错,体验很差。
另外,因为项目里大量用到组织架构树组件,我还处理过一个展开节点后滚动位置异常的问题。排查思路并不复杂:展开时记录当前节点高度,渲染完成后用滚动定位方法把目标节点带回视野。这类问题之所以费时间,是因为你不确定问题出在组件内部还是外部布局,这时候写一个最小复现用例,往往比盯着代码找原因高效得多。
6. 写在最后:项目代号不重要,工程习惯才是壁垒
rea 这个项目从启动到上线大约两个月,代码量不算大,但让我感触最深的不是某个具体技术点,而是工程习惯的价值。统一请求层、按业务模块划分的目录结构、路由与权限绑定、列表页状态不进全局 store——这些做法都没有太高门槛,难的是从第一天开始坚持做下去,而不是等项目写一半再回头补。
最后分享一个小技巧。每次项目启动时,我会把"地基没打好,就不要急着写业务"当成团队规则。先花半天时间把目录结构、请求层、路由骨架这些底层内容定好,后面每个人写页面时只需要关心自己的业务逻辑,整体效率会高出非常多。
另外,有新同学加入时,我会先让他把项目 README 补充完整,包括启动方式、环境变量说明、目录结构说明、常见问题链接。表面上这是顺手整理文档,实际上是把散落在各人脑子里的经验沉淀进代码库的最佳方式。项目代号"rea"迟早会被大家忘掉,但这些工程习惯会跟着团队走很远。