JSインタビュー頻出問題10選:本当に何が問われているのか
hoisting、クロージャ、this、イベントループ、型変換を使って10のJavaScript面接問題を解説し、出力と意図を示します。
JavaScriptのインタビュー問題の多くは、出力結果を答えさせることが目的ではありません。その結果を生み出すモデルを理解しているかどうかを問うているのです。ホワイトボードに console.log(x); var x = 5; と書くシニアインタビュアーは、undefined が出力されることをすでに知っています。彼らが評価しているのは、「ホイスティングが宣言をトップに移動させる」というフレーズに頼らずに なぜ そうなるかを説明できるかどうかです。なぜなら、そのフレーズはメンタルモデルであって、メカニズムではないからです。選考を通過する候補者は、ランタイムの挙動を正確に言語化し、出力を予測し、その理由を明確な2文で口頭で説明できる人です。
この記事では、10個の質問を通じて10の基本的な概念を取り上げます。実行モデル、Temporal Dead Zone、クロージャ、レキシカルスコープ、this バインディング、イベントループ、型強制(coercion)がその対象です。各質問は4つのパートに分けて解説します:コードスニペット、正確な出力とその理由、インタビュアーが本当に問いたいこと、そして次に来るであろうフォローアップ質問です。すべての出力は仕様で定義されており、現行のどのエンジンでも再現可能です。これらの挙動は現行のすべてのエンジンで安定しています。これらは言語のセマンティクスであり、バージョン固有の癖ではありません。
重要なポイント
varのホイスティング問題は、出力がundefinedであることを知っているかを問うのではなく、JavaScriptがスコープに入る際、いかなる行が実行される前に変数バインディングを生成することを理解しているかを問うています。letとconstもホイストされますが、宣言行に到達するまでTemporal Dead Zone内で未初期化のまま残るため、早期に読み取ろうとするとundefinedを返す代わりにReferenceErrorがスローされます。setTimeout-in-a-for-loop の古典的なバグでは、varはループの最終値(3 3 3)を出力します。これはすべてのコールバックが1つの関数スコープのバインディングを共有するためです。一方、letは各イテレーションの値(0 1 2)を出力します。これはイテレーションごとに新しいバインディングが生成されるためです。thisは関数が書かれた場所ではなく、関数がどのように呼び出されるかによって決まります。アロー関数は例外で、独自のthisを持ちません。- Promiseのコールバック(マイクロタスク)は、遅延が
0ms であっても、常にsetTimeoutのコールバック(マクロタスク)より先に実行されます。
ホイスティングと実行モデルに関するJavaScriptインタビュー問題
Discover how at OpenReplay.com.
1. なぜ var は代入前に undefined を出力するのか?
console.log(x); // undefined
var x = 20;
console.log(x); // 20
最初の行は ReferenceError ではなく undefined を出力します。よく見られる説明は「宣言がトップに移動される」というものですが、これはメタファーに過ぎません。実際に起きていることはこうです:エンジンがスコープに入ると、いかなる文が実行される前に、そのスコープのバインディングをインスタンス化します。そして var バインディングはその時点で undefined に初期化されます。= 20 という代入は元の行に留まり、順番通りに実行されます。このバインディング生成ステップは、変数環境のインスタンス化としてECMAScript言語仕様に定義されており、物理的に何かが移動するわけではありません。
本当に問われていること: JSには実行フェーズの前に生成フェーズがあることを理解しているかどうかです。undefined という答えはトリビアに過ぎず、2フェーズ実行モデルこそが問われているコンピテンシーです。標準的な解説はMDNのホイスティング用語集エントリを参照してください。
予想されるフォローアップ: 「var の代わりに let だったら?」——これが次の質問2です。
2. なぜ let は undefined を出力せずにエラーをスローするのか?
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;
let と const はホイストされます——バインディングはスコープ入場時に生成されます——しかし、実行が宣言行に到達するまで 未初期化 のまま残ります。スコープ入場から宣言行までの間がTemporal Dead Zoneであり、その中でバインディングを読み取ろうとすると ReferenceError: Cannot access 'y' before initialization がスローされます。これが核心的な区別です:var は生成時に undefined に初期化されますが、let/const はその行が実行されるまで一切初期化されません。MDNはこれをlet のTemporal Dead Zoneセクションに記載しています。
本当に問われていること: バインディングの生成 と バインディングの初期化 を区別できるかどうかです。「let はホイストされない」と答える候補者はモデルを誤解しています。正しい答えは、ホイストはされるが未初期化である、というものです。
予想されるフォローアップ: 「では、なぜTDZが存在するのか?」——const を強制可能にし、宣言前の使用をサイレントな undefined ではなく明示的なエラーにするためだと答えましょう。
インタビュアーが口頭で再現することを期待する参照テーブルを以下に示します:
| 挙動 | var | let | const |
|---|---|---|---|
| ホイスト(スコープ入場時にバインディング生成) | あり | あり | あり |
| 生成時の初期化 | undefined | なし(TDZ) | なし(TDZ) |
| 宣言前の読み取り | undefined | ReferenceError | ReferenceError |
| スコープ | 関数 | ブロック | ブロック |
| 同一スコープでの再宣言 | 可 | 不可 | 不可 |
| 再代入 | 可 | 可 | 不可 |
3. 関数宣言と関数式のホイスティングの違い
foo(); // "I run"
bar(); // TypeError: bar is not a function
function foo() { console.log("I run"); }
var bar = function () { console.log("I don't"); };
関数 宣言 は名前と本体ごとホイストされるため、定義の前でも foo() は動作します。var bar に代入された関数 式 は bar バインディングのみをホイストし、undefined に初期化されます。undefined を呼び出すと ReferenceError ではなく TypeError がスローされます——バインディングは存在しているが、まだ関数ではないからです。エラーの 種類 を正確に答えられるかどうかが、ここでの判断ポイントです。MDNはこの違いを関数宣言のページで解説しています。
本当に問われていること: 宣言は 値 をホイストし、式は バインディング のみをホイストするという区別を把握しているかどうかです。これは質問1と2と同じ区別を、関数に適用したものです。
予想されるフォローアップ: 「bar が let で宣言されていたら?」——その場合、早期呼び出しは TypeError ではなくTDZからの ReferenceError をスローします。
クロージャとスコープに関するJavaScriptインタビュー問題
4. setTimeout-in-a-loop の古典的な問題
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 3 3 3
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 0 1 2
}
var のループは 3 3 3 を出力します。関数スコープの i は1つしか存在せず、コールバックが実行される時点(同期的なループが完了した後)では i は 3 になっており、3つのクロージャすべてが同じバインディングを参照するためです。let のループは 0 1 2 を出力します。let はイテレーションごとに新しいバインディングを生成するため、各コールバックは独自の i をクローズオーバーするからです。これはウェブ上で最も誤解されているスニペットの1つであり、クロージャ、スコープ、イベントループ(コールバックは遅延されるため、ループが先に完了する)という3つの概念が絡み合っています。MDNのクロージャガイドにイテレーションごとの let バインディングについての解説があります。
本当に問われていること: クロージャが変数を いつ 読み取るかと、クロージャが いつ 生成されたかを区別して考えられるかどうかです。これは単なるトリビアではなく、イベントハンドラやReactのエフェクトにおけるstale-closureの状態として本番環境に出荷されるバグと同じ種類の問題です。stale-closureハンドラのセッションリプレイでは、以前のレンダーからキャプチャされた値をログに記録していることがしばしば確認されます。
予想されるフォローアップ: 「let を使わずに修正するには?」——i を引数として受け取るIIFEでループ本体をラップし、イテレーションごとに新しいスコープを生成します。
5. プライベートカウンター
function makeCounter() {
let count = 0;
return {
increment: () => ++count,
value: () => count,
};
}
const c = makeCounter();
c.increment(); // 1
c.increment(); // 2
c.value(); // 2
// count は外部からアクセス不可
クロージャとは、関数が 呼び出される 場所ではなく、定義された 場所でアクセスできた変数とともにバンドルされた関数です。これが、makeCounter が返った後も、返されたメソッドが count にアクセスできる理由です。外部から count を直接公開するものが何もないため、これは事実上プライベートです。これは #private フィールドではなく、スコープによって構築されたカプセル化です。
本当に問われていること: クロージャをクイズの答えとしてではなく、メモリとカプセル化のツールとして理解しているかどうかです。フォローアップではそのコストを知っているかが問われます。
予想されるフォローアップ: 「これはメモリリークを引き起こすか?」——クロージャが参照を保持するため、c が到達可能である限り count 変数は生き続けます。これはここでは意図的な動作ですが、大きなオブジェクトに対する制御されていないクロージャは実際のリーク源になり得ます。
6. レキシカルスコープ:定義された場所が基準
const x = 10;
function outer() {
const x = 20;
return inner;
}
function inner() {
console.log(x); // 10
}
outer()(); // 10
inner は 20 ではなく 10 を出力します。JavaScriptは自由変数を、関数がソースコード上で 定義された 場所によって解決します。呼び出された場所ではありません。inner はトップレベルで定義されているため、その x はトップレベルの 10 に解決されます——outer 内の x は inner にとって無関係です。これがレキシカル(静的)スコープであり、クロージャを予測可能にするものです。
本当に問われていること: コールスタックとスコープチェーンを混同していないかどうかです。動的スコープの言語では 20 が出力されますが、JavaScriptはそうではありません。
予想されるフォローアップ: 「inner の宣言を outer の 内部 に移動したら?」——定義場所が変わるため、20 に解決されます。
this バインディングに関するJavaScriptインタビュー問題
7. ここで this は何を参照するか?
const user = {
name: "Ada",
greet() { return this.name; },
};
const fn = user.greet;
user.greet(); // "Ada"
fn(); // undefined(またはstrictモードではエラー)
this は関数が書かれた場所ではなく、関数が 呼び出される 方法によって決まります。user.greet() として呼び出された場合、呼び出し元のオブジェクトは user であるため、this.name は "Ada" です。fn に代入して単独で呼び出した場合、呼び出し元のオブジェクトが存在しないため、this はsloppyモードではグローバルオブジェクト(this.name は undefined)、strictモードでは undefined(this.name はエラー)になります。4つのバインディングルールはMDNの this リファレンスにまとめられています。
本当に問われていること: this が動的であり、呼び出し元に依存するものであって、レキシカルに固定されるものではないことを理解しているかどうかです。
予想されるフォローアップ: 「this を user に固定するには?」——fn.call(user)、fn.apply(user)、または user.greet.bind(user) を使います。
| 呼び出し形式 | this のバインド先 |
|---|---|
fn()(単独呼び出し) | undefined(strict)/ グローバルオブジェクト(sloppy) |
obj.fn()(メソッド呼び出し) | obj(ドットの左側のオブジェクト) |
fn.call(o) / fn.apply(o) / fn.bind(o) | o(明示的指定) |
new Fn() | 新たに生成されたインスタンス |
| アロー関数 | 外側のレキシカルスコープから継承 |
8. コールバック内の this
const timer = {
seconds: 0,
startBroken() {
setInterval(function () { this.seconds++; }, 1000); // this が間違い
},
startFixed() {
setInterval(() => { this.seconds++; }, 1000); // this は timer
},
};
startBroken では、setInterval に渡された通常の function はタイマー機構によって呼び出し元オブジェクトなしで呼び出されるため、this は timer ではありません——this.seconds++ は誤ったオブジェクトを変更します。startFixed では、アロー関数は独自の this を持たず、startFixed のスコープから継承します。そこでは this は timer です。これがコールバックにアロー関数が存在する教科書的な理由です。MDNのアロー関数を参照してください。
本当に問われていること: レキシカルな this を理解しているか、そしてアロー関数がかつての var self = this というワークアラウンドをどのように解決したかを理解しているかどうかです。this を失うハンドラのバグは、本番のクラスコンポーネントやイベントリスナーで頻繁に発生します。
予想されるフォローアップ: 「なぜアロー関数をコンストラクタや独自の this が必要なオブジェクトメソッドとして使えないのか?」——独自の this バインディングを持たないため、new はエラーをスローし、メソッドレベルのアローはオブジェクトではなく 外側の this をキャプチャしてしまうからです。
ランタイムモデルに関するJavaScriptインタビュー問題
9. コンソールの出力順を予測せよ(イベントループ)
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// 出力: 1 4 3 2
Promiseのコールバック(マイクロタスク)は、遅延が 0ms であっても、常に setTimeout のコールバック(マクロタスク)より先に実行されます。実行のトレース:
console.log("1")が同期的に実行される →1setTimeoutがコールバックを マクロタスク キューにスケジュールするPromise.resolve().then(...)がコールバックを マイクロタスク キューにスケジュールするconsole.log("4")が同期的に実行される →4- コールスタックが空になる。エンジンはマクロタスクより先に マイクロタスクキュー全体 を処理する →
3 - その後、次のマクロタスクが処理される →
2
MDNのマイクロタスクガイドにこの順序が説明されています。
本当に問われていること: イベントループが1つのキューではなく、2つの優先度層を持つことを理解しているかどうかです。この2層構造のイベントループモデルは、「状態の更新が1ティック遅れた」というすべてのバグの背景にあるモデルです。
予想されるフォローアップ: 「async/await はどこに位置づけられるか?」——await の後のコードはマイクロタスクとして実行されるため、.then() コールバックと同じ優先度でキューに入ります。
10. == vs === と型強制
0 == "0"; // true
0 === "0"; // false
null == undefined; // true
NaN === NaN; // false
[] == ![]; // true
== は比較前に型強制を行いますが、=== は行いません。これが 0 == "0" が true で 0 === "0" が false になる理由です。直感に反するケースとして:null == undefined は特別なルールにより true です(そして両者は == において他の何とも等しくありません)。NaN は自分自身を含む何とも等しくありません。[] == ![] が true になるのは、![] が false になり、それが 0 に強制変換され、[] は "" を経て 0 に強制変換されるためです。完全なアルゴリズムはMDNの等値比較ガイドにあります。
本当に問われていること: 「常に === を使え」という格言を暗唱できるかではなく、型強制を予測できるかどうかです。インタビュアーはメカニズムを求め、その後に経験則を求めます。
予想されるフォローアップ: 「未宣言の変数への代入はどうなるか?」——value = 42(var/let/const なし)がエラーをスローするか暗黙のグローバルを生成するかは、モードによって異なります。strictモードとESモジュールは ReferenceError をスローし、sloppyモードのスクリプトは暗黙のグローバルを生成します。同じスニペットに対して2つの正しい答えが存在する——この区別を指摘できること自体がシニアレベルのシグナルです。
採用と不採用を分けるもの
10の質問すべてに共通する本質:インタビュアーが評価するのは、出力だけでなく説明です。3 3 3 や 1 4 3 2 を予測できることは、その問題を見たことがあることを証明するに過ぎません。バインディングモデル、スコープチェーン、呼び出し元ルール、2層構造のイベントループを言語化できることが、プレッシャー下でデバッグできるほどその言語を理解していることを証明します。これら10の質問を、メモなしで2文で なぜ を説明できるようになるまで練習してください。これらの挙動はバージョンに依存しないため、現行のどのエンジンでもスニペットを自分で検証でき、その結果を信頼できます。
よくある質問
Temporal Dead ZoneとUndefinedな変数の違いは何ですか?
Temporal Dead Zone内の変数はホイストされているが未初期化であるため、読み取ろうとするとReferenceErrorがスローされます。一方、undefinedなvar変数はホイストされてundefinedという値に初期化されているため、読み取るとundefinedが返されます。TDZを生成するのはletとconstのみです。TDZはスコープ入場から宣言行が実行されるまで続きます。この区別はバインディングの生成とバインディングの初期化の違いです。
なぜアロー関数はオブジェクトメソッドやコンストラクタとして使えないのですか?
アロー関数は独自のthisバインディングを持たないため、それを必要とする役割を果たすことができません。オブジェクトメソッドとして使用した場合、アロー関数はオブジェクトではなく外側のレキシカルスコープからthisをキャプチャするため、this.propertyはオブジェクトを指しません。newと共に使用した場合、新しいインスタンスを割り当てるthisバインディングが存在しないため、エンジンはTypeErrorをスローします。どちらの場合も通常の関数を使用してください。
setTimeoutのループバグはすべてのJavaScriptエンジンで同じ挙動をしますか?
はい。varバージョンはすべての現行エンジンでループの最終値を出力します。これはvarが1つの関数スコープのバインディングを生成し、すべてのコールバックがそれを共有するためです。letバージョンはどこでもイテレーションごとの値を出力します。仕様がforループのletに対してイテレーションごとの新しいバインディングを定義しているためです。この挙動はECMA-262で仕様定義されており、エンジン固有のものではないため、Node.js、V8、SpiderMonkey、JavaScriptCoreはすべて同一の出力を生成します。
async/awaitはPromise.thenと比較してイベントループの優先度を変えますか?
いいえ。awaitの後のコードはマイクロタスクとして実行され、Promise.thenコールバックとまったく同じ優先度を持ちます。そのため、setTimeoutのマクロタスクより先に実行されます。awaitは実質的に関数を一時停止し、待機している値が解決されたときにマイクロタスクキューに継続をスケジュールします。つまり、同じタイミングでキューに入ったawaitの継続とthenコールバックはソースコードの順序で解決され、どちらも0ミリ秒に設定されたタイマーコールバックより先に実行されます。
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.
Star on GitHub12k