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
Спасибо вам за чтение. Я надеюсь, что этот пост будет полезен вам в процессе подготовки к собеседованию.