[译] Virtual Dom 和 Diff 算法在 React 中是如何工作的?

5,593

译者:Kite
作者:Gethyl George Kurian
原文链接:medium.com/@gethylgeor…

文章已经过时,基于 react v16 的将会近期发出,此文仅供参考

我曾经尝试去深层而清晰地去理解 Virtual-DOM 的工作原理,也一直在寻找可以更详细地解释其工作细节的资料。

由于在我大量搜索的资料中没有获取到一点有用的资料,我最终决定探究 reactreact-dom 的源码来更好地理解它们的工作原理。

但是在我们开始之前,你有思考过为什么我们不直接渲染DOM的更新吗?

接下来的一节中,我将介绍 DOM 是如何创建的,以及让你了解为什么 React 一开始就创建了 Virtual-DOM

DOM 是如何创建的

(图片来自 Mozilla - https://developer.mozilla.org/en-US/docs/Introduction_to_Layout_in_Mozilla)

我不会说太多关于 DOM 是如何创建且是如何绘制到屏幕上的,但可以查阅这里这里去理解将整个 HTML 转换成 DOM 以及绘制到屏幕的步骤。

因为 DOM 是一个树形结构,每次DOM 中的某些部分发生变化时,虽然这些变化 已经相当地快了,但它改变的元素不得不经过回流的步骤,且它的子节点不得不被重绘,因此,如果项目中越多的节点需要经历回流/重绘,你的应用就会表现得越慢。

什么是 Virtual-DOM ? 它尝试去最小化回流/重绘步骤,从而在大型且复杂的项目中得到更好的性能。

接下来一节中将会解释更多有关于Virtual-DOM 如何工作的细节。

理解 Virtual-DOM

既然你已经了解了 DOM 是如何构建的,那现在就让我们去更多地了解一下 Virtual-DOM吧。

在这里,我会先用一个小型的 app 去解释 virtual dom 是如何工作的,这样,你可以容易地去看到它的工作过程。

我不会深入到最初渲染的工作细节,仅关注重新渲染时所发生的事情,这将帮助你去理解 virtual domdiff 算法是如何工作的,一旦你理解了这个过程,理解初始的渲染就变得很简单:)。

可以在这个git repo 上找到这个 app 的源码。这个简单的计算器界面长这样:

除了 Main.jsCalculator.js之外,在这个 repo 中的其他文件都可以不用关心。

// 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>
		);
	}
}
// 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 内部构建的上述 DOM 树的结构:

现在添加两个数字并点击「Add」按钮去更深入的理解

为了去理解 Diff 算法是如何工作及reconciliation 如何调度 virtual-dom 到真实的DOM 的,在这个计算器中,我将输入 100 和 50 并点击「Add」按钮,期待输出 150:

输入1: 100
输入2: 50

输出: 150

那么,当你按下「Add」按钮时,发生了什么?

在我们的例子中,当点击了「Add」按钮,我们 set 了一个包含有输出值 150 的 state:

// 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 方法得到了一个新的 state 值。

  3. 这个 setState() 方法将如以下几行代码一样,依次标记组件。

// ReactUpdates.js  - enqueueUpdate(component) function
dirtyComponents.push(component);

你是否在思考为什么 react 不直接标记这个 button, 而是标记整个组件?好了,这是因为你用了this.setState() 来调取 setState 方法,而这个 this 指向的就是这个 Calculator 组件

  1. 所以现在,我们的 Calculator 组件被标记了,让我们看看接下来又将发生什么。

遍历组件的生命周期

很好!现在这个组件被标记了,那么接下来会发生什么呢?接下来是更新 virtual dom,然后使用diff 算法做 reconciliation 并更新真实的 DOM

在我们进行下一步之前,熟悉组件生命周期的不同之处是非常重要的

以下是我们的 Calculator 组件在 react 中的样子:

Calculator Wrapper

以下是这个组件被更新的步骤:

  1. 这是通过 react 运行批量更新而更新的;

  2. 在批量更新中,它会检查是否组件被标记,然后开始更新。

 //ReactUpdates.js
