JavaScript
 Computer >> コンピューター >  >> プログラミング >> JavaScript

Web開発の基礎:スコープとJavaScriptクロージャ徹底解説 – 初心者向け入門ガイド

JavaScriptにおける最重要キーワードの一つが「クロージャ(Closure)」です。FAANGと呼ばれる大手テック企業の採用面接でも頻出のテーマとなっています。この記事では、クロージャとスコープの概念をシンプルなコード例とともにわかりやすく解説し、最後に大手テック企業の面接で実際に出題されたサンプル問題形式の内容まで踏み込みます。

スコープ(Scope)とは

「プロジェクトのスコープ内/スコープ外」という表現を耳にしたことはありませんか?

この答えを考えるとき、潜望鏡や望遠鏡を思い浮かべると理解しやすくなります。これらの道具は、レンズの範囲内にあるものだけを映し出します。それが「スコープ内」です。レンズの直径の外側にあるものは見えず、そこに光を当てることもできません。JavaScriptには、ローカルスコープグローバルスコープレキシカルスコープという3つの重要かつ明確に異なるスコープが存在します。それぞれ順番に見ていきましょう。

ローカルスコープ(Local Scope)

ローカルスコープは、3つの中で最も小さなスコープです。関数を宣言すると、波括弧({})の中身はすべてその関数にとって「ローカル」なものとみなされます。JavaScriptエンジンは関数を読み込む際に変数を宣言し、関数の実行が終わるとそれらの変数を破棄します。

function greeting() {
 var websiteName = 'Career Karma';
 return `Hello ${websiteName}`;
}
 
console.log(greeting()); // Hello Career Karma
console.log(websiteName); // ReferenceError: websiteName is not defined

ご覧のとおり、greeting関数を実行した結果をconsole.log()すると、関数の実行後でもwebsiteNameへアクセスでき、「Hello Career Karma」という文字列が得られます。一方、関数の内部で宣言されたwebsiteNameを直接console.log()するとエラーが発生します。これは、その変数が定義されていないためです。

websiteNameが未定義になる理由は、変数が関数の呼び出し時に作成され、終端ステートメントの実行時に破棄されるからです。特別な仕組みがない限り、関数の外部から関数の内部にあるものへアクセスすることはできません。

グローバルスコープ(Global Scope)

グローバルスコープは、文字どおりの意味を持つスコープです。関数の外側で宣言された要素は、すべてのスクリプト、メソッド、関数からアクセスして利用できる共通の領域に保持されます。

let counter = 0; // グローバル -- 関数の外で宣言

const add = () => { // 関数宣言
   let counter = 0; // ローカル -- 関数の中で宣言
   counter += 1;
   // counterを1増やす -- どちらのcounter変数が増えた?
   return counter;
}

add(); // 呼び出し
add(); // 3回
add(); // 呼び出す
console.log(counter) // 結果は3?それとも0?なぜ?

上記のコードの最後でcounterをconsole.log()すると、何が出力されるでしょうか?

コードの流れを順に追ってみましょう。

  1. counter変数がグローバル環境で宣言・初期化される。
  2. add関数がグローバル環境で宣言される。
  3. addが呼び出される。
  4. counter変数がローカル環境で宣言・初期化される。
  5. ローカルのcounterが1増える ← なぜグローバルではなくローカル?
  6. counterが返され、関数が終了する。
  7. addが再度呼び出される。
  8. 手順4〜6を繰り返す。
  9. 手順3〜6をもう一度繰り返す。
  10. console.log(counter) ← 何が出力される?

関数は毎回counterが1になった時点で終了するため、ローカルのcounter変数は関数が実行されるたびに0として再宣言・再初期化されます。どんなことがあっても、ローカルレベルではカウンターは必ず1で止まります。

関数は自分のスコープ内に変数を見つけると、グローバルスコープを探しに行きません。そのため、グローバル変数は決して変更されません。したがって、console.log()の出力は0になります。そのステートメントの環境の中で最も近くに定義されている変数が、グローバル環境のcounterだからです。

レキシカルスコープ(Lexical Scope)

レキシカルスコープは、JavaScriptにおける最も基本的な概念の一つです。「関数や変数の宣言位置によって、コードの一部からはアクセス可能になり、別の部分からはアクセス不可能になる」という考え方で、すべては各変数と関数の宣言場所に依存します。

次のコードを見てみましょう。

const init = () => { // <== 外側の関数
 const var1 = 'Career'; // 外側のスコープ
 const second = () => { // <== 内側の関数
   const var2 = 'Karma'; // 内側のスコープ
   console.log(var1); // Career
   console.log(var2); // Karma
   return var1 + " " + var2;
 };

 // console.log(var2); // undefined

 return second();
};
init();

ここにはネストされた関数があります。init()関数はvar1という変数を宣言し、secondという関数を宣言して、second()を呼び出します。

コンパイラが最初にこのコードを通過するとき、まず全体を俯瞰します。

  1. init()関数
  2. init()の呼び出し

この時点では、init()関数の中身はまだ見えません。関数が存在することだけが分かります。init()関数が呼び出されると、コンパイラは今度は関数の中身を俯瞰します。

  1. var1
  2. second()関数
  3. second()の呼び出し

