Vitual DOM 的内部工作原理

1,267 阅读13分钟
原文链接: efe.baidu.com

原文:The Inner Workings Of Virtual DOM

Preact VDOM 工作流程图

Preact VDOM 工作流程图

虚拟DOM (VDOM,也称为 VNode) 是非常神奇的,同时也是复杂难懂的。 ReactPreact 以及其他类似的 JS 库都使用了虚拟 DOM 技术作为内核。可惜我找不到任何靠谱的文章或者文档可以简单又清楚解释清虚拟DOM的内部细节。所以,我就想到自己动手写一篇。

注:这是一篇很长的博客。为了让内容更容易理解,我添加了很多图片。这也导致这篇博客看上去更长了。

在这篇博客中,我是基于 Preact 的代码和 VDOM 机制来介绍的。因为 Preact 代码量更少,你在以后也可以不费力地自己看看源码。但是我觉得绝大部分的概念也同样适用于 React。

我希望读者通过这篇博客可以更好地理解虚拟DOM,并期待你们可以为 React 和 Preact 等开源项目提供贡献。

在这篇博客中,我会通过一个简单的例子来仔细地介绍虚拟DOM的每个场景,给大家虚拟DOM是如何工作的。特别地,我会介绍以下内容:

  1. Babel 和 JSX
  2. 创建 VNode – 单个虚拟 DOM 元素
  3. 处理组件和子组件
  4. 初始渲染和创建 DOM 元素
  5. 再次渲染
  6. 删除 DOM 元素
  7. 替换 DOM 元素

演示程序

演示程序是一个简单的可筛选的搜索程序,包含了两个组件 FilteredListList。List 组件会渲染一个城市列表(默认情况是 California 和 New York)。示例还有一个搜索框,可以根据搜索框的输入内容来筛选列表。十分直接了当。

在线示例: codepen.io/rajaraodv/p…

概览

首先,我们用 JSX(html in js)来编写组件。我们会使用 Babel 将组件转译成纯 JS 。接着 Preact 的 『h』 hyperscript 函数会将组件再转化成 VDOM 树(也就是 VNode)。最终, Preact 的虚拟 DOM 算法,按照 VDOM 生成真实的 DOM 元素,完成我们的应用。

概览

概览

在我们深入 VDOM 生命周期的细节之前,先来理解一下 JSX;它提供了整个框架的起点。

1. Babel 和 JSX

在 React、Preact 以及类似的框架中,并没有 HTML;取而代之,所有都是 JS。所以我们甚至需要在 JavaScript 中来编写 HTML。但是,只用纯 JS 来写 DOM 简直就是恶梦!

拿我们的演示程序来说,我们必须这样写 HTML:

我一会儿来再解释 『h』

这就是我们需要引入 JSX 的原因。本质上来说,JSX 就是让我们愉快地在 JS 中写 HTML!同时,也允许我们在花括号里 {} 使用 JS。

如下所示,JSX 可以帮助我们很容易地编写组件

2. JSX 树转化为 JavaScript

JSX 很酷,但是它不是可用的 JS,而最终我们需要真实的 DOM。JSX 只能帮助我们简洁地表达真实 DOM,没有办法再完成其他的事情。

所以我们需要一个方法来把 JSX 转化成对应的 JSON 对象(VDOM,同时它也是一棵树)。只有这样我们最终才能使用它作为输入来创建真实 DOM。我们需要一个函数来实现它。

在 Preact 中,这个函数就是 『h 函数』。它与 React 中的 『React.createElement』是等效的。

『h』代表着 hyperscript —— 最先开始在 JS 中编写 HTML 的框架之一。

但如何把 JSX 转化成 『h』函数呢?这就是引入 Babel 的原因了。Babel 会找到所有的 JSX 结点并把它们转化成『h』函数调用。

babel-convert-jsx-to-js

babel-convert-jsx-to-js

3. Babel JSX (React vs Preact)

