JavaScript面接の質問:クロージャ・this・マイクロタスクの実行順を追う

JavaScript面接の質問を、八行の実行結果から練習。クロージャが読む現在値と保存値、thisとbind、マイクロタスクの入隊順を追います。Nodeの24項目とChromeで確認した独自ケース、待ち行列表と反例を使い、修正が変える結果と実行環境の境界を説明します。

Author: PracHub

Published: 10/11/2026

JavaScript面接の質問:クロージャ・this・マイクロタスクの実行順を追う

October 11, 2026

Quick Overview

JavaScript面接を独自のセンサーパネルの八行のログで練習します。クロージャの現在値と保存値、メソッド抽出とbind、アロー関数、入れ子のマイクロタスクとthenの順序を分けて説明。Nodeの24項目とChromeの実行結果、キューの比較表で予測と修正を検証します。

Software EngineerFree

JavaScript面接の質問で出力を予測するとき、「クロージャだから」「非同期だから」だけでは、同じ変数が途中で変わった場合の説明が足りなくなります。関数が読む変数、呼び出し時のthis、コールバックがキューへ入る時刻を分けると、長いコードも予測が外れた一行までさかのぼって確認できます。

この記事では、小さなセンサーパネルのログを使い、八行の出力を組み立てます。ネットワーク応答やタイマーの速さを当てる課題ではありません。同じコードで状態の読み取りと呼び出し方を変え、どの修正がどの結果に効くかを確かめる練習です。

根拠の区分: 関数の環境とthis、Promiseの反応処理はECMAScript仕様、ブラウザのマイクロタスク処理はWHATWG HTML仕様に基づきます。パネル、ログ、比較表、説明例は独自の練習ケースです。Node 26.9.0で24項目を検証し、Chrome 154でも同じモジュールの八行の出力を確認しました。特定企業の出題頻度や選考形式を示す候補者報告は使用していません。

クロージャ、this、キューの三つを分けて追う実行結果の図

予測する前に実行条件をそろえる

この練習はstrict modeで動かし、同期処理中に三つの仕事を予約します。M1とM2はqueueMicrotask、P1は完了済みPromiseに付けた最初のthenです。M3はM1の中から追加され、P2はP1の返した値を受け取る次のthenです。

P1、P2というログの接頭辞は、Promiseのコールバックの名前です。パネルのnameもP1からP2へ変わりますが、こちらは読み取るデータです。P1:P2:armed:3なら、「P1コールバックが、名前P2のパネルを読んだ」と解釈します。

ブラウザでは以下のコードを一つのスクリプトとして実行してください。別の行ごとにコンソールへ貼ると、その間にマイクロタスクが実行される場合があり、同じ前提で比較できません。ここではDOMイベント、描画、Worker、Node固有のprocess.nextTickを含めません。

"use strict";
const say = text => console.log(text);
let phase = "draft";
const state = { value: 1 };
const capturedPhase = phase;
const savedValue = state.value;
const panel = {
  name: "P1",
  read() { return `${this.name}:${phase}:${state.value}`; }
};
const detached = panel.read;
const bound = panel.read.bind(panel);
const reader = () => `${phase}/${state.value}`;
const snapshot = () => `${capturedPhase}/${savedValue}`;

say("sync:" + reader());
queueMicrotask(() => {
  say("M1:" + reader());
  state.value = 3;
  queueMicrotask(() => say("M3:" + reader()));
});
Promise.resolve()
  .then(() => {
    say("P1:" + bound());
    return "ok";
  })
  .then(value => say("P2:" + value));
queueMicrotask(() => {
  try { say("M2:" + detached()); }
  catch (error) { say("M2:" + error.name); }
});
phase = "armed";
state.value = 2;
panel.name = "P2";
say("sync:snapshot:" + snapshot());
say("sync:end");

