{T}

DOM 操作与事件 - 性能优化最佳实践

DOM 操作是 Web 应用性能的关键瓶颈之一。本文档系统性地介绍 DOM 性能优化的原理、策略和实践方法,帮助你构建高性能的 Web 应用。

📋 目录


浏览器渲染原理

理解浏览器渲染流程是优化的基础。当浏览器渲染一个页面时,会经历以下关键步骤:

渲染管道

code
JavaScript → Style → Layout → Paint → Composite
   ↓           ↓         ↓        ↓         ↓
 脚本执行   样式计算   布局计算   绘制      合成

详细流程:

  1. JavaScript 执行:运行 JavaScript 代码,可能触发 DOM 和 CSSOM 变化
  2. 样式计算(Style):计算每个元素的最终样式
  3. 布局(Layout/Reflow):计算元素的位置和大小
  4. 绘制(Paint/Repaint):将元素绘制到图层
  5. 合成(Composite):将图层合成到屏幕

性能瓶颈

操作类型触发条件性能影响优化策略
重排(Layout)改变元素几何属性高(同步计算)批量操作、脱离文档流
重绘(Paint)改变元素外观中(异步绘制)减少 DOM 节点、使用 CSS 动画
合成(Composite)图层变化低(GPU 加速)使用 transform、opacity

关键渲染路径

html
<!-- HTML 结构省略,仅展示关键 JS 逻辑 -->

优化建议:

  • CSS 放在 <head>
  • JavaScript 放在 <body> 末尾或使用 defer/async
  • 关键 CSS 内联,非关键 CSS 延迟加载

重排与重绘优化

什么是重排和重绘

重排(Reflow/Layout)

当 DOM 元素的几何属性发生变化时,浏览器需要重新计算元素的布局,这个过程称为重排。

触发重排的操作:

javascript
// ❌ 触发重排的常见操作

// 1. 改变窗口大小
window.addEventListener('resize', () => { })

// 2. 改变字体大小
element.style.fontSize = '16px'

// 3. 添加/删除样式表
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'style.css'

// 4. 添加/删除/移动元素
parent.appendChild(newElement)
parent.removeChild(oldElement)

// 5. 修改元素内容
element.textContent = '新内容'

// 6. 修改元素的几何属性
element.style.width = '200px'
element.style.margin = '10px'

// 7. 滚动或读取滚动位置
window.scrollTo(0, 100)

// 8. 获取布局信息(强制同步布局)
const height = element.offsetHeight
const width = element.offsetWidth
const rect = element.getBoundingClientRect()

重绘(Repaint)

当元素的外观发生变化但不影响布局时,浏览器会重新绘制元素,这个过程称为重绘。

触发重绘的操作:

javascript
// ❌ 触发重绘的常见操作

// 1. 改变颜色
element.style.color = 'red'
element.style.backgroundColor = 'blue'

// 2. 改变可见性
element.style.visibility = 'hidden'

// 3. 改变背景图
element.style.backgroundImage = 'url(bg.jpg)'

// 4. 改变边框颜色
element.style.borderColor = 'black'

注意:重排必定触发重绘,但重绘不一定触发重排。

强制同步布局

浏览器通常会批量处理 DOM 变化,但某些操作会强制浏览器立即执行布局计算。

javascript
// ❌ 错误:读写交替导致多次重排
const elements = document.querySelectorAll('.box')
elements.forEach(element => {
  const height = element.offsetHeight // 读取(触发布局)
  element.style.height = height + 10 + 'px' // 写入
})

// ✅ 正确:批量读取,批量写入
const elements = document.querySelectorAll('.box')
const heights = []
// 批量读取(只触发一次布局)
elements.forEach(element => {
  heights.push(element.offsetHeight)
})
// 批量写入(合并为一次重排)
elements.forEach((element, i) => {
  element.style.height = heights[i] + 10 + 'px'
})

优化策略

1. 批量修改 DOM

javascript
// ❌ 低效:多次重排
const list = document.getElementById('list')
for (let i = 0; i < 100; i++) {
  const li = document.createElement('li')
  li.textContent = `项目 ${i}`
  list.appendChild(li) // 每次都触发重排
}

// ✅ 高效:使用 DocumentFragment
const fragment = document.createDocumentFragment()
for (let i = 0; i < 100; i++) {
  const li = document.createElement('li')
  li.textContent = `项目 ${i}`
  fragment.appendChild(li)
}
list.appendChild(fragment) // 只触发一次重排

2. 脱离文档流

