在开发 web 应用程序时,性能都是必不可少的话题。同时通用 web 应用程序离不开数据的增删改查,虽然用户大部分操作都是在查询,但是我们也不可以忽略更改数据对于系统的影响。于是个人写了一个业务数据比对库 diff-helper。方便开发者在前端提交数据到服务端时候去除不必要的信息,优化网络传输和服务端性能。

项目演进

任何项目都不是一触而就的,下面是关于 diff-helper 库的编写思路。希望能对大家有一些帮助。

简单对象比对

前端提交 JSON 对象数据时,很多情况下都是对象一层数据比对。在不考虑对象中还有复杂数据(嵌套对象和数组)的情况下,编写如下代码

// newVal 表示新数据,oldVal 表示老数据
const simpleObjDiff = ({
  newVal,
  oldVal,
}): Record<string, any> => {
  // 当前比对的结果
  const diffResult: Record<string, any> = {};

  // 已经检查过的数据项,可以优化遍历性能
  const checkedKeys: Set<string> = new Set();

  // 遍历最新的对象属性
  Object.keys(newVal).forEach((key: string) => {
    // 将新数据的 key 记录一下
    checkedKeys.add(key);

    // 如果当前新的数据不等于老数据,直接把新的比对结果放入
    if (newVal[key] !== oldVal[key]) {
      diffResult[key] = newVal[key];
    }
  });

  // 遍历之前的对象属性
  Object.keys(oldVal).forEach((key) => {
    // 如果已经检查过了,不在进行处理
    if (checkedKeys.has(key)) {
      return;
    }

    // 新的数据有,但是老数据没有可以认为数据已经不存在了
    diffResult[key] = null;
  });
  return diffResult;
};

此时我们就可以使用该函数进行一系列简单数据操作了。

const result = simpleObjDiff({
  newVal: {
    a: 1,
    b: 1,
  },
  oldVal: {
    a: 2,
    c: 2,
  },
});
// => 返回结果为
result = {
  a: 1,
  b: 1,
  c: null,
};

添加复杂属性比对

当前函数在面对对象内部有复杂类型时候就没办法判断了,即使没有更改的情况下,结果也会包含新数据属性,但是考虑到提交到服务端的表单数据一般不需要增量提交,所以这里试一试 JSON.stringify 。

诸如:

JSON.stringify("123");
// '"123"'

JSON.stringify(123);
// '123'

JSON.stringify(new Date());
// '"2022-11-29T15:16:46.325Z"'

JSON.stringify([1, 2, 3]);
// '[1,2,3]'

JSON.stringify({ a: 1, b: 2 });
// '{"b":2,"a":1}'

JSON.stringify({ b: 2, a: 1 });
// '{"b":2,"a":1}'

JSON.stringify({ b: 2, a: 1 }, ["a", "b"]);
// '{"a":1,"b":2}'

JSON.stringify({ b: 2, a: 1 }, ["a", "b"]) === JSON.stringify({ a: 1, b: 2 });
// true

对比上述结果,我们可以看到,JSON.stringify 如果不提供 replacer 可能会对对象类型数据的生成结果产生“误伤”。但从系统实际运行上来说,对象内部属性不太会出现排序变化的情况。直接进行以下改造:

const simpleObjDiff = ({
  newVal,
  oldVal,
}): Record<string, any> => {
  // ... 之前的代码

  // 遍历最新的对象数据
  Object.keys(newVal).forEach((key: string) => {
    // 当前已经处理过的对象 key 记录一下
    checkedKeys.add(key);

    // 先去查看类型,判断相同类型后再使用 JSON.stringify 获取字符串结果进行比对
    if (
      typeof newVal[key] !== typeof oldVal[key] ||
      JSON.stringify(newVal[key]) !== JSON.stringify(oldVal[key])
    ) {
      diffResult[key] = newVal[key];
    }
  });

  // ... 之前的代码
};

这时候尝试一下复杂数据类型

const result = simpleObjDiff({
  newVal: {
    a: 1,
    b: 1,
    d: [1, 2, 3],
  },
  oldVal: {
    a: 2,
    c: 2,
    d: [1, 2, 3],
  },
});
// => 返回结果为
result = {
  a: 1,
  b: 1,
  c: null,
};

添加自定义对象属性比对

如果只使用 JSON.stringify 话,函数就没有办法灵活的处理各种需求,所以笔者开始追加函数让用户自行适配。

