JavaScript Tips | 配列ユーティリティ:差分抽出

JavaScript JavaScript
スポンサーリンク

何をしたいユーティリティか:「配列の差分抽出」

ここでの「差分抽出」は、「ある配列 A と配列 B を比べて、“どこが違うのか”を取り出す処理」です。

業務だと、例えばこんな場面で使います。

  • 画面で「変更前」と「変更後」のデータを比べて、追加・削除・変更された項目を知りたい。
  • マスタの「前回版」と「今回版」を比べて、増えたコード・減ったコードを一覧にしたい。
  • 権限リストの「前の状態」と「今の状態」の差分をログに残したい。

ここでは、まず「プリミティブ値の配列の差分」、次に「オブジェクト配列の差分」という順で、段階的にかみ砕いていきます。


プリミティブ配列の差分:A にだけあるもの・B にだけあるもの

まずは「左右どちらにだけあるか」を出す

一番基本の差分は、次の 2 つです。

  • A にだけある要素(B にはない)
  • B にだけある要素(A にはない)

図でいうとこんなイメージです。

A: [1, 2, 3]
B: [2, 3, 4]

A にだけ: [1]
B にだけ: [4]

実装例:Set を使ったシンプル版

function diffPrimitive(left, right) {
  const a = Array.isArray(left) ? left : [];
  const b = Array.isArray(right) ? right : [];

  const setA = new Set(a);
  const setB = new Set(b);

  const onlyInA = [];
  const onlyInB = [];

  for (const v of setA) {
    if (!setB.has(v)) {
      onlyInA.push(v);
    }
  }

  for (const v of setB) {
    if (!setA.has(v)) {
      onlyInB.push(v);
    }
  }

  return { onlyInA, onlyInB };
}
JavaScript

重要なポイントをかみ砕いて説明する

Set は「重複のない集合」です。
差分を考えるとき、多くの場合「同じ値が何回あるか」ではなく「その値が存在するかどうか」だけ分かれば十分なので、まず Set に変換してしまうと考えやすくなります。

const setA = new Set(a);
const setB = new Set(b);
JavaScript

そのうえで、

  • 「A の要素を順に見て、B にないものを onlyInA に入れる」
  • 「B の要素を順に見て、A にないものを onlyInB に入れる」

という 2 回のループで差分を出しています。

実際の動き

diffPrimitive([1, 2, 3], [2, 3, 4]);
// { onlyInA: [1], onlyInB: [4] }

diffPrimitive(["A", "B", "C"], ["B", "C", "D"]);
// { onlyInA: ["A"], onlyInB: ["D"] }

diffPrimitive([1, 1, 2], [2, 2, 3]);
// { onlyInA: [1], onlyInB: [3] }  // 重複は集合として扱う
JavaScript

「共通部分」も含めた 3 分類にする

A だけ・B だけ・両方にある

差分をもう少しリッチにすると、「3 分類」にできます。

  • A にだけある
  • B にだけある
  • 両方にある
function diffPrimitiveWithCommon(left, right) {
  const a = Array.isArray(left) ? left : [];
  const b = Array.isArray(right) ? right : [];

  const setA = new Set(a);
  const setB = new Set(b);

  const onlyInA = [];
  const onlyInB = [];
  const inBoth = [];

  for (const v of setA) {
    if (setB.has(v)) {
      inBoth.push(v);
    } else {
      onlyInA.push(v);
    }
  }

  for (const v of setB) {
    if (!setA.has(v)) {
      onlyInB.push(v);
    }
  }

  return { onlyInA, onlyInB, inBoth };
}
JavaScript

実際の動き

diffPrimitiveWithCommon([1, 2, 3], [2, 3, 4]);
// { onlyInA: [1], onlyInB: [4], inBoth: [2, 3] }
JavaScript

業務では、「共通部分はそのまま」「A だけは削除」「B だけは追加」といった処理をしたいことが多いので、この 3 分類はかなり使い勝手がいいです。


オブジェクト配列の差分:キーを使って比べる

「id が同じなら同じレコード」とみなす

業務では、こんな配列同士を比べることが多いです。

const before = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
];

const after = [
  { id: 2, name: "Bob (updated)" },
  { id: 3, name: "Carol" },
];
JavaScript

ここで知りたいのは、例えばこんなことです。

  • 追加されたレコード(after にだけある id)
  • 削除されたレコード(before にだけある id)
  • id は同じだが中身が変わったレコード

このとき、「何をもって同じとみなすか」を決める必要があります。
よくあるのは「id が同じなら同じレコード」とみなすパターンです。

id をキーにした差分抽出

function diffByKey(before, after, key) {
  const a = Array.isArray(before) ? before : [];
  const b = Array.isArray(after) ? after : [];

  const mapBefore = new Map();
  const mapAfter = new Map();

  for (const item of a) {
    if (item && typeof item === "object") {
      mapBefore.set(item[key], item);
    }
  }

  for (const item of b) {
    if (item && typeof item === "object") {
      mapAfter.set(item[key], item);
    }
  }

  const added = [];
  const removed = [];
  const maybeUpdated = [];

  for (const [id, itemAfter] of mapAfter.entries()) {
    if (!mapBefore.has(id)) {
      added.push(itemAfter);
    } else {
      const itemBefore = mapBefore.get(id);
      maybeUpdated.push({ before: itemBefore, after: itemAfter });
    }
  }

  for (const [id, itemBefore] of mapBefore.entries()) {
    if (!mapAfter.has(id)) {
      removed.push(itemBefore);
    }
  }

  return { added, removed, maybeUpdated };
}
JavaScript

