ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React 工程化脚手架 rea:Vite、状态管理与性能优化实践

React 工程化脚手架 rea:Vite、状态管理与性能优化实践

老实说,前端项目开发到一定阶段,大部分时间并不是在写业务逻辑,而是在跟构建配置、状态管理、代码规范较劲。最近我把这些年在 React 项目里反复踩过的坑和沉淀下来的思路,整理成了一个内部脚手架项目,代号就叫 rea。rea 不是某个新框架,也不是一个开箱即用的 npm 包,它是我个人对 React 工程化、状态管理和性能优化的一套完整实践,刚好可以用一个短名字记住它。如果你正准备从零搭一个中后台系统,或者手头项目正在被 props 穿透、打包体积失控、二次渲染频繁这些问题困扰,这篇内容会适合你。

注意:文中提到的技术选型和目录结构,都基于我实际搭建 rea 时的经验,不是纸上谈兵。如果你用的是纯 H5 活动页或者小型展示站,里面很多方案可以按需裁剪;但如果你是做中后台、有权限、有列表、有状态共享的 Web 应用,这段实操过程应该能帮你少走不少弯路。

1. 项目概述与设计初衷

1.1 为什么叫 rea,核心需求定位

项目代号rea,简单的三个字母,其实是我平时写 React 时最常敲的前缀。我一开始没想把它做成一个正式的开源项目,只是每次新项目启动,都需要重复做同样的事情:初始化 Vite、配 ESLint、装路由、封装请求、设计状态管理、处理构建优化。这些事单独做都不难,但把它们拼在一起,并且保证每次都能稳定复现,就有很多细节要处理。所以我把这些实践整理成一个模板,起名 rea,方便自己使用,也方便团队内部复制。

rea 的核心定位很明确:面向中后台管理系统和企业级 React 应用,解决三个问题。第一,工程配置碎片化,每个项目都从零开始浪费时间;第二,状态管理随意化,没有统一规范导致组件通讯写着写着就乱套;第三,性能优化经验化,只在线上出问题后才临时去排查。把这三块提前做进基建层面,新项目一启动就是健康状态。

1.2 技术选型背后的取舍

很多人喜欢一上来就追求最新版本,但在 rea 里我更看中稳定组合。构建工具我选了 Vite,而不是继续用 Webpack,因为中后台项目的开发体验差距太大了。Vite 依赖 esbuild 做依赖预构建,冷启动基本在一秒左右,大型项目也不至于等几十秒才看到页面。Webpack 的优势在于生态成熟、配置灵活,但对于不用复杂插件体系的场景,Vite 够用且更轻快。

状态管理选了 Zustand,而不是大家更熟悉的 Redux Toolkit。并不是说 Redux Toolkit 不好,而是 rea 里大多数业务状态是局部共享,用 Redux 的一套模板会写很多样板代码。Zustand 的 API 足够简洁,store 之间可以相互调用,配合 TypeScript 支持也舒服。这种“少一点模式,多一点灵活”的思路,更适合中小团队快速交付。

路由、请求、代码规范这些基础件没有太多争议,React Router 6、Axios、ESLint、Prettier 都是经过大量验证的方案。我唯一特别想提醒的是,工具选型一定要围绕团队维护成本来定,而不是单纯追求某个库的 star 数。一个几个月没人维护的库,哪怕设计再优雅,也不适合放在核心链路里。

1.3 目录结构与模块划分

rea 的目录结构我从一开始就按“功能模块 + 技术分层”来组织,而不是简单按文件类型堆。src 下面分成api、components、hooks、layout、pages、router、store、types、utils这几个目录。pages 内部再根据业务模块分组,比如pages/system/user、pages/order/list,这样后续扩展功能,不需要修改全局结构,只在对应模块下新增文件就行。

rea/ ├─ public/ ├─ src/ │ ├─ api/ # 接口请求定义 │ ├─ components/ # 通用组件 │ ├─ hooks/ # 自定义 Hooks │ ├─ layout/ # 页面框架布局 │ ├─ pages/ # 业务页面 │ ├─ router/ # 路由配置 │ ├─ store/ # 全局状态 │ ├─ types/ # 全局类型定义 │ ├─ utils/ # 工具函数 │ └─ main.tsx ├─ .env.development ├─ .env.production ├─ vite.config.ts └─ package.json

