Skip to content

DOM 操作性能优化:让网页响应如飞

DOM 操作为什么慢

在一个复杂的 Web 应用中,用户点击按钮后,页面可能需要几百毫秒甚至更长时间才有反应。这种卡顿往往源于频繁的 DOM 操作。

DOM 操作之所以"昂贵",是因为浏览器需要做大量工作。每次你修改 DOM,浏览器都可能需要:

  1. 重新计算样式(Recalculate Style):确定哪些 CSS 规则适用于哪些元素
  2. 重新布局(Layout/Reflow):计算每个元素在屏幕上的位置和尺寸
  3. 重新绘制(Paint):将元素的可视表现绘制到屏幕上
  4. 合成(Composite):将多个层合并成最终的画面

这就像重新装修房间:移动一件家具(修改 DOM),可能需要重新测量空间(布局)、粉刷墙壁(绘制),整个过程非常耗时。

理解这些机制,你就能找到优化的切入点,让页面响应更流畅。

回流与重绘

回流(Reflow)和重绘(Repaint)是 DOM 操作性能的两大杀手。

什么是回流

回流发生在浏览器需要重新计算元素的几何属性(位置、尺寸)时。这是最昂贵的操作,因为一个元素的变化可能影响到整个页面的布局。

以下操作会触发回流:

javascript
// 修改尺寸
element.style.width = "500px";
element.style.height = "300px";

// 修改位置
element.style.top = "100px";
element.style.left = "200px";

// 修改内容
element.textContent = "New very long content that changes layout";

// 添加/删除元素
parent.appendChild(newElement);
parent.removeChild(oldElement);

// 修改字体
element.style.fontSize = "20px";

更隐蔽的是,读取某些属性也会强制浏览器进行回流,因为浏览器需要计算最新的值:

javascript
// 这些操作会强制回流
const width = element.offsetWidth;
const height = element.offsetHeight;
const top = element.offsetTop;
const rect = element.getBoundingClientRect();
const computedStyle = window.getComputedStyle(element);

什么是重绘

重绘发生在元素的可视属性改变,但布局没有改变时。相比回流,重绘的代价要小一些:

javascript
// 只触发重绘,不触发回流
element.style.color = "red";
element.style.backgroundColor = "blue";
element.style.visibility = "hidden"; // 注意:display = 'none' 会触发回流
element.style.outline = "1px solid red";

批量修改样式

频繁修改样式是性能杀手:

javascript
// ❌ 不好的做法:多次触发回流
const box = document.querySelector(".box");
box.style.width = "200px"; // 回流
box.style.height = "200px"; // 回流
box.style.margin = "10px"; // 回流
box.style.padding = "20px"; // 回流

浏览器会尝试优化,将多次修改合并成一次回流,但这不总是可靠的。更好的做法是一次性修改:

javascript
// ✅ 方法一:使用 cssText
const box = document.querySelector(".box");
box.style.cssText = "width: 200px; height: 200px; margin: 10px; padding: 20px;";

// ✅ 方法二:使用 CSS 类
// CSS 文件中定义
// .styled-box {
//   width: 200px;
//   height: 200px;
//   margin: 10px;
//   padding: 20px;
// }

box.className = "styled-box";

// ✅ 方法三:使用 classList
box.classList.add("styled-box");

使用 CSS 类是最推荐的方式,因为它将样式逻辑从 JavaScript 中分离出来,更易维护。

避免强制同步布局

强制同步布局(Forced Synchronous Layout)是一个常见的性能陷阱:

javascript
// ❌ 问题代码:读写交替导致多次回流
const boxes = document.querySelectorAll(".box");

boxes.forEach((box) => {
  box.style.width = box.offsetWidth + 10 + "px"; // 读取 → 写入 → 强制回流
});

每次循环都会:读取 offsetWidth(触发回流)→ 修改 width(触发回流)→ 下一次循环重复。

正确的做法是分离读取和写入操作:

javascript
// ✅ 更好的做法:先读取所有值,再批量修改
const boxes = document.querySelectorAll(".box");

// 阶段一:读取所有值
const widths = Array.from(boxes).map((box) => box.offsetWidth);

// 阶段二:批量修改
boxes.forEach((box, index) => {
  box.style.width = widths[index] + 10 + "px";
});

这样只会触发两次回流:一次在读取时,一次在修改时(浏览器会将所有修改合并)。

批量 DOM 操作

当需要添加大量元素时,逐个插入会导致多次回流。

问题示例

javascript
// ❌ 不好的做法:每次插入都触发回流
const list = document.querySelector(".todo-list");

