去年年中团队接了一个内部项目代号就叫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 classNametask-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迟早会被大家忘掉但这些工程习惯会跟着团队走很远。