const simpleObjDiff = ({
  newVal,
  oldVal,
  options,
}): Record<string, any> => {
  // ... 之前的代码

  // 获取用户定义的 diff 函数
  const { diffFun } = { ...DEFAULT_OPTIONS, ...options };

  // 判断当前传入数据是否是函数
  const hasDiffFun = typeof diffFun === "function";

  // 遍历最新的对象数据
  Object.keys(newVal).forEach((key: string) => {
    // 当前已经处理过的对象 key 记录一下
    checkedKeys.add(key);

    let isChanged = false;

    if (hasDiffFun) {
      // 把当前属性 key 和对应的新旧值传入从而获取结果
      const diffResultByKey = diffFun({
        key,
        newPropVal: newVal[key],
        oldPropVal: oldVal[key],
      });

      // 返回了结果则写入 diffResult,没有结果认为传入的函数不处理
      // 注意是不处理,而不是认为不变化
      // 如果没返回就会继续走 JSON.stringify
      if (
        diffResultByKey !== null &&
        diffResultByKey !== undefined
      ) {
        diffResult[key] = diffResultByKey;
        isChanged = true;
      }
    }

    if (isChanged) {
      return;
    }

    if (
      typeof newVal[key] !== typeof oldVal[key] ||
      JSON.stringify(newVal[key]) !== JSON.stringify(oldVal[key])
    ) {
      diffResult[key] = newVal[key];
    }
  });

  // ... 之前的代码
};

此时我们尝试传入 diffFun 来看看效果:

const result = simpleObjDiff({
  newVal: {
    a: [12, 3, 4],
    b: 11,
  },
  oldVal: {
    a: [1, 2, 3],
    c: 22,
  },
  options: {
    diffFun: ({
      key,
      newPropVal,
      oldPropVal,
    }) => {
      switch (key) {
        // 处理对象中的属性 a
        case "a":
          // 当前数组新旧数据都有的数据项才会保留下来
          return newPropVal.filter((item: any) => oldPropVal.includes(item));
      }
      // 其他我们选择不处理,使用默认的 JSON.stringify
      return null;
    },
  },
});
// => 结果如下所示
result = {
  a: [3],
  b: 11,
  c: null,
};

通过 diffFun 函数,开发者不但可以自定义属性处理,还可以利用 fast-json-stringify 来优化内部属性处理。该库通过 JSON schema 预先告知对象内部的属性类型,在提前知道数据类型的情况下,针对性处理会让 fast-json-stringify 性能非常高。

import fastJson from "fast-json-stringify";

const stringify = fastJson({
  title: "User Schema",
  type: "object",
  properties: {
    firstName: {
      type: "string",
    },
    lastName: {
      type: "string",
    },
    age: {
      description: "Age in years",
      type: "integer",
    },
  },
});

stringify({
  firstName: "Matteo",
  lastName: "Collina",
  age: 32,
});
// "{\"firstName\":\"Matteo\",\"lastName\":\"Collina\",\"age\":32}"

stringify({
  lastName: "Collina",
  age: 32,
  firstName: "Matteo",
});
// "{\"firstName\":\"Matteo\",\"lastName\":\"Collina\",\"age\":32}"

可以看到,利用 fast-json-stringify 同时无需考虑对象属性的内部顺序。

添加其他处理

这时候开始处理其他问题:

// 添加异常错误抛出
const invariant = (condition: boolean, errorMsg: string) => {
  if (condition) {
    throw new Error(errorMsg);
  }
};

// 判断是否是真实的对象
const isRealObject = (val: any): val is Record<string, any> => {
  return Object.prototype.toString.call(val) === "[object Object]";
};

simpleObjDiff = ({
  newVal,
  oldVal,
  options,
}: SimpleObjDiffParams): Record<string, any> => {
  // 添加错误传参处理
  invariant(!isRealObject(newVal), "params newVal must be a Object");
  invariant(!isRealObject(oldVal), "params oldVal must be a Object");

  // ...
  const { diffFun, empty } = { ...DEFAULT_OPTIONS, ...options };

  // ...

  Object.keys(oldVal).forEach((key) => {
    // 如果已经检查过了,直接返回
    if (checkedKeys.has(key)) {
      return;
    }
    // 设定空数据,建议使用 null 或 空字符串
    diffResult[key] = empty;
  });
};

简单对象比对函数就基本完成了。有兴趣的同学也可以直接阅读 obj-diff 源码

简单数组对比

接下来就开始处理数组了,数组的比对核心在于数据的主键识别。代码如下:

const simpleListDiff = ({
  newVal,
  oldVal,
  options,
}: SimpleObjDiffParams) => {
  const opts = { ...DEFAULT_OPTIONS, ...options };

  // 获取当前的主键 key 数值,不传递 key 默认为 'id'
  const { key, getChangedItem } = opts;

  // 增删改的数据
  const addLines = [];
  const deletedLines = [];
  const modifiedLines = [];

  // 添加检测过的数组主键,ListKey 是数字或者字符串类型
  const checkedKeys: Set<ListKey> = new Set<ListKey>();

  // 开始进行传入数组遍历
  newVal.forEach((newLine) => {
    // 根据主键去寻找之前的数据,也有可能新数据没有 key,这时候也是找不到的
    let oldLine: any = oldVal.find((x) => x[key] === newLine[key]);

    // 发现之前没有,走添加数据逻辑
    if (!oldLine) {
      addLines.push(newLine);
    } else {

      // 更新的数据 id 添加到 checkedKeys 里面去,方便删除
      checkedKeys.add(oldLine[key]);

      // 传入函数 getChangedItem 来获取结果
      const result = getChangedItem!({
        newLine,
        oldLine,
      });

      // 没有结果则认为当前数据没有改过,无需处理
      // 注意,和上面不同,这里返回 null 则认为数据没有修改
      if (result !== null && result !== undefined) {
        modifiedLines.push(result);
      }
    }
  });

  oldVal.forEach((oldLine) => {
    // 之前更新过不用处理
    if (checkedKeys.has(oldLine[key])) {
      return;
    }

    // 剩下的都是删除的数据
    deletedLines.push({
      [key]: oldLine[key],
    });
  });

  return {
    addLines,
    deletedLines,
    modifiedLines,
  };
};

此时我们就可以使用该函数进行一系列简单数据操作了。

const result = simpleListDiff({
  newVal: [{
    id: 1,
    cc: "bbc",
  },{
    bb: "123",
  }],
  oldVal: [{
    id: 1,
    cc: "bb",
  }, {
    id: 2,
    cc: "bdf",
  }],
  options: {
    // 传入函数
    getChangedItem: ({
      newLine,
      oldLine,
    }) => {
      // 利用对象比对 simpleObjDiff 来处理
      const result = simpleObjDiff({
        newVal: newLine,
        oldVal: oldLine,
      });

      // 发现没有改动,返回 null
      if (!Object.keys(result).length) {
        return null;
      }

      // 否则返回对象比对过的数据
      return { id: newLine.id, ...result };
    },
    key: "id",
  },
});
// => 返回结果为
result = {
  addedLines: [{
    bb: "123",
  }],
  deletedLines: [{
    id: 2,
  }],
  modifiedLines: [{
    id: 1,
    cc: "bbc",
  }],
};

函数到这里就差不多可用了,我们可以传入参数然后拿到比对好的结果发送给服务端进行处理。

添加默认对比函数

这里就不传递 getChangedItem 的逻辑,函数将做如下处理。如此我们就可以不传递 getChangedItem 函数了。

const simpleListDiff = ({
  newVal,
  oldVal,
  options,
}: SimpleObjDiffParams) => {
  const opts = { ...DEFAULT_OPTIONS, ...options };

  // 获取当前的主键 key 数值,不传递 key 默认为 'id'
  const { key } = opts;

  let { getChangedItem } = opts;

  // 如果没有传递 getChangedItem,就使用 simpleObjDiff 处理
  if (!getChangedItem) {
    getChangedItem = ({
      newLine,
      oldLine,
    }) => {
      const result = simpleObjDiff({
        newVal: newLine,
        oldVal: oldLine,
      });
      if (!Object.keys(result).length) {
        return null;
      }
      return { [key]: newLine[key], ...result };
    };
  }

  //... 之前的代码
};

添加排序功能

部分表单提交不仅仅只需要增删改,还有排序功能。这样的话即使用户没有进行过增删改,也是有可能修改顺序的。此时我们在数据中添加序号,做如下改造:

const simpleListDiff = ({
  newVal,
  oldVal,
  options,
}: SimpleObjDiffParams) => {
  const opts = { ...DEFAULT_OPTIONS, ...options };

  // 此时传入 sortName,不传递则不考虑排序问题
  const { key, sortName = "" } = opts;

  // 判定是否有 sortName 这个配置项
  const hasSortName: boolean = typeof sortName === "string" &&
    sortName.length > 0;

  let { getChangedItem } = opts;

  if (!getChangedItem) {
    //
  }

  const addLines = [];
  const deletedLines = [];
  const modifiedLines = [];
  // 添加 noChangeLines
  const noChangeLines = [];

  const checkedKeys: Set<ListKey> = new Set<ListKey>();

  newVal.forEach((newLine, index: number) => {
    // 这时候需要查询老数组的索引,是利用 findIndex 而不是 find
    let oldLineIndex: any = oldVal.findIndex((x) => x[key] === newLine[key]);

    // 没查到
    if (oldLineIndex === -1) {
      addLines.push({
        ...newLine,
        // 如果有 sortName 这个参数,我们就添加当前序号(索引 + 1)
        ...hasSortName && { [sortName]: index + 1 },
      });
    } else {
      // 通过索引来获取之前的数据
      const oldLine = oldVal[oldLineIndex];

      // 判定是否需要添加顺序参数,如果之前的索引和现在的不同就认为是改变的
      const addSortParams = hasSortName && index !== oldLineIndex;

      checkedKeys.add(oldLine[key]);

      const result = getChangedItem!({
        newLine,
        oldLine,
      });

      if (result !== null && result !== undefined) {
        modifiedLines.push({
          ...result,
          // 更新的数据同时添加排序信息
          ...addSortParams && { [sortName]: index + 1 },
        });
      } else {
        // 这里是没有修改的数据
        // 处理数据没改变但是顺序改变的情况
        if (addSortParams) {
          noChangeLines.push({
            [key!]: newLine[key!],
            [sortName]: index + 1,
          });
        }
      }
    }
  });

  //... 其他代码省略,删除不用考虑顺序了

  return {
    addLines,
    deletedLines,
    modifiedLines,
    // 返回不修改的 line
    ...hasSortName && {
      noChangeLines,
    },
  };
};

