Интервью могут быть утомительными. Мы склонны думать, что только интервьюируемому приходится нелегко: сидеть на стуле, потеть и делать все возможное, чтобы произвести впечатление на интервьюера.

Но на самом деле собеседование может быть утомительным занятием и для интервьюера. Он не только должен провести собеседование с десятками или, может быть, даже с сотнями кандидатов, но и само собеседование иногда может быть грубым, потому что трудно по-настоящему оценить навыки кандидатов.

Будь вы интервьюер или собеседник, я хочу познакомить вас с одним из величайших вопросов на собеседовании, с которыми я когда-либо сталкивался.

Почему это так здорово? Потому что он охватывает огромное количество основных концепций JavaScript в одном блоке кода.

Давайте посмотрим на вопрос:

for(var i = 0; i<5; i++) {
    setTimeout(()=> console.log("i = ", i), i*10); 
}

1. Что в итоге?

2. Как это исправить?

Подумайте несколько минут.

Прежде чем ответить на первый вопрос, давайте проанализируем код. У нас есть цикл for, который продолжается до тех пор, пока «i» меньше 5, и увеличивает «i» на каждый раунд.

Внутри цикла for у нас есть тайм-аут, который регистрирует в своей функции обратного вызова значение «i» (и имеет время обратного отсчета, установленное на «i», умноженное на 10).

Что мы ожидаем увидеть в консоли? я = 0, я = 1, я = 2, я = 3, я = 4.

Стек вызовов JavaScript и цикл событий

Вы, наверное, уже догадались, что это неправильный ответ. Консоль пять раз напечатает i = 5. А теперь самое интересное - почему?

Прежде всего, мы должны понять, как JavaScript обрабатывает функцию тайм-аута или, точнее, как JavaScript обрабатывает асинхронные задачи.

В JavaScript у нас есть стек вызовов. Как видно из названия, стек вызовов - это стек вызовов - вызовов функций.

JavaScript является однопоточным, что означает, что он может обрабатывать только одну задачу за раз. Когда он достигает функции в вашем блоке кода, он складывает ее в стек вызовов и выполняет.

Если у этой функции больше обратных вызовов, они будут накладываться на нее, а когда они закончат выполнение, они выйдут из стека вызовов в том же порядке, в котором они пришли. Это называется структурой данных FILO: Первый пришел - последний вышел.

Давайте посмотрим, как следующий фрагмент будет выглядеть в стеке вызовов:

const sayHi = () => {
sayBye();
console.log('Hi');
};
const sayBye = () => {
console.log('Bye');
};
sayHi();

Мы вызываем функцию SayHi(), вызывая в своем теле функцию sayBye(). Функция sayHi() входит в стек, и сразу после этого функция sayBye() накладывается на нее. Когда sayBye() завершит выполнение, он выйдет из стека вызовов, и после этого sayHi() также будет существовать.

Но при обработке асинхронных операций JavaScript действует немного иначе. Например, если у нас есть тайм-аут, мы не хотим, чтобы движок JavaScript ждал все время обратного отсчета, пока он не перейдет к следующей строке кода (помните, что JavaScript однопоточный), верно?

Какое решение? JavaScript передаст эту задачу (обработки тайм-аута или любой другой асинхронной операции) API браузера, который работает в среде, отличной от среды однопоточного движка JavaScript.

Когда обратный отсчет таймера закончится, API браузера «подтолкнет» функцию тайм-аута к циклу событий.

Цикл событий немного отличается от стека вызовов с точки зрения того, какую структуру данных он использует. Цикл событий использует структуру данных очереди, что означает, что это FIFO: первым пришел - первым ушел. Когда движок JavaScript очищает стек вызовов (т.е. когда он завершает выполнение кода), цикл обработки событий примет действие и начнет перемещать задачи из своей очереди в стек вызовов.

Если мы снова посмотрим на вопрос интервью выше, то мы можем объяснить это так:

В первом раунде JavaScript обнаружит функцию тайм-аута и позволит API браузера обработать обратный отсчет. API браузера немедленно отправит функцию обратного вызова в цикл событий (поскольку время установлено на 0). Цикл событий теперь ожидает завершения остальной части кода (в данном случае цикла for).

