高效操作DOM

368 阅读8分钟

DOM(Document Object Model,文档对象模型),是JavaScript操作HTML的接口(这里只讨论属于前端范畴的HTML DOM)。

属于前端的入门知识,同样也是核心内容,因为大部分前端功能都需要借助DOM来实现。比如:

  • 动态渲染列表、表格表单数据
  • 监听点击、提交事件
  • 懒加载一些脚本或样式文件
  • 实现动态展开树组件,表单组件级联等这类复杂的操作

DOM V3标准

如果你查看过 DOM V3 标准,会发现包含多个内容,但归纳起来常用的主要由 3 个部分组成:

  • DOM 节点
  • DOM 事件
  • 选择区域:选择区域的使用场景有限,一般用于富文本编辑类业务,我们不做深入讨论

DOM节点

DOM节点概念区分

概念说明
标签标签是HTML的基本单位,比如p、div、input
节点节点是DOM树的基本单位,有多种类型,比如注释节点、文本节点
元素元素是节点中的一种,与HTML标签相对应,比如p标签会对应p元素

举例说明

<p>亚里士朱德</p>

p是标签,生成DOM树的时候会产生两个节点

  • 元素节点p
  • 字符串为“亚里士朱德”的文本节点

操作DOM耗时?

为什么说操作DOM耗时?要解释DOM操作带来的性能问题,不得不了解浏览器的工作机制

从深入理解DOM的必要性说起,然后分析了DOM操作耗时的原因,最后再针对这些原因提出了可行的解决方法。

线程切换

浏览器包含:渲染器引擎,JS引擎,它们都是单线程运行。单线程的优势:开发方便,避免多线程下的死锁、竞争等问题,劣势:失去了并发能力

为了避免两个引擎同时修改页面而造成渲染结果不一致的情况,这两个引擎具有互斥性,也就是说在某个时刻只有一个引擎在运行,另一个引擎会被阻塞。 操作系统在进行线程切换的时候需要保存上一个线程执行时的状态信息,并读取下一个线程的状态信息,俗称上下文切换。而这个操作相对而言是比较耗时的。

每次DOM操作就会引发进程的上下文切换,从JavaScript引擎切换到渲染引擎执行对应操作,然后再切换回JavaScript引擎继续执行,这就带来了性能损耗。单次消耗的时间是非常少的,但是频繁大量的切换,就会引发性能问题。

重新渲染

渲染过程中最耗时的两个步骤为重排(Reflow)与重绘(Repaint)

渲染页面时会将HTML和CSS分别解析成DOM树和CSSOM树,然后合并进行排布,再绘制成我们可见的页面,如果在操作DOM时涉及到元素、样式的修改 就会引起渲染引擎重新计算样式生成CSSOM树,同时还有可能触发对元素的重新排布(简称“重排”)和重新绘制(简称“重绘”)

验证一下,通过Chrome的性能分析工具,来对渲染耗时进行分析

  • 通过修改DIV的边距触发重排,粗略的认为渲染耗时为Rendering、Painting之和:3030 + 15
  • 通过修改DIV的边距触发重绘,粗略的认为渲染耗时为Rendering、Painting之和:2344 + 15

image.png

从结论可以看出:重排耗时明显高于重绘,同时两者Painting耗时接近,也印证了重排会导致重绘

高效操作DOM节点

了解了哪些因素会导致性能问题之后,就有相应的解决方案

循环外操作节点

本质:减少操作节点

const times = 10000
// 循环内
console.time('switch')
for (let i=0;i<times;i++){
  document.body ===1 ? console.log(1):void();
}
console.timeEnd('switch') //1.873046875ms
// 循环外
var body = JSON.stringify(document.body)
console.time('batch')
for (let i=0;i<times;i++){
  body === 1 ? console.log(1):void 0;
}
console.timeEnd('batch') //0.846923828125m

批量操作元素

