JavaScript
GoogleAppsScript

JavaScript で配列やオブジェクトなどが同じ内容か比較する

isSame(obj1, obj2) みたいなのが欲しい

2 つの内容が同じか比較がしたくなったけど、大変だったのでメモ。

やりたいこと.js
var obj1 = {a:'', b:0, c[true, false], d:{x:1}};
var obj2 = {a:'', b:0, c[true, false], d:{x:0}};

// isSame() を作って、obj1 と obj2 が違う内容ならエラーを投げたい
if (!isSame(obj1, obj2)) throw new Error();

つくったもの

JavaScript の標準オブジェクトなら、配列などでネストされていても同じ内容か判定する isSame() ができた。

つくったもの.js
/**
 * 2つの値の内容が同じか判定する関数  ※ 「0 と -0」 や 「JavaObject の内容」は区別しません。
 *
 * @param {*} val1 - 比較対象の値
 * @param {*} val2 - 比較対象の値
 * @return {boolean} 同じなら true、違うなら false を返す
 */
function isSame(val1, val2) {
  // 型を判定する関数を使って、val1 と val2 の両方が判定対象の型なら true を返す関数を定義
  var test = function(checkFunction) {
    return checkFunction.call(null, val1) && checkFunction.call(null, val2);
  }

  // 型が違えば false
  if (Object.prototype.toString.call(val1) !== Object.prototype.toString.call(val2)) return false;

  // 配列型なら isSameArray_() で比較
  if (test(isArray)) return isSameArray_(val1, val2);

  // オブジェクト型なら isSameObject_() で比較
  if (test(isObject)) return isSameObject_(val1, val2);

  // JSON.stringify() で判定できない型は、String 型に変換して比較
  if (test(isUndefined) || test(isDate) || test(isFunction) || test(isRegExp) || test(isError)) return val1 + '' === val2 + '';

  // 型は同じだが(Number 型)、判定が必要な値は String 型に変換して比較
  if (isNaN(val1) || isNaN(val2) || isInfinity(val1) || isInfinity(val2)) return val1 + '' === val2 + '';

  // JSON.stringify() で判定できる型なら比較
  if (test(isNull) || test(isString) || test(isNumber) || test(isBoolean)) return JSON.stringify(val1) === JSON.stringify(val2);

  // JavaObject (Google Apps Script のオブジェクト)を簡易比較
  if (test(isJavaObject)) return val1 + '' === val2 + '';

  // 上記以外は false
  return false;
}

/**
 * 2つの2次元配列の要素が同じか判定する関数  ※ 配列の並び順も含めて判定します。
 *
 * @param {Array} arr1 - 比較対象の2次元配列
 * @param {Array} arr2 - 比較対象の2次元配列
 * @return {boolean} 同じなら true、違うなら false を返す
 */
function isSameArray_(arr1, arr2) {
  // 要素数が異なれば false を返却
  if (arr1.length !== arr2.length) return false;

  // 空の配列同士なら true を返却
  if (arr1.length === 0) return true;

  // 各要素を走査
  return arr1.every(function(elem1, idx) {
    var elem2 = arr2[idx];
    return isSame(elem1, elem2);
  });
}

/**
 * 2つのオブジェクトの内容が同じか判定する関数
 *
 * @param {Object} obj1 - 比較対象のオブジェクト
 * @param {Object} obj2 - 比較対象のオブジェクト
 * @return {boolean} 同じなら true、違うなら false を返す
 */
function isSameObject_(obj1, obj2) {
  // キーを取得してソート  ※ sort() は破壊的なので slice() で複製してから実行
  var keysOfObj1 = Object.keys(obj1).slice().sort();
  var keysOfObj2 = Object.keys(obj2).slice().sort();

  // キー同士が異なれば false を返却
  if (!isSameArray_(keysOfObj1, keysOfObj2)) return false;

  // 空のオブジェクト同士なら true を返却
  if (keysOfObj1.length === 0) return true;

  // キー毎に各プロパティを走査
  return keysOfObj1.every(function(key) {
    var val1 = obj1[key];
    var val2 = obj2[key];
    return isSame(val1, val2);
  });
}


/////////////////////////////////////////////////
// 型をチェックする関数
/////////////////////////////////////////////////

/**
 * NaN か判定する関数
 */
function isNaN(value) {
  return Number.isNaN(value);
}

/**
 * Infinity か判定する関数
 */
function isInfinity(value) {
  return value === Infinity || value === -Infinity;
}

/**
 * 未定義か判定する関数
 */
function isUndefined(value){
  return typeof value === "undefined" ? true : false;
}

/**
 * null か判定する関数
 */
