Kyle Simpson「Advanced JavaScript」学習メモ(1)スコープ
Pluralsightの「Advanced JavaScript」(Kyle Simpson)学習メモ。
- スコープとJavaScriptコンパイラ
JavaScriptのコンパイラは、コンパイルと実行の2つのフェーズに分けられる(実際はさまざまな高速化のためのテクノロジーがあるが、ここでは単純化する)。
<例1>
var foo = "bar"; function bar() { var foo = "baz"; } function baz(foo) { foo = "bam"; bam = "yay"; }
コンパイルフェーズではすべての宣言を検索し、スコープごとに登録する。例1だと、
グローバル:foo、関数bar、関数baz
関数bar: foo
関数baz: foo
のように登録される。ここでは代入や実行は行われない。
そして、実行フェーズで、各行が実行されていく。
たとえば1行目:
var foo = "bar";
代入式を見つけたコンパイラは、現在のスコープ(グローバル)に変数fooという名前のLHS(Left Hand Side)参照がないか問い合わせる。そしてスコープは対応する変数を返し、代入が行われる。
注意が必要なのは、最後のbamへの代入。
function baz(foo) { foo = "bam"; bam = "yay"; }
bazには変数bamのLHSがない。そのため、コンパイラは外側のグローバルスコープにbamのLHS参照を問い合わせる。ここでグローバルスコープは、変数bamが存在しないため新たに作成して返してしまう。”use strict”を宣言してstrictモードにすればこの問題は回避できる(当該行で参照エラーになる)。
LHS参照がないことを、undeclaredと呼ぶ。undefinedとの混同に注意である。undefinedは、宣言はされているが、undefinedという特別な値が保持されているということ。
<例2>
var foo= "bar"; function bar() { var foo = "baz"; function baz(foo) { foo = "bam"; bam = "yay"; } baz(); } bar(); foo; bam; baz();
上記例2では、まずコンパイルフェーズで
グローバル:foo、関数bar
関数bar: foo、関数baz
関数baz: foo
がように登録される。
実行フェーズの13行目:
bar();
グローバルスコープに、barという名前のRHS(Right Hand Side: LHS以外)を問い合わせている。グローバルスコープは対応する関数オブジェクトを返し、”()”があるため該当関数が実行される。そして、bar内のスコープで変数fooへの代入以下が継続される。
最終行:
baz();
グローバルスコープにRHSを問い合わせているが、存在しないため参照エラーとなる。LHSと異なり、RHSは自動的に作成されない。
- 関数宣言と関数式
functionキーワードが式の最初にあったら関数宣言、そうでなければ関数式になる。
var foo = function bar() { var foo = "baz"; function baz(foo) { foo = bar; foo; } baz(); };
この例では、1行目の関数barは関数式で表現されている。そのため、barのスコープはグローバルではなく、関数bar自身の中になる。
関数式による関数は匿名にせず、必ず名前を付けるべき。
- 関数内で、関数自身を参照することができない
- スタックトレースを追いづらく、デバッグが難しい
- 関数名で機能を表現できない
- ブロックスコープ
function以外にも、例外としてcatchがブロックスコープを持つ。これを利用し、汎用的にブロックスコープを実現させるライブラリもある(後述)。
- 構文スコープ(Lexical Scope、静的スコープ)
JavaScriptには構文スコープのみ存在する。構文スコープとは、コンパイル時に構文を解析してスコープを決定する方法で、ほとんどの言語で使われている。動的スコープを採用する言語は少数。
動的スコープ
コードの構文ではなく、関数の呼び元を参照して解決する。動的に解析するのでランタイムのオーバーヘッドがある。
JavaScriptではthisがその役割を担う。
- evalステートメント
evalはコード全体のパフォーマンスが悪化するため、使うべきではない。
var bar = "bar"; function foo(str) { eval(str); console.log(bar); //42 } foo("var bar = 42;");
4行目でevalが実行されたとき、JavaScriptエンジンはあたかもコンパイルタイムに「var bar = 42;」が存在していたかのようにふるまい、スコープを参照する。そのためevalがあるとコンパイル時の最適化が難しくなる。
なお、strictモードだとeval専用のスコープが作られるため、パフォーマンス低下は小さい。
- withステートメント
withステートメントは次の理由で使うべきではない。
- 誤ってグローバルスコープに変数を作ってしまいやすい
- パフォーマンスが悪化する。withはランタイムに構文スコープを作成するため、コンパイルタイムの最適化が働かない
なお、 strictモードではwithキーワードは禁止されている。
- IIFE(Immediately Invoked Function Expression)
スコープを作ってグローバルスコープの汚染を防ぐため、無名の即時実行関数を作るパターンで、とてもよく使われる。ただし、この場合も関数に名前を付けることを推奨する(例:iife)。
var foo = "foo"; (function() { var foo = "foo2"; alert(foo); //foo2 })(); alert(foo); //foo
Ben Alman » Immediately-Invoked Function Expression (IIFE)
以下のように、IIFEにグローバルオブジェクトを渡して、関数内では引数globalとして扱うと、メンバのpublicとprivateを区別しやすい。
(function(global) { //local functions function a() { ... }; function b() { ... }; //global function to expose outside global.globalFunction = function globalFunction(){ ... }; })(window);
また、引数で”JQuery”を渡し、IIFEでは$ で受け取れば、関数内で$が確実にJQueryオブジェクトと保証される。
なお、次のスタイルを好むプログラマも多い(Douglas Crockfordなど)が、機能的には等価である。
(function() { ... }());
- letキーワード(ES6)
letキーワードはブロックスコープになる。varの代替になるものではなく、使い分けるべき。
"use strict" function foo() { var bar = "bar"; for(let i=0; i<bar.length; i++) { alert(bar.charAt(i)); } }
letキーワードの注意点
- ブロックの途中に置いても、スコープはブロック全体になる。常にブロック先頭に置くようにする
- リファクタリングしづらくなる。誤って意図しないブロックスコープを作らないように気をつける
- 暗黙的にスコープを作るので、メンテナンスしづらい。スコープであることを明示するようにする
たとえばスコープを明示する方法として、Keyle Simpsonが作成したライブラリを使う方法がある(ES3以降で動作)。
//need to use https://github.com/getify/let-er "use strict" function foo(bar) { let (baz) { baz = foo; console.log(baz); } }
※let-erはブロックスコープを実現するため、内部的にcatchを利用している
ES6について
ES6はまだスタンダードではないが、開発者はすぐ使い始めるべきである。Babelなどを使えばES5に変換できる。
- 巻き上げ(Hoisting)
LHS(varや関数宣言)はコンパイルフェーズで予め解析されて、該当するスコープに登録されている。そのため、スコープ内のLHSはすべてスコープ先頭に置かれたのと同じ意味になる。これを巻き上げと呼ぶ。
var a = b(); //OK。bはコンパイリングフェーズで関数として登録済 var c = d(); //エラー。dはLHSとして登録済だが、まだ関数が代入されていない function b() { return c; } var d = function() { return b(); };
上記コードは2行目でエラーになる。これは、関数式がコンパイルフェーズではスコープとして登録されないから。
巻き上げは、たとえば相互再帰を実現するため必要な機能である。つまり、巻き上げがないと関数Aが関数Bを呼び、さらにBがAを呼ぶような処理が書けない。
なお、letで宣言された変数は、宣言前に参照しようとすると(たとえスコープ内でも)ReferenceErrorになる。これをTemporal Dead Zone(TDZ)と呼ぶ。
- thisキーワード
thisへの値の束縛は4つのルールがある。どのルールが適用されるかは、関数の呼び出し位置と呼ばれ方に依存する。関数の定義のされ方は無関係。
関数のみでの呼び出し(デフォルト)
thisはstrictモードではundefined、non-strictモードではグローバルオブジェクトになる。
オブジェクト参照として呼び出し(暗黙的な束縛)
thisは参照元のオブジェクトになる。なお、thisはダイナミックスコープであり、構文スコープへのクロス参照は不可能である。
function foo() { var bar = "bar1"; baz(); } function baz() { alert(this.bar); } var bar = "bar2"; foo(); //bar2
よくあるミスが上記のようなコード。構文スコープと混同すると、最終行でbar1を期待してしまう。関数bazは関数のみで呼び出されているので、thisはグローバルオブジェクトになる。
call、apply、bindを使った呼び出し(明示的な束縛)
call、applyを使うと、引数で渡されたオブジェクトがthisになる。
bindではハードバインディングが実現できる。以下コードでは、呼び出し方にかかわらず、関数foo内のthisは必ずobjになる。
function foo() { alert(this.bar); } var obj = { bar: 'bar' }; var obj2 = { bar: 'bar2' }; foo = foo.bind(obj); foo(); //bar foo.call(obj2); //bar
newキーワードでの呼び出し
newキーワードを関数コールの前に置く(関数であれば何でもよい。このとき該当関数はコンストラクタ関数と呼ばれる)と、次の4つが実行される。
- 空オブジェクトを作成
- このオブジェクトを関数に関連付ける(関数オブジェクトのprototypeを継承する)
- このオブジェクトを使って、該当の関数を呼び出す(=thisに設定)
- Returnのない関数なら、関数の実行終了時に、暗黙的にこのオブジェクト(this)を返す
function foo() { this.baz = "baz"; alert(this.bar + " " + baz); //undefined undefined } var bar = "bar"; var baz = new foo(); alert(baz.baz); //baz
(3行目、bazは存在するがアサインメント前なのでundefinedになる)
thisに束縛される値の優先順位は以下のとおり。bindよりもnewが優先される。
- newキーワードによる呼び出し。
- call、apply、またはbindによる明示的な束縛を伴った呼び出し
- オブジェクト参照による、暗黙的な束縛での呼び出し
- 関数のみでの呼び出し