默认条件下,Babel 会把 JSX 转译成 React.createElement 调用,因为它默认就是支持的 React。

左边是 JSX,右边是转译成 React 版的 JS

左边是 JSX,右边是转译成 React 版的 JS

但我们可以通过添加『Babel Pragma』参数,很容易地把这个函数名换成任何我们想要的,比如 Preact 使用的 『h』:

Option 1:
// .babelrc
{
"plugins": [
[
"transform-react-jsx", {"pragma": "h"}
]
]
}

Option 2:
// 在每个 JSX 文件的第一行添加这一行注释
/** @jsx h*/
使用 Babel Pragma 来指定 h 函数

使用 Babel Pragma 来指定 h 函数

4. 挂载到真实 DOM 的主入口

不仅是在组件的『render』函数中的代码需要被转译成『h』函数,初始的挂载入口也需要。

这就是开始执行的位置,一切的开始!

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

5.『h』函数的返回值

『h』函数使用 JSX 的返回值作为参数,创建了一个叫『VNode』的东西(React 的『createElement』创建 ReactElement)。一个 Preact 的『VNode』(或者是 React 的 『Element』)只是一个 JS 对象,代表着一个 DOM 结点,其中包含了它的属性和子结点。

VNode 大概是这样的:

{
nodeName: '',
attributes: {},
children: []
}

举个例子,我的演示程序中搜索框 Input 的 VNode 应该是这样的:

{
nodeName: 'input',
attributes: {
type: 'text',
placeholder: 'Search',
onChange: ''
},
children: []
}

『h』函数不会创建整个树!它只会为指定的结点创建一个 JS 对象。但由于『render』方法已经得到了树结构的 DOM JSX,最终产出的结果就会是一个带有子结点、孙结点的 VNode,看上去就是一棵树。

相关的代码

『h』: github.com/developit/p…

『VNode』: github.com/developit/p…

『render』: github.com/developit/p…

『buildComponentFromVNode』: github.com/developit/p…

Preact 的虚拟 DOM 算法流程图

下面的流程图中展示了 Preact 是如何创建、更新、删除组件以及其子组件的。同时它也展示了诸如 componentWillMount 等生命周期事件是何时被调用的。

注:这个图看上去很复杂,不要担心,我们会逐个分章节一步一步地详细介绍。

是的,很难一次全部读懂它。所以让我们把它分解成多个章节,一步一步来介绍。

注:当我们讨论生命周期中的某部分时,我会在图中用黄色高亮区域把它们标注出来。

场景1:应用程序的创建

1.1 为给定的组件创建 VNode (Virtual DOM)

图中的高亮区域展示了创建组件 VNode(Vitual DOM) 树的循环。注意这里没有创建子组件的 VNode,那是另外一个循环。

黄色高亮的部分展示了 VNode 的创建过程

黄色高亮的部分展示了 VNode 的创建过程

下面这张图展示了我们的应用首次加载时发生了什么。框架完成时得到了 FilteredList 组件的一个带有子结点和属性的 VNode。

注:在这个过程中,componentWillMountrender 这两个生命周期方法被调用了(注意上图中的绿色框体)。

相关代码

绝大部分的生命周期事件,诸如:componentWillMount,render 都可以在这里找到:github.com/developit/p…

1.2 如果不是组件,那么创建一个真实 DOM

在这一步中,我们会为父结点(div)创建真实的 DOM 元素,并且遍历处理子结点(inputList)。

高亮的部分展现了为子组件创建真实 DOM 的处理过程

高亮的部分展现了为子组件创建真实 DOM 的处理过程

如下图所示,现在我们就得到了 div

相关代码

document.createElement: github.com/developit/p…

1.3 重复子结点

现在,这个循环是对每个子结点重复以上动作。在我们的应用中,我们将会重复 inputList

重复处理每个子结点

重复处理每个子结点

1.4 处理子结点并添加将其添加到父结点

在这一步中,我们会处理叶子结点。由于 input 拥有父结点 div,我们就把 input 作为子结点添加到 div 中。接着 input 的处理流程结束,继续处理 Listdiv的第二个子结点)。

