1. 项目代号rea:一次从零重建前端应用的真实记录
1.1 这个项目为什么叫rea
看到标题“rea”,可能很多人第一反应是某个库的缩写,或者一个命令的前缀。实际上这是一次真实项目开发中,我们内部给前端重构工程起的临时代号——取“rebuild everything again”的首字母缩写,意思想提醒自己别糊弄、别敷衍,把推倒重来的事情一次做干净。
这个项目所在的产品线已经跑了三年多,早期为了快速上线,用的是页面套页面、脚本挂脚本的老方案,业务一复杂就肉眼可见地吃力。领导层拍板要做一次彻底的前端重构,于是技术团队里拉了一个小组,成员包括我、两位前端同事和一位后端配合人员,代号就叫“rea”。
我写这篇东西的目的很简单:把这次从需求拆解、技术选型、核心模块实现到上线踩坑的完整过程记录下来,给那些正在做类似“老项目翻新”或“从零搭前端应用”的人一份可以直接参考的实战笔记。里面没有那种PPT式的空话,全部是敲过键盘、熬过日志之后才写得出的内容。
1.2 最初的痛点与目标清单
动手之前,我们花了两天时间把旧系统的毛病一条条列出来,不列不知道,一列吓一跳:
| 痛点 | 具体表现 | 影响程度 |
|---|---|---|
| 页面加载慢 | 首屏需要加载约2.8MB未压缩脚本 | 高 |
| 状态管理混乱 | 全局变量满天飞,组件间依赖顺序敏感 | 高 |
| 代码不可维护 | 单个JS文件接近5000行,改一个按钮要全文搜索 | 高 |
| 无组件复用 | 相似结构页面多,但代码各写各的 | 中 |
| 接口请求无统一层 | 每个页面自己拼URL、自己处理错误 | 中 |
目标清单因此很清晰:首屏资源压到600KB以内、建立统一的状态管理层、按业务模块拆分组件、收敛所有网络请求到一个封装层。这些目标后来全部落地了,而且过程中还额外收获了一个好处——因为组件被真正拆分出来,后续新需求的开发速度肉眼可见地提升了,这是初期完全没想到的。
2. 技术选型背后的取舍:组件化方案、状态管理与构建工具的三角权衡
2.1 为什么最终锁定React技术栈
选型会议上吵了一下午,选项主要有三个方向:继续用老方案打补丁、换另一个主流框架、用React重写。最终选了React,理由非常务实。
第一,团队成员对React的熟悉度最高。我们小组里两个人日常写React,一个人写过Vue但也不抵触React,与其在换框架的学习曲线上浪费时间,不如直接押注熟练度最高的方案。第二,React的生态足够成熟,特别是周边工具链,从路由到状态管理到UI库都有稳定选择,不需要自己造轮子。第三,React的组件模型天然适合我们列出的痛点——按模块拆组件、组件自治状态、通过Props传递数据,这套心智模型正好治标又治本。
这里插一句我个人的体会:技术选型最怕的不是“选错”,而是“为了换而换”。如果团队不会,再好的框架也白搭。我们在选型时列了一个简单的评估表:学习成本、生态成熟度、团队熟练度、长期维护风险,四个维度一打分,React优势很明显,就这么定了。
2.2 状态管理方案:从Context到轻量外部库的迁移
状态管理是这次重构里最纠结的部分。刚开始我们天真地想“用React内置的Context就够了,不引入额外依赖”。结果模块越拆越细,跨组件共享的数据越来越多——用户信息、权限标记、全局筛选条件、通知列表,全往Context里塞,很快发现两个问题:一是Context的Value一变,所有消费它的组件全部重新渲染,性能肉眼可见地下降;二是调试困难,数据流经过好几层Provider之后,根本不知道哪里改了状态。
于是我们转向了轻量级外部状态库。选它的一个关键考量是API简单、学习成本低,团队三个人一下午就能上手,不必引入重型方案。核心思路是把全局数据分成两类:一类是真正需要跨模块共享的全局态,放进状态库;另一类是组件内部或父子间传递的数据,继续用Props和本地State。
我强烈建议任何做React项目的人先想清楚这条边界。全局态放多了,状态管理库就变成另一个垃圾场;放少了,又会退化成大量Props钻洞。实际操作中我们定了一个规矩:一个数据如果被超过两个不相关的模块用到,才提升为全局态,否则保持局部状态。这个规矩帮我们避免了过度设计。
2.3 构建与部署链路的选择
构建工具我们选了目前社区里效率第一梯队的那套方案,也就是基于原生ES模块的构建工具。理由很直接:旧项目最大的痛是首屏加载慢,而新构建工具天然支持依赖预构建、按需编译和极快的冷启动,能帮我们从工具层面就解决一大部分性能问题。
配置上有一个值得分享的细节:开发环境与生产环境的拆分配置不要写在同一个文件里硬切,最好拆成公共配置、开发配置、生产配置三份,通过命令参数合并。我们一开始图省事,全写一个文件里,后来因为“开发环境要不要压缩”“生产环境要不要sourcemap”这类需求不断打架,拆分后整个世界清净了。
部署方面,产物是纯静态文件,直接扔到已有的CDN上,配合后端人员做了一层反向代理转发API请求。这里要特别提醒:如果前端项目涉及路由,一定要在服务器端配置好history回退规则。否则用户刷新二级页面时,服务器会尝试查找不存在的路径,直接返回404。我们上线当天就被这个问题坑过一次,具体细节在后面踩坑章节展开。
3. 核心模块实现:列表渲染、表单联动与异步数据流的落地细节
3.1 组件拆分原则:按业务边界还是按UI粒度
组件拆分是这次重构中最影响后续开发体验的决策。业界常见两种思路:按UI粒度拆,比如按钮、输入框、弹窗各成一个组件;按业务边界拆,比如订单模块、用户模块、报表模块各成一棵组件树。我们的结论是两者都要,但优先级不同。
顶层按业务边界划分成几个大模块,模块之间互不引用对方的内部组件;模块内部再按UI粒度拆成可复用的小组件。这样做的好处是:不同业务模块之间天然隔离,不会出现“改订单模块的弹窗导致用户模块样式错乱”的连锁反应;而模块内部的UI组件拆分又能保证一个页面里的重复结构不用写两遍。
落地时我们给组件建了一个简单的分层目录结构:核心基础组件(不包含任何业务逻辑)放一层,业务容器组件放另一层,页面级组件再往上放一层。依赖方向必须是单向的——页面层可以引用业务容器层,业务容器层可以引用核心基础层,反向引用坚决不允许。这个约定靠lint规则辅助强制检查,从根本上杜绝了循环依赖的问题。
3.2 列表页面的渲染性能优化
项目中有一个列表页需要展示几千条数据,每条数据又包含多个字段和操作按钮。刚开始实现时直接渲染完整列表,滚动起来有明显的卡顿感。排查发现两个性能瓶颈:一是每条数据的按钮组件都绑定了独立事件处理器,创建开销大;二是列表滚动时所有行都在参与渲染,包括屏幕外的。
第一个问题用事件委托解决——在列表容器上只绑定一个事件处理器,通过事件源判断用户点了哪一行的哪个按钮。这个思路和DOM事件模型的老技巧一脉相承,但在React里很多人会忽略它,因为React合成事件系统本身做了代理。不过当行数特别多时,每个按钮依然会生成独立的虚拟节点,开销依然存在,事件委托在实测中仍然能带来可感知的提升。
第二个问题用虚拟列表方案解决。我们选了一个轻量的虚拟滚动库,只渲染可视区域内的行,上下各预留一部分缓冲。配置好行高估算函数后,两千行的列表实际渲染的DOM节点只有二十个左右,滚动流畅度从肉眼可见的卡顿变成丝般顺滑。这里有一个重要提醒:虚拟列表要求每一行的高度必须是确定值或可估算的,如果行内内容高度不固定,会出现滚动位置跳变。我们的方案是固定行高,内容超出部分用省略号截断,产品层面接受了这个取舍。
3.3 表单联动中的受控组件陷阱
表单是后台管理系统里最常见的需求,这次项目里有一个复杂表单:选择“客户类型”会联动显示不同的字段,勾选“加急处理”会修改费用计算方式,填完“联系手机号”要实时校验格式并提示。这种联动场景如果不提前设计好,写出来的代码会是一团乱麻。
我们定的核心原则是所有表单状态集中到一个自定义Hook里管理,而不是分散在各个子组件自己的State中。每个字段的变更走统一的更新函数,字段之间的联动则通过更新函数内部的判断逻辑完成。任何一个字段的变更,都会触发一次整体状态更新,React会自动重渲染所有依赖该状态的组件。
这个方案的优点是联动逻辑集中、可测试、可追踪;缺点是如果表单很大,每次输入都会触发大范围重渲染。实测下来,我们的表单字段数在30个以内,性能完全没问题。如果字段数超过100,可能需要进一步做字段级订阅优化,但那是另一个量级的问题了。
这里有一个我自己踩过的坑:受控组件的Value必须和State严格同步,不能出现某个输入框绑定的State在特定交互下没更新的情况。我们遇到过“输入一个字符后被重新渲染、光标跳到最后”的问题,就是因为更新函数里对某些字段做了转换处理后回写,但转换逻辑没考虑到光标位置。解决方案是不要做过于激进的输入值改写,让用户输入什么就存什么,展示层的格式化需求在单独的地方处理。
3.4 异步请求竞态处理
接口请求的竞态问题在这次项目里反复出现。典型的场景是:用户在搜索框连续输入关键词,每输入一个字就触发一次搜索请求。由于不同请求的响应时间不可控,先发出的请求可能比后发出的请求晚返回,导致界面显示的是旧搜索结果。
这个问题有一个经典的解决方案,我们封装请求层时把它做成了标准能力。核心思路是在发起新请求前,通过递增的请求序号或AbortController来宣告旧请求作废,只有最新一次请求的响应才允许更新状态。
// 封装一个简单的竞态处理工具 const useLatestRequest = () => { const requestIdRef = useRef(0); const run = async (requestFn) => { const currentId = ++requestIdRef.current; try { const result = await requestFn(); if (currentId === requestIdRef.current) { // 只有最新请求会进入这个分支 return { success: true, data: result }; } } catch (err) { if (currentId === requestIdRef.current) { return { success: false, error: err }; } } // 对过期请求返回一个特殊标记 return { success: false, error: 'stale-request' }; }; return run; };代码看起来简单,但如果没有这个封装,业务代码里就要到处写判断标记,很容易漏掉。我们在项目里要求所有列表搜索、筛选、分页操作都必须走这个工具,后来几乎没再遇到过竞态导致的脏数据显示。
4. 上线前后踩过的坑:从报错日志到稳定运行的排查链路
4.1 首屏白屏:路由懒加载与分包策略
项目测试环境一切正常,结果一上生产环境,就收到“首屏白屏”的反馈。排查链路从浏览器控制台开始,看到报错信息提示某个路由对应的代码块加载失败。原因很快锁定:构建产物按路由拆分了多个JavaScript文件,但生产环境的CDN刷新存在延迟,用户浏览器加载HTML时引用了尚未同步到CDN节点的分包文件,于是加载失败。
这个问题的本质是“HTML文件先更新了,但JS分包还没更新完”。解决方案有几个层面:最直接的是发布时先上传所有静态资源,等CDN节点刷新完成后再切换HTML入口;更稳妥的是给HTML文件设置较短的缓存时间,同时给带哈希的JS/CSS文件设置很长的缓存时间。我们的最终方案是发布脚本里加入了一个“静默窗口”——上传完所有资源后等待60秒再切流量,这个时间足够绝大多数CDN节点完成同步。
另外还做了第二层保险:HTML入口文件里加入了一个简单的错误监听,如果某个分包加载失败,自动刷新页面重试一次。虽然这个方案有点笨拙,但作为兜底手段确实能挽回不少因为网络抖动导致的白屏情况。
4.2 内存泄漏:副作用清理的经典案例
上线运行两三天后,运营同事反馈“页面开着不动,隔几个小时就变得特别卡”。打开浏览器内存面板,发现内存曲线像爬坡一样只升不降,典型的泄漏特征。
排查过程从性能面板的堆快照开始。连续拍了两张不同时间的快照做对比,发现泄漏的对象集中在某个地图组件相关的实例上。回头看代码,问题出在这个地图组件在挂载时创建了一个定时器轮询设备位置,但卸载时没有清除定时器。页面被切换到其他模块后,该组件实例本应被销毁,定时器却依然在运行,同时它的闭包环境还保留着对组件State的引用,导致整棵组件树都无法被垃圾回收。
修复只需要在组件卸载时清除定时器:
useEffect(() => { const timer = setInterval(fetchDeviceLocation, 5000); return () => { clearInterval(timer); // 同时销毁地图实例 mapInstance.destroy?.(); }; }, []);这个案例给我们的教训是:所有在副作用里创建的资源——定时器、事件监听、外部实例、订阅关系——都必须成对地清理。人力资源角度讲,一个人靠自觉根本记不住,所以我们后续引入了专门的lint规则,检测是否存在未清理的副作用资源,把它当作错误而不是警告来对待。
4.3 兼容性:低版本浏览器上的Polyfill与降级方案
项目面向的用户群体里有相当一部分还在用较旧版本的浏览器,这在某些行业的后台系统里非常常见。我们的代码使用了新语法特性和新的API,在低版本浏览器上直接报“缺少某个功能定义”的错误,页面完全不可用。
排查方式是先拿到一台旧版本浏览器的测试环境,在控制台看到具体的报错信息,大多是使用了较新的API、语法特性和某些不兼容的函数方法。解决方案是引入按需填充的Polyfill方案,同时修改构建配置的浏览器兼容目标。
这里有个细节值得展开:Polyfill不要整包引入,否则会额外增加很多体积。我们的做法是用按需引入的插件,根据代码中实际用到的API,只对缺失的进行填充。实测下来,额外增加的体积在很小的范围内,对加载性能的影响可以忽略。
另外还要说一句:兼容性不是“加了Polyfill就万事大吉”的。有些旧浏览器的渲染引擎本身存在布局和样式层面的bug,Polyfill根本管不了。我们的策略是设定一个兼容范围——浏览器市场份额占比低于某个阈值的版本,允许样式出现轻微差异,但功能必须可用。这个取舍需要在项目初期就和产品负责人明确并达成共识,否则后续会陷入无休止的兼容细节里。
5. 写在项目稳定之后:我沉淀下来的几条前端工程经验
5.1 关于代码审查的几点个人建议
项目重构完成后,我们小组内部做了一次复盘,其中一个重要收获是关于代码审查的。很多团队把代码审查当成“走过场”,只关注代码能不能跑、有没有明显语法问题。但代码审查的真正价值在于发现架构和设计层面的问题,比如组件边界划分是否合理、状态管理是否符合既定规范、是否存在横向依赖。
我们这次为了推动代码审查真正发挥作用,做了两个小调整:一是有两位同学互相做交叉审查,要求审查者必须熟悉被审查模块的整体代码,而不只是看提交的片段;二是审查时使用了一个检查清单,清单里包含“是否引入新的全局状态”“是否存在直接在其他模块中引用内部组件”等问题。清单让审查从主观的“我觉得”变成客观的“查一查”。
让我印象很深的一个案例是,当时有位同事在新增功能时为了图省事,直接import了另一个业务模块的页面级组件。这种跨模块依赖在清单检查下被拦住了,后来改成通过事件总线的方式通知目标模块自行刷新,虽然写起来麻烦一点,但从架构健康度来讲完全值得。
5.2 可维护性比炫技更重要
项目稳定运行两个月后,最高频的感慨既不是性能多好,也不是构建多快,而是“新需求开发起来真轻松”。这让我更加确信一个原则:前端工程的本质是管理复杂度,而不是追求某个技术点的极致。
所谓的“可维护性”,落地到具体操作上其实是几件朴素的事情:组件命名能直接看出业务含义而不是一堆抽象代号;目录结构有清晰的分层约定而不会越级引用;数据流的方向保持单向而不会出现各种绕圈子的引用;公共逻辑抽成可测试的纯函数而不藏在组件内部加注释了事。这些事情每一个单独拿出来都不难,难的是长期坚持并在每一次代码合入时守住底线。
最后再放一个小建议:如果团队开发一个长期项目,工具链的升级计划一定要提前规划。旧版本依赖不升级,短期看省了时间,长期看只会越来越深的“技术债”。我们在rea项目稳定期专门安排了两个迭代做依赖升级和废弃API替换,后来证明这笔投入的回报非常高。