前端为什么操作DOM最耗性能


前端为什么操作DOM最耗性能


正文

前端操作DOM并不一定最耗性能,应该是DOM操作引起的页面重绘最耗性能。

下面配合代码进行说明。

DOM操作的性能损耗至少有两个地方:

  1. 进行DOM操作的时候上下文切换
  2. DOM操作引起的页面重绘

下面是测试代码:

// 测试次数:一百万次
const times = 1000000
  1. 创建一个复杂度和 DOM 元素相当的 JSON 对象,频繁修改对象属性,消耗时间:2ms
    let body = JSON.parse(JSON.stringify(document.body))
    console.time('json')
    for (let i = 0; i < times; i++) {
     body.a = i
    }
    console.timeEnd('json') // json: 2.66015625ms
    
  2. 频繁修改 DOM 元素,引起上下文切换,消耗时间:18ms
    console.time('dom')
    for (let i = 0; i < times; i++) {
     document.body.a = i
    }
    console.timeEnd('dom') // dom: 18.89990234375ms
    
  3. 频繁修改 DOM 元素,引起重绘,消耗时间:4622ms
    console.time('redraw')
    for (let i = 0; i < times; i++) {
     document.body.innerHTML = i
    }
    console.timeEnd('redraw') // redraw: 4622.656005859375ms
    

浏览器与JS引擎的关系

我们看一下浏览器和 JS 引擎的关系(以 Chrome 和 V8 举例):

其他浏览器和 JS 引擎也大同小异。

在 ECMAScript 规范中,Web IDL 定义了 DOM 和 JS Object 的对应关系:

通常而言,DOM 对象是使用 C++ 开发的,而通过 V8 Binding,在 V8 引擎内会有一个和 DOM 对象对应的 JS 对象,我们称之为Wrapper objects(包装对象)。

包装对象和原生对象的关系可能是 1 对 1,也可能是 n 对 1,但是不会是 1 对 n。

分析

为什么有时候 DOM 不慢

从上面的介绍可以看出,DOM 是 C++ 写的,性能肯定不慢。而且 V8 Binding 会把原生 DOM 对象映射为包装的 JS 对象。因此,我们操作 DOM 和操作 JS 对象是一样的。

我们以 document 为例。

js 普通对象:

let o = {a: 2};

console.time('js');
for (let i = 0; i <= 1e4; i++)
    o.a = i;
console.timeEnd('js');
// 耗时: 0.31396484375ms

document 对象:

console.time('dom');
for (let i = 0; i <= 1e4; i++)
    document.a = i;
console.timeEnd('dom');
// 耗时: 0.302978515625ms

DOM 为什么慢

现在我们把上面的测试代码改一下,把 document.a 改成 document.title:

console.time('dom');
for (let i = 0; i <= 1e4; i++)
    document.title = i;
console.timeEnd('dom');
// 耗时: 128.337646484375ms

耗时突然变成了原来的 400 多倍。

这是因为 JS 对象的属性(properties)映射到了 DOM 对象的特性(attributes)上。当我们修改 document.a 时,只是修改了普通的 JS 对象, 但是在我们修改 document.title 的时候,同时也修改了 DOM 对象的 attributes。 如果我们在 Chrome Devtool 运行这段代码,可以看到页面的标题在不停的改变,代码运行结束后,页面标题变成了 10000。

性能消耗在 JS 对象和 DOM 对象的转换和同步。也就是 V8XXX::toNative()::toV8() 的调用。

CSS

浏览器除了具有 JS 引擎外,还有排版引擎。而如果 JS 在操作 DOM 时修改了 CSS,那么性能就会再一次降低。

即使我们不修改 DOM 对象的 CSS,仅仅是读取样式值,也有可能会引起 relayout。

GC

DOM 对象还会导致 GC 变复杂:

虽然 DOM 的性能有可能比普通对象还快,但是在 99% 的场景下 DOM 对象是慢的。

理解

其一,为了让 Web 编程模型保持简单,浏览器的 JavaScript 引擎与 DOM 引擎共享一个主线程。 任何 DOM API 调用都要先将 JS 数据结构转为 DOM 数据结构,再挂起 JS 引擎并启动 DOM 引擎,执行过后再把可能的返回值反转数据结构, 重启 JS 引擎继续执行。这种上下文切换很耗性能,类似的还有单机进程间调用、远程过程调用等。

其二,很多 DOM API 的读写都涉及页面布局的“重新计算”,以确保返回值的准确,涉及样式、结构的还会触发页面“重新绘制”,更耗性能。

综上,单次 DOM API 调用性能就不够好,频繁调用就会迅速积累上述损耗,导致 DOM 引擎占用主线程过久, 用户操作不能及时触发 JS 事件回调,让用户感觉卡顿。