const times = 10000;
// 循环添加
console.time('createElement')
for (let i=0;i<times;i++){
  const div = document.createElement('div')
  document.body.appendChild(div)
}
console.timeEnd('createElement')  //54.964111328125ms// 批量添加
console.time('innerHTML'let html = ''
for (let i=0;i<times;i++){
  html += '<div></div>'
}
document.body.innerHTML += html
console.timeEnd('innerHTML')  //31.919921875ms

缓存元素集合

// 循环获取元素
for (let i=0;i document.querySelectorAll('div').length;i++){
  document.querySelectorAll(div)[i].innerText = i 
} // 21965ms// 缓存元素集合
const divs = document.querySelectorAll('div')
for (let i=0;i<divs.length;i++){
  divs[i].innerText = i
} // 838ms

除了以上方法,还有一些原则有可能帮助提升渲染性能

  • 尽量不要使用复杂的匹配规则和复杂的样式,从而减少渲染引擎计算样式规则生成CSSOM树的时间
  • 尽量减少重排和重绘影响的区域
  • 使用CSS3特性来实现动画效果

DOM事件

DOM事件数量非常多,即使分类也有十多种,比如键盘事件、鼠标事件、表单事件等,而且不同事件对象属性也有差异。

从防抖、节流、代理的角度出发,详细了解DOM事件

防抖

为函数的执行设置一个合理的时间间隔,避免事件在时间间隔内频繁触发。同时又保证函数的执行。

这个操作是完全可以抽取成公共函数的,在抽取成公共函数的同时,还需要考虑更复杂的情况

  • 参数和返回值如何传递?
  • 防料化之后的函数是否可以立即执行?
  • 防抖化的函数是否可以手动取消?

案例:用户输入后能即时看到搜索结果

const debounce (func,wait = 0)=>{
  let timeout = null , args
​
  function debounced(...arg){
    args = arg
    if(timeout){
      clearTimeout(timeout)
      timeout = null
    }
​
    // 以Promise的形式返回函数执行结果
    return new Promise((res,rej)=>{
      timeout = setTimeout(async ()=>{
        try{
          const result = await func.apply(this,args)
          res(result)
        }catch(e){
          rej(e)
        }
      },wait)
    })
  }
​
  //允许取消
  function cancel(){
    clearTimeout(timeout)
    timeout = null
  }
​
  //允许立即执行
  function flush(){
    cancel()
    return func.apply(this,args)
  }
​
  debounced.cancel cancel
  debounced.flush flush
  return debounced
}

节流

设置在指定一段时间内只调用一次函数,从而降低函数调用频率。

案例:当用户滚动阅读右侧文章内容时,左侧大纲相对应部分高亮显示,提示用户当前阅读位置

const throttle=(func,wait=0,execFirstCall)=>{
  let timeout = null , args , firstCallTimestamp
​
  function throttled(...arg){
​
    if (!firstCallTimestamp) firstCallTimestamp = new Date().getTime();
​
    if (!execFirstCall || !args){
      console.log('set args:',arg)
      args = arg
    }
​
    if (timeout){
      clearTimeout(timeout)
      timeout = null
    }
​
    //以Promise的形式返回函数执行结果
    return new Promise(async(res,rej)=>{
      if (new Date().getTime() - firstCallTimestamp >= wait){
        try{
​
          const result=await func.apply(this,args)
          res(result)
        }catch(e){
          rej(e)
        }finally{
          cancel()
        }
      }else{
        timeout setTimeout(async ()=>{
          try{
            const result = await func.apply(this,args)
            res(result)
          }catch(e){
            rej(e)
          }finally{
            cancel()
          }
        },firstCallTimestamp + wait - new Date().getTime())
      }
    })
  }
​
  //允许取消
  function cancel(){
    clearTimeout(timeout)
    args = null
    timeout = null
    firstCallTimestamp = null
  }
​
  //允许立即执行
  function flush(){
    cancel()
    return func.apply(this,args)
  }
​
  throttled.cancel = cancel
  throttled.flush = flush
  return throttled
}

代理

对每个元素都绑定事件,如果数据量一旦增大,事件绑定占用的内存以及执行时间将会成线性增加。 其实一些事件监听函数逻辑一致,只是参数不同而已。我们可以以事件代理或事件委托来进行优化。

下面的 HTML 代码是一个简单的无序列表,现在希望点击每个项目的时候调用 getInfo() 函数,当点击“编辑”时,调用一个 edit() 函数,当点击“删除”时,调用一个 del() 函数。

<ul class="list">
  <li class="item" id="item1">项目1<span class="edit">编辑</span><span class="delete">删除</span></li>
  <li class="item" id="item2">项目2<span class="edit">编辑</span><span class="delete" >删除</span></li>
  <li class="item" id="item3">项目3<span class="edit">编辑</span><span class="delete">删除</span></li>
  ...
</ul>

要实现这个功能并不难,只需要对列表中每一项,分别监听 3 个元素的 click 事件即可。 但如果数据量一旦增大,事件绑定占用的内存以及执行时间将会成线性增加,而其实这些事件监听函数逻辑一致,只是参数不同而已。此时我们可以以事件代理或事件委托来进行优化。不过在此之前,我们必须先复习一下DOM 事件的触发流程

回到事件代理,事件代理的实现原理DOM 事件的触发流程来对一类事件进行统一处理。比如对于上面的列表,我们在 ul 元素上绑定事件统一处理,通过得到的事件对象来获取参数,调用对应的函数。

知识支撑

重排、重绘

影响到其他元素排布的操作就会引起重排,继而引发重绘。比如:

  • 修改元素边距、大小
  • 添加、删除元素
  • 改变窗口大小

与之相反的操作(只要不影响元素排布)则只会引起重绘,比如:

  • 设置背景图片
  • 修改字体颜色
  • 改变visibility属性值

更多引起重排、重绘的CSS样式参考

DOM事件的触发的流程

  • 捕获阶段(Window => <td>)下图红色部分
  • 目标阶段(事件对象=> 事件对象目标)下图蓝色部分
  • 冒泡阶段(<td> => Window)下图绿色部分

image.png

3 种事件监听方式的区别

// 方式1
<input type="text" onclick="click()"/>
// 方式2
document.querySelector('input').onClick = function(e) {
  // ...
}
// 方式3
document.querySelector('input').addEventListener('click', function(e) {
  //...
})
  • 方式 1 和 方式 2 同属于 DOM0 标准,通过这种方式进行事件监会覆盖之前的事件监听函数。
  • 方式 3 属于 DOM2 标准,推荐使用这种方式。同一元素上的事件监听函数互不影响,而且可以独立取消,调用顺序和监听顺序一致。


最后一句:说一说你还知道哪些提升渲染速度的方法和原则?
有的前端工程师觉得认为熟悉框架就行,不需要详细了解DOM,其实会框架更要会DOM,高级资深前端工程师,不仅应该对DOM有深入的理解。还应该能够借此开发框架插件、修改框架甚至能写出自己的框架
学习心得!若有不正,还望斧正。