咱们要关心的两大核心问题就是:“DOM 为何这么慢”以及“如何使 DOM 变快”。html
后者是一个比“生存仍是毁灭”更加经典的问题。不只咱们为它“肝肠寸断”,许多优秀前端框架的做者大大们也曾为其绞尽脑汁。这一点可喜可贺——研究的人越多,产出优秀实践的几率就越大。所以在本章的方法论环节,咱们不只会根据 DOM 特性及渲染原理为你们讲解基本的优化思路,还会涉及到一部分生产实践。前端
循着这个思路,咱们把 DOM 优化这块划分为三个小专题:“DOM 优化思路”、“异步更新策略”及“回流与重绘”。本节对应第一个小专题。三个小专题休戚与共、你侬我侬,在思路上相互依赖、一脉相承,所以此处严格禁止任何姿式的跳读行为。浏览器
考虑到本节内容与上一节有着密不可分的关系,所以强烈不建议没有读完上一节的同窗直接跳读本节。缓存
把 DOM 和 JavaScript 各自想象成一个岛屿,它们之间用收费桥梁链接。——《高性能 JavaScript》性能优化
JS 是很快的,在 JS 中修改 DOM 对象也是很快的。在JS的世界里,一切是简单的、迅速的。但 DOM 操做并不是 JS 一我的的独舞,而是两个模块之间的协做。bash
上一节咱们提到,JS 引擎和渲染引擎(浏览器内核)是独立实现的。当咱们用 JS 去操做 DOM 时,本质上是 JS 引擎和渲染引擎之间进行了“跨界交流”。这个“跨界交流”的实现并不简单,它依赖了桥接接口做为“桥梁”(以下图)。前端框架
过“桥”要收费——这个开销自己就是不可忽略的。咱们每操做一次 DOM(不论是为了修改仍是仅仅为了访问其值),都要过一次“桥”。过“桥”的次数一多,就会产生比较明显的性能问题。所以“减小 DOM 操做”的建议,并不是空穴来风。app
过桥很慢,到了桥对岸,咱们的更改操做带来的结果也很慢。框架
不少时候,咱们对 DOM 的操做都不会局限于访问,而是为了修改它。当咱们对 DOM 的修改会引起它外观(样式)上的改变时,就会触发回流或重绘。异步
这个过程本质上仍是由于咱们对 DOM 的修改触发了渲染树(Render Tree)的变化所致使的:
回流:当咱们对 DOM 的修改引起了 DOM 几何尺寸的变化(好比修改元素的宽、高或隐藏元素等)时,浏览器须要从新计算元素的几何属性(其余元素的几何属性和位置也会所以受到影响),而后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。
重绘:当咱们对 DOM 的修改致使了样式的变化、却并未影响其几何属性(好比修改了颜色或背景色)时,浏览器不需从新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫作重绘。
由此咱们能够看出,重绘不必定致使回流,回流必定会致使重绘。硬要比较的话,回流比重绘作的事情更多,带来的开销也更大。但这两个说到底都是吃性能的,因此都不是什么善茬。咱们在开发中,要从代码层面出发,尽量把回流和重绘的次数最小化。
知道了 DOM 慢的缘由,咱们就能够对症下药了。
咱们来看这样一个🌰,HTML 内容以下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>DOM操做测试</title>
</head>
<body>
<div id="container"></div>
</body>
</html>
复制代码
此时我有一个假需求——我想往 container 元素里写 10000 句同样的话。若是我这么作:
for(var count=0;count<10000;count++){
document.getElementById('container').innerHTML+='<span>我是一个小测试</span>'
}
复制代码
这段代码有两个明显的可优化点。
第一点,过路费交太多了。咱们每一次循环都调用 DOM 接口从新获取了一次 container 元素,至关于每次循环都交了一次过路费。先后交了 10000 次过路费,但其中 9999 次过路费均可以用缓存变量的方式节省下来:
// 只获取一次container
let container = document.getElementById('container')
for(let count=0;count<10000;count++){
container.innerHTML += '<span>我是一个小测试</span>'
}
复制代码
第二点,没必要要的 DOM 更改太多了。咱们的 10000 次循环里,修改了 10000 次 DOM 树。咱们前面说过,对 DOM 的修改会引起渲染树的改变、进而去走一个(可能的)回流或重绘的过程,而这个过程的开销是很“贵”的。这么贵的操做,咱们居然重复执行了 N 屡次!其实咱们能够经过就事论事的方式节省下来没必要要的渲染:
let container = document.getElementById('container')
let content = ''
for(let count=0;count<10000;count++){
// 先对内容进行操做
content += '<span>我是一个小测试</span>'
}
// 内容处理好了,最后再触发DOM的更改
container.innerHTML = content
复制代码
所谓“就事论事”,就像你们所看到的:JS 层面的事情,JS 本身去处理,处理好了,再来找 DOM 打报告。
事实上,考虑JS 的运行速度,比 DOM 快得多这个特性。咱们减小 DOM 操做的核心思路,就是让 JS 去给 DOM 分压。
这个思路,在 DOM Fragment 中体现得淋漓尽致。
DocumentFragment 接口表示一个没有父级文件的最小文档对象。它被当作一个轻量版的 Document 使用,用于存储已排好版的或还没有打理好格式的XML片断。由于 DocumentFragment 不是真实 DOM 树的一部分,它的变化不会引发 DOM 树的从新渲染的操做(reflow),且不会致使性能等问题。
在咱们上面的例子里,字符串变量 content 就扮演着一个 DOM Fragment 的角色。其实不管字符串变量也好,DOM Fragment 也罢,它们本质上都做为脱离了真实 DOM 树的容器出现,用于缓存批量化的 DOM 操做。
前面咱们直接用 innerHTML 去拼接目标内容,这样作当然有用,但却不够优雅。相比之下,DOM Fragment 能够帮助咱们用更加结构化的方式去达成一样的目的,从而在维持性能的同时,保住咱们代码的可拓展和可维护性。咱们如今用 DOM Fragment 来改写上面的例子:
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 Fragment 做为经典的性能优化手段大受欢迎,这一点在 jQuery、Vue 等优秀前端框架的源码中均有体现。
相比 DOM 命题的博大精深,一个简单的循环 Demo 显然不能说明全部问题。不过不用着急,在本节,我只但愿你们能牢记原理与宏观思路。“药到病除”到这里才刚刚开了个头,下个小节,咱们将深挖事件循环机制,从而深刻 JS 层面的生产实践。