1. 项目代号"rea"背后的需求拆解与整体设计
1.1 为什么叫"rea":我给这个前端重构项目定的内部代号
先说个背景。当时某公司内部有一套运营管理后台,旧代码是用老技术栈写的,维护成本越来越高——改一个按钮的样式要翻半天文件,加一个新页面要复制粘贴一整块模板,而且页面一多,首屏加载速度肉眼可见地变慢。团队内部讨论了几轮,结论很统一:与其继续打补丁,不如用新技术栈把前端整体重写一遍。
这个重构项目当时需要一个内部代号,方便在需求文档、代码仓库、沟通群里统一称呼,我就随手写了个"rea"。其实没有特别深刻的含义,可以理解成"React Engineering Application"的缩写,也可以当成"Really Enhanced App"来调侃。反正它就是一个内部代号,真正要解决的事情很明确:用React把旧的前端整套逻辑重写清楚,让页面更快、代码更好维护、后续加需求不再像走迷宫。
这篇博文想把整个过程的思路和细节记录下来,包括需求拆解、技术选型、组件设计、状态管理、实操代码、性能优化、部署上线,还有我踩过的那些坑。如果你是前端开发者,或者正打算把一个老旧项目用现代技术栈重写一遍,这篇文章应该能给你一些可参考的路径。哪怕你还没接触过React,跟着我的思路走一遍,也能明白一个前端重构项目从0到上线到底要经历些什么。
1.2 需求拆解:先把业务场景读明白再动手
接手旧项目之后,我第一件事不是打开代码编辑器,而是把所有页面、功能、交互流程全部过了一遍。这是重构项目最容易被忽略的一步。你可能会觉得,旧系统不就在那里跑着吗,照着抄一遍不就行了?真要这么干,重构完大概率只是把"混乱"重写成了"新的混乱"。
我当时的做法是拉了一张表,把系统里的核心模块、高频操作、低频操作、特别耗时的页面分别列出来。比如运营后台里最常被吐槽的是列表查询页——数据量大,筛选条件多,每次查询要等好几秒,而且翻页还会偶发白屏。其次是表单提交页,校验规则散落在各种业务逻辑里,提交失败时错误提示不统一,用户经常搞不清楚哪里填错了。这些痛点列出来之后,重构的目标就不是"页面长一样"了,而是"页面更快、操作更顺畅、报错更清晰"。
笔记里我写了一份需求清单,最后沉淀成三个核心指标:首屏可交互时间控制在2秒以内,列表页查询响应控制在1秒内,表单校验错误提示做到逐字段高亮并给出具体建议。指标不是拍脑袋定的,是拿旧系统的实际耗时和用户反馈倒推出来的。
这个阶段还有一个容易被忽略的点:梳理旧系统的"隐含逻辑"。比如很多校验规则是写在接口返回里的,前端要根据不同的code做不同的提示;某些下拉选项的联动关系藏在业务人员的口述里,代码里只有一段看起来很奇怪的if判断。这些隐含逻辑必须提前问清楚、写下来,否则重构到一半你会发现某些交互行为怎么都对不上。
1.3 技术选型思路:为什么选中React而不是其他方案
需求理清楚了,接下来就是技术选型。团队内部当时有几种声音:有的说直接用Vue重写,上手快,模板语法更接近原来的写法;有的说干脆上Svelte,打包体积小,运行时更轻;还有的说继续用旧技术栈,只是把代码组织方式重新梳理一下。
我个人的选择是React,理由有三层。
第一层,生态成熟度。React的社区生态在这个领域是最丰富的,我们需要用到的路由、状态管理、UI组件库、表格组件、可视化图表库,都有非常成熟的方案。选型的时候不应该只看框架本身写起来爽不爽,还要看整个生态能不能支撑业务需求。比如旧系统里有一个特别复杂的权限联动表格,React生态里就有现成的复杂表格组件能覆盖这种需求,省去了自己造轮子的时间。
第二层,团队技术储备。团队里大部分人都系统学过React,虽然平时用得不多,但底子在。重构项目的时间窗口很紧,不可能给团队留一个月专门学新框架的缓冲期,选团队最熟悉的技术栈能显著降低项目风险。
第三层,重构的渐进式要求。旧系统代码量不小,不可能一个晚上全部替换完。React可以做到局部引入,新页面用React写,旧页面继续跑,中间通过微前端的方式桥接。这种渐进式迁移方案在其他框架上也能做,但React这边的实践案例更多,遇到问题能找到更多的参考。
顺带说一下我为什么没有选Svelte。不是说它不好,Svelte的编译思路很有趣,运行时的包确实小,但它和旧的代码体系差异太大,团队需要一个较长的适应期,而且那时候Svelte生态里的大型表格组件和复杂表单方案还没有现在这么完善。技术选型很多时候不是在选"最好的",而是在选"当前场景下综合代价最低的"。
2. 核心功能拆解与关键细节设计
2.1 组件体系搭建:从设计稿到可复用组件
项目正式开工之后,我先把组件体系搭了出来。这里说的组件体系不只是把页面拆成几个React组件就完事,而是要定一套规则:组件怎么分类、怎么命名、props怎么设计、样式怎么隔离、状态怎么传递。
我的组件分类沿用了业界比较成熟的思路,分成三层:
- 基础组件:按钮、输入框、下拉框、弹窗、单选多选这类不包含任何业务逻辑的组件,纯粹是UI层面的封装。
- 业务组件:与具体业务场景绑定的组件,比如"用户列表筛选条件区"、"订单状态标签"、"权限树选择器"。
- 页面组件:由基础组件和业务组件拼装而成的完整页面。
分层的好处是职责清晰。基础组件不依赖任何业务数据,可以在任意项目里复用;业务组件把旧系统里那些重复出现的UI片段沉淀下来,比如"带图标的搜索输入框"、"可展开的筛选面板",这些片段在旧系统里每个页面都复制粘贴一次,重构后只需要维护一份。
说几个组件设计里的细节。命名规则我统一用了"模块名+组件用途",比如UserFilterBar、OrderStatusTag,一眼就能看出这个组件是干什么的。props设计上有一个原则,尽量只传业务数据,不传样式对象——组件内部怎么排版是组件自己的事,外部不要通过style去强改,否则组件复用性会很差。
样式隔离用的是CSS Modules。以重构项目为例,每个组件一个同名的.module.css文件,编译时会自动生成带hash的类名,不会污染全局。旧系统里最头疼的就是样式互相覆盖问题,改A组件样式把B页面搞崩的情况几乎天天上演,换成CSS Modules之后这个问题彻底消失。
自定义hooks是React重构里的利器。我在项目里抽了
useTableQuery、useFormState、usePermissionCheck,分别负责列表页的数据查询、表单页的状态管理、按钮级权限的判断。这些自定义hooks把原来散落在生命周期里的逻辑集中到一起,组件代码量肉眼可见地减少,而且每个hook都能单独写单元测试。
2.2 状态管理:能不用就不用,用了就要理清楚
接着是目前团队里讨论最多的一个问题:重构的时候状态管理方案选什么?
我的观点很明确:优先使用组件自带的state,能不用全局状态就不用全局状态。很多初学者容易一上来就装一个状态管理库,把组件里的数据全丢进去,结果每个组件都要连接一下全局状态,代码反而更绕。
我用状态管理有一个判断标准:这个数据到底被多少个组件共享?如果只被父子组件用到,那就通过props传,或者用组合的方式共享;如果被两个毫无关系的组件同时用到,比如侧边栏的折叠状态和顶部的面包屑都要知道当前菜单是不是展开的,那就考虑用Context;如果页面复杂到Context的更新会引发大量无关组件重渲染,再上专门的全局状态管理库。
具体到"rea"项目,页面结构其实比较规整:顶部导航、左侧菜单、右侧内容区。全局要共享的只有登录用户信息、菜单权限集合、几组全局设置项。我直接用了React内置的Context加useReducer组合,没有额外引第三方状态库。原因很简单,项目规模没到需要引入的复杂度,少一个依赖runtime就少一份负担,出bug的概率也低一截。
列表页的数据请求和缓存我单独封装了一个数据请求hook,没有引入第三方数据请求库。这个hook负责发起请求、处理loading状态、缓存上次查询条件、在组件卸载时取消未完成的请求。这里有一个关键细节:React 18里组件的副效应在开发模式下会执行两次,如果hook在卸载时没有清理定时器或取消请求,很容易出现奇奇怪怪的报错。
2.3 路由与权限控制:让页面流转不迷路
路由设计上,我用了React Router的v6版本。相比老版本,v6的嵌套路由配置更扁平,API设计更符合直觉。路由表我单独抽了一个配置文件,用数组维护所有页面路径、组件映射、页面标题、所需权限码。菜单栏不再单独写死,而是根据路由表自动生成。这样加新页面的时候只需要在路由配置文件里加一项,菜单和面包屑会自动同步。
权限控制是后台系统绕不开的主题。这个系统的权限分两个层级:菜单权限和按钮权限。菜单权限决定当前用户能看到哪些菜单项,由后端在登录时返回权限码列表;按钮权限决定页面里的某个按钮要不要渲染。菜单权限我在路由层面做了拦截,封装一个受保护的Route组件,用户没有对应权限码就直接跳转无权限页。代码长这样:
// 简单示意:权限控制 const ProtectedRoute = ({ code, children }) => { const { hasPermission } = usePermissionCheck(); return hasPermission(code) ? children : <Navigate to="/403" replace />; };按钮权限我做了自定义指令式的封装。在封装基础组件的时候就给每个按钮组件留了permissionCode属性,渲染前统一检查权限码,没有权限则不渲染出来。这样一个按钮组件在写业务代码的时候,传一个permissionCode就能在全局统一完成权限控制,不用在每个页面里手动写条件判断。
这里的经验是:路由表和权限表一定要集中维护,千万不要散落到每个业务代码文件里。前三个页面还好,页面数量一多,如果权限判断逻辑是复制粘贴的,后期加一个新权限点会让人崩溃。
3. 实操过程与核心实现
3.1 从零搭建工程脚手架
工程脚手架我选了Vite。当时选它的理由很实际,启动速度确实比老一代的Webpack快,开发体验好很多。旧的构建工具每次热更新都要等好几秒,Vite这边基本是毫秒级响应,省下来的等待时间都是实打实的开发效率。
Vite配置里我改了三个比较关键的选项。
第一个是路径别名。所有源码里的import路径都通过@指向src目录,再也不用写一长串相对路径了。这个配置在每个项目里都要先弄好,对后续开发体验影响非常大。
第二个是代理配置。开发环境下所有/api开头的请求都代理到服务端地址,解决了跨域问题的同时也保留了真实网络环境的验证能力。注意代理配置里要区分开发环境和生产环境,生产环境一般由网关层处理,前端不需要配代理。
第三个是打包分块策略。Vite默认的打包策略会把所有第三方依赖打成一个文件,这样做的问题是一个依赖更新会导致整个大文件缓存失效,用户重新下载的量很大。我通过rollupOptions把React全家桶、UI组件库、图表库、工具函数库分别做成独立的包,这样用户只下载变化的那部分。分块之后首屏文件从之前的单包1.6MB降到了核心包只有几百KB,加载速度体感提升非常明显。
工程里还配了一套代码规范:ESLint加Prettier,规则用了社区里主流的React插件推荐配置,再手调了几个自己团队习惯的规则。比如强制要求函数组件统一用箭头函数声明、hooks必须在组件最顶层调用、常用props要按字母序排列等。这些规范不是为了好看,是希望大家提交的代码风格一致,code review的时候不用花费时间在"这里该不该分两行"这种问题上。
3.2 关键页面模块的实现思路
工程搭好以后,我先从最复杂的两个模块开始动手——列表查询页和表单提交页。这两个模块是运营后台的核心,做清楚了,其他页面都能顺着同样的模式写出来。
列表查询页的核心要求:数据量大、筛选条件多、查询反馈快。我封装了一个通用的useTableQueryhook来降低业务的重复代码量。这个hook需要传入一个查询函数,内部维护查询参数、loading状态、分页信息、请求取消逻辑。页面组件只需要提供筛选区的初始值和一个表格配置,剩下的逻辑全部交给hook处理。
关键代码的一个简单示意:
// 列表查询hook的简化版 function useTableQuery(requestAction) { const [params, setParams] = useState({ page: 1, pageSize: 20 }); const [loading, setLoading] = useState(false); const [data, setData] = useState({ list: [], total: 0 }); const fetchData = useCallback(async () => { setLoading(true); try { const res = await requestAction(params); setData(res); } catch (err) { message.error('查询失败,请稍后重试'); } finally { setLoading(false); } }, [params, requestAction]); useEffect(() => { fetchData(); }, [fetchData]); return { data, loading, params, setParams, reload: fetchData }; }这里有个必须要注意的细节:请求函数要用useCallback包住,否则每次渲染都会生成新的引用,useEffect里的依赖一变,就会无限循环请求。
表单提交页的核心难点在字段联动和校验。比如某个下拉框选了某个值,后面两个字段才显示;某个输入框填的内容格式会影响另一个字段的校验规则。这种联动逻辑如果散落在组件的JSX里,代码会越写越乱。我的做法是把表单字段配置抽成一个数组,每个字段声明自己的类型、默认值、校验规则、联动条件。渲染层用配置遍历生成,业务逻辑集中在表单容器组件里处理。
这样做的收益很明显:新增一个字段只需要在配置数组里加一项,不需要在JSX里再复制粘贴一段结构相似的代码。遇到了问题排查的时候也能直接按配置项去定位,不用在一个几百行的组件里翻来翻去。我常说,表单页的复杂度和字段数量成正比,如果不用配置化的方式去写,字段超过20个,维护成本就会爆炸式增长。
3.3 性能优化:让页面响应跑进及格线
性能优化是重构项目里最容易被低估的部分。最初我写完列表页,本地数据量小,测起来毫无压力。直到联调环境里导入了真实数据,才发现要命的问题——筛选条件一变,整个页面白屏好几秒,滚动列表卡顿明显。
第一层优化是减少不必要渲染。React默认状态下父组件重新渲染时,子组件也会跟着重新渲染,哪怕它的props根本没变。我把筛选区、表格区、分页区分别包上memo,这样筛选区更新的时候表格区不会跟着闪一遍。同时用useCallback稳定事件处理函数,用useMemo缓存计算量比较大的数据转换逻辑。
第二层优化是列表渲染。系统里最大的列表有超过一万条记录,全部渲染成dom节点是不现实的,我用虚拟滚动组件来做窗口化渲染,只渲染视口范围内的节点。数据量、滚动区域高度、行高设为定值之后,列表滚动变得非常顺滑。这里有个小坑:虚拟列表的容器必须设置明确的高度,如果高度是由内容撑开的,它就无法计算哪些节点应该渲染。第一次用的时候我忘了设高度,列表直接变成空白,排查了半天才反应过来。
第三层优化是路由懒加载。每个页面组件通过React.lazy语法引入,打包时自动分割成独立chunk,访问到该页面时才加载对应代码。首屏只加载核心框架和当前页面需要的代码,加载体积小了不少。配合上一开始的分包策略,首屏在弱网环境下也能比较快地进入可交互状态。
3.4 构建与部署:开发到上线的最后一公里
开发和优化都做得差不多了,接下来就是构建和部署。这一步看着简单,实际上有不少细节需要注意。
前面说过我用Vite作为工程脚手架,生产构建的命令是vite build,产出一堆带hash的静态文件。这些静态文件部署到服务器上的路径和代码里的资源引用路径是对应的,在base路径上要做正确配置。举个例子,如果静态资源放在https://example.com/admin/这个子路径下,构建时的base就要设成/admin/,否则页面加载时找不到JS和CSS文件,表现就是用户打开页面只能看到空白。这个问题在真实项目里特别常见,我记得的至少有两次是因为base配错,部署完直接白屏。
部署流程我走的是标准的前后端分离方案:构建产物推到对象存储或者静态服务器,前端只负责静态资源,数据全部走接口。部署的时候保持"先发布静态资源,再发布接口新版本"的顺序,这样即使有用户访问到了新页面但服务端还是旧逻辑,也不会出现接口结构对不上的问题。
上线前还有一道关卡是环境变量的管理。开发环境、测试环境、生产环境的接口地址都不同,我把这些配置写到环境变量文件里,构建时自动读取。这里提醒一句,环境变量文件名一定不要搞混,比如在测试环境上用了生产环境变量,那么测试环境的数据就全连到生产库了,后果很严重。
4. 常见问题与排查技巧实录
4.1 依赖更新带来的"薛定谔白屏"
项目进行到一半,我把某个UI组件库从2.x升级到了3.x。升级完成后的现象非常诡异:有的页面正常,有的页面直接白屏,而且报错信息在本地复现不出来。最后用生产模式的构建日志逐个比对,才发现是新版组件库对React版本的peer依赖要求提高了,工程里的React版本不满足要求,导致构建出的包在运行时就出问题。
这个坑以后我在每次升级依赖之前都会先看release notes,确认breaking changes清单,并且把升级放到单独的分支里验证,而不是在自己的开发分支上顺手做掉。React项目里依赖之间的peer依赖关系是一张复杂的网,升级任何一个基础库都可能牵动全局,稳妥的路线是升级一个、测试一个、合入一个,绝不批量升级。
4.2 状态不同步引发的渲染顺序坑
另一个记忆深刻的bug出在列表筛选功能上。用户点击筛选按钮,页面展示的内容是上一次的查询结果。排查后发现问题出在React的状态更新机制上——setParams是异步的,调用它之后立即请求,拿到的还是旧值。当时的错误代码如下:
const handleSearch = () => { setParams({ ...params, page: 1 }); fetchData(); // 这里获取到的params还是旧值 };修正方案是把请求动作放在状态更新之后,或者直接把新参数传给请求函数。在函数组件里,这种"先更新状态再执行逻辑"的诉求很常见,useEffect就是标准的答案。去掉手动调用fetchData,改成监听params变化自动发起请求,这个问题的界面表现就一致了。
4.3 样式痛点:CSS Modules 与全局样式的使用边界
前面说了样式方案用CSS Modules,但实际开发里总有一些场景需要覆盖第三方组件的内部样式。第三方时间选择器组件默认样式在暗色背景下显示不清,我就得在全局样式文件里手动覆盖它的内部类名。一开始我把这类覆盖压力直接写在全局文件里,很快全局文件就膨胀了,而且随着组件版本升级,这些覆盖规则随时面临失效。
后来我改用了一种做法:每个需要覆盖第三方样式的地方,单独建一个局部样式文件,用:global语法包住需要覆盖的类名,并且在该文件开头写清楚这个覆盖是为了哪个组件的哪个版本。这样做的好处是维护时候能明确知道每条覆盖规则存在的理由,版本升级时候也能快速定位测试点。
4.4 问题速查表
最后把这一路上遇过的部分问题整理成表格,方便读者快速对照:
| 问题现象 | 根因 | 排查思路/解决方案 |
|---|---|---|
| 页面白屏,控制台报找不到某个依赖 | 依赖版本不兼容或peer依赖缺失 | 用构建日志定位具体依赖,检查package.json中的版本范围 |
| 列表页筛选后数据不变 | setState是异步的,请求函数拿到的还是旧值 | 用useEffect监听状态变化,或直接把新参数传给请求函数 |
| 虚拟列表滚动时大量空白行 | 容器没有设置明确高度 | 给虚拟列表容器设置固定高度,高度一般是父容器计算后传入 |
| 样式改了一个组件,其他页面被影响 | 类名全局冲突 | 统一使用CSS Modules,避免手写全局类名 |
| 热更新越来越慢 | 工程体积增大,依赖没有得到合理分包 | 检查构建配置,合理设置分包策略 |
| 路由切换后页面不刷新 | 复用了相同组件实例,依赖的props没有变化 | 给路由组件设置key,强制刷新实例 |
这篇文章的内容到这里,基本把"rea"这个前端重构项目的关键路径走完了一遍。最后分享一个我铆钉在流程里的小习惯:我会在项目的docs目录下建一个"项目决策记录"文档,每做一个重要技术选型,就在里面写下为什么选了A而不是B、当时考虑了哪些备选方案。这个习惯在重构项目里特别有价值——因为重构的跨度通常以月计,三个月后你可能完全想不起来当初为什么定这个方案。有这份记录在,新同事接手代码的时候也能很快理解背后的意图,不用靠猜。