Между тем, в API браузера отправляется еще один тайм-аут, и через 10 миллисекунд он также будет передан в цикл событий. Теперь у нас есть две задачи в цикле событий, ожидающих входа в стек. И так далее.

Когда цикл for завершается, цикл обработки событий одну за другой перебрасывает эти задачи из очереди в стек вызовов. Как мы уже говорили ранее, первая задача, которая входит в цикл обработки событий, - первая задача, которая выходит (и входит в стек вызовов).

Объем функции и область действия блока

Но почему он пять раз напечатает i = 5? Что ж, ответ на этот вопрос заключается в том, что мы используем ключевое слово var.

Как вы, наверное, уже знаете, у каждой переменной есть своя «область видимости». Область действия означает, что переменная будет «заблокирована» в этой конкретной зоне. В JS у нас есть 3 основных области видимости - глобальная область действия, область действия функции и область действия блока.

Переменная будет рассматриваться в глобальной области видимости, если мы объявим ее вне функции. Нравится:

const globalScope = 'global';
var alsoGlobalScope = 'global';
function functionScope() {
const insideFunctionScope = 'function scope';
var alsoInsideFunctionScope = 'function scope';
}

Но для области видимости блока у нас другое поведение при сравнении let и const с var.

В то время как const и let «возьмут» область видимости блока при объявлении внутри оператора if / for, var не будет рассматривать это как отдельную область. это будет так, как если бы мы объявили это вне этой области (глобально).

Вот небольшой фрагмент, который поможет нам это визуализировать:

if(someValue) {
const insideBlock = 'inside';
}
console.log(insideBlock) // Uncaught ReferenceError: insideBlock is not defined
 
if(someValue) {
var insideBlock = 'inside';
}
console.log(insideBlock); // output: inside

Закрытие

Давайте посмотрим, что MDN может сказать о закрытии:

Замыкание - это комбинация объединенной (заключенной) функции со ссылками на ее окружающее состояние (лексическое окружение). Другими словами, замыкание дает вам доступ к области внешней функции из внутренней функции. В JavaScript замыкания создаются каждый раз, когда создается функция, во время создания функции.

Хорошо, это официальное определение. Звучит немного устрашающе? Упростим:

Замыкание означает, что перед тем, как JavaScript запустит ваш код, он ищет все переменные в ваших функциях.

Если он видит переменную в функции, внутри которой нет объявления (let, const или var), но во внешней области (где функция вложена), он «блокирует» значение этой переменной внутри заданного функция.

Вы все еще в замешательстве? Рассмотрим следующий фрагмент:

const myOuterFunction = () => {
  let variableInOuterFunction = 'Hello World';

  const myNestedFunction = () => {
    console.log(variableInOuterFunction);
  };

  myNestedFunction();
};

myOuterFunction();

Обратите внимание, как мы используем variableInOuterFunction (который объявлен в myOuterFunction) внутри myNestedFunction? Это закрытие. Значение «Hello World» теперь «закрыто» (заблокировано) во вложенной функции. Звучит довольно просто, правда?

Теперь мы можем понять, почему использование объявления var даст пять раз «i = 5».

Если мы попытаемся представить себе, как это выглядит, мы можем думать об этом так:

//First round in the for loop
var i = 0;
setTimeout(() => console.log('i = ', i), i * 10); // sent to browser API and then to the event loop queue
//Second round
var i = 1; // same variable being override since it's the same scope.
setTimeout(() => console.log('i = ', i), i * 10); //sent to browser API
output: i=1 X 2

Или вот так, если вы предпочитаете:

var i = 0;
while (i < 5) {
setTimeout(() => console.log('i = ', i), i * 10);
i++;
}

Дело в том, что даже несмотря на то, что у нас есть закрытие в каждом раунде (мы используем переменную «i» в функции тайм-аута), контекст среды всегда один и тот же. Поскольку у нас есть только одна переменная «i», во все вызовы функции тайм-аута, когда они вернутся из цикла обработки событий, не будет передано ничего, кроме окончательного значения.

