Источник: Nuances of Programming
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 выполняет процесс поиска, чтобы найти его. Этот процесс включает два основных этапа.
- Проверка собственных свойств объекта. Сначала JavaScript проверяет, обладает ли непосредственно сам объект нужным свойством или методом. Если свойство найдено в объекте, то доступ к нему и его использование осуществляются напрямую.
- Поиск по цепочке прототипов. Если свойство не найдено в самом объекте, 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';.
Разберем код пошагово.
- let a = {};: инициализирует пустой объект a.
- let b = { key: 'test' };: создает объект b со свойством key, имеющим значение 'test'.
- let c = { key: 'test' };: определяет другой объект c с той же структурой, что и b.
- a[b] = '123';: устанавливает значение '123' для свойства с ключом [object Object] в объекте a.
- 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
Читайте также:
Перевод статьи Rabi Siddique: 8 Advanced JavaScript Interview Questions for Senior Roles