开始测试一下:

simpleListDiff({
  newVal: [
    { cc: "bbc" }, 
    { id: 1, cc: "bb" }
  ],
  oldVal: [
    { id: 1, cc: "bb" }
  ],
  options: {
    key: "id",
    sortName: "sortIndex",
  },
});
// 同样也支持为新增和修改的数据添加 sortIndex
result = {
  addedLines: [
    {
      cc: "bbc",
      // 新增的数据目前序号为 1
      sortIndex: 1,
    },
  ],
  // id 为 1 的数据位置变成了 2,但是没有发生数据的改变
  noChangeLines: [{
    id: 1,
    sortIndex: 2,
  }],
  deletedLines: [],
  modifiedLines: [],
};

简单数组比对函数就基本完成了。有兴趣的同学也可以直接阅读 list-diff 源码

以上所有代码都在 diff-helper 中,针对复杂的服务端数据请求,可以通过传参使得两个函数能够嵌套处理。同时也欢迎大家提出 issue 和 pr。

其他

针对形形色色需求,上述两种函数处理方案也是不够用的,我们来看看其他的对比方案。

数据递归比对

当前库也提供了一个对象或者数组的比对函数 commonDiff。可以嵌套的比对函数,可以看一下实际效果。

import { commonDiff } from "diff-helper";

commonDiff({
  a: {
    b: 2,
    c: 2,
    d: [1, 3, 4, [3333]],
  },
}, {
  a: {
    a: 1,
    b: 1,
    d: [1, 2, 3, [223]],
  },
});
// 当前结果均是对象,不过当前会增加 type 帮助识别类型
result = {
  type: "obj",
  a: {
    type: "obj",
    a: null,
    b: 1,
    c: 2,
    d: {
      type: "arr",
      // 数组第 2 个数据变成了 3,第 3 数据变成了 4,以此类推
      1: 3,
      2: 4,
      3: {
        type: "arr",
        0: 223,
      },
    },
  },
};

westore 比对函数

westore 是个人使用过最好用的小程序工具,兼顾了性能和可用性。其中最为核心的则是它的比对函数,完美的解决了小程序 setData 时为了性能需要建立复杂字符串的问题。

以下代码是实际的业务代码中出现的:

// 更新表单项数据,为了性能,不建议每次都传递一整个 user
this.setData({ [`user.${name}`]: value });

// 设置数组里面某一项数据
this.setData({ [`users[${index}].${name}`]: value });

这里就不介绍 westore 的用法了,直接看一下 westore diff 的参数以及结果:

const result = diff({
  a: 1,
  b: 2,
  c: "str",
  d: { e: [2, { a: 4 }, 5] },
  f: true,
  h: [1],
  g: { a: [1, 2], j: 111 },
}, {
  a: [],
  b: "aa",
  c: 3,
  d: { e: [3, { a: 3 }] },
  f: false,
  h: [1, 2],
  g: { a: [1, 1, 1], i: "delete" },
  k: "del",
});
// 结果
{ 
  "a": 1, 
  "b": 2, 
  "c": "str", 
  "d.e[0]": 2, 
  "d.e[1].a": 4, 
  "d.e[2]": 5, 
  "f": true, 
  "h": [1], 
  "g.a": [1, 2], 
  "g.j": 111, 
  "g.i": null, 
  "k": null 
}

不过这种增量比对不适合通用场景,大家有需求可以自行查阅代码。笔者也在考虑上面两个比对函数是否有其他的使用场景。

鼓励一下

如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。

博客地址

参考资料

fast-json-stringify

westore

diff-helper


jump__jump
2.5k 声望4k 粉丝

猎奇者...未来战士...very vegetable