[Перевод] 8 углубленных вопросов на собеседованиях на роль сеньора в JavaScript
JavaScript — это мощный язык, который является частью фундамента интернета. У этого мощного языка также есть некоторые свои особенности. Например, знаете ли вы, что значение 0 === -0
равно true, или что Number("")
дает 0
?
Дело в том, что иногда эти причуды могут заставить вас почесать в затылке или даже задаться вопросом, был ли Брендан Эйч под кайфом в тот день, когда он изобретал JavaScript. Что ж, дело здесь не в том, что JavaScript — плохой язык программирования или он — зло, как говорят его критики. Со всеми языками программирования связаны какие-то странности, и JavaScript не является исключением.
В этом материале мы покажем подробное объяснение некоторых важных вопросов на интервью по JavaScript. Моя цель будет состоять в том, чтобы тщательно объяснить эти вопросы, чтобы мы могли понять лежащие в их основе концепции.
❯ 1 — Более пристальный взгляд на операторы + и -
console.log(1 + '1' - 1);
Можете ли вы угадать поведение операторов + и — в ситуациях, подобных описанной выше?
Когда JavaScript встречает 1 + '1'
, он обрабатывает выражение с помощью оператора +. Одним интересным его свойством является то, что он предпочитает объединение строк, когда один из операндов является строкой. В нашем случае '1'
— это строка, поэтому JavaScript неявно преобразует числовое значение 1 в строку. Следовательно, 1+'1'
становится '1'+'1'
, в результате чего получается строка '11'
.
Теперь наше уравнение равно '11' - 1
. Поведение оператора — совершенно противоположное. Он отдает приоритет числовому вычитанию, независимо от типов операндов. Когда операнды не относятся к типу number
, 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
использует цикл для перебора каждого элемента в заданном массиве. Но внутри цикла он добавляет новый элемент в конец массива, используя метод push()
. Это делает массив длиннее каждый раз, создавая проблему, при которой цикл никогда не остановится. Условие цикла (i < array.length
) всегда остается верным, потому что массив продолжает увеличиваться. Это приводит к тому, что цикл продолжается вечно, в результате чего программа зависает.
Чтобы решить проблему бесконечного цикла, вы можете сохранить начальную длину массива в переменной перед входом в цикл. Затем вы можете использовать эту начальную длину в качестве ограничения для итерации цикла. Таким образом, цикл будет выполняться только для исходных элементов массива и не будет затронут ростом массива из-за добавления дубликатов.
Вот измененная версия кода:
function duplicate(array) {
var initialLength = array.length; // Store the initial length
for (var i = 0; i < initialLength; i++) {
array.push(array[i]); // Push a duplicate of each element
}
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. Функции конструктора используются для создания объектов. Когда вы определяете функцию-конструктор, вы также можете присоединить свойства и методы к ее свойству prototype
. Затем они становятся доступными для всех объектов, созданных из этого конструктора. Таким образом, prototype
служит общим хранилищем для методов и свойств, которые совместно используются экземплярами.
Рассмотрим следующий фрагмент кода:
// Constructor function
function Person(name) {
this.name = name;
}
// Adding a method to the prototype
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}.`);
};
// Creating instances
const person1 = new Person("Haider Wain");
const person2 = new Person("Omer Asif");
// Calling the shared method
person1.sayHello(); // Output: Hello, my name is Haider Wain.
person2.sayHello(); // Output: Hello, my name is Omer Asif.
В этом примере у нас есть функция-конструктор с именем Person
. Расширяя Person.prototype
с помощью метода sayHello
, мы добавляем его в цепочку всех экземпляров Person
. Это позволяет каждому экземпляру Person
получать доступ к общему методу и использовать его. Вместо того, чтобы каждый экземпляр имел свою собственную копию метода.
С другой стороны, свойство __proto__
, часто произносимое как «dunder proto», существует в каждом объекте JavaScript. В JavaScript всё, кроме примитивных типов, может рассматриваться как объект. Каждый из этих объектов имеет прототип, который служит ссылкой на другой объект. Свойство __proto__
— это просто ссылка на этот объект-прототип. Он используется в качестве резервного источника для свойств и методов, когда исходный объект ими не обладает. По умолчанию, когда вы создаете объект, его прототипу присваивается значение Object.prototype
.
Когда вы пытаетесь получить доступ к свойству или методу объекта, JavaScript выполняет процесс поиска, чтобы найти его. Этот процесс включает в себя два основных этапа:
- Собственные свойства объекта: JavaScript сначала проверяет, обладает ли сам объект непосредственно нужным свойством или методом. Если свойство найдено внутри объекта, оно используется напрямую.
- Поиск по цепочке прототипов: Если свойство не найдено в самом объекте, JavaScript просматривает прототип (на который ссылается свойство
__proto__
) и выполняет поиск свойства там. Этот процесс продолжается рекурсивно вверх по цепочке до тех пор, пока свойство не будет найдено или пока поиск не достигнетObject.prototype
.
Если свойство не найдено даже в Object.prototype
, JavaScript возвращает undefined
, указывая, что свойство не существует.
❯ 4 — Области применения
При написании кода на JavaScript, важно понимать концепцию области видимости. Область видимости показывает доступность переменных в различных частях вашего кода. Прежде чем перейти к примеру, если вы не знакомы с «поднятием» (hoisting) и с тем, как выполняется JavaScript-код, вы можете узнать об этом по этой ссылке. Это поможет вам более подробно понять, как работает JavaScript-код.
Давайте подробнее рассмотрим фрагмент кода:
function foo() {
console.log(a);
}
function bar() {
var a = 3;
foo();
}
var a = 5;
bar();
Код определяет 2 функции foo()
и bar()
и переменную a
со значением 5
. Все эти операции делаются в общей области. Внутри функции bar()
объявляется переменная a
, которой присваивается значение 3
. Итак, когда вызывается функция bar()
, как вы думаете, какое значение a
она выведет?
Когда движок JavaScript выполняет этот код, объявляется глобальная переменная a и ей присваивается значение 5
. Затем вызывается функция bar()
. Внутри функции bar()
объявляется локальная переменная a
, которой присваивается значение 3
. Эта локальная переменная a
отличается от глобальной переменной a
. После этого функция foo()
вызывается из функции bar()
.
Внутри функции 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
. Такое расположение изменяет динамику масштаба. Теперь, когда foo
попытается получить доступ к переменной a
, он сначала выполнит поиск в своей собственной локальной области видимости. Поскольку он не находит там a
, он расширит свой поиск до области действия функции bar
. О чудо, там есть a со значением 3
. В результате консольный оператор выведет значение 3
.
❯ 5 — Принуждение объекта
Одним из интригующих аспектов, требующих изучения, является то, как 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';
(прим. переводчика: возможно, ошибка в оригинале: «Due to this behaviour, the second assignment, a[b] = '123'; will overwrite the first assignment a[c] = '456';» Но в коде все ровно наоборот, вторая запись a[c]='456' перезаписывает a[b]='123'). Давайте разберем код шаг за шагом:
let a = {};
: Инициализирует пустой объектa
.let b = { key: 'test' };
: Создает объектb
с ключом свойства, имеющим значение'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([]) // "object"
typeof(![]) // "boolean"
[]
это объект, что ясно. Поскольку все в JavaScript является объектом, включая массивы и функции. Но почему операнд ![]
имеет тип boolean
? Давайте попробуем разобраться в этом. Когда вы используете! при использовании примитивного значения выполняются следующие преобразования:
- Ложные значения: Если исходное значение является ложным значением (например,
false
,0
,null
,undefined
,NaN
или пустая строка''
), применение!
преобразует его в значениеtrue
. - Истинные значения: Если исходное значение является истинным значением (любое значение, которое не является ложным), применение
!
преобразует его в значениеfalse
.
В нашем случае []
— это пустой массив, который является истинным значением в JavaScript. Поскольку []
является истиной, ![]
становится ложью. Таким образом, наше выражение становится:
[] == ![]
[] == false
Теперь давайте продвинемся вперед и разберемся с оператором ==
. Всякий раз, когда сравниваются 2 значения с использованием оператора ==
, JavaScript выполняет Алгоритм Сравнения Абстрактного Равенства. Алгоритм состоит из следующих шагов:
Abstract Equality Comparison Algorithm
Как вы можете видеть, этот алгоритм учитывает типы сравниваемых значений и выполняет необходимые преобразования.
Для нашего случая давайте обозначим x
как []
, а y как ![]
. Мы проверили типы x
и y
и обнаружили, что x
является объектом, а y
— boolean. Поскольку y является boolean, а 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) — строка, а Type (y) — число, верни результат сравнения ToNumber (x) == y.
Следовательно, нам нужно преобразовать пустую строку ""
в число, которое дает нам 0
.
"" == 0
ToNumber("") == 0
0 == 0
Наконец, оба операнда имеют одинаковый тип, и выполняется условие 1. Поскольку оба имеют одинаковое значение, конечный результат равен:
0 == 0 // true
До сих пор мы использовали принуждение, что является важной концепцией при освоении JavaScript и решении подобных вопросов в интервью, которые, как правило, задают часто. Я действительно рекомендую вам ознакомиться с моим подробным постом в блоге о принуждении. В нем ясно и досконально объясняется эта концепция. Вот ссылка.
❯ 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 Invoking 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
Спасибо вам за чтение. Я надеюсь, что этот пост будет полезен вам в процессе подготовки к собеседованию.