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

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

我曾经尝试去深层而清晰地去理解 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)

猜你喜欢

转载自juejin.im/post/5c504f736fb9a049ef26fcd3