10 вопросов с JavaScript-собеседований и что они на самом деле проверяют
10 вопросов JS-собеседования с разборами hoisting, замыканий, this, цикла событий и приведения типов: вывод и что они проверяют.
Большинство вопросов на JavaScript-собеседованиях проверяют не знание ответа — они проверяют, понимаете ли вы модель, которая к нему приводит. Опытный интервьюер, пишущий на доске console.log(x); var x = 5;, уже знает, что выведется undefined. Он оценивает, можете ли вы объяснить почему — не прибегая к фразе «hoisting перемещает переменную наверх», потому что это лишь метафора, а не описание механизма. Кандидаты, которые проходят дальше, — это те, кто может назвать поведение среды выполнения, предсказать вывод и чётко сформулировать логику вслух в двух предложениях.
В этой статье рассматриваются десять вопросов, каждый из которых соответствует одному из фундаментальных понятий: модель выполнения, Temporal Dead Zone, замыкания, лексическая область видимости, привязка this, цикл событий и приведение типов. Каждый вопрос разбит на четыре части: фрагмент кода, точный вывод с объяснением, что на самом деле проверяет интервьюер и какой уточняющий вопрос последует. Все выводы определены спецификацией и воспроизводимы в любом современном движке. Рассматриваемые поведения стабильны во всех актуальных движках — это семантика языка, а не особенности конкретных версий.
Ключевые выводы
- Вопрос о hoisting с
varпроверяет не то, знаете ли вы, что выведетсяundefined, — он проверяет, понимаете ли вы, что JavaScript создаёт привязки переменных при входе в область видимости, до выполнения любой строки кода. letиconstтоже поднимаются, но остаются неинициализированными в Temporal Dead Zone вплоть до строки объявления, поэтому обращение к ним раньше времени бросаетReferenceError, а не возвращаетundefined.- В классической ошибке с
setTimeoutвнутри циклаfor:varвыводит финальное значение счётчика (3 3 3), потому что все колбэки разделяют одну привязку в области видимости функции, тогда какletвыводит значение каждой итерации (0 1 2), поскольку создаёт новую привязку на каждой итерации. thisопределяется не там, где написана функция, — а тем, как она вызывается; стрелочные функции являются исключением, поскольку у них нет собственногоthis.- Колбэки Promise (микрозадачи) всегда выполняются раньше колбэков
setTimeout(макрозадачи), даже при задержке0мс.
Вопросы о hoisting и модели выполнения
Discover how at OpenReplay.com.
1. Почему var выводит undefined до присваивания?
console.log(x); // undefined
var x = 20;
console.log(x); // 20
Первая строка выводит undefined, а не ReferenceError. Популярное объяснение гласит, что объявление «перемещается наверх», но это лишь метафора. На самом деле происходит следующее: когда движок входит в область видимости, он создаёт привязки для этой области до выполнения каких-либо инструкций, и привязка var инициализируется значением undefined в этот момент. Присваивание = 20 остаётся на своей исходной строке и выполняется в порядке очереди. Этот шаг создания привязок определён в спецификации ECMAScript в разделе об инициализации окружения переменных — ничто физически не перемещается.
Что на самом деле проверяется: понимаете ли вы, что в JS есть фаза создания, предшествующая фазе выполнения. Ответ undefined — это тривиальное знание; двухфазная модель выполнения — вот настоящая компетенция. Каноническое описание см. в статье MDN о Hoisting.
Вероятный уточняющий вопрос: «Что изменится, если использовать let вместо var?» — это вопрос 2.
2. Почему let бросает исключение вместо вывода undefined?
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;
let и const поднимаются — привязка создаётся при входе в область видимости, — но остаются неинициализированными до тех пор, пока выполнение не достигнет строки объявления. Промежуток между входом в область видимости и строкой объявления называется Temporal Dead Zone (TDZ), и обращение к привязке внутри неё бросает ReferenceError: Cannot access 'y' before initialization. Это принципиальное различие: var инициализируется значением undefined при создании; let/const не инициализируются вовсе до выполнения соответствующей строки. MDN документирует это в разделе Temporal Dead Zone для let.
Что на самом деле проверяется: разграничиваете ли вы создание привязки и инициализацию привязки. Кандидат, утверждающий, что «let не поднимается», использует неверную модель; правильный ответ: поднимается, но остаётся неинициализированным.
Вероятный уточняющий вопрос: «Зачем вообще нужна TDZ?» — скажите, что она делает const реализуемым и превращает использование переменной до объявления в явную ошибку вместо молчаливого undefined.
Вот справочная таблица, которую интервьюеры ожидают от вас воспроизвести устно:
| Поведение | var | let | const |
|---|---|---|---|
| Поднимается (привязка создаётся при входе в область видимости) | Да | Да | Да |
| Инициализируется при создании | undefined | Нет (TDZ) | Нет (TDZ) |
| Чтение до объявления | undefined | ReferenceError | ReferenceError |
| Область видимости | Функция | Блок | Блок |
| Повторное объявление в той же области видимости | Да | Нет | Нет |
| Допускает переприсваивание | Да | Да | Нет |
3. Hoisting объявлений функций и функциональных выражений
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 бросает TypeError, а не ReferenceError — привязка существует, но она ещё не является функцией. Правильно назвать тип ошибки — вот что здесь важно. MDN описывает это различие в разделе объявлений функций.
Что на самом деле проверяется: понимаете ли вы, что объявления поднимают значения, тогда как выражения поднимают только привязки. Это то же различие, что и в вопросах 1 и 2, применённое к функциям.
Вероятный уточняющий вопрос: «Что изменится, если bar объявить через let?» — тогда ранний вызов бросит ReferenceError из TDZ вместо TypeError.
Вопросы о замыканиях и области видимости
4. Классика: setTimeout внутри цикла
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 в области видимости функции; к моменту, когда колбэки срабатывают (после завершения синхронного цикла), i равно 3, и все три замыкания читают одну и ту же привязку. Цикл с let выводит 0 1 2, потому что let создаёт новую привязку на каждой итерации, и каждый колбэк замыкается на свою собственную переменную i. Это самый часто неправильно объясняемый фрагмент кода в интернете, и он объединяет три концепции: замыкания, область видимости и цикл событий (колбэки откладываются, поэтому цикл завершается первым). Руководство MDN по замыканиям документирует поведение привязки let на каждой итерации.
Что на самом деле проверяется: можете ли вы рассуждать о том, когда замыкание читает переменную, а не о том, когда оно было создано. Это не тривиальный вопрос — та же категория ошибок попадает в продакшн в виде устаревших замыканий в обработчиках событий и эффектах React. Записи сессий с устаревшими обработчиками-замыканиями нередко показывают, что они логируют значение, захваченное при предыдущем рендере.
Вероятный уточняющий вопрос: «Исправьте это без let.» — оберните тело цикла в IIFE, принимающее i как аргумент, создавая новую область видимости на каждой итерации.
5. Приватный счётчик
function makeCounter() {
let count = 0;
return {
increment: () => ++count,
value: () => count,
};
}
const c = makeCounter();
c.increment(); // 1
c.increment(); // 2
c.value(); // 2
// count недоступен извне
Замыкание — это функция вместе с переменными, к которым она имела доступ в месте своего определения, а не вызова, — именно поэтому возвращённые методы по-прежнему обращаются к count после того, как makeCounter завершила выполнение. Поскольку ничто снаружи не предоставляет прямого доступа к count, он фактически является приватным. Это инкапсуляция, построенная на области видимости, а не на полях #private.
Что на самом деле проверяется: понимаете ли вы замыкания как инструмент управления памятью и инкапсуляции, а не просто как ответ на вопрос. Уточняющий вопрос проверяет, знаете ли вы о цене этого подхода.
Вероятный уточняющий вопрос: «Это приводит к утечке памяти?» — переменная count остаётся в памяти, пока c доступна, поскольку замыкание держит на неё ссылку; здесь это намеренно, но неконтролируемые замыкания над большими объектами — реальный источник утечек.
6. Лексическая область видимости: место определения, а не вызова
const x = 10;
function outer() {
const x = 20;
return inner;
}
function inner() {
console.log(x); // 10
}
outer()(); // 10
inner выводит 10, а не 20. JavaScript разрешает свободные переменные по тому месту в исходном коде, где функция определена, а не где она вызывается. inner определена на верхнем уровне, поэтому её x разрешается в верхнеуровневое значение 10 — переменная x внутри outer для неё не имеет значения. Это лексическая (статическая) область видимости, именно она делает замыкания предсказуемыми.
Что на самом деле проверяется: не путаете ли вы стек вызовов с цепочкой областей видимости. Языки с динамической областью видимости вывели бы 20; JavaScript — нет.
Вероятный уточняющий вопрос: «Теперь переместите объявление inner внутрь outer.» — тогда разрешение даст 20, потому что место определения изменилось.
Вопросы о привязке this
7. На что ссылается this в данном случае?
const user = {
name: "Ada",
greet() { return this.name; },
};
const fn = user.greet;
user.greet(); // "Ada"
fn(); // undefined (или бросает исключение в строгом режиме)
this определяется не там, где написана функция, — а тем, как она вызывается. При вызове user.greet() объект в точке вызова — это user, поэтому this.name равно "Ada". При присваивании fn и вызове без контекста объект в точке вызова отсутствует, поэтому this — это глобальный объект в нестрогом режиме (что делает this.name равным undefined) или undefined в строгом режиме (что приводит к исключению при обращении к this.name). Четыре правила привязки описаны в справочнике MDN по this.
Что на самом деле проверяется: знаете ли вы, что this динамичен и определяется точкой вызова, а не фиксируется лексически.
Вероятный уточняющий вопрос: «Как привязать this к user?» — fn.call(user), fn.apply(user) или user.greet.bind(user).
| Форма вызова | К чему привязывается this |
|---|---|
fn() (без контекста) | undefined (строгий режим) / глобальный объект (нестрогий режим) |
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, вызывается механизмом таймера без объекта в точке вызова, поэтому this — не timer, и this.seconds++ изменяет не тот объект. В startFixed стрелочная функция не имеет собственного this и наследует его из области видимости startFixed, где this — это timer. Именно для этого и были введены стрелочные функции в колбэках. См. MDN о стрелочных функциях.
Что на самом деле проверяется: понимаете ли вы лексический this и почему стрелочные функции заменили старый приём var self = this. Ошибка с потерей this в обработчиках постоянно встречается в продакшн-коде классовых компонентов и слушателей событий.
Вероятный уточняющий вопрос: «Почему нельзя использовать стрелочную функцию в качестве конструктора или метода объекта, которому нужен собственный this?» — потому что у неё нет привязки this для присваивания, поэтому new бросает исключение, а стрелочный метод захватывает внешний this вместо объекта.
Вопросы о модели среды выполнения
9. Предскажите порядок вывода в консоль (цикл событий)
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// Вывод: 1 4 3 2
Колбэки Promise (микрозадачи) всегда выполняются раньше колбэков setTimeout (макрозадачи), даже при задержке 0мс. Трассировка выполнения:
console.log("1")выполняется синхронно →1.setTimeoutпомещает свой колбэк в очередь макрозадач.Promise.resolve().then(...)помещает свой колбэк в очередь микрозадач.console.log("4")выполняется синхронно →4.- Стек вызовов теперь пуст. Движок полностью опустошает очередь микрозадач перед любой макрозадачей →
3. - Только после этого обрабатывается следующая макрозадача →
2.
Руководство MDN по микрозадачам описывает этот порядок.
Что на самом деле проверяется: понимаете ли вы, что цикл событий имеет два уровня приоритета, а не одну очередь. Двухуровневый цикл событий — это модель, лежащая в основе каждой ошибки вида «моё состояние обновилось на тик позже».
Вероятный уточняющий вопрос: «Как в эту картину вписывается async/await?» — код после await выполняется как микрозадача, поэтому он ставится в очередь с тем же приоритетом, что и колбэк .then().
10. == и ===: приведение типов
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) исключение или молча создаст глобальную переменную, зависит от режима: строгий режим и ES-модули бросают ReferenceError; скрипты в нестрогом режиме создают неявную глобальную переменную. Один и тот же фрагмент — два правильных ответа, и умение указать на это различие само по себе является признаком опытного специалиста.
Что отличает принятого кандидата от отказа
Общая нить, проходящая через все десять вопросов: интервьюеры оценивают объяснение, а не только ответ. Предсказать 3 3 3 или 1 4 3 2 означает лишь, что вы видели эту задачу раньше; назвать модель привязок, цепочку областей видимости, правило точки вызова и двухуровневый цикл событий означает, что вы понимаете язык достаточно хорошо, чтобы отлаживать его под давлением. Отрабатывайте эти десять вопросов до тех пор, пока не сможете объяснить почему в двух предложениях без подсказок — и поскольку ни одно из этих поведений не зависит от версии, вы можете проверить каждый фрагмент в любом современном движке и доверять результату.
Часто задаваемые вопросы
В чём разница между Temporal Dead Zone и переменной, которая просто равна undefined?
Переменная в Temporal Dead Zone поднята, но ещё не инициализирована, поэтому обращение к ней бросает ReferenceError. Переменная var, напротив, поднята и инициализирована значением undefined, поэтому обращение к ней возвращает undefined. TDZ возникает только у let и const; она действует с момента входа в область видимости до выполнения строки объявления. Ключевое различие — между созданием привязки и её инициализацией.
Почему стрелочные функции не работают в качестве методов объекта или конструкторов?
Стрелочные функции не имеют собственной привязки this, поэтому не могут выполнять роли, требующие её наличия. Используемая как метод объекта, стрелочная функция захватывает this из охватывающей лексической области видимости, а не из объекта, поэтому this.property не указывает на объект. При использовании с new движок бросает TypeError, поскольку нет привязки this, которой можно было бы присвоить новый экземпляр. Для обоих случаев используйте обычные функции.
Одинаково ли ведёт себя ошибка с setTimeout в цикле во всех JavaScript-движках?
Да. Версия с var выводит финальное значение счётчика цикла в каждом современном движке, потому что var создаёт одну привязку в области видимости функции, разделяемую всеми колбэками. Версия с let выводит значения каждой итерации везде, потому что спецификация определяет создание новой привязки на каждой итерации цикла for с let. Это поведение определено в ECMA-262, а не является особенностью конкретного движка, поэтому Node.js, V8, SpiderMonkey и JavaScriptCore дают идентичный результат.
Меняет ли async/await приоритет в цикле событий по сравнению с Promise.then?
Нет. Код после await выполняется как микрозадача, с точно таким же приоритетом, что и колбэк Promise.then, поэтому он выполняется раньше любой макрозадачи setTimeout. await фактически приостанавливает функцию и помещает её продолжение в очередь микрозадач, когда ожидаемое значение разрешается. Это означает, что продолжение после await и колбэк then, поставленные в очередь в одной точке, разрешаются в порядке исходного кода — и оба выполняются раньше любого колбэка таймера с нулевой задержкой.
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