init()関数は、second()ブロックの内部で何が起きているかを知りません。自分のレキシカル環境(lexical environment)、つまり周囲の状態にあるものしか見えないのです。

ネストされた各関数は、より小さな容器に入っているようなものです。マトリョーシカ人形を思い浮かべてください。人形たちは、自分の容器の中で起きていることと、親ですでに起こったこと・宣言されたことしか知りません。例えば一番大きな人形は、自分の容器の中に次の人形が存在することだけを知っています。セットの他の人形については知らず、自分のレキシカル環境(状態)と、すでに起こったこと(外側のスコープ)だけを認識しているのです。

要するに、次の2点が重要です。

  1. 外側のスコープからは内側のスコープが見えない。
  2. 内側のスコープからは外側のスコープにアクセスできる。

外側のスコープは内側で何が起きているか見えないため、これは一方向の関係だと言えます。内側は外側の変数を見て利用できますが、外側は内側を見られません。これがレキシカルスコープです。

レキシカルスコープの美しさは、変数の値がコード上の配置によって決まる点にあります。関数はまず自分のローカル環境の中で変数の意味を探し、見つからなければ、その関数を定義した関数へ移動します。そこでも見つからなければ、チェーンを辿って次に定義された関数へと移動していきます。

これはJavaScriptにおいて非常に重要な概念で、フレームワークの仕組みを学ぶにつれて何度も登場します。外側から内側へは値を渡せますが、「上方向」へ渡すことは決してできません。そしてこれこそが、本題であるクロージャを理解するうえで非常に重要なのです。

クロージャ(Closure)

クロージャの定義はレキシカルスコープとよく似ています。両者の主な違いは、クロージャが高階関数(Higher Order Function)であるのに対し、レキシカルスコープはそうではないという点です。高階関数の基本的な特徴は、「関数を返す」か「関数をパラメータとして受け取る」かのいずれかであることです。

クロージャとは、その関数が後から呼び出されるときであっても、自身のレキシカルスコープにアクセスできる関数のことです。

クロージャもレキシカルスコープも、独自の変数を持ち、親関数の変数やパラメータにアクセスでき、グローバル変数を使用できます。次のコードを見てみましょう。

function greeting() { // 外側のスコープ(親関数)
 const userName = "CrrKrma1952"; // 親の変数
 function welcomeGreeting() { // 内側の関数
   console.log("Hello, " + userName); // 親の変数にアクセス
   return "Hello, " + userName; // 終端ステートメント
 }
 return welcomeGreeting; // 関数を返す(これにより高階関数になる)
} // greeting() の終わり

const greetUser = greeting();
greetUser(); // Hello, CrrKrma1952
  1. greeting()関数が存在するが、中身はまだ不明。
  2. greetUserが存在するが、中身はまだ不明。
  3. greetUser() — 前の行を呼び出し、それがさらにgreeting()関数を呼び出す。
  4. userNameが宣言される。
  5. welcomeGreeting()が存在するが、中身はまだ不明。
  6. welcomeGreeting()ブロックの下のreturn文が、同じ関数を返す。
  7. console.log('Hello, ' + userName) — このconsole.logは親スコープにアクセスしてuserNameの値を取得できる。
  8. 終端ステートメントが関数を終了させ、コードブロック内の変数を破棄する。

このコードでは、関数をネストさせることで情報を受け渡し、後から親スコープへアクセスできるようにしています。これがクロージャの本質的な動作です。

まとめ

この記事では、JavaScriptの重要トピックである「スコープ」と「クロージャ」について解説しました。ローカル・グローバル・レキシカルという3種類のスコープの違い、そしてクロージャが高階関数としてどのように機能するかを、コード例を通じて確認しました。

さらに理解を深めるために、複数の記事やドキュメントに目を通すことをおすすめします。教え方にはさまざまな視点があり、それはつまり、学び方にも多くのアプローチがあるということです。この入門記事が皆さんの学習の一助となれば幸いです。クロージャの学習がさらに進むことを願っています!

  1. JavaScriptの関数プロトタイプとは?仕組みと使い方を解説

    JavaScriptで作成されたすべての関数には、JavaScriptエンジンによって自動的にprototypeプロパティが追加されます。このprototypeプロパティはオブジェクトであり、デフォルトではconstructorプロパティを含んでいます。関数のプロトタイプには、以下のようにしてアクセスできます。functionName.prototypeコンストラクタ関数を使ってオブジェクトを生成する場合、このprototypeプロパティを活用することで、同じコンストラクタから作成された複数のオブジェクト間でメソッドやプロパティを効率的に共有することができます。これにより、各インスタンスごとに

  2. JavaScriptでのメソッド借用:call()・apply()・bind()の使い方を解説

    JavaScriptでは、call()、apply()、bind()という3つのメソッドを使うことで、あるオブジェクトが持つメソッドを別のオブジェクトが「借用」できます。これは、本来そのメソッドを持たないオブジェクトに対して、thisの参照先を差し替えてメソッドを実行できる便利なテクニックです。 3つのメソッドの違い call():引数をカンマ区切りで個別に渡し、即座に関数を実行します。 apply():引数を配列(または配列風オブジェクト)として渡し、即座に関数を実行します。 bind():thisを固定した新しい関数を返すだけで、即座には実行されません。返された関数は後から好きなタイミン