for (let i = 0; i < 100; i++) {
  const item = document.createElement("li");
  item.textContent = `Todo item ${i + 1}`;
  list.appendChild(item); // 触发 100 次回流
}

使用 DocumentFragment

DocumentFragment 是一个轻量级的文档容器,它存在于内存中,不属于真实的 DOM 树:

javascript
// ✅ 更好的做法:使用 DocumentFragment
const list = document.querySelector(".todo-list");
const fragment = document.createDocumentFragment();

for (let i = 0; i < 100; i++) {
  const item = document.createElement("li");
  item.textContent = `Todo item ${i + 1}`;
  fragment.appendChild(item); // 在内存中操作,不触发回流
}

list.appendChild(fragment); // 只触发一次回流

fragment 被插入到 DOM 时,它自己不会成为 DOM 树的一部分,只有它的子节点会被插入。这是一个非常高效的批量插入方式。

构建复杂的列表项

实际应用中,列表项通常更复杂:

javascript
function createTodoItems(todos) {
  const fragment = document.createDocumentFragment();

  todos.forEach((todo) => {
    const item = document.createElement("li");
    item.className = "todo-item";

    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = todo.completed;

    const label = document.createElement("label");
    label.textContent = todo.text;

    const deleteBtn = document.createElement("button");
    deleteBtn.textContent = "Delete";
    deleteBtn.className = "delete-btn";

    item.appendChild(checkbox);
    item.appendChild(label);
    item.appendChild(deleteBtn);

    fragment.appendChild(item);
  });

  return fragment;
}

// 使用
const todos = [
  { text: "Learn JavaScript", completed: true },
  { text: "Build a project", completed: false },
  { text: "Deploy to production", completed: false },
];

const list = document.querySelector(".todo-list");
const fragment = createTodoItems(todos);
list.appendChild(fragment);

使用 innerHTML 的权衡

另一个批量插入方法是使用 innerHTML

javascript
// 使用 innerHTML
const list = document.querySelector(".todo-list");
let html = "";

for (let i = 0; i < 100; i++) {
  html += `<li>Todo item ${i + 1}</li>`;
}

list.innerHTML = html; // 一次性插入

innerHTML 的优缺点:

优点:

  • 代码简洁
  • 性能通常不错(浏览器内部优化)

缺点:

  • 会移除所有事件监听器
  • 可能有 XSS 安全风险(如果内容来自用户输入)
  • 不够灵活(难以创建复杂的元素结构)

建议:

  • 静态内容使用 innerHTML
  • 需要绑定事件的复杂结构使用 DocumentFragment

离线操作 DOM

将元素从文档中"断开"进行修改,然后再插入回去,可以显著减少回流次数。

使用 display: none

javascript
const container = document.querySelector(".container");

// 第一步:隐藏元素(触发一次回流)
container.style.display = "none";

// 第二步:在隐藏状态下进行大量修改(不触发回流)
for (let i = 0; i < 100; i++) {
  const item = document.createElement("div");
  item.textContent = `Item ${i}`;
  container.appendChild(item);
}

// 第三步:重新显示(触发一次回流)
container.style.display = "block";

这种方法只触发两次回流,无论中间做了多少修改。

使用文档片段

另一个方法是克隆元素,在副本上操作,然后替换:

javascript
const container = document.querySelector(".container");

// 克隆元素
const clone = container.cloneNode(true);

// 在克隆上操作
for (let i = 0; i < 100; i++) {
  const item = document.createElement("div");
  item.textContent = `Item ${i}`;
  clone.appendChild(item);
}

// 替换原元素
container.parentNode.replaceChild(clone, container);

修改大量样式的优化

当需要修改大量元素的样式时:

javascript
// ❌ 不好的做法
const items = document.querySelectorAll(".item");
items.forEach((item) => {
  item.style.color = "red"; // 100 次重绘
  item.style.fontSize = "16px"; // 100 次回流
});

// ✅ 更好的做法:使用 CSS 类
const container = document.querySelector(".container");
container.classList.add("styled"); // 一次回流/重绘

// CSS 文件中
// .container.styled .item {
//   color: red;
//   font-size: 16px;
// }

虚拟滚动

当列表包含数千甚至数万条数据时,即使用了 DocumentFragment,渲染所有元素仍然会导致性能问题。虚拟滚动(Virtual Scrolling)只渲染可见区域的元素,是处理大数据集的有效方案。

基本原理

虚拟滚动的核心思想是:

  1. 只渲染可见区域的元素(比如 20 条)
  2. 当用户滚动时,动态替换显示的元素
  3. 保持正确的滚动条高度

简单实现

