8 продвинутых вопросов для собеседования по JavaScript

JavaScript  —  это высокоуровневый язык, который является одним из основных конструкционных веб-блоков. Однако у этого эффективного языка есть и свои особенности. Например, знаете ли вы, что значение 0 === -0 равно true или что Number("") дает 0?

Это может застать вас врасплох. Однако всем языкам программирования присущи те или иные причуды, и JavaScript не исключение.

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

1. Подробнее о свойствах операторов + и —

console.log(1 + '1' - 1);

Можете ли вы предположить, как поведут себя операторы + и    в ситуациях, подобных приведенной выше?

Когда JavaScript имеет дело с выражением 1 + '1', то обрабатывает его с помощью оператора +. Интересным свойством этого оператора является то, что он предпочитает конкатенацию строк, когда один из операндов является строкой. В нашем случае ‘1’ является строкой, поэтому JavaScript неявно преобразует числовое значение 1 в строку. Следовательно, 1 + '1' становится '1' + '1', в результате чего получается строка '11'.

Теперь у нас есть выражение '11' - 1. Поведение оператора    прямо противоположно. Приоритет отдается числовому вычитанию независимо от типа операндов. Если операнды не относятся к числовому типу, JavaScript выполняет неявное принуждение для их преобразования в числа. В данном случае '11' преобразуется в числовое значение 11, и выражение упрощается до 11 - 1.

Собираем все вместе:

'11' - 1 = 11 - 1 = 10

2. Дублирование элементов массива

Рассмотрите следующий JavaScript-код и постарайтесь найти в нем какие-либо проблемы:

function duplicate(array) {
for (var i = 0; i < array.length; i++) {
array.push(array[i]);
}
return array;
}

const arr = [1, 2, 3];
const newArr = duplicate(arr);
console.log(newArr);

В этом фрагменте кода требуется создать новый массив, содержащий дублированные элементы входного массива. При первоначальном рассмотрении кажется, что код создает новый массив newArr, дублируя каждый элемент исходного массива arr. Однако в самой функции duplicate возникает критическая проблема.

Функция duplicate использует цикл для перебора каждого элемента конкретного массива. Но внутри цикла она добавляет новый элемент в конец массива, используя метод push(). В результате массив каждый раз становится длиннее, что создает проблему, при которой цикл никогда не останавливается. Условие цикла (i < array.length) всегда остается истинным, поскольку массив продолжает увеличиваться. В результате цикл может продолжаться бесконечно, что приводит к зацикливанию программы.

Чтобы решить проблему бесконечного цикла, вызванного ростом длины массива, можно перед входом в цикл сохранить начальную длину массива в переменной. Затем эту начальную длину можно использовать в качестве ограничения для итерации цикла. Таким образом, цикл будет выполняться только для исходных элементов массива и перестанет зависеть от роста массива за счет добавления дубликатов. Вот модифицированная версия кода:

function duplicate(array) {
var initialLength = array.length; // Сохранение начальной длины
for (var i = 0; i < initialLength; i++) {
array.push(array[i]); // Дублирование каждого элемента
}
return array;
}

const arr = [1, 2, 3];
const newArr = duplicate(arr);
console.log(newArr);

В результате дублированные элементы окажутся в конце массива, и цикл не будет бесконечным:

[1, 2, 3, 1, 2, 3]

3. Различие между prototype и __proto__

Свойство prototype  —  это атрибут, связанный с функциями-конструкторами в JavaScript. Функции-конструкторы используются для создания объектов в JavaScript. При определении функции-конструктора к ее свойству prototype можно также прикрепить свойства и методы. Эти свойства и методы становятся доступными для всех экземпляров объектов, созданных с помощью данного конструктора. Таким образом, свойство prototype служит генеральным хранилищем для методов и свойств, общих для экземпляров.

Рассмотрим следующий фрагмент кода:

// Функция-конструктор
function Person(name) {
this.name = name;
}

