虚拟 DOM 内部是如何工作的?

流程图展现VDOM在Preact中如何工作

虚拟DOM (VDOM,也叫 VNode)非常神奇 ✨ 但也非常复杂和难以理解��。 ReactPreact和一些类似的JS库都在核心代码中使用了虚拟DOM。不幸的是我发现没有一篇好的文章或者文档简洁明了地来介绍它。 因此我决定自己写一篇.

注意:这篇文章很长。我已经添加尽可能多的图片来使其理解更简单一些,但是这样一来,文章就显得更长了。

我用的是 Preact 的代码 和 VDOM,因为它很小,你可以在之后轻松地阅读它。 但是我相信大部分概念同样适用于 React。

我希望你读完这篇文章后,能够更好地理解 React或者Preact这些库,甚至能给它们贡献代码。

在这篇博客中,我将会举一个简单示例,并且展示不同的场景,用以介绍它们到底是如何工作的。我会重点介绍以下几点:

  1. Babel 和 JSX

  2. 创建一个VNode - 一个简单的虚拟DOM元素

  3. 处理组件及子组件

  4. 初始化渲染并且创建一个DOM元素

  5. 重新渲染

  6. 移除DOM元素

  7. 替换DOM元素

关于这个 demo:

这是一个简单 过滤搜索应用, 仅包含有两个组件 “FilteredList” 和 “List” 。这个 List 组件渲染列表项(默认是 “California” 和 “New York” )。这个应用有一个搜索的区域,可以根据字母来过滤列表项。非常直观。

相关图片(点击放大,查看更多细节。译者注:原文在medium里是可以放大的,这里貌似不行,右击在新窗口打开,查看大图)

应用代码: http://codepen.io/rajaraodv/pen/BQxmjj

大图

在应用上层,我们用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中)。

虽然这些覆盖了主要的场景,但是我还没讲到代码的优化。

如果你发现问题,通知我,我非常乐意更新!如果你想知道更多,也请告诉我!

就这样! ���� ��

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值