ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript性能优化实战:从防抖节流到虚拟滚动的完整闭环

JavaScript性能优化实战:从防抖节流到虚拟滚动的完整闭环 做前端这些年被问得最多的问题就是“这个页面怎么这么卡”十个里面有八个都能追溯到JavaScript性能优化没做到位。我最早处理线上卡顿的时候也懵一上来就背八股文什么防抖节流虚拟滚动全部糊上去结果该卡的还是卡。后来我才慢慢想明白JavaScript性能优化不是几招野路子的堆砌而是一套从测量到定位从定位到改造再从改造回到验证的完整闭环。这篇文章我就把这套闭环掰开揉碎了讲从浏览器一帧的渲染预算说起到代码执行层的防抖、节流、DOM操作、内存管理再到渲染层的requestAnimationFrame、虚拟滚动、Web Worker最后配上完整的测量工具图谱以及一个移动端长列表的实战优化全记录。适合正在被线上卡顿问题折磨的前端同学也适合想系统建立性能优化知识体系的初学者。内容尽量说人话能上代码就上代码能贴数据就贴数据。1. 先搞明白JS性能优化到底在优化什么在动手之前我先端正一个态度JavaScript性能优化不等于“代码写得快”。很多时候你花一整天把某个排序算法优化了很多页面该卡还是卡你只是把一个滚动事件里的同步任务挪到下一帧体验立刻上来一个档次。因为现代浏览器碰到的大部分瓶颈都不在JS本身的计算速度而在JS与渲染管线之间的协作方式。1.1 一帧只有16.6ms钱要花在刀刃上先建立一个核心认知浏览器不是代码的搬运工他是导演。每一帧浏览器都要按照一套固定的流程工作先执行JavaScript脚本再计算样式再做布局然后是绘制最后合成输出到屏幕。在60Hz刷新率下这个完整流程只有16.6ms。一旦整个流程跑完的时间超过16.6ms屏幕就赶不上刷新表现出来就是掉帧、卡顿、滚动不跟手。这个16.6ms的概念用生活里的话说就好比你只有十六秒钟的时间做一顿饭切菜、炒菜、装盘都得在里面完成一个环节超时整桌菜全晚点。移动端环境更苛刻CPU频率更低、内存更小能分给JavaScript的预算往往只有不到10ms。所以JS性能优化的第一个指导思想不是把代码优化到极致而是识别出哪些代码跑在了关键的16.6ms里把它们变轻、变少或者干脆挪走。1.2 计算效率和渲染效率两条腿缺一不可JavaScript性能优化细分下来其实是两个维度的活。一个是计算效率同样的功能函数算法复杂度是否合理有没有在循环里做重复计算有没有因为隐式类型转换白白浪费性能。另一个是渲染效率你操作DOM的方式是否触发了额外的布局计算事件的触发频率是否超出实际需求动画有没有通过正确的通道去驱动。计算效率关注的是CPU怎么干活渲染效率关注的是浏览器在绘制流水线上跑了多少冤枉路。这两个维度经常互相牵扯比如你在一个高频滚动的回调里改了DOM样式它又会立刻引发新的布局计算两个问题叠在一起性能问题就被放大了。所以做优化的时候我一直习惯先把渲染层的问题按下去再回头清计算层的账因为渲染层问题的收益往往是肉眼可见的、一次性的计算层的优化则更多是积少成多。1.3 先测量再优化别靠感觉写代码我见过太多人做性能优化的起点是“我觉得这里慢”然后凭感觉疯狂改代码。这是最大的误区。性能问题如果不量化你根本不知道瓶颈在哪儿甚至改了之后也不知道有没有变好。我自己的习惯是接到任何卡顿反馈以后第一件事不是读代码而是先开Chrome的Performance面板录一段操作看看到底哪一帧超时了是脚本执行时间太长还是强制同步布局太频繁然后对着火焰图精准定位到某一行代码。优化完了再录一次对比长任务数量、帧率、内存占用。数据不会说谎。只有建立了这样的闭环你的每次优化才是有据可依的而不是在大海捞针。2. 代码执行层的四个高频优化点这节里讲的都是我自己在日常开发里碰见频率最高的坑。它们不深奥但每一个都实打实影响体验。2.1 高频事件扛不住防抖和节流安排上先说防抖和节流。这两个词大家都不陌生但真正用对的人不多。它们的本质都是在控制函数执行的频率解决同一个问题有些事件触发得远比你需要处理的频率高得多比如滚动、缩放、输入、鼠标移动。防抖debounce的思路是“等消停了我再干”你连续触发事件只有最后一次触发之后等待一定时间才执行回调。适合的场景是搜索框联想、窗口大小调整后的重算因为这些场景你关心的是最终状态连续的中间态没有意义。节流throttle的思路则是“干完一票之前不许再干”固定时间间隔内最多执行一次。适合滚动的懒加载判断、按钮点击后的限流因为这类场景需要保证一定的执行频次但又不能每次触发都执行。// 防抖最后一次触发后 delay 毫秒才执行 function debounce(fn, delay 300) { let timer null; return function (...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); timer null; }, delay); }; } // 节流每 interval 毫秒至多执行一次 function throttle(fn, interval 100) { let last 0; return function (...args) { const now Date.now(); if (now - last interval) { last now; fn.apply(this, args); } }; }我建议把这两个函数封装成公共工具不要每个组件里各写一份因为边界条件很容易写错。实际用的时候还有一个容易被忽略的点带状态更新的回调要谨慎处理闭包里的旧值否则防抖节流失效这一点在React里踩坑的人特别多。另外防抖和节流不是互斥的有些场景可以组合用比如输入框既要实时校验又要防止请求轰炸就可以先用节流控制请求频率再用防抖等待停顿后的最终值。2.2 DOM操作是性能黑洞得学会省着花浏览器里最贵的事情之一就是操作DOM。DOM操作本身的成本由两部分组成一是节点的查找和构建二是操作后触发的样式计算、布局、绘制和合成。后者的成本往往比前者大一个数量级。常见的坑有三个。第一个是频繁读写DOM属性比如在循环里反复读取offsetWidth、offsetHeight这类布局属性这会导致浏览器强制同步布局也就是所谓的“布局抖动layout thrashing”。第二个是不做批量处理给同一个节点一小时改一次样式浏览器就得重新计算一轮。第三个是不缓存DOM引用每次事件回调里都重新document.querySelector一遍。批量操作最优雅的解决方案是DocumentFragment。它相当于一个虚拟的DOM容器你在Fragment上怎么折腾都不会触发布局等全部改完再一次性挂到真实DOM上浏览器只做一次布局。const fragment document.createDocumentFragment(); for (let i 0; i items.length; i) { const li document.createElement(li); li.textContent items[i].name; fragment.appendChild(li); } list.appendChild(fragment);另一个我很推荐的习惯是能用CSS类搞定的事情绝不直接改style。比如要给一个元素切换多种样式最差的做法是在JavaScript里一行一行设style更好的做法是定义一个切换类把样式全放在CSS里JavaScript只负责className的增删。这样既减少了布局触发的次数又能借着浏览器对CSS类变更的优化省下不少计算。注意DocumentFragment并不是万能药。如果你的列表非常大一次性构建几千个节点仍然会阻塞主线程。这种场景后续的虚拟滚动才是正解Fragment只能帮你把“多次布局”压缩成“一次布局”。2.3 从字符串拼接看到隐式类型转换的代价很多刚入门的同学对隐式类型转换没有概念觉得反正JavaScript很灵活数字和字符串相加也无所谓。但这类“灵活”在性能上是有代价的。比如经典的字符串大量拼接场景// 不推荐在循环里用 拼接大量字符串 let result ; for (let i 0; i 10000; i) { result item[i].name ,; }每次循环都会有一次字符串的分配和拷贝随着result越来越长拷贝的成本越来越高整体复杂度接近O(n²)。换成数组的join或者ES6的模板字符串性能提升非常明显。// 推荐用数组收集一次 join const parts []; for (let i 0; i 10000; i) { parts.push(item[i].name); } const result parts.join(,);同样的逻辑也适用于循环里的类型判断。如果能让变量在循环内保持单一类型JavaScript引擎的JIT编译器就能大胆优化这是V8引擎里一个非常现实的工作原理。不要小看这些细节在大数据量计算、日志上报、表格渲染场景里微小的性能差别会被放大很多倍。2.4 内存泄漏是慢性病全局变量和闭包最要当心性能优化不能只看瞬时速度还得看长时间运行后的稳定性。内存泄漏就是那种“一开始不卡越用越卡”的经典元凶。它的原理很简单本可以被垃圾回收掉的内存因为某个引用一直没断导致GC永远无法释放。内存占用不断上涨最后浏览器开始频繁触发Full GC主线程被长时间阻塞页面就卡死了。最常见的泄漏来源有三个。一是全局变量挂在window上的对象永远活着尤其是不小心在全局作用域里存储了大数组、大对象。二是事件监听器没有移除在组件卸载或页面切换时如果忘记removeEventListener回调里引用的DOM节点和对象就全被抱着不放。三是闭包一个长期存活的外层函数如果内部引用了大对象外部又一直持有这个闭包整个对象链都释放不掉。// 容易泄漏的写法定时器引用了需要销毁的数据 const data loadLargeData(); const timer setInterval(() { console.log(data.length); // data 永远无法释放 }, 1000); // 修正用完后清理定时器并解除对大对象的引用 const timer setInterval(() { console.log(data.length); }, 1000); // 页面卸载时必须 clearInterval(timer)解决思路也很直接用完的监听器记得移除定时器记得清除大数据对象在使用完以后主动置为null容器组件卸载时做一个统一的清理函数。还有一个很实用的工具是WeakMap和WeakSet它们对键是弱引用不会阻碍垃圾回收非常适合用来建立对象到临时缓存之间的关联既不会拖累GC又能拿到缓存数据。3. 渲染层流畅体验的三个关键手段代码执行层优化完接下来是对渲染管线动手。这一层优化得好页面才能真正称得上“流畅”。3.1 requestAnimationFrame跟着刷新率走别乱来JavaScript里做动画最常见的写法是用setTimeout或者setInterval去循环更新这个写法有一个天然缺陷定时器不会和屏幕的刷新节奏对齐。你设置的间隔时间跟浏览器实际刷新率之间会有微小的相位差导致某些帧里回调落在两次刷新中间动画会出现肉眼可见的顿挫也容易造成丢帧。requestAnimationFrame是浏览器专门提供的一套机制它的回调会在下一次屏幕刷新之前执行保证你的每一次更新都精确落在帧的边界上。这是做动画的正确姿势尤其适合配合滚动、拖拽这类跟视觉反馈强相关的高频更新。function smoothScrollTo(targetY) { const startY window.scrollY; const distance targetY - startY; const duration 300; const startTime performance.now(); function step(currentTime) { const progress Math.min((currentTime - startTime) / duration, 1); window.scrollTo(0, startY distance * progress); if (progress 1) { requestAnimationFrame(step); } } requestAnimationFrame(step); }另外要记住requestAnimationFrame在没有切换标签页时会自动暂停这其实是好事能节省电池和CPU。但如果你的需求是页面在后台也要持续计时那就得另用setInterval所以两者的选择按场景来别一棒子打死。实测下来把高频的滚动数据处理统一放到requestAnimationFrame里配合上一轮说的防抖节流滚动掉帧的问题基本就解决了一大半。3.2 长列表虚拟滚动只渲染看得见的长列表是性能重灾区。我见过很多后台系统为了省事直接把一万行数据全部渲染成DOM节点滚动的时候浏览器光是维护这么多节点的样式和布局就已经忙不过来了。虚拟滚动的思想非常简单既然用户一次只能看到视口里的那几条那我只需要渲染可视区域前后一小段就够了滚动时动态更新这一段的内容。实现一个最基本的虚拟滚动核心参数有三个每行高度固定值、可视区域高度、缓冲区大小。可视区高度除以行高得到需要渲染的行数再额外渲染前后各N行作为缓冲区避免快速滚动时闪白。滚动事件触发后根据scrollTop算出起始索引把偏移量通过transform或padding的方式作用到容器上让视觉上列表看起来仍然是完整的。function updateVisibleRows(scrollTop, rowHeight, viewportHeight, buffer 5) { const startIndex Math.max(0, Math.floor(scrollTop / rowHeight) - buffer); const endIndex Math.min( totalCount, Math.ceil((scrollTop viewportHeight) / rowHeight) buffer ); // 只渲染 startIndex 到 endIndex 之间的数据 renderItems(startIndex, endIndex); // 给容器设置偏移量让未渲染的条目在视觉上仍在原位 container.style.transform translateY(${startIndex * rowHeight}px); }如果行高不固定就需要在渲染过程中动态测量和记录位置复杂度会高一截但思路不变永远只维护可视区域内的DOM。现在很多组件库都内置了虚拟滚动移动端尤其推荐使用数据量超过两三百条的时候收益是肉眼可见的。我自己在做的信息流类项目里虚拟滚动把DOM节点数从几千降到了几十滚动跟手程度完全不是一个量级。3.3 计算量太大分片和Web Worker来帮忙不是所有任务都适合塞进主线程。比如一个需要处理几万条数据的图表页主线程既要算数据又要渲染UI压力非常大。这种情况下我会先问自己一个问题这个计算能拆成很多小段分散到多个空闲时间去做吗能拆就用分片不能拆就考虑Web Worker。分片的核心思想是把一个大循环变成多个小循环每次在浏览器空闲或者一帧的间隙执行一小段。最直接的实现是结合requestAnimationFrame把大批量任务按帧拆开。更现代一点可以配合requestIdleCallback在浏览器真正空闲的时候去执行低优先级的预处理。function processInChunks(data, chunkSize 500) { let index 0; function processNext() { const end Math.min(index chunkSize, data.length); for (; index end; index) { // 处理单条数据 processItem(data[index]); } if (index data.length) { requestAnimationFrame(processNext); } } requestAnimationFrame(processNext); }Web Worker则是把计算任务丢到单独的线程。它在后台线程里可以放心地做JSON.parse、数据排序、图片像素计算等纯CPU任务唯一不能做的是操作DOM和访问window对象。用Worker有个要注意的地方线程间通信是通过postMessage传递消息数据会被结构化克隆拷贝一份所以不要把巨大的对象直接丢过去再丢回来否则通信成本可能抵消掉计算节省下的时间。大数据量场景可以尝试用Transferable对象转移所有权在支持的浏览器里能避免拷贝。4. 性能量化不会测量等于没优化做优化之前先把“尺子”准备好。这一节我讲讲我常用的测量工具和指标基本上够应付日常开发里的绝大多数情况了。4.1 Lighthouse一键体检Lighthouse是Chrome内置的一套性能审计工具它能自动对页面做一次加载过程中的性能分析给出一份包含多项指标的体检报告。用法也很简单F12打开开发者工具切到LightHouse标签页选好设备类型和模拟网络环境点生成报告就行。移动端模拟一般选择Moto G4这类中低端设备网络模拟用Fast 4G或者Slow 4G这样测出来的数据更接近真实用户遇到的场景。报告里我重点看四个指标LCPLargest Contentful Paint最大内容绘制时间反映页面主要内容出现得快不快。INPInteraction to Next Paint交互到下一帧的时间反映点击、输入等交互的响应速度。CLSCumulative Layout Shift累积布局偏移反映页面元素有没有跳动。TBTTotal Blocking Time总阻塞时间反映主线程被长任务霸占的时长。正常情况下这些指标都能直接映射到用户体验上。Lighthouse还会直接给出诊断建议比如哪个请求耗时过长、哪张图片可以再压缩、哪些第三方脚本阻塞了渲染照着报告一项项改效率很高。我一般是在完成一轮优化后跑一次Lighthouse把数据记录下来作为后续回归的基线。4.2 Chrome Performance面板定位真实卡顿点Lighthouse看的是加载阶段但运行时卡顿还是得靠Performance面板。用法是打开面板后点击录制然后去页面里做一遍会卡顿的操作录制完停止面板会生成一条完整的主线程时间线。我最常用的操作是按住主线程色条往下拉放大看每一帧里到底干了什么。如果发现某一帧的Scripting或Rendering时间特别长展开任务就能看到具体的函数名和耗时甚至能直接定位到源码位置。里面有几类典型标记需要认识长任务Long Task会在时间线上标深红色强制同步布局会以红色三角形警告标识频繁的样式重算会集中在某一块区域。这种现场级别的定位远比猜测有用得多。我第一次用Performance面板找出一个卡顿根因的时候发现是某个图表库在每次数据更新时都重新创建了整个SVG而不是做增量更新这就是典型的“没测量就想不到”的问题。4.3 自己埋点用数据说话工具只能覆盖一部分场景真要量化某段代码的耗时还得在自己的代码里埋点。最轻量的是console.time和console.timeEnd适合本地调试。更正规的方案是用Performance API打标记核心是Performance.mark和Performance.measure。performance.mark(list-start); // 执行列表渲染逻辑 performance.mark(list-end); performance.measure(列表渲染耗时, list-start, list-end); const measures performance.getEntriesByName(列表渲染耗时); console.log(measures[0]?.duration);线上场景还要考虑数据上报。我的做法是把关键操作耗时放在一个统一的采集函数里打点后异步上报到数据分析平台这样就能在发布新版本后观察到真实用户环境的性能变化。注意打点本身不要占太多主线程时间否则就是拿性能去换数据。5. 实战一个移动端长列表的完整优化过程理论讲了不少我拿一个真实项目来串联一遍。这是一个移动端的信息流列表每屏大概20条卡片单个列表页数据量在3000条左右。用户反馈“滑动起来很累像是拖着沙袋”。5.1 问题现场与初步定位我先用Performance录制了一段操作从进入页面开始记录了列表渲染、滚动、点击这些动作。回放时发现三个问题第一首屏渲染时主线程有个大约120ms的长任务卡在了一个脚本文件上第二滚动过程中频繁出现Scripting和Rendering同时超长的情况第三内存曲线持续上涨没有回落迹象。进一步看火焰图长任务来自一个把所有Card内容一次性遍历生成并innerHTML到容器里的函数。滚动时的卡顿则来自每个卡片内嵌的文字自动截断逻辑它在滚动时反复读取offsetWidth去测量文字宽度。这不就是前面说的布局抖动吗测量值读取时机不对导致每滚一下就要重新布局。5.2 四步优化逐步实施第一步把首屏渲染从全部构建改为可视区域构建直接引入虚拟滚动方案。固定卡片高度预定义一个缓冲区滚动时只渲染当前视口附近的节点。这一步做完首屏时间从800ms直接降到200ms。第二步改造卡片的DOM生成逻辑。把原本一段超大的innerHTML字符串改为DocumentFragment逐条构建并且把卡片内部的事件监听全部收拢到列表容器上用事件委托处理。这样既减少了DOM重复解析又减少了监听器数量。第三步处理文字截断的测量逻辑。把滚动过程中的测量逻辑挪到requestAnimationFrame里并且先读完所有宽度再统一做截断处理避免读写交错导致的强制同步布局。第四步图片全部启用懒加载。列表卡片里的图片没有进入视口范围时不加载滚动进入视口后再加载。同时给图片加了固定的宽高占位避免CLS指标恶化。提示每一步做完我都建议立刻跑一遍Performance确认效果而不是四步全做完再一起验证。这样每一步的收益都是清晰的出了新问题也好定位是哪个改动引起的。5.3 优化效果对比数据是会说谎的但曲线不会优化完成后我重新录制了同样一段滚动操作做了几组数据对比指标优化前优化后首屏渲染时间约800ms约200ms滚动时的长任务数量平均每2秒1个最长120ms几乎没有最长约15msDOM节点数量约3000个约40个含缓冲区内存占用曲线持续上升稳定在约60%内存水位Lighthouse性能评分45分92分这个项目最有价值的部分是所有改动都没有改业务逻辑只改了渲染策略和执行时机。真正让列表流畅起来的东西不是多厉害的算法而是老老实实的“少做点渲染、把测量放对位置、把计算挪到该去的地方”。整个实战过程测量工具的功劳至少占一半。6. 常见问题与排查技巧速查最后一部分我把这些年在性能优化上踩过的坑整理成一张速查表方便大家出问题的时候直接翻。6.1 滚动卡顿三板斧滚动卡顿大概率逃不出三个原因。一是滚动回调里有重活解决思路是拆小任务、丢给分片。二是在滚动回调里触发了强制同步布局解决思路是分离读写用requestAnimationFrame合并。三是节点太多了尤其是长列表渲染了所有数据解决思路是虚拟滚动。排查顺序我建议是从易到难先把DevTools的Rendering面板开启Paint Flashing看看是不是同一块区域一直在重绘开启之后继续滚动如果重绘区域巨大优先排查样式和图层问题最后再按Performance的记录去追主线程时长。6.2 内存泄漏的识别和定位内存泄漏识别最简单的办法是在Performance面板里录制一段长时间操作观察内存曲线是否斜着一直往上走。如果是水平线或冲高回落基本正常如果只升不降就要开始查了。定位技巧是先打开Memory面板做堆快照在操作前后各截一次对比两份快照里多出来的对象。重点看那些在操作后仍然被引用的对象逐层展开它们的引用路径就能找到是哪个变量还攥着不放。实践中我发现很多泄漏都是事件监听导致的组件卸载时没有做监听清理所以可以先从代码里搜索所有addEventListener确认对应的removeEventListener都存在。6.3 问题排查速查表症状常见原因排查方法解决方向页面加载漫长同步脚本过大、图片未压缩、未做懒加载Lighthouse抓加载瀑布流拆包、压缩、懒加载、异步加载滚动掉帧长列表全量渲染、滚动回调重Performance定位长任务虚拟滚动、防抖/节流、分片点击后反馈迟滞主线程被长任务霸占Performance观察长任务计算拆分、Web Worker页面越用越卡内存泄漏、DOM节点大量残留Memory快照对比清理监听器、弱引用、卸载清理动画一顿一顿使用setInterval驱动动画肉眼观察Rendering面板切换到requestAnimationFrame强制同步布局循环中读写布局属性代码审查Performance红色警告分离读写、批量操作、使用CSS类输入响应慢输入事件里做了大量过滤和格式化或触发了类型转换打点测量事件函数耗时防抖、减少同步逻辑、分片我经常在团队里说一句话性能优化不是一个独立的专项而应该是每一次代码评审里默认带上的一副眼镜。你不用一开始就把所有优化手段都记住只需要保持那种“这段代码会不会在16.6ms的预算里超支”的意识然后不断用测量工具去验证自己的判断。最后再分享一个小技巧我自己用了很久在代码里给高频路径的关键操作做一个logDuration的公共函数同时区分本地和线上开关。本地开全量线上只上报超过阈值的样本。这样既不会拖累线上性能又能持续积累真实数据。你会发现你判断代码性能水平的能力会在一次次数据反馈中变得特别准。
返回列表