性能优化这件事,最神奇的地方在于:大部分项目根本不需要等到“慢到受不了”才动手。我见过太多线上事故,都是用户反馈页面卡了、脚本报错了,开发才匆匆忙忙打开面板一顿排查。真要说的话,JavaScript性能优化更像是一个持续的工程习惯,而不是临时救火动作。下面这30招,是我在真实项目里反复验证过的东西,按代码写法、DOM操作、渲染加载、内存管理几个方向做了分类,该有的代码示例都给了,该提醒的坑也标了。不管你是刚接手一个老项目,还是从零搭一个新项目,都能从中找到可以直接照搬的做法。
1. 动手优化之前,先搞清楚瓶颈在哪
1.1 先测量再优化:三个十分钟动作
任何优化,第一步都是测量。我见过不少人一上来就重写排序算法,结果真正的瓶颈是某个第三方脚本阻塞了解析。我自己就犯过这个错:有一次在Chrome DevTools的Performance面板里录了五秒钟,发现主线程被一个很小的正则表达式卡了整整三秒,而那个正则只执行了一次。这就是只靠“猜”去优化的风险——你投入大量精力优化了一段根本不在关键路径上的代码,页面该卡还是卡。
所以动手之前,至少要花十分钟做三件事:
- 打开Chrome DevTools -> Performance,录制一段用户实际操作(滚动、点击、切换路由),看主线程的Tasks里哪些函数占用了最多时间。
- 切到Memory面板,拍几次堆快照,看看内存是否在反复操作后持续上涨。
- 用Lighthouse跑一遍移动端模拟,拿到FCP、LCP、CLS这些核心指标。
这三件事做完,你的优化方向基本就明确了。我自己的习惯是:优先处理主线程耗时最高的任务,再看是否有连续的重排/重绘,最后才看网络加载。对用户来说,“页面响应”是主观感受,主线程一旦被占满,任何交互都会卡顿,这种体感比加载慢更致命。
1.2 面板到底各管什么
DevTools里三个工具的分工一定要搞清楚,否则很容易对着错误的指标瞎忙活。
| 工具 | 主要用途 | 什么时候用 |
|---|---|---|
| Performance | 记录主线程任务、重排耗时、长任务 | 定位“卡顿”的具体函数 |
| Memory | 拍堆快照、看内存增长趋势 | 排查内存泄漏,比较前后快照 |
| Lighthouse | 产出FCP、LCP、CLS等指标报告 | 上线前体检,做持续性能监控 |
提示:Performance面板里优先看红色长条任务(Long Task)。超过50ms的任务就会让用户感到“卡”,如果你的应用里经常出现几百毫秒的长任务,性能优化重点就是拆分它们。
2. 代码写法:从源头给JS减负(10招)
代码层面的优化,核心原则只有一句话:让JavaScript引擎少做点事。V8、SpiderMonkey这些引擎已经很强大了,但很多写法仍会触发多余的工作,比如隐藏类切换、哈希表退化、作用域链查找。这10招是我在实际项目中反复用到的,多数改动只是几个字符的差别,收益却非常明显。
- 全局变量缓存在局部变量里再访问,减少作用域查找。
- 别写特别深的属性链,比如
config.user.info.address.city,每次访问都是一次查找。 - 高频增删查的数据结构,用Map/Set代替Object。
- 循环前把length缓存进局部变量,避免每轮循环重新读取。
- 循环里能提前break/continue的,绝不无条件跑完。
- 处理整数场景时,用位运算代替
Math.floor/取模,注意可读性。 - 高频字符串拼接用数组push后join,不要写一堆
+=。 - 热路径(执行次数最多的代码)里避免try/catch。
- 对纯函数做memoization,用缓存换时间。
- 昂贵对象延迟创建,等真正用到时再初始化。
2.1 高频查找用Map还是Object,数据量一大就看出差距
先给结论:如果你需要频繁地增删键值,或者查找的数据量在几千以上,用Map。原因在于V8对Object的优化路线是针对“形状稳定”的对象的,它会把属性压缩成隐藏类,固定属性偏移量,访问快得离谱。可一旦你开始频繁删除、动态添加属性,对象就可能退化成字典模式,属性查找变成哈希表查找,性能会掉一个量级。Map在这个场景下从头到尾都是哈希表,但它经过了专门优化,插入删除的代价比对象小得多。
我做过一个粗略测试:往Object和Map里各插入十万个键值对,Object用了约120ms,Map只用约40ms。查找场景更夸张,对象退化到字典模式后,查找十万次比Map慢了将近三倍。所以当你的代码里有“以Id为键的缓存池”“频繁变更的配置表”这类结构,优先考虑Map。
要注意,Map也不是万能的。如果你是一个形状固定、属性数量极少的对象,比如坐标点{x, y},用Object反而更快更省内存。简单说:高频读写的表结构选Map,固定形状的实体对象选Object。
2.2 循环内部的三个隐藏浪费
循环是JS里最常见的性能陷阱。我以前review过一个同事的代码,他循环一个一万项的列表,每次循环都写const length = list.length,这还行。真正的问题是他在循环体里访问了三次window.config.limit,并且内部还有一次startsWith调用。每次循环都沿着window到config到limit查一遍作用域,看起来只是几次属性访问,乘以一万就是三万次多余查找。
三个最值得注意的细节:
第一,把length和循环里反复用到的值提前存到局部变量,这是最老套也最有效的优化。第二,遍历数组时,如果逻辑允许,尽量用倒序循环或者提前跳出,尤其是做查找型遍历。第三,能用简单数学运算替代的,别调用库函数。比如判断一个数是否是偶数,num % 2 !== 0顺手就写了,但如果你高频调用,位运算(num & 1) === 0更快。不过这里提醒一句:位运算会牺牲可读性,只在确认是性能瓶颈的热路径上用,别在全项目里刷存在感。
2.3 try/catch为什么会拖慢热路径
try/catch本身并不会让你的程序出错,但它会影响V8对函数的优化。V8的优化编译器TurboFan在优化一个函数前,会尝试做类型推论和内联,而try/catch引入的控制流会让一部分优化措施失效。你要是把try/catch写在一个每秒钟被调用几百次的函数体内部,函数的整体性能可能明显下降。
我推荐的做法是把错误处理放到调用链的外层。比如你有一段解析用户输入并写入数据库的逻辑,让parse和save函数各自保持清晰,在它们的调用方统一包裹try/catch,这样热路径上的函数能被正常优化,错误又能被集中捕获。当然,如果你的逻辑本身就必须在某个小函数内部捕获异常,那也没必要为了性能强行往上挪,这种场景下代码正确性优先。
还有一个容易被忽略的点:不要在循环体内部去做异常抛出的判断。写一个标志位或者提前return,都比让异常机制频繁介入好。异常应该表示“意外情况”,而不是替代正常控制流。
3. DOM操作与事件:把重排重绘压到最低(8招)
如果说代码层面的优化是“让引擎少做事”,那么DOM相关的优化就是“让浏览器少干活”。浏览器每一次改变样式、插入节点,都可能触发重排(layout)和重绘(paint),这两个阶段的成本远高于大部分人想象。我见过最典型的慢页面,JS逻辑非常简单,却在循环里一个个插入节点,每插入一次都触发一次重排,最后页面卡成幻灯片。
- 插入多个节点时,先把它们塞进DocumentFragment,再一次性挂到DOM上。
- 列表类交互用事件委托,把监听器挂到父容器,而不是每个子元素一个监听器。
- 修改样式时用class的增删来切换,不要一行行改
el.style。 - 需要连续更新视觉状态时,用requestAnimationFrame把操作合并到下一帧。
- scroll、resize、mousemove等高频事件,用防抖或节流控制执行频率。
- 需要复制模板节点时,用cloneNode代替重新createElement和设置属性。
- 要批量操作一个子树时,先把它
display:none,操作完再显示,减少中间态的重排。 - 动画优先用transform和opacity,别用left/top/width/height,前者走合成器,后者会触发layout。
3.1 批量插入DOM的正确姿势
先看一段随手写的代码,很多初学项目里都有这种写法:
for (const item of list) { const div = document.createElement('div'); div.textContent = item.name; container.appendChild(div); }这段代码如果list只有3项,没问题;如果有300项,浏览器会为了每一次appendChild重新计算布局。每插入一个节点都触发一次layout,页面必然卡。
改成DocumentFragment之后:
const fragment = document.createDocumentFragment(); for (const item of list) { const div = document.createElement('div'); div.textContent = item.name; fragment.appendChild(div); } container.appendChild(fragment);区别在于fragment是一个“离线”的容器,你在它上面做任何操作都不会影响真实DOM,只有最后一次appendChild会触发重排。注意,fragment被插入后它自己会清空,你没法二次使用它,所以别指望复用fragment。
还有一个进阶版:如果插入的节点结构固定,用innerHTML拼一次字符串,比appendChild一百次还快。但使用前要对内容做转义处理,否则容易出现XSS问题。
3.2 事件委托:一个监听器搞定整片列表
一个常见的错误:给一个动态列表的每一项都绑定click事件,每创建一项就addEventListener一次。列表项少的时候无所谓,但如果是聊天消息、feed流这类十几万条数据的页面,监听器数量会直接拖垮内存和初始化速度,而且动态创建的事件还不方便统一卸载。
事件委托的思路是在父容器上监听一次,然后通过事件对象的target来判断真正触发的是谁:
container.addEventListener('click', (e) => { const card = e.target.closest('.card'); if (!card) return; handleCardClick(card.dataset.id); });注意这里用了 这两个属性都能让脚本不阻塞HTML解析,但行为和时机完全不同。defer是“延迟到文档解析完之后、DOMContentLoaded之前执行”,多个defer脚本按文档顺序执行;async是“下载完就立刻执行”,多个async脚本谁先下载完谁先执行,不保证顺序。 选型逻辑很简单:如果你的脚本之间有依赖关系,用defer;如果是完全独立的统计代码、广告代码,用async,因为它和页面其他脚本互不干扰,下载完直接跑。反过来,如果把有依赖关系的脚本标成async,极大概率会因为在对应库还没加载时就执行而报错。 还有一个细节:defer只对同源的外部脚本可靠。如果你用了动态生成的script标签,它默认就是async的,需要显式把 长列表渲染慢,是因为DOM节点数量过多,浏览器要维护的布局、样式、事件都随之膨胀。虚拟滚动的核心思路是:不管数据有一万条还是一百万条,我只渲染当前可视区域几十条节点,配合监听滚动事件更新渲染范围。 实现一个最简版本,只需要四样东西:固定行高、滚动容器的高度、当前滚动位置、渲染起始索引。数据量大时必须给每一项一个唯一的key,否则复用节点时会出现状态错乱。如果你的场景是动态行高,复杂度会高不少,需要提前估算高度或缓存测量结果。 如果你只是想快速解决问题,强烈建议直接使用成熟的库,比如react-window、vue-virtual-scroller这一类的,而不是自己从零写。自己写很容易在滚动边界抖动、键盘导航、焦点管理这些细节上翻车,我用这些库之前就翻过两次。 很多人一听Web Worker能把计算丢到后台线程,就觉得所有重的活都能往里塞。但Worker有一个天然限制:它拿不到DOM。要操作DOM的数据必须通过postMessage传回主线程,而postMessage本身有序列化开销。如果你的数据量是几十兆,传输一次的成本甚至超过原地计算。 我之前优化过一个CSV解析功能,主线程解析1万行数据要2秒。把解析丢给Worker后,解析本身只用了400ms,但30MB的CSV文件序列化到Worker花了1.8秒,整体几乎没收益。后来改成流式读取片段,每读一块就传给Worker解析,才真正快起来。 所以结论是:Worker适合处理“计算量大但数据交换少”的任务,比如图片压缩、PDF生成、复杂数学计算。如果你的任务需要频繁和主线程交换大对象,先把传输设计好,否则可能白折腾。 最后一块大头是内存。很多人只关注“页面打开快不快”,忽略了“页面开着时间长了会不会变慢”。内存泄漏不会立刻报错,但会让浏览器GC越来越频繁,用户能感受到的是:操作越来越卡、切页面越来越慢,最后内存飙到让浏览器崩溃。下面5招属于排查心法,比具体的代码技巧更值钱。 闭包本身不会泄露内存,泄露的是“闭包里意外捕获的大对象”。比如你在一个函数里定义了事件回调,回调里引用了外层一个超大数组,只要事件不解除,数组永远留在内存里。排查方法很简单:在Memory面板拍下快照,筛选Retained Size最大的对象,看它的引用链被谁拿着,基本能定位。 定时器是更隐蔽的:一个setInterval里如果捕获了整个组件的this,而组件因为路由切换已经被销毁,定时器没被clear,那么整个组件连同它的DOM、子组件都会被定时器“拉住”,永远回收不了。我见过最严重的案例,一个后台页面来回切换十次,堆内存涨了500MB,最后定位到是一个没清理的requestAnimationFrame循环。 写代码养成三个习惯:第一,凡是在组件mount阶段创建的定时器、事件监听器、外部请求,都在卸载阶段成对清理;第二,能用once的地方就用once;第三,公共的轮询方法封装时,提供一个stop函数。 给一个对象挂额外数据,最直接的做法是往对象上添加属性,或者用一个Map来存映射关系。问题是这两种方式都会让“键”被强引用,对象本身无法被回收。比如你有一个DOM节点,想在节点上缓存某份数据,如果用Map存,节点删除后Map还持有它,内存就白白占着。 WeakMap/WeakSet的区别就是“弱引用”:键被回收时,对应的值也能被回收。语法上几乎一样: 唯一要记住的约束是:WeakMap的键只能是对象,不能是字符串或数字。它的确没有size属性和迭代方法,但这反而是好事,意味着你不能遍历它,只能在“知道某个对象时”查它的数据,这正好符合“附加数据”的定位。 注意,弱引用不等于完全无脑用。如果你的业务数据本身就需要长期驻留,比如需要遍历或统计的缓存,还是用常规Map更合适。WeakMap适合的是“生命周期跟对象走”的那一类数据。 把这30招串起来看,你会发现性能优化并没有那么玄。它本质上就是两件事:让浏览器少干活,以及让已经干完的活不留后患。我个人的习惯是,每写完一个模块,都会顺手做一次主线程任务检查;每次发布前,跑一遍Lighthouse看核心指标有没有退化。性能问题不是“优化一次就永远不卡”,它更像慢性病,需要你持续监控。 最后送大家一句实话:别为了优化而优化。先把业务逻辑写清楚,再针对测量出来的瓶颈动手。上面这些招数里很多属于“应急手段”,当你发现某个页面确实卡到影响用户了,拿它们出来救火,往往一击即中。closest而不是matches,因为target可能是卡片内部的某个span或img,closest能帮我们向上找到最近的符合选择器的元素。配合>loading="lazy"或IntersectionObserver。4.1 async和defer别再凭感觉选
async设为false来改变行为。这个坑我在一个SDK接入项目里踩过,排查了半天才找到原因。4.2 虚拟滚动的核心思想
4.3 Web Worker不是万金油
5. 内存管理:防止“越跑越慢”的隐形杀手(5招)
5.1 闭包和定时器是内存泄漏的两大来源
5.2 WeakMap的正确用法
const cache = new WeakMap(); function process(el) { if (cache.has(el)) return cache.get(el); const result = doHeavyWork(el); cache.set(el, result); return result; }