思わぬところで学習スイッチが入るかもしれないJavaScriptの小ネタ5選!
UI開発者 宇賀みなさんどうもこんにちは、UI開発者の宇賀です!
もうすっかり春ですね。暖かくなってきて過ごしやすくなってきましたが、花粉との戦いに日々明け暮れたり、新しい環境に四苦八苦したりと、反面身の回りは大変な時期なのではないでしょうか。
今回はJavaScriptを学び初めてしばらくすると陥りがちな「学習に飽きてきた状態」な方向けの記事です。
何かを作るために必ずしも直接必要ではない知識を知ることで、意外と学習スイッチが入ったりすることがある個人的な経験から、ちょっとした小ネタをいくつか紹介したいと思います。
微妙なチョイスで「最近ちょっとマンネリ化してきた」という方の学習スイッチを押していきたいと思います。
注意:
本記事は、これから紹介するコードや仕組みの利用を推奨するものではありません。実際にコードを書くときは、パフォーマンスや可読性、アンチプラクティスを採用してしまっていないかなどに配慮しつつ書くべきです。
次の順にご紹介していきたいと思います!よろしくお願いいたします。
01. 参照は続くよどこまでも
window.window.window.window.window.window.window.window.window...
JavaScriptの基本機能が全て詰まっているグローバルオブジェクト「window」。このwindowは、自身の中に自分を参照しているプロパティが存在するため、無限に「window.」を続けて書いていくことができます。なんだか面白いですね。
windowオブジェクトだけでなく、自分で用意したオブジェクトでもこのような現象を引き起こすことが可能です。
let obj = {}
obj.obj = obj;
console.log(obj.obj.obj.obj.obj.obj.obj.obj.obj.obj.obj.obj.obj.obj.obj.obj.obj.obj); // > obj
このような状態を「循環参照」といいます。挙動自体は面白いですがこのままではガベージコレクト(不要なメモリの解放)されなくなったり、オブジェクトの中身を再帰的に走査するコードを書いた場合Maximum call stack size exceeded errorに陥ったりするので注意が必要です。
02. 実は小数点の計算が苦手
JavaScriptは小数点の計算が得意ではありません。もちろんこれはJavaScriptの特徴というわけではなく、世の中の多くの言語が苦手としています。
たとえば、次のような式を計算したとします。
0.1 + 0.1; // -> 0.2
答えは0.2になります。これは正しい値ですね。しかし次はどうでしょうか。
0.1 + 0.1 + 0.1; // -> 0.30000000000000004
0.7 + 0.1; // -> 0.7999999999999999
なんだか雲行きが怪しくなってきました。
0.16666666666666666 * 6; // -> 1
0.16666666666666665 * 6; // -> 1
6倍する数字が違うのにも関わらず、いずれも答えは1になります。実に不思議ですね...。
これは「浮動小数点方式」というキーワードを調べていくと詳しい原因がわかるかもしれません。このような現象を回避する方法の1つに、「仮想小数点方式」という考え方があります。
小数点でも、全て整数として考えて計算しよう、というものです。もし小数点の計算でつまずくことがあれば、思い出していただけるといいですね。
((0.1 * 10) + (0.1 * 10) + (0.1 * 10)) / 10; // -> 0.3
((0.7 * 10) + (0.1 * 10)) / 10; // -> 0.8
ただし、次のようにあまりにも桁が多い計算は整数でも正確に行うことができません。安全に計算できる数字かどうかはNumber.isSafeInterger()で確かめることができます。
((0.16666666666666666 * 100000000000000000) * 6) / 100000000000000000; // -> 1
((0.16666666666666665 * 100000000000000000) * 6) / 100000000000000000; // -> 1
Number.isSafeInteger((0.16666666666666665 * 100000000000000000) * 6); // -> false
Number.isSafeInteger((0.166666666666666 * 1000000000000000) * 6); // -> true
03. 参照渡しと値渡し
var obj = [0, 1, 2];
var b = obj[0]; // 配列の先頭の値をコピーした
b += 100; // コピーした値に100を足す
console.log(obj[0]); // > 0
console.log(b); // > 100
JavaScriptにも参照渡し、値渡しという概念が存在します。
これを知らずにうっかりオブジェクトを他の変数に渡したつもりになってしまうと、思わぬバグを引き起こしてしまうかもしれません。
var obj = [0, 1, 2]; // 配列(object型)を定義
var b = obj; // 配列をコピーする
b[0] += 100; // コピーした配列の最初の値に100を足す
console.log(obj[0]); // > 100
console.log(b[0]); // > 100
なんと、コピーしたはずの元のオブジェクト「obj」のほうの値もかわってしまいました。このような挙動が参照渡しと呼ばれています。
詳しく調べると「プリミティブ型」、「オブジェクト型」などのキーワードが登場しますが、次の5つの種類は値渡しになり、それ以外は参照渡しになります。
- number型
- string型
- boolean型
- null型(実際には値がnullのobject型)
- undefined型
なので、1つ前の例のコメントアウトは次のようになります。
var obj = [0, 1, 2]; // 配列(object型)を定義
var b = obj; // 変数bにも、変数objが参照している配列を参照させる
b[0] += 100; // 変数bが参照している配列の最初の値に100を足す
console.log(obj[0]); // > 100
console.log(b[0]); // > 100
いま扱っている値の型は、値渡しになる型なのか、参照渡しになる型なのかを意識してみると思わぬバグを生み出さずにすみそうですね。
配列を値渡しにしたい場合、正しい使い方とは違いますがArray.prototype.concat()を利用することで実現できます。
var obj = [0, 1, 2];
var b = obj.concat(); // objの配列の値を持った新しい配列を作ります
b[0] += 100; // 新しく作った配列の最初の値に100を足す
console.log(obj[0]); // > 0
console.log(b[0]); // > 100
連想配列の場合はJSONに変換してオブジェクトに戻すという方法があります。ただし、NaNはnullに変換されたり、関数は削除されたりしますので、必ずしも同じものがコピーできるわけではないことに注意が必要です。
var obj = {
a: 0,
b: 1
};
var b = JSON.parse(JSON.stringify(obj)); // JSON(string型)に変換してから、連想配列(object型)に変換することで新しい連想配列を作ります
b[0] += 100; // 新しく作った配列の最初の値に100を足す
console.log(obj.a); // > 0
console.log(b.a); // > 100
安全に連想配列をコピーしたい場合は、for...in文や、Object.keys().forEachなどを使って新しい連想配列に値を1つ1つ入れていくのがベターではないでしょうか。とはいえ、最も安全なのは連想配列をコピーしなくてもいいロジックを考えることかもしれませんね。
04. !, truthy, falsy
if文やwhile文などで条件式を書くとき、よく次のような書き方をすると思います。
if (flag) {
console.log(true);
}
while (flag) {
flag = false;
}
if (!flag) {
flag = true;
}
これらは全て条件式の結果がtrueであれば、波括弧(ブレース)の中の処理が実行されるといったものです。
最後のif文の条件式で登場している、「変数に論理否定演算子(エクスクラメーションマーク、びっくりまーく)をつける」という記法ですが、これは「値が偽(false)であることが真(true)」と解釈されます。もう1つ論理否定演算子をつけると、「値が偽(false)であることが真(true)であることが偽(false)」となり、さらにもう1つ論理否定演算子をつけると、「値が偽(false)であることが真(true)であることが偽(false)であることが真(true)」となります。
つまり、次のような判定になります。
console.log(!true); // > false
console.log(!!true); // > true
console.log(!!!!!!!!!!!!!true); // > false
console.log(!!!!!!!!!!!!!!true); // > true
論理否定演算子は1つの変数にいくらでもつけることができて一見面白いですが、数が多いとわけがわからなくなりますから、使うとしたら上限は1つか2つまでではないでしょうか。
この論理否定演算子が使われる場面の中で、変数の型が前述のtrue or falseのboolean型ではないことは往々にしてあると思います。
(() => {
const TARGET_ELEMENT_SELECTOR = '.hoge';
let target = document.querySelector(TARGET_ELEMENT_SELECTOR);
if (!target) {
return;
}
// ...
})();
上記の例では、変数targetに格納される値は「要素」あるいは「null」になります。
「要素」も「null」もそれ自体はtrueでもないしfalseでもありませんが、続くif文の条件式では、あたかも値にtrueかfalseが入っているかのように記述されています。
このようなケースでは、if文はその値が「trueっぽいか」「falseっぽいか」の判定をします。「trueっぽい」値のことをtruthyと呼び、「falseっぽい」値のことをfalsyと呼びます。JavaScript以外でも、この考え方は登場しますが、JavaScriptにおけるtruthyな値、falsyな値は次の通りです。
種別 | 値 | 型 | 備考 |
---|---|---|---|
falsyな値 | false | boolean型 | |
null | null型(実際はobject型) | nullのデータ型は本来null型が正しいですが、ECMAScriptのバグで現在は事実上object型として判断されます。 | |
undefined | undefined型 | ||
0 | number型 | 勘違いしやすいですが、負の値もtruthyな値になります。 | |
NaN | number型 | Not a numberを示す値ですが、型はnumber型です。 | |
'' | string型 | 空文字(値が入っていない状態) | |
truthyな値 | falsyではない値全て(true, {}, [], 1, -1, 'hoge', function () {} など) |
falsyであると定義されていない値については全てtrueだと判断されます。実際にコードを書くときには、同様の効果を得られるBoolean()を利用したほうが何をしているかが伝わりやすいかもしれないですね。
console.log(!!false); // > false
console.log(!!null); // > false
console.log(!!undefined); // > false
console.log(!!0); // > false
console.log(!!NaN); // > false
console.log(!!''); // > false
console.log(Boolean(null)); // > false
console.log(Boolean(undefined)); // > false
console.log(Boolean(0)); // > false
console.log(Boolean(NaN)); // > false
console.log(Boolean('')); // > false
console.log(!!true); // > true
console.log(!!{}); // > true
console.log(!![]); // > true
console.log(!!1); // > true
console.log(!!-1); // > true
console.log(!!'hoge'); // > true
console.log(!!function () {}); // > true
console.log(Boolean({})); // > true
console.log(Boolean([])); // > true
console.log(Boolean(1)); // > true
console.log(Boolean(-1)); // > true
console.log(Boolean('hoge')); // > true
console.log(Boolean(function () {})); // > true
勘違いしやすいですが、中身が空っぽの配列([])や連想配列({})も、truthyな値なので、もし中身が入っているかどうかを判定したい場合は入っているデータの数が0かどうかで判定するとよいでしょう。
let arr = [];
let obj = {};
if (arr.length <= 0) {
console.log('変数arrの中身がありません');
}
if (Object.keys(obj).length <= 0) {
console.log('変数objの中身がありません');
}
05. あれもこれも始まりはみんな同じだった
様々な型の値を作るとき、それぞれ次のように記述すると思います。
var arr = []; // object型(配列)
var obj = {}; // object型(連想配列)
var str = ''; // string型
var num = 0; // number型
var func = function () {}; // function型
これらはいずれもコンソールに変数名を打ち込み、ピリオドを入力すると「__proto__」というプロパティを持っています。
「__proto__」プロパティの中身を見てみると、なにやらいろいろメソッドが格納されており、その中にも「__proto__」というプロパティが。
「__proto__」の中の「__proto__」をどんどん参照していくと、最終的にはnullが帰ってきます。nullが終点です。
これはnumber型のみならず、nullとundefined(及び意図的に__proto__を含まないように生成されたオブジェクト)を除き、全ての値で同じ現象が確認できます。当然、要素もNodeListもHTMLCollectionも、trueやfalseも「__proto__」を持ちます。
この「__proto__」には何が入っているのかというと、その値の元になったオブジェクトが入っています。前述で述べた一部の例外を除いた全ての値は、元になったオブジェクトの機能を継承して、生成されます。キーワードは「プロトタイプチェーン」や「プロトタイプ継承」などで詳しく調べることができます。
試しにnumber型の元になったオブジェクトを見つからなくなるまで見てみましょう。
var num = 0; // number型の値
// number型の元になっているのはグローバル関数「Number」のprototype
console.log(num.__proto__ === Number.prototype); // > true
// Number.prototypeの元になっているのはObjectのprototype
console.log(Number.prototype.__proto__ === Object.prototype); // > true
// Object.prototypeの元になっているのは null なので Object.prototype が頂点
console.log(Object.prototype.__proto__ === null); // > true
続いて、string型の元になったオブジェクトを見つからなくなるまで見てみましょう。
var str = 'hoge'; // string型の値
// string型の元になっているのはグローバル関数「String」のprototype
console.log(str.__proto__ === String.prototype); // > true
// String.prototypeの元になっているのはObjectのprototype
console.log(String.prototype.__proto__ === Object.prototype); // > true
// Object.prototypeの元になっているのは null なのでまたしても Object.prototype が頂点
console.log(Object.prototype.__proto__ === null); // > true
最後に、function型(関数)の元になっているオブジェクトを見ていきましょう。
var func = function () {};
// function型の元になっているのはグローバル関数「Function」のprototype
// NumberやStringも関数なので、それらの元になったprototypeもFunction.prototypeです
console.log(func.__proto__ === Function.prototype); // > true
// Function.prototypeの元になっているのはObjectのprototype
console.log(Function.prototype.__proto__ === Object.prototype); // > true
// 3度目ですが、Object.prototypeの元になっているのは null やはり Object.prototype が頂点
console.log(Object.prototype.__proto__ === null); // > true
なんだか面白いですね。全然関係ない値や型も、上っていけばみんなObject.prototypeに行き着きます。
みんなの元になってるだけあって、次のようなコードを書くと、あらゆる変数に同じ機能を持たせることができます。
// オブジェクトのprototypeをいじるのはpolyfillでもない限りご法度とされています
// あくまで例としてお試しください
var arr = [0, 1, 2]; // object型(配列)
var obj = {// object型(連想配列)
a: 0
};
var str = 'Hello world'; // string型
var num = 100; // number型
var func = function () { // function型
console.log(0);
};
/**
* 受け取った値をconsole.log()する機能
* @param {number|string|object|function|boolean|null} [arg] console.log()に渡す値
* @returns {void}
*/
Object.prototype.hoge = function (arg) {
console.log(arg, this);
};
arr.hoge('hoge'); // > 'hoge', arr
obj.hoge('hoge'); // > 'hoge', obj
str.hoge('hoge'); // > 'hoge', str
num.hoge('hoge'); // > 'hoge', num
func.hoge('hoge'); // > 'hoge', func
実際にprototypeを利用してコードを書く際には、上記のような感覚で自作の大元になる関数(コンストラクタ、class)のprototypeに、共通で利用できるメソッドを持たせておくことでメソッドを別のオブジェクトにも継承させることができます。
var Hoge = (function () {
var Constructor = function (arg) {
this.name = arg;
};
// 名前を書き換える共通機能
Constructor.prototype.setName = function (arg) {
this.name = arg;
};
return Constructor ;
}());
var hoge = new Hoge('uga');
var hoge02 = new Hoge('uga');
console.log(hoge.name); // > 'uga'
console.log(hoge02.name); // > 'uga'
// hoge.name = 'piyo'; と同じですが今回は例として
// prototypeで実装したメソッドを利用して実現します
hoge.setName('piyo');
hoge02.setName('foo');
console.log(hoge.name); // > 'piyo'
console.log(hoge02.name); // > 'foo'
// どちらも同じHoge.prototypeが元になっている
console.log(hoge.__proto__ === Hoge.prototype); // > true
console.log(hoge02.__proto__ === Hoge.prototype); // > true
// 同じコンストラクタ経由であることがわかる
console.log(hoge.constructor === hoge02.constructor); // > true
実際はただ、あらゆる値がオブジェクトの構造を持っており、故に自然とObject.prototypeに行き着いているというだけですが、初めて知る人にとっては新鮮で不思議に感じられるかもしれません。
まとめ
さて、思わぬところから学習意欲は湧いてきましたでしょうか?本稿でみなさんの学習スイッチが押せていれば幸いです。
今回取り上げた内容は、ちょっとややこしい一面を持つとはいえ何の変哲も無いありふれたJavaScriptの仕様です。
最初のうちは「なんとなくそういうものだ」と気にせずコードを書いていることが多いかもしれませんが、機会を見つけて普段気にしないようなJavaScriptの仕様にも目を向けてみると、新たな発見があったり、スキルアップにつながったりするかもしれません。
これら以外でも、「これってどうしてこういう挙動なんだろうか?」という視点を持ってJavaScriptと触れ合ってみてはいかがでしょうか?
この記事が面白かった!と思った方はぜひぜひシェアしていただければと思います!
次回をお楽しみに!