原文: A blazing-fast algorithm to transform one DOM tree into another
包括移动操作,以及wrapping和解wrapping节点
对我来说,这个主题有点像 Frontend 开发的圣杯。小部件(组件)的标记可能相当复杂。状态变化会触发多个操作,从而彻底改变当前树。
开发人员可以根据自己的技术水平,以智能或不那么智能的方式手动转换 DOM。
好消息是我们可以通过一组最少的可预测操作,完全自动化地改变 DOM 树。我们还能以极快的速度计算所需的 "增量更新(delta-updates)"。
我们不会直接使用真实DOM,而是使用一个抽象层(VDom)。代码也可以在 Workers 或 Node.js 中运行。
1.一般假设
- 每个树节点都需要有唯一的标识符(否则,要识别移动过的节点就太费事了)
- 两棵树的顶层标识符是相同的(范围界定)
- 我们希望支持以下操作:
- 添加节点
- 插入节点
- 删除所有子节点
- 删除节点
- 更新节点(属性、CSS 选择器、样式)
- DOM树可以以任何方式改变。算法不知道变化的历史,因此必须只通过获取新旧 DOM 树为我们提供最小的操作集。
- 算法必须以人脑易于理解和预测的顺序为我们提供分隔符
- 只有在 "真实" 索引发生变化的情况下,节点才能在同一父节点内移动。例如按钮 2 是容器的第一个子节点。我们在它前面插入一个新的按钮 1。现在,按钮 2 的索引从 0 变为 1,但它不需要
moveNode
操作。 - 插入的节点可以包含已移动的节点(包裹)。只创建新的部分。
-
- 要删除的节点可能包含被移动到其他地方的节点(解包)。移动的节点不能丢失
2.为什么移动操作很重要?
显而易见,将重型部件移动到网站的不同位置比重新绘制更快。其他用例:
试想一下,你使用的 CardLayout 或 TabContainer 包含大量 DOM 树项。现在,你希望在切换卡片时有一个漂亮的 CSS 过渡,同时保持 DOM 的最小化。
使用包裹和解除包裹节点实现 CardLayout 过渡
- 表格的父容器(div)使用
overflow:hidden;
。 - 如果你想从右侧 "滑入",可以插入一个新的包装 div,其宽度是卡片宽度的两倍
- 将表格移到包装器中(不重新生成整个 DOM 树)
- 将图表插入包装器中,紧靠表格
- 执行所需的 CSS 向左过渡。
- 用图表替换封装 div → 解包裹(不重新生成图表实例)
当然,你也可以通过手动修改 DOM 来实现这一点。或者,你只需轻松描述所需的 4 种树状态,然后让该算法为你处理其余部分:
const tree1 =
{id: 'card-container-1', cn: [
{id: 'table-1'}
]};
const tree2 =
{id: 'card-container-1', cn: [
{id: 'wrapper-1', cn: [
{id: 'table-1'},
{id: 'chart-1'}
]}
]};
const tree3 =
{id: 'card-container-1', cn: [
{id: 'wrapper-1', cls: ['slide-left'], cn: [
{id: 'table-1'},
{id: 'chart-1'}
]}
]};
const tree4 =
{id: 'card-container-1', cn: [
{id: 'chart-1'}
]};
移动操作的另一个用例是无限滚动:
vdom.cn.push(vdom.cn.shift()); // 将第一个 childNode 移动到最后。
3.算法输出的强大示例
import VdomHelper from '../../../../src/vdom/Helper.mjs';
let deltas, output, vdom, vnode;
StartTest(t => {
t.it('Wrapping nodes multiple times', t => {
vdom =
{id: 'neo-container-1', cn: [
{id: 'neo-component-1'},
{id: 'neo-component-2'},
{id: 'neo-component-3'},
{id: 'neo-component-4'},
{id: 'neo-component-5'},
{id: 'neo-component-6'}
]};
vnode = VdomHelper.create(vdom);
vdom =
{id: 'neo-container-1', cn: [
{id: 'neo-wrapper-1', cn: [ // single wrapper
{id: 'neo-component-1', cls: ['foo1']}
]},
{id: 'neo-wrapper-2', cn: [ // double wrapper
{id: 'neo-wrapper-3', cn: [
{id: 'neo-component-2', cls: ['foo2']}
]}
]},
{id: 'neo-wrapper-4', cn: [ // wrapping multiple nodes
{id: 'neo-component-3', cls: ['foo3']},
{id: 'neo-component-4', cls: ['foo4']}
]},
{id: 'neo-wrapper-5', cn: [ // nested wrapping
{id: 'neo-component-5', cls: ['foo5']},
{id: 'neo-wrapper-6', cn: [
{id: 'neo-component-6', cls: ['foo6']}
]}
]}
]};
output = VdomHelper.update({vdom, vnode}); deltas = output.deltas; vnode = output.vnode;
t.is(deltas.length, 16, 'Count deltas equals 16');
t.isDeeplyStrict(deltas, [
{action: 'insertNode', index: 0, parentId: 'neo-container-1', outerHTML: '<div id="neo-wrapper-1"></div>'},
{action: 'moveNode', id: 'neo-component-1', index: 0, parentId: 'neo-wrapper-1'},
{ id: 'neo-component-1', cls: {add: ['foo1']}},
{action: 'insertNode', index: 1, parentId: 'neo-container-1', outerHTML: '<div id="neo-wrapper-2"><div id="neo-wrapper-3"></div></div>'},
{action: 'moveNode', id: 'neo-component-2', index: 0, parentId: 'neo-wrapper-3'},
{ id: 'neo-component-2', cls: {add: ['foo2']}},
{action: 'insertNode', index: 2, parentId: 'neo-container-1', outerHTML: '<div id="neo-wrapper-4"></div>'},
{action: 'moveNode', id: 'neo-component-3', index: 0, parentId: 'neo-wrapper-4'},
{ id: 'neo-component-3', cls: {add: ['foo3']}},
{action: 'moveNode', id: 'neo-component-4', index: 1, parentId: 'neo-wrapper-4'},
{ id: 'neo-component-4', cls: {add: ['foo4']}},
{action: 'insertNode', index: 3, parentId: 'neo-container-1', outerHTML: '<div id="neo-wrapper-5"><div id="neo-wrapper-6"></div></div>'},
{action: 'moveNode', id: 'neo-component-5', index: 0, parentId: 'neo-wrapper-5'},
{ id: 'neo-component-5', cls: {add: ['foo5']}},
{action: 'moveNode', id: 'neo-component-6', index: 0, parentId: 'neo-wrapper-6'},
{ id: 'neo-component-6', cls: {add: ['foo6']}}
], 'Deltas got created successfully');
});
});
为了使逻辑简单明了,第一棵树只是一个容器,其中有 6 个组件作为直接子节点。
提示 1:我们正在用类似 JSON 的结构来描述 DOM → 嵌套对象和数组
提示 2:没有标记属性的对象就是 div。
提示 3:我们调用 VdomHelper.create(vdom)
生成一个 vnode(树),然后创建第二个树。之后,我们将调用 VdomHelper.update({vdom, vnode})
来获取三角洲,理想情况下,三角洲将通过 requestAnimationFrame()
传输。
让我们来看看新的树。我们正在增加每个组件的复杂性。
- 所有组件都将获得一个新的 CSS 选择器,以显示它们已被解析用于
updateNode
操作。 - 第一个组件被包装成一个 div。
- 第二个组件被包装成一个嵌套 div。
- 组件 3 和组件 4 被移动到同一个新的包装 div 中
- 组件 5 被封装到一个 div 中,并有第二个封装 div 作为同级,其中包含组件 6。
由此产生的 delta-updates
也以同样的方式运行。
提示4:delta 的默认操作是 updateNode
,我们不需要编写。
- 我们将插入新的封装 div
neo-wrapper-1
- 我们将组件 1 移入其中
- 为组件 1 添加 CSS 选择器
- 我们同时插入两个包装 div →
neo-wrapper-1
和neo-wrapper-2
。 - 我们将组件 2 移到内包装中
- 我们为组件 2 添加 CSS 选择器
- 插入新的包装 div
neo-wrapper-3
- 我们将组件 3 移入其中
- 我们将在组件 3 中添加 CSS 选择器
- 将组件 4 移入其中
- 为组件 4 添加 CSS 选择器
- 我们同时插入两个包装 div →
neo-wrapper-4
和neo-wrapper-5
。 - 我们将组件 5 移到
neo-wrapper-4
中。 - 将组件 6 移至
neo-wrapper-5
中。 - 我们在组件 6 中添加 CSS 选择器
==> 简单明了,易于理解
接下来,让我们把新树变回旧树:
// ...
vdom =
{id: 'neo-container-1', cn: [
{id: 'neo-component-1'},
{id: 'neo-component-2'},
{id: 'neo-component-3'},
{id: 'neo-component-4'},
{id: 'neo-component-5'},
{id: 'neo-component-6'}
]};
output = VdomHelper.update({vdom, vnode}); deltas = output.deltas; vnode = output.vnode;
t.is(deltas.length, 16, 'Count deltas equals 16');
t.isDeeplyStrict(deltas, [
{action: 'moveNode', id: 'neo-component-1', index: 0, parentId: 'neo-container-1'},
{ id: 'neo-component-1', cls: {remove: ['foo1']}},
{action: 'moveNode', id: 'neo-component-2', index: 1, parentId: 'neo-container-1'},
{ id: 'neo-component-2', cls: {remove: ['foo2']}},
{action: 'moveNode', id: 'neo-component-3', index: 2, parentId: 'neo-container-1'},
{ id: 'neo-component-3', cls: {remove: ['foo3']}},
{action: 'moveNode', id: 'neo-component-4', index: 3, parentId: 'neo-container-1'},
{ id: 'neo-component-4', cls: {remove: ['foo4']}},
{action: 'moveNode', id: 'neo-component-5', index: 4, parentId: 'neo-container-1'},
{ id: 'neo-component-5', cls: {remove: ['foo5']}},
{action: 'moveNode', id: 'neo-component-6', index: 5, parentId: 'neo-container-1'},
{ id: 'neo-component-6', cls: {remove: ['foo6']}},
{action: 'removeNode', id: 'neo-wrapper-1'},
{action: 'removeNode', id: 'neo-wrapper-2'},
{action: 'removeNode', id: 'neo-wrapper-4'},
{action: 'removeNode', id: 'neo-wrapper-5'}
], 'Deltas got created successfully');
- 我们将组件 1 移回到
neo-container-1
中。 - 删除组件 1 的 CSS 选择器
- …
- 我们将把组件 6 移回
neo-container-1
中。 - 我们将移除组件 6 的 CSS 选择器
- 我们将删除
neo-wrapper-1
。 - 我们正在移除
neo-wrapper-2
。 - 我们正在移除
neo-wrapper-4
- 我们将删除
neo-wrapper-5
现在你可能会问:那么 neo-wrapper-3
和 neo-wrapper-6
呢?
因为它们的父节点(neo-wrapper-2
和 neo-wrapper-5
)已被移除,所以没什么可做的。
你可以在此处找到完整的测试案例。
4.我们需要多少次树的解析?
简答:只有 三次。
当我们比较新树和旧树中的 childNodes
数组时,可能会有一些节点只存在于两个数组中的一个。在这种情况下,我们需要知道某个节点是否存在于另一棵树的 "其他地方"(移动操作),或者它是否被添加(仅在新树中)或移除(仅在旧树中)。
在一棵树中搜索特定节点的存在可能会导致对整棵树的解析,因此我们一定要避免这种情况。
解决方法很简单:我们为两棵树创建展平 maps,其中包含作为键的节点 ID 和一个元信息对象。
/**
* Creates a flap map of the tree, containing ids as keys and infos as values
* @param {Object} config
* @param {Neo.vdom.VNode} config.vnode
* @param {Neo.vdom.VNode} [config.parentNode=null]
* @param {Number} [config.index=0]
* @param {Map} [config.map=new Map()]
* @returns {Map}
* {String} id vnode.id (convenience shortcut)
* {Number} index
* {String} parentId
* {Neo.vdom.VNode} vnode
*/
createVnodeMap(config) {
let {vnode, parentNode=null, index=0, map=new Map()} = config,
id = vnode?.id;
map.set(id, {id, index, parentNode, vnode});
vnode?.childNodes?.forEach((childNode, index) => {
this.createVnodeMap({vnode: childNode, parentNode: vnode, index, map})
});
return map
}
在元信息中,我们还存储了父节点(parentNode)引用,以便直接检查节点是否在同一个父节点中。
JavaScript Map 为我们提供了对每个键的直接访问,因此我们已经节省了大量的额外查询。
生成映射显然需要对整棵树进行解析。我们对两棵树都进行了解析。因此,我们已经使用了三次树解析中的两次。
现在你可能会想:
"怎么可能只剩下一次树解析就能创建我们的 delta 操作呢?"
5.研究算法
你可能还记得,在单元测试中,我们从 VdomHelper.update({vdom, vnode})
开始。让我们快速了解一下入口点:
/**
* Creates a Neo.vdom.VNode tree for the given vdom template and compares the new vnode with the current one
* to calculate the vdom deltas.
* @param {Object} opts
* @param {Object} opts.vdom
* @param {Object} opts.vnode
* @returns {Object|Promise<Object>}
*/
update(opts) {
let me = this,
vnode = me.createVnode(opts.vdom),
deltas = me.createDeltas({oldVnode: opts.vnode, vnode});
// Trees to remove could contain nodes which we want to re-use (move),
// so we need to execute the removeNode OPs last.
deltas = deltas.default.concat(deltas.remove);
let returnObj = {deltas, updateVdom: true, vnode};
return Neo.config.useVdomWorker ? returnObj : Promise.resolve(returnObj)
}
我们正在将传入的 vdom 对象转换为 vnode 对象。严格来说,这等同于另一次树形解析,但我将其排除在外是有充分理由的。你可以直接创建一棵 vnode 树来比较两棵语法相同的树。算法本身并不依赖它。
题外话:你可能想知道
vdom
和vnode
树有什么区别。两者都是嵌套数组和对象。vnode 树使用的语法更接近 DOM API。例如,我们使用cls:[], cn:[]
(较短),而className:[], childNodes:[
在 vnode 树中。使用两种不同树语法的主要原因是开发人员保护。在 JavaScript 中,数组和对象是通过引用存储的。如果开发人员将 vdom 子树链接到 vnode 子树,或反之亦然,就会发生不好的事情。
deltas = deltas.default.concat(deltas.remove)
因此,我们可以使用 "引用" 来进一步提高算法性能。我们将在 deltas.remove
中存储 removeAll
和 removeNode
操作,并在 deltas.default
中存储所有其他删除操作。如果我们将所有移除操作推到最后,我们就不再需要解析将被移除的子树,以检查是否有节点被移到了子树之外。
6. createDeltas() => 拉与推策略
我尽力缩短算法的核心部分。包括注释在内只有 80 行代码。让我们深入了解一下:
/**
* @param {Object} config
* @param {Object} [config.deltas={default: [], remove: []}]
* @param {Neo.vdom.VNode} config.oldVnode
* @param {Map} [config.oldVnodeMap]
* @param {Neo.vdom.VNode} config.vnode
* @param {Map} [config.vnodeMap]
* @returns {Object} deltas
*/
createDeltas(config) {
let {deltas={default: [], remove: []}, oldVnode, vnode} = config,
oldVnodeId = oldVnode?.id,
vnodeId = vnode?.id;
// Edge case: setting `removeDom: true` on a top-level vdom node
if (!vnode && oldVnodeId) {
deltas.remove.push({action: 'removeNode', id: oldVnodeId});
return deltas
}
if (vnode.static) {
return deltas
}
if (vnodeId !== oldVnodeId) {
throw new Error(`createDeltas() must get called for the same node. ${vnodeId}, ${oldVnodeId}`);
}
let me = this,
oldVnodeMap = config.oldVnodeMap || me.createVnodeMap({vnode: oldVnode}),
vnodeMap = config.vnodeMap || me.createVnodeMap({vnode}),
childNodes = vnode .childNodes || [],
oldChildNodes = oldVnode.childNodes || [],
i = 0,
indexDelta = 0,
len = Math.max(childNodes.length, oldChildNodes.length),
childNode, nodeInNewTree, oldChildNode;
me.compareAttributes({deltas, oldVnode, vnode, vnodeMap});
if (childNodes.length === 0 && oldChildNodes.length > 1) {
deltas.remove.push({action: 'removeAll', parentId: vnodeId});
return deltas
}
for (; i < len; i++) {
childNode = childNodes[i];
oldChildNode = oldChildNodes[i + indexDelta];
if (!childNode && !oldChildNode) {
break
}
// Same node, continue recursively
if (childNode && childNode.id === oldChildNode?.id) {
me.createDeltas({deltas, oldVnode: oldChildNode, oldVnodeMap, vnode: childNode, vnodeMap});
continue
}
if (oldChildNode) {
nodeInNewTree = vnodeMap.get(oldChildNode.id);
// Remove node, if no longer inside the new tree
if (!nodeInNewTree) {
me.removeNode({deltas, oldVnode: oldChildNode, oldVnodeMap});
i--;
continue
}
// The old child node got moved into a different not processed array. It will get picked up there.
if (childNode && vnodeId !== nodeInNewTree.parentNode.id) {
i--;
indexDelta++;
continue
}
}
if (childNode) {
me[oldVnodeMap.get(childNode.id) ? 'moveNode' : 'insertNode']({deltas, oldVnodeMap, vnode: childNode, vnodeMap})
}
}
return deltas
}
我们只会为根级调用生成一次 oldVnodeMap
和 vnodeMap
。所有递归调用都将在配置参数中获得这两个参数。我们还将传递 deltas
。
我将不深入讨论 compareAttributes()
(经典的 "diffing" => 检查已更改的属性、CSS 选择器和样式),因为它非常琐碎。你可以在此处了解一下。
在深入研究 childNodes
之前,我们将检查新树中是否有 0 个项目,而旧树中是否有多个项目。如果是,我们将创建一个 removeAll
delta。在 DOM 层上,这将导致element.innerHTML = '';
=>我们不需要检查直接的旧子代是否被移动到其他地方,因为它们可以在操作发生前被 "拉出"(我们将所有移除 delta 移动到最后)。
如果新旧树中的 childNode
具有相同的 id,我们将递归地钻取它。我们首先这样做,因为这是在树内移动的自然顺序。
为了理解 for
循环中的其他逻辑,让我们快速讨论一下我所说的 "Pull-In"(拉)和 "Push-Out"(推)策略。
如果我们在新的 childNodes
数组中发现了一个节点,而该节点并不存在于旧树的同一数组中,我们就会立即将其移入。需要创建的新节点也是如此。
我们还可以直接删除存在于旧树的 childNodes
数组中,但不在新树中的节点。好在我们可以通过平面地图轻松检查这种情况。请记住:移除脱元素会被移到最后。
如果我们在旧树的 childNodes
数组中找到一个节点,而这个节点并不存在于新树的 childNodes
数组中,我称之为 "Push-Out"(推) 策略。
强烈建议:不要这样做!
实际上,我在创建该算法的前一版本时就已经这样做了。虽然我们仍然能得到正确的三角洲,但对于人脑来说,要跟上正在发生的事情可能会变得非常困难。我们会跳转到一个完全不同的子树,在那里继续递归,然后可能会被多次推出。
好消息是:如果旧节点确实存在于新树的另一个 childNodes
数组中,这意味着它确实存在于一个尚未被解析的子树中。一旦算法到达那里,就会找到它并将其拉入。
7. removeNode()
/**
* @param {Object} config
* @param {Object} config.deltas
* @param {Neo.vdom.VNode} config.oldVnode
* @param {Map} config.oldVnodeMap
*/
removeNode(config) {
let {deltas, oldVnode, oldVnodeMap} = config,
delta = {action: 'removeNode', id: oldVnode.id},
{parentNode} = oldVnodeMap.get(oldVnode.id);
if (oldVnode.vtype === 'text') {
delta.parentId = parentNode.id
}
deltas.remove.push(delta);
NeoArray.remove(parentNode.childNodes, oldVnode)
}
在 DOM 层面,我们可以使用 element.remove()
,因此删除 delta 不需要 parentId
。
有一个例外:
<div><span>hello</span>world</div>
不在本文讨论范围内,但 "world "将是一个{vtype: 'text'}
。它不是一个真正的节点(标签),而是包裹在包含 id 的注释中。要找到它,我们需要parentId
。
我们添加了新的 delta,正如承诺的那样,我们将不再进一步深入 "删除树"。Pull-In(拉)。
但是,我们要做的是我们将从旧树的 childNodes
数组中删除节点。
这才是真正有趣的地方:
从一个 DOM 树过渡到下一个 DOM 树时,我们总是调用const {deltas, vnode} = VdomHelper.update({vdom, vnode});
意思是我们传递 vdom 和 vnode 树,然后得到 deltas 和新的 vnode 树。而不是旧的 vnode 树。
因为旧的节点树无论如何都会被删除,所以我们可以随意修改它。算法会大量使用它:每次操作都会使它与新的节点树更加相似,从而进一步提高后续操作的性能。
8. insertNode()
/**
* @param {Object} config
* @param {Object} config.deltas
* @param {Map} config.oldVnodeMap
* @param {Neo.vdom.VNode} config.vnode
* @param {Map} config.vnodeMap
*/
insertNode(config) {
let {deltas, oldVnodeMap, vnode, vnodeMap} = config,
details = vnodeMap.get(vnode.id),
{index} = details,
parentId = details.parentNode.id,
me = this,
movedNodes = me.findMovedNodes({oldVnodeMap, vnode, vnodeMap}),
outerHTML = me.createStringFromVnode(vnode, movedNodes);
deltas.default.push({action: 'insertNode', index, outerHTML, parentId});
// Insert the new node into the old tree, to simplify future OPs
oldVnodeMap.get(parentId).vnode.childNodes.splice(index, 0, vnode);
movedNodes.forEach(details => {
let {id} = details,
parentId = details.parentNode.id;
deltas.default.push({action: 'moveNode', id, index: details.index, parentId});
me.createDeltas({deltas, oldVnode: oldVnodeMap.get(id).vnode, oldVnodeMap, vnode: details.vnode, vnodeMap})
})
}
由于新节点可能包含移动过的节点,因此我们要识别顶层节点,只创建真正新的部分(如包裹节点)。
/**
* The logic will parse the vnode (tree) to find existing items inside a given map.
* It will not search for further childNodes inside an already found vnode.
* @param {Object} config
* @param {Map} [config.movedNodes=new Map()]
* @param {Map} config.oldVnodeMap
* @param {Neo.vdom.VNode} config.vnode
* @param {Map} config.vnodeMap
* @returns {Map}
*/
findMovedNodes(config) {
let {movedNodes=new Map(), oldVnodeMap, vnode, vnodeMap} = config,
id = vnode?.id;
if (id) {
let currentNode = oldVnodeMap.get(id)
if (currentNode) {
movedNodes.set(id, vnodeMap.get(id))
} else {
vnode.childNodes.forEach(childNode => {
if (childNode.vtype !== 'text') {
this.findMovedNodes({movedNodes, oldVnodeMap, vnode: childNode, vnodeMap})
}
})
}
}
return movedNodes
}
非常简单,因为我们有旧树的展平 maps。一旦找到节点,就停止钻取。
我们再次修改旧树的结构,以简化未来的操作。
对于每个移动的节点(新树中未处理的区域),我们都会再次调用 createDeltas()
。这是走树的 "自然 "方式,可以确保我们尊重移动节点的变化,以及新树或嵌套移动树中移动节点内插入节点的变化。递归会为我们处理这一点。
9. moveNode()
/**
* @param {Object} config
* @param {Object} config.deltas
* @param {Map} config.oldVnodeMap
* @param {Neo.vdom.VNode} config.vnode
* @param {Map} config.vnodeMap
*/
moveNode(config) {
let {deltas, oldVnodeMap, vnode, vnodeMap} = config,
details = vnodeMap.get(vnode.id),
{index, parentNode} = details,
parentId = parentNode.id,
movedNode = oldVnodeMap.get(vnode.id),
movedParentNode = movedNode.parentNode,
{childNodes} = movedParentNode;
if (parentId !== movedParentNode.id) {
// We need to remove the node from the old parent childNodes
// (which must not be the same as the node they got moved into)
NeoArray.remove(childNodes, movedNode.vnode);
let oldParentNode = oldVnodeMap.get(parentId);
if (oldParentNode) {
// If moved into a new parent node, update the reference inside the flat map
movedNode.parentNode = oldParentNode.vnode;
childNodes = movedNode.parentNode.childNodes
}
}
deltas.default.push({action: 'moveNode', id: vnode.id, index, parentId});
// Add the node into the old vnode tree to simplify future OPs.
// NeoArray.insert() will switch to move() in case the node already exists.
NeoArray.insert(childNodes, index, movedNode.vnode);
this.createDeltas({deltas, oldVnode: movedNode.vnode, oldVnodeMap, vnode, vnodeMap})
}
我们需要区分从不同父节点 "拉(Pull-In)"节点和同一数组内的索引移动。
就像在 insertNode()
和 removeNode()
中一样,我们正在修改旧树。
如果你再看一遍 createDeltas()
(第 6 节),索引的移动就会明白了。我们将不会对通过其他操作间接推送到新索引的节点进行移动操作。
10.总结和实际案例
现在,我们有了一个非常强大的工具,可以用我能想到的任何方式转换 DOM 树,并获得最小但功能强大的 DOM 更新。与neo.mjs项目的所有部分一样,该算法也是在 MIT 许可下发布的。
你可以查看 src/vdom/Helper.mjs 中的完整源代码。
行动号召:
如果你愿意接受挑战,我们非常欢迎你贡献更多测试用例。用你能想到的最 "卑鄙" 的方式将一棵树转化为另一棵树。
由于该算法通过了所有单元测试,它将被发布到下一个 neo.mjs 版本中。我们将在专用的 Worker 或 SharedWorker 中运行该算法,以遵循 "脱离主线程 "的模式。
由于我们以 "类 JSON" 的方式描述我们的树,因此当通过 postMessage() 传递时,它们可以很容易地被序列化/反序列化。
这也确保了状态的不变性。
一个仍在处理中的问题是添加将节点标记为static: true
的支持,以便将它们排除在createDeltas()
调用之外。问题解决后,我将在文章中添加注释。
既然你将这篇长文读到了最后,我很期待听到你的反馈意见!
致以最诚挚的问候,祝你编码愉快,
托比亚斯