コンテキスト、スコープ、クロージャについて
学習の目的
この単元を完了すると、次のことができるようになります。
- JavaScript で変数のスコープを指定する方法を特定する。
- 関数がコールされた場所によって
this
がどのように変化するかを説明する。 - クロージャを使用して、関数の変数への参照をキャプチャする。
どのようなプログラミング言語でもそれを理解するために重要な点は、変数の可用性、状態の維持方法、その状態へのアクセス方法を把握することです。
JavaScript では、変数の可用性と可視性をスコープといいます。スコープは、変数が宣言された場所によって決まります。
コンテキストは、コードの現在の実行状態です。この情報には this
ポインタを使ってアクセスします。
変数のスコープ
JavaScript の変数は、var
、let
、const
のいずれかのキーワードを使用して宣言されます。キーワードをコールする場所によって、作成される変数のスコープが決まります。
これらの 3 つの違いを理解するということは、突き詰めれば、代入の変更可能性と (補足的に) 非関数ブロックスコープの 2 つの要因を認識することです。代入の変更可能性については、このモジュールの最初の単元で説明しました。ここではスコープについて解説します。
スコープの面白さ
スコープは、変数または引数がコードのどのブロックで宣言されたかによって決まります。ただし、var
はコードの非関数ブロックを認識しません。つまり、if
ブロックまたはループブロックで var
をコールすると、変数が直近の囲み関数のスコープに代入されます。この機能を巻き上げといいます。
let
または const
を使用する場合は、宣言された実際のブロックが常に引数または変数のスコープになります。この点を示すおなじみの思考演習があります。
function countToThree() { // i is in the scope of the countToThree function for (var i = 0; i < 3; i++){ console.log(i); // iteration 1: 0 // iteration 2: 1 // iteration 3: 2 } console.log(i); // What is this? }
for
ループの内側での console.log
の出力は驚くことではなく、反復ごとに i
の値が出力されます。ここで驚くであろう点は、最後の console.log
ステートメントで、3
が出力されます。i
が for
ループのスコープと思われるものの内側で宣言されているため、エラーと思ったかもしれません。けれども、巻き上げにより、i
は実際には countToThree
のスコープに属します。
巻き上げは必ずしも悪いものではありませんが、誤解されることが多く、変数のリークが発生することや、変数がコードブロックで再宣言された場合に誤って上書きされることがあります。こうした誤解に対処するために、この言語に let
と const
が追加され、ブロックレベルのスコープを使用して変数を作成できるようになっています。思考演習をもう一度見てみましょう。
for (let j = 0; j < 3; j++){ console.log(j); // 0 // 1 // 2 } console.log(j); // error
var
の代わりに let
を使用すると、変数が for
ループのコンテキスト内にのみ存在するようになります。ループが閉じた後でこの変数にアクセスしようとするとエラーが発生します。
コンテキストと this
これまで解説してきたとおり、JavaScript はオブジェクトを中心に展開します。関数が呼び出されるときは、常にその関数の周辺にオブジェクトコンテナが存在します。このオブジェクトコンテナがそのコンテキストで、this
キーワードはそのコンテキストをポイントします。つまり、コンテキストは関数が宣言されたときに設定されるのではなく、関数が呼び出されたときに設定されます。
関数はオブジェクト間で受け渡しができるため、this
のポイント先が変わることがあります。
たとえば、これが JavaScript のオブジェクトであるとします。
var obj = { aValue: 0, increment: function(incrementBy) { this.aValue = this.aValue + incrementBy; } }
インクリメント関数にアクセスすると、予想どおり機能します。
obj.increment(2); console.log(obj.aValue); // 2
では、この関数を別の変数に代入した場合に、どのように機能するか見てみましょう。
//assign function to variable var newIncrement = obj.increment; //now invoke through the new pointer newIncrement(2); console.log(obj.aValue); // still 2 not 4
変数を newIncrement
に代入すると、関数が異なるコンテキストで実行されます。この場合は、具体的にグローバルコンテキストです。
グローバルオブジェクト
あなたが開発者として記述した包含オブジェクトが 1 つもない JavaScript を実行すると、グローバルオブジェクトで実行されます。このため、こうした状態で呼び出された関数は、グローバルコンテキストで実行されていると考えられ、this
にアクセスするとこのグローバルコンテキストをポイントします。
ブラウザでは、グローバルコンテキストは window
オブジェクトです。この点は、ブラウザ開発者ツールで次のとおり実行すれば、簡単にテストできます。
this === window; // true
increment
の例で、increment
関数を newIncrement
変数に代入すると、コンテキストが呼び出された場所、つまりグローバルオブジェクトに移ります。この動作は簡単に示すことができます。
console.log(this.aValue); // NaN console.log(window.aValue); // NaN console.log(typeof window.aValue); // number
新しいコンテキストを使用して this.aValue
を代入しようとすると、JavaScript オブジェクトの変更可能性が発揮されます。初期化されていない新しい aValue
プロパティが this
に追加されます。初期化されていない変数での算術処理に失敗し、値が NaN
になります。けれども、window
に aValue
が存在することを確認でき、その値は実際には数値です。
オブジェクトのあるコンテキスト
increment
の例で、increment
関数がドット表記の obj
を使用して呼び出される限り、this
は obj
をポイントします。あるいは、一般的に関数を someObject.function()
としてコールすると、ドットの左側にあるものが、その関数が呼び出されるコンテキストになります。
Bike
の例を考えてみます。Bike
コンストラクタは、this
参照を使用していくつかのプロパティを定義します。また、this
を参照するそのプロトタイプに代入された関数があります。
const Bike = function(frontIndex, rearIndex){ this.frontGearIndex = frontIndex || 0; this.rearGearIndex = rearIndex || 0; ... } ... Bike.prototype.calculateGearRatio = function(){ let front = this.transmission.frontGearTeeth[this.frontGearIndex], rear = this.transmission.rearGearTeeth[this.rearGearIndex]; if (front && rear) { return (front / rear) ; } else { return 0; } };
その後、new
キーワードを使用して Bike
をコールします。
const bike = new Bike(1,2); console.log(bike.frontGearIndex); // 1 console.log(bike.rearGearIndex); // 2
一見すると、Bike
コンストラクタをグローバルコンテキストで呼び出しているように見えます。けれども、この new
キーワードがコンテキスト (と this
ポインタ) を、代入の左側にある新しいオブジェクトに移しています。
いずれかの関数を呼び出すと、この時点では関数が bike
オブジェクトのメンバーであるため、そのオブジェクトを包含コンテキストとして使用します。
let gearRatio = bike.calculateGearRatio(); console.log(gearRatio); // 3
コンストラクタを間違った方法で呼び出してしまうことがよくあります。以下は、うまくいかない可能性がある場合を示しています。
const badBike = Bike(1,2); console.log(badBike.frontGearIndex); // error console.log(window.frontGearIndex); // 1
new
を使い忘れると、Bike
が他の関数のようにコールされ、this
の window
から新規作成されたオブジェクトへの重要な移行に失敗します。ここでオブジェクトの変更可能性が発揮され、代わりに frontGearIndex
プロパティが window
に追加されます。
クロージャ
関数は、宣言されると、その中で宣言される変数または引数への参照と、それが該当するスコープ内で参照する変数をすべて保持します。関数の変数や引数と、その関数が該当するスコープからのローカル変数や引数をすべてまとめたものをクロージャといいます。
この関数と、この関数が返す関数を考えてみます。
const greetingMaker = function(greeting){ return function(whoGreeting){ return greeting + ", " + whoGreeting + "!"; } } const greetingHello = greetingMaker("Hello"); const greetingBonjour = greetingMaker("Bonjour"); const greetingCiao = greetingMaker("Ciao"); console.log(greetingHello("Gemma")); // Hello, Gemma! console.log(greetingBonjour("Fabien")); // Bonjour, Fabien! console.log(greetingCiao("Emanuela")); // Ciao, Emanuela!
greetingMaker
が呼び出された場合、通常ならばその greeting
引数は、コールの対象のライフサイクルの間しか持続しないと思うでしょう。
けれども、返された関数は、greetingMaker
のスコープで greeting
引数を参照し続けます。そのため、最終的に greetingHello
/Bonjour
/Ciao
を介して呼び出された時点でもアクセスできます。
この言語を理解し使用するためには、クロージャについても把握することが欠かせません。