1. 项目起因与整体设计思路1.1 为什么我会以"rea"为代号启动这个项目先说说这个项目是怎么来的。年初我接到一个内部孵化的前端项目需求,目标是把一套历史遗留的报表系统迁移到现代前端技术栈上。项目代号最终定为"rea",有人问是不是React缩写,我倒觉得更像是"rebuild、evolve、adapt"这三个词的首字母——重构、演进、适配。这个定位恰好贯穿了项目全程。这类迁移项目的痛点很典型:旧系统是基于服务端模板渲染的,每打开一个页面就是一次完整刷新,操作响应慢,状态同步靠手动刷新,最难受的是所有功能都堆在一个巨大的入口文件里,改一行代码要等十几秒热更新。当时的团队大概有五六个人并行开发,每天都在互相覆盖代码。所以"rea"这个项目的核心目标非常明确:用现代前端工程化手段,在尽量不改变业务功能的前提下,把整个系统重写为前后端分离的架构,同时建立一套可持续迭代的组件库和工程规范。这篇文章不会讲泛泛的React教程,而是围绕这套方案从选型、搭建、实现到上线的完整实操记录,所有核心代码和配置都是可以直接抄作业的。1.2 方案选型与环境约束做技术选型之前,先要认清这个项目的约束条件,否则方案再先进也落不了地。第一,团队技术栈背景。成员大多写过React但水平参差不齐,有人对Hooks很熟,有人还在用Class组件。选型时必须考虑到团队能快速上手,不能为了炫技引入过重的抽象。第二,业务形态。这是一个中后台管理类系统,包含数据看板、复杂表单、列表查询、权限控制这几类典型页面,对SEO没有要求,对首屏速度要求中等偏高,但对交互流畅度和状态一致性要求很高。第三,后端配套。后端团队提供的接口遵循REST规范,但部分老接口返回结构不统一,有的返回{ data: [...] },有的直接返回数组。这意味着前端必须在数据层做适配,而不是理想化地假设后端接口都规范。基于这三点,最终敲定的技术栈是:React 18 + TypeScript + Vite + React Router + Zustand + TanStack Query。这套组合不是业界最新潮的,但每一步选择都是有据可循的。React 18是必然选择,并发特性带来的startTransition能力在后续优化列表筛选时非常有用。Vite替代Webpack是看中了它的开发冷启动速度和HMR效率,实测一个大项目从启动到可访问从原来的十几秒缩短到两秒以内。状态管理没有选择Redux全家桶,而是用了Zustand——这个决定在后面会详细展开。项目初始化完成后,整个工程骨架长这样:rea/ ├── src/ │ ├── api/ # 接口请求与数据适配层 │ ├── components/ # 通用组件库 │ ├── features/ # 业务模块(按领域拆分) │ ├── hooks/ # 通用自定义Hooks │ ├── layouts/ # 布局组件 │ ├── stores/ # Zustand全局状态 │ ├── types/ # 全局类型定义 │ ├── utils/ # 工具函数 │ ├── App.tsx │ └── main.tsx ├── index.html ├── tsconfig.json └── vite.config.ts1.3 为什么不用Redux而选Zustand关于状态管理,我听到最多的问题就是:现在不用Redux会不会被认为不专业?恰恰相反,对中后台场景来说,Zustand的性价比远高于Redux。Redux的模式本身没问题,但它为大型应用设计的严格约束,在小团队里很容易变成负担。写Action、写Reducer、配Selector、处理不可变更新,这些样板代码占据了大量开发时间。更现实的问题是,团队里多数人对Redux的异步流程理解不够,经常写出难以调试的action chain。Zustand的核心理念是“用一个轻量store解决80%的状态问题”,它没有Provider嵌套、没有Action常量、没有Reducer模板,直接定义store、直接调用方法。比如我要处理全局用户信息:import { create } from 'zustand'; interface UserState { userInfo: UserInfo | null; permissions: string[]; setUserInfo: (info: UserInfo) => void; setPermissions: (perms: string[]) => void; reset: () => void; } export const useUserStore = create<UserState>((set) => ({ userInfo: null, permissions: [], setUserInfo: (userInfo) => set({ userInfo }), setPermissions: (permissions) => set({ permissions }), reset: () => set({ userInfo: null, permissions: [] }), }));在这个store里可以直接调用useUserStore.getState()在组件外部获取状态,比如处理登录过期时清理用户信息,不需要侵入组件内部。这个能力在拦截器里尤其好用。选择Zustand的另一个理由是它和React并发模式兼容良好,它可以订阅单个状态切片,粒度比Context细得多,不会因为一层状态变化导致大片组件重渲染。关于性能测试数据,后面优化章节会专门说。2. 工程搭建与基础设施配置2.1 Vite + TypeScript 的初始化细节工程初始化我推荐直接用官方脚手架,不需要手动搭配置。执行:npm create vite@latest rea -- --template react-ts这个模板自带React 18、TypeScript和基础ESLint配置。但直接开箱使用还不够,有几个关键配置必须调。第一,路径别名。把@/映射到src/目录,这是几乎所有中大型项目的标配,避免写一长串相对路径。// 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: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, }, });这里的server.proxy配置特别重要。开发环境前端跑在3000端口,后端跑在8080端口,如果不在Vite层做代理,每个请求都要面对跨域问题。配置了代理之后,前端代码里所有请求都写相对路径/api/xxx,由Vite在开发服务器转发到后端,生产环境再通过Nginx做同样的代理,这样前端代码里不需要关心环境差异。然后需要调整tsconfig.json,让TypeScript也认别名:{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }第二,ESLint的Team规范。Vite模板默认的ESLint是基于eslint-plugin-react-hooks和eslint-plugin-react-refresh的,对Hook依赖检查这块默认比较严格,但对命名规范、导入排序等还没有约束。我在实践里习惯集成eslint-plugin-import来强制import顺序:先外部库、再内部模块、最后样式文件。这套规范不需要纠结,直接配好放团队公共配置里就好。第三,环境变量。Vite使用import.meta.env读取环境变量,类型声明需要自己在src/vite-env.d.ts中补齐:interface ImportMetaEnv { readonly VITE_API_BASE: string; readonly VITE_APP_TITLE: string; } interface ImportMeta { readonly env: ImportMetaEnv; }我在项目里设置了三套环境:development、test、production,对应的接口地址、调试开关、CDN前缀都可以分别配置。这样执行npm run build -- --mode test就能打出测试环境的包。2.2 目录结构与模块边界划分目录划分是工程搭建里最需要想清楚的事。很多项目写着写着变成一坨,根源就在于一开始没有定义好模块边界。我采用的方案是features模式,每个业务域独立成一个文件夹,包含自己的组件、hooks、api调用和类型定义。比如权限管理、订单列表、数据看板各自独立:src/features/ ├── dashboard/ │ ├── components/ # 看板专属组件 │ ├── hooks/ # 看板数据加载逻辑 │ ├── api.ts # 看板接口定义 │ └── types.ts # 看板相关类型 ├── orders/ │ ├── components/ │ ├── hooks/ │ ├── api.ts │ └── types.ts └── auth/ ├── components/ ├── hooks/ ├── api.ts └── types.ts这套结构的好处是职能内聚。改订单业务时你只需要进入orders目录,不会碰到看板的代码。目录之间的引用规则是:features内部模块可以引用components、hooks、utils这些共享层,但共享层不能反向依赖业务模块。这个规则写进团队规范之后,代码依赖关系变得非常清楚。我见过反例是文件夹按“组件/页面/工具”划分,看起来整洁,但业务改动往往横跨多个目录,要同时打开四五个文件夹才能找到所有相关文件。按功能域划分之后,只需要定位到一个模块目录,边界清晰得多。组件库部分,我只放了真正有复用价值的基础组件:Button、Modal、Table、Form、Pagination、Empty等。业务组件绝不放进通用组件库,否则目录又会变成垃圾场。2.3 路由设计与权限控制路由方案选择的是React Router v6,没有用Next.js或者Umi这样重量级的框架,原因还是那个:不引入不必要的约束。React Router v6在这个场景下足够轻,同时它的嵌套路由设计非常契合中后台的布局模式。整体路由设计采用两层结构:外层是登录页,内层是主布局,主布局内部再嵌套各个业务页面。代码看起来是这样:<Routes> <Route path="/login" element={<LoginPage />} /> <Route path="/" element={<MainLayout />}> <Route path="dashboard" element={<DashboardPage />} /> <Route path="orders" element={<OrdersPage />} /> <Route path="settings" element={<SettingsPage />} /> </Route> </Routes>权限控制的实现,核心是两个环节:路由守卫和按钮级权限。路由守卫我用了一个自定义组件,在渲染页面之前检查当前用户有没有对应权限,没有就直接跳403页面:import { Navigate, useLocation } from 'react-router-dom'; import { useUserStore } from '@/stores/useUserStore'; interface RequireAuthProps { children: JSX.Element; requiredPermission?: string; } export default function RequireAuth({ children, requiredPermission }: RequireAuthProps) { const token = useUserStore((state) => state.token); const permissions = useUserStore((state) => state.permissions); const location = useLocation(); if (!token) { return <Navigate to="/login" replace state={{ from: location }} />; } if (requiredPermission && !permissions.includes(requiredPermission)) { return <Navigate to="/403" replace />; } return children; }按钮级权限我封装了一个PermissionButton组件,本质上是对antdButton的薄封装,无权限时直接返回null。这个组件看起来简单,但能让业务代码里少写很多v-if式的判断,可读性提升明显。路由懒加载一开始就要做。React Router v6配合React.lazy非常顺滑:const DashboardPage = lazy(() => import('@/features/dashboard/components/DashboardPage'));注意懒加载组件需要包裹在Suspense中,我习惯在主布局的路由出口处统一包一层。这里有个小坑:React.lazy只能接收默认导出的模块,如果业务页面用了命名导出,需要多加一行转换。团队规范里我直接规定页面组件一律默认导出。3. 核心业务模块的落地实操3.1 数据请求层与接口适配数据请求层是这次项目里最值得说透的部分。前面提到后端老接口返回结构不统一,如果每个API调用都写一遍判断逻辑,代码会极其冗余。更关键的是,如果后端改了接口结构,前端要到处改。所以我在api/目录下建立了一个统一的HTTP客户端,基于axios封装。核心能力包括:自动附带token、统一处理错误码、超时控制、取消重复请求。代码如下:import axios from 'axios'; import { message } from 'antd'; import { useUserStore } from '@/stores/useUserStore'; const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 15000, }); http.interceptors.request.use((config) => { const token = useUserStore.getState().token; if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); http.interceptors.response.use( (response) => { const res = response.data; // 兼容直接返回数组或二进制的情况 if (res instanceof Blob || Array.isArray(res)) { return res; } if (res.code === 0) { return res.data; } message.error(res.message || '请求失败'); return Promise.reject(new Error(res.message || '请求失败')); }, (error) => { if (error.response?.status === 401) { useUserStore.getState().reset(); window.location.href = '/login'; } return Promise.reject(error); } );这里有个值得注意的设计:拦截器统一处理了401跳转登录。处理登录过期最常见的问题是,如果页面正处于异步请求中,用户数据被重置后组件不知道状态变化,可能出现白屏。我的解决方案是在重置用户store之后强制跳转,保证用户看到的是明确的登录页而不是一个失效页面。接口定义层面的做法是每个feature下的api.ts只写这个业务的请求函数,不直接在业务组件里写URL。比如订单接口:export interface OrderQuery { page: number; pageSize: number; status?: string; keyword?: string; } export interface OrderItem { id: string; orderNo: string; customerName: string; amount: number; status: string; createdAt: string; } export async function fetchOrders(params: OrderQuery): Promise<PageResult<OrderItem>> { return http.get('/orders', { params }); }PageResult是全局定义的分页响应结构,后端只要遵循{ list, total }这种格式就能直接复用。3.2 数据获取与缓存:TanStack Query如果每个页面都在自己的useEffect里发请求、处理loading、管理错误,那么代码会重复到让人崩溃。所以我引入了TanStack Query来统一管理服务端状态。这也是整个项目里收益最大的一笔投入。理解TanStack Query的关键认知是:它把“读数据”这件事抽象成了“缓存管理”,不是简单的请求封装。它会自动缓存数据、后台重新验证、处理请求去重、在tab切回来时自动刷新。这些能力在日常使用时几乎不用写代码就能获得。一个典型业务页面的数据获取逻辑长这样:const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['orders', queryParams], queryFn: () => fetchOrders(queryParams), keepPreviousData: true, });keepPreviousData: true这个参数是分页查询的隐藏神器。默认情况下切换页码时,因为queryKey变了,旧数据会被丢弃,页面先变空白再加载新数据。启动keepPreviousData后,切页时保留上一页数据,等新数据返回再替换,配合渐入动画,体验平滑很多。关于缓存更新的问题,比如某个操作改变了列表数据,必须让列表重新获取。做法是在操作成功后调用invalidateQueries:const queryClient = useQueryClient(); async function handleDelete(id: string) { await deleteOrder(id); message.success('删除成功'); queryClient.invalidateQueries({ queryKey: ['orders'] }); }这里有个值得强调的细节:invalidateQueries不只是清缓存,而是把query标记为stale,然后如果有正在挂载的组件用到这个queryKey,它会自动重新请求。所以操作成功后列表会自动刷新,又不会把所有未使用的缓存全部清掉,效率更高。团队里我规定了三条铁律:所有涉及服务端数据的读取,必须走useQuery,禁止在useEffect里手写请求。所有修改数据的操作,完成后必须invalidate相关queryKey。queryKey必须按业务域组织,例如['orders', { status: 'pending' }],不允许随意字符串。3.3 表单场景的复杂状态管理表单是中后台系统里绕不开的核心场景。这个项目里最复杂的一个表单有十几个字段,包含联动逻辑、条件显隐、嵌套表格。这种复杂表单如果不用好的方案,写出来的代码就是一场灾难。我采用的方案是React Hook Form配合Zod做运行时校验。不夸张地说,这对组合是当前React表单方案里最省心的。React Hook Form的核心优势是它用受控方式注册字段,而不是把每个输入框都包在自己的状态管理里。这意味着几十个字段的页面也不会有性能问题。初始化方式:const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { status: 'active', pageSize: 10 }, });Zod定义校验schema,好处是类型可以在前后端共享,校验规则直接用TypeScript写,不用额外维护类型声明:import { z } from 'zod'; export const orderCreateSchema = z.object({ customerName: z.string().min(2, '客户姓名至少2个字符'), amount: z.number().positive('金额必须大于0'), status: z.enum(['pending', 'active', 'closed']), remark: z.string().optional(), }); export type OrderCreateValues = z.infer<typeof orderCreateSchema>;这个模式里最妙的是z.infer,它从schema自动推导出TypeScript类型,保证校验规则和类型定义永远不会不同步。联动逻辑方面,React Hook Form的watch可以订阅字段变化,条件渲染直接读取对应值:const paymentType = watch('paymentType'); useEffect(() => { if (paymentType === 'online') { setValue('paymentCode', ''); } }, [paymentType, setValue]);这里有一个坑:在useEffect里调用setValue要小心死循环。如果setValue的字段又被某个hook订阅,就会触发多次执行。实际项目里我建议把联动变化的处理收敛到一个独立函数里,用onChange替代useEffect监听,可读性和稳定性都更好。4. 性能优化与体验治理4.1 从数据层面解决渲染性能瓶颈中后台系统的性能问题,绝大多数不是React渲染机制的问题,而是组件写法不合理导致的不必要渲染。团队里流传一句话:“先看数据流,再看Profiler,最后才怀疑React本身。”我在项目里做性能治理,第一步永远是找出浪费的渲染来源。用React Profiler看页面,经常发现点击一个按钮导致整个列表页重新渲染,但它们的数据完全没变。这种现象的根源一般是状态被放在了过高的层级,引发了大面积的重渲染。优化方案是“状态下沉”。比如一个列表筛选面板里,筛选条件只供面板自身使用,就不要把它放到全局store里。只把最终生效的筛选结果提升到列表查询的层级。这样用户操作筛选器时,只有筛选面板自己重新渲染,表格根本不受影响。具体到Zustand的使用上,订阅的粒度也直接决定性能。Zustand本质上是发布订阅模式,订阅的selector返回新引用时才会触发重渲染。写如下代码会有问题:const state = useUserStore();因为返回的是整个store对象,state一变组件必然重渲染。应该按需订阅:const token = useUserStore((state) => state.token); const permissions = useUserStore((state) => state.permissions);这样只有token或permissions变化时,订阅者才会感知。多字段需要一起使用的场景,我用useShallow来做浅比较:const { token, permissions } = useUserStore(useShallow((state) => ({ token: state.token, permissions: state.permissions, })));4.2 长列表的虚拟化实践与细节探索列表页是这个项目的核心页面,单月订单量能达到上万条。一次性渲染上万行DOM,浏览器的解析和渲染都会卡顿,滚动时尤其明显。解决这个问题,虚拟滚动是最直接的手段。我选择的是react-window这个库,轻量、API直观,配合react-virtualized-auto-sizer自动填充容器宽度高度。核心用法:import { FixedSizeList as List } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; function OrdersTable({ orders }: { orders: OrderItem[] }) { return ( <AutoSizer> {({ height, width }) => ( <List height={height} width={width} itemCount={orders.length} itemSize={48} > {({ index, style }) => ( <OrderRow order={orders[index]} style={style} /> )} </List> )} </AutoSizer> ); }虚拟滚动的原理其实很好理解:它只渲染视口内可见的行,滚动时动态更新可见范围,通过绝对定位配合transform把行放到正确位置。items数量无论是一千还是一万,渲染的DOM节点始终只有视口能容纳的量。实际使用中有几个必须处理好的细节。第一,行高必须固定。react-window的itemSize是一个固定值,如果内容高度不同,行会错乱。对此我给每行设置了固定高度,并使用cssoverflow: hidden截断超长内容。第二,虚拟列表内部不要使用display: table之类的布局,会影响定位计算。保持每行的根节点是普通块级元素,子元素再自由布局。第三,配合筛选操作时,滚动位置要重置到顶部。在列表外层保存listRef,筛选条件变化时调用scrollToItem(0),避免用户看到空白区。4.3 首屏加载与详见优化首屏加载的优化主要靠两板斧:代码分割和资源压缩。代码分割的核心是把不常用的页面、依赖库拆成独立chunk,让首屏只加载当前页面必需的代码。React.lazy配合动态import天然支持这个能力。Vite在构建时会自动识别动态import并生成独立的chunk:dist/assets/ ├── index-xxxx.js # 首屏入口(约200KB) ├── dashboard-xxxx.js # 看板页独立chunk ├── orders-xxxx.js # 订单页独立chunk └── vendor-xxxx.js # 公共依赖库这里值得注意的优化点是把公共依赖单独打包。Vite默认会做代码分割,但有时第三方库会被重复打进多个chunk。通过manualChunks手动配置分割:build: { rollupOptions: { output: { manualChunks: { 'react-vendor': ['react', 'react-dom', 'react-router-dom'], 'antd': ['antd'], 'query': ['@tanstack/react-query'], }, }, }, }这样浏览器加载业务页时,公共库的chunk可以被长期缓存,业务代码改了只重新加载业务chunk,不需要下载庞大的vendor文件。针对Ant Design这种比较大的组件库,还有一步优化是引入unplugin-vue-components风格的按需加载。Vite生态里有对应的unplugin-auto-import和unplugin-vue-components的React版。配置后只在用到Button时才引入对应的样式和组件代码,依赖体积能缩小一大截。构建产物上,我设置了reportCompressedSize: true并接入vite-plugin-compression做gzip和brotli预压缩。中后台系统本身对传输体积敏感度没有C端高,但这些优化做完之后,首屏从原来的1.8MB降到500KB左右,再配合后端开启gzip,实际传输体积大概150KB,体感提升非常明显。5. 工程质量保障与自动化流程5.1 单元测试与关键业务逻辑覆盖工程质量的保障不是一句口号,而是靠一套测试策略逐步建立的。对于业务代码,我没有盲目追求覆盖率,而是把测试资源集中在最有价值的部分:工具函数、状态管理和复杂表单校验逻辑。这些部分是纯逻辑的,测试成本低、收益高。Zustand的store可以直接测试,不需要渲染组件。比如说用户store的权限更新逻辑:import { useUserStore } from '@/stores/useUserStore'; describe('useUserStore', () => { beforeEach(() => { useUserStore.getState().reset(); }); it('setPermissions should replace permissions list', () => { useUserStore.getState().setPermissions(['dashboard:view', 'orders:edit']); expect(useUserStore.getState().permissions).toEqual(['dashboard:view', 'orders:edit']); }); });工具函数和数据适配逻辑也很适合单测。比如金额格式化、日期转换、接口返回结构的适配,这些纯函数只要输入输出正确,测试就是稳定的。在接口数据适配层,我写出过一个比较典型的测试场景。某个老接口返回的金额字符串是"1,234.56",包含千分位逗号,前端拿到后需要转成数字并补齐精度。这个转换函数如果不测试,上线后一旦遇到特殊格式数据就是线上事故:export function parseAmountToNumber(input: string): number { const cleaned = input.replace(/,/g, ''); const value = parseFloat(cleaned); return Math.round(value * 100) / 100; } describe('parseAmountToNumber', () => { it('should handle thousands separator', () => { expect(parseAmountToNumber('1,234.56')).toBe(1234.56); }); it('should handle plain number string', () => { expect(parseAmountToNumber('42')).toBe(42); }); it('should handle integer without decimals', () => { expect(parseAmountToNumber('999')).toBe(999); }); });测试框架用的是Vitest,因为和Vite天然配套,不需要额外配置转换器。测试数量不要求多,但关键逻辑不能裸奔。5.2 CI流水线的搭建思路项目从第一天就接入了CI流水线。流程设计很直接:每次推送分支和合并请求时自动跑lint检查、类型检查、单元测试和构建。用GitHub Actions/Circle/GitLab CI的差别不大,关键是流水线要挡得住不合格代码合并,而不是走个形式。以下是我的流水线核心步骤:# 1. 安装依赖(使用lockfile保证一致性) npm ci # 2. ESLint检查 npm run lint # 3. 类型检查 npm run type-check # 4. 单元测试 npm run test -- --run # 5. 构建产物 npm run build这套流程看起来简单,但每条命令都对应一个强制性门槛。type-check这一步特别重要,很多团队只依赖编辑器自动报错,但CI里的强制执行保证了即使有人绕过编辑器,代码也过不了关。流水线里有一个值得分享的细节:构建产物检查。避免只验证“能构建成功”,还要验证“构建产物体积是否异常增大”。我写了一个小脚本,读取dist/assets下所有JS文件的大小,超过阈值就报警。这样某个依赖被误引入的时候,CI能第一时间发现。5.3 部署方案与回滚预案部署方案本来想上Kubernetes,但考虑到团队运维能力,最终选择了更加务实的方案:Nginx + 静态资源,通过CI流水线自动发布到对象存储或专门的静态服务器。原子部署是核心原则。发布流程是:新版本构建完成后,先上传到带版本号的目录,比如/app/releases/20250101-1230/,然后通过切换Nginx的软链接指向新版本,实现秒级切换。如果新版本有问题,只需要把软链接指回上一版本即可回滚。配套Nginx配置是这样的:server { listen 80; server_name rea.example.internal; root /app/releases/current; # 软链接,发布时切换 index index.html; location /api/ { proxy_pass http://backend-server:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } location ~* \.(js|css|png|jpg|gif|svg|ico)$ { expires 30d; add_header Cache-Control "public, no-transform"; } }这个配置里try_files $uri $uri/ /index.html是SPA应用必需的一行。没有它,用户在浏览器直接访问某个路由,比如/orders/123,Nginx会返回404。有了这一行,所有没有对应物理文件的请求都会回退到index.html,然后由前端路由接管。缓存策略上,带hash的静态资源(JS、CSS)适合长缓存,HTML文件不能缓存。所以我用expires 30d处理静态资源,Nginx对index.html默认走try_files且不设过期时间,确保每次发布后用户能拿到最新的HTML。这个细节做好了,发版后刷新页面才能看到新版本,否则会被浏览器缓存卡住半天。团队内部还约定了一条发布规范:发布窗口选择在业务低峰期,比如工作日的晚上或周末。表面上这是一个流程上的细节,但实际经历过发布后大量用户在操作、新版本有问题的惨痛教训后,这条规范成了硬性要求。6. 常见问题排查与实战避坑6.1 状态不同步的排查思路与解决过程这个项目上线运行两周后,遇到了一个最典型的疑难问题:用户在A页面修改了某条记录的数据,切到B页面后看到旧数据,必须手动刷新才正常。这类问题的本质是缓存一致性问题,排查思路比直接给方案更有价值。首先定位问题出现的范围。是只影响单个模块,还是全局都有?排查后发现所有列表页都有类似问题,这就排除了单个组件的bug,怀疑点集中到了数据获取层。TanStack Query的缓存机制默认会把请求结果缓存5分钟(staleTime默认0,gcTime默认5分钟)。当用户修改了某条数据,列表页的query缓存还保留着旧数据,并没有被自动刷新。这就是为什么从列表页进入详情页修改后,再返回到列表页,看到的还是旧列表。解决思路很明确:任何修改类操作完成后,必须让涉及到的queryKey失效并重新请求。我这边的统一方案是维护一个keys.ts文件,集中管理所有queryKey的定义:export const queryKeys = { orders: (params?: OrderQuery) => ['orders', params] as const, orderDetail: (id: string) => ['orderDetail', id] as const, };然后封装一个useInvalidateOrders自定义Hook:export function useInvalidateOrders() { const queryClient = useQueryClient(); return useCallback(() => { queryClient.invalidateQueries({ queryKey: ['orders'] }); }, [queryClient]); }业务代码里修改完订单后,调用一下这个Hook就行。这个方案看起来简单,但能把缓存一致性问题从源头上控制住。6.2 接口错误请求重复触发的排查记录第二个高频问题出现在网络状况不佳的时候:用户在表单里点提交,等了很久没有反馈,于是又点了一下,结果提交了两条重复数据。这个问题排查起来有难度,因为它涉及的是请求重复执行,而不是执行失败。根因有两层。第一层是前端没有做请求去重和防重复提交;第二层是后端接口本身不是幂等的,同样的请求重复提交会新增两条记录。前端方案我做了两层防护。第一层是按钮级别的loading状态,提交期间按钮禁用、显示loading,这是最直接有效的防重入手段:const [submitting, setSubmitting] = useState(false); async function handleSubmit(values: FormValues) { setSubmitting(true); try { await createOrder(values); message.success('创建成功'); } finally { setSubmitting(false); } } <Button type="primary" loading={submitting} onClick={handleSubmit}> 提交订单 </Button>第二层是请求拦截器层面的去重逻辑。axios拦截器里,对同一个地址的并发请求进行合并,只有第一个请求真正发出,后续相同请求直接复用第一个请求的Promise:const pendingMap = new Map<string, Promise<any>>(); function getRequestKey(config: InternalAxiosRequestConfig): string { const { method, url, params, data } = config; return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&'); } http.interceptors.request.use((config) => { const key = getRequestKey(config); const pendingPromise = pendingMap.get(key); if (pendingPromise) { return new Promise((_, reject) => { reject({ code: 'DUPLICATE_REQUEST', message: '重复请求已拦截' }); }); } const promise = axios.request(config).finally(() => { pendingMap.delete(key); }); pendingMap.set(key, promise); return promise; });注意这个方案要排除普通的GET请求,因为GET请求本身是幂等的,重复请求的代价低。真正需要去重的是POST和PUT。我的实现里会判断method,只对写操作生效。这里需要明确:前端防重复提交只是补救手段,后端接口设计成幂等才是根治。比如创建订单的接口可以接受一个前端生成的requestId,后端用这个字段做幂等键,重复请求直接返回第一次的结果。6.3 构建阶段内存溢出与体积失控的应对策略项目越做越大之后,迎来了构建层面的两个经典问题:内存溢出和构建时间飙升。第一个问题表现很直接:执行npm run build时,Node进程直接崩溃,报错JavaScript heap out of memory。排查原因是项目里某个页面引入了大量依赖,Rollup解析和打包时内存占用超过Node默认限制。解决方式是在构建命令中扩大内存:{ "scripts": { "build": "node --max-old-space-size=4096 node_modules/vite/bin/vite.js build" } }这只能缓解症状,真正的根治方案是拆包。把可独立加载的业务模块拆成独立的页面,同时把大依赖库单独chunk,降低单次解析的内存压力。配合前面的manualChunks配置,构建峰值内存从3.5GB降到了1.8GB左右。第二个问题是构建时间越来越长。随着业务代码膨胀,从最初的30秒逐步涨到三分钟。排查后定位到两个耗时点:一是TypeScript类型检查,二是打包压缩阶段。我的处理方式是:把类型检查从构建流程中抽离出来,放到CI流水线独立步骤执行。本地构建不再跑完整类型检查,节省大量时间。同时接入了vite-plugin-legacy的优化替代方案,使用esbuild做预压缩,构建时间从三分钟降到了一分钟以内。对一个频繁迭代的业务系统来说,构建速度就是开发体验的一部分。如果每次构建要等三分钟,开发者很快就会想办法绕过构建直接在本地跑,这其实是一种对工程规范的侵蚀。写在最后的一些经验体会代码写完之后,回头再看这个项目,我最大的感受是:工程化的意义不在于用了多少工具、写了多少规范,而在于让每个参与的开发者都能在可预期的节奏里推进功能,并且不会因为改动一小块地方导致另一块崩掉。有些细节确实是在实际踩坑之后才真正理解的。比如TanStack Query的缓存失效机制,文档里写得清清楚楚,但只有亲眼看到用户反馈“数据不对”时,才对invalidateQueries的价值有了切身体会。再比如虚拟滚动里滚动位置重置这种小事,不做也不会报错,但做了之后用户体验的差别是实打实的。这个项目后续如果能继续投入,我倾向于在几个方向扩展:把权限配置做成可视化管理页面,让运营角色可以直接调整角色权限;把通用表单组件进一步沉淀,让复杂表单的开发变成配置化组装;同时把前端监控(错误上报、性能指标)接入进来,让线上问题有更精准的定位手段。如果读者正在做一个类似的中后台迁移项目,最想给的建议是老生常谈但极其重要的一句话:先想清楚要解决什么问题,再选工具。Redux没有问题,Zustand也没有多高级,关键是适合团队现状和业务场景。工程方案没有银弹,小步快跑、持续迭代,比一步到位更加实际。
1. 项目起因与整体设计思路1.1 为什么我会以"rea"为代号启动这个项目先说说这个项目是怎么来的。年初我接到一个内部孵化的前端项目需求,目标是把一套历史遗留的报表系统迁移到现代前端技术栈上。项目代号最终定为"rea",有人问是不是React缩写,我倒觉得更像是"rebuild、evolve、adapt"这三个词的首字母——重构、演进、适配。这个定位恰好贯穿了项目全程。这类迁移项目的痛点很典型:旧系统是基于服务端模板渲染的,每打开一个页面就是一次完整刷新,操作响应慢,状态同步靠手动刷新,最难受的是所有功能都堆在一个巨大的入口文件里,改一行代码要等十几秒热更新。当时的团队大概有五六个人并行开发,每天都在互相覆盖代码。所以"rea"这个项目的核心目标非常明确:用现代前端工程化手段,在尽量不改变业务功能的前提下,把整个系统重写为前后端分离的架构,同时建立一套可持续迭代的组件库和工程规范。这篇文章不会讲泛泛的React教程,而是围绕这套方案从选型、搭建、实现到上线的完整实操记录,所有核心代码和配置都是可以直接抄作业的。1.2 方案选型与环境约束做技术选型之前,先要认清这个项目的约束条件,否则方案再先进也落不了地。第一,团队技术栈背景。成员大多写过React但水平参差不齐,有人对Hooks很熟,有人还在用Class组件。选型时必须考虑到团队能快速上手,不能为了炫技引入过重的抽象。第二,业务形态。这是一个中后台管理类系统,包含数据看板、复杂表单、列表查询、权限控制这几类典型页面,对SEO没有要求,对首屏速度要求中等偏高,但对交互流畅度和状态一致性要求很高。第三,后端配套。后端团队提供的接口遵循REST规范,但部分老接口返回结构不统一,有的返回{ data: [...] },有的直接返回数组。这意味着前端必须在数据层做适配,而不是理想化地假设后端接口都规范。基于这三点,最终敲定的技术栈是:React 18 + TypeScript + Vite + React Router + Zustand + TanStack Query。这套组合不是业界最新潮的,但每一步选择都是有据可循的。React 18是必然选择,并发特性带来的startTransition能力在后续优化列表筛选时非常有用。Vite替代Webpack是看中了它的开发冷启动速度和HMR效率,实测一个大项目从启动到可访问从原来的十几秒缩短到两秒以内。状态管理没有选择Redux全家桶,而是用了Zustand——这个决定在后面会详细展开。项目初始化完成后,整个工程骨架长这样:rea/ ├── src/ │ ├── api/ # 接口请求与数据适配层 │ ├── components/ # 通用组件库 │ ├── features/ # 业务模块(按领域拆分) │ ├── hooks/ # 通用自定义Hooks │ ├── layouts/ # 布局组件 │ ├── stores/ # Zustand全局状态 │ ├── types/ # 全局类型定义 │ ├── utils/ # 工具函数 │ ├── App.tsx │ └── main.tsx ├── index.html ├── tsconfig.json └── vite.config.ts1.3 为什么不用Redux而选Zustand关于状态管理,我听到最多的问题就是:现在不用Redux会不会被认为不专业?恰恰相反,对中后台场景来说,Zustand的性价比远高于Redux。Redux的模式本身没问题,但它为大型应用设计的严格约束,在小团队里很容易变成负担。写Action、写Reducer、配Selector、处理不可变更新,这些样板代码占据了大量开发时间。更现实的问题是,团队里多数人对Redux的异步流程理解不够,经常写出难以调试的action chain。Zustand的核心理念是“用一个轻量store解决80%的状态问题”,它没有Provider嵌套、没有Action常量、没有Reducer模板,直接定义store、直接调用方法。比如我要处理全局用户信息:import { create } from 'zustand'; interface UserState { userInfo: UserInfo | null; permissions: string[]; setUserInfo: (info: UserInfo) => void; setPermissions: (perms: string[]) => void; reset: () => void; } export const useUserStore = create<UserState>((set) => ({ userInfo: null, permissions: [], setUserInfo: (userInfo) => set({ userInfo }), setPermissions: (permissions) => set({ permissions }), reset: () => set({ userInfo: null, permissions: [] }), }));在这个store里可以直接调用useUserStore.getState()在组件外部获取状态,比如处理登录过期时清理用户信息,不需要侵入组件内部。这个能力在拦截器里尤其好用。选择Zustand的另一个理由是它和React并发模式兼容良好,它可以订阅单个状态切片,粒度比Context细得多,不会因为一层状态变化导致大片组件重渲染。关于性能测试数据,后面优化章节会专门说。2. 工程搭建与基础设施配置2.1 Vite + TypeScript 的初始化细节工程初始化我推荐直接用官方脚手架,不需要手动搭配置。执行:npm create vite@latest rea -- --template react-ts这个模板自带React 18、TypeScript和基础ESLint配置。但直接开箱使用还不够,有几个关键配置必须调。第一,路径别名。把@/映射到src/目录,这是几乎所有中大型项目的标配,避免写一长串相对路径。// 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: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, }, });这里的server.proxy配置特别重要。开发环境前端跑在3000端口,后端跑在8080端口,如果不在Vite层做代理,每个请求都要面对跨域问题。配置了代理之后,前端代码里所有请求都写相对路径/api/xxx,由Vite在开发服务器转发到后端,生产环境再通过Nginx做同样的代理,这样前端代码里不需要关心环境差异。然后需要调整tsconfig.json,让TypeScript也认别名:{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }第二,ESLint的Team规范。Vite模板默认的ESLint是基于eslint-plugin-react-hooks和eslint-plugin-react-refresh的,对Hook依赖检查这块默认比较严格,但对命名规范、导入排序等还没有约束。我在实践里习惯集成eslint-plugin-import来强制import顺序:先外部库、再内部模块、最后样式文件。这套规范不需要纠结,直接配好放团队公共配置里就好。第三,环境变量。Vite使用import.meta.env读取环境变量,类型声明需要自己在src/vite-env.d.ts中补齐:interface ImportMetaEnv { readonly VITE_API_BASE: string; readonly VITE_APP_TITLE: string; } interface ImportMeta { readonly env: ImportMetaEnv; }我在项目里设置了三套环境:development、test、production,对应的接口地址、调试开关、CDN前缀都可以分别配置。这样执行npm run build -- --mode test就能打出测试环境的包。2.2 目录结构与模块边界划分目录划分是工程搭建里最需要想清楚的事。很多项目写着写着变成一坨,根源就在于一开始没有定义好模块边界。我采用的方案是features模式,每个业务域独立成一个文件夹,包含自己的组件、hooks、api调用和类型定义。比如权限管理、订单列表、数据看板各自独立:src/features/ ├── dashboard/ │ ├── components/ # 看板专属组件 │ ├── hooks/ # 看板数据加载逻辑 │ ├── api.ts # 看板接口定义 │ └── types.ts # 看板相关类型 ├── orders/ │ ├── components/ │ ├── hooks/ │ ├── api.ts │ └── types.ts └── auth/ ├── components/ ├── hooks/ ├── api.ts └── types.ts这套结构的好处是职能内聚。改订单业务时你只需要进入orders目录,不会碰到看板的代码。目录之间的引用规则是:features内部模块可以引用components、hooks、utils这些共享层,但共享层不能反向依赖业务模块。这个规则写进团队规范之后,代码依赖关系变得非常清楚。我见过反例是文件夹按“组件/页面/工具”划分,看起来整洁,但业务改动往往横跨多个目录,要同时打开四五个文件夹才能找到所有相关文件。按功能域划分之后,只需要定位到一个模块目录,边界清晰得多。组件库部分,我只放了真正有复用价值的基础组件:Button、Modal、Table、Form、Pagination、Empty等。业务组件绝不放进通用组件库,否则目录又会变成垃圾场。2.3 路由设计与权限控制路由方案选择的是React Router v6,没有用Next.js或者Umi这样重量级的框架,原因还是那个:不引入不必要的约束。React Router v6在这个场景下足够轻,同时它的嵌套路由设计非常契合中后台的布局模式。整体路由设计采用两层结构:外层是登录页,内层是主布局,主布局内部再嵌套各个业务页面。代码看起来是这样:<Routes> <Route path="/login" element={<LoginPage />} /> <Route path="/" element={<MainLayout />}> <Route path="dashboard" element={<DashboardPage />} /> <Route path="orders" element={<OrdersPage />} /> <Route path="settings" element={<SettingsPage />} /> </Route> </Routes>权限控制的实现,核心是两个环节:路由守卫和按钮级权限。路由守卫我用了一个自定义组件,在渲染页面之前检查当前用户有没有对应权限,没有就直接跳403页面:import { Navigate, useLocation } from 'react-router-dom'; import { useUserStore } from '@/stores/useUserStore'; interface RequireAuthProps { children: JSX.Element; requiredPermission?: string; } export default function RequireAuth({ children, requiredPermission }: RequireAuthProps) { const token = useUserStore((state) => state.token); const permissions = useUserStore((state) => state.permissions); const location = useLocation(); if (!token) { return <Navigate to="/login" replace state={{ from: location }} />; } if (requiredPermission && !permissions.includes(requiredPermission)) { return <Navigate to="/403" replace />; } return children; }按钮级权限我封装了一个PermissionButton组件,本质上是对antdButton的薄封装,无权限时直接返回null。这个组件看起来简单,但能让业务代码里少写很多v-if式的判断,可读性提升明显。路由懒加载一开始就要做。React Router v6配合React.lazy非常顺滑:const DashboardPage = lazy(() => import('@/features/dashboard/components/DashboardPage'));注意懒加载组件需要包裹在Suspense中,我习惯在主布局的路由出口处统一包一层。这里有个小坑:React.lazy只能接收默认导出的模块,如果业务页面用了命名导出,需要多加一行转换。团队规范里我直接规定页面组件一律默认导出。3. 核心业务模块的落地实操3.1 数据请求层与接口适配数据请求层是这次项目里最值得说透的部分。前面提到后端老接口返回结构不统一,如果每个API调用都写一遍判断逻辑,代码会极其冗余。更关键的是,如果后端改了接口结构,前端要到处改。所以我在api/目录下建立了一个统一的HTTP客户端,基于axios封装。核心能力包括:自动附带token、统一处理错误码、超时控制、取消重复请求。代码如下:import axios from 'axios'; import { message } from 'antd'; import { useUserStore } from '@/stores/useUserStore'; const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 15000, }); http.interceptors.request.use((config) => { const token = useUserStore.getState().token; if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); http.interceptors.response.use( (response) => { const res = response.data; // 兼容直接返回数组或二进制的情况 if (res instanceof Blob || Array.isArray(res)) { return res; } if (res.code === 0) { return res.data; } message.error(res.message || '请求失败'); return Promise.reject(new Error(res.message || '请求失败')); }, (error) => { if (error.response?.status === 401) { useUserStore.getState().reset(); window.location.href = '/login'; } return Promise.reject(error); } );这里有个值得注意的设计:拦截器统一处理了401跳转登录。处理登录过期最常见的问题是,如果页面正处于异步请求中,用户数据被重置后组件不知道状态变化,可能出现白屏。我的解决方案是在重置用户store之后强制跳转,保证用户看到的是明确的登录页而不是一个失效页面。接口定义层面的做法是每个feature下的api.ts只写这个业务的请求函数,不直接在业务组件里写URL。比如订单接口:export interface OrderQuery { page: number; pageSize: number; status?: string; keyword?: string; } export interface OrderItem { id: string; orderNo: string; customerName: string; amount: number; status: string; createdAt: string; } export async function fetchOrders(params: OrderQuery): Promise<PageResult<OrderItem>> { return http.get('/orders', { params }); }PageResult是全局定义的分页响应结构,后端只要遵循{ list, total }这种格式就能直接复用。3.2 数据获取与缓存:TanStack Query如果每个页面都在自己的useEffect里发请求、处理loading、管理错误,那么代码会重复到让人崩溃。所以我引入了TanStack Query来统一管理服务端状态。这也是整个项目里收益最大的一笔投入。理解TanStack Query的关键认知是:它把“读数据”这件事抽象成了“缓存管理”,不是简单的请求封装。它会自动缓存数据、后台重新验证、处理请求去重、在tab切回来时自动刷新。这些能力在日常使用时几乎不用写代码就能获得。一个典型业务页面的数据获取逻辑长这样:const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['orders', queryParams], queryFn: () => fetchOrders(queryParams), keepPreviousData: true, });keepPreviousData: true这个参数是分页查询的隐藏神器。默认情况下切换页码时,因为queryKey变了,旧数据会被丢弃,页面先变空白再加载新数据。启动keepPreviousData后,切页时保留上一页数据,等新数据返回再替换,配合渐入动画,体验平滑很多。关于缓存更新的问题,比如某个操作改变了列表数据,必须让列表重新获取。做法是在操作成功后调用invalidateQueries:const queryClient = useQueryClient(); async function handleDelete(id: string) { await deleteOrder(id); message.success('删除成功'); queryClient.invalidateQueries({ queryKey: ['orders'] }); }这里有个值得强调的细节:invalidateQueries不只是清缓存,而是把query标记为stale,然后如果有正在挂载的组件用到这个queryKey,它会自动重新请求。所以操作成功后列表会自动刷新,又不会把所有未使用的缓存全部清掉,效率更高。团队里我规定了三条铁律:所有涉及服务端数据的读取,必须走useQuery,禁止在useEffect里手写请求。所有修改数据的操作,完成后必须invalidate相关queryKey。queryKey必须按业务域组织,例如['orders', { status: 'pending' }],不允许随意字符串。3.3 表单场景的复杂状态管理表单是中后台系统里绕不开的核心场景。这个项目里最复杂的一个表单有十几个字段,包含联动逻辑、条件显隐、嵌套表格。这种复杂表单如果不用好的方案,写出来的代码就是一场灾难。我采用的方案是React Hook Form配合Zod做运行时校验。不夸张地说,这对组合是当前React表单方案里最省心的。React Hook Form的核心优势是它用受控方式注册字段,而不是把每个输入框都包在自己的状态管理里。这意味着几十个字段的页面也不会有性能问题。初始化方式:const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { status: 'active', pageSize: 10 }, });Zod定义校验schema,好处是类型可以在前后端共享,校验规则直接用TypeScript写,不用额外维护类型声明:import { z } from 'zod'; export const orderCreateSchema = z.object({ customerName: z.string().min(2, '客户姓名至少2个字符'), amount: z.number().positive('金额必须大于0'), status: z.enum(['pending', 'active', 'closed']), remark: z.string().optional(), }); export type OrderCreateValues = z.infer<typeof orderCreateSchema>;这个模式里最妙的是z.infer,它从schema自动推导出TypeScript类型,保证校验规则和类型定义永远不会不同步。联动逻辑方面,React Hook Form的watch可以订阅字段变化,条件渲染直接读取对应值:const paymentType = watch('paymentType'); useEffect(() => { if (paymentType === 'online') { setValue('paymentCode', ''); } }, [paymentType, setValue]);这里有一个坑:在useEffect里调用setValue要小心死循环。如果setValue的字段又被某个hook订阅,就会触发多次执行。实际项目里我建议把联动变化的处理收敛到一个独立函数里,用onChange替代useEffect监听,可读性和稳定性都更好。4. 性能优化与体验治理4.1 从数据层面解决渲染性能瓶颈中后台系统的性能问题,绝大多数不是React渲染机制的问题,而是组件写法不合理导致的不必要渲染。团队里流传一句话:“先看数据流,再看Profiler,最后才怀疑React本身。”我在项目里做性能治理,第一步永远是找出浪费的渲染来源。用React Profiler看页面,经常发现点击一个按钮导致整个列表页重新渲染,但它们的数据完全没变。这种现象的根源一般是状态被放在了过高的层级,引发了大面积的重渲染。优化方案是“状态下沉”。比如一个列表筛选面板里,筛选条件只供面板自身使用,就不要把它放到全局store里。只把最终生效的筛选结果提升到列表查询的层级。这样用户操作筛选器时,只有筛选面板自己重新渲染,表格根本不受影响。具体到Zustand的使用上,订阅的粒度也直接决定性能。Zustand本质上是发布订阅模式,订阅的selector返回新引用时才会触发重渲染。写如下代码会有问题:const state = useUserStore();因为返回的是整个store对象,state一变组件必然重渲染。应该按需订阅:const token = useUserStore((state) => state.token); const permissions = useUserStore((state) => state.permissions);这样只有token或permissions变化时,订阅者才会感知。多字段需要一起使用的场景,我用useShallow来做浅比较:const { token, permissions } = useUserStore(useShallow((state) => ({ token: state.token, permissions: state.permissions, })));4.2 长列表的虚拟化实践与细节探索列表页是这个项目的核心页面,单月订单量能达到上万条。一次性渲染上万行DOM,浏览器的解析和渲染都会卡顿,滚动时尤其明显。解决这个问题,虚拟滚动是最直接的手段。我选择的是react-window这个库,轻量、API直观,配合react-virtualized-auto-sizer自动填充容器宽度高度。核心用法:import { FixedSizeList as List } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; function OrdersTable({ orders }: { orders: OrderItem[] }) { return ( <AutoSizer> {({ height, width }) => ( <List height={height} width={width} itemCount={orders.length} itemSize={48} > {({ index, style }) => ( <OrderRow order={orders[index]} style={style} /> )} </List> )} </AutoSizer> ); }虚拟滚动的原理其实很好理解:它只渲染视口内可见的行,滚动时动态更新可见范围,通过绝对定位配合transform把行放到正确位置。items数量无论是一千还是一万,渲染的DOM节点始终只有视口能容纳的量。实际使用中有几个必须处理好的细节。第一,行高必须固定。react-window的itemSize是一个固定值,如果内容高度不同,行会错乱。对此我给每行设置了固定高度,并使用cssoverflow: hidden截断超长内容。第二,虚拟列表内部不要使用display: table之类的布局,会影响定位计算。保持每行的根节点是普通块级元素,子元素再自由布局。第三,配合筛选操作时,滚动位置要重置到顶部。在列表外层保存listRef,筛选条件变化时调用scrollToItem(0),避免用户看到空白区。4.3 首屏加载与详见优化首屏加载的优化主要靠两板斧:代码分割和资源压缩。代码分割的核心是把不常用的页面、依赖库拆成独立chunk,让首屏只加载当前页面必需的代码。React.lazy配合动态import天然支持这个能力。Vite在构建时会自动识别动态import并生成独立的chunk:dist/assets/ ├── index-xxxx.js # 首屏入口(约200KB) ├── dashboard-xxxx.js # 看板页独立chunk ├── orders-xxxx.js # 订单页独立chunk └── vendor-xxxx.js # 公共依赖库这里值得注意的优化点是把公共依赖单独打包。Vite默认会做代码分割,但有时第三方库会被重复打进多个chunk。通过manualChunks手动配置分割:build: { rollupOptions: { output: { manualChunks: { 'react-vendor': ['react', 'react-dom', 'react-router-dom'], 'antd': ['antd'], 'query': ['@tanstack/react-query'], }, }, }, }这样浏览器加载业务页时,公共库的chunk可以被长期缓存,业务代码改了只重新加载业务chunk,不需要下载庞大的vendor文件。针对Ant Design这种比较大的组件库,还有一步优化是引入unplugin-vue-components风格的按需加载。Vite生态里有对应的unplugin-auto-import和unplugin-vue-components的React版。配置后只在用到Button时才引入对应的样式和组件代码,依赖体积能缩小一大截。构建产物上,我设置了reportCompressedSize: true并接入vite-plugin-compression做gzip和brotli预压缩。中后台系统本身对传输体积敏感度没有C端高,但这些优化做完之后,首屏从原来的1.8MB降到500KB左右,再配合后端开启gzip,实际传输体积大概150KB,体感提升非常明显。5. 工程质量保障与自动化流程5.1 单元测试与关键业务逻辑覆盖工程质量的保障不是一句口号,而是靠一套测试策略逐步建立的。对于业务代码,我没有盲目追求覆盖率,而是把测试资源集中在最有价值的部分:工具函数、状态管理和复杂表单校验逻辑。这些部分是纯逻辑的,测试成本低、收益高。Zustand的store可以直接测试,不需要渲染组件。比如说用户store的权限更新逻辑:import { useUserStore } from '@/stores/useUserStore'; describe('useUserStore', () => { beforeEach(() => { useUserStore.getState().reset(); }); it('setPermissions should replace permissions list', () => { useUserStore.getState().setPermissions(['dashboard:view', 'orders:edit']); expect(useUserStore.getState().permissions).toEqual(['dashboard:view', 'orders:edit']); }); });工具函数和数据适配逻辑也很适合单测。比如金额格式化、日期转换、接口返回结构的适配,这些纯函数只要输入输出正确,测试就是稳定的。在接口数据适配层,我写出过一个比较典型的测试场景。某个老接口返回的金额字符串是"1,234.56",包含千分位逗号,前端拿到后需要转成数字并补齐精度。这个转换函数如果不测试,上线后一旦遇到特殊格式数据就是线上事故:export function parseAmountToNumber(input: string): number { const cleaned = input.replace(/,/g, ''); const value = parseFloat(cleaned); return Math.round(value * 100) / 100; } describe('parseAmountToNumber', () => { it('should handle thousands separator', () => { expect(parseAmountToNumber('1,234.56')).toBe(1234.56); }); it('should handle plain number string', () => { expect(parseAmountToNumber('42')).toBe(42); }); it('should handle integer without decimals', () => { expect(parseAmountToNumber('999')).toBe(999); }); });测试框架用的是Vitest,因为和Vite天然配套,不需要额外配置转换器。测试数量不要求多,但关键逻辑不能裸奔。5.2 CI流水线的搭建思路项目从第一天就接入了CI流水线。流程设计很直接:每次推送分支和合并请求时自动跑lint检查、类型检查、单元测试和构建。用GitHub Actions/Circle/GitLab CI的差别不大,关键是流水线要挡得住不合格代码合并,而不是走个形式。以下是我的流水线核心步骤:# 1. 安装依赖(使用lockfile保证一致性) npm ci # 2. ESLint检查 npm run lint # 3. 类型检查 npm run type-check # 4. 单元测试 npm run test -- --run # 5. 构建产物 npm run build这套流程看起来简单,但每条命令都对应一个强制性门槛。type-check这一步特别重要,很多团队只依赖编辑器自动报错,但CI里的强制执行保证了即使有人绕过编辑器,代码也过不了关。流水线里有一个值得分享的细节:构建产物检查。避免只验证“能构建成功”,还要验证“构建产物体积是否异常增大”。我写了一个小脚本,读取dist/assets下所有JS文件的大小,超过阈值就报警。这样某个依赖被误引入的时候,CI能第一时间发现。5.3 部署方案与回滚预案部署方案本来想上Kubernetes,但考虑到团队运维能力,最终选择了更加务实的方案:Nginx + 静态资源,通过CI流水线自动发布到对象存储或专门的静态服务器。原子部署是核心原则。发布流程是:新版本构建完成后,先上传到带版本号的目录,比如/app/releases/20250101-1230/,然后通过切换Nginx的软链接指向新版本,实现秒级切换。如果新版本有问题,只需要把软链接指回上一版本即可回滚。配套Nginx配置是这样的:server { listen 80; server_name rea.example.internal; root /app/releases/current; # 软链接,发布时切换 index index.html; location /api/ { proxy_pass http://backend-server:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } location ~* \.(js|css|png|jpg|gif|svg|ico)$ { expires 30d; add_header Cache-Control "public, no-transform"; } }这个配置里try_files $uri $uri/ /index.html是SPA应用必需的一行。没有它,用户在浏览器直接访问某个路由,比如/orders/123,Nginx会返回404。有了这一行,所有没有对应物理文件的请求都会回退到index.html,然后由前端路由接管。缓存策略上,带hash的静态资源(JS、CSS)适合长缓存,HTML文件不能缓存。所以我用expires 30d处理静态资源,Nginx对index.html默认走try_files且不设过期时间,确保每次发布后用户能拿到最新的HTML。这个细节做好了,发版后刷新页面才能看到新版本,否则会被浏览器缓存卡住半天。团队内部还约定了一条发布规范:发布窗口选择在业务低峰期,比如工作日的晚上或周末。表面上这是一个流程上的细节,但实际经历过发布后大量用户在操作、新版本有问题的惨痛教训后,这条规范成了硬性要求。6. 常见问题排查与实战避坑6.1 状态不同步的排查思路与解决过程这个项目上线运行两周后,遇到了一个最典型的疑难问题:用户在A页面修改了某条记录的数据,切到B页面后看到旧数据,必须手动刷新才正常。这类问题的本质是缓存一致性问题,排查思路比直接给方案更有价值。首先定位问题出现的范围。是只影响单个模块,还是全局都有?排查后发现所有列表页都有类似问题,这就排除了单个组件的bug,怀疑点集中到了数据获取层。TanStack Query的缓存机制默认会把请求结果缓存5分钟(staleTime默认0,gcTime默认5分钟)。当用户修改了某条数据,列表页的query缓存还保留着旧数据,并没有被自动刷新。这就是为什么从列表页进入详情页修改后,再返回到列表页,看到的还是旧列表。解决思路很明确:任何修改类操作完成后,必须让涉及到的queryKey失效并重新请求。我这边的统一方案是维护一个keys.ts文件,集中管理所有queryKey的定义:export const queryKeys = { orders: (params?: OrderQuery) => ['orders', params] as const, orderDetail: (id: string) => ['orderDetail', id] as const, };然后封装一个useInvalidateOrders自定义Hook:export function useInvalidateOrders() { const queryClient = useQueryClient(); return useCallback(() => { queryClient.invalidateQueries({ queryKey: ['orders'] }); }, [queryClient]); }业务代码里修改完订单后,调用一下这个Hook就行。这个方案看起来简单,但能把缓存一致性问题从源头上控制住。6.2 接口错误请求重复触发的排查记录第二个高频问题出现在网络状况不佳的时候:用户在表单里点提交,等了很久没有反馈,于是又点了一下,结果提交了两条重复数据。这个问题排查起来有难度,因为它涉及的是请求重复执行,而不是执行失败。根因有两层。第一层是前端没有做请求去重和防重复提交;第二层是后端接口本身不是幂等的,同样的请求重复提交会新增两条记录。前端方案我做了两层防护。第一层是按钮级别的loading状态,提交期间按钮禁用、显示loading,这是最直接有效的防重入手段:const [submitting, setSubmitting] = useState(false); async function handleSubmit(values: FormValues) { setSubmitting(true); try { await createOrder(values); message.success('创建成功'); } finally { setSubmitting(false); } } <Button type="primary" loading={submitting} onClick={handleSubmit}> 提交订单 </Button>第二层是请求拦截器层面的去重逻辑。axios拦截器里,对同一个地址的并发请求进行合并,只有第一个请求真正发出,后续相同请求直接复用第一个请求的Promise:const pendingMap = new Map<string, Promise<any>>(); function getRequestKey(config: InternalAxiosRequestConfig): string { const { method, url, params, data } = config; return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&'); } http.interceptors.request.use((config) => { const key = getRequestKey(config); const pendingPromise = pendingMap.get(key); if (pendingPromise) { return new Promise((_, reject) => { reject({ code: 'DUPLICATE_REQUEST', message: '重复请求已拦截' }); }); } const promise = axios.request(config).finally(() => { pendingMap.delete(key); }); pendingMap.set(key, promise); return promise; });注意这个方案要排除普通的GET请求,因为GET请求本身是幂等的,重复请求的代价低。真正需要去重的是POST和PUT。我的实现里会判断method,只对写操作生效。这里需要明确:前端防重复提交只是补救手段,后端接口设计成幂等才是根治。比如创建订单的接口可以接受一个前端生成的requestId,后端用这个字段做幂等键,重复请求直接返回第一次的结果。6.3 构建阶段内存溢出与体积失控的应对策略项目越做越大之后,迎来了构建层面的两个经典问题:内存溢出和构建时间飙升。第一个问题表现很直接:执行npm run build时,Node进程直接崩溃,报错JavaScript heap out of memory。排查原因是项目里某个页面引入了大量依赖,Rollup解析和打包时内存占用超过Node默认限制。解决方式是在构建命令中扩大内存:{ "scripts": { "build": "node --max-old-space-size=4096 node_modules/vite/bin/vite.js build" } }这只能缓解症状,真正的根治方案是拆包。把可独立加载的业务模块拆成独立的页面,同时把大依赖库单独chunk,降低单次解析的内存压力。配合前面的manualChunks配置,构建峰值内存从3.5GB降到了1.8GB左右。第二个问题是构建时间越来越长。随着业务代码膨胀,从最初的30秒逐步涨到三分钟。排查后定位到两个耗时点:一是TypeScript类型检查,二是打包压缩阶段。我的处理方式是:把类型检查从构建流程中抽离出来,放到CI流水线独立步骤执行。本地构建不再跑完整类型检查,节省大量时间。同时接入了vite-plugin-legacy的优化替代方案,使用esbuild做预压缩,构建时间从三分钟降到了一分钟以内。对一个频繁迭代的业务系统来说,构建速度就是开发体验的一部分。如果每次构建要等三分钟,开发者很快就会想办法绕过构建直接在本地跑,这其实是一种对工程规范的侵蚀。写在最后的一些经验体会代码写完之后,回头再看这个项目,我最大的感受是:工程化的意义不在于用了多少工具、写了多少规范,而在于让每个参与的开发者都能在可预期的节奏里推进功能,并且不会因为改动一小块地方导致另一块崩掉。有些细节确实是在实际踩坑之后才真正理解的。比如TanStack Query的缓存失效机制,文档里写得清清楚楚,但只有亲眼看到用户反馈“数据不对”时,才对invalidateQueries的价值有了切身体会。再比如虚拟滚动里滚动位置重置这种小事,不做也不会报错,但做了之后用户体验的差别是实打实的。这个项目后续如果能继续投入,我倾向于在几个方向扩展:把权限配置做成可视化管理页面,让运营角色可以直接调整角色权限;把通用表单组件进一步沉淀,让复杂表单的开发变成配置化组装;同时把前端监控(错误上报、性能指标)接入进来,让线上问题有更精准的定位手段。如果读者正在做一个类似的中后台迁移项目,最想给的建议是老生常谈但极其重要的一句话:先想清楚要解决什么问题,再选工具。Redux没有问题,Zustand也没有多高级,关键是适合团队现状和业务场景。工程方案没有银弹,小步快跑、持续迭代,比一步到位更加实际。