先说个结论rea 不是某个开源框架也不是新出的工具链它是我这边内部一个前端工程基础项目的代号。这件事的起因很简单团队里同时维护着三个中后台系统页面结构越来越像但每个项目的代码却各写各的组件不统一、请求方式不统一、权限逻辑各搞一套谁接手新项目都得从零折腾一遍。后来我们把公共能力抽出来做成了一套以 rea 为代号的前端基础工程包含组件封装、请求层、权限模型、构建优化这些内容。这篇文章就用 rea 当引子从设计思路到实操代码再到线上踩过的坑完整记录一遍。适合正在准备搭建自己的前端工程体系、或者想梳理现有后台项目结构的开发者参考新手也可以直接照着抄一部分。1. 项目整体设计与思路拆解1.1 先搞清楚 rea 到底要解决什么问题很多团队搞基建容易一上来就写代码rea 最开始也是差点走这条路。后来停下来盘了一下真正要解决的问题其实只有三个。第一个是重复建设。三个后台项目里都有表格页、表单页、详情页每个项目的列表页组件都是自己写的查询条件、表格列、分页逻辑这些代码相似度接近七成但维护成本翻了三倍。只要涉及一个公共改动就要同时改三个项目漏改一个就是线上事故。第二个是行为不一致。同样是请求失败一个项目弹出错误提示另一个项目直接白屏还有一个项目在控制台打印完就没下文了。用户看到的行为不一样反馈的问题也五花八门排查成本特别高。第三个是新人上手慢。一个刚进组的开发光看三个项目的目录结构就要看一天每个项目的路由配置、权限校验方式都不一样想改个小需求都得先考古。搞清楚这三件事之后rea 的定位就清晰了它不是业务系统而是一层前端基础设施把登录态、权限、通用组件、请求逻辑这些横向能力下沉让业务项目只关注自己的页面和交互。这样一来新项目接入 rea十分钟就能跑起来一个带登录、带权限、带基础布局的空壳剩下的时间全部用来写业务。1.2 技术栈选型当时是怎么定的技术选型这件事没有绝对的对错关键看团队现状。先说结论rea 最终的技术栈是 React 18 TypeScript Vite Zustand React Router v6组件库在开源UI库基础上做二次封装。用 React 而不是 Vue主要是因为团队存量项目里 React 占比超过八成换赛道意味着所有人重新学习短期内看不到收益。TypeScript 没有悬念中后台系统涉及大量的接口数据结构、表单模型、权限模型类型不写清楚后期重构就是灾难。Vite 替代 Webpack 在开发体验上是代差级别的优势冷启动速度和热更新速度都不是一个量级。状态管理选了 Zustand 而不是 Redux。Redux 的模板代码太多reducer、action、dispatch 这一套对于中后台场景来说过于沉重。Zustand 写起来像直接操作一个全局变量又没有 MobX 那种魔法感TypeScript 支持也足够好。React Router 用 v6主要是因为嵌套路由和路由对象的表达方式更清晰后面做路由级权限拦截的时候代码量会少很多。组件库方面没有从零去造轮子而是选了一款成熟的开源组件库作为底座。自己写一个完整的设计系统投入太大中后台场景下的表格、表单、弹窗这些基础组件直接用现成的能省大量时间。rea 真正做的是在底座之上封装了一层贴合内部业务的通用组件这件事后面展开讲。提示技术选型最怕跟风。我在定 rea 之前也查了不少流行趋势但最终决定性的因素还是团队技术背景和现有代码的迁移成本。哪怕某个框架再热门团队没人能 hold 住上线时间就得往后拖没必要。1.3 项目目录结构设计避免“约定优于配置”的陷阱rea 的目录结构看起来不复杂但每一层都有讲究。贴一下核心结构rea/ ├── src/ │ ├── app/ # 全局配置路由、主题、入口 │ ├── components/ # 通用业务组件二次封装 │ ├── services/ # 接口请求层按业务域划分 │ ├── stores/ # 全局状态用户、布局、权限 │ ├── routes/ # 路由表和路由守卫 │ ├── hooks/ # 公共自定义 Hooks │ ├── utils/ # 纯工具函数 │ ├── types/ # 全局类型定义 │ └── pages/ # 业务页面 ├── scripts/ # 构建和发布脚本 ├── public/ # 静态资源 └── package.json这里要重点说的是components和services的边界。components只放被多个业务域复用的组件比如SearchTable查询表格、FormDialog表单弹窗、PageContainer页面容器某个页面单独用的拆料组件直接放在对应的pages目录里不需要全局可见。services按后端模块划分文件比如services/user.ts、services/order.ts每个文件导出一组请求函数页面不直接写fetch或者裸调axios。这种结构的核心思路是依赖方向单向流动页面只能依赖 services、stores、components这些底层模块之间互不依赖。如果发现components里的组件开始引services里的接口说明设计出了问题要停下来重构。很多项目之所以后期乱就是这种跨层引用积少成多造成的。2. 核心细节解析与实操要点2.1 组件封装不是简单包一层就完事rea 的组件设计原则是把重复的交互模式固化下来而不是把开源组件库换个文件名。最容易犯的错误是把一个开源表格组件包一层就对外输出结果只是减少了导入路径的长度实际没有任何收益。我们的SearchTable组件是这类封装里比较典型的一个。它要做的事情是把查询表单、数据请求、表格展示、分页这四个环节串成一个整体。使用者在业务页面里只需要写配置化的查询字段和列定义不用关心请求什么时候发、loading 怎么控制、分页参数怎么维护。大致的用法是这样const columns [ { title: 订单号, dataIndex: orderNo }, { title: 金额, dataIndex: amount }, ] const searchFields [ { name: orderNo, label: 订单号, type: input }, { name: status, label: 状态, type: select, options: statusOptions }, ] SearchTable columns{columns} searchFields{searchFields} request{fetchOrderList} /组件内部维护了查询条件、分页页码、排序字段这几个状态任何一项变化时自动重新请求loading 和错误重试也统一处理。业务方不需要再写useState管理表格数据、useEffect触发加载这些事情全部被收进组件内部。封装这个组件我们踩过的坑有两个。第一个是配置项的扩展性早期列定义直接透传给了底层表格组件一旦某个列需要自定义渲染函数类型就变得很难写。后来统一用ColumnType类型约束并让业务方可以通过 render 函数注入 React 节点问题才解决。第二个是请求依赖问题如果查询字段里的某一个选项依赖另一个字段的值组件就需要支持字段联动配置。这个一开始没做进去后面业务提需求才发现拆了一版重构才补上。心得判断一个组件是否需要被抽成公共组件标准不是“会不会被用两次”而是“它封装的是不是一条完整且可复用的交互链路”。如果只是封装了一个 API那和直接复制代码没有本质区别。2.2 请求层统一封装错误处理不再各写各的rea 的请求层是基于 axios 封装的一个实例核心解决三件事统一携带凭证、统一错误处理、避免重复请求。统一携带凭证这块比较常规在拦截器里从 store 里取 token 加到请求头后端返回 401 时触发重新登录流程。需要注意的一点是 token 刷新时的并发请求处理如果同时有三个请求都返回 401不能各自重新登录三次应该把刷新凭证的逻辑做成一个单例后面的请求等待刷新完成后再重放。统一错误处理这块最有价值。以前三个项目的错误提示风格完全不一样现在请求层捕获所有非 2xx 响应统一按错误码映射成用户能看懂的中文提示并且通过全局消息组件弹出。业务代码里基本见不到 try-catch 包裹接口调用的场景只在确实需要特殊处理的页面里单独接管错误。避免重复请求是容易被忽略的小点。用户连续快速点击同一个查询按钮如果不做处理同一个接口可能同时发出几次。rea 的处理方式是在请求拦截器里维护一个请求指纹集合以method url params作为 key如果同一个 key 的请求还没结束再次发起时直接丢弃并提示用户稍等。请求层的代码量不大但把所有业务都纳入了统一治理范围。前端项目的混乱往往就是从请求这种基础能力开始的这里值得下功夫。2.3 权限模型落地方案从路由到按钮都不放过中后台系统的权限通常是两层的页面级权限控制用户能访问哪些路由按钮级权限控制用户在某个页面能执行哪些操作。rea 的页面权限走的是动态路由方案。登录成功后后端返回当前用户的权限码列表前端拿到这个列表先过滤掉没有权限访问的路由再动态注册到路由表里。这样用户在地址栏里手动输入没有权限的 URL会直接落到 404 页面而不是弹出无权限提示再跳转。按钮级权限的实现方式是用一个包装组件。角色为管理员可以看到所有按钮普通用户只能看到自己权限码列表里包含的按钮。这个比路由权限容易实现但要注意权限码的命名规范前后端必须约定同一个格式比如order:create、order:export对不上就是权限漏洞。下面这段是最简版的权限组件实现function Permission({ code, children, fallback null }) { const { permissionCodes } useAuthStore() if (permissionCodes.includes(code)) return children return fallback } // 使用方式 Permission codeorder:export fallback{Button disabled导出/Button} Button typeprimary onClick{handleExport}导出/Button /Permission这个设计看起来简单真正掉坑的地方在权限码刷新。用户角色可能在系统里被管理员调整但前端 store 里还是旧权限码只有重新登录才会刷新体验很差。rea 的解决方式是在请求层捕获到权限变动的信号时静默调一次刷新权限接口并更新 store不需要用户感知。2.4 Store 的边界问题别把全局状态当成垃圾桶Zustand 很灵活但太灵活也容易乱用。什么数据都想放全局 store最后就是项目里满地都是全局状态调试的时候根本不知道哪个组件改了它。rea 给 store 定了几条规则。第一只放跨页面需要共享的数据比如用户信息、权限码、布局配置、全局消息。第二页面内部的临时数据一律放在组件内部状态里用 React Query 这类服务端状态管理或者组件内 state 解决。第三接口返回的数据默认不进 store直接以服务端响应为准避免出现份数据不一致的问题。实际开发中最常见的问题是跨页面传递数据比如从列表页点击一条记录跳到详情页有人喜欢把这条记录塞进全局 store。这个做法在刷新页面的时候就会丢数据因为 store 里的内容内存不持久一刷新就没了。rea 的做法是把必要的信息通过 URL 参数传递详情页自己重新拉取数据鲁棒性完全不一样。全局状态最怕的是不可控的修改。Zustand 本身不支持严格模式所以 rea 在 store 里约定所有的修改必须通过 action 完成而不是直接在组件里setState。这个约束靠代码规范强制配合 lint 规则检查基本能堵住大部分乱改 store 的行为。3. 实操过程与核心环节实现3.1 初始化项目一步步手写配置rea 的项目初始化没有用重型脚手架直接基于 Vite 模板手动补充配置。创建项目这一步很简单npm create vitelatest rea -- --template react-ts装依赖之后第一件事是配置路径别名。中后台项目的目录层级深到处都是../../../components这种引用后期改目录结构会变得非常痛苦。在vite.config.ts里加上别名指向src目录同时在tsconfig.json里同步配置paths两边不一致会导致 TypeScript 报错。编辑器规范和代码风格检查是初始化阶段就要做的事情。安装 ESLint 和 Prettier 之后建议开启lint-staged每次提交只检查本次改动的文件不然老项目一跑 lint 就是几千个错误根本改不动。Git 提交规范也在这个阶段配合 husky 装好commit message 统一用feat、fix、chore这类前缀后面自动生成 changelog 才会顺利。这一套配置下来一次性做完比后面补要轻松得多。很多项目刚开始不重视写了几万行代码再接入 ESLint等于自找麻烦。3.2 请求模块的完整实现可以直接抄这里贴一段 rea 请求层核心代码的精简版本省略了一些细节但整体思路完整import axios from axios import { useAuthStore } from /stores/auth const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, }) // 请求指纹集合避免重复提交 const pendingRequests new Map() function getRequestKey(config) { return [config.method, config.url, JSON.stringify(config.params)].join() } request.interceptors.request.use((config) { const authStore useAuthStore.getState() if (authStore.token) { config.headers.Authorization Bearer ${authStore.token} } const key getRequestKey(config) if (pendingRequests.has(key)) { // 同一个请求还没结束直接中断 return Promise.reject({ code: DUPLICATE_REQUEST, message: 操作过于频繁请稍候再试 }) } pendingRequests.set(key, true) return config }) request.interceptors.response.use( (response) { const key getRequestKey(response.config) pendingRequests.delete(key) return response.data }, (error) { const key getRequestKey(error.config || {}) pendingRequests.delete(key) // 统一错误提示 const message mapErrorMessage(error) if (message) { toast(message) } return Promise.reject(error) } )这段代码的关键点有两个。一个是pendingRequests的清理必须在响应拦截器里确保删除否则请求结束后 key 还在同参数请求永远发不出去。另一个是在请求被中断时返回一个错误对象业务方可以根据code判断是不是重复请求避免把这种主动中断的场景当成真正的接口失败上报。实际接入业务系统时baseURL建议用环境变量区分不同环境本地开发指到测试环境接口构建时通过环境配置切换不要写死在代码里。3.3 权限路由落地的完整流程路由权限这块rea 的实现流程分三步定义路由表、登录后拉取权限码、过滤并注册动态路由。第一步所有页面路由在一个集中配置里声明并通过meta.roles或meta.permission字段标注访问需要的权限码const routeConfig [ { path: /dashboard, component: DashboardPage, meta: { title: 工作台, icon: dashboard }, }, { path: /orders, component: OrderListPage, meta: { title: 订单管理, permission: order:view }, }, // ... ]第二步用户登录时把后端返回的权限码列表存进 store。第三步根据权限码过滤路由表生成可访问路由再通过 React Router v6 的useRoutes渲染。这里有一个比较隐蔽的问题动态路由在刷新页面时会失效。因为刷新后 store 被清空路由表又变成空的了用户一刷新就跑到 404。rea 的解法是路由守卫里面加一个初始化检查发现权限码不存在时先调接口拉取用户信息等数据回来之后再渲染路由组件。这个过程中用全局 loading 遮罩避免白屏闪烁。3.4 构建优化从 8 秒降到 3 秒的一次实战记录构建优化这块的收益是最直观的。rea 早期版本构建出来的产物有一个超过 1.2MB 的主包首屏加载慢得明显。第一次优化用打包分析工具看分布发现问题集中在三块组件库全量打进了主包、工具函数库被多个入口重复引用、业务代码没有按路由做代码分割。针对这三个问题方案依次是组件库改为按需导入公共依赖提取到单独 chunk路由组件全部改用懒加载。路由懒加载是成本最低收益最高的优化改写法就行const DashboardPage lazy(() import(/pages/dashboard)) const OrderListPage lazy(() import(/pages/order/list))配合Suspense包裹页面切换时按需加载对应模块。经过这三轮优化主包体积从 1.2MB 降到了 350KB 左右配合 Gzip 压缩后实际传输只有 100 多 KB首屏速度肉眼可见地提升了一个档位。注意路由懒加载虽然好用但页面太多的情况下模块拆太细反而会导致切换路由时出现长时间白屏。rea 的做法是把关系紧密的页面按业务域合并成几个 chunk而不是无脑一个页面一个文件。4. 常见问题与排查技巧实录4.1 排查首屏加载缓慢从 Network 到产物分析首屏慢的问题不一定都是代码体积造成的。曾经有一个版本首次进入页面时白屏时间很长Network 面板里接口请求并不多但是页面迟迟不渲染。最后发现是一个全局请求拦截器里做了同步的本地存储读取数据量大的时候阻塞了主线程。排查思路是先看 Network 里的关键请求耗时再看 Performance 面板的主线程任务。如果网络耗时正常但白屏时间长基本可以断定是脚本执行阻塞或者组件渲染太重这时候用 React DevTools 的 Profiler 分析哪个组件渲染耗时最离谱。实践中很多所谓的性能问题都不是框架的锅而是某个组件在地柜数据量大时写了个没必要的O(n²)计算。4.2 一个异步竞态导致的全局状态错乱这是个很有意思的 Bug。用户先从详情页 A 进了一个编辑弹窗弹窗里的数据是通过接口异步拉取的。然后用户快速切换到列表页打开了另一条记录 B 的编辑弹窗。理论上 A 的请求和 B 的请求是独立的不会互相影响但因为两处代码用的都是同一个 store 里的editingData字段A 的响应晚于 B 到达结果把 B 的编辑数据覆盖了。这类问题出现的原因是对全局状态的滥用。两个独立弹窗的状态不应该放在同一个 store 字段里。修复方式也简单把编辑数据从全局 store 移到弹窗组件内部的状态里每条记录对应一个独立的组件实例数据自然是隔离的。4.3 按需加载组件库后样式丢失项目引入组件库按需加载后本地跑着没问题一旦打包上线就发现部分页面样式混乱。排查了半天发现是构建时组件库样式的 tree-shaking 依赖插件配置一些在配置之外被直接引用的组件样式没有被打包进去。解决方案是开启自动导入插件的完整模式并且让 ESLint 检测直接引入组件库底层组件的代码强制只能使用按需加载版本。这类问题在开发环境很难发现一定要在构建流程里增加一次样式对比检查或者干脆直接在打包后跑一遍视觉回归。4.4 内存泄漏定位DevTools 堆快照对比法中后台页面长时间挂着内存持续上涨这是典型的全局事件监听没有移除导致的内存泄漏。定位方法不复杂用 Chrome DevTools 的 Memory 面板先打一个堆快照然后反复操作页面几次再打第二个快照对比两次快照里增长的对象找到意外持有引用的地方。rea 遇到的一次泄漏来自一个全局监听 resize 事件的函数注册时用了匿名函数导致移除监听时引用对不上cleanup 完全失效。这类问题的通用排查技巧是凡是addEventListener的对称操作removeEventListener必须使用同一函数引用尤其是组件卸载时的清理逻辑不能图省事把清理写在只能在特定条件下执行的代码里。写在最后rea 从最初的一个内部项目代号慢慢变成了一套完整的前端基础设施回过头去看大部分时间其实不是在写代码而是在做取舍和整理思路。技术方案没有标准答案适合团队现状的就是好方案这也是 rea 整个过程中我体会最深的一点先别急着动手把问题拆清楚把边界定明白后面的事情自然顺。如果你也在搭自己的前端工程体系可以先用它当参考跑通一个最小可用版本再根据业务反馈逐步调整远比一开始就追求大而全要靠谱。