手寫一個業務資料比對庫

jump__jump 發表於 2022-12-01

在開發 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

相關文章