まず、同期処理だけの出力と、その時点で予約済みの仕事を紙へ書きます。次にM1を一つ処理し、追加されたM3を待ち行列の末尾へ置きます。最初から「Promiseが先」とまとめず、予約する行へ到達した順序を追うのが、このケースで使う方法です。

クロージャは作成時の値を丸ごと保存するか

仕様上の根拠: 通常の関数の作成では、その関数が参照する環境が記録されます。ECMAScriptの関数作成手順が一次資料です。MDNのクロージャの解説では、外側のスコープへアクセスする仕組みを例付きで確認できます。

本例のreaderはphaseの束縛と、stateからたどる現在のvalueを読みます。関数を作った時点のdraftと1が、自動で固定されるわけではありません。同期処理の最後で変数とプロパティを変更すると、後で呼ぶreaderは変更後の内容を読みます。

一方、snapshotが読むのは、先に別の名前へ保存した二つのプリミティブ値です。capturedPhaseにはdraft、savedValueには1が入り、その後どちらも変えていません。最初のreaderと最後のsnapshotは、どちらもdraft/1になりますが、同じ理由ではありません。

読み取り関数参照するもの同期処理の終わりに呼ぶなら
reader()現在のphaseとstate.valuearmed/2
snapshot()保存したcapturedPhaseとsavedValuedraft/1
() => state.value同じオブジェクトの現在のプロパティ2

constという宣言も、オブジェクト全体の内容を固定するものではありません。ここではstateを別オブジェクトへ代入し直していませんが、state.valueは1から2、さらに3へ変わります。「constだから1」と答える前に、固定されているのが束縛なのか、変更しようとしているのがプロパティなのかを確認します。

また、const savedObject = stateとしてからsavedObject.valueを読む関数を作っても、深いコピーにはなりません。このケースでは同じオブジェクトを指すため、変更後の値を読みます。「登録時の状態を保存する」要件なら、必要なプリミティブを保存するのか、複合データをコピーするのかまで決める必要があります。これは練習から導いた設計上の判断です。

メソッドを取り出すとthisはどう決まるか

仕様上の根拠: 通常の関数のthisは呼び出し時に扱われ、strict modeでは渡された値をそのまま使います。ECMAScriptのOrdinaryCallBindThisとMDNのthis解説が参照先です。

panel.read()なら、呼び出しの相手であるレシーバーはpanelです。しかしconst detached = panel.readは関数を取り出すだけなので、後でdetached()と呼んでもpanelは自動で付いてきません。本例のstrictな通常呼び出しではthisがundefinedになり、this.nameへのアクセスがTypeErrorになります。

ここではqueueMicrotask(() => detached())に相当する呼び方を、tryの中で行っています。ホストAPIがコールバックへどんなthisを渡すかに頼らず、アロー関数の中からdetached()を通常呼び出しするためです。DOMのイベントリスナーやタイマーへメソッドを直接渡す例と、条件を混ぜないようにします。

ローカル検証結果: メソッドの抽出後の通常呼び出しは例外になり、detached.call({ name: "other" })ならotherをレシーバーとして使えました。関数が同じ場所で定義されたことと、同じレシーバーで呼ばれることは別です。

M2では例外をそのコールバック内で捕まえ、エラー名だけを記録しています。実装ごとに異なる詳細なエラーメッセージを期待値にはしません。ここで確認したのはTypeErrorという分類と、その後のログが続くことです。例外を捕まえても、失敗したreadの結果が得られるわけではありません。

bindはプロパティを凍結するか

boundはpanel.read.bind(panel)で作っています。これにより、後で通常の関数として呼んでも、元のpanelを使います。ただし、panelの中身を複製しているわけではありません。

本例では、予約後の同期処理でpanel.nameをP2へ変えています。そのためP1コールバックからbound()を呼ぶと、名前P2が読み取られます。さらに、その前のM1がstate.valueを3へ変えるので、結果はP2:armed:3です。

