看到标题里孤零零的rea三个字母我第一反应是 React 的前三个字符。正巧上个月我就在做一台老后台系统的改版把原来靠 jQuery 拼 HTML 的页面整体迁到 React TypeScript 上这篇文章就当是那个过程的复盘顺带把一些踩过的坑重新梳理清楚。如果你正准备从一个传统多页工程切换到 React 技术栈或者已经在用 React 但总觉得代码有点乱、性能有点卡那这篇内容应该能给你一些可以直接抄作业的思路。我始终觉得做前端重构这事儿最难的从来不是写代码而是想清楚为什么要这么写。所以整篇文章会围绕几下几个问题展开思路怎么定、细节怎么抠、实操怎么做、坑怎么填。没有教科书式的理论全是我自己动手改过的经验记录。1. 项目整体设计与思路拆解从rea联想到 React 之后我很快把这件事确认成了“某个老旧后台管理系统的 React 化改造”。这种项目在开发里太常见了特点就是业务逻辑不复杂但页面多、交互碎、老代码堆了五六年。当初那个后台用的是 jQuery 加服务端模板一个页面把 HTML 拼成字符串塞进容器改一个筛选条件就要刷新整页数据量一大浏览器直接卡成幻灯片。1.1 核心需求与改造目标当时拿到的需求其实只有一句话把系统的用户管理、订单查询、数据报表三个模块迁移到新框架要求是“能用、不卡、好维护”。听完这种需求别急着动手先拆目标交互体感必须提升原来每次操作都白屏刷新现在要局部更新筛选、翻页、弹窗都要丝滑。代码结构必须能持续迭代后续有好几个新功能要基于这套代码扩展不能今天迁完明天就变成新的“老系统”。团队上手成本要低组里有人只写过 Vue有人还在用 jQuery所以选型不能太激进API 要稳、生态要成熟。围绕这三点技术栈才定下来。不是说 React 一定比别的框架好而是对于这个场景React 的组件模型、状态管理方式、周边库的成熟度都能对得上。1.2 技术选型背后的取舍有人会问为什么不直接上 Vue其实组里确实有 Vue 熟手但核心评估点在于目标系统要长期维护招聘市场、社区活跃度、第三方组件库丰富度这些因素都要考虑。相比之下 React 的生态覆盖面更广遇到奇奇怪怪的需求总有现成方案可以抄。TypeScript 这件事我没有任何犹豫直接加进去。老系统最痛苦的地方就在于接口返回的数据没人敢动因为不知道改了这个字段会影响哪个页面的哪段逻辑加类型等于给数据流画了一张地图。状态管理方面新项目的状态量其实不大就是登录信息、路由状态、几个跨页面共享的筛选条件。所以一开始只用 Context 加 useReducer没有引入 Redux。很多新项目一上来就配全套 Redux Toolkit结果大多数 state 都只在一个页面内部流通纯粹给自己找麻烦。轻状态用轻方案等真的出现多模块强依赖的复杂状态再升级也不迟。构建工具从 webpack 换成了 Vite。老的 webpack 配置已经被前任维护者改得千疮百孔每次启动都要七八秒热更新遇到大文件还会崩。Vite 基于 esbuild 预构建依赖冷启动基本一两秒搞定开发体验完全不在一个层级。生产构建 Vite 打包器用的是 Rollup一样可以做代码分割和静态资源优化。1.3 为什么把“可维护性”放在性能前面这个判断在项目里特别重要。最初有人提方案说用模板引擎拼接字符串渲染速度会更快但页面一旦复杂起来拼接代码的维护成本是灾难级的。React 的声明式组件让 DOM 操作从“命令式指令”变成“状态映射”维护者看到组件代码就能知道它渲染什么而不是去猜某一段innerHTML是哪个流程塞进去的。性能上当然也有考量。React 核心的虚拟 DOM 机制并不是“绝对比原生操作快”绝大多数时候它是“足够快且可控”。对于后台系统这种中低并发、交互高频但数据量有限的场景虚拟 DOM 的损耗可以忽略不计而组件化带来的可读性提升是实打实的。所以整个改造的核心思路可以浓缩成一句话把页面拆成组件把接口收进服务层把状态约束在边界里让任何一个人接手代码时都能在十分钟内定位到“要改的东西在哪个文件”。2. 核心细节解析与实操要点设计阶段定了大方向真正动手的时候细节才最折磨人。下面挑几个我觉得最值得拿出来说的点都是那种“文档里不会写但实际项目里一定会遇到”的东西。2.1 组件拆分的边界到底怎么定组件拆分是 React 项目里最玄学的问题之一。拆太粗一个页面一个大组件几百行 JSX 堆在一起跟以前写长函数没什么区别拆太细组件之间 props 像传纸条一样隔几层传一个值改个字段全局飘红。我这次总结下来的经验是按“会不会被复用”和“职责是否单一”两条原则来切。具体做法是这样的。页面级组件只负责数据获取和整体布局比如用户管理页面它知道要调哪个接口、总共有哪些功能模块但表格每一行怎么渲染它不关心。业务组件负责一块完整的业务能力比如“筛选栏”“数据表格”“分页器”它接收约定好的 props内部完成自己的交互逻辑。纯展示组件则是最小的可复用单元比如状态标签、操作按钮组、头像这些组件不包含任何业务语义。一个反面教材是订单查询页的筛选条件。最开始我把筛选栏拆成了十几个原子组件每个字段一个组件结果传参传到心态爆炸。后来重构时我把同一行内联的几个字段合成一个“订单筛选组”内部自己管理字段布局对外只暴露“提交筛选参数”和“重置筛选参数”两个回调整个调用面的复杂度立刻降下来了。2.2 Hooks 使用时的三个深坑Hooks 用起来简单用好了很难。我这次踩坑最痛的三个点值得单独拿出来说第一个是useEffect的依赖数组闭包问题。第一次写列表页的时候我在 effect 里读了 props 上的查询参数忘了写依赖项结果参数变了界面不刷新排查了一个多小时才发现是闭包捕获了上一次渲染的值。这个问题的本质是effect 的回调函数捕获的是它定义时那一次渲染的数据依赖数组就是告诉 React “什么时候需要重新创建这个函数”。解决办法很简单所有在 effect 里用到的变量都写进依赖数组但如果依赖变化太频繁又要配合useCallback来控制 effect 执行次数。第二个是useCallback和useMemo的滥用。我在做性能优化之前先看了下代码发现几乎每个函数都包了useCallback每段计算都包了useMemo但这种包裹本身是有成本的——它要额外维护依赖数组、做浅比较。真正的优化原则只有一条先测量再优化。用 Chrome DevTools 的 Performance 面板录制操作过程看到明显的重复渲染卡顿再去加缓存。如果组件本身很轻、树很浅那不加任何缓存反而更快。第三个坑是自定义 Hook 的抽象时机。我第一次写的时候把“发起请求 管理 loading 处理错误”的通用逻辑抽象成了useFetch听起来很美好但真正用起来发现不同页面对数据处理的差异太大有的要轮询有的要缓存有的要拼接本地数据强行套一个通用 Hook 反而让代码变得抽象难懂。后来我调整策略只对“状态管理”做抽象比如把筛选表单的状态和分页状态封装成自定义 Hook而请求逻辑保持显式写在页面或业务组件里一眼看过去就知道数据从哪来、怎么处理。2.3 样式方案与主题切换的实现细节样式方案我最终选了 CSS Modules 加 CSS 变量说几个实际理由。后台系统有大量状态类样式比如标签的不同颜色、按钮的不同类型CSS Modules 的局部作用域能防止样式污染而 CSS 变量在切主题的时候是天然的适配方案。主题切换的玩法说穿了就是通过>:root { --bg-primary: #ffffff; --bg-secondary: #f5f7fa; --text-primary: #1f2329; --text-secondary: #646a73; --brand-color: #1677ff; } [data-themedark] { --bg-primary: #141414; --bg-secondary: #1d1d1d; --text-primary: #e5e6eb; --text-secondary: #86909c; --brand-color: #4c8dff; }所有组件里都引用变量不写死具体色值。切换时只需要在根节点上修改 attributeconst toggleTheme (theme: light | dark) { document.documentElement.setAttribute(data-theme, theme); localStorage.setItem(theme, theme); };这里有个经验刷新以后主题会弹回默认值所以初始化的时候要读一次localStorage。而且要在 React 渲染前就把主题属性挂上否则能看到白屏到暗色的闪烁加个行内脚本在public/index.html里执行能解决这个问题。3. 实操过程与核心环节实现思路聊完下面是整个项目从零搭建到核心功能落地的一个完整过程记录。我尽量按实际执行的时间线来讲包括命令、代码、配置和中间遇到问题的处理方式。3.1 项目初始化与工程配置项目是用 Vite 的 React TypeScript 模板初始化的。执行完创建命令后我先做了一组常规但很重要的配置调整。第一件事是路径别名。这是个高频需求以前老代码里../../../../utils/request这种路径看得人头皮发麻新项目必须解决。我改了两处// vite.config.ts import path from node:path; export default defineConfig({ resolve: { alias: { : path.resolve(__dirname, src), }, }, });然后在tsconfig.json里也对应加上 paths 配置让 TypeScript 能识别别名。这一步必须两边同步配只配 Vite 的话编辑器会报红只配 TS 的话构建会报错。第二件事是 ESLint 和 Prettier 的能力拆分。很多模板默认把两者绑在一起但实际体验下来格式化直接用 Prettier代码检查用 ESLint职责分开以后反而少了很多莫名其妙的冲突。ESLint 这边我保留了react-hooks/recommended规则它会直接警告依赖数组写错的地方这个规则省了我后续排查的时间。第三件事是目录划分。我采用按模块和按功能混合的组织方式src/ ├── api/ # 接口请求定义 ├── components/ # 全局共享组件 ├── hooks/ # 通用自定义 hooks ├── layouts/ # 布局组件 ├── pages/ # 页面级组件 ├── router/ # 路由配置 ├── types/ # 全局类型定义 ├── utils/ # 工具函数 └── styles/ # 全局样式和变量这种结构的好处是新需求进来时开发能快速判断代码该放在哪个目录避免所有东西都堆在components里。3.2 路由与页面懒加载配置路由用的 React Router v6配置方式跟 v5 差别挺大。新版本把Route改成了声明式嵌套我这次设计了一套带权限拦截的嵌套路由关键配置如下const router createBrowserRouter([ { path: /login, element: LoginPage /, }, { path: /, element: Layout /, children: [ { index: true, element: Navigate to/dashboard replace / }, { path: dashboard, element: lazyLoad(() import(/pages/Dashboard)), }, { path: user, element: lazyLoad(() import(/pages/User)), }, { path: order, element: lazyLoad(() import(/pages/Order)), }, ], }, ]);lazyLoad这里做了一层包装内部用React.lazy加Suspenseconst lazyLoad (load: () Promise{ default: React.ComponentTypeany }) { const Component React.lazy(load); return ( Suspense fallback{PageLoading /} Component / /Suspense ); };性能上的收益是页面代码按需加载首页不再背着三个大模块的 JS 一起下。老项目首屏资源有 3MB 左右改造完以后的订单页路由单独分包首屏只加载跟登录、布局相关的代码体感能差出一倍以上。3.3 服务端分页表格的完整实现后台系统里最核心的功能就是表格。这次做的表格组件花了最多心思因为要同时处理“搜索条件变化时重置页码”和“翻页时保持搜索参数”的联动问题。最终方案是把筛选条件和分页参数合并成一个 query 对象统一状态管理同时同步到 URL 的 query 参数上。这样页面刷新以后所有筛选条件都还能保留分享链接也能还原列表状态是后台系统里一个非常实用的小技巧。实现思路是这样const [query, setQuery] useStateQueryState({ page: 1, pageSize: 20, keyword: , status: , }); const updateField (patch: PartialQueryState) { setQuery(prev ({ ...prev, ...patch, // 任何非分页字段变化都重置到第一页 ...(!(page in patch) ? { page: 1 } : {}), })); };列表数据的加载统一走一个fetchList请求函数query 一变化就触发请求。翻页、筛选、重置这几个交互行为本质都是修改 query 状态数据加载逻辑不需要重复写。踩过一个很实际的坑筛选条件里的日期范围组件修改的时候会连续触发两次onChange一次是开始日期一次是结束日期。如果每次都直接发请求等于一次操作打了两个接口。解决办法是引入了一个 300ms 的防抖const debouncedQuery useDebounce(query, 300); useEffect(() { fetchList(debouncedQuery); }, [debouncedQuery]);防抖的副作用是表格会出现短暂的“延迟感”我的取舍只在关键词输入这种高频操作上加防抖普通的下拉筛选保持即时请求。这样既不浪费接口请求又能保证交互反馈的及时性。3.4 构建优化与资源分包配置项目功能跑通以后我开始优化生产包体。Vite 默认配置能直接用但需要按业务情况调整。我这边用的是// vite.config.ts build: { rollupOptions: { output: { manualChunks: { react-vendor: [react, react-dom, react-router-dom], antd-vendor: [antd, ant-design/icons], }, }, }, }为什么这么分react-vendor 是基础运行时基本永远不变适合稳定缓存antd 体积大独立成一个 chunk 避免跟业务代码混在一起。这样业务代码频繁更新时浏览器只需要重新下载业务包基础库都走强缓存。静态资源处理也很关键。系统里的图片资源以前都是直接打包进 JS改成在 Vite 里设置build.assetsInlineLimit为 4096小于 4KB 的图片转 base64 内联大图片输出到独立目录并用路径引用。这一步操作简单但效果立竿见影首屏请求数能下降一截。4. 常见问题与排查技巧实录真正开发过程中再怎么小心都会遇到问题我把这次改造遇到的高频问题整理成一个排查清单方便以后照着查。4.1 接口数据缺失导致页面白屏典型场景是表格里的某个字段要读取record.address.city但后端返回的数据里address是null于是一整行直接崩溃。React 组件树一个错误都没处理的话白屏就来了。我的排查思路分两步。第一步是在开发环境打开 React DevTools查看组件树具体哪一层抛错错误边界的作用就在这它能把渲染错误拦截成一条显眼的提示而不是整棵树崩掉。第二步是给核心数据流加类型守卫不要在组件层到处用可选链interface UserItem { id: number; name: string; address?: { city: string; } | null; } const getCity (user: UserItem) { return user.address?.city ?? -; };关键原则是脏数据在数据入口统一处理不要让每个组件都背负防御式判断。4.2 构建后路由刷新 404这个坑几乎每个 React 项目都会遇到一次。本地开发访问/order/123正常部署到服务器以后一刷新就 404。原因很简单浏览器直接向服务器请求/order/123服务器没有这个文件路径如果它不配置回退规则就会返回 404。React Router 的createBrowserRouter使用的是 History API这需要服务器把所有路由都重写到index.html。我在部署环境里配置的是 Nginx 的回退规则location / { try_files $uri $uri/ /index.html; }如果是其他平台服务器要找对应配置。实在不方便配服务器的话也可以改用createHashRouterURL 里多个 # 号但那不优雅我们只是拿它当兜底方案。4.3 Vite 开发服务器热更新偶发失效这个问题最玄学查了半天。现象是改某个文件以后页面不自动更新控制台也没有报错。最后发现根源是那个文件里动态import了一个无扩展名的模块Vite 在解析路径时命中缓存失败导致依赖图更新异常。解决办法是清理 Vite 的缓存目录rm -rf node_modules/.vite以及把动态导入的路径写成完整带扩展名的形式。这里也想提醒一句遇到热更新失效先清缓存再谈其他大概率五分钟内解决不要一开始就怀疑配置问题。4.4 组件重复渲染导致列表卡顿表格组件在输入关键字的时候明显掉帧打开 Performance 面板录制才发现每敲一个字母整个表格都重新渲染了一遍。原因是筛选栏的输入框状态挂在列表页输入框每次变动都会导致列表页重新渲染进而让表格数据组件也跟着渲染。优化方案很简单把关键字输入状态提升到了子组件内部然后通过防抖回调把最终值传给父组件。这样输入过程中只有输入框自己在重渲染表格不会被打扰。这个经验很重要性能优化不是一味加 memo有些情况下改变状态位置才是正解。4.5 常见问题速查表问题原因处理方式页面白屏接口返回 null 或字段缺失数据入口加类型守卫组件加错误边界刷新 404服务器没有路由回退Nginx 配置try_files或改用 hash 路由热更新失效Vite 缓存异常或动态导入解析失败删.vite目录补全文件扩展名表格卡顿状态位置太高频繁触发重渲染把高频状态下沉到子组件防抖回调样式不生效CSS Modules 文件名或类名冲突检查类名是否用了global或嵌套层级太深接口重复请求useEffect 依赖项未正确设置审查 effect 依赖数组用useCallback稳定引用最后再分享一点个人体会这套改造做完以后最大的收获不是代码写得有多漂亮而是想明白了一件事重构的本质是给未来的维护者留一条活路。每一个命名、每一层抽象、每一次性能取舍背后都应该有可解释的理由。我常跟组里新人说如果一段代码被问“为什么这样写”时只能回答“模板就是这么生成的”那这段代码就是需要被改写的代码。关于 React 的学习路径我的建议是从写一个完整的小功能入手不要停留在看文档和跑 Demo 的程度。只有在做真实功能的过程中你才会遇到闭包陷阱、状态同步问题、路由部署 404 这些文档里不会告诉你的细节。遇到问题也别慌打开 DevTools 看报错看 Network 面板看组件树大部分问题都能定位到根因。rea这个标题本身现在回头看还挺贴切的一个听起来不太起眼的小项目剥开以后发现里面全是值得记录的 React 实战经验。如果你也在做类似的老系统迁移希望这篇复盘能让你少踩一些我踩过的坑。