上个月我接手一个内部业务系统的重构需求,第一版是原型团队用低代码工具拖出来的,业务验证没问题,但做到第五个相似页面之后,维护成本开始失控。需求方把项目代号随手写成“rea”,我在需求文档里看到这三个字母的时候愣了一下:你说它是 read 少个 d,像是哪门子英语缩写,又像是某个框架名被截断了。后来和需求方碰了两轮需求,才确认这是一套面向运营人员的数据管理后台,核心功能有复杂筛选、数据列表、批量操作、权限配置、报表看板。听起来也不复杂,但页面数量多、交互频繁、状态关系绕,真正难的地方就在这里。
这篇文章会把整个项目从需求拆解到技术选型、核心实现、上线前后的坑完整讲一遍。如果你正在带中后台项目,或者准备从一个“能跑就行”的前端页面切到工程化程度更高的方案,这篇内容应该能帮上忙。我用“rea”作为项目代号,既是还原当初那个随手一写的名字,也顺便体现这套方案的核心精神:把界面理解为对数据的反应,数据流清了,视图自然就稳了。下面进入正题。
1. 项目背景:一个代号叫“rea”的项目,到底在解决什么问题
1.1 从三个字母说起:需求其实并不模糊
刚看到“rea”这个代号时,我差点以为需求文档没写完。后来整理业务资料,发现需求方内部一直这么称呼这个项目,因为最初的需求是从一个“实时数据看板”的诉求演化来的,缩写后变成了“rea”,所以就这么沿用了下来。名字虽然随意,需求却一点不模糊。我花了两周梳理业务流程,最后提炼出四个核心痛点:
第一,页面形态高度相似。权限管理、订单管理、用户列表、操作日志,全部是“筛选条件 + 数据表格 + 批量操作 + 详情抽屉”,如果不做抽象,后面每个页面都是复制粘贴再改改,代码量爆炸。第二,状态关系绕。筛选条件要跨页面保存,权限配置影响按钮显隐,报表数据要实时刷新,每一处都涉及多个组件联动。第三,权限模型很细。不光是路由级权限,连导出按钮、删除按钮都要按用户角色做显隐控制。第四,性能要求不低。表格要支持滚动加载,报表要处理上千条数据,首屏不能白屏太明显。
如果把这个项目理解成一个技术问题,那它真正的关键词不是“后台管理系统”,而是“可复用、可维护、高性能”。需求方其实不懂什么前端工程化,他们只关心加新页面能不能更快、改筛选条件会不会影响别的功能、高峰期打开页面会不会卡。我的任务,就是把这三个问题转化成一套能长期演进的技术方案。
1.2 给谁看,以及需要什么基础
我不打算把文章写成零基础入门教程,但也尽量让刚接触中后台项目的人能读懂。看完之后你至少能搞明白三件事:目录结构为什么按业务域切分;服务端状态和客户端状态为什么要分开管理;列表页、权限控制、首屏优化这些常见场景的代码到底怎么组织。
适读人群大概是这三类:第一,已经会用 React 写简单页面,想搞清楚工程化项目为什么那么复杂的同学;第二,团队里想从业务代码里提炼公共方案,但不想一上来就引入太重框架的开发者;第三,被“组件库用完就乱、状态到处飞”困扰,想做一次重构的技术负责人。前置要求不高,能理解 React 基础组件和 hooks 用法就够。TypeScript 不太熟也没关系,我会在代码示例里把关键类型标注出来,照着写就能跑,跑几次基本就熟悉了。
注意:文章里提到的“rea”只是我手头这个模拟项目的代号,不代表任何真实产品。写它的目的是用具体案例把工程化思路串起来,你完全可以把这个代号替换成自己项目的名字。
2. 整体设计与技术选型:为什么选了这套组合
2.1 技术栈全景:React 18、TypeScript、Vite、Zustand、TanStack Query
先给出最终技术栈,后面会逐个解释为什么这么选:
- 前端框架:React 18.2,开启 StrictMode
- 开发语言:TypeScript 5.x,开启 strict 模式
- 构建工具:Vite 5.x
- 路由方案:React Router v6 的数据路由模式
- 服务端状态管理:TanStack Query v5
- 客户端状态管理:Zustand v4
- 样式方案:CSS Modules + 少量设计变量
React 18 比 17 强在哪里?很多人第一反应是并发渲染,但中后台项目里真正带来体感提升的是自动批处理和useSyncExternalStore。React 18 之后,setState在大多数场景下会被自动批处理,连续多个setState不会触发多次组件渲染,复杂交互页面少掉很多无谓的重复渲染。React 18 配合 TypeScript strict 模式,在团队协作里作用更明显:组件 props 有类型约束,改一个字段名,所有用到的地方直接编译报错,不会等到上线前才发现漏改了。
构建工具我从 CRA 换成了 Vite。CRA 本身没什么大问题,但基于 webpack 的冷启动和热更新速度,对一个每天要频繁改页面的项目来说实在太熬人。Vite 基于原生 ESM,开发服务器启动快,改动后的热更新几乎是秒级,中后台页面组件又多又碎,这种体验差异会被放大很多倍。生产构建有 esbuild 和 Rollup 托底,产物质量和配置灵活度也够用。
2.2 选型背后的取舍:为什么不用 Redux,也不用整套晦涩架构
中后台项目最常见的状态管理方案是 Redux,但我在这次项目里刻意避开了。Redux 生态成熟、理念清晰,可是样板代码太多,一个异步列表请求要写 action、reducer、thunk,还要配 selector,一个小团队维护起来负担不小。Zustand 没有这些概念,写法接近普通的自定义 hook,也不用包 Provider,全局 store 直接导入就能用,团队上手成本低一个量级。可能有人担心 Zustand 太自由导致代码不规范,我的答案是,规范性靠约定和 Code Review 来保证,而不是靠框架本身。
数据请求层我用了 TanStack Query。这是整个架构里最关键的一环。列表数据、详情数据、字典数据本质上都属于服务端状态,它们的加载、缓存、失效、重试、分页处理,不应该每个页面自己用 useEffect 手写一遍。TanStack Query 把这一整套网络状态管理封装得很好,组件里不用再关心 loading、error、缓存命中,代码会干净很多。
为什么没有上微前端?我也认真考虑过。微前端解决的是多团队独立开发、独立部署的问题,不是性能问题。这个项目虽然页面多,但团队规模不大,没有跨部门发布的硬需求,硬拆微前端只会增加通信成本和故障边界复杂度。很多时候想把项目做成微前端,不是因为需要,而是看着架构图很漂亮,这个心态要避免。
2.3 目录结构:按业务域切分,而不是按技术类型切分
目录结构我采用了“按功能域划分”的方式,而不是传统的components、utils、types三大目录。传统结构初期很干净,但业务一多,每个组件的辅助文件散落得到处都是,后期要改一个业务模块,需要跨目录找半天的相关代码。功能域结构更像是让每个业务模块自治,我实际用的结构是这样:
src/ app/ # 应用入口、全局路由、布局 components/ # 真正跨业务共享的基础组件 features/ # 按业务模块划分,每个模块内部有组件/hooks/api hooks/ # 全局共享 hooks lib/ # 第三方库封装、请求客户端 stores/ # Zustand store types/ # 全局类型比如权限管理、订单管理分成两个 features 文件夹,各自内部的页面组件、筛选逻辑、请求接口都放在一起。这样的好处是,需求变更时基本只动对应 features 内部的文件,不会波及全局基础组件。代码 Review 时也更容易判断影响范围。
这种划分方式有一个容易走偏的地方:components目录不能变成垃圾桶。很多人会把“暂时不知道放哪”的组件塞进全局 components,两个月后这里就会变成几百个组件的集合,没人知道该不该复用。我的约定是,只有至少被两个业务模块用到的组件才有资格放进全局 components,否则就留在自己的 features 内部。
3. 核心功能实现:数据流、状态管理和基础组件
3.1 请求层封装:标准大于想象,克制大于封装
请求层我封装了一层很薄的 axios 实例,没有把接口地址、请求方法统统做成复杂函数库。过于夸张的封装,初期看着省事,后期需求一变化往往成为瓶颈。我的原则在lib/request.ts里:baseURL、超时、token 注入、统一错误提示、取消重复请求,其他业务逻辑留在 features 模块内部自己写。
下面是实际在用的请求封装骨架,业务字段已经隐藏:
import axios from 'axios'; export const request = axios.create({ baseURL: '/api', timeout: 10_000, }); request.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( (response) => response.data, (error) => { const status = error.response?.status; if (status === 401) { // 在这里只做最基本的通知,具体跳转交给路由层 } return Promise.reject(error); } );这段代码看起来很普通,但关键点在于“克制”。我见过有人把路由跳转、弹窗提示、权限刷新全塞进拦截器,结果任何一个环节出错,所有请求全部受影响。拦截器只负责最基础的 token 注入和统一错误边界,业务侧的错误提示放在调用方或 Query 的全局回调里,这样职责单一,排查问题也快。
3.2 服务端状态:用 Query 函数管理列表的加载状态
中后台最常见的页面就是“列表加筛选条件”,我直接把 TanStack Query 的实践写出来:
import { useQuery, keepPreviousData } from '@tanstack/react-query'; interface ListParams { page: number; pageSize: number; keyword?: string; status?: string; } function getList(params: ListParams) { return request.get('/list', { params }); } export function useListData(params: ListParams) { return useQuery({ queryKey: ['list', params], queryFn: () => getList(params), placeholderData: keepPreviousData, }); }queryKey 要把所有会影响请求结果的参数都放进去,只要参数变化,Query 会自动重新请求。placeholderData设为keepPreviousData之后,切换筛选条件时表格区域会保留上一页数据,而不是闪一个 loading 空白页。这个参数在分页场景里非常关键,用户翻页时视觉上几乎无感。
这背后的核心思路是:把网络数据当成一个可被缓存的服务端快照,而不是组件里的临时变量。组件里只需要拿到data、isLoading、isFetching,按需渲染即可。这就避免了传统写法里每个列表页都要写“isLoading + useEffect + catch 错误”的重复代码。
3.3 客户端状态:Zustand store 到底该放什么
Zustand store 很容易被滥用。我看到过很多项目把页面级临时状态也丢进全局 store,过了一两个月,store 变得臃肿不堪,完全分不清哪些数据是什么生命周期。我的约定是:只有“多个组件共享、且需要跨页面保留”的状态才放全局 store。一个弹窗的开关状态,用组件内部useState就好;从服务端拿到的数据,放 TanStack Query 管理,不要复制一份放进全局 store。
实际项目里我用 Zustand 管理筛选条件草稿和用户信息:
import { create } from 'zustand'; interface AppState { user: { id: string; name: string; permissions: string[] }; draftFilters: Record<string, unknown>; setUser: (user: AppState['user']) => void; setDraftFilters: (filters: Record<string, unknown>) => void; } export const useAppStore = create<AppState>((set) => ({ user: { id: '', name: '', permissions: [] }, draftFilters: {}, setUser: (user) => set({ user }), setDraftFilters: (draftFilters) => set({ draftFilters }), }));使用的时候有一个性能细节:组件里读取 store 数据尽量用单个字段的 selector,不要一次性取回一个大对象。Zustand 底层依赖useSyncExternalStore,并发渲染下很稳定,但如果你把整个 store 对象作为 selector 结果返回,任何字段变化都会让组件重新渲染。这个问题在项目初期不明显,页面一多就会感受到交互卡顿。
3.4 路由与权限:从路由级到按钮级的下沉控制
权限控制是后台系统绕不开的需求。我的做法是路由表在应用初始化时,根据当前用户的权限码动态生成,而不是把所有路由都写死在配置文件里。按钮级权限我用一个统一的权限判断函数和<Can>组件来处理,避免每个业务组件里到处写permission.includes。
路由动态生成的示意代码:
import { createBrowserRouter, Navigate } from 'react-router-dom'; type RouteConfig = { path: string; element: React.ReactNode; permissionCode?: string; children?: RouteConfig[]; }; function filterRoutesByPermission(routes: RouteConfig[]): RouteConfig[] { return routes .filter((route) => { if (!route.permissionCode) return true; return hasPermission(route.permissionCode); }) .map((route) => ({ ...route, children: route.children ? filterRoutesByPermission(route.children) : undefined, })); } export const router = createBrowserRouter( filterRoutesByPermission([ { path: '/dashboard', element: <DashboardPage />, }, { path: '/orders', element: <OrdersPage />, permissionCode: 'order:view', }, ]) );这里有个容易踩的坑:动态路由的注册时机。用户刷新页面后,路由表需要重新构建,这个过程必须在权限检查之后、页面渲染之前完成,否则会出现“先渲染一个无权限页面,再跳转登录页”的闪屏。我的做法是在入口处用一个加载态组件包住整个应用,等权限数据准备好后再渲染真正的路由。
按钮权限我抽了一个小组件:
function Can({ code, children }: { code: string; children: React.ReactNode }) { if (!hasPermission(code)) return null; return <>{children}</>; } // 使用方式 <Can code="order:export"> <ExportButton onClick={handleExport} /> </Can>这个组件很薄,但把所有权限判断集中到一处,后续如果权限模型要调整,只改一个地方就够了。这个收益比想象中大得多,因为业务页面一旦多起来,权限逻辑会越埋越深,后期非常难统一处理。
3.5 Suspense 与错误边界:不要把加载问题全推给 useEffect
React 的 Suspense 和错误边界需要尽早引入。传统写法是组件渲染后用useEffect去加载数据,再内部维护 loading 状态,这样每个页面都有一套相似代码,稍有不慎就出现请求竞态:先发起的请求后返回,把后发起请求的结果覆盖了。TanStack Query 搭配 Suspense 能解决大部分加载态问题,组件只要正常读取data,数据没准备好时自然展示 fallback。
当然,Suspense 也不是全场景适用。如果某个列表组件的数据只在特定交互后才出现,一开始就 fallback 加载,反而会让页面多一次无意义的“闪加载”。我最终只在路由级和详情页级整页加载时用了 Suspense,列表内部的加载交给组件区域性的 loading 状态。这样既保持代码统一,又不会牺牲交互流畅度。
错误边界方面,我给应用包了一个全局 ErrorBoundary,捕获渲染阶段未预期的异常,展示一个友好的错误页,并把错误信息上报。这不会解决所有 bug,但至少能避免用户面对白屏,也能给开发留下日志线索。
4. 实操过程:从零把一个“rea”项目跑起来
4.1 环境准备:固定 Node 版本,用 pnpm 管理依赖
开始之前先把环境固定下来。很多“在我机器上明明能跑”的尴尬局面,都是 Node 版本不一致导致的。我推荐用 Volta 管理 Node 版本,在项目里标记需要的版本号,团队成员切到项目目录时工具会自动切换。依赖管理我用 pnpm,安装速度快,磁盘占用小,也能从机制上避免一部分“幽灵依赖”的心智负担。
# 初始化项目(这里用 Vite 的 react-ts 模板) pnpm create vite rea-demo --template react-ts cd rea-demo pnpm install注意:pnpm 搭配 React 项目偶尔会遇到依赖重复的问题,表现是同一个包被安装到不同层级,出现类型冲突或运行时双实例问题。遇到这类情况,先检查根目录的
.npmrc是否缺少install-peers=true。这是我在多个项目里踩过的实际教训。
4.2 基础配置:TypeScript 严格模式和路径别名
TypeScript 我直接开strict。中后台项目业务复杂,不做严格类型检查的后果是,重构时所有隐患集中爆发。路径别名让业务代码里少写../../..这类看着就头疼的相对路径,提高代码可读性。
// tsconfig.json 关键片段 { "compilerOptions": { "strict": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }配置别名时,TypeScript 和 Vite 两边必须同步,否则类型检查认为路径能解析,运行时却找不到模块。Vite 侧设置:
// vite.config.ts import { defineConfig } from 'vite'; import path from 'node:path'; export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, }, });这种配置十分钟就能完成,但能极大改善后续开发体验。我把路径别名当作项目基础设施的一部分,从一开始就配好,不要等代码写多了再回头改,那时候全局替换的代价会让人非常痛苦。
4.3 核心场景落地:一个带分页和筛选的列表页
列表页是所有后台系统的高频场景,把这个吃透,比做十个不同页面更有价值。整个数据流的实现分三步。
第一步,在页面组件里维护筛选参数和分页参数。列表页的状态分三类:服务端数据本身由useListData管理,当前筛选和分页参数由页面组件管理,弹窗开关这类临时状态用组件内部useState就够了。
import { useState } from 'react'; import { useListData } from '@/h/useListData'; export function ListPage() { const [params, setParams] = useState({ page: 1, pageSize: 10, keyword: '', status: 'all', }); const { data, isLoading } = useListData(params); const searchByName = (keyword: string) => { setParams((prev) => ({ ...prev, keyword, page: 1 })); }; return ( <div> <SearchBar onSearch={searchByName} /> <ListTable data={data?.items ?? []} loading={isLoading} /> <Pagination current={params.page} total={data?.total ?? 0} pageSize={params.pageSize} onChange={(page) => setParams((prev) => ({ ...prev, page }))} /> </div> ); }注意每次变更筛选条件时,我都把page重置为 1。这是列表页最常见的 bug 来源:人在第 5 页,输入搜索关键词,结果列表仍显示第 5 页的数据,用户以为搜索没生效。这类问题不报错,但非常影响使用体验,务必在写筛选逻辑时统一处理。
第二步,把筛选条件通过 Zustand 保存草稿。有些业务场景里,用户填了一半的筛选条件,切到别的菜单再切回来,希望还能保留。这个状态我放在全局 store 里,而不是页面内部的 useState。
第三步,把查询逻辑收敛到useListData这个自定义 hook 中。所有页面直接调用,不重复写查询参数组装逻辑,也不重复处理 loading 和错误。如果后续要对列表加缓存、加轮询,只改一个 hook 即可,所有页面自动生效。
4.4 编译构建与产物优化
Vite 构建默认做得好,但中后台单页包体积仍可能在不知不觉中膨胀。开发收尾阶段,我会先跑一次pnpm build,再用构建产物分析工具看看是哪个包占了大头。常见套路是把 echarts、moment、lodash 这类体积大的库做按需加载,或者使用动态导入把详情页、报表页变成按需加载模块。
const ReportPage = lazy(() => import('./features/report/ReportPage'));配合 Suspense 包裹路由级组件:
<React.Suspense fallback={<PageLoading />}> <ReportPage /> </React.Suspense>这样用户不进入报表模块,浏览器就不下载相关 JS,首屏加载时间可以明显缩短。我在实际项目里用这种方式把报表模块从主包里拆分后,首屏 JS 体积减少了接近 30%,白屏时间从 2 秒左右降到了 1 秒以内。性能优化的核心是让用户只加载当前需要的代码,不要把所有页面都塞进一个巨大的 bundle。
5. 上线前后的常见问题和排查技巧实录
5.1 重复请求和竞态:往往不是接口慢,而是代码写错了
我遇到最多的问题是“筛选条件变更后出现两次请求”。排查下来大部分是 queryKey 设置不严谨。比如 queryKey 里只放了 keyword,没放 page,那么分页变更时 TanStack Query 认为数据没变,就不会重新请求。另一种情况是,在useListData外面又手动调用了一次接口,导致同一个请求发了两遍。解决思路很简单:请求只通过useQuery的 queryFn 发起,不要再在其他地方手动调用同一个数据接口。
竞态问题是另一种形态。典型现象是快速切换筛选条件时,后一个请求先返回,前一个请求后返回,结果旧数据把新数据覆盖了。理论上先发起的请求可能后返回,因为接口响应时间不稳定。TanStack Query 会为同一个 queryKey 管理请求的取消逻辑,但如果业务代码里直接使用 axios fetch,就需要自己处理 AbortController。我的建议是,凡是通过useQuery管理的请求,不要再去手动取消;不在 Query 体系内的请求,一定要传signal。
5.2 状态更新导致的无限循环
React 18 自动批处理之后,有些状态循环问题更容易踩到。典型场景是一个组件读取 Zustand store 里的对象,然后在useEffect里调用setUser,setUser之后 store 对象引用变了,useEffect又依赖了对象,于是循环触发请求或渲染。解决办法是:useEffect依赖不要用整个 store 对象,只依赖需要响应变化的字段;setState的写法尽量用 updater 函数,避免闭包拿到旧值。
还有一个现象是 React 18 StrictMode 下,开发环境会故意执行两次渲染和挂载,某些请求也会发两次。这是正常行为,用来帮助发现副作用隐患。不要试图通过去掉 StrictMode 来“修复”,而是检查自己的代码是否存在不规范的数据获取时机。
5.3 TypeScript 严格模式下的未知错误处理
严格模式下,catch到的 error 类型是unknown,直接读error.message会报类型错误。许多人下意识用(error as any).message处理,我并不是很赞成。比较稳的做法是封装一层错误信息提取函数:
function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; return '未知错误'; }这段代码看起来简单,在统一错误提示和日志上报里非常有价值,能避免前端页面因为 error 字段结构异常直接崩溃。前端捕获到的错误来源很杂,有时是接口返回的特定格式,有时是浏览器原生异常,有时是路由转换错误,归一化到 string 输出后,所有错误边界都能统一展示。
5.4 首屏加载和白屏问题:先看证据再优化
上线前查性能时,我打开 DevTools 发现首屏加载里有一张图片资源直接从 node_modules 里引出来,600KB 还没压缩。这种问题用肉眼看代码很难发现,必须看网络请求列表。我的排查顺序是:先看网络面板有没有明显的大文件和无用请求;再看首屏渲染有没有长时间的空 loading;最后才考虑代码层面的懒加载、分包、接口合并。
对中后台项目而言,减少首屏时间最有效的三个手段通常是:路由级代码按需拆包、UI 组件库按需引入、静态资源走 CDN 并开启压缩。不要一上来就考虑微前端或复杂缓存,性价比太低。等真实数据证明哪里慢了,再针对性地优化。
6. 从“rea”项目沉淀下来的东西,以及后续扩展方向
6.1 这套方案适用边界:什么项目别照搬这套架构
上面整套思路并不适合所有项目。如果一个项目只有三五个页面、生命周期可能只有几个月,那直接用单文件组件配合简单状态管理就很好,强行引入 TanStack Query、Zustand 和复杂目录,只会增加学习成本和沟通成本。架构要跟着团队规模和产品演进速度走。三个人以上、持续做类似后台业务的团队,这套方案的效果会开始显现。如果团队只有一个人、项目是一次性的,重点应该是业务理解而不是架构炫技。
我也经常提醒团队,不要为了“以后可能会用到”的假想需求做过度设计。比如项目里目前只有一个下拉选择项需要异步数据,就没必要马上给整个组件库封装异步 Select。等到第三个相似场景出现时,再抽通用组件也完全来得及。
6.2 组件库与设计规范怎么沉淀
项目稳定后,我开始整理组件库。不是去购买或引入一套现成的组件库,而是把 features 里重复出现的业务组件抽出来,比如“高级筛选器”“详情抽屉”“批量操作栏”“权限按钮”。每个组件分基础层和业务层:基础层只负责交互、样式和可访问性,业务层绑定特定接口和权限语义。
这样做最大的收益是,新同事接手项目时不用翻旧页面,只需要看组件文档,就知道哪些场景有现成的实现。这个过程中我强烈建议搭配 Storybook 或类似的组件文档工具,让组件脱离页面也能独立查看、测试和联调。不过要记住,抽组件不要为了“复用”而硬抽。如果两个页面形态相似但业务逻辑差别很大,硬塞到一个组件里只会让组件接口爆炸,最后没人敢改。三个相似需求出现之前,保持复制粘贴反而可能是更理智的选择。
6.3 测试、监控和持续迭代
这版架构的后续迭代,我重点关注三个方向:测试覆盖、监控埋点、研发流程。测试我先补 hooks 层和工具函数层的单元测试,因为这里最容易隐藏回归风险。核心流程比如权限判断、列表查询参数组装、错误信息归一化,都值得写单测。组件测试用 React Testing Library,只覆盖关键交互,不追求“100% 覆盖率”,因为虚假的覆盖率没有意义。
监控方面,我给前端加了全局错误捕获和页面性能上报。中后台最容易出现的问题是“某个接口偶发超时,用户操作没反应”,如果没有监控,问题很难复现。后续可以接更完整的前端监控方案,把 JS 异常、请求失败率、首屏渲染时间都纳入指标看板。开发流程上,我坚持让代码合并前跑一遍 lint 和类型检查。这个过程看似阻断了一些“小提交”,但能避免整个代码库在两个月后进入“不敢重构”的状态。
提示:我踩过最深的一个坑,是一开始做了大量性能优化,后来发现真正拖慢体验的是后端某个接口返回了几百条没有分页的数据。性能优化一定要先从真实的数据链路里找证据,不要先猜,否则方向很容易错。
6.4 如果重新做一次,我在第一天就会做的事
如果现在让我回到项目第一天,我会在前面三件事上多花时间。第一,把 TypeScript 严格模式、路径别名和目录结构一次性定好,不要再留“以后再说”的口子。第二,把所有接口和数据类型先定义出来,哪怕页面还没开始写,类型先行能让团队沟通更顺畅。第三,把 TanStack Query 的 queryKey 约定写进团队文档,避免后面每个人按自己的习惯写 key,导致缓存失效和重复请求。
说实话,代码本身不难写,难的是决定每段代码该住在哪里。架构没有终局,也没有放之四海而皆准的最佳方案。这套“rea”项目的写法,最大的意义不是提供了一个模板,而是让我重新理解了状态边界、请求边界和组件边界之间的关系。踩过几次坑之后,我慢慢形成了一种直觉:看到一个新需求,先问数据从哪来、存在哪、谁消费它,然后才决定页面怎么写。如果你手头也有一个越改越乱的项目,我建议先别急着加更多状态管理工具,而是把现有代码的数据流画一遍,把重复请求和乱放的查询逻辑清干净。你会惊喜地发现,很多问题不是能力问题,只是结构问题。