var flushBatchedUpdates = function () {
  while (dirtyComponents.length || asapEnqueued) {
    if (dirtyComponents.length) {
      var transaction = ReactUpdatesFlushTransaction.getPooled();
      transaction.perform(runBatchedUpdates, null, transaction);
  1. 接下来,它会检查是否存在必须更新的待处理状态或是否发出了forceUpdate
if (this._pendingStateQueue !== null || this._pendingForceUpdate) {
      this.updateComponent(transaction, this._currentElement, this._currentElement, this._context, this._context);

在我们的例子中,您可以看到 this._pendingStateQueue 在具有新输出状态的计算器包装器里

  1. 首先,它会检查我们是否使用了componentWillReceiveProps(),如果我们使用了,则允许使用收到的 props 更新 state

  2. 接下来,react 会检查我们在组件里是否使用了 shouldComponentUpdate() ,如果我们使用了,我们可以检查一个组件是否需要根据它的 stateprops 的改变而重新渲染。

当你知道不需要重新渲染组件时,请使用此方案,从而提高性能

  1. 接下来的步骤依次是 componentWillUpdate(), render(), 最后是 componentDidUpdate()

从第 4,5 和 6 步, 我们只使用 render()

  1. 现在,让我们深入看看 render() 期间发生了什么?

渲染即是 Virtual-DOM 比较差异并重新构建

渲染组件 - 更新 Virtual-DOM, 运行diff 算法并更新到真实的DOM

在我们的例子中,所有在这个组件里的元素都会在 Virtual-DOM 中被重新构建

它会检查相邻已渲染的元素是否具有相同的类型和键,然后协调这个类型与键匹配的组件。

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

有一个重要的点就是这里是调用组件 render 方法的地方。比如,Calculator.render()

这个 reconciliation 过程通常采用以下步骤:

组件的 render 方法 - 更新Virtual DOM,运行 diff 算法,最后更新 DOM

红色虚线意味着所有的reconciliation 步骤都将在下一个子节点及子节点中的子节点里重复。

上述的流程图总结了 Virtual DOM 是如何更新实际 DOM 的。

我可能在知情或不知情的情况下错过了几个步骤,但此图表涵盖了大部分关键步骤。

因此,你可以在我们的示例中看到这个reconciliation是如何像以下这样进行运作的:

我先跳过前一个<div>reconciliation ,引导你看看 DOM 变成 Output:150 的更新步骤,

  • Reconciliation 从这个组件的类名为 "container" 的<div> 开始
  • 它的孩子是一个包含了输出的<div>, 因此,react 将从这个子节点开始reconciliation
  • 现在这个子节点拥有了子节点 <hr><h2>
  • 所以 react 将为 <hr> 执行reconciliation
  • 接下来,它将从 <h2>reconciliation 开始,因为它有自己的子节点,即输出和 state 的输出,它将开始对这两个进行reconciliation
  • 第一个输出文本经过了reconciliation,因为它没有任何变化,所以 DOM 没有什么需要改变。
  • 接下来,来自 state 的输出经过reconciliation,因为我们现在有了一个新值,即 150,react 会更新真实的 DOM。 ...

真实 DOM 的渲染

我们的例子中,在 reconciliation 期间,只有输出字段有如下所示的更改和在开发人员控制台出现绘制闪烁。

仅重绘输出

以及在真实 DOM上更新的组件树

结论

结论虽然这个例子非常简单,但它可以让你基本了解react 内部所发生的事情。

我没有选择更复杂的应用程序是因为绘制整个组件树真的很烦人。:-|

reconciliation 过程就是 React

  • 比较前一个的内部实例与下一个内部实例
  • 更新内部实例 Virtual DOM(JavaScript 对象) 中的组件树结构。
  • 仅更新存在实际变化的节点及其子节点的真实 DOM

(注: 作者文中的 react 版本是 v15.4.1)