ローカル検証結果: 別の短いケースでは、束縛済み関数を作った後に元オブジェクトのnameを変えると、変更後の名前が返りました。bindが保つレシーバーと、そのレシーバーの現在のプロパティを分けて確認しています。

では、bindと() => panel.read()はいつも同じでしょうか。panelを別オブジェクトへ代入し直せる形にすると、違いが見えます。

let currentPanel = {
  name: "old",
  read() { return this.name; }
};
const boundOld = currentPanel.read.bind(currentPanel);
const readCurrent = () => currentPanel.read();
currentPanel = {
  name: "new",
  read() { return this.name; }
};
console.log(boundOld());
console.log(readCurrent());
old
new

boundOldは元のオブジェクトを保ち、readCurrentは呼び出すときの変数から現在のオブジェクトをたどります。「thisを直すため」という理由だけで一方へ置き換えると、オブジェクトの入れ替え後の意味まで変わり得ます。固定した相手へ呼びたいのか、現在の相手へ呼びたいのかを要件として確認します。

この比較は、メモリの解放時刻やリークの有無を測定したものではありません。参照が残る設計を説明する材料にはなりますが、実際の保持経路を調べるなら、登録先や解除処理、ヒープの証拠が別に必要です。

同期処理が終わるまでに何が出力されるか

最初のreader()は、その場で呼ばれるためdraft/1です。この一つのスクリプト内でM1、P1、M2を予約しても、phaseをarmedへ変える同期処理の途中には割り込みません。phase、state.value、panel.nameを変更し、保存した値を読むsnapshotと終了ログまで進みます。

sync:draft/1
sync:snapshot:draft/1
sync:end

この時点で、待っている順序はM1、P1、M2です。二つ目のthenであるP2は、コード上に書かれているだけで、まだこの待ち行列へ入っていません。最初のthenの結果を受け取るため、その処理が進んでから反応が予約されます。

仕様上の根拠: Promiseの反応は、そのPromiseの状態と登録された処理に従って仕事として予約されます。ECMAScriptのPerformPromiseThenが一次資料です。「thenが二つ並んでいるから、最初にP1とP2を両方入れる」という追い方は、このコードの順序と一致しません。

Promiseに関係するコードがすべて後回しになるわけでもありません。今回の検証用モジュールは結果収集のため外側をPromiseで包みますが、そのexecutor内で同期部分を実行しています。本文のコードは直接ログを出す形にし、検証用の収集処理と、予測する八行を区別しています。

途中で追加されたマイクロタスクはどこへ入るか

ブラウザ仕様: WHATWG HTMLのマイクロタスク・チェックポイントは、キューから古い仕事を取り出して実行し、空になるまで処理します。該当する仕様の手順が根拠です。このケースでは、処理中に新しく追加した仕事も、そのキューの末尾で待ちます。

M1はarmed/2を読み、その後valueを3へ変え、M3を追加します。すでにP1とM2が待っているので、M3はその後です。次にP1がboundを呼び、変更後の名前と値を読み、okを返します。その完了によりP2が予約されますが、この時点でM2とM3が先に待っています。

処理が終わった地点次に待っている順序その地点での変更
同期処理の終了M1、P1、M2phase=armed、value=2、name=P2
M1の終了P1、M2、M3value=3、M3を末尾へ追加
P1の終了M2、M3、P2okを返し、P2を末尾へ追加
M2の終了M3、P2TypeErrorを記録
M3の終了P2armed/3を記録

M1の中で追加したM3と、P1の完了で追加したP2がキューの末尾へ入る図

M2はレシーバーのない呼び出しで失敗し、M3は現在のarmed/3を読み、P2はokを受け取ります。同期部分を含めた全出力は次の八行です。

sync:draft/1
sync:snapshot:draft/1
sync:end
M1:armed/2
P1:P2:armed:3
M2:TypeError
M3:armed/3
P2:ok

