前端性能优化原理与实践(三)

摘自前端性能优化原理与实践

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比如: setTimeoutsetIntervalsetImmediatescript(整体代码)、I/O操作、UI渲染等。
  • 常见的micro-task比如:process.nextTickPromiseMutationObserver等。

Event Loop 过程解析

一个完整的 Event Loop过程,可以概括为以下阶段:

  • 初始状态:调用栈空。micro 队列空,macro队列里有且只有一个 script脚本(整体代码)。

  • 全局上下文(script 标签)被推入调用栈,同步代码执行。在执行的过程中,通过对一些接口的调用,可以产生新的 macro-taskmicro-task,它们会分别被推入各自的任务队列里。同步代码执行完了,script 脚本会被移出macro队列,这个过程本质上是队列的 macro-task的执行和出队的过程。

  • 上一步我们出队的是一个macro-task,这一步我们处理的是 micro-task。但需要注意的是:当 macro-task出队时,任务是一个一个执行的;而 micro-task出队时,任务是一队一队执行的(如下图所示)。因此,我们处理micro队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。
  • 执行渲染操作,更新界面(敲黑板划重点)。

  • 检查是否存在Web worker任务,如果有,则对其进行处理 。(上述过程循环往复,直到两个队列都清空)

异步更新策略

当我们使用VueReact提供的接口去更新数据时,这个更新并不会立即生效,而是会被推入到一个队列里。待到适当的时机,队列中的更新任务会被批量触发。这就是异步更新。

最典型的例子,比如有时我们会遇到这样的情况:

// 任务一
this.content = '第一次测试'
// 任务二
this.content = '第二次测试'
// 任务三
this.content = '第三次测试'

我们在三个更新任务中对同一个状态修改了三次,如果我们采取传统的同步更新策略,那么就要操作三次DOM。但本质上需要呈现给用户的目标内容其实只是第三次的结果,也就是说只有第三次的操作是有意义的——我们白白浪费了两次计算。

但如果我们把这三个任务塞进异步更新队列里,它们会先在 JS的层面上被批量执行完毕。当流程走到渲染这一步时,它仅仅需要针对有意义的计算结果操作一次DOM——这就是异步更新的妙处。

你可能感兴趣的