多くの人は、何かを作ることからJavaScriptを学び始めます。ボタンに機能を紐付け、データを取得し、DOMが変化する様子を眺める。しかし、やがて抽象化が漏れ出します。変数が宣言される前に存在していたり、関数がアクセスすべきでない変数まで覚えていたり、thisキーワードがwindowを指したり、ボタンを指したり、あるいは何も指さなかったりといった、不可解なバグが表面化します。それこそが、エンジンの内部で実際に何が起きているのかを理解するために、その「ボンネットの下」を覗き込む必要があると気づく瞬間なのです。

実行コンテキスト:2つのフェーズによるセットアップ

ブラウザやNode.jsのJavaScriptエンジンがスクリプトに遭遇したとき、人間がページをスキャンするように単に上から下へとファイルを読み込むわけではありません。代わりに、特定のコードチャンクを実行するために必要なすべてを保持するコンテナである「実行コンテキスト」を構築します。すべての実行コンテキストは、2つの異なるフェーズを経て進みます。

メモリ作成フェーズ(Memory Creation Phase)。 この最初のパスでは、エンジンはスコープ全体をスキャンし、見つかったすべての変数と関数宣言のためにメモリを割り当てます。varを見つけると、領域を確保し、プレースホルダーとしてundefinedを格納します。関数宣言を見つけると、関数本体全体を格納します。従来のfunctionキーワードで宣言された関数が、同じスコープ内のより前の行から呼び出せるのはこのためです。エンジンは実行を開始する前に、すでにその関数の存在を知っているのです。

コード実行フェーズ(Code Execution Phase)。 ここでエンジンはコードを一行ずつ実行します。代入が行われ、式が評価され、関数が呼び出されます。もしvar name = "Alice";と書いていれば、フェーズ1で用意されたプレースホルダーが、ようやく文字列に置き換わります。この2パスの挙動を理解することで、多くの混乱が解消されます。エンジンはいい加減なのではなく、厳格なセットアップルーチンに従っているだけなのです。

変数と一時的死角(Temporal Dead Zone)

varletconstのどれを選ぶかは、単なるスタイルの好みではありません。varは関数スコープであり、中括弧(ブレース)を完全に無視します。ifブロックの中で宣言しても、その外側へと漏れ出します。初期のJavaScriptではその挙動が理にかなっていたかもしれませんが、現代のアプリケーションではメンテナンス上の大きな問題を引き起こします。一方、letconstはブロックスコープです。これらは中括弧を尊重し、ブロックが終わると消滅します。

また、ホイスティング(巻き上げ)の扱いにも、微妙ながら決定的な違いがあります。varの宣言は巻き上げられ、直ちにundefinedで初期化されます。letconstも技術的には巻き上げられます。エンジンは宣言の行に到達する前に、それらが存在することを知っています。しかし、それらは初期化されません。これらは「一時的死角(Temporal Dead Zone)」と呼ばれる宙吊りの状態に置かれます。宣言の行が実行される前にそれらを読み取ろうとすると、こっそりundefinedになるのではなく、厳格なReferenceErrorが発生します。このクラッシュは、実は役に立つのです。初期化されていないデータに基づいてロジックが進んでしまうのを防いでくれるからです。

レキシカルスコープとクロージャ

スコープは「この変数にどこからアクセスできるか?」という単純な問いに答えるものです。JavaScriptはレキシカルスコープを採用しています。これは、関数のアクセス権が「どこで呼び出されたか」ではなく、「ソースコードのどこに物理的に書かれたか」によって決まることを意味します。ある関数の中で別の関数を定義した場合、内側の関数は外側へ手を伸ばして親の変数を読み取ることができます。しかし、外側の関数が内側へ手を伸ばすことはできません。この関係は静的です。その内側の関数をモジュール間で渡したり、グローバル変数に保存したり、全く別のファイルから呼び出したりしても、その関数は自分が生まれたスコープの変数を記憶し続けます。

この挙動によって、自然に「クロージャ(Closure)」が生まれます。クロージャは、内側の関数が外側のスコープの変数への参照を保持するときに形成されます。外側の関数の実行が終了し、そのローカル変数がガベージコレクションの対象となるべき状態になっても、内側の関数がそれらを必要としている限り、JavaScriptはメモリ内にそれらを保持します。内側の関数は、自分を取り巻く環境を携えて持ち運ぶのです。

