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
Читайте также:
- 10 ключевых концепций JavaScript
- Решение головоломки судоку на JavaScript с помощью хэш-карт и рекурсий
- Как отследить событие закрытия браузера и вкладки с помощью JavaScript
Читайте нас в Telegram, VK и Дзен
Перевод статьи Rabi Siddique: 8 Advanced JavaScript Interview Questions for Senior Roles