[Перевод] 8 углубленных вопросов на собеседованиях на роль сеньора в JavaScript

image


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 выполняет процесс поиска, чтобы найти его. Этот процесс включает в себя два основных этапа:

  1. Собственные свойства объекта: JavaScript сначала проверяет, обладает ли сам объект непосредственно нужным свойством или методом. Если свойство найдено внутри объекта, оно используется напрямую.
  2. Поиск по цепочке прототипов: Если свойство не найдено в самом объекте, 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'). Давайте разберем код шаг за шагом:

  1. let a = {};: Инициализирует пустой объект a.
  2. let b = { key: 'test' };: Создает объект b с ключом свойства, имеющим значение '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([]) // "object"
typeof(![]) // "boolean"


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

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


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

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


Теперь давайте продвинемся вперед и разберемся с оператором ==. Всякий раз, когда сравниваются 2 значения с использованием оператора ==, JavaScript выполняет Алгоритм Сравнения Абстрактного Равенства. Алгоритм состоит из следующих шагов:

image


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


Спасибо вам за чтение. Я надеюсь, что этот пост будет полезен вам в процессе подготовки к собеседованию.

Возможно, захочется почитать и это:
b5pjofdoxth14ro-rjsrn7sbmiy.png

© Habrahabr.ru