[译]一种将一个DOM树转换为另一个DOM树的超快算法(vdom-diff)

121 阅读16分钟

原文: A blazing-fast algorithm to transform one DOM tree into another

包括移动操作,以及wrapping和解wrapping节点

对我来说,这个主题有点像 Frontend 开发的圣杯。小部件(组件)的标记可能相当复杂。状态变化会触发多个操作,从而彻底改变当前树。

开发人员可以根据自己的技术水平,以智能或不那么智能的方式手动转换 DOM。

好消息是我们可以通过一组最少的可预测操作,完全自动化地改变 DOM 树。我们还能以极快的速度计算所需的 "增量更新(delta-updates)"。

我们不会直接使用真实DOM,而是使用一个抽象层(VDom)。代码也可以在 Workers 或 Node.js 中运行。

1.一般假设

  1. 每个树节点都需要有唯一的标识符(否则,要识别移动过的节点就太费事了)
  2. 两棵树的顶层标识符是相同的(范围界定)
  3. 我们希望支持以下操作:
    • 添加节点
    • 插入节点
    • 删除所有子节点
    • 删除节点
    • 更新节点(属性、CSS 选择器、样式)
  4. DOM树可以以任何方式改变。算法不知道变化的历史,因此必须只通过获取新旧 DOM 树为我们提供最小的操作集。
  5. 算法必须以人脑易于理解和预测的顺序为我们提供分隔符
  6. 只有在 "真实" 索引发生变化的情况下,节点才能在同一父节点内移动。例如按钮 2 是容器的第一个子节点。我们在它前面插入一个新的按钮 1。现在,按钮 2 的索引从 0 变为 1,但它不需要 moveNode 操作。
  7. 插入的节点可以包含已移动的节点(包裹)。只创建新的部分。
    1. 要删除的节点可能包含被移动到其他地方的节点(解包)。移动的节点不能丢失

2.为什么移动操作很重要?

显而易见,将重型部件移动到网站的不同位置比重新绘制更快。其他用例:

试想一下,你使用的 CardLayout 或 TabContainer 包含大量 DOM 树项。现在,你希望在切换卡片时有一个漂亮的 CSS 过渡,同时保持 DOM 的最小化。

image.png 使用包裹和解除包裹节点实现 CardLayout 过渡

  1. 表格的父容器(div)使用overflow:hidden;
  2. 如果你想从右侧 "滑入",可以插入一个新的包装 div,其宽度是卡片宽度的两倍
  3. 将表格移到包装器中(不重新生成整个 DOM 树)
  4. 将图表插入包装器中,紧靠表格
  5. 执行所需的 CSS 向左过渡。
  6. 用图表替换封装 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() 传输。

让我们来看看新的树。我们正在增加每个组件的复杂性。

  1. 所有组件都将获得一个新的 CSS 选择器,以显示它们已被解析用于 updateNode 操作。
  2. 第一个组件被包装成一个 div。
  3. 第二个组件被包装成一个嵌套 div。
  4. 组件 3 和组件 4 被移动到同一个新的包装 div 中
  5. 组件 5 被封装到一个 div 中,并有第二个封装 div 作为同级,其中包含组件 6。

由此产生的 delta-updates 也以同样的方式运行。

提示4:delta 的默认操作是 updateNode ,我们不需要编写。

  1. 我们将插入新的封装 div neo-wrapper-1
  2. 我们将组件 1 移入其中
  3. 为组件 1 添加 CSS 选择器
  4. 我们同时插入两个包装 div → neo-wrapper-1 和 neo-wrapper-2 。
  5. 我们将组件 2 移到内包装中
  6. 我们为组件 2 添加 CSS 选择器
  7. 插入新的包装 div neo-wrapper-3
  8. 我们将组件 3 移入其中
  9. 我们将在组件 3 中添加 CSS 选择器
  10. 将组件 4 移入其中
  11. 为组件 4 添加 CSS 选择器
  12. 我们同时插入两个包装 div → neo-wrapper-4 和 neo-wrapper-5 。
  13. 我们将组件 5 移到 neo-wrapper-4 中。
  14. 将组件 6 移至 neo-wrapper-5 中。
  15. 我们在组件 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()调用之外。问题解决后,我将在文章中添加注释。

既然你将这篇长文读到了最后,我很期待听到你的反馈意见!

致以最诚挚的问候,祝你编码愉快,
托比亚斯