最終的なコード
コメントを基にコードを訂正し、バリデーター関数を導入しネストしていた IF 文を整理した。
訂正前のコードでは同じプロパティ数で、object_A の当該プロパティが undefined
かつ object_B がそのキーのプロパティを持たない時に、判定に失敗する不具合があったが訂正した。
この章は記事全体が長くなったため、「オブジェクト 比較 JavaScript」で検索しただけの読者とコピーペーストの便宜のために先頭へ繰り出された。
/**
* 2つのオブジェクトが等しいかどうかを比較する。
*
* プロパティの数とすべてのプロパティの型と値が一致する場合「同じ」であるとする。
* @param {Object} object_A
* @param {Object} object_B
* @returns {boolean}
*/
function isEqual(object_A, object_B) {
/**
* プロパティの型が一致するかどうかを確認
* @param {*} valueA
* @param {*} valueB
* @returns {boolean}
*/
function validateType(valueA, valueB) {
if (typeof valueA !== typeof valueB) {
return false;
}
// オブジェクトまたは配列の場合は再帰的に比較
if (typeof valueA === "object" && typeof valueB === "object") {
return isEqual(valueA, valueB);
}
// NaN の場合の特別な処理
// NaN は両辺が NaN の比較でも false を返すのでここで true
if (Number.isNaN(valueA) && Number.isNaN(valueB)) {
return true;
}
if (Number.isNaN(valueA) || Number.isNaN(valueB)) {
return false;
}
return true;
}
// オブジェクトのすべてのプロパティキーを取得
const keysA = Reflect.ownKeys(object_A);
const keysB = Reflect.ownKeys(object_B);
// 「同じ」オブジェクトならプロパティ数は一致する
if (keysA.length !== keysB.length) {
return false;
}
// 「同じ」オブジェクトであると仮定してループする
for (const key of keysA) {
// 同じキーを持つはずだ
if (!(key in keysB)) {
return false;
}
// 同じキーのプロパティは型が一致するはずだ
if (!validateType(object_A[key], object_B[key])) {
return false;
}
// 同じキーのプロパティは中身が一致するはずだ
if (object_A[key] !== object_B[key]) {
return false;
}
}
// すべてのプロパティについて検査が終わったなら等しい
return true;
}
JavaScript にはプリミティブ型とオブジェクトがある
手短に言うと、プリミティブ型は同じ型同士を===
で比較できる。
オブジェクトはプリミティブ型ではなく、オブジェクトは===
で比較できない。
JSON.stringify によってプリミティブ型に変換する
オブジェクトをプリミティブ型に変換することで比較できる。簡単な方法としてJSON.stringifyが良く紹介されている。
しかし、問題がある。JSON.stringify では順序が保証されないのだ。
const a = {a:1,b:2}
const b = {b:2,a:1}
console.log(JSON.stringify(a) === JSON.stringify(b)) // false
では、ソートすればいい?
function isEqual(object_A, object_B) {
return JSON.stringify(Object.entries(object_A).sort()) === JSON.stringify(Object.entries(object_B).sort())
}
実はそうではない。
const c = {a:null,b:2}
const d = {b:2,a:NaN}
const e = {a:() => {}}
const f = {a:undefined}
isEqual(c,d) // true
isEqual(e,f) // true
こうなる。
実は JSON.stringify()
は NaN
を入れると null
を返し、関数を入れると undefined
を返す。
比較する範囲を工夫する
考え方を変えよう、すべてを一度に変換するのをやめて、プロパティを一つづつ比較する。
このとき、上記の問題児たちはくくりだして比較する。
プロパティの数が有限で、数が一致して、すべてのプロパティが同じならば2つのオブジェクトは「同じ」ということにする。
function isEqual(object_A, object_B) {
// オブジェクトのキーを取得
const keysA = Object.keys(object_A);
const keysB = Object.keys(object_B);
// オブジェクトのプロパティ数が異なる場合は等しくない
if (keysA.length !== keysB.length) {
return false;
}
// 各プロパティを再帰的に比較
for (let key of keysA) {
// プロパティがオブジェクトである場合は再帰的に比較
if (typeof object_A[key] === 'object' && typeof object_B[key] === 'object') {
if (!isEqual(object_A[key], object_B[key])) {
return false;
}
} else {
// NaN の場合の特別な処理
if (Number.isNaN(object_A[key]) && Number.isNaN(object_B[key])) {
// NaN ならば同じ値か?と言えるかは難しい。
// たとえば 0 * Infinity === undefined + undefined は false である
continue;
}
// 関数や undefined の場合をくくりだす
if (typeof object_A[key] === 'function' || typeof object_B[key] === 'function' ||
typeof object_A[key] === 'undefined' || typeof object_B[key] === 'undefined') {
if (object_A[key] !== object_B[key]) {
return false;
}
} else {
// スッキリと変換できる値を比較
if (object_A[key] !== object_B[key]) {
return false;
}
}
}
}
// すべてのプロパティが一致した場合は等しい
return true;
}
Object.keys() は列挙可能なプロパティしか返さないとの指摘を受けた
@juner(jun maeda)
Object.keys() だと class とかを入れたり Symbol なメソッドやプロパティが含まれていると対応できなくなるやつですね。(class だと 関数宣言がデフォルト列挙されない や getter が列挙されないがある為
Object.keys() - JavaScript | MDN
Object.keys() は、object で直接発見された列挙可能なプロパティに対応する文字列を要素とする配列を返します。プロパティの順序は、オブジェクトのプロパティをループにより手動で取得した場合と同じです。
知らなかった。
他にもオブジェクトが持つプロパティキーの配列を返すメソッドObject.getOwnPropertyNames()
と Reflect.ownKeys()
があり、 Symbol 型を扱うには Reflect.ownKeys()
を使うようだ。
とりあえず以下のようにした。
/*
* XXX: Symbol 型と Reflect.ownKeys() のことは
* さっきちょっと調べただけの状態であるから、偶然動いているだけかもしれない。
*/
function isEqual(object_A, object_B) {
// オブジェクトのすべてのプロパティキーを取得
const keysA = Reflect.ownKeys(object_A);
const keysB = Reflect.ownKeys(object_B);
// オブジェクトのプロパティ数が異なる場合は等しくない
if (keysA.length !== keysB.length) {
return false;
}
// 各プロパティを再帰的に比較
for (let key of keysA) {
// シンボル型のプロパティをくくりだす
if (typeof key === "symbol") {
// 「同じ」オブジェクトなら、key が両方のオブジェクトに含まれているはず
if (!keysB.includes(key)) {
return false;
}
// 対応するシンボル型のプロパティを比較
if (object_A[key] !== object_B[key]) {
return false;
}
} else {
// プロパティがオブジェクトである場合は再帰的に比較
if (
typeof object_A[key] === "object" &&
typeof object_B[key] === "object"
) {
if (!isEqual(object_A[key], object_B[key])) {
return false;
}
} else {
// NaN の場合の特別な処理
if (Number.isNaN(object_A[key]) && Number.isNaN(object_B[key])) {
continue;
}
// 関数や undefined の場合をくくりだす
if (
typeof object_A[key] === "function" ||
typeof object_B[key] === "function" ||
typeof object_A[key] === "undefined" ||
typeof object_B[key] === "undefined"
) {
if (object_A[key] !== object_B[key]) {
return false;
}
} else {
// スッキリと変換できる値を比較
if (object_A[key] !== object_B[key]) {
return false;
}
}
}
}
}
// すべてのプロパティが一致した場合は等しい
return true;
}
テストコード
const a = { a: 1, b: 2 };
const b = { b: 2, a: 1 };
const c = { a: null, b: 2 };
const d = { b: 2, a: NaN };
const e = { a: () => {} };
const f = { a: undefined };
class MyClass {
constructor(value) {
this.value = value;
}
}
const obj1 = {
numberProp: 123,
stringProp: "abc",
symbolProp: Symbol("symbol"),
classProp: new MyClass(1),
};
const obj2 = {
numberProp: 123,
stringProp: "abc",
symbolProp: Symbol("symbol"),
classProp: new MyClass(2),
};
const obj3 = {
numberProp: 123,
stringProp: "def",
symbolProp: Symbol("symbol"),
classProp: new MyClass(1),
};
const obj4 = {
numberProp: 123,
stringProp: "abc",
symbolProp: Symbol("differentSymbol"),
classProp: new MyClass(1),
};
console.assert(isEqual(a, b) === true, "a b"); // false(プロパティの順番が異なるだけで値は同じなため)
console.assert(isEqual(c, d) === false, "c d"); // false(a の型が異なるため)
console.assert(isEqual(e, f) === false, "e f"); // false(a の型が異なるため)
console.assert(isEqual(obj1, obj2) === false, "obj1 obj2"); // false (classPropの値が異なるため)
console.assert(isEqual(obj1, obj3) === false, "obj1 obj3"); // false (stringPropの値が異なるため)
console.assert(isEqual(obj1, obj4) === false, "obj1 obj4"); // false (symbolPropの値が異なるため)
console.assert(isEqual(obj1, obj1) === true, "obj1 obj1"); // true (同じオブジェクトなのでtrue)
終わりに
ブラウザのコンソールでオブジェクトをスッと比較する方法を探したら Lodash の isEqual()
を勧める記事を見つけてカッとなって書いた。
自分のケースでも、良く考えたらオブジェクトを比較する必要はなかったし、ライブラリを使うのなら簡単な方法もあるようなので良きに計らってほしい。