译者:Kite
作者:Gethyl George Kurian
原文链接:medium.com/@gethylgeor…
我曾经尝试去深层而清晰地去理解 Virtual-DOM
的工作原理,也一直在寻找可以更详细地解释其工作细节的资料。
由于在我大量搜索的资料中没有获取到一点有用的资料,我最终决定探究 react
和 react-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 dom
与diff
算法是如何工作的,一旦你理解了这个过程,理解初始的渲染就变得很简单:)。
可以在这个git repo 上找到这个 app 的源码。这个简单的计算器界面长这样:
除了 Main.js
和 Calculator.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>
复制代码
标记组件
(注: 将发生变化的组件)
首先,让我们理解第一步,一个组件是如何被标记的:
-
所有的
DOM
事件监听器都被包裹在React
自定义的事件监听器中,因此,当点击「Add」按钮时,这个点击事件被发送到 react 的事件监听器,从而执行上面代码中你所看到的匿名函数 -
在匿名函数中,我们调取
this.setState
方法得到了一个新的 state 值。 -
这个
setState()
方法将如以下几行代码一样,依次标记组件。
// ReactUpdates.js - enqueueUpdate(component) function
dirtyComponents.push(component);
复制代码
你是否在思考为什么 react 不直接标记这个 button, 而是标记整个组件?好了,这是因为你用了
this.setState()
来调取setState
方法,而这个 this 指向的就是这个 Calculator 组件
- 所以现在,我们的 Calculator 组件被标记了,让我们看看接下来又将发生什么。
遍历组件的生命周期
很好!现在这个组件被标记了,那么接下来会发生什么呢?接下来是更新 virtual dom
,然后使用diff
算法做 reconciliation
并更新真实的 DOM
在我们进行下一步之前,熟悉组件生命周期的不同之处是非常重要的
以下是我们的 Calculator 组件在 react
中的样子:
Calculator Wrapper
以下是这个组件被更新的步骤:
-
这是通过
react
运行批量更新而更新的; -
在批量更新中,它会检查是否组件被标记,然后开始更新。
//ReactUpdates.js
var flushBatchedUpdates = function () {
while (dirtyComponents.length || asapEnqueued) {
if (dirtyComponents.length) {
var transaction = ReactUpdatesFlushTransaction.getPooled();
transaction.perform(runBatchedUpdates, null, transaction);
复制代码
- 接下来,它会检查是否存在必须更新的待处理状态或是否发出了
forceUpdate
。
if (this._pendingStateQueue !== null || this._pendingForceUpdate) {
this.updateComponent(transaction, this._currentElement, this._currentElement, this._context, this._context);
复制代码
在我们的例子中,您可以看到 this._pendingStateQueue
在具有新输出状态的计算器包装器里
-
首先,它会检查我们是否使用了
componentWillReceiveProps()
,如果我们使用了,则允许使用收到的props
更新state
。 -
接下来,
react
会检查我们在组件里是否使用了shouldComponentUpdate()
,如果我们使用了,我们可以检查一个组件是否需要根据它的state
或props
的改变而重新渲染。
当你知道不需要重新渲染组件时,请使用此方案,从而提高性能
- 接下来的步骤依次是
componentWillUpdate()
,render()
, 最后是componentDidUpdate()
从第 4,5 和 6 步, 我们只使用
render()
- 现在,让我们深入看看
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
)