С объявлениями let или const это выглядит совершенно иначе. Каждый раз при запуске цикла создается новая область, блокирующая значение «i» во вложенной функции тайм-аута.

Вы можете представить это так (с функцией для визуализации):

//First round in the for loop
const firstRound = (() => {
let i = 0;
setTimeout(() => console.log('i = ', i), i * 10); // sent to browser API and then to the event loop queue
})()
//Second round
const secondRound = (() => {
let i = 1;
setTimeout(() => console.log('i = ', i), i * 10); // sent to browser API and then to the event loop queue
})()
//yields i =  0  i =  1

Опять же, ключевой момент, который следует отсюда сделать, заключается в том, что функция setTimout запомнит область видимости, в которой она была запущена, и при возврате из цикла событий она будет использовать переменную в этой запомненной области.

В первом сценарии, когда мы используем объявление var, область видимости, которую запоминает setTimout, является глобальной областью, и поскольку у нас есть только одна переменная в этой области, она будет использовать это окончательное значение (5).

Во втором сценарии у нас разная область видимости в каждом раунде и, следовательно, новая переменная «i».

Подъем

Люди часто ошибочно думают, что подъем означает, что ваши объявления переменных (с использованием ключевого слова var) и функций перемещаются в верхнюю часть вашего кода.

Что ж, на самом деле никто не будет трогать ваш код, не беспокойтесь.

Так что же такое подъем?

Прежде чем движок JavaScript продолжит работу и выполнит наш код построчно, он проведет вводный сеанс с кодом. Это называется временем анализа.

Он просматривает код и ищет объявления переменных и функций.

Найдя их, он сохранит их в своей памяти или, скорее, зарезервирует специальное место в памяти (для переменной он сохранит место в памяти с неопределенным значением в качестве значения, а что касается функций - вся функция будет сохранена в памяти).

Теперь, когда JS-движок запускает код, он не будет «напуган», если обнаружит, что вызов функции или переменная использовалась до того, как они были объявлены (внизу в коде), поскольку, как мы уже говорили, они хранятся в памяти.

Итак, где у нас есть подъем в нашем вопросе интервью?

На самом деле это очень просто. Все объявления var поднимаются в начало вашего кода. Поскольку все они объявляют одну и ту же переменную (i), оказывается, что у нас есть только одно объявление переменной. Нравится:

var i;
for(i=0; i<5; i++) {
setTimeout(()=> console.log("i = ", i), i*10);
}

Как решить это

Мы уже видели, как решить эту проблему с помощью ключевого слова var. Но есть другой способ решить эту проблему. Есть идея другого решения? Вы сможете решить эту проблему, используя знания, которые вы получили до сих пор. Подумайте об этом на секунду.

Понятно?

Если мы создадим новую функцию для хранения нашей логики тайм-аута, мы будем создавать новое закрытие на каждой итерации. Посмотрим, как это выглядит:

const doSetTimeout = (i) => {
  setTimeout(() => { console.log(i) },i*10);
}
for (var i = 0; i < 5; i++)
  doSetTimeout(i);

Теперь мы передаем значение «i» нашей doSetTimeout функции. Поскольку новое замыкание со значением параметра «i» создается на каждой итерации, мы будем выводить на нашу консоль 0, 1,2,3,4, как и ожидалось.

Заключение

Я считаю, что ключевой вывод из этой статьи заключается в том, что вы должны очень хорошо знать основные концепции JavaScript, чтобы успешно пройти собеседование.

Даже если вы не ответите правильно - объясните интервьюеру, как JavaScript работает за кулисами. Я уверен, что после того, как вы покажете, что разбираетесь в предмете, вы произведете хорошее впечатление на интервьюера.

То же самое и с интервьюером - не ждите, что ваш собеседник все сделает правильно. Собеседование может быть стрессовым событием, и его ум может быть не в лучшем виде. Вместо этого поощрите его поделиться своими знаниями и оцените его по этим параметрам.

Больше контента на plainenglish.io