javascript
// ✅ 方法1:display: none
const element = document.getElementById('element')
element.style.display = 'none' // 脱离文档流
// 执行多次 DOM 操作
element.style.width = '100px'
element.style.height = '100px'
element.style.margin = '10px'
element.style.display = 'block' // 恢复显示

// ✅ 方法2:克隆节点
const element = document.getElementById('element')
const clone = element.cloneNode(true)
// 在克隆节点上修改
clone.style.width = '100px'
clone.style.height = '100px'
// 一次性替换
element.parentNode.replaceChild(clone, element)

3. 使用 CSS 类批量修改

javascript
// ❌ 低效:多次样式修改
element.style.width = '100px'
element.style.height = '100px'
element.style.margin = '10px'
element.style.padding = '5px'

// ✅ 高效:使用 CSS 类
.active {
  width: 100px;
  height: 100px;
  margin: 10px;
  padding: 5px;
}

element.classList.add('active') // 只触发一次重排

4. 避免强制同步布局

javascript
// ❌ 低效:循环中读取布局信息
const elements = document.querySelectorAll('.box')
for (let i = 0; i < elements.length; i++) {
  elements[i].style.width = elements[i].offsetWidth + 10 + 'px'
}

// ✅ 高效:先批量读取,再批量写入
const elements = document.querySelectorAll('.box')
const widths = Array.from(elements).map(el => el.offsetWidth)
elements.forEach((el, i) => {
  el.style.width = widths[i] + 10 + 'px'
})

5. 使用 transform 和 opacity

css
/* ❌ 低效:触发重排 */
.box {
  position: absolute;
  left: 0;
  top: 0;
  transition: left 0.3s, top 0.3s;
}

.box.active {
  left: 100px;
  top: 100px;
}

/* ✅ 高效:只触发合成,GPU 加速 */
.box {
  transform: translate(0, 0);
  transition: transform 0.3s;
  will-change: transform; /* 提示浏览器优化 */
}

.box.active {
  transform: translate(100px, 100px);
}

性能对比:

javascript
// 测试:移动 1000 个元素

// 使用 left/top
console.time('left/top')
for (let i = 0; i < 1000; i++) {
  const div = document.createElement('div')
  div.style.position = 'absolute'
  div.style.left = i + 'px'
  document.body.appendChild(div)
}
console.timeEnd('left/top') // ~500ms

// 使用 transform
console.time('transform')
for (let i = 0; i < 1000; i++) {
  const div = document.createElement('div')
  div.style.transform = `translate(${i}px, 0)`
  document.body.appendChild(div)
}
console.timeEnd('transform') // ~50ms

DOM 操作优化

减少 DOM 操作次数

javascript
// ❌ 低效:频繁 DOM 操作
function updateList(items) {
  const list = document.getElementById('list')
  list.innerHTML = '' // 清空
  items.forEach(item => {
    const li = document.createElement('li')
    li.textContent = item
    list.appendChild(li) // 每次都触发重排
  })
}

// ✅ 高效:减少操作次数
function updateList(items) {
  const list = document.getElementById('list')
  const fragment = document.createDocumentFragment()
  items.forEach(item => {
    const li = document.createElement('li')
    li.textContent = item
    fragment.appendChild(li)
  })
  list.innerHTML = '' // 清空
  list.appendChild(fragment) // 一次性插入
}

// ✅ 更高效:使用 innerHTML(适合大量简单节点)
function updateList(items) {
  const list = document.getElementById('list')
  const html = items.map(item => `<li>${item}</li>`).join('')
  list.innerHTML = html
}

缓存 DOM 查询

javascript
// ❌ 低效:重复查询
for (let i = 0; i < 100; i++) {
  document.getElementById('element').style.width = i + 'px'
}

// ✅ 高效:缓存引用
const element = document.getElementById('element')
for (let i = 0; i < 100; i++) {
  element.style.width = i + 'px'
}

// ✅ 更好:批量样式设置
const element = document.getElementById('element')
element.style.cssText = 'width: 100px; height: 100px;'

虚拟滚动

当渲染大量列表项时,使用虚拟滚动只渲染可见区域的元素。

javascript
class VirtualScroll {
  constructor(container, options = {}) {
    this.container = container
    this.itemHeight = options.itemHeight || 50
    this.bufferSize = options.bufferSize || 5
    this.data = []
    this.visibleItems = []

    this.init()
  }

  init() {
    // 监听滚动,更新可见区域
    this.container.addEventListener('scroll', () => this.onScroll())
    // 撑起容器高度,模拟全部数据
    this.container.style.position = 'relative'
  }