// Добавление метода к prototype
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}.`);
};

// Создание экземпляров
const person1 = new Person("Haider Wain");
const person2 = new Person("Omer Asif");

// Вызов общего метода
person1.sayHello(); // Вывод: Здравствуйте, меня зовут Хайдер Вейн.
person2.sayHello(); // Вывод: Здравствуйте, меня зовут Омер Асиф.

В данном примере у нас есть функция-конструктор с именем Person. Расширяя Person.prototype методом типа sayHello, мы добавляем этот метод в цепочку прототипов всех экземпляров Person. Это позволяет каждому экземпляру Person получить доступ к общему методу и использовать его (вместо того чтобы располагать своей копией метода).

С другой стороны, свойство __proto__ существует у каждого объекта JavaScript. В JavaScript все, кроме примитивных типов, может рассматриваться как объект. Каждый такой объект имеет прототип, который служит ссылкой на другой объект. Свойство __proto__  —  это просто ссылка на этот объект-прототип. Объект-прототип используется в качестве резервного источника свойств и методов, когда исходный объект ими не обладает. По умолчанию при создании объекта его прототип устанавливается в Object.prototype.

Когда вы пытаетесь получить доступ к свойству или методу объекта, JavaScript выполняет процесс поиска, чтобы найти его. Этот процесс включает два основных этапа.

  1. Проверка собственных свойств объекта. Сначала JavaScript проверяет, обладает ли непосредственно сам объект нужным свойством или методом. Если свойство найдено в объекте, то доступ к нему и его использование осуществляются напрямую.
  2. Поиск по цепочке прототипов. Если свойство не найдено в самом объекте, JavaScript обращается к прототипу объекта (на который ссылается свойство __proto__) и ищет его там. Этот процесс продолжается рекурсивно вверх по цепочке прототипов до тех пор, пока свойство не будет найдено или пока поиск не достигнет Object.prototype.

Если свойство не найдено даже в прототипе Object.prototype, JavaScript возвращает значение undefined, указывая на то, что свойство не существует.

4. Области видимости

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

Рассмотрим подробно фрагмент кода:

function foo() {
console.log(a);
}

function bar() {
var a = 3;
foo();
}

var a = 5;
bar();

В коде определены 2 функции foo() и bar() и переменная a со значением 5. Все эти объявления происходят в глобальной области видимости. Внутри функции bar() объявляется переменная a, которой присваивается значение 3. Какое значение a будет выведено при вызове функции bar()?

Когда движок JavaScript выполняет этот код, глобальная переменная a объявляется и ей присваивается значение 5. Затем вызывается функция bar(). Внутри функции bar() объявляется локальная переменная a, которой присваивается значение 3. Эта локальная переменная a отлична от глобальной переменной a. Затем из функции bar() вызывается функция foo().

Внутри функции foo() оператор console.log(a) пытается записать значение a. Поскольку в области видимости функции foo() локальная переменная a не определена, JavaScript просматривает цепочку областей видимости в поисках ближайшей переменной с именем a. Под цепочкой областей видимости понимаются все различные области видимости, к которым имеет доступ функция, когда пытается найти и использовать переменные.

Теперь обратимся к вопросу о том, где JavaScript будет искать переменную a. Будет ли он искать ее в области видимости функции bar или в глобальной области видимости? Как выясняется, JavaScript будет искать в глобальной области видимости, и такое поведение обусловлено концепцией, называемой лексической областью видимости.

Под лексической областью видимости понимается область видимости функции или переменной на момент ее написания в коде. При определении функции foo ей предоставляется доступ как к собственной локальной области видимости, так и к глобальной области видимости. Эта характеристика сохраняется независимо от того, где вызывается функция foo  —  внутри функции bar или экспортируется в другой модуль и запускается там. Лексическая область видимости не определяется тем, где вызывается функция.

Результатом этого является то, что на выходе всегда будет одно и то же значение a, найденное в глобальной области видимости, которое в данном случае равно 5.

Однако если определить функцию foo внутри bar, получится другой сценарий:

function bar() {
var a = 3;

function foo() {
console.log(a);
}

foo();
}

var a = 5;
bar();

В этой ситуации лексическая область видимости foo будет включать три различные области видимости: свою локальную область видимости, область видимости функции bar и глобальную область видимости. Лексическая область видимости определяется тем, куда помещается код в исходнике во время компиляции.

При выполнении данного кода foo находится внутри функции bar. Такое расположение изменяет динамику области видимости. Теперь, пытаясь получить доступ к переменной a, foo сначала ищет ее в своей локальной области видимости. Не найдя там a, расширит поиск до области видимости функции bar. И оказывается, что a существует там со значением 3. В результате в консольном операторе будет выведено значение 3.

5. Объектное принуждение

const obj = {
valueOf: () => 42,
toString: () => 27
};
console.log(obj + '');

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

Подобное преобразование очень важно при работе с объектами в таких сценариях, как конкатенация строк и арифметические операции. Для этого JavaScript использует два специальных метода: valueOf и toString.

Метод valueOf является фундаментальной частью механизма преобразования объектов в JavaScript. Когда объект используется в контексте, требующем примитивного значения, JavaScript сначала ищет метод valueOf в объекте. В тех случаях, когда метод valueOf либо отсутствует, либо не возвращает соответствующего примитивного значения, JavaScript обращается к методу toString. Этот метод отвечает за строковое представление объекта.

Вернемся к исходному фрагменту кода:

const obj = {
valueOf: () => 42,
toString: () => 27
};

console.log(obj + '');

При выполнении этого кода объект obj преобразуется в примитивное значение. В данном случае метод valueOf возвращает значение 42, которое затем неявно преобразуется в строку за счет конкатенации с пустой строкой. Следовательно, на выходе кода будет 42.

Однако в тех случаях, когда метод valueOf либо отсутствует, либо не возвращает подходящего примитивного значения, JavaScript прибегает к методу toString. Изменим предыдущий пример:

const obj = {
toString: () => 27
};

console.log(obj + '');

Здесь удален метод valueOf, остался только метод toString, который возвращает число 27. В этом сценарии JavaScript будет использовать метод toString для преобразования объектов.

6. Понимание ключей объектов

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

let a = {};
let b = { key: 'test' };
let c = { key: 'test' };

a[b] = '123';
a[c] = '456';

console.log(a);

На первый взгляд может показаться, что этот код должен создавать объект a с двумя различными парами ключ-значение. Однако результат оказывается совершенно иным из-за особенностей работы JavaScript с ключами объектов.

Для преобразования ключей объектов в строки JavaScript использует стандартный метод toString(). Но почему? В JavaScript ключи объектов всегда являются строками (или символами) либо автоматически преобразуются в строки путем неявного принуждения. Если в качестве ключа объекта используется какое-либо значение, отличное от строки (например, число, объект или символ), JavaScript внутренне преобразует это значение в его строковое представление, прежде чем использовать его в качестве ключа.

Следовательно, при использовании объектов b и c в качестве ключей в объекте a, оба они преобразуются в одно и то же строковое представление: [object Object]. Вследствие такого поведения, второе присваивание a[b] = '123'; будет перезаписывать первое присваивание a[c] = '456';.

Разберем код пошагово.

  1. let a = {};: инициализирует пустой объект a.
  2. let b = { key: 'test' };: создает объект b со свойством key, имеющим значение 'test'.
  3. let c = { key: 'test' };: определяет другой объект c с той же структурой, что и b.
  4. a[b] = '123';: устанавливает значение '123' для свойства с ключом [object Object] в объекте a.
  5. a[c] = '456';: обновляет значение до '456' для того же свойства с ключом [object Object] в объекте a, заменяя предыдущее значение.

В обоих присваиваниях используется одинаковая строка ключа [object Object]. В результате второе присваивание перезаписывает значение, заданное первым присваиванием.

При записи объекта a наблюдаем следующий вывод:

{ '[object Object]': '456' }

7. Оператор двойного равенства

console.log([] == ![]);

Это довольно сложный пример. Итак, как вы думаете, что получится на выходе? Попробуем шаг за шагом прийти к ответу. Для начала посмотрим типы обоих операндов:

typeof([]) // "объект"
typeof(![]) // "булево значение"

[]  —  это object, что вполне понятно. Ведь в JavaScript все является объектом, включая массивы и функции. Но каким образом операнд ![] имеет тип boolean? Попробуем разобраться в этом. При использовании ! с примитивным значением происходят следующие преобразования.

  • Ложные значения: если исходное значение является ложным (например, false, 0, null, undefined, NaN или пустая строка ''), то применение ! преобразует его в true.
  • Истинные значения: если исходное значение является истинным (любое значение, не являющееся ложным), то применение ! приведет к его преобразованию в false.

В нашем случае []  —  это пустой массив, который является истинным значением в JavaScript. Поскольку []  —  истинное значение, ![] становится false. Таким образом, выражение приобретает вид:

[] == ![]
[] == false

Теперь перейдем к рассмотрению оператора ==. При сравнении двух значений с помощью оператора == JavaScript выполняет алгоритм сравнения абстрактных равенств (Abstract Equality Comparison Algorithm). Этот алгоритм состоит из следующих шагов:

Алгоритм сравнения абстрактных равенств

Алгоритм учитывает типы сравниваемых значений и выполняет необходимые преобразования.

Для нашего случая обозначим x как [], а y как ![]. Мы проверили типы x и y и обнаружили, что x является объектом, а y  —  булевым числом. Поскольку y  —  булево значение, а x  —  объект, то применяется 7-е условие алгоритма сравнения абстрактных равенств:

Если Type(y)  —  Boolean, вернуть результат сравнения x == ToNumber(y).

То есть если один из типов является булевым, то перед сравнением его необходимо преобразовать в число. Каково же значение ToNumber(y)? Как вы видели, []  —  истинное значение, отрицание делает его false. В результате ,Number(false) равно 0.

[] == false
[] == Number(false)
[] == 0

Теперь у нас есть сравнение [] == 0, и на этот раз вступает в силу 8-е условие:

Если Type(x)  —  либо String, либо Number, и Type(y)  —  Object,
вернуть результат сравнения x == ToPrimitive(y).

Исходя из этого условия, если один из операндов является объектом, необходимо преобразовать его в примитивное значение. Вот здесь-то и приходит на помощь алгоритм ToPrimitive. Нам необходимо преобразовать x, который является [], в примитивное значение. В JavaScript массивы являются объектами. Как вы видели ранее, при преобразовании объектов в примитивы в дело вступают методы valueOf и toString. В данном случае valueOf возвращает сам массив, который не является корректным примитивным значением. В результате для вывода мы переходим к методу toString. Применение метода toString к пустому массиву приводит к получению пустой строки, которая является допустимым примитивом:

[] == 0
[].toString() == 0
"" == 0

Преобразование пустого массива в строку дает пустую строку "", и мы сталкиваемся со сравнением "" == 0.

Теперь, когда один из операндов имеет тип string, а другой  —  тип number, выполняется 5-е условие:

Если Type(x)  —  String и Type(y)  —  Number, вернуть результат сравнения ToNumber(x) == y.

Следовательно, необходимо преобразовать пустую строку "" в число, что дает 0.

"" == 0
ToNumber("") == 0
0 == 0

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

0 == 0 // true

8. Замыкания

Один из самых распространенных вопросов на собеседовании связан с замыканиями:

const arr = [10, 12, 15, 21];
for (var i = 0; i < arr.length; i++) {
setTimeout(function() {
console.log('Index: ' + i + ', element: ' + arr[i]);
}, 3000);
}

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

Index: 0, element: 10
Index: 1, element: 12
Index: 2, element: 15
Index: 3, element: 21

Однако здесь дело обстоит иначе. Из-за концепции замыканий и того, как JavaScript обрабатывает область видимости переменных, фактический результат будет иным. Когда обратные вызовы setTimeout будут выполнены после задержки в 3000 миллисекунд, все они станут ссылаться на одну и ту же переменную i, которая после завершения цикла будет иметь конечное значение 4. В результате на выходе код будет иметь такой вид:

Index: 4, element: undefined
Index: 4, element: undefined
Index: 4, element: undefined
Index: 4, element: undefined

Такое поведение объясняется тем, что ключевое слово var не имеет области видимости блока, а обратные вызовы setTimeout захватывают ссылку на одну и ту же переменную i. При выполнении обратных вызовов все они видят конечное значение i, равное 4, и пытаются получить доступ к arr[4] (является undefined).

Чтобы добиться желаемого результата, можно использовать ключевое слово let для создания новой области видимости для каждой итерации цикла. Таким образом, каждый обратный вызов будет захватывать правильное значение i:

const arr = [10, 12, 15, 21];
for (let i = 0; i < arr.length; i++) {
setTimeout(function() {
console.log('Index: ' + i + ', element: ' + arr[i]);
}, 3000);
}

При такой модификации получается ожидаемый результат:

Index: 0, element: 10
Index: 1, element: 12
Index: 2, element: 15
Index: 3, element: 21

Использование let создает новую привязку для i в каждой итерации, обеспечивая ссылку каждого обратного вызова на правильное значение.

Многие разработчики знакомы с решением, в котором используется ключевое слово let. Однако на собеседовании вас могут попросить пойти дальше и решить задачу без использования let. В таких случаях альтернативным подходом является создание замыкания путем немедленного вызова функции IIFE (Immediately Invoked Function Expression) внутри цикла. Таким образом, у каждого вызова функции будет своя копия i. Вот как это можно сделать:

const arr = [10, 12, 15, 21];
for (var i = 0; i < arr.length; i++) {
(function(index) {
setTimeout(function() {
console.log('Index: ' + index + ', element: ' + arr[index]);
}, 3000);
})(i);
}

В этом коде немедленно вызываемая функция (function(index) { ... })(i); создает новую область видимости для каждой итерации, захватывая текущее значение i и передавая его в качестве параметра index. Таким образом, каждая функция обратного вызова получает свое отдельное значение index, что предотвращает проблему, связанную с замыканием, и дает ожидаемый результат:

Index: 0, element: 10
Index: 1, element: 12
Index: 2, element: 15
Index: 3, element: 21

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Rabi Siddique: 8 Advanced JavaScript Interview Questions for Senior Roles

Предыдущая статьяКак создавать легкие платформонезависимые приложения на Go  —  без JS и BS
Следующая статьяИнженерия геопространственных данных: пространственное индексирование