这种结构的优点在于:多人协作时,每个人负责的模块边界清晰;代码 review 的时候,能看到每个目录的职责是否被遵守;当你要回滚某个功能时,也能快速定位到关联文件。很多项目后期难维护,不是代码本身写得差,而是目录混乱导致没人敢动历史模块。只要目录边界清楚,改代码的心理负担会小很多。

2. 工程化配置:从零搭建可维护的 React 环境

2.1 Vite 构建配置与开发代理

Vite 用起来简单,但想要在项目里稳定落地,几个关键配置必须提前处理好。第一个是路径别名,如果不用 alias,深层组件引入文件时会写一长串../../../../,现在统一通过@指向src,维护成本会低很多。第二个是开发服务器代理,中后台项目通常需要跨域调用接口,直接在server.proxy里把/api转发到目标地址,这样前端代码里只关心相对路径,上线时再让网关或反向代理处理同域穿透。

我给 rea 写的vite.config.ts大概长这样:

import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, server: { port: 5173, host: true, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (p) => p.replace(/^\/api/, '') } } }, build: { sourcemap: false, chunkSizeWarningLimit: 800, rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom', 'react-router-dom'], zustand: ['zustand'], axios: ['axios'] } } } } });

手动拆包是我强烈建议做的事。默认情况下,Vite 会按依赖关系自动分割,但有时候会把多个大包混在一个 chunk 里,导致首屏加载一个几 MB 的 JS。手动定义manualChunks,把 React 核心、状态库、请求库单独拆出来,配合强缓存,后续版本更新时,业务代码变了,依赖库还是能命中浏览器缓存,加载速度会有明显提升。

2.2 代码规范与提交拦截

工程化不只是能不能跑,更关键的是每个人写的代码风格能不能保持一致。rea 里用 ESLint 做代码检查、Prettier 做格式化、Husky 拦截提交、lint-staged 只检查暂存区。这里的核心逻辑是:把问题挡在提交之前,而不是等代码合并后再靠 review 发现。

我初始化时的命令大致是:

npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin npm install -D prettier eslint-config-prettier npm install -D husky lint-staged npx husky install

.husky/pre-commit文件里只需要一行核心命令:

npx lint-staged

然后package.json里配置:

{ "lint-staged": { "*.{ts,tsx}": ["eslint --fix", "prettier --write"], "*.{css,scss}": ["prettier --write"] } }

这样做的好处是速度快。项目大起来后,全量跑 ESLint 可能要一分钟,但 lint-staged 只检查改动的文件,基本秒级完成。我还在 ESLint 配置里关了几个过于主观的规则,比如react-hooks/exhaustive-deps保留,但允许必要的时候添加忽略注释;no-explicit-any保留,但接口类型确实没定义完时可以加局部禁用。规范是服务开发的,不是反过来。

2.3 环境变量与多环境管理

中后台项目基本都会涉及开发、测试、生产甚至灰度环境。每个环境的接口地址、静态资源前缀、埋点开关都不一样。rea 里我用了.env.development、.env.test、.env.production三份文件,变量统一以VITE_开头,这样 Vite 才会暴露给客户端代码。

# .env.development VITE_APP_TITLE=rea-dev VITE_API_BASE=/api # .env.production VITE_APP_TITLE=rea-prod VITE_API_BASE=/gateway/api

注意一个坑:不要把一个环境变量同时用于多个语义,比如既当接口前缀又当静态资源前缀。一旦上线后发现 CDN 路径拼错了,排查时容易混淆。我会把资源前缀单独用VITE_CDN_URL表示,接口前缀用VITE_API_BASE表示,职责一眼分明。

构建时执行vite build --mode production或vite build --mode test,Vite 会自动加载对应的 env 文件。这个机制本身不复杂,但我见过不少项目把所有环境变量写在一个.env文件里,然后手动切换注释,这么做很容易把测试环境配置推到生产。环境文件该分开就分开,别怕文件多。

3. 状态管理与数据流:告别混乱的 props drilling

3.1 状态管理方案对比:Zustand 为什么更适合 rea

状态管理选型我一直很谨慎。 React 自带的 useState 适合组件局部状态,useContext 适合跨层级共享少量状态,但一旦业务复杂起来,比如用户信息、权限列表、多个页面共享的筛选条件,再依赖 Context 会引发很多无意义的整体渲染。这时候必须上一个专门的状态管理库。

我把常见的方案放在一起做过对比:

方案包体积学习成本模板代码适合场景
Redux Toolkit较大高多大型团队、复杂中间件场景
Zustand很小低少中小团队、快速迭代场景
Jotai小低少原子化状态、模块嵌套深场景

Redux Toolkit 有强大的 DevTools 和时间旅行调试,但代价是每加一个数据域都要写 slice、reducer、action,项目一多容易审美疲劳。Jotai 的原子粒度很优雅,但在中后台这种“用户信息、字典数据、全局配置”场景里,原子分散在各处反而不方便统一管理。

Zustand 对我而言最顺手的地方是,它不强制你遵循某种模板,Store 就是一个 Hook,可以直接在组件外部使用,也可以在 Store 里调用另一个 Store 的方法。这样写出来的状态代码更像普通业务代码,团队接手时的理解成本低。

3.2 基于 Zustand 的 Store 组织

rea 里的 Store 不是把所有状态塞到一个大对象里,而是按业务域拆成多个 Store。比如useUserStore管用户信息和登录态,useDictStore管字典数据,useAppStore管侧边栏折叠、主题切换等 UI 状态。每个 Store 文件保持在 100 行以内,超过就说明粒度过大。

写一个典型的 Store:

import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { loginApi, getUserInfoApi } from '@/api/user'; import type { UserInfo } from '@/types/user'; interface UserState { token: string; userInfo: UserInfo | null; login: (params: { username: string; password: string }) => Promise<void>; fetchUserInfo: () => Promise<void>; logout: () => void; } export const useUserStore = create<UserState>()( persist( (set) => ({ token: '', userInfo: null, login: async (params) => { const res = await loginApi(params); set({ token: res.token }); await useUserStore.getState().fetchUserInfo(); }, fetchUserInfo: async () => { const info = await getUserInfoApi(); set({ userInfo: info }); }, logout: () => { set({ token: '', userInfo: null }); } }), { name: 'rea-user-store', partialize: (state) => ({ token: state.token }) } ) );

这里有个小技巧:persist中间件只持久化token,用户信息不持久化。原因是用户信息每次登录后都应该重新拉取,保证是最新的角色和权限;如果连 userInfo 都缓存到 localStorage,用户权限变更后可能需要很久才能生效,这是个安全隐患,需要格外注意。

3.3 接口请求层的封装

状态层再舒服,也离不开一个稳定的接口层。rea 的api目录下,每个模块一个文件,导出对应的请求函数。所有请求都走同一个 Axios 实例,统一处理基础路径、超时、token 注入、错误提示。

我封装 Axios 实例时比较注重这几个细节:

import axios from 'axios'; import { useUserStore } from '@/store/user'; import { message } from '@/components/message'; const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000 }); service.interceptors.request.use((config) => { const token = useUserStore.getState().token; if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } return config; }); service.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 0) { message.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res.data; }, (error) => { if (error.response?.status === 401) { useUserStore.getState().logout(); window.location.href = '/login'; } else { message.error(error.message || '网络异常'); } return Promise.reject(error); } );

