DOM 操作实战:从 “卡顿列表” 到 “丝滑交互” 的 5 个救命技巧

上周改代码时,我差点惊掉下巴 —— 之前写的商品列表,滚动时像幻灯片一样卡顿,鼠标滚轮滚一下,页面能卡半秒。打开 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 次 
appendChild
,触发了 100 次重排 —— 相当于让浏览器反复 “重新计算位置 + 重新绘制”,不卡才怪。

二、技巧 1:用 “文档片段” 批量操作 DOM,重排次数从 100→1

解决 “频繁添加 DOM 元素” 的核心,是减少重排次数。最好的办法是:先把所有元素在 “虚拟容器” 里拼好,最后一次性添加到真实 DOM 中。

这个 “虚拟容器” 就是浏览器原生提供的 
DocumentFragment
(文档片段)—— 它是一个存在于内存中的 DOM 节点容器,往里面加元素不会触发重排,只有当它被添加到真实 DOM 时,才会触发一次重排。

优化前后代码对比:

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
 属性,只能用 
appendChild
 加元素。后来才发现,也可以用 “字符串拼接” 先拼好 HTML,最后一次性赋值给 
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';
}

原理:

当元素 
display: none
 时,它不在渲染树中,对它的任何修改都不会触发重排。只有当它被重新显示时,浏览器才会计算一次位置 —— 相当于用 2 次重排,换 N 次重排,非常划算。

注意:

别用 
visibility: hidden
 代替 
display: none

visibility: hidden
 只是隐藏元素,但元素仍在渲染树中,修改它的子元素依然会触发重排,达不到 “离线” 效果。

四、技巧 3:事件委托,把 100 个事件监听变成 1 个

除了渲染,过多的事件监听也是 DOM 操作的性能杀手。比如商品列表有 100 个 “加入购物车” 按钮,给每个按钮都绑一个 
click
 事件,会创建 100 个事件处理函数,既占内存,又拖慢页面初始化速度。

解决办法是用事件委托:利用事件冒泡机制,只给父元素绑一个事件,通过判断点击的目标元素,来执行对应的逻辑。

优化前后代码对比:

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 次 
addEventListener
 调用,页面初始化时间减少 40%(亲测数据)。

避坑点:

不是所有事件都能委托!比如 
blur
(失焦)、
focus
(聚焦)这类不冒泡的事件,不能用事件委托。这时可以用 
focusin

focusout
 代替(它们会冒泡)。

五、技巧 4:缓存 DOM 查询,别让浏览器 “反复找元素”

DOM 查询(比如 
getElementById

querySelector
)比 JS 计算慢得多 —— 浏览器需要遍历 DOM 树才能找到元素。如果在循环里反复查询同一个元素,性能会大打折扣。

反例:循环里反复查询 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 的 
reactive
),它会自动帮你做 “差异更新”;如果在用原生 JS,就手动实现简单的 “新旧对比”。

七、避坑总结:DOM 操作的 5 个 “不要”

不要在循环里操作真实 DOM → 用文档片段或字符串拼接批量处理;不要频繁查询同一个 DOM 元素 → 缓存查询结果到变量;不要给大量子元素绑事件 → 用事件委托让父元素处理;不要修改可见元素的样式 / 结构 → 先隐藏,改完再显示;不要更新没变化的数据 → 用 “新旧对比” 只更改变化的部分。

八、最后:性能优化要 “看数据”,别凭感觉

小王改完代码后,兴奋地说:“我感觉页面快多了!” 但我还是让他用 Chrome Performance 面板录了个屏 —— 数据不会骗人:渲染耗时从 320ms 降到 45ms,帧率从 22fps 涨到 58fps,这才是真的 “优化成功”。

DOM 操作优化的核心不是 “用什么 API”,而是 “减少浏览器的计算量”—— 重排重绘对性能的影响,远比 JS 代码本身大。记住:能在内存里做的事,就别放到 DOM 上做

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容