Back

JavaScript クロージャの仕組み

JavaScript クロージャの仕組み

関数の中に別の関数を書いたとき、外側の関数の実行が終わった後でも、内側の関数が外側の関数の変数にアクセスできることがあります。この動作は多くの開発者を混乱させますが、シンプルなルールに従っています:クロージャは値ではなく、バインディングをキャプチャします。

この記事では、JavaScriptのクロージャがどのように機能するか、レキシカルスコープが実際に何を意味するか、そしてこれらの仕組みを誤解することから生じる一般的な間違いを回避する方法を説明します。

重要なポイント

  • クロージャは、関数とそのレキシカル環境(関数が作成されたときに存在していた変数バインディング)の組み合わせです。
  • クロージャは値ではなくバインディング(参照)をキャプチャするため、クローズオーバーされた変数への変更は引き続き可視化されます。
  • ループ内でletまたはconstを使用して、反復ごとに新しいバインディングを作成し、古典的なループ問題を回避します。
  • メモリの問題は、クロージャ自体からではなく、不要な参照を保持することから生じます。

クロージャとは何か?

クロージャは、関数とそのレキシカル環境(関数が作成されたときに存在していた変数バインディングのセット)の組み合わせです。JavaScriptのすべての関数は、作成時にクロージャを形成します。

function createGreeter(greeting) {
  return function(name) {
    return `${greeting}, ${name}`
  }
}

const sayHello = createGreeter('Hello')
sayHello('Alice') // "Hello, Alice"

createGreeterが戻ると、その実行コンテキストは終了します。それでも、返された関数はgreetingにアクセスできます。内側の関数は文字列「Hello」をコピーしたのではなく、バインディング自体への参照を保持しました。

JavaScriptのレキシカルスコープの説明

レキシカルスコープとは、変数アクセスが関数の実行場所ではなく、ソースコード内で関数が定義された場所によって決定されることを意味します。JavaScriptエンジンは、最も内側のスコープから外側に向かってスコープチェーンを辿ることで変数名を解決します。

const multiplier = 2

function outer() {
  const multiplier = 10
  
  function inner(value) {
    return value * multiplier
  }
  
  return inner
}

const calculate = outer()
calculate(5) // 50, not 10

inner関数は、同じ名前のグローバル変数に関係なく、そのレキシカル環境(定義された場所のスコープ)からmultiplierを使用します。

クロージャはスナップショットではなく、バインディングをキャプチャする

よくある誤解は、クロージャが変数の値を「凍結」するというものです。そうではありません。クロージャはバインディングへの参照を保持するため、変更は引き続き可視化されます:

function createCounter() {
  let count = 0
  return {
    increment() { count++ },
    getValue() { return count }
  }
}

const counter = createCounter()
counter.increment()
counter.increment()
counter.getValue() // 2

両方のメソッドは同じcountバインディングを共有します。incrementによる変更は、同じ変数を参照しているため、getValueから見えます。

古典的なループ問題: var対let

この区別は、ループで最も重要です。varを使用すると、すべての反復が1つのバインディングを共有します:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100)
}
// Logs: 3, 3, 3

各コールバックは同じiをクローズオーバーし、コールバックが実行されるときには3になっています。

letを使用すると、各反復で新しいバインディングが作成されます:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100)
}
// Logs: 0, 1, 2

ブロックスコープにより、各クロージャは独自のiを持ちます。

実用的なパターン: ファクトリ関数とイベントハンドラ

クロージャは、特化した動作を生成するファクトリ関数を可能にします:

function createValidator(minLength) {
  return function(input) {
    return input.length >= minLength
  }
}

const validatePassword = createValidator(8)
validatePassword('secret') // false
validatePassword('longenough') // true

イベントハンドラは、コンテキストを保持するために自然にクロージャを使用します:

function setupButton(buttonId, message) {
  document.getElementById(buttonId).addEventListener('click', () => {
    console.log(message)
  })
}

コールバックは、setupButtonが戻った後もmessageへのアクセスを保持します。

メモリに関する考慮事項: クロージャが実際に保持するもの

クロージャは本質的にメモリリークを引き起こしません。問題は、関数が意図せずに大きなオブジェクトや長期間存続するデータ構造への参照を保持する場合に発生します。

function processData(largeDataset) {
  const summary = computeSummary(largeDataset)
  
  return function() {
    return summary // largeDatasetではなく、summaryのみを保持
  }
}

クロージャが小さなデータだけを必要とする場合は、内側の関数を作成する前にそれを抽出してください。元の大きなオブジェクトはガベージコレクションの対象になります。

最新のJavaScriptエンジンは、クロージャを積極的に最適化します。クロージャは通常の言語機能であり、一般的な使用ではパフォーマンスの懸念事項ではありません。問題はクロージャ自体ではなく、不要な参照を保持することです。

信頼できるメンタルモデルの構築

クロージャを次のように考えてください:関数が作成されると、その周囲のスコープへの参照をキャプチャします。そのスコープには、値そのものではなく、名前と値の間の接続であるバインディングが含まれています。関数は、その生存期間中、これらのバインディングを読み取り、変更できます。

このモデルは、なぜ変更が可視化されるのか、なぜletがループ問題を修正するのか、そしてなぜクロージャが非同期境界を越えて機能するのかを説明します。関数とそのレキシカル環境は一緒に移動します。

まとめ

クロージャは、レキシカル環境とバンドルされた関数です。値ではなくバインディングをキャプチャするため、クローズオーバーされた変数への変更は引き続き可視化されます。ループ内でletまたはconstを使用して、反復ごとに新しいバインディングを作成します。メモリの問題は、クロージャ自体からではなく、不要な参照を保持することから生じます。

JavaScriptのスコープとクロージャを理解することで、変数のライフタイム、データのカプセル化、コールバックの動作について推論するための基礎が得られます。これらは、フロントエンド開発で日常的に遭遇するパターンです。

よくある質問

JavaScriptのすべての関数は、作成時にレキシカル環境をキャプチャするため、技術的にはクロージャです。クロージャという用語は通常、そのスコープの実行が終了した後に外側のスコープから変数にアクセスする関数を指します。自分のローカル変数またはグローバル変数のみを使用する関数は、意味のある方法でクロージャの動作を示しません。

クロージャは本質的にメモリリークを引き起こしません。問題は、クロージャが意図せずにガベージコレクションされるべき大きなオブジェクトやデータ構造への参照を保持する場合に発生します。これを回避するには、内側の関数を作成する前に、クロージャが必要とするデータのみを抽出してください。最新のJavaScriptエンジンはクロージャを積極的に最適化するため、一般的な使用ではパフォーマンスの懸念事項ではありません。

これは、ループでvarを使用した場合に発生します。varはブロックスコープではなく、関数スコープだからです。すべての反復が同じバインディングを共有するため、コールバックが実行されるときに最終値が表示されます。代わりにletを使用することで修正できます。letは各反復で新しいバインディングを作成します。各クロージャは、ループ変数の独自のコピーをキャプチャします。

はい。クロージャは値のスナップショットではなく、バインディング(変数への参照)をキャプチャします。クローズオーバーされた変数が変更されると、クロージャは更新された値を見ます。これが、同じクロージャを共有する複数の関数が共有変数を通じて通信できる理由です。カウンターパターンでは、incrementとgetValueメソッドが同じcountバインディングを共有する例が見られます。

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay