【実務・中級編】【中級者向け】関数宣言と関数式の巻き上げ優先順位:AST(抽象構文木)から読み解く解析順序 – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

コードレビューの場で「なぜこのコードが動かないのか、あるいはなぜ動いてしまうのか」を説明できないエンジニアに何度も遭遇してきた。
特に、変数や関数の「巻き上げ(Hoisting)」に関する挙動は、JavaScript初学者から中級者へステップアップする際の最初の難所であり、同時にプロダクション環境で潜む「難解なバグ」の温床となる。

ネット上の記事では、「`var`は巻き上げられるが、`let`や`const`はTDZ(一時的デッドゾーン)に入る」「関数宣言は関数式よりも優先される」といった表面的なルールだけがまことしやかに語られがちだ。しかし、V8エンジンやSpiderMonkeyといったモダンJSエンジンが、ソースコードをどのようにAST(抽象構文木)へ変換し、スコープを構築しているかというランタイムの根本を理解していなければ、複雑なクロージャや非同期処理が絡むコードベースで正確な予測を立てることは不可能だ。

今回は、ASTのパースプロセスと実行コンテキストの生成フェーズに踏み込み、関数宣言と関数式の巻き上げ優先順位の真実を解き明かす。実務のコードレビューで即座に使える堅牢な設計パターンまでを一気に叩き込む。

—

1. JavaScriptエンジンはコードをどう読んでいるか:ASTと巻き上げの正体

多くのエンジニアは「巻き上げ」を「コードが物理的に上部に移動する現象」だと誤解している。しかし、DOMやV8エンジンのメモリ空間において、そんな物理的な移動は一切起きていない。

JavaScriptの実行は、大きく分けて以下の2つのフェーズで進行する。

1. 生成フェーズ(Creation Phase / 構文解析・コンパイル)

  • ソースコードが字句解析(Lexical Analysis)および構文解析(Parsing)され、AST(抽象構文木)が構築される。
  • エンジンはASTを走査し、変数名や関数名を見つけて実行コンテキスト(Execution Context)の環境レコード(Environment Record)にメモリを割り当てる。これが「巻き上げ」の正体である。

2. 実行フェーズ(Execution Phase)

  • 上から順にコードが実行され、値の代入や関数の呼び出しが行われる。

つまり、巻き上げとは「コードの移動」ではなく、「コード実行前に、スコープ内の識別子がメモリ上に登録されるプロセス」に他ならない。

—

2. 関数宣言 vs 関数式:AST解析における優先順位のメカニズム

では、同じスコープ内に「同名の関数宣言」と「同名の関数式(あるいは変数)」が混在するとき、V8エンジンはどちらを優先してメモリに焼き付けるのだろうか?

以下のコードスニペットを見てほしい。あなたはこのコードの出力結果を正確に予測できるだろうか?

// 【コードレビュー対象:この出力はどうなるか?】
console.log(typeof processPayload); // 出力結果は?

var processPayload = function() {
return “Function Expression”;
};

function processPayload() {
return “Function Declaration”;
}

console.log(typeof processPayload()); // 出力結果は?

実行結果とV8の内部挙動

このコードの実行結果は以下のようになる。

“function”
“Function Expression”

なぜ、最初の `typeof processPayload` で `”function”` が返り、かつその中身が関数式の方で上書きされているのか?
その答えは、AST生成時のスコープへの識別子登録の優先順位にある。

1. 関数宣言の絶対的優位性

生成フェーズにおいて、エンジンはまず関数宣言(Function Declaration)を真っ先に環境レコードに登録する。この際、関数宣言は「識別子名」とその「関数オブジェクト全体」が紐づけられた状態でメモリに固定される。

2. 変数宣言(var)と関数式の後追い処理

次に、`var` による変数宣言が登録されるが、変数としての `var processPayload` は「初期値 `undefined`」として登録される。もしすでに同名の識別子(関数宣言によって登録されたもの)が存在する場合、`var` の宣言はそれを上書きせず、無視(あるいは既存のバインディングを維持)する。

そのため、最初の `console.log` の時点では、関数宣言された関数オブジェクトがメモリ上に存在するため `”function”` となり、まだ実行フェーズに達していない(変数への関数式の代入が行われていない)ため、関数宣言の中身が維持されている。

しかし、実行フェーズに入ると話は別だ。
コードが上から実行されるにつれ、以下の順番で処理が流れる。

// — 実行フェーズのシミュレーション —

// 1. すでに生成フェーズで関数宣言がメモリに存在(中身は “Function Declaration”)
// 2. 実行フェーズがこの行に到達し、変数に「関数式」が代入される
processPayload = function() {
return “Function Expression”;
};

// 3. ここで実行されるため、中身は “Function Expression” に書き換わっている
console.log(processPayload()); // “Function Expression”

この挙動を理解していないと、「なぜか意図したモジュールやハンドラーが古い定義のまま実行される」「非同期処理の中で予期せぬ関数が呼ばれる」という、デバッグが極めて困難なバグを引き起こす。

—

3. レビューで一発レッドカード:アンチパターンと堅牢な設計

