什么是虚拟 DOM
虚拟 DOM 是一个 JS 对象
,它是对真实 DOM 的描述。它的形式可以是下面这样:
{
type: 'div',
key: null,
ref: null,
props: {
className: "app"
},
children: [
{
type: "p",
key: null,
ref: null,
props: {},
children: []
}
]
}
上面这段虚拟 DOM 对应描述的是如下格式的 JSX:
<div className="app">
<p></p>
</div>
在 React 里,虚拟 DOM 的工作流程分为两个阶段:挂载阶段
和 更新阶段
。
- 挂载阶段:React 会读取 render 里的 JSX,然后构建虚拟 DOM 树,最后通过 ReactDOM.render 方法渲染出真实 DOM
- 更新阶段:如果此时 JSX 内容更改了,React 不会直接操作 DOM,而是构建一棵新的虚拟 DOM 树,然后借助
diff
算法来对比更新前后两棵树的差异,然后再把差异部分更新到真实 DOM。
理解虚拟 DOM,首先要知道它是跟操作 DOM 相关的,那我们就会想,DOM 操作有很多种解决方案,比如 jQuery
,模板引擎
等,为什么最后是虚拟 DOM 胜出呢?任何技术方案都是 trade-off
权衡的艺术,肯定是虚拟 DOM 在综合对比下更胜一筹,所以我们首先要了解,在虚拟 DOM 出现之前,都有哪些处理 DOM 的方式。
操作 DOM 的演化过程
1. 原生 JS 直接操作 DOM
早期 JS 刚出现的时候,它就是作为浏览器的一个脚本语言,主要就是在页面加载完成后,添加一些特效,交互啥的。这时候对 DOM 操作的需求比较简单,因此直接使用原生 DOM API 就行。
2. jQuery 时期
但是由于原生 DOM API 太繁琐太难记了,使用起来不趁手,而且还要处理跨浏览器兼容的问题。当你要完成一个 JS 特效或滑动交互的时候,需要写一大堆 JS 代码实现。有没办法可以简化这些 DOM API 提高编码效率呢?于是 jQuery 横空出世,它提供了一大堆封装好的 API,让我们轻松操作 DOM,也不用管浏览器兼容问题,jQuery 通通帮我们处理好了。当你同时用原生 DOM API 和 jQuery 实现一个需求时,就会发现 jQuery 是多么好用!当年 jQuery 也确实流行,极大地提高我们的开发效率,虽然说现在用得比较少了。
3. 模板引擎 + innerHTML
jQuery 虽然好,但它是 “命令式” 的,命令式比较关注过程,按照 1, 2, 3, 4
一步步实现需求。这种方式跟另一种 声明式
形成对比。声明式比较关注结果,至于过程怎么做它并不关心,这其中比较典型的就是函数式编程:一个 sayHello(person)
函数,它负责根据传入的 person 进行 sayHello,至于它怎么做的我们不关心,我们只管调用这个函数就行。从这两种编程范式看,声明式的编程模式更胜一筹。
于是前端的先驱者就尝试了下模板引擎。模板引擎更关心数据,而不关注 DOM 操作的细节,通过解析模板数据来实现 DOM 渲染。它的工作流程就是:
- 定义一个 HTML 模板,里面包含一些 JS 变量信息,如
<{% product.name %}>
- 通过 JS 或 jQuery 提取模板里的变量信息,替换数据
- 动态拼接 HTML 字符串
- 将 HTML 字符串赋值给 innerHTML ,触发 DOM 渲染
这种模式在性能方面有很大问题,因为每次执行 innerHTML 都是全量销毁旧的 DOM,再新建 DOM,性能损耗太大。
4. 虚拟 DOM
模板引擎看起来已经很接近 数据驱动视图
的**了,在 HTML 模板里夹杂数据,然后通过 JS 解析模板,再挂载 DOM。只是模版引擎在挂载 DOM 这一步性能消耗大,那既然直接操作 DOM 消耗大,能不能操作个假的 DOM,因为相比修改 DOM,操作一个 JS 对象就快得多。于是就出现了虚拟 DOM:通过一个 JS 对象来描述 DOM,每次有更新变化时,借助 diff 算法来快速找出两个 JS 对象的差异,然后差量更新真实 DOM,这样解决了模板渲染的性能问题。
而虚拟 DOM 除了性能不错外,它还有心智负担小,可维护性强,跨平台的优势。所谓心智负担小,是指我们通过 JS 对象来表示 DOM,不用直接命令式地去维护 DOM 操作的过程;可维护性这个是肯定的,而跨平台是指,通过使用虚拟 DOM 来抽象真实 DOM,我们可以实现一次编码,多端复用。比如同一套虚拟 DOM,在 web 端可以描述 DOM 对象;在 native 端可以表示原生 APP 组件等,这也是 React Native
的应用技术。
虚拟 DOM 的调和过程
我们在 React 写完 JSX 后,会被编译成虚拟 DOM 对象,最后由 ReactDOM.render
渲染成真实 DOM,这个过程有个专门的术语叫 Reconciler (调和)
。React 15 采用的是 栈调和
,而 React16 之后采用的是 Fiber 调和
。
React15 栈调和
所谓栈调和,就是借助算法使虚拟 DOM 与真实 DOM 保持一致的过程,这是一个同步递归的过程,其中就用到了 diff
算法。
diff 策略
我们常说的虚拟 DOM,它是一个 JS 对象,从结构上看,它是一棵树。而通常我们对比两棵树的差异,需要 O(n³)
时间复杂度,这个复杂度显然是不可接受的。于是 React 团队想办法降低这个复杂度,其基本思路是:
- 若组件属于同一类型,通常拥有相同的 DOM 树形结构,因此只在两个节点类型一致,才继续 diff 下去
- 日常开发中跨层级节点操作很少,因此采用分层对比,对同层级节点进行两两比较
- 对于同一层级的一组节点,通过设置
key
属性,保持节点在渲染过程中的稳定性,尽可能重用节点
通过以上三个思路,把时间复杂度从 O(n³)
硬是降到了 O(n)
,所以 diff 算法非常高效。这里再额外说说 key 属性,它的作用到底是什么呢?React 官方是这么定义的:
key 是用来帮助 React 识别哪些内容被更改、添加或者删除。key 需要写在用数组渲染出来的元素内部,并且需要赋予其一个稳定的值。稳定在这里很重要,因为如果 key 值发生了变更,React 则会触发 UI 的重渲染。这是一个非常有用的特性。
如果没有 key 属性,同一层级的节点,如果位置发生了变化,React diff 算法是感知不到的,会把它当做节点新建或删除,然后频繁的销毁重建;而有了 key 属性,相当于给同一层里的每个节点增加了唯一标识,借助这个标识,如果只是交换了两个节点的位置,diff 算法就能识别出来,直接互换节点,而不会去销毁重建节点。
React16 Fiber 调和
既然出现了 Fiber 调和,那就说明栈调和有它的缺陷,它的缺陷就是同步递归过程所带来的不可中断特性。JS 是单线程的,而浏览器是多线程的,浏览器除了 JS 线程外,还有渲染线程,网络线程等等。其中渲染线程与 JS 线程是互斥的,这是因为 JS 可以修改 DOM,所以 JS 主线程在执行的时候,渲染线程是暂停的。这时麻烦就来了,由于栈调和是同步递归,也就是对两颗树进行深度优先遍历,这个过程一旦开始,就停不下来,递归的调用栈会越来越深,那么当面对数据量比较大的虚拟 DOM 时,就会导致主线程长期被霸占着,此时页面 UI 是无法响应的,进而造成卡死状态。
针对栈调和同步递归所带来的问题,React16 引入了新的 Fiber 架构。Fiber 可以理解为「纤维」的意思,它是比进程和线程更小的执行单位。在 Fiber 架构下,一个渲染任务不再是不可中断的一直执行下去,而是分解成多个任务,为此有三个重要概念:可中断
, 可恢复
与 优先级
。
所谓可中断,就是拆分后的任务是一个个的,每个任务有个 优先级
的概念,React16 加入了一个调度器,这个调度器会根据接收到的任务优先级,决定是否推给调和器执行。比如 A 任务来到了调度器,此时 B 任务新加入了,调度器发现 B 任务比 A 任务优先级还高,它就会暂停掉 A 任务,这就是 可中断
,然后转而将 B 任务推入执行,当 B 任务执行完后,就会重新讲 A 任务推入执行,这就叫 可恢复
。
通过这个过程就可以了解到,Fiber 调和
是异步可中断的
,而 栈调和
是 同步递归的
,而不管是 Fiber 调和还是栈调和都是在 React 内部的阶段执行,这个阶段对用户来说是无感知的,因此 Fiber 任务才可以中断再恢复执行,这也就是说,某些生命周期可能会被重复执行,比如 componentWillMount
,componentWillUpdate
,componentWillReceiveProps
,shouldComponentUpdate
,所以 Will 开头的生命周期,在 React16 被干掉了,因为这些生命周期可能有 副作用
发生,试想你在里面写了一段付款逻辑,然后由于 Fiber 可中断可恢复的特性,同一段付款逻辑被执行了两次,这肯定是不行的。
总结
- 虚拟 DOM 是在权衡了多种操作 DOM 方案后,留下的较好方案
- 虚拟 DOM 到真实 DOM 的过程,叫
调和
。调和分为旧的栈调和和 Fiber 架构下的 Fiber
调和。栈调和是同步递归的过程,而 Fiber 调和是异步可中断的最小化渲染。
- 调和过程有个关键的
diff
算法,这个算法高效在于 分层对比
,只比较同一类型节点
以及 key 属性
- 虚拟 DOM 中 key 属性的作用是让 React 识别出节点被修改,添加或删除,目的是为了尽可能地重用节点,避免频繁的销毁重建
- React 的生命周期分为三个阶段:
render
,pre-commit
和 commit
,而调和是发生在 render 阶段,这个阶段对用户来说是无感知的,所以才可以实现中断又恢复执行,这也导致某些生命周期会被重复执行,因此 React16 废弃了一些生命周期,新增了几个更安全的生命周期替代。