DOM 优化原理与基本实践
JS
是很快的,在 JS
中修改DOM
对象也是很快的。在JS
的世界里,一切是简单的、迅速的。但 DOM
操作并非 JS
一个人的独舞,而是两个模块之间的协作。
JS
引擎和渲染引擎(浏览器内核)是独立实现的。当我们用JS
去操作DOM
时,本质上是JS
引擎和渲染引擎之间进行了跨界交流
。这个跨界交流
的实现并不简单,它依赖了桥接接口作为桥梁
。
对 DOM 的修改引发样式的更迭
我们对 DOM
的操作都不会局限于访问,而是为了修改它。当我们对DOM
的修改会引发它外观(样式)上的改变时,就会触发回流或重绘。
这个过程本质上还是因为我们对DOM
的修改触发了渲染树(Render Tree
)的变化所导致的,重绘不一定导致回流,回流一定会导致重绘。硬要比较的话,回流比重绘做的事情更多,带来的开销也更大。但这两个说到底都是吃性能的,所以都不是什么善茬。我们在开发中,要从代码层面出发,尽可能把回流和重绘的次数最小化。
DOM Fragment
DocumentFragment
接口表示一个没有父级文件的最小文档对象。它被当做一个轻量版的Document
使用,用于存储已排好版的或尚未打理好格式的XML
片段。因为DocumentFragment
不是真实DOM
树的一部分,它的变化不会引起DOM
树的重新渲染的操作(reflow
),且不会导致性能等问题。作为脱离了真实DOM
树的容器出现,用于缓存批量化的DOM
操作。
let container = document.getElementById('container')
// 创建一个DOM Fragment对象作为容器
let content = document.createDocumentFragment()
for(let count=0;count<10000;count++){
// span此时可以通过DOM API去创建
let oSpan = document.createElement("span")
oSpan.innerHTML = '我是一个小测试'
// 像操作真实DOM一样操作DOM Fragment对象
content.appendChild(oSpan)
}
// 内容处理好了,最后再触发真实DOM的更改
container.appendChild(content)
我们运行这段代码,可以得到与前面两种写法相同的运行结果。
可以看出,DOM Fragment
对象允许我们像操作真实 DOM
一样去调用各种各样的DOM API
,我们的代码质量因此得到了保证。并且它的身份也非常纯粹:当我们试图将其append
进真实DOM
时,它会在乖乖交出自身缓存的所有后代节点后全身而退,完美地完成一个容器的使命,而不会出现在真实的 DOM
结构中。这种结构化、干净利落的特性,使得DOM FragmentDOM Fragment
作为经典的性能优化手段大受欢迎。
Event Loop 与异步更新策略
Micro-Task 与 Macro-Task
事件循环中的异步队列有两种:macro
(宏任务)队列和 micro
(微任务)队列。
- 常见的
macro-task
比如:setTimeout
、setInterval
、setImmediate
、script
(整体代码)、I/O
操作、UI
渲染等。 - 常见的
micro-task
比如:process.nextTick
、Promise
、MutationObserver
等。
Event Loop 过程解析
一个完整的 Event Loop
过程,可以概括为以下阶段:
初始状态:调用栈空。
micro
队列空,macro
队列里有且只有一个script
脚本(整体代码)。全局上下文(
script
标签)被推入调用栈,同步代码执行。在执行的过程中,通过对一些接口的调用,可以产生新的macro-task
与micro-task
,它们会分别被推入各自的任务队列里。同步代码执行完了,script
脚本会被移出macro
队列,这个过程本质上是队列的macro-task
的执行和出队的过程。
- 上一步我们出队的是一个
macro-task
,这一步我们处理的是micro-task
。但需要注意的是:当macro-task
出队时,任务是一个一个执行的;而micro-task
出队时,任务是一队一队执行的(如下图所示)。因此,我们处理micro
队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。
执行渲染操作,更新界面(敲黑板划重点)。
检查是否存在
Web worker
任务,如果有,则对其进行处理 。(上述过程循环往复,直到两个队列都清空)
异步更新策略
当我们使用Vue
或React
提供的接口去更新数据时,这个更新并不会立即生效,而是会被推入到一个队列里。待到适当的时机,队列中的更新任务会被批量触发。这就是异步更新。
最典型的例子,比如有时我们会遇到这样的情况:
// 任务一
this.content = '第一次测试'
// 任务二
this.content = '第二次测试'
// 任务三
this.content = '第三次测试'
我们在三个更新任务中对同一个状态修改了三次,如果我们采取传统的同步更新策略,那么就要操作三次DOM
。但本质上需要呈现给用户的目标内容其实只是第三次的结果,也就是说只有第三次的操作是有意义的——我们白白浪费了两次计算。
但如果我们把这三个任务塞进异步更新队列里,它们会先在
JS
的层面上被批量执行完毕。当流程走到渲染这一步时,它仅仅需要针对有意义的计算结果操作一次DOM
——这就是异步更新的妙处。