原文来自: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.js
和Calculator.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 | // Main.js |
下图是初始化之后的DOM节点:
下图是对应上图DOM节点,React构建的组件树:
输入input1和input2,点击add按钮
为了了解Virtual DOM到DOM过程中,diff工作和和解工作方式。在栗子中,我们分别给input输入100和50,点击add按钮。随后,预期中的output是1501
2
3
4Input 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>
标注脏组件

第一步:组件如何标记为脏组件。
- 所有真实DOM事件,都包裹在React自定义的监听事件之内。因此,点击add按钮之后,就会把这个事件发送给React事件监听,然后执行上面写的匿名函数。
- 在上面这个匿名函数中,我们调用
this.setState()
赋值一个新的值。 setState()
随后,反过来会将这个组件,标记为脏组件。如下代码:1
2//ReactUpdates.js - enqueueUpdate(component) function
dirtyComponents.push(component);
如果你奇怪,为什么React没有将button标记为脏组件,而是将这个
Calculator
标记成脏组件?这是因为,当你调用this.setState()
的时候,这时的this
指的是Calculator
这个组件。
- 这样一来,
Calculator
就被标记成脏组件。让我们看看接下来会发生什么?
遍历组件生命周期
现在Calculator
被标记成了脏组件!那接下来是要做什么呢?接下来就是更新Virtual-DOM,然后利用diff算法做和解,最后更新到真是的DOM上。
在我们进入接下来的步骤前,我们非常有必要熟悉组件的生命周期。
下图是Calculator
组件,在Ract中的结构:
接下来更新组件:
- 这个更新是由React的批量更新完成的。
在这个批量更新中,他会检查这个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);接下来,React会检查是否存在需要更新的
pending state
,或者存在forceUpdate
。如下:1
2if (this._pendingStateQueue !== null || this._pendingForceUpdate) {
this.updateComponent(transaction, this._currentElement, this._currentElement, this._context, this._context);
在我们的栗子中,我们可以看到,在calculator wrapper里,this._pendingStateQueue
保存了一个最新的状态。
- 首先,他会检查组件是否调用
componentWillReceiveProps()
,如果调用,我们就可以用新的props再次更新state。 接下来,React会检查组件是否调用
shouldComponentUpdate()
,如果调用,我们就可以主动判断这个组件是否需要重新render。在一些已知的场景中,你可以利用这个去阻止一些没有必要的render,从而提高app的性能。
接下来
componentWillUpdate()
,render()
,componentDidUpdate()
会按顺序调用。- 现在,我们看一下在
render()
中发生了什么。render是Virtual DOM重新构建,和diff算法发生的地方
render组件——更新Virtual-DOM,调用diff算法,最后更新到DOM中。
在我们的栗子中,Calculator
组件内的所有元素会再一次在Virtual DOM里构建:
React会检之前的render的React元素和render之后的React元素是不是一个type和一个key。然后对组件、type和key协调匹配。1
2var 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上。