深入研究Virtual DOM【转载】

2017-05-23

对Virtual DOM这个名词并不陌生,但是有什么深入的理解谈不上。看到medium上rajaraodv写的The Inner Workings Of Virtual DOM这篇文章,比较深入的介绍了Virtual DOM的各个方面,在此翻译一下。

这篇文章比较简单,在翻译的过程中都不需要google翻译,但是图片比较多。

Virtual DOM

Virtual DOM (VDOM 也叫 VNode) 很魔幻 ✨,但是也很复杂以至于让人难以理解😱。像React,Preact这些js的库都用到了Virtual DOM。不幸的是,我没有找到任何一篇深入浅出的解释VDOM文章或者文档,所以我决定自己写一篇。

注意:这篇文章很长,为了更通俗易懂,我加了很多图片,同时也使这也是这篇文章很长。

我用了Preact的代码,希望将来你很容易看懂,但是我觉得大多数情况下也适用于React。我希望读到这篇文章的人能更好的理解React和Preact,也为他们的发展做出一点贡献

本文会举一个简单的例子,然后介绍不同的场景,让你理解他们是怎么样运行的:

  1. Babel 和 JSX

  2. 创建虚拟节点-只是一个单一的虚拟DOM元素

  3. 处理组件和子组件

  4. 初始渲染和创建DOM元素

  5. 重新渲染

  6. 删掉DOM元素

  7. 替换DOM元素

App

这个App是一个简单的可过滤搜索器。包含“FilteredList”和“List”两个组件。List组件渲染了一个列表(默认值是“California”和“New York”)。App还有一个搜索框,通过在搜索框里输入文字来过滤列表。

Virtual DOM

首先,我们用JSX来写组件,然后用Babel的CLI工具转成纯JS。然后用Preact的“h” (hyperscript)函数转成VDOM树。最终Preact的Virtual DOM算法把VDOM转换成真正的DOM,这样就生成了我们的App。

Virtual DOM

在了解VDOM的生命周期之前,先来了解一下JSX.

Babel和JSX

在React和Preact这些库里,没有html,只有JavaScript。所以我们需要在JavaScript里写html,但是在纯js里写DOM简直是噩梦😱

对我们的App来说,html像下面这样

Preact Preact

这就是jsx,允许你在JavaScript里写html,然后也可以在{}里使用JavaScript。

用jsx写组件就很容易:

1
2
3
4
<div>
<input type="text" placeholder="Search" onChange={ this.filterLsit }/>
<List items={ this.state.items }/>
</div>
1
2
3
4
5
6
7
8
//List component
<ul>
{
this.props.items.map((item) => {
return (<li key={item}>{item}</li>);
})
}
</ul>

把jsx转换成JavaScript

jsx很酷,但是不是有效的JS,浏览器不支持。我们需要的是真实的DOM。JSX仅仅是在写DOM的表现层的时候有用。
所以我们需要一个函数来把jsx转换成相应的JSON对象(VDOM,也是一个树形结构),然后我们可以把这个json对象作为创建真实DOM的输入。

这个函数就叫h,和React里的React.createElement是一样的功能。

“h” 代表hyperscript

怎么样把jsx转换成h函数呢,这就是Babel干的事情。Babel遍历每一个JSX节点,把他们转换成h函数的调用

hyperscript

Babel JSX (React Vs Preact)

Babel默认会把jsx转成React.createElement调用,因为默认是React。

但是我们也能通过添加Babel编译宏,把这个函数的名字改成任何我们想要的名字:

1
2
3
4
5
6
7
8
9
Option 1:
//.babelrc
{ "plugins": [
["transform-react-jsx", { "pragma": "h" }]
]
}
Option 2:
//Add the below comment as the 1st line in every JSX file
/** @jsx h */

挂载到真实的DOM

starting mount和render函数都被转换到了h函数里,这是一切的开端:

1
2
3
4
//Mount to real DOM
render(<FilteredList/>, document.getElementById(‘app’));
//Converted to "h":
render(h(FilteredList), document.getElementById(‘app’));

h函数的输出

h函数接收Babel转换后的JSX,创建一个叫“VNode”的节点(React通过“createElement”创建ReactElement)一个Preact的“VNode”(或者是React的“Element”)就是一个包含自身属性和子元素的DOM节点,看起来像这样:

1
2
3
4
5
{
"nodeName": "",
"attributes": {},
"children": []
}

举个🌰,我们的App的DOM节点看起来像这样:

1
2
3
4
5
6
7
8
9
{
"nodeName": "input",
"attributes": {
"type": "text",
"placeholder": "Search",
"onChange": ""
},
"children": []
}

注意:h函数并不会创建整个DOM树,对于指定的节点,只创建一个js的对象,但是因为render函数的参数是一个树形的DOM,最终的VNode看上去就像一棵树

相关代码:
h:https://github.com/developit/preact/blob/master/src/h.js
VNode:https://github.com/developit/preact/blob/master/src/vnode.js
render:https://github.com/developit/preact/blob/master/src/render.js
buildComponentFromVNode:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L102

