浏览器里每个页面都是一棵DOM树,我们写的JavaScript几乎每天都在跟它打交道。日常开发中,往页面上加内容、改样式、绑事件,都离不开DOM操作。很多初学者觉得直接操作就行,可一旦数据量大或者操作特别频繁,页面就会变得卡顿、滚动不流畅。这篇文章就把常见的DOM性能问题掰开揉碎,聊聊背后的原因,以及在生产环境下真正能用的优化手段。
一、先从“慢”说起:DOM操作为什么拖后腿
浏览器展示一个页面,并不是把HTML画上去就完事了。它得先把HTML解析成DOM树,再把CSS样式和DOM树合在一起生成渲染树,然后计算每个元素该放在哪儿、有多大,最后才真正画到屏幕上。这个“计算位置和大小”的过程叫重排(也叫回流),“重新画”的过程叫重绘。其中重排的代价非常高,因为一个元素的位置变了,可能带动其他元素跟着变,浏览器需要重新算一整片区域。
想象一下,你用JavaScript在一个循环里连续改了100次某个元素的宽度,浏览器就很有可能被逼着重排100次。这就像你每次往行李箱里塞一件衣服,都要把箱子盖起来拉上拉链,然后再打开接着塞,效率自然很低。所以DOM性能优化的核心思路特别简单:减少重排重绘的次数、减少不必要的DOM查询、尽量把零散的改动合并成一次批量操作。
二、减少DOM操作次数:从“频繁”到“批量”
2.1 用DocumentFragment做批量添加
往页面上循环添加大量元素,是新手最容易踩的一个坑。比如要做一个10000条数据的列表,如果每生成一个li就立刻appendChild到页面上,那每加一次都可能导致一次重排。正确做法是先准备一个“虚拟容器”,把所有新元素都在这个容器里攒好,最后一口气塞进文档。
// 技术栈:原生 JavaScript
// 假设我们要往 id 为 list 的 ul 里添加 10000 个 li
const list = document.getElementById('list');
// 方式一(不推荐):循环里每次都 appendChild
// 问题:每次往文档里插节点,都可能触发一次重排
for (let i = 0; i < 10000; i++) {
const li = document.createElement('li');
li.textContent = '项目' + i;
list.appendChild(li); // 这里在反复打扰浏览器
}
// 方式二(推荐):用 DocumentFragment 攒一批
// DocumentFragment 是一个轻量的“虚拟容器”,它在内存里工作,不会引起页面重排
const fragment = document.createDocumentFragment();
for (let i = 0; i < 10000; i++) {
const li = document.createElement('li');
li.textContent = '项目' + i;
fragment.appendChild(li); // 只是加到内存里的容器中
}
list.appendChild(fragment); // 只触发一次真实的重排和重绘
2.2 用innerHTML批量替换
有时候整个容器里的内容都要换掉,比如刷新一个表格的tbody、重做一个筛选后的商品列表。这种场景下,拼接字符串再用innerHTML一次性赋值,通常比一个个创建DOM元素更快。因为浏览器解析一段HTML字符串,比多次调用createElement再手工插入更省事。
// 技术栈:原生 JavaScript
// 批量更新一个表格的 tbody 内容
const tbody = document.getElementById('tbody');
const data = [
{ name: '苹果', value: 10 },
{ name: '香蕉', value: 20 },
{ name: '橘子', value: 30 }
];
// 先把所有行拼成一个完整字符串
let html = '';
for (const item of data) {
html += `<tr><td>${item.name}</td><td>${item.value}</td></tr>`;
}
// 最后一次性赋值给 innerHTML
tbody.innerHTML = html;
不过要注意,如果这些数据里有用户输入的内容,直接拼进innerHTML可能会被注入恶意脚本。正确的做法是先把特殊字符转义掉,比如把<换成<,把>换成>,再拼接。
三、事件委托:把监听器数量减下来
假如一个页面里有1000个按钮,每个按钮都绑一个click事件,内存里就得多出1000个事件处理函数。这不仅占内存,绑定过程本身也有损耗。更麻烦的是,如果你用JavaScript动态添加新的按钮,还得记得给新按钮重新绑事件,不然它就没反应。
这时候可以用事件委托。事件冒泡的原理是:子元素点击后,事件会一层层往上传递到父元素。我们只需要在父元素上监听一次,然后判断点击的目标是不是我们想要的元素,就可以统一处理所有子元素的事件。
// 技术栈:原生 JavaScript
// 不用给每个 li 单独绑定 click,只给 ul 绑定一次就行
const list = document.getElementById('list');
list.addEventListener('click', function (event) {
// event.target 是真正被点击的那个元素
const target = event.target;
// 判断点击的是不是 li(也可以用 dataset 加标记来区分)
if (target.tagName === 'LI') {
console.log('你点击了:', target.textContent);
}
});
这样做的好处显而易见:监听器数量变少了,内存占用更小;而且即便之后往列表里新增li,不需要再单独绑事件,新元素自动就能响应点击。
四、缓存DOM引用:别总去“查户口”
每次写document.getElementById,浏览器都要从文档根部开始挨着找,这个过程是有开销的。如果在一个循环里反复去查同一个元素,就相当于反复去“翻户口本”,明明已经找到了人,还要一遍遍重新翻。正确的做法是把查到的元素引用存在一个变量里,后面直接用这个变量。
// 技术栈:原生 JavaScript
// 错误示范:循环里每次都去查找 DOM
for (let i = 0; i < 1000; i++) {
document.getElementById('box').style.height = i + 'px'; // 反复查找
}
// 正确示范:先把引用缓存到 box 变量
const box = document.getElementById('box');
for (let i = 0; i < 1000; i++) {
box.style.height = i + 'px'; // 直接操作缓存好的引用
}
需要提醒一句:缓存引用也不是一劳永逸的。如果某个DOM节点被整体替换了,之前保存的引用可能就失效了,这时候需要重新获取。所以缓存要放在合适的时机,比如元素保持不变的前提下使用。
五、避免强制同步布局:让浏览器“从容”地工作
浏览器其实有自己的优化策略。它会把一段时间内的DOM写操作存在缓冲区里,等到合适的时机再统一做重排和重绘,这样能大幅减少计算量。但如果我们一边写样式,一边立刻去读取布局信息(比如offsetWidth、offsetHeight、scrollTop这些属性),浏览器为了给你返回一个准确的值,就只能被迫立刻执行一次重排。这就叫强制同步布局,它会打断浏览器的批量优化,让性能变差。
5.1 读写分离
一个常见的坏习惯是,在同一个循环里既改样式又读尺寸。稍微改进一下,就能让性能提升不少。
// 技术栈:原生 JavaScript
// 坏习惯:一会儿写一会儿读,浏览器被反复折腾
for (let i = 0; i < items.length; i++) {
items[i].style.width = (i * 10) + 'px';
const width = items[i].offsetWidth; // 读取时会强制重新布局
}
// 好习惯:先一次性把要读的数据读完,再统一去写
const widths = [];
for (let i = 0; i < items.length; i++) {
widths.push(items[i].offsetWidth); // 第一阶段:读
}
for (let i = 0; i < items.length; i++) {
items[i].style.width = (widths[i] + 10) + 'px'; // 第二阶段:写
}
5.2 用requestAnimationFrame合并高频更新
滚动事件、窗口尺寸变化事件触发频率非常高,有时候一秒钟能触发几十次甚至上百次。如果每次触发都直接操作DOM,浏览器根本忙不过来。requestAnimationFrame可以帮我们把这些高频操作合并成“每一帧只执行一次”,视觉上依然流畅,但计算量大幅降低。
// 技术栈:原生 JavaScript
// 滚动时更新一个元素的位置,避免在滚动回调里反复操作DOM
let ticking = false;
window.addEventListener('scroll', function () {
if (!ticking) {
// 在下一帧到来时统一执行一次
requestAnimationFrame(function () {
const scrollTop = window.scrollY;
const target = document.getElementById('floating');
if (target) {
target.style.transform = 'translateY(' + scrollTop + 'px)';
}
ticking = false; // 执行完,允许下一次触发
});
ticking = true; // 在当前帧内不再重复触发
}
});
六、生产环境里的常见场景与最佳实践
前面聊的都是基础思路,接下来看几个生产环境里非常典型的场景,以及对应的最佳实践。
6.1 一次性渲染大列表:虚拟滚动
当你要渲染几万条甚至几十万条数据时,就算用DocumentFragment一次性加进去,页面也会因为节点太多而占用大量内存,滚动起来还是卡。生产环境通常会用“虚拟滚动”来解决:只渲染可视区域内的那一部分元素,其他数据等滚动到了再渲染。这样做牺牲了一些实现复杂度,但换来了极大的性能提升。
// 技术栈:原生 JavaScript
// 虚拟滚动核心思路(示意)
const container = document.getElementById('container');
const itemHeight = 40; // 每个列表项的高度
const visibleCount = 20; // 可视区域最多显示多少项
let startIndex = 0;
function updateList() {
// 根据滚动距离计算出应该从第几条数据开始渲染
const scrollTop = container.scrollTop;
startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;
// 渲染 startIndex 到 endIndex 之间的数据
renderRange(startIndex, endIndex);
}
container.addEventListener('scroll', function () {
// 用 requestAnimationFrame 把滚动事件合并成每帧一次
requestAnimationFrame(updateList);
});
真实生产环境里,虚拟滚动还要处理“占位元素高度”,也就是给整个列表一个总高度,即使只渲染一小部分,滚动条的长度也要看起来和全部数据一样。另外还需要在可视区域上下各加一段缓冲,以免快速滚动时出现白屏。
6.2 频繁改动样式:用class而不是逐个style
给元素改多个样式属性时,每次都style.xxx = ...,一行一行地写,浏览器接收到的指令又多又碎。更好的做法是把这些样式都写进CSS里,然后用classList.add切一个类名,让浏览器统一处理。
// 技术栈:原生 JavaScript
// 不推荐:逐个设置 style 属性
element.style.width = '100px';
element.style.height = '50px';
element.style.color = 'red';
element.style.backgroundColor = 'blue';
// 推荐:提前在 CSS 里定义好一个类
// .active {
// width: 100px;
// height: 50px;
// color: red;
// background-color: blue;
// }
element.classList.add('active');
6.3 离线DOM操作:把元素先摘下来
如果需要大幅度修改一个容器内部的结构,比如重排一组卡片,可以先把这个容器设为display: none,等改完再设回block。因为在隐藏状态下,浏览器不会对它做重排。另一种做法是用cloneNode克隆一份,在克隆体上随便改,改完再替换原节点。
// 技术栈:原生 JavaScript
// 方式一:先 display: none,改完再显示
const parent = document.getElementById('parent');
const originalDisplay = parent.style.display;
parent.style.display = 'none';
// 在 parent 内部做各种增删改操作...
// 比如 parent.appendChild(newChild)、parent.removeChild(oldChild)
parent.style.display = originalDisplay;
// 方式二:克隆一份离线操作,最后替换
const clone = parent.cloneNode(true); // true 表示深克隆子节点
// 对 clone 做修改...
// parent.parentNode.replaceChild(clone, parent);
注意使用cloneNode会丢失原节点上绑定的事件监听器,所以如果原节点内部有很多事件,需要重新绑定。这种方式比较适合纯展示型的结构更新。
七、不同方案的优缺点与注意事项
DOM性能优化没有“银弹”,每种方法都有自己的长处和陷阱,踩对场景才能发挥价值。
先说DocumentFragment。它特别适合一次性添加大量同级节点,代码清晰,性能也稳。缺点是一次性加入太多节点,仍然会触发一次大规模重排,如果页面本身很卡,可以考虑把数据分批,比如每500个一组,用setTimeout分几次插入。
innerHTML的优点是简洁高效,适合整个容器内容替换;但它的安全风险需要警惕,而且只改一个子节点时用innerHTML会把其他兄弟节点也重新解析一遍,反而更慢。事件委托的优点是省内存、支持动态元素,但要小心事件目标的判断,如果页面里既有li又有span,需要看清楚target到底是哪一个,别把点击span也误判成点击li。
缓存DOM引用是一个零成本的优化,但记住“引用会失效”。如果一个节点被父容器整体innerHTML覆盖了,你保存的旧引用其实已经指向了一个不在页面里的“幽灵节点”,继续操作不会有任何视觉变化,还不好排查。遇到这种情况就要重新查询。
强制同步布局的坏处一般不容易被察觉,尤其在动画里,每帧都强制重排,会让动画出现明显的掉帧。除了上面提到的读写分离,还可以用requestAnimationFrame把读和写放到不同的帧里执行,进一步减少卡顿。
requestAnimationFrame也不是万能药。它适合持续变化的画面,但如果你的操作本身不涉及视觉变化,用它反而白耗一次帧回调。另外,在定时器里如果忘了cancelAnimationFrame,回调可能一直执行,造成内存泄漏。
八、文章总结
DOM操作慢的根本原因,是浏览器需要不断重新布局和绘制。优化的方向其实就三条:减少操作次数、缩小作用范围、尽量别打断浏览器的批量优化。对应到日常开发,可以使用DocumentFragment和innerHTML做批量更新,用事件委托减少监听器数量,用缓存引用避免重复查询,用requestAnimationFrame合并高频操作。生产环境里,遇到大数据列表要优先考虑虚拟滚动,改样式优先用class,需要大规模修改结构时可以把元素离线处理。技术没有绝对的好坏,关键要看具体场景。别急着优化,先打开浏览器的性能面板,找到真正的瓶颈,再动手。希望这篇文章能帮你把页面调得越来越丝滑。
评论
围绕“JavaScript DOM操作的性能优化思路及生产环境下的最佳实践”参与讨论