javascript
class VirtualScroller {
  constructor(container, items, itemHeight) {
    this.container = container;
    this.items = items;
    this.itemHeight = itemHeight;
    this.visibleCount = Math.ceil(container.clientHeight / itemHeight);
    this.startIndex = 0;

    this.init();
  }

  init() {
    // 创建滚动容器
    this.scrollContainer = document.createElement("div");
    this.scrollContainer.style.height = `${
      this.items.length * this.itemHeight
    }px`;
    this.scrollContainer.style.position = "relative";

    // 创建可见内容容器
    this.content = document.createElement("div");
    this.content.style.position = "absolute";
    this.content.style.top = "0";
    this.content.style.left = "0";
    this.content.style.right = "0";

    this.scrollContainer.appendChild(this.content);
    this.container.appendChild(this.scrollContainer);

    // 监听滚动
    this.container.addEventListener("scroll", () => this.handleScroll());

    // 初始渲染
    this.render();
  }

  handleScroll() {
    const scrollTop = this.container.scrollTop;
    const newStartIndex = Math.floor(scrollTop / this.itemHeight);

    if (newStartIndex !== this.startIndex) {
      this.startIndex = newStartIndex;
      this.render();
    }
  }

  render() {
    // 计算要显示的元素范围
    const endIndex = Math.min(
      this.startIndex + this.visibleCount + 1, // +1 作为缓冲
      this.items.length
    );

    // 清空并重新渲染
    this.content.innerHTML = "";
    this.content.style.transform = `translateY(${
      this.startIndex * this.itemHeight
    }px)`;

    for (let i = this.startIndex; i < endIndex; i++) {
      const item = document.createElement("div");
      item.className = "virtual-item";
      item.style.height = `${this.itemHeight}px`;
      item.textContent = this.items[i];
      this.content.appendChild(item);
    }
  }
}

// 使用
const container = document.querySelector(".scroll-container");
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);
new VirtualScroller(container, items, 50); // 每项高度 50px

这个实现可以流畅地处理上万条数据,因为 DOM 中始终只有 20-30 个元素。

改进版:支持不同高度

javascript
class AdvancedVirtualScroller {
  constructor(container, items, estimatedHeight = 50) {
    this.container = container;
    this.items = items;
    this.estimatedHeight = estimatedHeight;
    this.heights = new Array(items.length).fill(estimatedHeight);
    this.positions = this.calculatePositions();

    this.init();
  }

  calculatePositions() {
    const positions = [0];
    for (let i = 0; i < this.heights.length; i++) {
      positions.push(positions[i] + this.heights[i]);
    }
    return positions;
  }

  findStartIndex(scrollTop) {
    // 二分查找找到起始索引
    let low = 0;
    let high = this.positions.length - 1;

    while (low < high) {
      const mid = Math.floor((low + high) / 2);
      if (this.positions[mid] < scrollTop) {
        low = mid + 1;
      } else {
        high = mid;
      }
    }

    return Math.max(0, low - 1);
  }

  render() {
    const scrollTop = this.container.scrollTop;
    const containerHeight = this.container.clientHeight;

    const startIndex = this.findStartIndex(scrollTop);
    let endIndex = startIndex;
    let height = 0;

    // 找到结束索引
    while (height < containerHeight && endIndex < this.items.length) {
      height += this.heights[endIndex];
      endIndex++;
    }

    // 渲染可见元素
    const fragment = document.createDocumentFragment();

    for (let i = startIndex; i < endIndex; i++) {
      const item = this.createItem(this.items[i], i);
      fragment.appendChild(item);
    }

    this.content.innerHTML = "";
    this.content.style.transform = `translateY(${this.positions[startIndex]}px)`;
    this.content.appendChild(fragment);
  }

  createItem(data, index) {
    const item = document.createElement("div");
    item.className = "virtual-item";
    item.dataset.index = index;
    item.textContent = data;

    // 测量实际高度(在首次渲染后)
    requestAnimationFrame(() => {
      const actualHeight = item.offsetHeight;
      if (actualHeight !== this.heights[index]) {
        this.heights[index] = actualHeight;
        this.positions = this.calculatePositions();
      }
    });

    return item;
  }

  // ... 其他方法类似
}

CSS 优化技巧

某些 CSS 属性的性能消耗特别大,合理使用可以显著提升性能。

使用 transform 代替位置属性

javascript
// ❌ 触发回流
element.style.left = "100px";
element.style.top = "50px";

// ✅ 不触发回流,只触发合成
element.style.transform = "translate(100px, 50px)";

transformopacity 是少数几个不触发回流的动画属性,浏览器可以在合成层上直接处理,性能最佳。