请求层最关键的是“统一返回数据的格式”。很多后端接口设计不规范,有的返回code/message/data,有的直接返回成功对象,还有的 error 信息藏在 HTTP 状态里。如果不在 Axios 拦截器里统一,业务代码就会到处写判断逻辑。rea 的做法是,假定后端全部走{ code, message, data }结构,拦截器里把data直接解包返回,业务代码拿到的就是最干净的数据。

4. 路由权限与业务模块设计

4.1 路由表的静态与动态注册

React Router 6 和早期版本差别挺大,现在推荐用createBrowserRouter这类声明式路由对象。rea 的路由设计分成两块:基础路由和业务路由。登录页、404 页、错误页这些是基础路由,静态写死;业务路由全部走懒加载,并且按目录约定读取生成,避免每次新加页面都要手动注册。

路由配置看起来大致是:

import { createBrowserRouter, Navigate } from 'react-router-dom'; import BasicLayout from '@/layout/BasicLayout'; import Login from '@/pages/login'; import NoFound from '@/pages/404'; const routes = [ { path: '/login', element: <Login /> }, { path: '/', element: <BasicLayout />, children: [ { index: true, element: <Navigate to="/dashboard" replace /> }, { path: 'dashboard', lazy: () => import('@/pages/dashboard') }, { path: 'system/user', lazy: () => import('@/pages/system/user') } // 其他业务页面 ] }, { path: '*', element: <NoFound /> } ]; export default createBrowserRouter(routes);

lazy是 React Router 原生支持的路由级别懒加载,配合React.lazy其实也行。我用lazy函数返回一个包含Component的模块,这样分包效果更可控。每个业务页面独立成一个 chunk,首屏只加载基础布局和首页需要的代码。

4.2 权限控制的实现思路

中后台系统绕不开权限。权限分类很多,最常见的是“接口权限 + 菜单权限 + 按钮权限”。rea 里的思路是,登录成功后从后端拿一份权限数据,包含用户角色、可访问的菜单 code 列表、按钮权限标识,然后在前端做一个统一的权限判断组件。

菜单权限我用的是路由守卫 + 动态过滤:拿到权限列表后,筛选出当前用户可见的路由,再用 React Router 的useRoutes动态生成路由。这里要强调,前端路由权限只是为了用户体验,真正安全校验一定要靠后端接口,不能因为前端隐藏了菜单就以为安全了。

按钮权限我封装了一个AuthButton组件,接受code参数,如果没有权限就渲染null:

import { usePermission } from '@/hooks/usePermission'; interface AuthButtonProps { code: string; children: React.ReactNode; } export default function AuthButton({ code, children }: AuthButtonProps) { const { hasPermission } = usePermission(); if (!hasPermission(code)) return null; return <>{children}</>; }

这样业务页面里只需要这样写:

<AuthButton code="user:delete"> <Button danger>删除</Button> </AuthButton>

所有权限判断集中在一个 Hook 里,以后权限逻辑从后端接口换成前端常量池,只需要改hasPermission的实现,页面上不需要到处改。

4.3 页面级 loading 与业务错误处理

路由切换时会有一个 loading 过程。如果懒加载的页面比较大,网络又是慢速环境,用户会看到一段白屏,体验很差。rea 里的做法是给BasicLayout里的Outlet加一个 Suspense fallback,渲染一个带品牌色的骨架屏,而不是转圈圈。骨架屏的优点是它暗示用户“页面结构已经确定,正在填充数据”,比转圈圈更安抚人心。

业务错误处理则需要区分边界。接口异常已经在 Axios 拦截器处理了,但页面渲染时的异常不能依赖拦截器。我为整个业务页面包了一层ErrorBoundary,一旦组件渲染抛错,不会让整个系统白屏,而是显示一个带错误详情和刷新按钮的卡片。这里埋一个我自己的典型坑:ErrorBoundary只捕获生命周期和渲染阶段的错误,不能捕获事件处理函数里的错误,所以异步代码里仍然需要 try/catch。

5. 性能优化实录:从秒开到秒交互

5.1 构建层的拆包、压缩与 CDN 缓存策略

性能优化一定要从构建产物入手。第一步是上面提过的manualChunks,它能把核心库单独拆出来;第二步是压缩,Vite 在生产模式默认会用 esbuild 压缩 JS,但 CSS 压缩和 gzip 预压缩往往需要额外配置。我在 rea 里用vite-plugin-compression生成.gz文件,服务器开启 gzip 后,浏览器下载的包体积能再少 70% 左右。

拆包时要注意“公共 chunk 是否存在循环依赖”。我踩过一次坑:把axios单独拆出来后,由于axios内部又依赖某些工具函数,产生了循环依赖的警告。解决办法很简单,把相关工具库也一起塞进axioschunk。拆包不是越碎越好,碎得太细反而会产生大量 HTTP 请求,HTTP/2 下还好,HTTP/1.1 下就是个灾难。

一个比较适合中后台拆包配置是这样的:

manualChunks: { 'react-vendor': ['react', 'react-dom', 'react-router-dom'], 'lib-vendor': ['axios', 'dayjs', 'lodash-es'], 'ui-vendor': ['antd', '@ant-design/icons'] }

注意:UI 组件库单独拆包能避免业务代码改动导致 UI 库缓存失效,但这个 chunk 体积往往很大,建议配合路由级别的懒加载一起看效果。

5.2 渲染层避免无效更新

渲染优化是 React 项目里最容易被忽略也最容易出效果的环节。很多人只知道用React.memo,但不会判断哪里该用。rea 里我总结了一个排序原则:先从“数据源”下手,确保状态不入组件层级过深;再看“组件拆分”,让状态粒度尽量小;最后才用 memo 和 useCallback。

最常见的问题场景是:父组件持有筛选条件状态,导致整个列表组件每次输入都重新渲染。解决思路是把筛选条件抽成独立组件,并把列表包裹在React.memo里,只有当筛选条件最终提交时,才更新父组件状态。这样输入过程中,列表组件完全不会重渲染。

useCallback也不是随便用的。如果它作为 prop 传给已经 memo 的子组件,那它是有意义的;如果它只是用在父组件内部,那反而会增加内存开销。记住一个判断标准:useCallback和useMemo的意义不在于“省计算”,而在于“保持引用稳定”,只有配合子组件的 memo 才有实际效果,否则就是自嗨。

5.3 图片懒加载与大列表虚拟化

中后台页面经常有头像、预览图、富文本里的图片。如果图片不多还好,一多就会阻塞渲染。rea 里统一使用loading="lazy"配合 CSScontent-visibility: auto,让浏览器原生支持图片懒加载,不额外引库。注意一个细节:loading="lazy"对当前视口内的图片不会生效,所以业务上需要给图片容器设置一个最小高度,否则懒加载可能因为布局偏移而失效。

表格和下拉列表是大数据量的重灾区。当时有一个业务需要用表格展示几千条数据,不做处理时滚动流畅度明显下降。我换成虚拟滚动后,只渲染视口内能看到的 30 行,滚动体验立刻流畅。实现虚拟滚动不需要引重型库,一个自定义 Hook 配合绝对定位就能完成。核心思路是:根据滚动容器的总高度和每行高度算出渲染范围,然后只渲染这段时间内的行,并把容器高度撑起来,保证滚动条长度不变。

6. 常见问题与排查心得

6.1 问题排查速查表

我在搭建和迭代 rea 的过程中,收集了很多看起来相似、原因却完全不同的问题。把这些整理成速查表,很多问题一眼就能定位:

问题现象可能原因解决思路
修改代码后页面不更新Vite 缓存异常删除 node_modules/.vite 后重启 dev
打包后体积巨大依赖库重复打包检查 manualChunks 和依赖引用方式
刷新页面后 404路由模式与服务器配置不匹配服务器把未知路径全部回退到 index.html
状态被清空没有持久化或存储 key 冲突确认 zustand persist 的 name 是否唯一
图片首次显示缓慢懒加载失效或图片未压缩检查图片尺寸和 loading 属性
组件渲染两次StrictMode 双重调用开发环境属正常,检查副作用是否有清理函数

这张表不追求覆盖所有情况,但足以解决日常开发里 80% 的问题。遇到一张表解决不了的,再通过二分法删代码定位,通常也能快速缩小范围。

6.2 真实踩坑案例:三个最典型的错误

第一个坑是useCallback依赖漏掉导致接口重复请求。当时页面里有一个搜索函数依赖pageParams,我用useCallback包了一层,却忘了把pageParams加进依赖数组。结果就是点击搜索时,函数里读到的pageParams永远是初始值,导致列表一直请求第一页。这类问题在 React Hook 时代很典型,最好把相关依赖补全,并且不要随手关闭 eslint 的 exhaustive-deps 规则。

第二个坑是动态路由刷新 404。前端把路由配成path: '/system/:id'后,本地开发点击链接没问题,一刷新页面,静态资源服务器找不到对应路径,直接返回 404。原因是发布环境没有做强回退配置。如果发布在普通静态服务器上,需要把未知路径全部重写到index.html;如果用了网关,也要确保/system这类路由不是真实物理路径,而是前端 history 路由。

第三个坑是持久化状态版本冲突。我用了 zustand 的persist,早期数据结构和现在不一样,上线后老用户 localStorage 里还是旧数据,页面直接渲染异常。后来在 persist 里加了version和migrate函数,每次结构变化都主动升级。这个坑提醒我,任何持久化的数据结构都要考虑兼容和迁移,不能只想着写入方便。

6.3 一些长期坚持的代码习惯

做完了 rea 这个项目,我更确信工程化的价值是“把常识变成默认配置”。现在每次新建项目,我不再花时间想该装哪个版本、该不该配代理、该用哪种状态管理,因为所有答案都已经沉淀在 rea 里。哪怕未来技术栈升级,我也只需要在脚手架这个层面统一改,而不是让每个业务项目各自打补丁。

习惯上我有几点特别坚持: store 里的异步请求一定要加载状态,不能只存数据;接口层和业务层之间多一层类型定义,前端后端约定好数据结构,避免 any 泛滥;写组件时尽量用少量大组件加细分小组件的方式,让重渲染边界清晰。这些习惯听起来很基础,但我见过太多项目就是因为基础约定缺失,后期维护成本暴增。

rea 目前还在持续迭代,但我已经很满意它帮我节省的时间了。如果你也在维护类似的内部脚手架,不妨从你最近的项目里挑三个重复出现的问题,把它们固化成配置或工具,日积月累,效果会出乎意料。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进