React中的Virtual DOM和diff工作原理

原文来自:https://medium.com/@gethylgeorge/how-virtual-dom-and-diffing-works-in-react-6fc805f9f84e

尽管,我们大概知道react中的virtual DOM是如何工作的,但是我一直视图去理解它的工作u案例。并且尝试解释的更清楚。
刚开始,我做了很多调研,但是都没法等到我想要的答案。最后,我决定阅读react和react-dom源码,这样,我就可以对工作原理有更好的了解。

在我们阅读之前,你有没有想过:为什么我们不直接将改变直接渲染到DOM上呢?

下一节将会概括DOM是如何被创建。并且会解释为什么React会在一开始创建Virtual DOM。

DOM的构建

这里,我不会想详细去说DOM是如果创建的,并且如何绘制到屏幕上。但是请你阅读下这篇文章这篇文章了解下浏览器是如何将HTML转化成DOM节点绘制到屏幕上的全部过程。

对于每一次DOM的改变,由于DOM是代表一个树的结构。对DOM的改变会非常快,但是对改变作用在元素上和元素的子节点时,会经历Reflow/Layout阶段,然后这些改变会被重新Re-painted到屏幕上,这一个过程是非常慢的。因此,Reflow/Layout的节点越多,你的app就会变得越慢。

Virtual-DOM真正要做的,就是尽力去轻量化这两个阶段(Reflow/Layout和Re-painted),这样对于复杂的app,也会有较好的性能。

下一节将会详细解释Virtual DOM是如何工作的。

理解Virtual DOM

现在,了解了DOM的构建之后,让我们开始看看Virtual DOM。现在,我将会利用一个app的栗子来解释Virtual DOM的工作方式。这样方便你就会有一个直观的了解。

在这里,我不会去解释第一次渲染时发生了什么事,而是着重说下重复渲染会发生什么。这样会帮助你了解Virtual DOM和diff工作机制。一旦你清楚了这个,初始化过程将会变得很清晰。

实例代码可以从这里获取:UnderstandingReactVirtualDOM,下图是demo。
。git库中的代码,除了Main.jsCalculator.js,其它都是不重要的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Calculator.js
import React from "react"
import ReactDOM from "react-dom"