使用 will-change 提示浏览器

css
.animated-element {
  will-change: transform, opacity;
}

will-change 告诉浏览器这个元素即将发生变化,浏览器可以提前优化。但不要滥用,因为它会消耗额外内存:

javascript
// 动画开始前设置
element.style.willChange = "transform";

// 动画结束后移除
element.addEventListener("animationend", () => {
  element.style.willChange = "auto";
});

使用 contain 属性

CSS contain 属性告诉浏览器某个元素的变化不会影响外部:

css
.independent-component {
  contain: layout style paint;
}

这让浏览器可以跳过对页面其他部分的计算,提升性能。

性能测量与监控

优化之前先测量,才能知道瓶颈在哪里。

使用 Performance API

javascript
// 标记开始
performance.mark("dom-operation-start");

// 执行 DOM 操作
const list = document.querySelector(".list");
for (let i = 0; i < 1000; i++) {
  const item = document.createElement("li");
  item.textContent = `Item ${i}`;
  list.appendChild(item);
}

// 标记结束
performance.mark("dom-operation-end");

// 测量时间
performance.measure(
  "dom-operation",
  "dom-operation-start",
  "dom-operation-end"
);

// 获取结果
const measure = performance.getEntriesByName("dom-operation")[0];
console.log(`DOM 操作耗时: ${measure.duration.toFixed(2)}ms`);

使用 Chrome DevTools

浏览器开发者工具提供了强大的性能分析功能:

  1. Performance 面板:录制页面活动,查看详细的性能时间线
  2. Rendering 面板:实时显示 FPS、回流/重绘区域
  3. Layers 面板:查看合成层结构

封装性能测试工具

javascript
class PerformanceMonitor {
  static measure(name, fn) {
    const startMark = `${name}-start`;
    const endMark = `${name}-end`;

    performance.mark(startMark);
    const result = fn();
    performance.mark(endMark);

    performance.measure(name, startMark, endMark);
    const measure = performance.getEntriesByName(name)[0];

    console.log(`${name}: ${measure.duration.toFixed(2)}ms`);

    // 清理标记
    performance.clearMarks(startMark);
    performance.clearMarks(endMark);
    performance.clearMeasures(name);

    return result;
  }

  static async measureAsync(name, fn) {
    const startMark = `${name}-start`;
    const endMark = `${name}-end`;

    performance.mark(startMark);
    const result = await fn();
    performance.mark(endMark);

    performance.measure(name, startMark, endMark);
    const measure = performance.getEntriesByName(name)[0];

    console.log(`${name}: ${measure.duration.toFixed(2)}ms`);

    performance.clearMarks(startMark);
    performance.clearMarks(endMark);
    performance.clearMeasures(name);

    return result;
  }

  static compare(name1, fn1, name2, fn2) {
    const time1 = this.measure(name1, fn1);
    const time2 = this.measure(name2, fn2);

    const measures = performance.getEntriesByType("measure");
    const measure1 = measures.find((m) => m.name === name1);
    const measure2 = measures.find((m) => m.name === name2);

    const diff = measure2.duration - measure1.duration;
    const percent = ((diff / measure1.duration) * 100).toFixed(1);

    console.log(
      `${name2} 比 ${name1} ${diff > 0 ? "慢" : "快"} ${Math.abs(diff).toFixed(
        2
      )}ms (${Math.abs(percent)}%)`
    );
  }
}

// 使用示例
PerformanceMonitor.compare(
  "逐个插入",
  () => {
    const list = document.querySelector(".list");
    for (let i = 0; i < 1000; i++) {
      const item = document.createElement("li");
      item.textContent = `Item ${i}`;
      list.appendChild(item);
    }
  },
  "使用 Fragment",
  () => {
    const list = document.querySelector(".list");
    const fragment = document.createDocumentFragment();
    for (let i = 0; i < 1000; i++) {
      const item = document.createElement("li");
      item.textContent = `Item ${i}`;
      fragment.appendChild(item);
    }
    list.appendChild(fragment);
  }
);

实际优化案例

案例一:优化表格渲染

假设需要渲染一个 1000 行的数据表格:

javascript
// ❌ 慢速版本
function renderTableSlow(data) {
  const table = document.querySelector("table tbody");
  table.innerHTML = ""; // 清空

  data.forEach((row) => {
    const tr = document.createElement("tr");

    Object.values(row).forEach((value) => {
      const td = document.createElement("td");
      td.textContent = value;
      tr.appendChild(td); // 多次 DOM 操作
    });

    table.appendChild(tr); // 每行都触发回流
  });
}

