前两天,组里的同事拿一个页面来让我看:两千行的表格,每次勾选一个复选框,整个页面都要卡个半秒。我打开Chrome的Performance面板一查,问题出在一个再常见不过的JavaScript性能优化场景——状态变更后,整张表格的DOM节点被全部重建了一遍。
我这些年做前端,遇到的大多数“页面卡顿”问题,根源都不是什么高深算法,而是那些每天都在写的、看起来人畜无害的小操作:DOM改得太频繁、事件绑得太多、该缓存的地方不缓存、该拆的包不拆。JavaScript性能优化,本质上就是把这类“小账”一笔笔算清楚。
这篇文章我梳理了10个在自己项目里验证过、能直接落地的实战技巧。适合两类人:一种是页面已经卡了但不知道从哪下手的同学,另一种是功能能跑、但想系统补齐性能知识的开发者。每个技巧我都会讲清楚原理、放出代码,再聊聊实际使用中容易踩的坑。
1. 动手之前,先把性能问题从“感觉”变成“数字”
很多项目里最典型的错误是:上来就优化,不问到底哪里慢。有人觉得是DOM操作的问题,就一顿改成虚拟DOM;有人觉得是列表的问题,就上一堆复杂组件,结果改完发现该卡还是卡。性能优化最忌讳的就是凭感觉下手。
正确顺序是先量化,再动手。量化工具用现成的就行,浏览器已经帮你准备好了。
1.1 用Performance面板揪出最长的任务
Chrome开发者工具里的Performance面板是排查交互卡顿的第一站。操作路径不长:
- 打开开发者工具,切到Performance标签。
- 点击左上角的录制按钮。
- 去页面上复现你的操作(勾选、滚动、点击,一次只复现一个问题)。
- 停止录制,看底部时间轴里标成红色的长任务条。
长任务(Long Task)是指耗时超过50毫秒的主线程任务,超过这个阈值用户就会感觉到“卡”。点开任务条,能看到这段JS代码的调用栈,哪个函数占了大部分时间,在这个面板里会直接标出来。比如我同事那个表格页面,录制之后长任务接近400毫秒,展开栈一看,renderTable函数独占大头——代码在状态变更后把整张表格的内部DOM重拼了一遍。
还有个更轻量的排查技巧:Chrome里按Shift+Esc打开浏览器自带的任务管理器,可以看到每个标签页的CPU和内存占用。它可以快速定位“是不是某个页面在狂吃资源”,不用写一行代码就能过滤掉环境干扰。
1.2 建立基线数据,别让优化变成一场盲猜
优化前先记录一组数字,我习惯把这组数字叫“基线”。比如:勾选一次复选框卡400毫秒、滚动列表掉帧率是X、首屏加载要3秒。改完代码之后再跑一遍同样的操作,得出一组新数字,优化前和优化后才有对比,你才知道自己做了多少有效工作。
这组基线数据不需要多么专业,重要的是“可复现”——用同一套操作、同一个浏览器、同一个网络条件去测。我见过有人在优化前用公司电脑测,优化后用自己的Mac测,最后得出结论“优化了50%”,实际上同一套代码在Mac上本来就更快。这种对照方式是无效的。
1.3 按影响面排序:先处理真正卡的那20%
量化之后的下一步是排序。一个页面慢,可能同时存在接口慢、渲染慢、内存泄漏等多个问题。这时候遵循80/20法则:先把真正背锅的那一小块代码找出来。
举个例子,如果一个页面的数据接口响应就要2秒,你花三天优化前端渲染,用户感知几乎为零。这时候应该先去Network面板看接口耗时长在哪里——是后端查询慢还是数据量太大。反过来,如果接口50毫秒返回,前端却花了400毫秒去渲染,那问题就在你的JavaScript代码里。Performance面板能告诉你的就是这个事实:瓶颈在主线程,还是在网络层。排完序之后,再往下看具体的优化手段。
2. 渲染链路四板斧:从DOM批量更新到事件委托
渲染层的优化是收益最直接的。用户在页面上看到的一切变化,最后都要经过DOM,而DOM操作恰恰是JavaScript性能的主要开销点之一。
2.1 技巧1:用DocumentFragment批量更新DOM节点
先说一个最常见的反模式:在循环里逐个插入节点。
// 慢:每一次循环都触发一次 DOM 插入 for (let i = 0; i < 1000; i++) { const item = document.createElement('div'); item.textContent = `第 ${i} 条`; document.getElementById('list').appendChild(item); }这段代码会让浏览器在每次appendChild时都重新计算布局,1000次循环就是1000次重排。优化方法是把节点先放到内存里的“临时容器”中,最后一次性挂载:
// 快:先在 DocumentFragment 里攒好,最后一次性插入 const fragment = document.createDocumentFragment(); for (let i = 0; i < 1000; i++) { const item = document.createElement('div'); item.textContent = `第 ${i} 条`; fragment.appendChild(item); } document.getElementById('list').appendChild(fragment);原理不复杂:DocumentFragment是一个脱离文档流的节点容器,你往里面塞节点不会触发文档重排。等所有节点都准备好了,一次appendChild(fragment)才真正把内容挂进页面,触发一次重新布局。
这个思路还可以延伸出另一个重要原则:读写分离。浏览器在“读”一些布局属性(比如offsetHeight、getBoundingClientRect)时,如果前面有尚未提交的“写”操作,会强制同步布局来保证读到的是最新值。看下面这段:
// 错误:循环里反复读 offsetHeight 又改 style,每次都会强制同步布局 for (let i = 0; i < items.length; i++) { const h = items[i].offsetHeight; items[i].style.height = h * 2 + 'px'; } // 正确:先统一读,再统一写,把布局刷新次数降到最低 const heights = items.map(item => item.offsetHeight); heights.forEach((h, i) => { items[i].style.height = h * 2 + 'px'; });“先读后写”这条经验,在写拖拽组件、表格自适应、元素测量工具时尤其好用。
2.2 技巧2:事件委托,用1个监听器代替N个
事件委托的原理是事件冒泡:点击任意一个子元素,事件会一直冒泡到父节点。所以在父节点上挂一个监听器,就能处理所有子元素的同类事件。
// 慢:1000 个 li 就要绑 1000 个事件 document.querySelectorAll('.list li').forEach(li => { li.addEventListener('click', () => { /* ... */ }); }); // 快:父节点上只绑一次 document.querySelector('.list').addEventListener('click', e => { const li = e.target.closest('li'); if (!li) return; // 处理 li 的点击 });这个技巧的收益有两方面:第一,内存上从1000个监听器变成1个,差距明显;第二,动态添加的列表项天然被覆盖,新增的元素不需要重新绑事件。
使用时有几个注意点。一是判断目标要准:e.target往往不是li本身,而是li里的span、文字节点或者其他子元素,用closest('li')可以稳稳地拿到最近的li;二是如果列表里同时有不同操作按钮,可以在li上用>function debounce(fn, delay = 300) { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); }, delay); }; }
节流的代码实现:
function throttle(fn, interval = 200) { let last = 0; return function (...args) { const now = Date.now(); if (now - last >= interval) { last = now; fn.apply(this, args); } }; }业务上的用法很固定:搜索联想用防抖,因为用户停下来以后请求才有意义;滚动页面加载更多用节流,保证滚动过程中会触发但不会每秒触发十几次。
实际项目里我还会给节流补一个“尾巴”:如果最后一次滚动发生在间隔之后但又被丢弃了,用户可能停在页面底部而加载却没有触发。这时候可以在节流里再加一个定时器,保证最后一次调用一定会补执行。
2.4 技巧4:动画优先用requestAnimationFrame
用setInterval或setTimeout做动画,时间是不可预测的。浏览器要重排、绘制,定时器执行时机并不固定,可能这一帧执行了两次回调,下一帧又没执行,视觉上就表现为抖动。而requestAnimationFrame(简称rAF)是和浏览器的渲染周期绑定的:屏幕60Hz时,它大约16.7毫秒执行一次,正好卡在浏览器重绘之前。
一个用rAF实现的进度条动画:
const bar = document.getElementById('bar'); let start = null; const duration = 1000; function step(timestamp) { if (!start) start = timestamp; const progress = Math.min((timestamp - start) / duration, 1); bar.style.width = progress * 100 + '%'; if (progress < 1) { requestAnimationFrame(step); } } requestAnimationFrame(step);注意到一个细节了吗:动画进度不是每次加一个固定数值,而是通过timestamp时间戳来计算。这样即使浏览器掉帧,动画在时间轴上也是准确的,不会出现“跑慢了”或者“跳一下”的问题。
rAF还有个隐藏优势:页面切到后台标签页时,rAF会自动暂停,等切回来再继续,不会白耗CPU。而setInterval在后台会被浏览器限频甚至积压,积压的回调会在切回前台的瞬间一次性执行完,反而造成一次卡顿。
3. 计算层降本增效:缓存、数据结构与并行
渲染只是JavaScript性能的一部分,另一部分重头戏在计算。数据量一大,任何不够优雅的算法都可能成为卡顿的来源。
3.1 技巧5:记忆化缓存,让重复计算只算一次
记忆化的思路是:既然某些函数对相同的输入总是产生相同的输出,那我把第一次的结果缓存起来,下次同样的参数进来直接返回结果,不再重复计算。
最经典的案例是递归求斐波那契数列。不带缓存的时间复杂度是指数级的,算到fib(50)基本就要卡死页面了;加上一个Map缓存,性能立刻变成线性级:
const cache = new Map(); function fib(n) { if (n <= 1) return n; if (cache.has(n)) return cache.get(n); const result = fib(n - 1) + fib(n - 2); cache.set(n, result); return result; }实际业务中,记忆化更适合那些“输入参数模式有限、计算本身较重”的纯函数,比如复杂数据的格式化、模板字符串拼接、某个配置对象的深拷贝。写一个通用的memoize包装函数也不难:
function memoize(fn) { const cache = new Map(); return function (...args) { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result = fn.apply(this, args); cache.set(key, result); return result; }; }用JSON.stringify做key有个天然的坑:如果参数是循环引用对象会报错,而且对象属性顺序变化会导致缓存失效。所以这个通用版本适合简单参数场景,复杂场景更建议按业务类型手写缓存逻辑。
记忆化还有个容易忽略的问题:缓存本身也是内存。如果缓存key无限增多,Map会越占越大。生产环境里我会给缓存设置上限,比如超过一定数量就清掉最旧的一批,或者干脆定时清理。
3.2 技巧6:用Map/Set替代数组查找,把O(n)变成O(1)
JavaScript里数组的includes和findIndex是线性查找,数据量一大、调用一多,累加起来很吓人。而Map和Set底层是哈希结构,查找操作平均时间复杂度是O(1),差距非常大。
一个高频场景:判断某个ID是否在白名单里。
// 慢:每次判断都要扫描整个数组 const ids = [1001, 1002, 1003, ...]; if (ids.includes(targetId)) { /* ... */ } // 快:Set 的 has 方法接近 O(1) const idSet = new Set(ids); if (idSet.has(targetId)) { /* ... */ }另一个高频场景:频繁根据ID更新列表项。
// 数组方式:findIndex 是 O(n) const index = users.findIndex(u => u.id === id); users[index].name = newName; // Map方式:get 是 O(1) const userMap = new Map(users.map(u => [u.id, u])); userMap.get(id).name = newName;坦率地说,数据量在几百条以内时,用数组还是Map感受不出差别。真正拉开差距的是“在循环里做查找”:假设一个2000行的表格,勾选操作要判断当前行是否已选中,每次判断都调用includes扫一遍2000个元素的数组,最坏情况一轮循环下来就是数百万次比较。这种情况换成Set,效果立竿见影。我的经验是:只要代码里出现了“循环里套查找”的结构,就该立刻考虑Map或Set。
3.3 技巧7:把重计算丢给Web Worker
JavaScript主线程承担了所有DOM操作和大部分业务逻辑,一旦计算任务超过50毫秒,页面就会出现长任务,用户点什么都像在“死机”。Web Worker的价值就在于:它可以开一个独立的后台线程跑计算,主线程继续响应UI操作。
典型的应用场景:大量数据的排序和筛选、超大JSON的解析、图片像素级处理、复杂的加密计算。基本代码结构很固定:
// 主线程 const worker = new Worker('process.js'); worker.onmessage = e => { console.log('计算结果', e.data); }; worker.postMessage(largeData); // process.js self.onmessage = e => { const result = heavyCompute(e.data); self.postMessage(result); };Worker的使用有三个必须知道的边界。第一,Worker里没有window和document,不能操作DOM;第二,postMessage通信有结构化克隆的开销,如果数据动辄几十MB,传输本身可能比计算还慢;第三,如果传的是ArrayBuffer这类二进制数据,可以“转移所有权”而不是拷贝,代价是主线程里原来的ArrayBuffer就空了:
// 第二个参数是转移列表:不再复制,直接把内存移交给 Worker const buffer = new ArrayBuffer(1024 * 1024 * 50); worker.postMessage(buffer, [buffer]);什么时候该上Worker?我的判断标准很简单:预计单次计算超过100毫秒,并且操作是纯数据的、不依赖DOM,就值得走Worker。用上之后页面至少不会在计算过程中变成“假死”状态。
4. 两个容易被忽略的内存杀手
优化做久了你会发现,卡顿可以被分为两类:一类是瞬时的高负载,处理完就好了;另一类是内存泄漏引发的长期劣化——页面刚打开挺流畅,越用越慢,最后彻底卡死。后者的元凶,很多时候就是下面这两个“不起眼”的习惯。
4.1 技巧8:定时器和全局引用不清,内存只涨不降
先看一个典型的SPA场景:某个页面启动了一个setInterval,代码写在组件里,但页面切走了,定时器还活着。
// 组件挂载时 this.timer = setInterval(() => { // 每秒钟刷新一次某个统计图 refreshChart(); }, 1000); // 组件卸载时必须清掉 clearInterval(this.timer);如果不清,定时器会一直执行。更麻烦的是,如果这个定时器的回调里引用了一个很大的数据对象,那么即使页面已经切走,这个对象也会被定时器一间接引用,垃圾回收器永远不会把它回收掉。页面的内存占用就是这么一步步涨上去的。
类似的还有全局变量。有人习惯把大数组、离屏Canvas对象挂在window上,用完不置空,后续页面生命周期里就一直被引用着。这在单页应用里是致命的:所有曾经的页面实例都被全局变量拽着,无法释放。
排查内存泄漏可以借助开发者工具的Memory面板:切到Memory标签,先拍一个Heap Snapshot,做几轮页面进出操作,再拍一次,看堆内存总量有没有持续上升。如果一个对象被标注为“Detached”(脱离的),那基本就能锁定泄漏源了。
4.2 技巧9:事件监听器只加不移,泄漏加重复触发
事件监听器泄漏的表现比定时器更隐蔽。最典型的问题是:同一个事件,同一个函数,被addEventListener重复注册了多次,执行时也会触发多次。
function handleResize() { /* ... */ } // 如果这段代码被反复执行,handleResize 会被注册 N 次 window.addEventListener('resize', handleResize); // 执行 N 次也就会重复触发 N 次解决办法很简单:要用removeEventListener移除时,必须传入同一个函数引用。所以匿名函数是个坑——你remove不掉一个没有名字的函数。如果只是想执行一次,可以直接加{ once: true }。
还有一个更现代的做法:AbortController。
const controller = new AbortController(); window.addEventListener('resize', handler, { signal: controller.signal }); // 之后一次性解绑 controller.abort();这个API的好处是:一个AbortController可以同时管理多个监听器的解绑,代码清爽,也不容易漏。现在主流浏览器都支持,可以放心用。
在React、Vue这类框架里,对应位置就是组件的清理函数:useEffect里的return清理函数,或者onUnmounted钩子里把该移除的监听器、该清掉的定时器统一做完。我在团队里review代码时,“只绑定不清理”一直是最常被点名的接口臭毛病之一。
5. 加载阶段的取舍:代码分割与懒加载
交互层面的卡顿优化完,还有一个前置环节:加载。一个用户打开页面,如果首屏要下载1MB的JavaScript,解析执行完才能看到东西,体验就已经输了。代码分割和懒加载就是从这个层面做优化的。
5.1 技巧10:动态import让重型代码按需到达
现代构建工具默认会把所有JavaScript打包成一个bundle,意味着首屏会下载整个应用的代码,包括你可能根本用不到的部分——比如管理后台里的图表编辑器、Markdown预览器、一堆只在特定路由才用到的业务模块。
动态import可以打破这个局面:
// 点击按钮时才去加载编辑器模块 btn.addEventListener('click', async () => { const { openEditor } = await import('./editor'); openEditor(); });构建工具看到这种写法,会把./editor单独打成一个chunk文件。浏览器在用户点击按钮之前不会去下载它,也不会去解析执行它,首屏体积变小,加载和初始化都更快。
React和Vue生态里都有对应的封装。React是lazy加Suspense:
import { lazy, Suspense } from 'react'; const Editor = lazy(() => import('./Editor')); function App() { return ( <Suspense fallback={<div>编辑器加载中…</div>}> <Editor /> </Suspense> ); }Vue则是defineAsyncComponent。底层原理都指向同一个:动态import。
5.2 拆包粒度怎么把握:不要为懒而懒
代码分割也不是拆得越细越好。如果一个模块拆得太小块,一个页面要发十几个请求去拉碎片文件,网络往返的时间反而会拖慢加载速度。HTTP/2解决了多路复用的问题,但请求之间的时延依然存在,拆分粒度需要结合项目的实际网络环境去权衡。
我的实践参考是这样:
- 路由级组件:按页面拆,几乎是默认配置。
- 大型第三方库:按使用时机拆。图表库、代码编辑器这类体积动不动几百KB的,必须懒加载。
- 普通工具函数:除非体积特别大,否则不建议拆。为了一个几KB的函数多一次网络请求,不划算。
另外一个小技巧是配合preload和prefetch:预加载能保证“马上要用”的关键资源立即可用,预获取则能利用浏览器空闲时间提前下载“很快可能用到”的资源。比如弹窗组件,可以在页面空闲时预获取,真到弹窗打开的时候用户无感知。
6. 一次真实排查:两千行表格勾选卡顿的修复过程
最后把前文的技巧串起来,完整复盘一个真实案例。这个案例就是我开头提到的那个表格页面,排查和修复的全过程大概一小时,改动不大,效果却很直观。
6.1 录制长任务,找到真正背锅的代码
第一步还是老规矩:打开Performance面板,录制,复现勾选操作,停止录制。长任务接近400毫秒,调用栈指向一个叫renderTable的函数。
去看代码,发现这个函数的工作方式是:每次勾选态变化,就把整个表格重新渲染一遍——两层for循环里创建了所有行和所有单元格,然后逐个appendChild到表格容器里。这本身就犯了技巧1说的“循环里逐个插入DOM”的错误。
更隐蔽的问题是勾选状态的查找。代码里维护了一个普通数组selected,每次判断一行是否被选中,都调用selected.includes(id)。而判断发生在渲染过程中,渲染又要遍历全部2000行,等于在最坏情况下,一次勾选操作要执行2000 × 2000,也就是400万次数组比较。两个问题叠加,卡顿就是这么来的。
6.2 两个小改动,长任务从400ms降到30ms
修复做了三件事:
第一,把勾选状态从数组换成Set。selected.has(id)从O(n)变成O(1),400万次比较直接消失。
// 之前 const selected = []; function isSelected(id) { return selected.includes(id); } // 之后 const selected = new Set(); function isSelected(id) { return selected.has(id); }第二,渲染改成DocumentFragment批量插入,不再一行一行append到表格里。
第三,放弃整表重建。勾选操作只影响当前行的选中样式,那就只更新这一行的class,而不是把2000行全部重画一遍。
// 勾选后只切当前行的样式,不重建整张表 row.classList.toggle('selected', isSelected(id));改完再录一次Performance,长任务降到了30毫秒以内,勾选操作肉眼可见地跟手了。
这个案例里没有用到任何“高深”的优化API,就是老老实实把三个最基础的原则落实了:批量操作DOM、用对数据结构、只更新必要部分。回到开头说的,JavaScript性能优化最大的障碍从来不是不知道技巧,而是不愿意先把问题量化、定位到那一行具体的代码上。如果你手头正好有个卡顿页面,不妨按这个流程走一遍:先录一段Performance,找到那个最长的任务,再回去看看那段代码——说不定它就在你每天都会路过的地方,只是之前没被正眼看过。我自己在这些年的排查里,最深的体会也就在这四个字:少即是多。多数卡顿不是你写得不够复杂,而是你做了太多不必要的事。