export default class Calculator extends React.Component{
constructor(props) {
super(props);
this.state = {output: ""};
}

render(){
let IntegerA,IntegerB,IntegerC;


return(
<div className="container">
<h2>using React</h2>
<div>Input 1:
<input type="text" placeholder="Input 1" ref="input1"></input>
</div>
<div>Input 2 :
<input type="text" placeholder="Input 2" ref="input2"></input>
</div>
<div>
<button id="add" onClick={ () => {
IntegerA = parseInt(ReactDOM.findDOMNode(this.refs.input1).value)
IntegerB = parseInt(ReactDOM.findDOMNode(this.refs.input2).value)
IntegerC = IntegerA+IntegerB
this.setState({output:IntegerC})
}
}>Add</button>

<button id="subtract" onClick={ () => {
IntegerA = parseInt(ReactDOM.findDOMNode(this.refs.input1).value)
IntegerB = parseInt(ReactDOM.findDOMNode(this.refs.input2).value)
IntegerC = IntegerA-IntegerB
this.setState({output:IntegerC})

}
}>Subtract</button>
</div>
<div>
<hr/>
<h2>Output: {this.state.output}</h2>
</div>

</div>
);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Main.js 
import React from "react";
import Calculator from "./Calculator"

export default class Layout extends React.Component{
render(){
return(
<div>
<h1>Basic Calculator</h1>
<Calculator/>
</div>
);
}
}

下图是初始化之后的DOM节点:

下图是对应上图DOM节点,React构建的组件树:

输入input1和input2,点击add按钮

为了了解Virtual DOM到DOM过程中,diff工作和和解工作方式。在栗子中,我们分别给input输入100和50,点击add按钮。随后,预期中的output是150

1
2
3
4
Input 1: 100
Input 2: 50

Output : 150

点击add按钮之后发生了什么?

在我们案例中,点击add按钮式,我们会给state设置一个新的output值:150。

1
2
3
4
5
6
7
8
9
//Calculator.js
<button id="add" onClick={ () => {
IntegerA = parseInt(ReactDOM.findDOMNode(this.refs.input1).value)
IntegerB = parseInt(ReactDOM.findDOMNode(this.refs.input2).value)
IntegerC = IntegerA+IntegerB
this.setState({output:IntegerC})
}
}
>Add</button>

标注脏组件

第一步:组件如何标记为脏组件。

  1. 所有真实DOM事件,都包裹在React自定义的监听事件之内。因此,点击add按钮之后,就会把这个事件发送给React事件监听,然后执行上面写的匿名函数。
  2. 在上面这个匿名函数中,我们调用this.setState()赋值一个新的值。
  3. setState()随后,反过来会将这个组件,标记为脏组件。如下代码:
    1
    2
    //ReactUpdates.js  - enqueueUpdate(component) function
    dirtyComponents.push(component);

如果你奇怪,为什么React没有将button标记为脏组件,而是将这个Calculator标记成脏组件?这是因为,当你调用this.setState()的时候,这时的this指的是Calculator这个组件。

  1. 这样一来,Calculator就被标记成脏组件。让我们看看接下来会发生什么?

遍历组件生命周期

现在Calculator被标记成了脏组件!那接下来是要做什么呢?接下来就是更新Virtual-DOM,然后利用diff算法做和解,最后更新到真是的DOM上。

在我们进入接下来的步骤前,我们非常有必要熟悉组件的生命周期

下图是Calculator组件,在Ract中的结构:

接下来更新组件:

  1. 这个更新是由React的批量更新完成的。
  2. 在这个批量更新中,他会检查这个dirtyComponents队列是否存在脏组件,然后开始更新。

    1
    2
    3
    4
    5
    6
    //ReactUpdates.js
    var flushBatchedUpdates = function () {
    while (dirtyComponents.length || asapEnqueued) {
    if (dirtyComponents.length) {
    var transaction = ReactUpdatesFlushTransaction.getPooled();
    transaction.perform(runBatchedUpdates, null, transaction);
  3. 接下来,React会检查是否存在需要更新的pending state,或者存在forceUpdate。如下:

    1
    2
    if (this._pendingStateQueue !== null || this._pendingForceUpdate) {
    this.updateComponent(transaction, this._currentElement, this._currentElement, this._context, this._context);

在我们的栗子中,我们可以看到,在calculator wrapper里,this._pendingStateQueue保存了一个最新的状态。

  1. 首先,他会检查组件是否调用componentWillReceiveProps(),如果调用,我们就可以用新的props再次更新state。
  2. 接下来,React会检查组件是否调用shouldComponentUpdate(),如果调用,我们就可以主动判断这个组件是否需要重新render。

    在一些已知的场景中,你可以利用这个去阻止一些没有必要的render,从而提高app的性能。

  3. 接下来componentWillUpdate()render()componentDidUpdate()会按顺序调用。

  4. 现在,我们看一下在render()中发生了什么。

    render是Virtual DOM重新构建,和diff算法发生的地方

render组件——更新Virtual-DOM,调用diff算法,最后更新到DOM中。

在我们的栗子中,Calculator组件内的所有元素会再一次在Virtual DOM里构建:

React会检之前的render的React元素和render之后的React元素是不是一个type和一个key。然后对组件、type和key协调匹配。

1
2
var prevRenderedElement = this._renderedComponent._currentElement;
var nextRenderedElement = this._instance.render(); //Calculator.render() method is called and the element is build.

注意:这个地方是调用组件render的地方。在这个栗子中就是:Calculator.render()

整个和解阶段可以通过如下步骤:

图中,红色的点线表示所有的这些步骤会在下一个子节点,子节点中的子节点递归调用。

上图概括了,Virtual DOM是如何将更新应用到DOM节点上的。(图中,我忽略了一些已知的或不知的步骤,但图表概括了大多数的核心步骤)

因此,你可以知道,在我们的栗子中,和解阶段是如何进行的。我会引领你,将DOM更新为Output:150。并跳过一些div的和解阶段。

  • 和解阶段从入口组件的div开始,也就是class=”container的那个组件(A)。
  • A中有一个拥有Output的div(B),因此react会和解这个组件的子节点。
  • 在B中,他有两个孩子:<hr><h2>
  • React会从<hr>开始和解。
  • 在对<h2>开始和解,由于<h2>有两个孩子:一个是文本『Output』,另一个是来自state的output值。因此,React会分别对这两个孩子进行和解。
  • 对于第一个文本Output,文本经过调解,发现并没有变化。因此不对DOM做任何操作。
  • 对于来自state的ouput值,经过调解后,发现output有一个新的值(150),React将会更新真实DOM。

渲染真实DOM

栗子中,在调解过程,只有Output重新绘制:

组件树更新如下:

总结

尽管这个栗子非常不重要,但会给你一个关于React中发生了什么的底层认知。

我并不会用一个非常复杂的应用解释,因为这样画组件树会变得非常困难且没有意义

React的和解阶段如下:

  • 对比渲染之前的实例和渲染之后的实例。
  • 更新在组件树中的实例(Virtual DOM)。
  • 仅在存在实际变化的节点及其子节点上,更新真实DOM。

牛牛理解

本文发表于2017年的1月份,针对的是当时的React,应该是React15,因此相关的链接可能有误,且说到的生命周期属于原先的生命周期。

但是,文章整个diff流程,用了一个很简洁的栗子。描述清楚,且对未来阅读React16也有很大帮助。

阅读之前,始终不理解React中的reconciliation为什么会翻译成:和解?从文中来看,出现reconciliation这个单词的时候,都会伴随着previous和next。因此和解应该叫做:渲染之前的组件和渲染之后的组件做调解。他们如果属性一致,表示和解成功了,即不会做改变。如果不一致,表示和解不成功,最终会将改变作用在真实DOM上。