Preact的虚拟DOM算法流程图

下面的流程图介绍了组件和子组件是如何创建,更新和删除的。也展示了像“componentWillMount”这样的生命周期函数是何时被调用的

我们会一步一步的来分析每一个过程,所以不要觉得太复杂。

Preact的虚拟DOM算法流程图

要马上理解确实很困难,让我们根据不同的场景来一步步看:

我会用黄色来高亮生命周期相关的部分:

场景1:初始创建App

为指定的组件创建VNode(Virutal DOM)

这张图展示了为给定组件创建VNode树的初始循环,在这个循环里没有创建子组件(创建子组件的过程略有不同)

为指定的组件创建VNode

下面这张图展示了当我们的App第一次运行的时候发生了什么,Preact最终为FilteredList组件创建了一个包含子组件和自身属性的VNode

App第一次运行的时候发生了什么

目前为止,我们有了一个VNode,其中div是它的父节点,input和List是它的子节点

相关代码:
大多数生命周期函数:https://github.com/developit/preact/blob/master/src/vdom/component.js

如果不是组件则创建真实的DOM

这一步主要是创建父节点div,循环创建子节点

创建父节点div

div显示如下

div显示

相关代码:
document.createElement: https://github.com/developit/preact/blob/master/src/dom/recycler.js

重复创建子节点

这一步,要循环创建所有节点,对我们的App来说,就是input和List

重复创建子节点

把子节点添加到父节点

这一步处理叶子节点,因为input有一个div的父节点,我们把input作为div的子节点,然和创建List,也就是div的第二个子节点

把子节点添加到父节点

到这一步,我们的app看上去像这样:

app

注意:创建完input之后并不是立即去创建list组件,而是先把input添加到父div节点之后才继续处理List节点
相关代码:
appendChild:https://github.com/developit/preact/blob/master/src/vdom/diff.js

处理子组件

控制流又回到1.1开始处理List组件,因为List是一个组件而不是DOM元素,会调用List的render函数生成VNodes

处理子组件

List的虚拟节点看上去像下面这样:

处理子组件

相关代码:
buildComponentFromVNode:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L102

重复2.1.1到2.1.4处理所有的子节点

重复2.1.1到1.4处理所有的子节点

下面这张图展示了每个节点被添加的过程(深度优先)

重复1.1到1.4处理所有的子节点

结束处理

这一步就结束了,调用所有组件的“componentDidMount”函数,然后结束

结束处理

重要提示:当这些步骤完成以后,每个组件都有一个对真实DOM的引用,用来更新和比较,避免重新创建同样的DOM节点

场景2:删除叶子节点

当我们在搜索框里输入“cal”,然后敲下回车之后,就只剩下了(New York)这个叶子节点,删除了List的第二个节点

场景2:删除叶子节点

让我们看看这个场景的流程是怎么样的:

像之前一样,创建虚拟节点

在初始化渲染之后,将来的每一次更改都是update。

当创建节点的时候,update的生命周期和create的生命周期很像。也会从头创建VNodes

但是因为是更新而不是创建组件,会调用每个组件和字组件的“componentWillReceiveProps”, “shouldComponentUpdate”, and “componentWillUpdate”方法

另外在update的周期里,如果VNodes对应的DOM元素已经存在,则不会重新创建

创建虚拟节点

相关代码:
removeNode:https://github.com/developit/preact/blob/master/src/dom/index.js#L9
insertBefore:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L253

使用组件对真实DOM的引用,避免重新创建DOM

像先前提到的,每一个组件都有一个对其在初始化过程中创建的真实DOM的一个引用,下图展示了我们的App的引用

使用组件对真实DOM的引用

当虚拟节点创建之后,节点的每一个属性都会和真实DOM的节点属性比较,如果真实DOM是存在的,则循环跳到下一个节点

使用组件对真实DOM的引用

相关代码:
innerDiffNode:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L185

如果真实DOM里还有其他节点则删除

如果真实DOM里还有其他节点则删除

因为有差异,“New York”节点在真实DOM里被下面的流程展示的算法删除了,该算法还会调用“componentDidUpdate”生命周期函数

如果真实DOM里还有其他节点则删除

场景3:卸载整个组件

考虑这样一种用户场景:我们在过滤器了输入blabla,因为它既不匹配“California”也不匹配“New York”,所以不需要渲染List这个子组件。这也就意味着我们需要卸载整个组件

场景3:卸载整个组件 场景3:卸载整个组件

删除一个组件和删除一个节点类似。另外,当删除一个被组件引用的节点的时候,框架会调用“componentWillUnmount”函数,然后递归删除所有的DOM元素。
下图展示了真是DOM里ul对List组件的引用

场景3:卸载整个组件

下图中高亮的部分展示了删除/卸载组件是如何工作的
The below picture highlights the section in the flowchart to show how deleting/unmounting a component works.

场景3:卸载整个组件

相关代码:
unmountComponent:https://github.com/developit/preact/blob/master/src/vdom/component.js#L250

《完》

【文章转载至风君子 深入研究Virtual-DOM