上周改代码时,我差点惊掉下巴 —— 之前写的商品列表,滚动时像幻灯片一样卡顿,鼠标滚轮滚一下,页面能卡半秒。打开 Chrome 性能面板一看,主线程上全是红色的 “长任务”,最长的一个居然持续了 320ms。
“我就循环创建了 100 个 div 而已啊,怎么会这么卡?” ,感觉一脸委屈,怎么可能。
我点开他的代码,瞬间明白了问题所在:
javascript
运行
// 小王的代码:循环创建并添加 DOM 元素
function renderGoodsList(goods) {
const list = document.getElementById('goods-list');
goods.forEach(good => {
const item = document.createElement('div');
item.className = 'goods-item';
item.innerHTML = `
<img src="${good.img}" alt="${good.name}">
<h3>${good.name}</h3>
<p class="price">¥${good.price}</p>
`;
list.appendChild(item); // 每次循环都往 DOM 里加元素
});
}
这段代码看起来 “没毛病”,但藏着 DOM 操作的经典陷阱 ——频繁操作真实 DOM,导致浏览器频繁 “重排重绘”。
DOM 操作是前端性能的 “重灾区”,但也是最容易优化的地方。今天就结合我在电商、后台系统等项目里踩过的坑,分享 5 个实战技巧,从 “卡顿列表” 到 “丝滑交互”,改完就能看到效果,每个技巧都附代码对比和性能数据。
一、先搞懂:为什么频繁操作 DOM 会卡顿?
在讲技巧前,必须先明白一个核心原理:DOM 不是 JS 对象,操作 DOM 会触发 “重排” 和 “重绘”,这俩操作比 JS 计算慢 10-100 倍。
重排(Reflow):当 DOM 元素的位置、大小、结构发生变化时(比如添加元素、修改宽高),浏览器需要重新计算所有元素的几何位置,这个过程叫重排。重绘(Repaint):当元素的样式变化但不影响位置时(比如改颜色、背景),浏览器只需要重新绘制元素的像素,这个过程叫重绘。
重排必然导致重绘,重绘不一定导致重排。但不管哪种,都会占用浏览器的主线程 —— 如果主线程被这些操作占满,就没时间处理用户输入、滚动等交互,页面自然就卡了。
代码之所以卡,就是因为循环 100 次 ,触发了 100 次重排 —— 相当于让浏览器反复 “重新计算位置 + 重新绘制”,不卡才怪。
appendChild
二、技巧 1:用 “文档片段” 批量操作 DOM,重排次数从 100→1
解决 “频繁添加 DOM 元素” 的核心,是减少重排次数。最好的办法是:先把所有元素在 “虚拟容器” 里拼好,最后一次性添加到真实 DOM 中。
这个 “虚拟容器” 就是浏览器原生提供的 (文档片段)—— 它是一个存在于内存中的 DOM 节点容器,往里面加元素不会触发重排,只有当它被添加到真实 DOM 时,才会触发一次重排。
DocumentFragment
优化前后代码对比:
javascript
运行
// 反例:循环添加,触发 100 次重排
function renderGoodsListBad(goods) {
const list = document.getElementById('goods-list');
goods.forEach(good => {
const item = document.createElement('div');
item.className = 'goods-item';
item.innerHTML = `...`;
list.appendChild(item); // 每次都触发重排
});
}
// 正例:用文档片段,只触发 1 次重排
function renderGoodsListGood(goods) {
const list = document.getElementById('goods-list');
// 1. 创建文档片段(内存中的虚拟容器)
const fragment = document.createDocumentFragment();
goods.forEach(good => {
const item = document.createElement('div');
item.className = 'goods-item';
item.innerHTML = `...`;
fragment.appendChild(item); // 往片段里加,不触发重排
});
// 2. 最后一次性添加到真实 DOM,只触发 1 次重排
list.appendChild(fragment);
}
性能对比(100 条数据):
| 指标 | 循环添加(反例) | 文档片段(正例) | 提升幅度 |
|---|---|---|---|
| 重排次数 | 100 次 | 1 次 | 减少 99% |
| 渲染耗时 | 320ms | 45ms | 减少 86% |
| 滚动帧率(FPS) | 22fps | 58fps | 提升 164% |
我的踩坑经历:
第一次用文档片段时,我傻到在循环里给片段设置 —— 其实
innerHTML 本身没有
DocumentFragment 属性,只能用
innerHTML 加元素。后来才发现,也可以用 “字符串拼接” 先拼好 HTML,最后一次性赋值给
appendChild,效果类似:
list.innerHTML
javascript
运行
// 字符串拼接法(适合简单场景)
function renderGoodsListSimple(goods) {
const list = document.getElementById('goods-list');
let html = ''; // 先在内存里拼字符串
goods.forEach(good => {
html += `
<div class="goods-item">
<img src="${good.img}" alt="${good.name}">
<h3>${good.name}</h3>
<p class="price">¥${good.price}</p>
</div>
`;
});
list.innerHTML = html; // 一次性赋值,触发 1 次重排
}
但字符串拼接有个问题:如果内容包含用户输入(比如商品名称有特殊字符),可能导致 XSS 攻击,而 更安全 ——复杂场景用文档片段,简单场景用字符串拼接。
createElement
三、技巧 2:“离线” 修改 DOM,先隐藏再操作
如果需要批量修改已有 DOM(比如给列表所有项加 “促销标签”),直接在页面上修改会触发多次重排。这时可以先把元素 “离线”(隐藏),改完再 “上线”(显示),把重排次数从 “N 次” 降到 “2 次”(隐藏和显示各 1 次)。
实战场景:给 50 个商品项加 “限时折扣” 标签
javascript
运行
// 反例:直接在页面上修改,触发 50 次重排
function addDiscountTagsBad(items) {
items.forEach(item => {
const tag = document.createElement('span');
tag.className = 'discount-tag';
tag.textContent = '限时折扣';
item.appendChild(tag); // 每次添加都触发重排
});
}
// 正例:先隐藏元素,批量修改后再显示
function addDiscountTagsGood(items) {
const list = document.getElementById('goods-list');
// 1. 先隐藏列表(触发 1 次重排)
list.style.display = 'none';
// 2. 批量修改 DOM(此时修改不会触发重排)
items.forEach(item => {
const tag = document.createElement('span');
tag.className = 'discount-tag';
tag.textContent = '限时折扣';
item.appendChild(tag);
});
// 3. 恢复显示(触发 1 次重排)
list.style.display = 'block';
}
原理:
当元素 时,它不在渲染树中,对它的任何修改都不会触发重排。只有当它被重新显示时,浏览器才会计算一次位置 —— 相当于用 2 次重排,换 N 次重排,非常划算。
display: none
注意:
别用 代替
visibility: hidden!
display: none 只是隐藏元素,但元素仍在渲染树中,修改它的子元素依然会触发重排,达不到 “离线” 效果。
visibility: hidden
四、技巧 3:事件委托,把 100 个事件监听变成 1 个
除了渲染,过多的事件监听也是 DOM 操作的性能杀手。比如商品列表有 100 个 “加入购物车” 按钮,给每个按钮都绑一个 事件,会创建 100 个事件处理函数,既占内存,又拖慢页面初始化速度。
click
解决办法是用事件委托:利用事件冒泡机制,只给父元素绑一个事件,通过判断点击的目标元素,来执行对应的逻辑。
优化前后代码对比:
javascript
运行
// 反例:给每个按钮绑事件,100 个按钮 = 100 个事件监听
function bindAddToCartEventsBad(goods) {
const list = document.getElementById('goods-list');
// 渲染列表(省略代码)...
// 给每个按钮绑事件
goods.forEach(good => {
const btn = list.querySelector(`[data-id="${good.id}"]`);
btn.addEventListener('click', () => {
addToCart(good.id); // 每个按钮一个处理函数
});
});
}
// 正例:事件委托,父元素只绑 1 个事件
function bindAddToCartEventsGood(goods) {
const list = document.getElementById('goods-list');
// 渲染列表(省略代码)...
// 只给父元素绑 1 个事件
list.addEventListener('click', (e) => {
// 判断点击的是“加入购物车”按钮
if (e.target.classList.contains('add-to-cart')) {
// 从 data-id 获取商品 ID
const goodId = e.target.dataset.id;
addToCart(goodId); // 共用一个处理函数
}
});
}
优势:
内存占用减少:事件处理函数从 100 个变成 1 个,内存占用直降 99%;动态元素适配:新增商品项(比如分页加载)不用重新绑事件,父元素的事件能自动处理;初始化更快:少了 99 次 调用,页面初始化时间减少 40%(亲测数据)。
addEventListener
避坑点:
不是所有事件都能委托!比如 (失焦)、
blur(聚焦)这类不冒泡的事件,不能用事件委托。这时可以用
focus、
focusin 代替(它们会冒泡)。
focusout
五、技巧 4:缓存 DOM 查询,别让浏览器 “反复找元素”
DOM 查询(比如 、
getElementById)比 JS 计算慢得多 —— 浏览器需要遍历 DOM 树才能找到元素。如果在循环里反复查询同一个元素,性能会大打折扣。
querySelector
反例:循环里反复查询 DOM
javascript
运行
// 计算所有商品的总价(反例)
function calculateTotalPriceBad() {
const items = document.querySelectorAll('.goods-item');
let total = 0;
items.forEach((item, index) => {
// 问题:每次循环都查询 .price 元素(重复查询 DOM)
const priceEl = item.querySelector('.price');
total += parseFloat(priceEl.textContent.replace('¥', ''));
});
return total;
}
正例:缓存 DOM 查询结果
javascript
运行
function calculateTotalPriceGood() {
const items = document.querySelectorAll('.goods-item');
let total = 0;
// 1. 提前获取所有 .price 元素(只查询 1 次 DOM)
const priceEls = document.querySelectorAll('.goods-item .price');
items.forEach((item, index) => {
// 2. 直接从缓存的集合里取,不用再查询
const price = parseFloat(priceEls[index].textContent.replace('¥', ''));
total += price;
});
return total;
}
性能对比(100 个商品):
| 操作 | 反复查询 DOM(反例) | 缓存查询结果(正例) | 提升幅度 |
|---|---|---|---|
| 执行耗时 | 68ms | 12ms | 减少 82% |
| DOM 查询次数 | 100 次 | 1 次 | 减少 99% |
我的习惯:
在函数顶部集中查询所有需要的 DOM 元素,并存成变量 —— 既提升性能,又让代码更清晰,比如:
javascript
运行
function handleGoodsList() {
// 顶部集中缓存 DOM 元素
const list = document.getElementById('goods-list');
const addBtn = document.getElementById('add-goods-btn');
const priceEls = list.querySelectorAll('.price');
// 后面直接用缓存的变量
addBtn.addEventListener('click', () => { /* ... */ });
// ...
}
六、技巧 5:用 “虚拟 DOM 思想” 减少不必要的更新
如果需要频繁更新 DOM(比如实时刷新商品库存),直接修改 DOM 会导致大量重排。这时可以借鉴 “虚拟 DOM” 的思想:先在内存中比较新旧数据,只更新变化的部分,不变的部分不碰。
实战场景:实时更新 100 个商品的库存
javascript
运行
// 反例:不管库存变没变,全量更新 DOM
function updateStocksBad(goods) {
goods.forEach(good => {
const stockEl = document.querySelector(`[data-id="${good.id}"] .stock`);
// 即使库存没变,也会更新 DOM(触发重绘)
stockEl.textContent = `库存:${good.stock}`;
});
}
// 正例:只更新库存变化的商品
function updateStocksGood(goods) {
// 1. 缓存当前库存(假设之前存过)
const prevStocks = window.goodsStocks || {};
// 2. 保存新库存,供下次对比
window.goodsStocks = {};
goods.forEach(good => {
const currentStock = good.stock;
window.goodsStocks[good.id] = currentStock;
// 3. 只更新库存变化的商品
if (prevStocks[good.id] !== currentStock) {
const stockEl = document.querySelector(`[data-id="${good.id}"] .stock`);
stockEl.textContent = `库存:${currentStock}`;
}
});
}
优化效果:
当只有 10 个商品库存变化时,DOM 更新次数从 100 次降到 10 次,重绘次数减少 90%,页面滚动时再也不会因为 “无效更新” 卡顿。
延伸:
Vue、React 的虚拟 DOM 就是这个原理 —— 通过 diff 算法找到新旧 DOM 的差异,只更新变化的部分,大幅减少重排重绘。如果你在用框架,尽量用框架的状态管理(比如 Vue 的 ),它会自动帮你做 “差异更新”;如果在用原生 JS,就手动实现简单的 “新旧对比”。
reactive
七、避坑总结:DOM 操作的 5 个 “不要”
不要在循环里操作真实 DOM → 用文档片段或字符串拼接批量处理;不要频繁查询同一个 DOM 元素 → 缓存查询结果到变量;不要给大量子元素绑事件 → 用事件委托让父元素处理;不要修改可见元素的样式 / 结构 → 先隐藏,改完再显示;不要更新没变化的数据 → 用 “新旧对比” 只更改变化的部分。
八、最后:性能优化要 “看数据”,别凭感觉
小王改完代码后,兴奋地说:“我感觉页面快多了!” 但我还是让他用 Chrome Performance 面板录了个屏 —— 数据不会骗人:渲染耗时从 320ms 降到 45ms,帧率从 22fps 涨到 58fps,这才是真的 “优化成功”。
DOM 操作优化的核心不是 “用什么 API”,而是 “减少浏览器的计算量”—— 重排重绘对性能的影响,远比 JS 代码本身大。记住:能在内存里做的事,就别放到 DOM 上做。




















暂无评论内容