完成对子结点的处理

完成对子结点的处理

此时,我们的应用是这样的:

注意:在创建 input 之后,由于它没有任何子结点,因此对它的处理结束。但这里并不是立即继续循环并创建 List。而是先将 input 添加到父结点 div,而后再返回处理 List

相关代码:

appendChild: github.com/developit/p…

1.5 处理子组件

控制流程返回到步骤 1.1,对 List 组件开始新的一轮处理。由于 List 是一个组件,所以它也会调用 Listrender 方法来获取到新的 VNode,如下所示:

对每个子组件重复以上所有的处理

对每个子组件重复以上所有的处理

当处理 List 组件的循环完成时,我们可以得到 List 的 VNode,如下所示:

process-child-component

process-child-component

相关代码:

buildComponentFormVNode: github.com/developit/p…

1.6 对所有子结点重复步骤 1.1 到 1.4

现在再次对所有的子结点重复以上处理。一旦到达叶子结点时,就把它添加到父元素上并重复整个过程。

一直重复此流程,直到所有结点都被创建并添加到 DOM 树

一直重复此流程,直到所有结点都被创建并添加到 DOM 树

下边个张图展示了每个子结点是如何被添加的(提示:深度优先)

DOM 是如何被创建的

DOM 是如何被创建的

1.7 结束

此时,我们就完成了整个的处理过程。这里只需要地调用所有组件的 componentDidMount 方法(自子组件开始,至父组件结束),然后停止。

重要提示:一旦所有的工作都完成时,我们会将真实 DOM 对象的引用添加到每个相应的组件实例上。这些引用将会帮助完成后续的操作(创建、更新、删除),对比并避免重复创建相同的 DOM 结点。

场景2:删除叶子结点

假设我们在 input 中输入 cal 然后回车。这将移除第二个列表结点,另一个叶子结点(New York)则到被保留下来。

好,接下来让我们看一下这一场景的处理流程。

2.1 以之前一样,创建 VNode

在初始渲染之后的每个变化都称为一个 更新(update) 。对于 更新 周期中的创建 VNode 工作,与前边讲到 创建 周期中的非常类似,就是再来一次创建 VNode。

既然是更新(不是创建)一个组件,那么每个组件以及子组件的 componentWillReceivePropsshouldComponentUpdatecomponentWillUpdate 事件将会被触发。

额外的,更新周期,不会再次创建 DOM 元素,因为它们已经存在了

译者注

如果 DOM 元素可复用就不会再次创建。不可复用的情况主要是指标签名发生变化。这种情况下,我们仍然会创建新的 DOM 元素,并且会把旧有的 DOM 回收掉。

例如从 div 变为 section,那么就会创建一个新的 section 元素,替换原有 div,而 div 会被回收;

组件更新的处理流程

组件更新的处理流程

相关代码

removeNode: github.com/developit/p…

insertBefore: github.com/developit/p…

2.2 使用真实 DOM 结点引用 & 避免重复创建结点

之前有提到过,在初始化过程中完成创建之后,每个组件都会有一个指向到对应的真实的 DOM 树结点的引用。下边的图片展示了我们演示 app 当前状态的引用关系。

DOM 与组件实例之间的引用关系

DOM 与组件实例之间的引用关系

每当我们创建一个新 VNode 时,它的每个属性都会与对应结点的真实 DOM 属性做对比。如果真实 DOM 所有属性都与新的 VNode 一致,那么就会继续处理下一个结点。

更新过程中 DOM 结点已经存在的处理流程

更新过程中 DOM 结点已经存在的处理流程

译者注

实际上,这里的逻辑并不是简单地把 VNode 与 DOM 的 attributes 作对比。

在 preact 中,每个 DOM 都有一个 Symbol(__preactattr__) 的属性,这里称之为属性缓存。这个属性的值就是我们的 VNode 的所有属性(不包含 children)。我们是用这个属性缓存与 VNode 作对比的。