  setData(data) {
    this.data = data
    this.container.style.height = data.length * this.itemHeight + 'px'
    this.render()
  }

  onScroll() {
    this.render()
  }

  render() {
    const scrollTop = this.container.scrollTop
    const viewportHeight = this.container.clientHeight
    const start = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.bufferSize)
    const end = Math.min(this.data.length, start + Math.ceil(viewportHeight / this.itemHeight) + this.bufferSize * 2)

    this.container.innerHTML = ''
    for (let i = start; i < end; i++) {
      const div = document.createElement('div')
      div.textContent = this.data[i]
      div.style.position = 'absolute'
      div.style.top = i * this.itemHeight + 'px'
      div.style.height = this.itemHeight + 'px'
      div.style.width = '100%'
      this.container.appendChild(div)
    }
  }
}

// 使用示例
const virtualScroll = new VirtualScroll(document.getElementById('list'), {
  itemHeight: 50,
  bufferSize: 5
})

const data = Array.from({ length: 10000 }, (_, i) => `项目 ${i + 1}`)
virtualScroll.setData(data)

性能对比:

javascript
// 传统方式:渲染 10000 个元素
console.time('传统渲染')
const list = document.getElementById('list')
for (let i = 0; i < 10000; i++) {
  const li = document.createElement('li')
  li.textContent = `项目 ${i + 1}`
  list.appendChild(li)
}
console.timeEnd('传统渲染') // ~2000ms

// 虚拟滚动:只渲染可见元素
console.time('虚拟滚动')
virtualScroll.setData(data)
console.timeEnd('虚拟滚动') // ~10ms

事件处理优化

事件委托

javascript
// ❌ 低效:为每个元素添加监听器
document.querySelectorAll('.item').forEach(item => {
  item.addEventListener('click', handleClick)
})

// ✅ 高效:使用事件委托
document.getElementById('container').addEventListener('click', (e) => {
  const item = e.target.closest('.item')
  if (item) {
    handleClick.call(item, e)
  }
})

防抖和节流

javascript
// 防抖:延迟执行,适合搜索输入
function debounce(func, wait) {
  let timeout
  return function(...args) {
    clearTimeout(timeout)
    timeout = setTimeout(() => func.apply(this, args), wait)
  }
}

// 使用防抖
const searchInput = document.getElementById('search')
searchInput.addEventListener('input', debounce((e) => {
  searchApi(e.target.value)
}, 300))

// 节流:限制频率,适合滚动、resize
function throttle(func, limit) {
  let inThrottle = false
  return function(...args) {
    if (!inThrottle) {
      func.apply(this, args)
      inThrottle = true
      setTimeout(() => inThrottle = false, limit)
    }
  }
}

// 使用节流
window.addEventListener('scroll', throttle(() => {
  updateScrollPosition()
}, 200))

Passive 事件监听器

javascript
// ✅ 使用 passive 提升滚动性能
document.addEventListener('touchstart', (e) => {
  // 不会调用 preventDefault
}, { passive: true })

// ❌ 不使用 passive(默认)
document.addEventListener('touchstart', (e) => {
  // 可能调用 preventDefault,浏览器必须等待
  // 阻塞滚动
})

// ✅ 正确:需要 preventDefault 时不要使用 passive
document.addEventListener('touchstart', (e) => {
  e.preventDefault() // 阻止默认行为
}, { passive: false })

内存管理

避免内存泄漏

1. 清除事件监听器

javascript
// ❌ 内存泄漏:未清理监听器
class Component {
  constructor() {
    this.handleClick = this.handleClick.bind(this)
    window.addEventListener('resize', this.handleResize)
    document.addEventListener('click', this.handleClick)
  }

  handleResize() { }
  handleClick() { }
  // 没有 destroy 方法,监听器永远无法移除
}

// ✅ 正确:在 destroy 中清理监听器
class ComponentGood {
  constructor() {
    this.controller = new AbortController()
    const { signal } = this.controller
    window.addEventListener('resize', this.handleResize, { signal })
    document.addEventListener('click', this.handleClick, { signal })
  }

  handleResize() { }
  handleClick() { }

  destroy() {
    this.controller.abort() // 自动移除所有监听器
  }
}

2. 清除定时器

javascript
// ❌ 内存泄漏:未清理定时器
class Component {
  constructor() {
    this.timer = setInterval(() => {
      this.update()
    }, 1000)
  }
  
  update() { }
  
  // 没有 destroy 方法
}

// ✅ 正确:清理定时器
class Component {
  constructor() {
    this.timer = setInterval(() => {
      this.update()
    }, 1000)
  }
  
