通过构建 UI 框架学习 JavaScript:第 6 部分 - 虚拟 DOM 算法简介
本文是深入探讨 JavaScript 系列的第六篇。您可以访问与本项目相关的Github 仓库来查看之前的文章。
本系列并非全面涵盖所有 JavaScript 功能,而是根据各种问题的解决方案中出现的功能进行讲解。此外,每篇文章都基于其他开发者提供的教程和开源库,因此和您一样,我也在每篇文章中学习新知识。
在上一篇文章中,我们通过向 DOM 元素添加事件来扩展 Aprender 的功能。在本文中,我们将迈出第一步,探索 UI 框架中最核心的部分——动态更改 DOM 元素。
和往常一样,我们将从一些示例代码开始。给定下面两个代表 UI 的对象,我们如何从旧 UI 转换为新 UI?
{ // old dom
type: "div",
attrs: {},
children: [
"Search",
{
type: "p",
attrs: {},
children: []
}
]
}
{ // new dom
type: "div",
attrs: {},
children: [
"No Search",
{
type: "span",
attrs: {},
children: []
}
]
}
任何 UI 框架的核心(或者说是秘诀,如果你愿意的话)都在于如何检测和执行变更。在一个典型的应用程序中,底层框架会花费大量时间来弄清楚发生了哪些变更、如何变更以及如何应用这些变更。例如,React 代码库中的这个issue就对此进行了详细的技术讨论。市面上有很多 虚拟 DOM 实现,为了构建我们自己的虚拟 DOM,我们将从simple-virtual-dom中汲取灵感。
那么...发生了什么变化?
实现的“什么改变了?”部分从以下函数开始:
function diff(oldTree, newTree) {
const patches = {};
const index = 0;
performDiff(oldTree, newTree, patches, index)
return patches;
}
参数oldTree
和newTree
是 UI 的对象表示——旧状态和新状态。在我们的例子中,我们将文本从 更改为"Search"
,"No Search"
并将段落元素更改为 span 元素。patches
对象存储两个状态之间的差异,当我们使用 发现更改时,它将被填充performDiff
。我们将在 diff 过程中进行递归调用,因此index
它充当计数器来跟踪当前的 diff 迭代。最后,我们返回patches
对象。它稍后将用于进行更改。让我们看一下performDiff
:
function performDiff(oldTree, newTree, patches, index) {
const currentPatch = [];
if (newTree === undefined) {
// we do nothing here because the final else statement will deal with it
} else if (typeof oldTree === 'string' && typeof newTree === 'string') {
if (oldTree !== newTree) {
currentPatch.push({
type: 'TEXT',
content: newTree
})
}
} else if (oldTree.type === newTree.type) {
diffChildren(oldTree.children, newTree.children, patches, index)
} else {
currentPatch.push({
type: 'REPLACE',
node: newTree
})
}
if (currentPatch.length) {
patches[index] = currentPatch
}
}
performDiff
当我们对任何子代进行 diff 操作时,都会递归调用这个函数,因此它currentPatch
会保存属于当前迭代的更改。该函数的大部分工作是由一系列 if 语句完成的,这些语句源于以下问题:
我们是否有新的 DOM 树/元素可以进行比较?
如果没有,我们什么也不做,因为 if 语句的 else 子句会处理这个问题。
我们正在比较文本节点吗?
如果我们处理的是文本节点,则只有当文本不同时才会进行更改。此更改通过一个对象记录,该对象包含有关更改类型及其相关内容的信息。
我们有必要比较孩子吗?
这就是递归乐趣的开始。diffChildren
函数如下:
function diffChildren(oldChildren, newChildren, patches, index) {
oldChildren.forEach((oldChild, idx) => {
index++
performDiff(oldChild, newChildren[idx], patches, index)
})
}
这很简单。其他框架之所以能在这方面脱颖而出,是因为它们必须考虑一系列问题。例如,如果所有子元素都没有改变,有没有办法跳过这个调用?比较子元素的最有效方法是什么?
我们是在比较不同的元素吗?
如果我们要比较两个完全不同的 DOM 元素,最简单的做法就是用新的替换旧的。
大多数 UI 框架的算法都可以归结为类似的问题列表。
在我们的例子中,示例代码将沿着子比较路线进行,因此让我们逐步介绍一下:
首先,diffChildren
接收一个由我们要比较的元素组成的子元素数组。对于每个子元素,它都会递归调用performDiff
。然而,在执行此操作之前,它会先递增index
计数器。在我们的示例中,它从 递增0
到1
。
我们调用performDiff
函数,传入参数“Search”和“No Search”作为比较元素。由于比较的是文本节点,因此我们创建了以下对象:
{
type: "TEXT",
content: "No Search"
}
并将其存储在currentPatch
每次调用时都会初始化的数组中performDiff
。该数组会跟踪所有需要进行的更改,如果有更改,则在函数结束时将它们赋值给用作键的patches
对象。index
我们对第二个孩子重复这个过程,一旦performDiff
它完成工作,它就给我们留下了以下对象:
{
1: [
{
type: "TEXT",
content: "No Search"
}
],
2: [
{
type: "REPLACE",
node: {
type: "span",
attrs: {},
children: []
}
}
]
}
函数返回的对象diff
代表了我们想要对 UI 进行的更改。您可以将其视为更新过程的第一阶段。在第二阶段,我们将这些更改应用到 DOM。这两个步骤与 React 的操作方式类似。
我们将开始使用这两个函数应用我们的更改:
function patch(rootDomNode, patches) {
const index = 0;
performPatches(rootDomNode, patches, index)
}
function performPatches(node, patches, index) {
const currentPatches = patches[index];
if (node.childNodes) {
node.childNodes.forEach(node => {
index++
performPatches(node, patches, index)
});
}
if (currentPatches) {
applyPatches(node, currentPatches)
}
}
这里的工作流程应该很熟悉。patch
获取正在更新的 DOM 元素,然后performPatches
使用更改和计数器进行调用。performPatches
我们首先对子元素执行任何更改,然后再对目标元素进行更改。
function applyPatches(node, currentPatches) {
currentPatches.forEach(patch => {
switch (patch.type) {
case 'TEXT': {
if (node.textContent) {
node.textContent = patch.content
}
break;
}
case 'REPLACE': {
const newNode = render(patch.node);
node.parentNode.replaceChild(newNode, node);
break;
}
}
})
}
简单的 switch case 语句是我们更新过程第二阶段的核心。替换元素时,我们调用 Aprenderrender
函数来创建 DOM 元素。
就是这样!现在,通过diff
和patch
函数,我们可以更新 DOM 元素了。如果我们要把它写成一个合适的应用程序,它应该是这样的:
const aprender = require('aprender');
const oldTree = aprender.createElement('div', {
children: ['Search', aprender.createElement('p')]
}
);
const newTree = aprender.createElement('div', {
children: ['No Search', aprender.createElement('span')]
}
);
const root = aprender.render(oldTree)
aprender.mount(root, document.getElementById('app'))
const diff = aprender.diff(oldTree, newTree);
setTimeout(() => {
aprender.patch(root, diff);
}, 5000)
概括
我们的算法只不过是一系列条件检查。如果你深入研究 UI 框架更新机制的核心,你会发现它们也是一样的。然而,我们的实现仍然缺少一个基本要素——自动执行更新并响应数据变化的能力。我们必须使用它setTimeout
来触发变更检测过程。我们将在下一篇文章中解决这个问题。