恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
React 工程化脚手架 rea:Vite、状态管理与性能优化实践
首页
资讯中心
/
React 工程化脚手架 rea:Vite、状态管理与性能优化实践
React 工程化脚手架 rea:Vite、状态管理与性能优化实践
发布时间:2026/10/18 8:51:43
老实说前端项目开发到一定阶段大部分时间并不是在写业务逻辑而是在跟构建配置、状态管理、代码规范较劲。最近我把这些年在 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_TITLErea-dev VITE_API_BASE/api # .env.production VITE_APP_TITLErea-prod VITE_API_BASE/gateway/api注意一个坑不要把一个环境变量同时用于多个语义比如既当接口前缀又当静态资源前缀。一旦上线后发现 CDN 路径拼错了排查时容易混淆。我会把资源前缀单独用VITE_CDN_URL表示接口前缀用VITE_API_BASE表示职责一眼分明。构建时执行vite build --mode production或vite build --mode testVite 会自动加载对应的 env 文件。这个机制本身不复杂但我见过不少项目把所有环境变量写在一个.env文件里然后手动切换注释这么做很容易把测试环境配置推到生产。环境文件该分开就分开别怕文件多。3. 状态管理与数据流告别混乱的 props drilling3.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 行以内超过就说明粒度过大。写一个典型的 Storeimport { 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 }) Promisevoid; fetchUserInfo: () Promisevoid; logout: () void; } export const useUserStore createUserState()( 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参数如果没有权限就渲染nullimport { 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 codeuser: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 里统一使用loadinglazy配合 CSScontent-visibility: auto让浏览器原生支持图片懒加载不额外引库。注意一个细节loadinglazy对当前视口内的图片不会生效所以业务上需要给图片容器设置一个最小高度否则懒加载可能因为布局偏移而失效。表格和下拉列表是大数据量的重灾区。当时有一个业务需要用表格展示几千条数据不做处理时滚动流畅度明显下降。我换成虚拟滚动后只渲染视口内能看到的 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 目前还在持续迭代但我已经很满意它帮我节省的时间了。如果你也在维护类似的内部脚手架不妨从你最近的项目里挑三个重复出现的问题把它们固化成配置或工具日积月累效果会出乎意料。