ローカル検証結果: この八行はNodeとChromeで一致しました。全体が同じ順番になったことに加え、M3がM2の後にあること、P2がM3の後にあることも確認しました。Nodeでは同じモジュールのrunTraceを二度呼び、各呼び出しのローカル状態が初期化され、同じ出力になることも確認しました。

この順序から「画面がこの瞬間に描画される」「何ミリ秒後に終わる」とは判断していません。タイマーやネットワークが加わる課題なら、その予約条件や実行環境を新しく確認します。今回確かめた待ち行列の順序を、そのまま任意の外部イベントの先後へ広げないことが境界です。

修正する前に、変えたい結果を一つ決める

M2でパネルを読めるようにしたいなら、detached()をbound()に変える方法と、panel.read()を呼ぶ方法を比較できます。現在のmainコードではどちらも同じpanelを使いますが、先ほどのオブジェクト入れ替えの例では意味が分かれました。

一方、M1でdraft/1を記録したいなら、レシーバーの修正だけでは足りません。M1はreaderが読む値を変える問題なので、登録時に保存した値を読む必要があります。thisを直しても、phaseを読む時点は変わりません。逆に、最新の値を読みたい要件なら、snapshotへ置き換えると古い状態が残ります。出力が変わることと、要件に合うことを別に確認します。

アロー関数も万能な置き換えではありません。外側の呼び出しで得たthisを使う関数を作ると、後からcallで別のレシーバーを渡しても切り替わりません。

function makeReader() {
  return () => this.name;
}
const first = { name: "first" };
const second = { name: "second" };
const arrow = makeReader.call(first);
console.log(arrow.call(second));
first

ローカル検証結果: この例も確認しました。makeReaderを呼ぶときのレシーバーがfirstで、その中で作ったアロー関数は外側のthisを使います。通常関数の呼び出し規則を、そのままアロー関数へ適用しないことが説明の要点です。

面接で修正を提案するときは、「M2のレシーバーだけを直します」「M1が読む値を登録時のものへ変えます」のように、変える観察結果を一つ言います。その後、他の七行が変わるかも予測して実行すると、修正の影響を確認できます。

出力の理由を短く答える練習

このケースの回答は、三つの軸を順に述べると整理できます。

readerは現在のphaseとstate.valueを読み、snapshotは先に保存した二値を読みます。boundは元のpanelを使いますが、nameの変更は見えます。同期終了時のキューはM1、P1、M2で、M1がM3、P1の完了がP2を後ろへ追加します。M2の抽出メソッドはstrictな通常呼び出しでthisを失い、TypeErrorになります。

実行前に全体の順序を予測し、実行後に違った一行だけを、束縛・呼び出し・入隊時刻のどれで説明するか選びます。どの行で前提が変わったかまで示すと、出力の理由が伝わります。

24項目のNodeチェックは、八行の全体比較、繰り返し実行、抽出メソッド、明示したレシーバー、bind後のプロパティ変更、変数の入れ替え、アロー関数、プリミティブとオブジェクト参照の違いを含みます。Chromeで確認したのは同じモジュールの八行の出力です。二つの検証範囲を同じ件数として扱っていません。

PracHubの問題今回の軸をどう使うか
Explain JavaScript Scope, Closures, Timers, and the Event Loop変数の読み取りとホストの予約条件を分ける
Predict JavaScript let and var Behaviorコールバックが共有する束縛と別になる束縛を図にする
Explain JS event loop and related conceptsキューへ入る時点と実行される時点を別々に書く
Implement a Publish-Subscribe Event Bus in JavaScript or TypeScript登録した関数、レシーバー、解除に使う参照を追う
Review Code for Memory Risks束縛済み関数が保つ相手と、実測すべき保持経路を分ける

M1の後にP1、M2、M3が待つことを手元で確認したら、イベントループを説明する問題で別の順序を予測してみましょう。最終出力だけでなく、仕事が増えた地点ごとの待ち行列も残すと、説明を検証しやすくなります。

Sources and Further Reading


Comments (0)