尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

一次前端性能优化实战:从卡顿到流畅的全过程

一次前端性能优化实战:从卡顿到流畅的全过程 一次真实的性能优化从“能用”到“流畅得不像话”事情是这样的。我接手维护一个内部数据可视化管理后台功能很全图表很多表格密密麻麻左侧还有一堆筛选器。但每次切换筛选条件页面都要卡顿个两三秒滚动列表的时候帧率肉眼可见地往下掉操作起来就像隔着一层水在戳屏幕。这玩意儿说大不大说小不小日常用也凑合但每次看到那个转圈的加载动画总觉得心里堵得慌。于是我决定花点时间认真“折腾一个优化”。这篇内容不是什么高深的理论课而是我完整走一遍从定位卡顿根源、到设计优化方案、再到逐步实施并验证效果的记录。如果你手头也有那种功能堆叠得比较满、但性能不太如意的前端项目可以参考我的整体思路和具体操作手法。1. 优化前的“问诊”不靠感觉靠数据说话接到这种优化任务第一反应往往不是“我要改什么”而是“哪里慢了”。但盯着页面发呆是看不出问题的凭感觉猜“是不是图表太重了”或者“是不是数据量太大了”也没意义。我习惯先跑一圈标准的性能测量流程把问题量化出来再决定从哪里下刀。1.1 用Performance面板还原卡顿现场打开Chrome DevTools的Performance面板点击录制然后快速切换几次筛选器、滚动几轮列表、点开两个折叠面板最后停止录制。这个过程等于给页面做了一次内窥镜检查浏览器会记录下这段时间内所有主线程任务、渲染帧率、脚本执行时间、样式重算、绘制过程等。录制完成后关键要看几个指标。第一是FPS每秒帧数正常交互时页面应该维持60帧如果录制的火焰图里FPS条上出现大段红色区块说明这期间渲染掉帧了。第二是Scripting脚本执行时间如果脚本执行占据了大量灰色块说明JS这块的计算逻辑在拖后腿。第三是Rendering渲染时间如果有一段段紫色或者绿色的窄条堆积说明浏览器在反复做样式计算和布局重排这通常是DOM结构或样式更新过于频繁引起的。我这次录制下来的结果非常典型切换筛选条件的那段时间里主线程被一大片灰色脚本任务占满FPS直接掉到个位数。这就相当于做菜时厨师在全神贯注剁肉炉子上的菜没时间看整体效率自然就下来了。1.2 从火焰图里揪出“耗时大户”光看出总体区间还不够得放大逐帧看。火焰图里每一段横条代表一个函数调用横条越长说明这个函数占用同步执行的时间越多。我把时间轴拖到切换筛选器触发的那一帧往下展开调用栈看到了两个名字反复出现一个是React渲染相关的re-render过程另一个是一段用于格式化表格展示数据的工具函数。当时项目里表格组件在每次数据源变化后会对全部行数据做一遍格式化操作比如把后端返回的时间戳转成“YYYY-MM-DD HH:mm:ss”格式把状态码映射成中文枚举文案。这个操作本身不算昂贵问题在于它是在每个单元格渲染时同步执行的表格有30列、每列展示50条数据等于一次渲染要重复执行1500次格式化函数而且每次切换筛选条件都会从头再来一遍主线程被占满也就不奇怪了。至于React侧火焰图里re-render的段数多得惊人。我数了一下切换一次筛选器不仅仅是表格组件在更新左侧筛选器、顶部的统计卡片、甚至页面底部的页脚组件都跟着重新渲染了一遍。这种级联式的无关渲染让原本只需要局部更新的场景变成了整页大刷新白白消耗了大量计算资源。1.3 给优化定一个“可验证”的目标拿到测量结果后我没有立刻动手改代码而是先给自己定了一个明确的目标切换筛选条件后页面从触发交互到内容完全渲染完成的时间要控制在500毫秒以内滚动列表或操作折叠面板时帧率稳定在50帧以上。这个目标不是凭空拍脑袋定的而是参考了业界的交互体验标准——100毫秒内响应被认为“立即生效”500毫秒内属于“短得不容易察觉”500毫秒以上用户就会明显感觉到等待压力。有了这个量化目标后续每一步优化是否有效就不靠“感觉流畅了一点”而是靠再次录制Performance面板看数据说话。2. 优化方案的选择为什么不动用“重型武器”常见的前端性能优化手段其实不少比如上Web Worker把计算放到后台线程、把表格改成虚拟滚动、给组件加memo做缓存、用并发模式调度渲染优先级等等。面对这么多种手段我一开始也犹豫过上来就搞虚拟滚动和并发模式听起来确实炫酷但仔细想过之后我发现现阶段用这些“重型武器”并不太划算。2.1 虚拟滚动虽然厉害但场景不匹配虚拟滚动是处理超大列表的三板斧它只渲染可视区域内的行滚动时动态替换可以撑住几万行数据不卡顿。但我们的表格加上默认分页单页最多显示50行。50行对于虚拟滚动来说完全是大材小用而且引入虚拟滚动组件库意味着要替换现有的表格组件表格的列调整、固定列、表头筛选这些交互逻辑都得跟着适配改动范围太大收益却微乎其微。Web Worker也是同理。格式化1500次时间戳虽然累但本质上还是纯CPU计算任务数据量并不到需要并行处理的程度。真正的问题不是计算量巨大而是频繁重复计算、大量无关渲染塞满了主线程。Web Worker解决不了“渲染过多”的问题它只能分担“计算过重”的问题。2.2 问题本质重复劳动和暴力刷新回到火焰图给我的直观感受这个卡顿的根源其实是两件事不必要的重复计算和不必要的组件重渲染。时间戳格式化函数在每次渲染单元格时被调用但是表格的数据源并没有变。React组件在父级状态变化时子组件无条件跟着重新渲染哪怕它显示的内容压根不依赖那个状态。这两个问题的本质与React的渲染机制和浏览器的事件循环调度方式有关。在React的默认行为下父组件状态变更后所有子组件都会被重新执行render函数生成一份新的虚拟DOM再与之前的做对比如果发现不同才实际操作真实DOM。但问题在于即使是“对比发现没有变化”这个过程本身也是有计算成本的。子组件数量一多、层级一深对比的过程也会消耗不少时间。还有一些组件由于内部引用了父级传入的对象字面量或者内联箭头函数每次父组件渲染时都会拿到一个新的引用于是React的memo缓存机制失效子组件只能被动跟着渲染。这些现象叠加在一起就形成了我在Performance面板里看到的那种“整页大滑动”。想清楚这一点之后我的优化思路就非常清晰了第一把每一次渲染时重复执行的格式化逻辑做成缓存第二切断无关组件之间的渲染链路第三针对高频交互比如输入筛选关键词的动作做一下节流处理。2.3 方案组合拳先减负再隔绝最后节流最终我定下的优化动作不做架构级重构不做组件库替换就围绕几个核心策略展开计算缓存表格展示数据的格式化操作只在数据源变化时执行一次执行完缓存结果二次渲染直接读取缓存。渲染隔离用React.memo包裹接收props的展示型组件配合useCallback稳定回调函数避免父组件更新时子组件跟着“陪跑”。状态拆解把全局筛选条件和表格展示数据的状态彻底分开防止筛选器输入时拖累表格渲染。交互节流搜索框的onChange事件做防抖处理用户停止输入300毫秒后才真正触发查询。这四个策略没有太花哨的东西都是常规手段但组合在一起正好针对火焰图里暴露出来的那两个主要矛盾。3. 逐个击破从“重复计算”到“渲染隔离”的落地过程方案定完之后就是动手实施了。我把优化过程分成三个阶段每一步改完都立刻用Performance面板重测确定有效之后再进入下一步。这里我也把我们做数据格式化的初始代码结构和优化后的代码结构做个对比方便理解。3.1 数据格式化缓存把常用结果记在“小本本”上项目里有一张表格展示的是最近一周操作日志列包含操作时间、操作人、行为类型、状态。后端接口返回的数据里操作时间是时间戳行为类型和状态都是数字编码。原来的格式转换逻辑是这样的// 优化前每次渲染都重新格式化整行数据 function formatRow(row) { return { time: formatTimestamp(row.timestamp), // 时间戳格式化为 YYYY-MM-DD HH:mm:ss operator: row.operator, action: ACTION_MAP[row.actionCode] || 未知操作, status: STATUS_MAP[row.statusCode] || 未知状态 }; }问题很明显formatRow函数在每次render过程中都会被表格组件循环调用而且每次返回的都是一个全新的对象。表格的行列更新、内容对比、渲染绘制都会因此被迫重新执行一遍逻辑处理。优化思路很好理解既然后端返回的数据很少变化那格式化结果其实是可以复用的。// 优化后利用Map做结果缓存数据源未变时直接返回缓存 const formatCache new Map(); function formatRowCached(row) { const cacheKey row.id _ row.timestamp _ row.actionCode _ row.statusCode; if (formatCache.has(cacheKey)) { return formatCache.get(cacheKey); } const formatted { time: formatTimestamp(row.timestamp), operator: row.operator, action: ACTION_MAP[row.actionCode] || 未知操作, status: STATUS_MAP[row.statusCode] || 未知状态 }; formatCache.set(cacheKey, formatted); return formatted; }这里我用Map做一个格式化结果的缓存key由行ID和数据字段拼接而成。只要后端的某一行数据没有实质变化第二次渲染就直接读取缓存里的格式化对象不再重复执行时间戳处理和枚举映射。这一步做完重新测了一遍Performance火焰图里那段密集的Scripting块明显缩短了。不过这只是阶段性的胜利React组件层面的无关渲染问题还需要继续处理。3.2 React.memo哪些组件值得被“记忆化”给组件添加memo缓存之前我先做了一次组件分类。React.memo并不是随便给所有组件套上就能提效的它适合纯展示型组件比如一个操作按钮、一个状态标签、一个文本块。这类组件接收的props不变时渲染结果必然不变缓存后可以跳过整个render流程。而那些依赖内部状态、依赖全局上下文、自身逻辑特别复杂的组件强行memo反而可能带来额外的缓存比较开销。我的做法是把表格的“行”抽成了独立的子组件并用React.memo包裹导出的组件定义// 表格行组件接收格式化好的数据只负责渲染 const LogRow React.memo(function LogRow({ rowData, onExpand }) { return ( tr td{rowData.time}/td td{rowData.operator}/td td{rowData.action}/td td{rowData.status}/td td onClick{() onExpand(rowData)}展开/td /tr ); });同时表格对外暴露的展开操作函数我改用了useCallback来稳定引用// 父组件中用useCallback保证每次渲染引用不变 const handleExpand useCallback((rowData) { setDetailVisible(true); setCurrentDetail(rowData); }, []);这里有一个很容易踩的坑就算子组件用了React.memo如果父组件传入的是一个每次渲染都重新定义的内联箭头函数比如onExpand{(rowData) handleExpand(rowData)}memo的浅比较依然会判定props变了缓存就失效了。用useCallback把函数引用固定下来之后当父组件的一次状态更新与当前行数据无关时该行组件会直接跳过渲染性能收益非常可观。3.3 拆分筛选状态与表格状态让改动它的组件去振聋发聩再看整个页面的结构。顶部有一个全局筛选区包含日期范围、操作人、行为类型的多个下拉框下面才是主体表格区域。原本的状态管理是一锅炖的所有筛选条件和表格数据都在同一个顶层组件的useState里。也就是说用户每调整一个下拉框的值触发的不光是筛选区的局部更新连带着整个表格区域也一起重新渲染了。但这时用户明明还在操作筛选器表格的数据根本不需要刷新。实际上还有个更细的层次问题。筛选区自身的多个下拉框之间也不应该共享一个大的状态对象。按照React官方的建议要把关联的状态拆分成更小的单元。于是我把页面状态拆分成了两组// 拆分前的状态 const [filters, setFilters] useState({ dateRange: [], operator: , actionCode: }); const [tableData, setTableData] useState([]); // 拆分后的状态 const [dateRange, setDateRange] useState([]); const [operator, setOperator] useState(); const [actionCode, setActionCode] useState(); const { tableData, setTableData } useTableDataStore(); // 独立的数据层管理UI上的变化并不大但内部的渲染传播路径完全变了。用户在筛选区切换任何选项会命中独立的state更新逻辑React只调度和执行这部分组件树的更新表格树完全不受影响。反过来表格数据加载完成时要触发的loading状态、tableData更新也只在表格组件内部响应。这种状态隔离相当于在物理上切断了级联更新的通道从根源上减少了无谓渲染。3.4 输入防抖让搜索提示不再“每敲一个字母都翻一次船”还有一个容易被忽略的卡顿点筛选区里的“行为类型”字段我设计成了一个可输入搜索的下拉框。用户每敲一个字母onChange都会触发一次回调然后实时把输入值同步到筛选状态。如果这个值又被用于接口请求那就相当于用户连续输入过程中每按一次键盘就会发出一次网络请求。网络慢的情况下卡片、表格、列表都会因为这个频繁请求而长时间处于加载状态页面看起来就是各种组件轮流转圈整体体验非常破碎。常规解法是写一个防抖Hook延迟处理用户的输入。我这里直接套用了自己封装的一个组件DebouncedInput组件内部用户输入时设置一个300毫秒的定时器停止输入300毫秒后才把最终值交给父组件使用。function DebouncedInput({ onChange, delay 300, ...delegatedProps }) { const [innerValue, setInnerValue] useState(); const timerRef useRef(null); const handleChange (e) { const value e.target.value; setInnerValue(value); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current setTimeout(() { onChange(value); }, delay); }; useEffect(() () clearTimeout(timerRef.current), []); return input value{innerValue} onChange{handleChange} {...delegatedProps} /; }这段代码并不复杂核心逻辑就一个把props上的onChange包一层防抖父组件拿到的回调频率被我人为压低了。这样即使用户快速敲入“查询”两个字React调度器也不会被打到连续五六次渲染饱和状态页面稳定安静许多。4. 中途踩过的坑memo缓存失效与内存泄漏问题优化做到这一步页面主流程已经流畅很多。但我个人有个习惯每次做完阶段优化后不仅要运行业务场景测试还要故意去点一些平时“用不到”的犄角旮旯功能。这次折腾的过程中我确实连着踩了两个有意思的坑。4.1 memo失灵每次渲染都拿到新的缓存key第一次踩坑发生在添加缓存之后。我给格式化函数加了缓存之后高高兴兴地重新录制性能面板结果发现Scripting时间并没有预想中下降得那么明显。我回头审视代码发现问题出在我伪造了一个反例场景来测试表格中每一行的数据在后端返回时带上了一个随机的时间戳我把它也拼进了cacheKey。这相当于告诉缓存“嘿我每个请求的数据都是新的”结果缓存完全失效。实际情况并没有这么极端但很接近。我原来的cacheKey里包含了一个时间字段而后端这个时间字段表示的是“数据最后修改时间”偶尔会被更新。对于操作日志来说时间戳变了实际上意味着内容可能真的变了为了安全起见确实应该重新格式化。但这种字段用在这里会让缓存命中率降低很多。我的最终改法是把cacheKey从“全字段极大相似判断”改成“按数据行身份ID 特定业务字段版本号”弱化对时间戳等高频变化字段的敏感度。排查过程是这样进行的我在formatRowCached函数内部临时加了计数器在控制台观察每次渲染时命中和未命中的次数。发现未命中次数几乎等同渲染次数时我意识到是key里的字段选错了。用版本号替代之后缓存命中率明显提升火焰图里的脚本时长又一次出现了肉眼可见的下滑。这个坑值得分享的原因很简单——缓存策略的key设计直接决定缓存效率用错了一个高频变化字段整套缓存就是摆设。4.2 事件监听未清理切换页面后Timer还在跑第二个坑是在检查内存面板时发现的。我用Chrome DevTools的Memory面板录制了几轮“进入页面 - 来回操作 - 返回列表页”的流程然后对比堆快照发现有少量内存在持续增长无法被垃圾回收。顺着Heap Snapshot里的Retainer链路查下去发现是一个全局事件总线实例里挂着一个引用计数异常的事件监听器它把某个组件实例的生命周期拉长了。起因是这样的在做一个消息实时提醒功能时我在组件里用window.addEventListener监听了一个自定义事件用来更新页面上的小红点数量。当时的代码在组件销毁时确实写了清理逻辑但是有一种特殊情况没有覆盖到当用户点击刷新按钮页面进入重新加载流程时React的严格模式会先执行一次组件卸载再执行重新挂载卸载阶段如果异步操作还没结束事件监听清理的代码可能在异步回调之后才执行导致第一次挂载时的监听器残留在了window对象上。这个问题与防抖功能的清理逻辑也有点关系。DebouncedInput组件里我特意在卸载时清理了定时器但带宽用的这个接口回调却因为闭包机制保留了旧组件的状态。优化动作在组件卸载的统一清理函数里显式地移除所有全局事件监听器同时把防抖定时器的引用也一起清掉。useEffect(() { const handleReminder () { /* 更新红点 */ }; window.addEventListener(app-reminder-updated, handleReminder); return () { window.removeEventListener(app-reminder-updated, handleReminder); if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } }; }, []);排查完这个之后内存曲线的持续阶梯式增长明显停止了。这个坑告诉我们写了清理逻辑不等于清理逻辑覆盖了所有场景像React严格模式的双调用、异步任务的竞态条件都会让监听器“赖”在全局对象上不走。5. 优化效果复盘数字才是最好的说服力所有代码修改完成之后我来来回回按照同一条路径跑了多次性能录制进入列表页切换日期范围滚动表格到底部再回到顶部输入搜索关键词等待结果展示展开一行详情最后返回列表页。每次流程操作方式和间隔尽量保持一致这样录制的数据才有可比性。为了直观看到每一步优化的贡献我分别记录了四次数据不做任何优化的初始版本加格式化缓存的版本再加组件渲染隔离的版本最后加上防抖和完整状态拆分后的版本。版本阶段交互响应耗时峰值主线程阻塞平均FPS滚动阶段初始版本约2.8秒3.1秒约8帧格式化缓存约1.9秒2.2秒约12帧渲染隔离/memo约0.9秒0.8秒约35帧防抖与状态拆分约0.4秒0.2秒约55帧从这几组数字可以看得很清楚格式化缓存单独提升的幅度有限可能因为表格本身行数不算多真正质变发生在渲染隔离和状态拆分落地之后。它直接砍掉了大量无关的re-render让交互响应时间从秒级掉到了毫秒级。最后加上防抖把偶发的主线程长时间占用问题也抑制住了。最终实测结果交互响应大约400毫秒滚动时的平均帧率保持在55帧上下达到了我先前的优化目标。除了Performance面板上的数字还有一个体感指标可以作为辅助参考在优化之前页面右上角刷新按钮点击后整个页面会短暂出现一段白屏loading动画闪烁优化之后刷新按钮几乎单击立即出现内容反馈loading动画基本来不及完整展示。这种小细节用户可能说不上哪里变好了但整体感觉就是“顺畅了很多”。如果你正在处理一个类似的前端性能问题我的建议是先不要急着套用什么高级方案老老实实打开Performance面板跑一圈从火焰图里找到耗时函数和无关渲染的根源再针对性地“减负”和“隔离”。性能优化不是玄学数据早就把答案写好了你只需要愿意花时间去读它。最后再分享一个保持成果稳定的经验性能问题很容易在后续迭代中悄悄复发。新增代码如果随手在父组件里写内联函数、随手在render里做数据处理没多久优化效果就会被抵消。我现在会在Code Review阶段额外检查两点一是新组件是否有必要用memo包裹二是回调函数引用有没有保持稳定。把这两条作为日常约束性能才不会出现“优化一时爽回归火葬场”的尴尬局面。
返回列表