具体的 diff 过程大概是这样的:

首先,我们会先在 DOM 上找 Symbol(__preactattr__) 的属性;如果这个属性不存在,那么我们会遍历 DOM 上所有的 attributes 来生成它。

接着,我们一一对比 VNode属性缓存 的所有属性。如果两者完全一致,那么我们不会对 DOM 做任何更新操作;如果 VNode 与这个属性存在差异,我们则会更新 DOM 属性,并同时更新属性缓存。注意,这里 VNode 的属性对比完成时,也同时完成了对 DOM 的更新。

相关代码:

  1. 生成缓存:github.com/developit/p…

  2. 使用属性缓存:github.com/developit/p…

  3. 对比属性缓存与 VNode 属性:github.com/developit/p…

2.3 移除多余的 DOM 结点

下边这张图展示了真实 DOM 与 VNode 之间的差异:

VNode 与 DOM 间的差

VNode 与 DOM 间的差

由于真实 DOM 比 VNode 多了一个 New York 结点,在下边的图中高亮的部分中我们会把它移除掉。同时,在所有过程完成之后,还会触发生命周期中的 componentWillUnmount 事件。

Remove DOM node lifecycle

Remove DOM node lifecycle

相关代码

unmountComponent: github.com/developit/p…

场景 3:移除整个组件

假设我们在筛选框中输入 blabla。那么 “California” 或者 “New York” 都匹配不上,所以我们根本不会去渲染子组件 “List”。这意味着,我们需要卸载整个组件。

如果没有结果,那么列表组件会被移除

如果没有结果,那么列表组件会被移除

FilteredList 的 render 的方法

FilteredList 的 “render” 的方法

移除一个组件与移除一个结点类似。当我们移除一个有组件引用的 DOM 结点时,会触发组件的生命周期处理函数 “componentWillUnmount”,接着递归地删除所有的子孙 DOM 结点。所有的元素都被删除时,会触发引用组件的生命周期处理函数 “componentDidUnmount”。

下面这张图片展示了 DOM 结点与组件实例之间的引用关系:

DOM 结点与组件实例之间的引用关系

DOM 结点与组件实例之间的引用关系

下面的流程图中高亮的部分展示了移除/卸载组件的处理过程:

移除并卸载组件

移除并卸载组件

相关代码

unmountComponent: github.com/developit/p…

子结点 diff 算法

译者注:对于子结点的 diff 计算是 virtual dom 算法中至关重要的一个环节。但原文没有涉及到其中的细节,因此译者补充这一小节。

在处理完 VNode 的自身属性后,会对子结点进行 diff 计算;为了提高这个计算的性能,我们在框架中强制要求每个子 VNode 都必须有一个属性 key,字符串类型,并且每个 key 互不相同。我们需要使用 key 来构建索引,加速子 VNode 的匹配过程。

子结点 diff 的过程大概是这样的:

  1. 首先,先将当前子 VNode 按属性 key 为键、VNode 为值,构建成一个 Map;

    这里就是为什么 key 一定要互不相同的原因。如果 key 有冲突,那么这个 Map 就无法构建了。

  2. 遍历所有新的子 VNode;
    1. 使用新子 VNode 的 key,找到在 Map 中的当前子 VNode;
    2. 将两者做 diff;

      实际上是递归整个 diff 算法。没找到对应 VNode 就是新增结点,找到了就是更新结点。

    3. 将此 VNode 的 key 从 Map 中移除;

  3. 最后,把 Map 中剩余的 VNode 全部卸载。

    这里是场景 2.3 和场景 3 中移除结点的触发点。

相关代码 innerDiffNode:github.com/developit/p…

最后

我希望这篇文章可以充分地让大家了解 Virtual DOM 是如何工作的,至少是 preact。

请注意我只提到了主要的一些场景,并没有涉及到代码中某些的优化处理。

同时,如果你发现了任何问题,请告诉我。我非常乐意更正!