これは単なる学術的な詳細ではありません。クロージャは、明示的なアクセス修飾子を持たない言語において、プライベートな状態(private state)を作成するための実用的な方法を提供してくれます。

function makeCounter() {
  let count = 0;
  return function() {
    count = count + 1;
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2

ここでは、countは隠蔽されています。返された関数以外のどこからも、これをリセットしたり直接読み取ったりすることはできません。これは、スコープの仕組みだけで構築されたプライベート変数なのです。

実践におけるホイスティング

JavaScriptでは「宣言がトップに移動する」とよく言われます。これは便利なメンタルモデルですが、コードが実際に書き換えられるわけではありません。メモリ作成フェーズにおいて、エンジンは実行が始まる前に単に宣言を登録するだけです。var x = 5; のような文は、宣言と初期化が切り離されているかのように振る舞います。var x; という宣言は早い段階で処理され、undefined で初期化されます。x = 5; という代入は、書いた通りの場所に留まり、実行フェーズで実行されます。

このため、var は予期しない結果を招くことがあります。関数の冒頭付近で使用される変数が、たとえ末尾に巨大な代入文があったとしても undefined を保持してしまう可能性があるからです。letconst を使用すれば、この特有の地雷(foot-gun)を回避できます。なぜなら、一時的死区(Temporal Dead Zone)によって、宣言を使用箇所よりも上に書くことが強制されるからです。

this の値が決まる仕組み

実行コンテキストとスコープが変数の所在を決定するなら、this は現在どのオブジェクトが制御権を持っているかを決定します。レキシカルな変数とは異なり、this は関数がどこに書かれているかによって決まるのではありません。関数がどのように呼び出されたかによって、完全に決まります。

Default binding は、プレーンな独立した関数を呼び出したときに発生します。非厳格モード(non-strict mode)では、this はグローバルオブジェクトにフォールバックします。ブラウザの場合、それは window です。コンテキストを指定せずに関数を呼び出すと、気づかないうちに誤ってグローバルな状態に触れてしまう可能性があります。

Implicit binding は、関数をオブジェクトのメソッドとして呼び出したときに発生します。user.sayName() と書いた場合、ドット演算子がエンジンに対して、その呼び出しの間 thisuser に設定するよう静かに指示します。定義された場所よりも、呼び出された場所の方が重要になります。

Explicit binding を使えば、すべてを手動で上書きできます。call()apply() は、this を提供した特定のオブジェクトに強制しながら、関数を即座に呼び出します。両者の唯一の違いは引数の渡し方です。call はカンマ区切りのリストを受け取り、apply は配列を受け取ります。bind() は動作が異なります。これは関数をすぐに呼び出すのではなく、this を指定した値に永久に固定した新しい関数を返します。これは、他の場所に渡される際にコンテキストを失ってしまう可能性があるコールバックにおいて、非常に価値があります。

New binding は、関数呼び出しの前に new キーワードを使用するときに作用します。エンジンは全く新しい空のオブジェクトを構築し、そのプロトタイプへのリンクを設定し、コンストラクタ内の this をその新しいインスタンスに向けます。

長く使えるコードを書く

メカニズムを理解することは、技術の半分に過ぎません。もう半分は、6ヶ月後の人間が読めるコードを書くことです。

DRY (Don't Repeat Yourself) は当たり前のことのように聞こえますが、常に破られています。もし、3つの異なるファイルで同じバリデーションロジックやAPI呼び出しのパターンを書いていることに気づいたら、それを抽出してください。一つの関数にまとめましょう。「信頼できる唯一の情報源(One source of truth)」があれば、要件が変わったときに更新すべき場所が一つで済み、その時間の節約効果は計り知れません。

KISS (Keep It Simple, Stupid) は、エゴに対する防御策です。ネストされた三項演算子や一行のクロージャは賢く見えますが、デバッグの際に何時間ものコストを強いることになります。先ほど示したクロージャのパターンは強力ですが、単に「できるから」という理由で5段階もネストさせるのは間違いです。シンプルなコードこそが、チームの入れ替わりや本番環境のトラブル、そして何が起きたのか誰も覚えていないような真夜中のアラートを生き延びることができるのです。

真の報酬

実行を学ぶことは