前端 JS 如何高效对比两个“树形对象”?增删改,还有移!

1,262 阅读12分钟

一、前言

image001.png

在前端开发中,比较两个 JS 对象的差异是一个常见的需求,当前有许多第三方库可以实现这一功能,例如 microdiffdeep-object-diff 等。借助这些库我们能够轻易识别出两个对象之间的三种主要变化:属性的增加、属性的删除以及属性值的更新。对于大多数应用场景来说,这些库提供的功能已经足够满足我们的需求。然而,在 JS 对象中存在一种特殊类型——树形对象,树形对象通常用于表示具有层级结构的数据,例如组织结构、文件系统、DOM 树等内容。在比较树形对象时,除了属性值的变化,我们更关注的是节点级别的变化,包括节点的增加、删除、更新,以及一个更为特殊的情况——节点的移动,即节点在树中的位置发生了变化。例如,当前存在两个树形对象,oldTreeObject 和 newTreeObject:

const oldTreeObject = {
  id: "1",
  value: "a",
  children: [
    { id: "2", value: "b", children: [] },
    {
      id: "3",
      value: "c",
      children: [
        { id: "4", value: "d", children: [] },
        { id: "5", value: "e", children: [] },
        { id: "6", value: "f", children: [] },
      ],
    },
  ],
};

const newTreeObject = {
  id: "1",
  value: "a",
  children: [
    { id: "7", value: "g", children: [] },
    {
      id: "3",
      value: "cc",
      children: [
        { id: "5", value: "e", children: [] },
        { id: "4", value: "d", children: [] },
      ],
    },
    { id: "6", value: "f", children: [] },
  ],
};

当我们使用 microdiff 库基于属性值变化进行比较的话会得到 9 项差异,但是对于树形对象而言,当我们考虑到树形结构的特点并引入“节点移动”这一概念后,我们其实并没有必要涉及这么多的变化,仅 5 项差异就能表示两者的差别,如下所示:

// microdiff 对比结果
const result1 = [
  { path: ["children", 0, "id"], type: "CHANGE", value: "7", oldValue: "2" },
  { path: ["children", 0, "value"], type: "CHANGE", value: "g", oldValue: "b" },
  { path: ["children", 1, "value"], type: "CHANGE", value: "cc", oldValue: "c" },
  { path: ["children", 1, "children", 0, "id"], type: "CHANGE", value: "5", oldValue: "4" },
  { path: ["children", 1, "children", 0, "value"], type: "CHANGE", value: "e", oldValue: "d" },
  { path: ["children", 1, "children", 1, "id"], type: "CHANGE", value: "4", oldValue: "5" },
  { path: ["children", 1, "children", 1, "value"], type: "CHANGE", value: "d", oldValue: "e" },
  { path: ["children", 1, "children", 2], type: "REMOVE", oldValue: { id: "6", value: "f", children: [] } },
  { path: ["children", 2], type: "CREATE", value: { id: "6", value: "f", children: [] } },
];
// 期望得到的对比结果
const treeDiffResult = [
  { type: "delete", path: ["children", 0], id: "2", value: "b" }, // 删除
  { type: "add", path: ["children", 0], id: "7", value: "g" }, // 新增
  { type: "update", path: ["children", 1],  id: "3", value: "cc", oldValue: "c" }, // 更新
  { type: "move", path: ["children", 1, "children", 1], id: "4", value: "d" }, // 移动(同层)
  { type: "move", path: ["children", 2], id: "6", value: "f" }, // 移动(跨层)
]

在前端开发中,我们最为熟悉的树形对象 diff 算法就是由 Vue/React 等框架提出的虚拟 DOM diff 算法,这些算法对同层级 DOM 节点进行对比,得到节点的增删改以及最小数量的节点移动,从而优化渲染性能。受此启发,我们设计了一种支持跨层级对比的树形对象对比算法,称为 TreeObjectDiff,这种算法不仅能够识别节点的增加、删除、更新,还能识别节点的同层级和跨层级的移动,这样更准确地描述了两个树形对象之间的差异。

💡 注意本文中所提及的“同层级”概念特指父节点相同的节点,而不是距离根节点的层级数相同的节点;“跨层级”概念特指父节点不相同的节点。