所以,解决此问题的方案本质不在于用不用 jQuery、用不用虚拟 DOM,而是 —— 减少不必要的 DOM API 调用。 虚拟 DOM 只是一种可选的方案,不是每种 Web 前端框架都用它。

而减少不必要调用的各种方案,都遵循“在 JS 中缓存必要数据,计算界面更新时的阶段数据差异,只提交最终差集”的基本思路。 虚拟 DOM 计算的是最终 DOM 结构的差异,还有的引擎计算的是 DOM 所绑定数据的差异,各有千秋。

虚拟 DOM 的设计也不是性能首位的,React 意在保持较好性能的同时,拿它作为一个抽象层,方便向没有 DOM 接口的原生应用平台移植。

使用

在前端开发的过程中,javascript极为重要的一个功能就是对DOM对象的操作,无论增删改查在前端页面操作这一范围内都是比较消耗性能的, 如何高效率的,便捷的操作DOM,这就是本文要讲述的。希望看完全文,你能知道如何更高效的通过原生js以及jQuery操作DOM元素。

操作DOM

最基本最常用的DOM操作:

Javascript:

jQuery:

上面的思维导图分别是javascript和jQuery下操作DOM的一些常用Function,并不完全我仅仅列出相对常用的。 这里我比较推荐的是jQuery的操作方式,更加便捷的同时在性能上也相对有所保障。

性能影响

DOM操作会导致最重要的,也是我们最需要的问题就是导致用户阻塞的重构(reflow)和重绘(repaint)。 比较通俗的一句话就是你在页面上的任何操作都是有代价的,有些大有些小,如果我们的操作比较频繁或者波及范围较大, 那么就要讲究方式和技巧。reflow和repaint就是我们在改变页面或者说操作DOM时,会带来的两种后果。

reflow意味着结构的改变,比如一堆元素堆叠,改变其中一个的宽高,那么相应的所有元素的位置都要改变。 repaint意味着样式的改变比如div调整了背景色等,但是位置不变,只改变我们操作的元素。 所以通常来看repaint的代价要远小于reflow,速度也更快。

影响性能的因素我们已经知道了,那么下面看一下怎么避免。

更有效的操作

最重要的观点:既然任何DOM操作都有代价,那么最好就是不操作或者最少的操作DOM。所以首先记住一个原则,将DOM操作尽量少! 这里有我认为主要的4个原则,记下来足以应付大多数情况。

  • 能放到DOM操作之外的操作就放到外面,DOM操作要尽量少。

DOM操作优化中这一观点在网上已经很普及了,很多例子都有比如遍历一个数组然后逐渐把内容添加到DOM上, 这里就推荐先遍历完数组,然后一次性在DOM上操作。大家可以看代码:

// 不好的做法
for (var i=0; i < items.length; i++){
    var item = document.createElement("li");
    item.appendChild(document.createTextNode("Option " + i);
    list.appendChild(item);
}   


// 更好的做法
// 使用容器存放临时变更, 最后再一次性更新DOM
var fragment = document.createDocumentFragment();
for (var i=0; i < items.length; i++){
    var item = document.createElement("li");
    item.appendChild(document.createTextNode("Option " + i);
    fragment.appendChild(item);
}
list.appendChild(fragment);
  • 大范围操作先把容器隐藏,在其中操作完成后,再显示。

这是一个我刚接触前端时遇到的一个优化办法,当时很不理解为什么display=none之后操作就算是性能优化了。 但是数据证明如此渲染确实快了很多,这个的原理要涉及到浏览器加载和渲染的原理, 简单说就是隐藏的元素其中不会产生reflow。这个例子我就不写了,很简单。

  • 样式操作不要注意修改属性,直接替换class

这个还是比较容易理解的,你逐一修改要访问很多次,而替换class就相当于批量操作了,访问一次DOM就可以了,当然性能提高了。

  • 用变量保存DOM对象而不是多次获取,同时减少操作DOM属性的次数。
//不好
function addAnchor(parentElement, anchorText, anchorClass) {
  var element = document.createElement('a');
  parentElement.appendChild(element);
  element.innerHTML = anchorText;
  element.className = anchorClass;
}


//更好
function addAnchor(parentElement, anchorText, anchorClass) {
  var element = document.createElement('a');
  element.innerHTML = anchorText;
  element.className = anchorClass;
  parentElement.appendChild(element);
}

总结

说到这里DOM的操作就差不多了,其实没有什么太新鲜的内容只是做了一个系统点总结。 对于性能这部分要平时积累这个意识,因为大多数时候它在开发过程中体现的并不明显。本文还有很多不足,希望大家留言沟通吧。






参考资料

https://www.zhihu.com/question/324992717/answer/1105978329

https://www.zhihu.com/question/324992717/answer/707044362

https://www.zhihu.com/question/324992717/answer/714013341

Javascript的DOM操作 - 你真的了解吗? https://my.oschina.net/blogshi/blog/198910?p=1


返回