闇の魔術に対する防衛術 Advent Calendar 2019 の6日目の記事 にこんなことを書きました。
a = new Uint16Array([10, 20]);
b = new Array(10, 20);
console.log(JSON.stringify(a)); // {"0":10,"1":20}
console.log(JSON.stringify(b)); // [10,20]
ArrayとUint16Arrayでは同じ配列なのに、なぜかこのように挙動が異なります。
なぜ Array
を名乗るのに少し動きが違うのか?
今回はもう少し深掘りします。
JSON.stringify() の挙動
ポリフィル
JSON オブジェクトは古いブラウザでサポートされていません。この問題はスクリプトの先頭に以下のコードを挿入して、(Internet Explorer 6 のような) JSON をネイティブにサポートしないブラウザでの JSON オブジェクトの利用を可能にすることで回避できます。
以下のアルゴリズムは、ネイティブな JSON オブジェクトを模倣するものです:
...
なるほど、ポリフィルに書いてあるコードを見ればなにかわかるかもしれないですね。
if (!window.JSON) {
window.JSON = {
parse: function(sJSON) { return eval('(' + sJSON + ')'); },
stringify: (function () {
var toString = Object.prototype.toString;
var hasOwnProperty = Object.prototype.hasOwnProperty;
var isArray = Array.isArray || function (a) { return toString.call(a) === '[object Array]'; };
var escMap = {'"': '\\"', '\\': '\\\\', '\b': '\\b', '\f': '\\f', '\n': '\\n', '\r': '\\r', '\t': '\\t'};
var escFunc = function (m) { return escMap[m] || '\\u' + (m.charCodeAt(0) + 0x10000).toString(16).substr(1); };
var escRE = /[\\"\u0000-\u001F\u2028\u2029]/g;
return function stringify(value) {
if (value == null) {
return 'null';
} else if (typeof value === 'number') {
return isFinite(value) ? value.toString() : 'null';
} else if (typeof value === 'boolean') {
return value.toString();
} else if (typeof value === 'object') {
if (typeof value.toJSON === 'function') {
return stringify(value.toJSON());
} else if (isArray(value)) {
var res = '[';
for (var i = 0; i < value.length; i++)
res += (i ? ', ' : '') + stringify(value[i]);
return res + ']';
} else if (toString.call(value) === '[object Object]') {
var tmp = [];
for (var k in value) {
// in case "hasOwnProperty" has been shadowed
if (hasOwnProperty.call(value, k))
tmp.push(stringify(k) + ': ' + stringify(value[k]));
}
return '{' + tmp.join(', ') + '}';
}
}
return '"' + value.toString().replace(escRE, escFunc) + '"';
};
})()
};
}
parseメソッドは一行で終わるのに対し、stringifyメソッドはやたらと長いですね...
それでは見ていきましょう。
// ...
} else if (typeof value === 'object') {
if (typeof value.toJSON === 'function') {
return stringify(value.toJSON());
} else if (isArray(value)) {
var res = '[';
for (var i = 0; i < value.length; i++)
res += (i ? ', ' : '') + stringify(value[i]);
return res + ']';
} else if (toString.call(value) === '[object Object]') {
var tmp = [];
for (var k in value) {
// in case "hasOwnProperty" has been shadowed
if (hasOwnProperty.call(value, k))
tmp.push(stringify(k) + ': ' + stringify(value[k]));
}
return '{' + tmp.join(', ') + '}';
}
}
// ...
おやおや、 else if (isArray(value))
なんてものが気になります。
Array.isArray
ではなく、stringifyメソッド内で利用できるものが次のようにまた定義されています。
var isArray = Array.isArray || function (a) { return toString.call(a) === '[object Array]'; };
ただ、ほとんど Array.isArray
と同じですね。
Array.isArray
については、オブジェクトがArrayであればtrue、それ以外ならfalseが返るようになっています。
a = new Uint16Array([10, 20]);
b = new Array(10, 20);
console.log(Array.isArray(a)); // false
console.log(Array.isArray(b)); // true
console.log(toString.call(a)); // [object Uint16Array]
console.log(toString.call(b)); // [object Array]
Arrayの場合は isArray(value)
でtrueを返すので、角括弧を使った変換がされて [10,20]
という文字列が返ります。
Uint16Arrayの場合は isArray(value)
でfalseを返すので、Arrayと同じような変換がされません。
ポリフィルではただのカンマ区切りになります。
まとめると、 似たような配列のオブジェクトであっても挙動が異なる というわけです。
あくまでポリフィルのコードを見ただけなので、実際のJavaScriptの仕様は厳密に異なることがあります。その点はご留意くださいませ。
おわりに
今回は JSON.stringify()
の動きが、ArrayとUint16Arrayで異なることを紹介しました。
Uint16Arrayを完全なArrayと思わないことが防衛術になるでしょうか...
もしかしたら、思わぬところで沼にはまることがあるかもしれないので備忘録として書き留めておきます