DOM 操作与事件 - 性能优化最佳实践
DOM 操作是 Web 应用性能的关键瓶颈之一。本文档系统性地介绍 DOM 性能优化的原理、策略和实践方法,帮助你构建高性能的 Web 应用。
📋 目录
浏览器渲染原理
理解浏览器渲染流程是优化的基础。当浏览器渲染一个页面时,会经历以下关键步骤:
渲染管道
code
JavaScript → Style → Layout → Paint → Composite
↓ ↓ ↓ ↓ ↓
脚本执行 样式计算 布局计算 绘制 合成详细流程:
- JavaScript 执行:运行 JavaScript 代码,可能触发 DOM 和 CSSOM 变化
- 样式计算(Style):计算每个元素的最终样式
- 布局(Layout/Reflow):计算元素的位置和大小
- 绘制(Paint/Repaint):将元素绘制到图层
- 合成(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') // ~50msDOM 操作优化
减少 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 面板
- 录制性能:点击录制按钮,执行操作,停止录制
- 分析结果:
- 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 限制重排范围
📊 性能基准
操作耗时参考
| 操作 | 数量 | 优化前 | 优化后 | 提升比例 |
|---|---|---|---|---|
| 创建元素 | 1000 | 200ms | 20ms | 10x |
| 批量插入 | 1000 | 500ms | 50ms | 10x |
| 样式修改 | 1000 | 100ms | 10ms | 10x |
| 事件监听 | 1000 | 100KB | 0.1KB | 1000x |
| 虚拟滚动 | 10000 | 2000ms | 10ms | 200x |
浏览器性能预算
| 指标 | 目标值 | 说明 |
|---|---|---|
| First Paint | < 1s | 首次绘制 |
| First Contentful Paint | < 1.5s | 首次内容绘制 |
| Time to Interactive | < 3s | 可交互时间 |
| Long Tasks | < 50ms | 长任务阈值 |
| Layout Shift | < 0.1 | 布局偏移分数 |