function isNull(value){
  return value === null ? true : false;
}

/**
 * 値が特定の型かどうか判定する関数
 * http://bonsaiden.github.io/JavaScript-Garden/ja/#types.typeof
 */
function is_(type, obj){
  var clas = Object.prototype.toString.call(obj).slice(8, -1);
  return obj !== undefined && obj !== null && clas === type;
}

/**
 * 値が String 型かどうか判定する関数
 */
function isString(value){
  return is_('String', value);
}

/**
 * 値が Number 型かどうか判定する関数
 */
function isNumber(value){
  return is_('Number', value);
}

/**
 * 値が Boolean 型かどうか判定する関数
 */
function isBoolean(value){
  return is_('Boolean', value);
}

/**
 * 値が Date 型かどうか判定する関数
 */
function isDate(value){
  return is_('Date', value);
}

/**
 * 値が Error 型かどうか判定する関数
 */
function isError(value){
  return is_('Error', value);
}

/**
 * 値が Array 型かどうか判定する関数
 */
function isArray(value){
  return is_('Array', value);
}

/**
 * 値が Function 型かどうか判定する関数
 */
function isFunction(value){
  return is_('Function', value);
}

/**
 * 値が RegExp 型かどうか判定する関数
 */
function isRegExp(value){
  return is_('RegExp', value);
}

/**
 * 値が Object 型かどうか判定する関数
 */
function isObject(value){
  return is_('Object', value);
}

/**
 * 値が Javasctip でラップされた Java オブジェクトかどうか判定する関数
 */
function isJavaObject(value) {
  return is_('JavaObject', value);
}


/////////////////////////////////////////////////
// ポリフィル
/////////////////////////////////////////////////

/**
 * ポリフィル: 引数として与えた数がNaNかどうかの真偽値を返します。
 * https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Number/isNaN
 */
Number.isNaN = Number.isNaN || function(value) {
  return typeof value === "number" && value !== value;
}

※ JavaObject は、「GAS のオブジェクト」と「JavaScript の素のオブジェクト」と区別するために使った。
 GAS のオブジェクトを Object.prototype.toString.call() でオブジェクトクラスの検出すると、JavaObject となったので、どの GAS の標準クラスでも判定できるはず。
 (SpreadsheetApp の Spreadsheet 型や Sheet 型、Utilities の Blob 型でのみ検証)

テスト結果

ちゃんと動いてそう。

テスト.js
function test_isSame() {
  // 一意に判定したい値を定義
  var testHash = {
    'undefined'  : [undefined],
    'null'       : [null],
    'string'     : ['', 'undefined', 'null', 'NaN', 'Infinity', '-Infinity', '0', '1', 'true', 'false', 'Sun Jan 01 2017 00:00:00 GMT+0900 (JST)', 'function(val) {return val;}', '/\d+/', 'Error: ', '[]', '{}', 'Logger'],
    'number'     : [0, -1, 1, 2*3, NaN, Infinity, -Infinity],
    'boolean'    : [true, false],
    'date'       : [new Date(2017, 0, 1)],
    'function'   : [function(val) {return val;}, function(arg1, arg2) {return arg1 + arg2 + ''}],
    'regExp'     : [/\d+/, /\^\s*$/],
    'error'      : [new Error(), new Error('err01'), new TypeError('err01')],
    'array'      : [[], ['0'], ['1'], [0], [1], [NaN], [true], [false], [null], [undefined], [[[null, undefined]]], [[[undefined, null]]]],
    'object'     : [{}, {'x':0,'y':0}, {'x':0,'z':0}, {'x':0,'z':1}, {'x':1, 'y':{'z':{'a':undefined}}}, {'x':1, 'y':{'z':{'a':null}}}],
    'javaObject' : [Logger.log(''), Utilities.newBlob('')]
  };

  // testHash を配列に変換  ex. {key1:[value1], key2:[valu2], ...} -> [value1, value2]
  var testArr = Object.keys(testHash).reduce(function(resultArr, key) {
    return resultArr.concat(testHash[key]);
  }, []);

  // エラーが出たら失敗
  var result = testArr.reduce(function(parentHash, e1, i1, arr) {
    parentHash[i1 + ':' + e1] = arr.map(function(e2, i2) {
      if (i1 === i2 && isSame(e1, e2))  return true;           // 自分自身との比較が true なら何もしない
      if (i1 !== i2 && !isSame(e1, e2)) return true;           // 自分以外との比較が false なら何もしない
      throw new Error(i1 + ':' + e1 + ' / ' + i2 + ':' + e2);  // それ以外はエラー
    });
    return parentHash;
  }, {});

  return result;
}