実際のフロントエンド開発(React/Vueのコンポーネント設計や、Node.jsのAPIルーティングなど)において、この巻き上げの挙動に依存したコードを書くことは「技術的負債の故意的な発生」と同義である。

❌ 悪い設計:巻き上げに依存したカオスなコード

// アンチパターン:どこで何が定義されているか追いかけにくい
function handleUserAction(actionType) {
if (actionType === ‘LOGIN’) {
return executeLogin(); // まだ下で定義されている関数宣言を呼び出している
}
return executeLogout();
}

// 可読性が低く、ASTのパース順序に依存した危険な実装
function executeLogin() {
// …ロジック
}

var executeLogout = function() {
// …ロジック
};

このようなコードは、コードベースが巨大化した際にリファクタリングを極めて困難にし、V8の最適化(インラインキャッシュの効き具合など)においても悪影響を及ぼす可能性がある。

—

⭕ 良い設計:現代のモダンJSにおけるベストプラクティス

テクニカルリードとして、チームには以下の鉄則を徹底させるべきだ。

1. `var` は完全に駆逐し、`let` と `const` のみを使用する。

  • `let` と `const` はTDZ(一時的死地)により、宣言前のアクセスを強制的にエラー(`ReferenceError`)にしてくれる。これにより、巻き上げに起因するバグをコンパイル/実行初期段階で完全にシャットアウトできる。

2. 関数は「アロー関数(Arrow Function)」または「定数代入された関数式」として定義し、使用する前に必ず記述する。

  • コードの可読性(メンタルモデル)を「上から下へ流れる自然な順序」に一致させる。

プロダクションクオリティの設計コード例

以下に、非同期API連携とDOM操作(あるいはモダンなデータ処理)を想定した、保守性の高い堅牢なモジュール設計のコードを示す。

/

  • @file user-controller.js
  • @description 堅牢なスコープ管理とアロー関数を用いたユーザーデータ処理モジュール

/

‘use strict’;

// 依存関係や定数の定義(スコープの最上位)
const API_ENDPOINT = ‘https://api.example.com/v1/users’;
const DEFAULT_RETRY_COUNT = 3;

/

  • ユーザーデータをフェッチしてDOMを更新するメインコントローラー
  • @param {string} userId – 対象のユーザーID
  • @returns {Promise}

/
const initializeUserProfile = async (userId) => {
// 入口のバリデーションを先頭に配置(ガード cláusula)
if (!userId || typeof userId !== ‘string’) {
throw new TypeError(‘Invalid userId provided to initializeUserProfile.’);
}

try {
// 非同期API連携
const userData = await fetchUserDataWithRetry(userId, DEFAULT_RETRY_COUNT);

// DOM操作やビューの更新処理を分離して呼び出し
renderUserProfileView(userData);

} catch (error) {
console.error(‘[ProfileInitializationError]:’, error.message);
renderErrorStateView(error);
}
};

/

  • リトライロジックを含む堅牢なフェッチ関数(関数式・アロー関数)
  • @private

/
const fetchUserDataWithRetry = async (userId, retries) => {
let attempt = 0;

while (attempt < retries) { try { const response = await fetch(`${API_ENDPOINT}/${userId}`); if (!response.ok) { throw new Error(`HTTP Error: ${response.status}`); } return await response.json(); } catch (err) { attempt++; if (attempt >= retries) throw err;
// バックオフ待機(実務で必須の非同期処理)
await new Promise((resolve) => setTimeout(resolve, 1000 attempt));
}
}
};

/

  • 正常系のビュー描画処理
  • @private

/
const renderUserProfileView = (userData) => {
// パフォーマンス配慮:DOMの頻繁なアクセスを避けるためのDocumentFragment活用など
const container = document.getElementById(‘user-profile-container’);
if (!container) return;

// 効率的なDOM構築
const userNameElement = document.createElement(‘h2’);
userNameElement.textContent = userData.name;

container.replaceChildren(userNameElement); // 一括置換による再描画(Reflow)の最小化
};

/

  • 異常系のビュー描画処理
  • @private

/
const renderErrorStateView = (error) => {
const container = document.getElementById(‘user-profile-container’);
if (!container) return;

container.textContent = `データの取得に失敗しました: ${error.message}`;
};

// モジュールとしての公開
export { initializeUserProfile };

—

4. チーフアーキテクトからの提言

JavaScriptの言語仕様やランタイムの挙動(AST、巻き上げ、実行コンテキスト)を深く理解することは、単に「バグらないコードを書く」ためだけではない。

ブラウザのメインスレッドをブロックしない非同期設計、DOMのReflow/Repaintを最小限に抑える効率的なレンダリングパイプラインの制御、そしてV8エンジンがメモリ効率良く最適化(JITコンパイル)しやすいコードを書くこと。そのすべての土台となるのが、この「コードがどのように解釈されるか」という解像度の高い知識である。

「動くからいいや」という妥協を捨て、ASTの深部まで見通す視点を持つこと。それこそが、プロダクトの寿命を延ばし、チーム全体の開発生産性を劇的に引き上げる真のエンジニアリングである。次のコードレビューからは、ぜひこの視点でコードを精査してほしい。

タイトルとURLをコピーしました