一段看似简单的循环,如果里面同时读取 offsetHeight 又修改 style.height,页面就可能从快速响应变成逐帧卡顿。这不是因为 JavaScript 引擎本身执行得慢,而是代码触发了浏览器的强制同步布局,导致渲染管线被反复打断。JavaScript 性能优化很难靠死记硬背几条规则完成,需要理解浏览器如何执行脚本、如何渲染页面、如何回收内存。下面从最常见的性能陷阱入手,给出可复用的排查思路和优化代码。

DOM 操作与布局抖动如何避免
浏览器渲染页面时,通常会先执行 JavaScript,再根据样式计算布局,最后绘制到屏幕。如果在脚本中读一个需要布局信息的属性,比如 offsetHeight、clientWidth、getBoundingClientRect(),而此前又修改过 DOM 或样式,浏览器就必须暂停脚本,立即完成一次样式计算和布局,这就是强制同步布局。
更坏的情况是读写交替出现在循环中,每一次读取都强制布局,每次写入又让上一次的布局失效。这样原本只需要一次合渲染的操作,会变成几十次甚至上百次完整的布局计算,性能开销会成倍放大。下面是一段典型的问题代码。
for (let i = 0; i < list.length; i++) {
const h = list[i].offsetHeight; // 强制布局
list[i].style.height = h + 10 + 'px'; // 使布局失效
}
优化的核心思路是先把所有读取操作做完,再进行批量写入。这样读取过程只触发一次布局计算,写入过程只引发一次后续的合并渲染。
const heights = [];
for (let i = 0; i < list.length; i++) {
heights.push(list[i].offsetHeight);
}
for (let i = 0; i < list.length; i++) {
list[i].style.height = heights[i] + 10 + 'px';
}
如果是响应窗口缩放、滚动等高频事件,还可以把计算和写入统一放进 requestAnimationFrame 回调中,让浏览器在下一帧绘制前只执行一次更新。
let pending = false;
function updateHeights() {
const heights = [];
for (let i = 0; i < list.length; i++) {
heights.push(list[i].offsetHeight);
}
for (let i = 0; i < list.length; i++) {
list[i].style.height = heights[i] + 10 + 'px';
}
pending = false;
}
window.addEventListener('resize', () => {
if (pending) return;
pending = true;
requestAnimationFrame(updateHeights);
});
另一个常见的 DOM 性能问题是频繁插入节点。每插入一个节点,页面都可能重新计算布局。若需要一次性添加大量列表项,应优先使用 DocumentFragment 先在内存中构建完整结构,最后一次性挂载到文档中。这样能大幅减少页面重排次数,尤其是列表项包含图片、样式类和大段文本时效果更明显。
闭包与事件监听带来的内存泄漏
JavaScript 使用垃圾回收机制管理内存,但垃圾回收器只能回收不再被引用的对象。闭包会让内层函数持有外层作用域的变量引用,如果这个内层函数被长期保留,外层的大对象就无法释放。很多内存泄漏都发生在事件监听、定时器和缓存场景中。
下面这段代码中,mount 每次执行都会创建一个新的匿名函数,同时让这个函数闭包引用 data 数组。由于监听器没有被移除,旧的 data 会一直驻留在内存里。反复调用 mount 就会造成内存持续增长。
function mount() {
const data = new Array(100000).fill({ value: 1 });
document.getElementById('btn').addEventListener('click', function handler() {
console.log(data.length);
});
}
优化的第一步是避免在不必要时创建闭包。可以将事件处理函数提取到外部作用域,统一管理引用。第二步是在组件销毁、页面切换或不再需要交互时,主动调用 removeEventListener 移除监听器。
const data = new Array(100000).fill({ value: 1 });
function handler() {
console.log(data.length);
}
const btn = document.getElementById('btn');
btn.addEventListener('click', handler);
// 不再需要时移除
btn.removeEventListener('click', handler);
缓存也需要考虑引用生命周期。如果用普通对象 Map 保存以对象为键的缓存,即使原始对象不再被业务代码使用,缓存仍然会阻止它被回收。此时可以使用 WeakMap,它的键是弱引用,不会影响垃圾回收。
const cache = new WeakMap();
function getExpensiveValue(obj) {
if (!cache.has(obj)) {
cache.set(obj, computeExpensiveValue(obj));
}
return cache.get(obj);
}
定时器同样需要及时清理。使用 setInterval 或 setTimeout 时,如果页面已经切换但回调仍在运行,会继续占用 CPU 和内存。配合生命周期钩子或 clearInterval 能够避免无意义的后台计算。对于体积较大的全局缓存,建议设置容量上限或淘汰策略,不能只进不出。
低效算法与不必要的数据复制
性能问题并不总是来自浏览器或语言特性,很多卡顿源于算法复杂度过高。假设你有一个数组保存了所有订单 ID,另有一批用户列表需要判断每个用户是否在订单中。常见写法是对每个用户都调用一次 indexOf 进行线性查找,整体复杂度会变成 O(n*m)。
function isAllowed(ids, targetId) {
for (let i = 0; i < ids.length; i++) {
if (ids[i] === targetId) return true;
}
return false;
}
const allowedUsers = users.filter(user => isAllowed(orderIds, user.id));
当 orderIds 和 users 都比较大时,这种写法会快速消耗主线程。优化方法很直接:将需要频繁查找的数组提前转换为 Set 或 Map,查找操作从线性复杂度降到接近常数级。
const orderIdSet = new Set(orderIds); const allowedUsers = users.filter(user => orderIdSet.has(user.id));
类似的问题还出现在字符串拼接和数组操作中。如果在一个长循环里使用 += 直接拼接大字符串,由于 JavaScript 字符串不可变,每次拼接都会创建新的字符串并把旧内容复制过去,时间会随字符串长度增长。更合适的做法是把片段收集到数组,最后用 join 合并。
const parts = [];
for (let i = 0; i < 10000; i++) {
parts.push('row' + i);
}
const result = parts.join(',');
另外要避免在循环中重复调用返回固定结果的方法。比如在 for 条件里每次访问 arr.length 通常影响不大,但如果调用的是 getRange() 这类涉及计算或 DOM 查询的函数,就应该先存到局部变量中。性能优化不意味着把所有代码都改成更晦涩的形式,而是识别真正的复杂度热点,优先解决高频路径上的低效操作。
高频事件与主线程阻塞的处置
滚动、鼠标移动、窗口大小变化、键盘输入等事件会在很短时间内连续触发。如果每个事件回调都执行 DOM 更新、网络请求或复杂计算,主线程很快会被占满,导致页面无法响应用户操作。针对这类场景,防抖和节流是两种基础且有效的控制手段。
防抖适合输入框搜索这类行为,目标是让用户在停止输入一段时间后才执行真正逻辑,避免每个字符都触发请求。节流则适合滚动加载等场景,限制回调在固定时间间隔内最多执行一次。下面是一个防抖实现。
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
window.addEventListener('scroll', debounce(() => {
// 处理滚动逻辑
}, 200));
如果事件回调中的计算量很大,即使做了节流,单次执行仍然可能超过一帧的预算。此时可以使用 requestIdleCallback 把非关键任务拆到浏览器空闲时间执行,或者把长时间计算移到 Web Worker 中,避免阻塞主线程。需要注意 Web Worker 不能直接操作 DOM,只能负责纯计算并将结果传回主线程。
还需要警惕异步任务的堆积。短时间内派发大量 Promise 或 setTimeout 可能让微任务队列和宏任务队列持续高位运行,事件循环来不及处理用户交互。对于可合并的更新,使用标志位和调度器统一执行,能有效降低任务数量。性能优化的目标不是把每一行代码都改到极致,而是保证关键交互路径不被阻塞,让页面在复杂业务中依然保持稳定响应。
JavaScript性能优化性能陷阱DOM操作修改时间:2026-10-02 07:22:10