
前两天组里的同事拿一个页面来让我看两千行的表格每次勾选一个复选框整个页面都要卡个半秒。我打开Chrome的Performance面板一查问题出在一个再常见不过的JavaScript性能优化场景——状态变更后整张表格的DOM节点被全部重建了一遍。我这些年做前端遇到的大多数“页面卡顿”问题根源都不是什么高深算法而是那些每天都在写的、看起来人畜无害的小操作DOM改得太频繁、事件绑得太多、该缓存的地方不缓存、该拆的包不拆。JavaScript性能优化本质上就是把这类“小账”一笔笔算清楚。这篇文章我梳理了10个在自己项目里验证过、能直接落地的实战技巧。适合两类人一种是页面已经卡了但不知道从哪下手的同学另一种是功能能跑、但想系统补齐性能知识的开发者。每个技巧我都会讲清楚原理、放出代码再聊聊实际使用中容易踩的坑。1. 动手之前先把性能问题从“感觉”变成“数字”很多项目里最典型的错误是上来就优化不问到底哪里慢。有人觉得是DOM操作的问题就一顿改成虚拟DOM有人觉得是列表的问题就上一堆复杂组件结果改完发现该卡还是卡。性能优化最忌讳的就是凭感觉下手。正确顺序是先量化再动手。量化工具用现成的就行浏览器已经帮你准备好了。1.1 用Performance面板揪出最长的任务Chrome开发者工具里的Performance面板是排查交互卡顿的第一站。操作路径不长打开开发者工具切到Performance标签。点击左上角的录制按钮。去页面上复现你的操作勾选、滚动、点击一次只复现一个问题。停止录制看底部时间轴里标成红色的长任务条。长任务Long Task是指耗时超过50毫秒的主线程任务超过这个阈值用户就会感觉到“卡”。点开任务条能看到这段JS代码的调用栈哪个函数占了大部分时间在这个面板里会直接标出来。比如我同事那个表格页面录制之后长任务接近400毫秒展开栈一看renderTable函数独占大头——代码在状态变更后把整张表格的内部DOM重拼了一遍。还有个更轻量的排查技巧Chrome里按ShiftEsc打开浏览器自带的任务管理器可以看到每个标签页的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 WorkerJavaScript主线程承担了所有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加Suspenseimport { 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找到那个最长的任务再回去看看那段代码——说不定它就在你每天都会路过的地方只是之前没被正眼看过。我自己在这些年的排查里最深的体会也就在这四个字少即是多。多数卡顿不是你写得不够复杂而是你做了太多不必要的事。