  update() { }
  
  destroy() {
    clearInterval(this.timer)
  }
}

3. 断开引用

javascript
// ❌ 内存泄漏:保留引用
class Component {
  constructor() {
    this.elements = []
    for (let i = 0; i < 1000; i++) {
      const el = document.createElement('div')
      document.body.appendChild(el)
      this.elements.push(el)
    }
  }

  destroy() {
    // 从 DOM 中移除所有元素
    this.elements.forEach(el => el.remove())
    this.elements = null // 断开引用
  }
}

WeakMap 和 WeakSet

使用 WeakMap 存储与 DOM 元素关联的数据,当元素被移除时自动释放内存。

javascript
// ❌ 内存泄漏:普通 Map
const elementData = new Map()

document.querySelectorAll('.item').forEach(item => {
  elementData.set(item, {
    clicks: 0,
    lastClick: null
  })
  
  item.addEventListener('click', () => {
    const data = elementData.get(item)
    data.clicks++
    data.lastClick = Date.now()
  })
})
// 问题:即使 item 从 DOM 移除,普通 Map 仍强引用它,造成泄漏

// ✅ 正确:使用 WeakMap,当 item 被移除时自动释放
const elementDataWeak = new WeakMap()
document.querySelectorAll('.item').forEach(item => {
  elementDataWeak.set(item, { clicks: 0 })
})
// 当 item 被移除时,WeakMap 自动释放内存

性能监控与测量

Performance API

javascript
// 测量代码执行时间
console.time('操作')
// ... 代码
console.timeEnd('操作')

// 使用 performance.now()
const start = performance.now()
// ... 代码
const end = performance.now()
console.log(`耗时: ${end - start}ms`)

// 使用 performance.mark 和 measure
performance.mark('start')
// ... 代码
performance.mark('end')
performance.measure('操作耗时', 'start', 'end')

const measures = performance.getEntriesByName('操作耗时')
console.log(measures[0].duration)

PerformanceObserver

javascript
// 监控长任务
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('长任务:', entry)
  }
})

observer.observe({ entryTypes: ['longtask'] })

// 监控布局偏移
const layoutShiftObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('布局偏移:', entry.value)
    }
  }
})

layoutShiftObserver.observe({ entryTypes: ['layout-shift'] })

Chrome DevTools

Performance 面板

  1. 录制性能:点击录制按钮,执行操作,停止录制
  2. 分析结果
    • Main 线程:JavaScript 执行
    • Layout:布局计算
    • Paint:绘制
    • Composite:合成

Layers 面板

查看图层信息,优化合成性能。

Rendering 面板

  • FPS meter:实时 FPS
  • Paint flashing:高亮重绘区域
  • Layer borders:显示图层边界
  • Layout shift regions:显示布局偏移区域

优化清单

DOM 操作优化清单

  • 使用 DocumentFragment 批量操作 DOM
  • 缓存 DOM 查询结果
  • 使用 CSS 类批量修改样式
  • 避免在循环中读取布局信息
  • 使用 transform 和 opacity 实现动画
  • 虚拟化长列表
  • 使用 innerHTML 替代多次 createElement(简单场景)

事件处理优化清单

  • 使用事件委托减少监听器数量
  • 使用防抖和节流控制高频事件
  • 使用 passive 提升滚动性能
  • 及时移除不再需要的监听器
  • 使用 AbortController 管理监听器生命周期

内存管理优化清单

  • 及时清理事件监听器
  • 清除定时器和 interval
  • 断开不再需要的对象引用
  • 使用 WeakMap/WeakSet 存储关联数据
  • 避免闭包导致的内存泄漏
  • 使用 Chrome DevTools Memory 面板检测泄漏

渲染性能优化清单

  • 使用 CSS 动画替代 JavaScript 动画
  • 使用 will-change 提示浏览器优化
  • 避免强制同步布局
  • 使用 requestAnimationFrame 优化动画
  • 减少 DOM 节点数量
  • 使用 CSS Containment 限制重排范围

📊 性能基准

操作耗时参考

操作数量优化前优化后提升比例
创建元素1000200ms20ms10x
批量插入1000500ms50ms10x
样式修改1000100ms10ms10x
事件监听1000100KB0.1KB1000x
虚拟滚动100002000ms10ms200x

浏览器性能预算

指标目标值说明
First Paint< 1s首次绘制
First Contentful Paint< 1.5s首次内容绘制
Time to Interactive< 3s可交互时间
Long Tasks< 50ms长任务阈值
Layout Shift< 0.1布局偏移分数

📚 延伸阅读