二、前置知识

🏷️ 推荐阅读:Vue3中的diff算法——diff算法的前4步处理 + Vue3中的diff算法——乱序处理

Tree Object Diff 算法是受 Vue3 虚拟 DOM Diff 算法启发并进行改进,那么我们就先简单来了解一下 Vue3 虚拟 DOM Diff 算法。该算法主要是同层级之间的 DOM 节点进行 diff 对比,由于是同层级对比,每一轮对比可以简单视为两个节点数组在进行对比,其主要分为 5 个步骤:

  1. 自前向后比对,如果节点相同则继续,反之则停止进入下一步;
  2. 自后向前比对,如果节点相同则继续,反之则停止进入下一步;
  3. 前后对比节点完毕,旧节点均完成对比,但新节点多于旧节点,则剩余都是新增节点;
  4. 前后对比节点完毕,新节点均完成对比,但新节点少于旧节点,则剩余都是删除节点;
  5. 最后就是对剩余不确定的节点再进行对比,通过应用最长递增子序列算法找到最小的更改序列;

那么什么是最长递增子序列呢?假设我们有如下两个数组,

  • 旧数组 [1, 2, 3, 4, 5, 6]
  • 新数组 [1, 3, 2, 4, 6, 5]

对于新数组而言,我们可以找到非常多的递增子序列,例如 “16”、“246”、“1245”、“1246”、“1346”、“1345” 等,这些序列的值是从小到大增长的。对于递增子序列“1245”,这意味着1245无需变动,只需变动36两个内容即可完成从旧数组到新数组的变化。这也就意味着当我们的递增子序列越长,我们所需要的移动次数就越少,因此基于最长递增子序列算法我们能够找到最小的移动次数。

三、TreeObjectDiff 算法

3.1 数据结构

算法输入为两个树形对象,其中 id 属性为节点的唯一标识,children 属性存储子节点信息,其余为节点属性值内容:

type TreeNode<TValues> = { id: ID; children?: TreeNode<TValues>[] } & TValues;
type Tree<TValues> = TreeNode<TValues>;

算法主要对比节点的 5 种类型的变更:

enum CHANGE_TYPE {
  Added = "added",
  Deleted = "deleted",
  Moved = "moved",
  Updated = "updated",
  Unchanged = "unchanged",
}
  1. 节点新增(added),即对应 id 的节点不在旧树中存在只在新树中存在;
  2. 节点删除(deleted),即对应 id 的节点不在新树中存在只在旧树中存在;
  3. 节点移动(moved),区分为两种情况:
    • 跨层移动,即节点的父节点发生改变,则认为是发生了跨层移动;
    • 同层移动,即节点的父节点保持一致,但相应的位置发生了变更;
  4. 节点值更新(updated),即对应 id 的节点在新旧树中均存在,但属性值不同;
  5. 节点无变动(unchanged),即除上述情况之外的节点;

📌 节点移动说明:

  1. 跨层移动。仅对相同节点在旧树和新树下的父节点是否保持一致进行比较,如果发现父节点不一致,则认为该节点含其子节点一起发生了跨层移动,但是注意子节点不会标注移动。
  2. 同层移动。同层移动这里所谓的位置发生变更,并不是指实际索引值变了,而是去除同层级下新增、删除和跨层移动等节点,剩余节点最少数量变更可从旧树变成新树,这些节点认为发生了同层移动,也就是索引变更并不一定标注动,只会对最小变更序列标注。

节点移动的同时也可以进行节点值更新,因此最终每个节点主要可能有 6 种情况的变更:

type Change = [CHANGE_TYPE.Unchanged] | [CHANGE_TYPE.Added] | [CHANGE_TYPE.Deleted] | [CHANGE_TYPE.Moved] | [CHANGE_TYPE.Updated] | [CHANGE_TYPE.Moved, CHANGE_TYPE.Updated];

对比结果输出主要包含两个内容,isChange 用于标注是否发生变更,diffTree 为对比树结果,包含了新旧树中的所有节点,以及各个节点的变更、新旧值、新旧路径等内容,其数据结构如下:

type DiffTreeNode<TValues> = {
  id: ID;
  change: Change;
  detail: {
    newNode?: { id: ID } & TValues;
    newPath?: string[];
    oldNode?: { id: ID } & TValues;
    oldPath?: string[];
    isCross?: boolean;
  };
  children: DiffTreeNode<TValues>[];
};
type DiffTree<TValues> = [DiffTreeNode<TValues>] | [DiffTreeNode<TValues>, DiffTreeNode<TValues>];
type DiffResult<TValues> = { diffTree: DiffTree<TValues>; isChange: boolean };

3.2 算法实现

完整源码请阅读 👉 tree-object-diff

image003.png

step1. 找到删除节点

image005.png

首先遍历旧树的节点(不含根节点),判断每一个旧树中的节点是否在新树中存在,节点根据 id 来唯一识别,如果不存在相同 id 的节点,则会将则将该旧树节点标记为删除节点

step2. 找到新增节点,并对比节点值

image007.png

在新树中遍历每个节点(不含根节点),检查它们是否在旧树中存在,如果某个节点在旧树中不存在,则将其标记为新增节点。同时,在遍历过程中还会对新旧树中均存在的节点比较其值是否发生变化,如果发生变化会暂时标记为更新节点

step3. 找到跨层移动节点

image009.png

对于新树中除新增节点外的其他节点,检查它们的父节点在新旧树中是否一致,当节点的父节点不一致时,我们认为其一定发生了移动,且为跨层移动,并根据前面检查的值是否变化情况标记为移动节点移动&更新节点;如果父节点一致,我们将其记录下其在父节点下对应的索引值,用于后续同层移动的判断。

step4. 找到同层移动节点

image011.png

在同层移动的判断中,我们并不直接根据新旧树中的索引值进行判断,这样会导致有很多无效变更,我们对于同层移动只会找到最小数量的变更已满足变更情况。根据前面记录下的索引号,我们使用最长递增子序列(LIS)算法来确定哪些节点在同层中移动。根据 LIS 结果,如果节点无需移动,则根据值变化情况标记为更新节点无变化节点;如果节点需要移动,则根据值变化情况标记为移动节点移动&更新节点。我们的示例中 id 为 3 的节点下有 3 个可能同层移动的节点,id 分别为 4、5 和 7,通过 LIS 算法我们知道只需要移动 id 为 5 的节点即可从旧树变化为新树。

step5. 根节点判断

image013.png

在结束第四步后,我们已经完成了新旧树除根节点之外的所有节点的对比,最后我们来进行新旧树根节点的对比,判断旧树根节点是否删除以及判断新树根节点是否新增,如果新旧树根节点在新旧树中均存在,则判断更新和移动的情况。

step6. 构建diff结果树

image015.png

最后,基于得到的所有节点的变化情况会将其扩展为完整的 diff 结果树,在 diff 结果树中删除的节点同样会挂在其父节点上。注意,如果旧树根节点标记为删除节点时,最后生成的diff结果树会有两棵,一棵以旧树根节点为根节点,一棵以新树根节点为根节点。通过以上步骤,TreeObjectDiff能够有效地比较两棵树的结构和内容,识别出节点的新增、删除、移动和更新等变化。

四、使用与示例

安装

npm i tree-object-diff

使用

import { diff } from "tree-object-diff";

const oldTreeObject = {
  id: "root",
  value: "root",
  children: [{ id: "1", value: "a", children: [] }, { id: "2", value: "b", children: [] }],
};
const newTreeObject = {
  id: "root",
  value: "root",
  children: [{ id: "2", value: "bb", children: [] },{ id: "1", value: "a", children: [] }],
};

const diffResult = diff(oldTreeObject, newTreeObject);

每个节点的标识符是 id,用于检测添加、删除和移动。根据 3.1 小节中 Tree 的数据结构,节点值格式不确定,并不是统一使用 value 属性,例如:

const treeData1 = {
  id: "1",
  value: "a",
  children: [{ id: "2", value: "b", children: [] }],
};
const treeData2 = {
  id: "1",
  name: "a",
  age: 18,
  children: [{ id: "2", name: "b", age: 20, children: [] }],
};

