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

Virtual DOM (VDOM 也叫 VNode) 很魔幻 ✨,但是也很复杂以至于让人难以理解😱。像React,Preact这些js的库都用到了Virtual DOM。不幸的是,我没有找到任何一篇深入浅出的解释VDOM文章或者文档,所以我决定自己写一篇。
注意:这篇文章很长,为了更通俗易懂,我加了很多图片,同时也使这也是这篇文章很长。
我用了Preact的代码,希望将来你很容易看懂,但是我觉得大多数情况下也适用于React。我希望读到这篇文章的人能更好的理解React和Preact,也为他们的发展做出一点贡献
本文会举一个简单的例子,然后介绍不同的场景,让你理解他们是怎么样运行的:
- Babel 和 JSX
- 创建虚拟节点-只是一个单一的虚拟DOM元素
- 处理组件和子组件
- 初始渲染和创建DOM元素
- 重新渲染
- 删掉DOM元素
- 替换DOM元素
这个App是一个简单的可过滤搜索器。包含“FilteredList”和“List”两个组件。List组件渲染了一个列表(默认值是“California”和“New York”)。App还有一个搜索框,通过在搜索框里输入文字来过滤列表。

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

在了解VDOM的生命周期之前,先来了解一下JSX.
在React和Preact这些库里,没有html,只有JavaScript。所以我们需要在JavaScript里写html,但是在纯js里写DOM简直是噩梦😱
对我们的App来说,html像下面这样


这就是jsx,允许你在JavaScript里写html,然后也可以在里使用JavaScript。
用jsx写组件就很容易:
<div>
<input type="text" placeholder="Search" onChange={ this.filterLsit }/>
<List items={ this.state.items }/>
</div>//List component
<ul>
{
this.props.items.map((item) => {
return (<li key={item}>{item}</li>);
})
}
</ul>jsx很酷,但是不是有效的JS,浏览器不支持。我们需要的是真实的DOM。JSX仅仅是在写DOM的表现层的时候有用。 所以我们需要一个函数来把jsx转换成相应的JSON对象(VDOM,也是一个树形结构),然后我们可以把这个json对象作为创建真实DOM的输入。
这个函数就叫h,和React里的React.createElement是一样的功能。
“h” 代表hyperscript
怎么样把jsx转换成h函数呢,这就是Babel干的事情。Babel遍历每一个JSX节点,把他们转换成h函数的调用

Babel默认会把jsx转成React.createElement调用,因为默认是React。
但是我们也能通过添加Babel编译宏,把这个函数的名字改成任何我们想要的名字:
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 */starting mount和render函数都被转换到了h函数里,这是一切的开端:
//Mount to real DOM
render(<FilteredList/>, document.getElementById(‘app’));
//Converted to "h":
render(h(FilteredList), document.getElementById(‘app’));h函数接收Babel转换后的JSX,创建一个叫“VNode”的节点(React通过“createElement”创建ReactElement)一个Preact的“VNode”(或者是React的“Element”)就是一个包含自身属性和子元素的DOM节点,看起来像这样:
{
"nodeName": "",
"attributes": {},
"children": []
}举个🌰,我们的App的DOM节点看起来像这样:
{
"nodeName": "input",
"attributes": {
"type": "text",
"placeholder": "Search",
"onChange": ""
},
"children": []
}注意:h函数并不会创建整个DOM树,对于指定的节点,只创建一个js的对象,但是因为render函数的参数是一个树形的DOM,最终的VNode看上去就像一棵树
相关代码:
buildComponentFromVNode:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L102
下面的流程图介绍了组件和子组件是如何创建,更新和删除的。也展示了像“componentWillMount”这样的生命周期函数是何时被调用的
我们会一步一步的来分析每一个过程,所以不要觉得太复杂。

要马上理解确实很困难,让我们根据不同的场景来一步步看:
我会用黄色来高亮生命周期相关的部分:
这张图展示了为给定组件创建VNode树的初始循环,在这个循环里没有创建子组件(创建子组件的过程略有不同)

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

目前为止,我们有了一个VNode,其中div是它的父节点,input和List是它的子节点
相关代码:
这一步主要是创建父节点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看上去像这样:

注意:创建完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

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

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

重要提示:当这些步骤完成以后,每个组件都有一个对真实DOM的引用,用来更新和比较,避免重新创建同样的DOM节点
当我们在搜索框里输入“cal”,然后敲下回车之后,就只剩下了(New York)这个叶子节点,删除了List的第二个节点

让我们看看这个场景的流程是怎么样的:
在初始化渲染之后,将来的每一次更改都是update。
当创建节点的时候,update的生命周期和create的生命周期很像。也会从头创建VNodes
但是因为是更新而不是创建组件,会调用每个组件和字组件的“componentWillReceiveProps”, “shouldComponentUpdate”, and “componentWillUpdate”方法
另外在update的周期里,如果VNodes对应的DOM元素已经存在,则不会重新创建

相关代码: removeNode:https://github.com/developit/preact/blob/master/src/dom/index.js#L9
像先前提到的,每一个组件都有一个对其在初始化过程中创建的真实DOM的一个引用,下图展示了我们的App的引用

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

相关代码:

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

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


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

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

相关代码:
《完》
【文章转载至风君子 深入研究Virtual-DOM】