流程图展现VDOM在Preact中如何工作
虚拟DOM (VDOM,也叫 VNode)非常神奇 ✨ 但也非常复杂和难以理解��。 React,Preact和一些类似的JS库都在核心代码中使用了虚拟DOM。不幸的是我发现没有一篇好的文章或者文档简洁明了地来介绍它。 因此我决定自己写一篇.
注意:这篇文章很长。我已经添加尽可能多的图片来使其理解更简单一些,但是这样一来,文章就显得更长了。
我用的是 Preact 的代码 和 VDOM,因为它很小,你可以在之后轻松地阅读它。 但是我相信大部分概念同样适用于 React。
我希望你读完这篇文章后,能够更好地理解 React或者Preact这些库,甚至能给它们贡献代码。
在这篇博客中,我将会举一个简单示例,并且展示不同的场景,用以介绍它们到底是如何工作的。我会重点介绍以下几点:
Babel 和 JSX
创建一个VNode - 一个简单的虚拟DOM元素
处理组件及子组件
初始化渲染并且创建一个DOM元素
重新渲染
移除DOM元素
替换DOM元素
关于这个 demo:
这是一个简单 过滤搜索应用, 仅包含有两个组件 “FilteredList” 和 “List” 。这个 List 组件渲染列表项(默认是 “California” 和 “New York” )。这个应用有一个搜索的区域,可以根据字母来过滤列表项。非常直观。
相关图片(点击放大,查看更多细节。译者注:原文在medium里是可以放大的,这里貌似不行,右击在新窗口打开,查看大图)
大图
在应用上层,我们用JSX(JS中的html)写了组件,通过 babel 的命令行工具将其转换为原生的 JS。然后 Preact 的 “h” (hyperscript)函数将它转换为虚拟 DOM 树(也称为 VNode)。最后 Preact 的虚拟 DOM 算法根据虚拟 DOM 创建一个真实的 DOM,用以构成我们的应用。
大图
在我们深入理解VDOM的生命周期之前,让我们理解下 JSX,它为库提供了基础。
1. Babel 和 JSX
在React,Preact 这样的库中,没有 HTML,一切皆 JavaScript 。因此我们需要用 JavaScript 来写 HTML。但是用原生 JS 写 DOM 是一种噩梦。 ��
对于我们的应用,我们必须像下面这样书写 HTML:
注意: 等会儿我会介绍 “h”
这时候就轮到 JSX 上场了。JSX 本质上允许我们在 JavaScript 中书写HTML!并且允许我们在 HTML 的 {} 号中使用 JS 的语法。
JSX帮助我们像下面这样轻松地书写组件:
将 JSX 树转换为 JavaScript
JSX 很酷,但它不是合法的 JS,最终我们还是需要真实的 DOM。JSX 只能帮助我们书写对真实 DOM 的描述。除此之外,它毫无用处。
因此我们需要一种方法将 JSX 转换为正确的 JSON 对象(即VDOM, 也是一个“树”形结构),我们需要将 JSX 作为创建真实DOM的基础。我们用一个函数来做这样的事情。
在 Preact 中这个函数就是 “h” 函数。它的作用和 React 中的React.createElement的作用是一样的。
“h”是指 hyperscript - 一种通过 JS 来创建 HTML 的库。
但是怎样将 JSX 转换为 “h” 函数调用呢?这时就需要 Babel 了。Babel 只需要遍历 JSX 的节点,然后将它们转换为 “h” 函数式的调用。
Babel JSX (React Vs Preact)
由于Babel默认针对React,所以Babel 会将 JSX 转换为 React.createElement 函数调用。
左边: JSX。右边: React 的JS版本 (点击放大)
我们可以像下面这样增加Babel的Pragma配置,就能轻松修改要转换的函数名(比如Preact的“h”函数):
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 */
“h” —通过 Babel 的 Pragma 配置 (点击放大)
挂载到真实DOM
不光组件里的 render 方法中的代码会转换为 “h” 函数,开始挂载里的render方法中的代码也会被转换成“h”函数。
这是应用执行的开始,也是一切的开始
//挂在到真实DOM
render(<FilteredList/>, document.getElementById(‘app’));
//转换成 "h":
render(**h(FilteredList)**, document.getElementById(‘app’));
“h” 函数的输出
“h” 函数会根据 JSX 的输出,创建一个 “VNode”(React 的 “createElement” 函数会创建 ReactElement)。一个 Preact 的 “VNode”(或者 React 的 “Element”)就是一个JS对象,用以表示单个DOM 节点,并且包含了该节点的属性和子元素。
这个对象看起来像下面这样:
{
"nodeName": "",
"attributes": {},
"children": []
}
举个例子,我们的应用的Input表单的VNode像这样:
{
"nodeName": "input",
"attributes": {
"type": "text",
"placeholder": "Search",
"onChange": ""
},
"children": []
}
注意“h”函数不会创建完整的DOM树!它仅仅对给定的 node 创建了一个JS对象。但是由于 “render” 方法已经有了树形的 DOM JSX 语法。因此最后的结果将会是一个看起来像树的,带有子孙元素的 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
好了,让我们看下虚拟 DOM 如何工作?
Preact 虚拟 DOM 的算法流程图
下面的流程图展现了组件和子组件是如何被Preact创建,更新,删除的。也展现了什么时候会调用生命周期事件,比如“componentWillMount”。
注意: 我们会一步一步讲解每一部分,如果你觉得复杂,不用担心。
没错,很难一下子理解所有的知识。让我们通过一步一步地探索不同的情景,来理清流程图的不同部分。
注意: 当讨论到关键的生命周期的部分我将会用黄色高亮。
情景 1: APP创建初始化
1.1 为指定组件创建一个 VNode(虚拟DOM)
黄色高亮区域展示了为一个给定的组件创建虚拟 DOM 树的初始化循环过程。注意没有为子组件创建虚拟 DOM (这是个不同的循环)
黄色区域展示了虚拟DOM的创建
下面这张图片展示了当应用第一次加载的时候发生了什么。这个库最终为主要组件 “FilteredList” 创建了一个带有子元素和属性的VNode。
注意: 在此过程中它还调用了生命周期方法 “componentWillMount” 和 “render”。(看上面图片绿色的部分)
这个时候,我们有了个 “div” 的父元素,它包含了子节点 “input” 和 “list”。
引用代码:
大多数的生命周期事件,像 componentWillMount,render 等等: https://github.com/developit/preact/blob/master/src/vdom/component.js
1.2 如果不是一个组件,创建一个真实的 DOM
这一步,它仅会对父元素div创建一个真实的DOM,并且对子节点(“input” 和 “List”)重复这一步骤。
黄色的循环部分展现了子组件的创建。
在这一步,如下面的图片所示,仅有 “div” 被创建出来了。
引用代码:
document.createElement: https://github.com/developit/preact/blob/master/src/dom/recycler.js
1.3 对所有的子元素重复这一步
在这一步,将会对所有的子元素重复这一循环。在我们的应用中,会对“input” 和 “List” 重复。
循环处理每一个子元素
1.4 处理子元素,并且把它加到父元素上.
在这一步我们将会处理叶子节点。既然“input”有一个父元素“div”,我们将会把input作为一个子元素加到div中。然后停止,返回创建“List”(“div”的第二个子元素)。
结束处理叶子节点
这一步,我们的应用看起来像下面这样:
注意: “input”被创建后,由于它没有子元素,不会立即循环和创建“List”!而是会先将“input”加入到父元素“div”中,然后再返回处理“List”。
引用代码:
appendChild: https://github.com/developit/preact/blob/master/src/vdom/diff.js
1.5 处理子组件(们)
控制流程回到1.1,对“List”组件重复之前的步骤。但是“List”是一个组件,它调用“List”组件的render方法,得到一组新的VNode,像下面这样
对一个子组件重复所有的操作
对List组件重复操作之后,返回的VNode像下面这样:
引用代码:
“buildComponentFromVNode: https://github.com/developit/preact/blob/master/src/vdom/diff.js#L102
1.6 对所有子节点重复1.1到1.4步骤
它会再一次对每一个节点重复上面的步骤。一旦它到达叶子节点,就会把它加入到节点的父节点,并且重复这个过程。
重复这一步骤,直到所有的父子节点被创建和添加。
下面的图片展示了每个节点的添加(提示: 深度优先)
真实的DOM树如何被虚拟DOM算法创建的。
1.7 结束处理
在这一步,结束处理。它仅对所有的组件调用“componentDidMount”(按照从子组件到父组件的顺序)然后停止。
重要提示: 一旦所有步骤执行完毕,会给每个组件实例添加一个真实DOM的引用。这个引用将用于在持续更新(创建,更新,删除)中进行比较,以避免重复创建同样的DOM节点。
情景 2: 删除叶子节点
当我们输入“cal” 关键字,点击确认。将会移除掉第二个list节点,即叶子节点(New York),同时保留所有别的父节点。
让我们看下这个场景的执行过程。
2.1 像之前那样创建VNodes.
在初始化渲染之后,未来的每一个变化都是一个更新。当需要创建VNodes时,更新周期跟创建周期非常相似,并且会再一次创建所有的VNodes。
不过,因为是一个组件更新(不是创建),所以它会调用每个组件和子组件的“componentWillReceiveProps”, “shouldComponentUpdate”, 和 “componentWillUpdate”
另外, 如果元素已经存在,更新循环不会重复创建这些真实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
2.2 使用真实DOM节点的引用,避免创建重复的nodes
之前提到过,每个组件都有一个引用,对应地指向初始化加载时所创建的真实DOM树。下面这张图片展现了现在我们应用中的引用。
显示每一个组件和之前的DOM之间的引用
当虚拟DOM被创建,每个虚拟DOM的属性都会跟真实DOM的属性进行比较。如果真实DOM存在,循环处理将会继续处理下一个节点。
真实DOM已经存在(在更新期间)
引用代码:
innerDiffNode: https://github.com/developit/preact/blob/master/src/vdom/diff.js#L185
2.3 如果在真实的DOM中存在额外的节点,移除他们
下面的图片展现了真实DOM和虚拟DOM的差异
(click to zoom)
由于这里存在差异,所以在真实节点中的“New York”节点会被算法移除,如下面的流程图所示。当所有工作进行完毕算法也会调用“componentDidUpdate”。
移除DOM节点生命周期
情景 3 — 卸载整个组件
让我们看看在filter组件中输入blabla,既然没有匹配到“California” 和 “New York”, 我们不会渲染子组件“List”,这意味着我们需要卸载整个组件。
如果没有结果的话List组件没有被移除
组件FilteredList的render方法
删除一个组件跟删除单个节点差不多。只有一点不同,当我们删除一个相对于组件有引用的节点,框架会调用“componentWillUnmount”,然后递归删除所有的DOM元素。当所有的元素从真实DOM移除,将会调用引用的组件的“componentDidUnmount”方法。
下面的图片显示在真实的DOM“ul”中,对“List”组件的引用。
下面流程图的高亮部分展现了移除和卸载组件的过程
移除和卸载组件
引用
unmountComponent: https://github.com/developit/preact/blob/master/src/vdom/component.js#L250
最后一点:
我希望这篇博文足以让你理解虚拟DOM是如何工作的(至少在Preact中)。
虽然这些覆盖了主要的场景,但是我还没讲到代码的优化。
如果你发现问题,通知我,我非常乐意更新!如果你想知道更多,也请告诉我!
就这样! ���� ��