重要なポイントを深掘りする

mapBeforemapAfter は、「id → レコード」の対応表です。

mapBefore.set(item[key], item);
mapAfter.set(item[key], item);
JavaScript

こうしておくと、「この id は before にいたか? after にいるか?」を高速に調べられます。

そのうえで、

  • after にだけいる id → 追加されたレコード
  • before にだけいる id → 削除されたレコード
  • 両方にいる id → 「更新された可能性のあるレコード」

として分類しています。

ここでは「更新されたかどうか」の判定はまだしていません。
maybeUpdated{ before, after } のペアを入れておき、
あとで「本当に中身が変わっているか」を別途チェックするイメージです。

実際の動き

const before = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
];

const after = [
  { id: 2, name: "Bob (updated)" },
  { id: 3, name: "Carol" },
];

const diff = diffByKey(before, after, "id");

// diff.added   → [{ id: 3, name: "Carol" }]
// diff.removed → [{ id: 1, name: "Alice" }]
// diff.maybeUpdated → [
//   { before: { id: 2, name: "Bob" }, after: { id: 2, name: "Bob (updated)" } }
// ]
JavaScript

「更新されたかどうか」を判定する

shallow な比較で十分な場合

maybeUpdated のペアについて、「本当に変わっているか」を判定する関数を用意します。

function isShallowEqual(a, b) {
  if (a === b) return true;
  if (!a || !b) return false;
  if (typeof a !== "object" || typeof b !== "object") return false;

  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;

  for (const k of keysA) {
    if (a[k] !== b[k]) return false;
  }

  return true;
}
JavaScript

これを使って、「本当に更新されたものだけ」を抽出します。

function extractUpdated(maybeUpdatedPairs) {
  const updated = [];

  for (const pair of maybeUpdatedPairs) {
    if (!isShallowEqual(pair.before, pair.after)) {
      updated.push(pair);
    }
  }

  return updated;
}
JavaScript

実際の動き

const diff = diffByKey(before, after, "id");
const updated = extractUpdated(diff.maybeUpdated);

// updated → [
//   { before: { id: 2, name: "Bob" }, after: { id: 2, name: "Bob (updated)" } }
// ]
JavaScript

これで、「追加」「削除」「更新」の 3 種類の差分がきれいに取れます。


実務で意識してほしい設計のポイント

「何をもって同じとみなすか」を最初に決める

差分抽出は、「同じとは何か?」を決める作業でもあります。

  • プリミティブ配列なら「値が同じなら同じ」。
  • オブジェクト配列なら「id が同じなら同じ」。
  • 場合によっては「code+version の組み合わせが同じなら同じ」。

これを曖昧にしたまま書き始めると、「このケースは別扱いにしたい」といった例外が増えていきます。

ユーティリティとして、

  • diffPrimitive / diffPrimitiveWithCommon
  • diffByKey
  • extractUpdated

のように「ルールごとに関数を分ける」と、意図がコードに乗りやすくなります。

「差分をどう使うか」までセットで考える

差分を取る目的は、「差分を眺めて満足すること」ではなく、その先のアクションです。

例えば、

  • 追加されたもの → DB に INSERT する
  • 削除されたもの → DB から DELETE する
  • 更新されたもの → DB を UPDATE する

あるいは、

  • 追加・削除・更新の内容を監査ログに残す
  • 画面に「何が変わったか」を表示する

といった使い方をします。

だからこそ、差分ユーティリティの戻り値は、

{
  added: [...],
  removed: [...],
  updated: [...],
}
JavaScript

のように、「そのまま次の処理に渡しやすい形」にしておくと、後工程が楽になります。

「順序」をどう扱うか

ここまでの実装では、「順序」はあまり意識していません。
業務の差分では、「順序はどうでもよくて、存在するかどうかだけ知りたい」ことが多いからです。

もし「順序の変化」も差分として扱いたい場合は、
別途「インデックスの違い」を見るロジックが必要になりますが、
それは一段難しい話になるので、まずは「集合としての差分」を押さえるのがおすすめです。


少し手を動かして感覚をつかむ

コンソールで、次のようなコードを実際に打ってみてください。

diffPrimitive([1, 2, 3], [2, 3, 4]);
diffPrimitiveWithCommon([1, 2, 3], [2, 3, 4]);

const before = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
];

const after = [
  { id: 2, name: "Bob (updated)" },
  { id: 3, name: "Carol" },
];

const d = diffByKey(before, after, "id");
d;
extractUpdated(d.maybeUpdated);
JavaScript

「どの要素が追加・削除・更新として扱われているか」を、自分の目で確認してみてください。

そのうえで、自分のプロジェクトに

export function diffPrimitive(...) { ... }
export function diffPrimitiveWithCommon(...) { ... }
export function diffByKey(...) { ... }
export function extractUpdated(...) { ... }
JavaScript

を置き、

「配列の差分を取りたくなったら、必ずこの“差分抽出ユーティリティ”を通す」

というルールを作ってみてください。
それだけで、あなたの「変更検知」や「同期処理」は、場当たり的な for 文から、意図と一貫性を備えた業務レベルの設計に近づいていきます。

タイトルとURLをコピーしました