☰
前端工程化实战:rea中后台基础项目从设计到落地
2026/10/21 23:11:45 网站建设 项目流程

先说个结论: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 code="order:export" fallback={<Button disabled>导出</Button>}> <Button type="primary" 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 vite@latest 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 整个过程中我体会最深的一点:先别急着动手,把问题拆清楚,把边界定明白,后面的事情自然顺。如果你也在搭自己的前端工程体系,可以先用它当参考,跑通一个最小可用版本,再根据业务反馈逐步调整,远比一开始就追求大而全要靠谱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询