为了比较节点值是否已更改,目前默认的判断节点值是否一致的方法是通过 JSON.stringify() 将节点对象(不包括 children 字段)字符串化并判断是否相同来比较结果。当然,我们也提供自定义判断节点值是够发生变化的方法,使用方式如下:

const sameNodeValue = (oldNode, newNode) => oldNode.curValue === newNode.curValue;
const diffDetail = diff(oldTree, newTree, { valueEquality: sameNodeValue });

使用示例如下:

const oldTreeObject = {
  id: "1",
  value: "a",
  children: [
    { id: "2", value: "b", children: [] },
    {
      id: "3",
      value: "c",
      children: [
        { id: "4", value: "d", children: [] },
        { id: "5", value: "e", children: [] },
        { id: "6", value: "f", children: [] },
        { id: "7", value: "g", children: [] },
      ],
    },
  ],
};

const newTreeObject = {
  id: "1",
  value: "a",
  children: [
    { id: "8", value: "h", children: [] },
    {
      id: "3",
      value: "cc",
      children: [
        { id: "5", value: "e", children: [] },
        { id: "4", value: "d", children: [] },
        { id: "7", value: "g", children: [] },
      ],
    },
    { id: "6", value: "f", children: [] },
  ],
};

const { isChange, diffTree } = diff(oldTreeObject, newTreeObject);

/* ------------- 结果 ------------- */
const isChange = true;
const diffTree = [
  {
    id: "1",
    change: ["unchanged"],
    detail: {
      newValue: { id: "1", value: "a" }, newPath: [],
      oldValue: { id: "1", value: "a" }, oldPath: [],
    },
    children: [
      {
        id: "2",
        change: ["deleted"],
        detail: {
          oldValue: { id: "2", value: "b" }, oldPath: ["children", "0"],
        },
        children: [],
      },
      {
        id: "8",
        change: ["added"],
        detail: {
          newValue: { id: '8', value: "h" }, newPath: ["children", "0"],
        },
        children: [],
      },
      {
        id: "3",
        change: ["updated"],
        detail: {
          newValue: { id: "3", value: "cc" }, newPath: ["children", "1"],
          oldValue: { id: "3", value: "c" }, oldPath: ["children", "1"],
        },
        children: [
          {
            id: "5",
            change: ["moved"],
            detail: {
              newValue: { id: "5", value: "e" }, newPath: ["children", "1", "children", "0"],
              oldValue: { id: "5", value: "e" }, oldPath: ["children", "1", "children", "1"],
              isCross: false,
            },
            children: [],
          },
          {
            id: "4",
            change: ["unchanged"],
            detail: {
              newValue: { id: "4", value: "d" }, newPath: ["children", "1", "children", "1"],
              oldValue: { id: "4", value: "d" }, oldPath: ["children", "1", "children", "0"],
            },
            children: [],
          },
          {
            id: "7",
            change: ["unchanged"],
            detail: {
              newValue: { id: "7", value: "g" }, newPath: ["children", "1", "children", "2"],
              oldValue: { id: "7", value: "g" }, oldPath: ["children", "1", "children", "3"],
            },
            children: [],
          },
        ],
      },
      {
        id: "6",
        change: ["moved"],
        detail: {
          newValue: { id: "6", value: "f" }, newPath: ["children", "2"],
          oldValue: { id: "6", value: "f" }, oldPath: ["children", "1", "children", "2"],
          isCross: true,
        },
        children: [],
      },
    ],
  },
];

五、总结

TreeObjectDiff 算法通过跨层级的树形对象对比,提供了一种更精确、更全面的方法来描述两个树形对象之间的差异,其具有以下特点:

  • 支持基于唯一标识符"id"进行跨层级比较;
  • 支持对比节点的添加、删除、移动和值更新;
  • 支持自定义函数进行节点值进行比较;
  • 轻量级依赖库,无需其他依赖项;
  • 提供了 TypeScript 支持,完全兼容;
  • 提供了全面详细的对比结果,并以树形结果返回

TreeObjectDiff 算法对于需要处理复杂树形结构数据提供了对比能力,欢迎大家安装使用,如果有更好的方法或问题也可以评论或 issues 讨论。