
1. rea这个代号背后的React实践背景与目标rea最早是我给一个内部后台项目起的代号当时没想太多就是从React里截了三个字母顺手又带一点重新出发的意思。等到项目真正跑起来这个代号反而越来越贴切——因为整个开发过程中我一直在做的事就是重新审视那些看起来理所当然的工程决策。如果你也正在用React做中后台系统或复杂的业务前端大概率会踩到类似的坑脚手架选了之后改配置费劲状态管理越来越重首屏加载慢得没法交代组件写着写着就变成一个两千行的大文件。这篇文章不是讲React基础语法而是把我从零搭建rea项目过程中踩过的坑、做过的选型对比、优化手段和上线前的工程化收尾完整梳理一遍涉及的方案都是我在真实项目中验证过的。读完你应该能直接照着调整自己的项目。项目本身的业务并不复杂一套带权限管理的运营后台包含数据报表、表单流程、列表查询、多角色权限这些典型模块。但因为要长期迭代所以代码质量和可维护性的优先级从一开始就被抬得很高。先说结论这个项目最终的技术栈是Vite React TypeScript React Query Zustand按业务模块组织目录构建层面做了路由级代码分割和依赖瘦身。下面我把每个环节的决策过程、替代方案的对比、以及具体实施细节逐一展开。2. 工程基座搭建从脚手架选型到目录结构设计2.1 为什么没有选Create React App而是Vite很多React项目都是从Create React App以下简称CRA起步的坦率说CRA在最早期确实降低了上手门槛但随着项目变大它的几个固有短板会越来越明显配置是黑盒想自定义webpack规则必须eject开发服务器的热更新在模块多起来之后明显变慢依赖版本更新节奏也经常滞后。rea项目本身是中后台业务页面多、依赖杂如果继续用CRA我预感到后面每次改构建配置都要对着配置文件折腾很久。所以我在技术选型阶段直接把Vite作为主力方案。Vite的核心优势在于开发环境利用浏览器原生的ESModule能力按需编译模块越多热更新速度的优势越明显。它内置的插件体系也比较干净配置文件高度可读要改什么规则一目了然。这里有一个实操细节值得一提Vite 4之后的版本对React支持已经非常成熟创建项目只需要一条命令模板自带React插件和HMR配置。npm create vitelatest rea -- --template react-ts创建完成之后我会立刻做两件事。第一把base配置成项目的相对路径或CDN前缀避免部署到子路径时资源404第二检查vite.config.ts里的server.host和server.port方便局域网联调。我在实际项目中遇到过一个问题Vite默认对Node版本有要求如果本机Node版本过旧启动时会直接报错。建议全团队统一Node版本用.nvmrc文件锁住版本号。2.2 目录结构按业务模块划分比按文件类型划分更省心rea的目录结构经历了一次比较大的调整。最早我采用的是按文件类型划分的方式也就是所有组件丢到components里所有页面丢到views里所有请求丢到api里。项目刚开始看着挺整洁等到业务模块增加到十几个之后问题就出来了改一个用户管理功能得同时打开views下的用户列表页、components下的用户表格、api下的用户接口跨目录跳来跳去心智负担很重。后来我改成了按业务模块划分的结构这也是很多成熟中后台项目采用的方案src/ app/ # 应用级配置路由、入口、全局样式 shared/ # 跨模块复用的基础能力UI组件、工具函数、类型定义 features/ # 业务模块按功能域拆分 users/ # 用户管理模块 api.ts types.ts components/ hooks/ index.ts orders/ # 订单管理模块 reports/ # 报表模块 ...这个结构的核心逻辑是让一个业务模块的全部代码内聚在一起改需求的时候你只需要进入对应的模块目录。shared里只放真正会被多个模块复用的内容比如统一的按钮风格、日期工具函数。app层负责路由和全局状态初始化不掺业务逻辑。一开始可能会觉得把api和components都塞进features里有点乱但实际用下来你会发现查找代码的速度快了非常多。我给自己定了一个约束凡是只在某个业务模块内使用的东西一律放在该模块的features目录下只有当第二个模块也开始用同一个组件或函数时才把它提升到shared。2.3 基础配置中容易被忽略的细节基础配置阶段有三个细节我觉得特别值得提醒都是踩过坑之后才补上的。第一个是TypeScript的路径别名。用Vite创建的项目默认不支持/components这样的写法需要同时修改tsconfig.json和vite.config.ts。// tsconfig.json { compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }// vite.config.ts import { fileURLToPath, URL } from node:url export default defineConfig({ resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } } })这里有个很容易踩的坑只改vite.config.ts而忘记改tsconfig.json结果就是Vite能跑但TypeScript类型检查直接飘红。反过来也一样。两个文件必须同步修改。第二个是环境变量的类型声明。Vite会把.env文件里的变量挂到import.meta.env上但默认返回的是any类型这会导致代码里到处写as string之类的强制断言。我习惯在项目里维护一个src/vite-env.d.ts手动声明所有环境变量的类型这样变量名写错的时候编译器能第一时间报错。第三个是ESLint和Prettier的配合。Vite模板默认只带ESLint不带Prettier。如果团队对代码风格有统一要求建议在项目初始化阶段就把Prettier接进去否则后面再补的话格式化已有代码会产生大量的diff噪音。提示开发依赖里加上prettier之后在package.json的scripts中配置format: prettier --write .提交代码前跑一遍能省去很多review阶段关于格式的争论。3. 状态管理选型数据架构如何影响整个项目的开发体验3.1 服务端状态React Query搞定请求缓存与失效rea项目里我做的第一个重要决策是引入React Query来处理所有服务器端数据。为什么要把服务端状态单独拎出来因为服务端状态和客户端状态本质上是两种完全不同的东西。服务端状态是异步的、远端的、会被多人同时修改的客户端状态是同步的、本地的、只和用户当前交互相关的。如果用同一套状态管理工具去管这两种数据就会出现一大堆手动维护的loading、error、data变量以及A页面改了数据B页面不知道要刷新这种典型的缓存同步问题。React Query现在叫TanStack Query把请求、缓存、重试、失效、分页、无限加载这些逻辑全部内置了。我在项目里用得最多的三个API是useQuery、useMutation和queryClient。import { useQuery } from tanstack/react-query function useUsers(params: UserQueryParams) { return useQuery({ queryKey: [users, params], queryFn: () fetchUsers(params), staleTime: 30_000 }) }queryKey是缓存的核心当params变化时React Query会把它当成一个新的缓存项来处理。staleTime设为30秒意味着用户在30秒内切换条件查询同样的数据时会直接走缓存不重新发请求体验上明显更快。更新操作的逻辑同样简洁const mutation useMutation({ mutationFn: updateUser, onSuccess: () { queryClient.invalidateQueries({ queryKey: [users] }) } })invalidateQueries是React Query的灵魂。它解决了中后台项目最常见的改完数据之后列表不刷新问题只要某个用户更新成功所有挂在[users]这个key下的查询都会自动标记为失效下次重新获取。另外有一个细节如果接口返回的数据会频繁被多个模块使用可以把它们放进queryClient.setQueryData既避免重复请求又能让跨模块的数据保持一致。3.2 客户端全局状态Zustand只留真正需要全局的部分处理完服务端状态之后客户端全局状态需要管的东西就少多了。rea项目里真正需要放进全局store的只有当前登录用户信息、侧边栏折叠状态、以及一些全局性的UI主题设置。我当时对比了三个方案Redux Toolkit、Zustand和React Context。方案样板代码量学习曲线性能表现推荐场景Redux Toolkit中等偏多陡中需配selector优化大型团队、有严格规范约束Zustand极少平缓好细粒度订阅中小型项目、个人/小团队React Context最少平缓一般更新会穿透低频全局状态、主题、语言最终选了Zustand。它几乎不用写模板代码一个store定义加上一行hook调用就能用import { create } from zustand interface UserStore { user: UserInfo | null setUser: (user: UserInfo | null) void reset: () void } export const useUserStore createUserStore((set) ({ user: null, setUser: (user) set({ user }), reset: () set({ user: null }) }))组件里使用的时候一个重要的性能细节是只选择需要的字段避免组件对整个store产生无意义的重新渲染。const userName useUserStore((state) state.user?.name)有人可能会问React Context不是也能做吗Context在rea项目里我只用它传递那些几乎不变的配置信息比如主题色和当前语言。因为Context的值每次变化都会让所有消费该Context的组件重新渲染如果用来放频繁变化的数据性能隐患很大。设计原则可以归纳为一句话能放在组件本地的状态就不要放进全局store能放在服务端状态层的状态就不要放进客户端store。全局store里的东西越少项目的心智负担就越低。3.3 本地UI状态useState和useReducer的使用边界本地UI状态指的是只影响单个组件或少量子组件的状态比如弹窗开关、表单输入值、下拉选中项。rea项目对这类状态的处理原则是就近声明不跨层级传递。一个复杂的筛选表单是典型的本地状态场景。多个筛选条件、多个输入项、还有重置操作如果用一堆useState硬写代码会非常啰嗦。这时候更适合用useReducer把用户修改了某个字段和用户重置所有字段当成明确的行为看待type FilterState { keyword: string status: string dateRange: [string, string] | null } type FilterAction | { type: setField; field: keyof FilterState; value: string | number | null } | { type: reset } function filterReducer(state: FilterState, action: FilterAction): FilterState { switch (action.type) { case setField: return { ...state, [action.field]: action.value } case reset: return { keyword: , status: , dateRange: null } default: return state } }这个写法有两个好处一是把状态更新逻辑集中到了一个纯函数里方便单元测试二是当表单字段增多时不需要新增成堆的setState函数。顺便说一个我实际使用中总结的判断标准如果一个状态只有自己的当前值这一个维度用useState如果状态的更新逻辑涉及多个字段、多种触发方式或者更新规则比较复杂用useReducer如果这个状态要被好几个兄弟组件共享先考虑提升到最近的公共父组件而不是直接扔进全局store。4. 性能优化从首屏到包体积的逐层排查4.1 首屏慢接口串行与组件加载顺序rea项目上线前做过一轮性能排查发现首屏加载时间一直卡在2秒以上。用浏览器网络面板一看问题很明显多个接口在瀑布图上是串行的前一个接口没返回后一个接口绝不会发起。串行的原因往往不是后端的问题而是前端代码里用了连续的await// 错误的写法串行等待 const userInfo await fetchUserInfo() const permissionList await fetchPermissionList() const configData await fetchConfigData()上面这三行代码如果三个接口互不依赖改成Promise.all就能让它们并发请求总耗时取最大值而不是累加和const [userInfo, permissionList, configData] await Promise.all([ fetchUserInfo(), fetchPermissionList(), fetchConfigData() ])这个改动本身很简单但它的意义在于你会在排查过程中发现很多类似的问题。比如有的页面把不依赖首屏渲染的组件也放进主流程里加载导致首屏JS体积和接口请求都被拉长。一个更深入的优化思路是组件级别的数据预取路由切换前就把数据请求发出去等用户进入页面时数据已经在缓存里了。实际上把Vite的路由懒加载和React Query的预取能力结合起来可以用一个很小的成本获得接近原生App的切换体验。4.2 长列表渲染卡顿虚拟滚动和memo的正确打开方式rea项目里有一个消息记录页面单次可能渲染上千条列表项。最初的实现是最朴素的Array.map页面滚动时能明显感受到掉帧。原因其实不难理解上千个DOM节点同时存在每次滚动都会触发浏览器重排重绘再加上列表项里还有图片和图标卡顿就不可避免。解决长列表渲染问题最有效的手段是虚拟滚动只渲染可视区域内的列表项随着滚动动态替换。当时我没有引入重型虚拟滚动库而是用了一个轻量方案——固定行高的虚拟列表。实现思路是这样的外层容器高度固定开启overflow: auto用一个高度为列表项高度 * 总条数的占位元素撑起滚动条监听滚动事件计算当前可视区域的起始索引和结束索引只渲染起始索引到结束索引之间的列表项并把它们绝对定位到正确的位置。function VirtualList({ items, itemHeight, containerHeight }: VirtualListProps) { const [scrollTop, setScrollTop] useState(0) const visibleCount Math.ceil(containerHeight / itemHeight) const startIndex Math.floor(scrollTop / itemHeight) const endIndex Math.min(startIndex visibleCount, items.length) const visibleItems items.slice(startIndex, endIndex) return ( div style{{ height: containerHeight, overflow: auto }} onScroll{(e) setScrollTop(e.currentTarget.scrollTop)} div style{{ height: items.length * itemHeight, position: relative }} {visibleItems.map((item, index) { const realIndex startIndex index return ( div key{item.id} style{{ position: absolute, top: realIndex * itemHeight, left: 0, right: 0, height: itemHeight }} {item.content} /div ) })} /div /div ) }这个实现代码不过几十行但滚动性能直接从掉帧变成了丝滑。如果你的列表项高度不固定就需要在渲染后动态测量并维护一个高度数组复杂度会高一些。对大多数中后台场景来说固定行高的虚拟列表已经够用。还有一个经常被过度使用的手段是React.memo和useMemo。我的经验是不要无脑给所有组件包memo。React的浅比较本身有开销只有在组件渲染成本确实高、且父组件更新频繁的时候memo才有明显收益。rea项目里我只对列表行组件和大型图表组件使用了memo。4.3 构建包体积分析从moment到lodash的依赖清理首屏优化到一定程度之后构建体积就成了下一个瓶颈。我用Vite的rollup-plugin-visualizer生成了打包分析报告结果一目了然——moment.js占了非常大的体积lodash全量引入也贡献了不少。moment这个库在海外时代确实好用但它的体积和不可优化程度让它在现代构建体系里越来越尴尬。我做的替换是把moment换成dayjsAPI几乎兼容体积却从300多KB降到不到10KB。npm uninstall moment npm install dayjs然后做全局替换把import moment from moment改成import dayjs from dayjs。需要说明一点dayjs是可配置插件机制默认不支持dayjs().format(YYYY-MM-DD HH:mm:ss)带小时格式的功能实际上它是支持的只是部分高级功能需要引入插件。常见的做法是在入口文件统一导入需要的插件import dayjs from dayjs import relativeTime from dayjs/plugin/relativeTime import dayjs/locale/zh-cn dayjs.extend(relativeTime) dayjs.locale(zh-cn)loddash的问题则出在全量引入上。改造成按需引入的方式非常直接import { debounce } from lodash-eslodash-es模块化更好配合tree shaking打包时只保留实际用到的函数。另外一个容易被忽略的依赖清理方向是检查node_modules里是否残留了多个版本的重复库比如lodash和lodash-es同时存在这在依赖树里是真实出现过的场景。做完依赖瘦身之后rea项目的产物缩小了将近一半。再配合路由级代码分割首屏只加载当前路由用到的JS文件。Vite对路由级懒加载的支持非常干净const UserManagement lazy(() import(/features/users/pages/UserManagement))最后在部署层面给静态资源开启gzip或brotli压缩这一步能再省20%到40%的传输体积。Nginx配置里开启gzip后实测首屏资源的传输时间明显下降。5. 组件设计与代码组织从能跑到可维护5.1 容器组件与展示组件拆分rea项目在开发中期遇到一个典型问题单个页面组件越来越厚既管数据请求又管筛选逻辑还管表格渲染。改动任何一个字段的值都要在大组件里翻很久才能找到对应的JSX片段。后来我采用容器组件与展示组件分离的思路来治理代码。容器组件负责数据获取、状态管理和业务逻辑展示组件只接收props把数据渲染出来不关心数据从哪里来。拿用户管理页面举例UserManagementPage是容器组件用useQuery获取用户列表维护筛选条件调用删除接口UserTable是展示组件接收users、loading、onDelete等props只负责渲染表格UserFilterBar也是展示组件接收当前筛选条件和onFilterChange只负责渲染筛选项。这样做的好处非常直观展示组件天然可复用、可测试而且因为不需要关心数据来源本地开发时可以直接用假数据调试。5.2 复用逻辑的三种组织方式对比React中复用逻辑主要有三种方式高阶组件、Render Props、自定义Hooks。在rea项目里我最后几乎全部选择了自定义Hooks原因也很简单它最符合人的直觉。高阶组件HOC在旧代码里常见它的问题在于组件层层包裹后很难追踪数据来源。Render Props的写法更加嵌套代码读起来不够直观。自定义Hook则把逻辑折叠成一个个普通函数在组件里调用一下就完事。我在项目里封装了一个useTableQuery的Hook用来统一处理所有列表页筛选分页的模式function useTableQueryT({ queryKey, fetchFn }: TableQueryOptionsT) { const [page, setPage] useState(1) const [filters, setFilters] useStateRecordstring, unknown({}) const { data, isLoading, refetch } useQuery({ queryKey: [...queryKey, page, filters], queryFn: () fetchFn({ page, ...filters }) }) return { page, setPage, filters, setFilters, data, isLoading, refetch } }这个Hook把列表页最核心的交互闭环都管住了。调用方只需要传入数据key和请求函数page、filters和各状态的联动都不用关心。5.3 一次真实的重构案例rea项目里最典型的重构案例是一个报表页面。最初这个页面组件超过2000行包含筛选逻辑、图表配置、表格操作、导出功能和权限判断连我自己改起来都小心翼翼更不用说新接手的人。重构分三步走第一步把数据请求抽到自定义Hook里。原组件里所有useQuery、状态声明、请求函数全部搬进useReportPageData组件里只保留调用结果。第二步把UI拆成独立组件ReportFilterBar、ReportChart、ReportTable、ReportExportButton。每个组件只接收它需要的props商定好回调函数的名字接口通过类型定义锁定。第三步用组合的方式把页面拼起来export function ReportPage() { const { data, filters, setFilters, loading, exportReport } useReportPageData() return ( div ReportFilterBar filters{filters} onFilterChange{setFilters} / ReportChart data{data?.chartData} loading{loading} / ReportTable data{data?.tableData} loading{loading} / ReportExportButton onExport{exportReport} / /div ) }重构之后这个页面的主组件只有几十行每个子组件的职责一目了然。更重要的是后续增加新需求时改动范围被严格限制在对应的子组件或Hook里不会再出现改图表动了筛选器这种牵一发而动全身的情况。6. 构建部署与上线后的工程化收尾6.1 多环境配置和构建脚本rea项目同时存在本地开发、测试环境、预发布环境和生产环境环境切换的需求很常见。Vite对多环境支持是内置的项目根目录下放.env.development、.env.production、.env.staging三个文件分别对应本地、生产、预发布。每个环境文件里定义好接口地址和应用名称注意变量名要以VITE_开头才会被Vite暴露给客户端代码。为了避免变量名写错我在src/vite-env.d.ts里做了类型声明interface ImportMetaEnv { readonly VITE_API_BASE_URL: string readonly VITE_APP_TITLE: string }构建脚本需要保证在上线前把类型检查、代码规范检查、单元测试都跑一遍。我在package.json里配置了组合脚本{ scripts: { type-check: tsc --noEmit, lint: eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0, build: npm run type-check npm run lint vite build } }这个顺序特别重要先做类型检查再做lint最后构建。如果前面的检查没过构建根本不会开始从源头上杜绝带病上线。6.2 静态资源部署注意事项rea项目的产物是一堆纯静态文件部署非常轻量但有两个细节如果不注意上线后必然出问题。第一个是路由回退配置。前端用的是history模式路由用户在浏览器里访问/users/123这种深链接时如果 Nginx 配置不对会返回404。正确的配置是把所有不存在的路径都try_files到index.htmllocation / { try_files $uri $uri/ /index.html; }第二个是静态资源缓存策略。打包生成的JS和CSS文件默认带hash指纹可以直接设置长缓存index.html本身不能缓存必须每次请求都去服务器校验否则新版本发布后用户会继续使用旧的HTML和旧资源。location /assets/ { expires 30d; add_header Cache-Control public, immutable; }还有一个容易被忽略的点开启gzip压缩。Nginx配置里加上gzip相关指令对文本类静态资源启用压缩传输体积能明显减小。如果后端支持brotli优先brotli它的压缩率通常比gzip更好。6.3 线上监控与错误处理部署上线只是开始线上监控才是工程化的最后一块拼图。rea项目里我做了三件事。第一给应用包了一层错误边界ErrorBoundary当某个模块渲染抛错时不至于整个页面白屏而是显示一个可恢复的降级界面同时把错误信息上报。第二接入了前端错误监控。所有未捕获的异常、资源加载错误、接口请求异常都会通过监控SDK上报到平台开发者在后台能看到错误堆栈、发生时间、影响用户量。接入这个能力之后很多用户反馈的问题都能在用户反馈之前被定位到。第三收集了核心Web性能指标。首屏内容渲染时间、交互响应时间、页面加载耗时这些数据虽然不是项目功能的一部分但它们直接决定了用户对软件的体验评价。通过性能监控平台能看到持续的变化趋势发现某次发版后指标异常恶化就能快速定位到是不是某个新的依赖或组件拖慢了性能。错误处理这块有一个我在实际项目里归纳的定律接口错误提示不能全是请求失败请稍后重试这种笼统文案。最好根据错误码区分场景比如登录态过期就跳转登录页权限不足就提示联系管理员。用户能够根据提示自主处理的问题不要让用户去提工单。写到这里rea这个项目的工程化链路基本完整了。回看整段经历我最深的感受是任何一项技术选型都不是因为它流行或者别人都在用而是因为它在你的项目场景下真正解决了问题。React Query之所以好用是因为它把服务端状态的复杂度收走了虚拟滚动之所以值得实现是因为数据量上去了之后卡顿感真实存在。还有一些看起来很小的配置比如环境变量类型声明、缓存策略、错误边界它们不会出现在功能列表里但恰恰是这些细节决定了一个项目能不能在半年后依然让人愿意继续迭代。如果你也在搭建自己的rea希望我的这些选择能帮你少走几步弯路。