// ✅ 快速版本
function renderTableFast(data) {
  const table = document.querySelector("table tbody");
  const fragment = document.createDocumentFragment();

  data.forEach((row) => {
    const tr = document.createElement("tr");

    Object.values(row).forEach((value) => {
      const td = document.createElement("td");
      td.textContent = value;
      tr.appendChild(td);
    });

    fragment.appendChild(tr); // 在内存中操作
  });

  table.innerHTML = ""; // 先清空
  table.appendChild(fragment); // 一次性插入
}

// 测试数据
const testData = Array.from({ length: 1000 }, (_, i) => ({
  id: i + 1,
  name: `User ${i + 1}`,
  email: `user${i + 1}@example.com`,
  role: ["Admin", "User", "Guest"][i % 3],
}));

// 性能对比
PerformanceMonitor.compare(
  "慢速版本",
  () => renderTableSlow(testData),
  "快速版本",
  () => renderTableFast(testData)
);
// 输出类似: 快速版本 比 慢速版本 快 450ms (75%)

案例二:优化动画

javascript
// ❌ 使用 setInterval 修改位置(触发回流)
function animateSlow(element, distance, duration) {
  const start = Date.now();
  const startPos = element.offsetLeft;

  const interval = setInterval(() => {
    const elapsed = Date.now() - start;
    const progress = Math.min(elapsed / duration, 1);

    element.style.left = startPos + distance * progress + "px"; // 回流

    if (progress >= 1) {
      clearInterval(interval);
    }
  }, 16);
}

// ✅ 使用 requestAnimationFrame 和 transform(不触发回流)
function animateFast(element, distance, duration) {
  const start = Date.now();

  function update() {
    const elapsed = Date.now() - start;
    const progress = Math.min(elapsed / duration, 1);

    element.style.transform = `translateX(${distance * progress}px)`; // 只合成

    if (progress < 1) {
      requestAnimationFrame(update);
    }
  }

  requestAnimationFrame(update);
}

案例三:优化列表过滤

javascript
class OptimizedList {
  constructor(container, items) {
    this.container = container;
    this.allItems = items;
    this.filteredItems = items;
    this.render();
  }

  filter(searchTerm) {
    // 过滤数据(不操作 DOM)
    this.filteredItems = this.allItems.filter((item) =>
      item.toLowerCase().includes(searchTerm.toLowerCase())
    );

    // 一次性重新渲染
    this.render();
  }

  render() {
    // 使用 innerHTML 一次性渲染(适合静态内容)
    this.container.innerHTML = this.filteredItems
      .map((item) => `<li class="item">${item}</li>`)
      .join("");
  }
}

// 使用
const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`);
const list = new OptimizedList(document.querySelector(".list"), items);

// 搜索框输入时防抖
const searchInput = document.querySelector("#search");
let debounceTimer;

searchInput.addEventListener("input", (e) => {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    list.filter(e.target.value);
  }, 300); // 300ms 防抖
});

性能优化清单

完成 DOM 操作后,用这个清单检查是否还有优化空间:

样式修改

  • [ ] 批量修改样式(使用 CSS 类而非逐个属性)
  • [ ] 避免读写交替(分离读取和写入操作)
  • [ ] 使用 transformopacity 实现动画
  • [ ] 合理使用 will-change 提示浏览器

DOM 操作

  • [ ] 批量插入使用 DocumentFragment
  • [ ] 大量修改时使用 display: none 隐藏元素
  • [ ] 缓存 DOM 查询结果
  • [ ] 长列表使用虚拟滚动

事件处理

  • [ ] 使用事件委托减少监听器数量
  • [ ] 滚动和 resize 事件使用防抖或节流
  • [ ] 使用 passive 事件监听器提升滚动性能

测量验证

  • [ ] 使用 Performance API 测量优化效果
  • [ ] 在 Chrome DevTools 中分析性能
  • [ ] 在低端设备上测试

总结

DOM 操作性能优化的核心原则:

  1. 减少回流和重绘:理解何时触发回流,批量修改样式
  2. 批量操作:使用 DocumentFragmentinnerHTML 等技术
  3. 异步和缓存:避免强制同步布局,缓存 DOM 查询结果
  4. 虚拟化技术:大数据集使用虚拟滚动
  5. 使用合适的 CSS:利用 transformwill-changecontain 等属性
  6. 持续测量:用数据说话,不要盲目优化

过早优化是万恶之源。先让代码正确运行,发现性能瓶颈后再针对性优化。用 Performance API 和 DevTools 测量,确保优化真的有效。

掌握这些技术,你就能构建出流畅、快速响应的 Web 应用,为用户提供优质的体验。