JavaScript — мощный язык, который является одним из основных строительных блоков Интернета. Этот мощный язык также имеет некоторые свои особенности. Например, знаете ли вы, что 0 === -0 имеет значение true или что Number("") возвращает 0?

Дело в том, что иногда эти причуды могут заставить вас почесать голову или даже задаться вопросом, был ли Брэндон Эйх под кайфом в тот день, когда он изобрел JavaScript. Ну, тут дело не в том, что JavaScript — плохой язык программирования или зло, как говорят его критики. Со всеми языками программирования связаны какие-то странности, и JavaScript не является исключением.

В этом сообщении блога мы увидим подробное объяснение некоторых важных вопросов на собеседовании по JavaScript. Моя цель — подробно объяснить эти вопросы на собеседовании, чтобы мы могли понять основные концепции и, надеюсь, решить другие подобные вопросы на собеседованиях.

1. Более внимательный взгляд на операторы + и –

console.log(1 + '1' - 1); 

Можете ли вы угадать поведение операторов + и - JavaScript в ситуациях, подобных приведенной выше?

Когда 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; // 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-Разница между прототипом и __proto__

Свойство prototype — это атрибут, связанный с функциями-конструкторами в JavaScript. Функции конструктора используются для создания объектов в JavaScript. Когда вы определяете функцию-конструктор, вы также можете присоединять свойства и методы к ее свойству prototype. Эти свойства и методы затем становятся доступными для всех экземпляров объектов, созданных из этого конструктора. Таким образом, свойство theprototype служит общим хранилищем для методов и свойств, общих для разных экземпляров.

Рассмотрим следующий фрагмент кода:

// 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», существует в каждом объекте JavaScript. В JavaScript все, кроме примитивных типов, можно рассматривать как объект. У каждого из этих объектов есть прототип, который служит ссылкой на другой объект. Свойство __proto__ — это просто ссылка на этот объект-прототип. Объект-прототип используется в качестве резервного источника свойств и методов, когда исходный объект ими не обладает. По умолчанию при создании объекта его прототипу присваивается значение Object.prototype.

Когда вы пытаетесь получить доступ к свойству или методу объекта, JavaScript выполняет процесс поиска, чтобы найти его. Этот процесс включает в себя два основных этапа:

  1. Собственные свойства объекта: JavaScript сначала проверяет, обладает ли сам объект нужным свойством или методом. Если свойство обнаружено внутри объекта, к нему обращаются и используют напрямую.
  2. Поиск цепочки прототипов. Если свойство не найдено в самом объекте, JavaScript просматривает прототип объекта (на который ссылается свойство __proto__) и ищет там свойство. Этот процесс продолжается рекурсивно вверх по цепочке прототипов до тех пор, пока свойство не будет найдено или пока поиск не достигнет Object.prototype.

Если свойство не найдено даже в Object.prototype, JavaScript возвращает undefined, указывая, что свойство не существует.

4-Области

При написании кода JavaScript важно понимать концепцию области видимости. Область действия означает доступность или видимость переменных в различных частях вашего кода. Прежде чем приступить к примеру, если вы не знакомы с подъемом и тем, как выполняется код JavaScript, вы можете узнать об этом по этой ссылке. Это поможет вам более детально понять, как работает код JavaScript.

Давайте подробнее рассмотрим фрагмент кода:

function foo() {
    console.log(a);
}
  
function bar() {
    var a = 3;
    foo();
}

var a = 5;
bar();

В коде определены две функции 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-объектное принуждение

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';. Разберем код шаг за шагом:

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

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

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

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

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

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

Как видите, этот алгоритм учитывает типы сравниваемых значений и выполняет необходимые преобразования.

В нашем случае обозначим x как [], а y как ![]. Мы проверили типы x и y и обнаружили x как объект и y как логическое значение. Поскольку y — логическое значение, а x — объект, применяется условие 7 из абстрактного алгоритма сравнения на равенство:

Если Type(y) имеет логическое значение, верните результат сравнения 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 для вывода. Применение метода toStringmethod к пустому массиву приводит к получению пустой строки, которая является допустимым примитивом:

[] == 0
[].toString() == 0
"" == 0

Преобразование пустого массива в строку дает нам пустую строку "", и теперь мы сталкиваемся с сравнением: "" == 0.

Теперь, когда один из операндов имеет тип string, а другой — тип number, условие 5 выполняется:

Если Type(x) — строка, а Type(y) — число, верните результат сравнения ToNumber(x) == да.

Следовательно, нам нужно преобразовать пустую строку "" в число, что даст нам 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) внутри цикла. Таким образом, каждый вызов функции имеет собственную копию 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

Спасибо за чтение. Я надеюсь, что этот пост поможет вам в подготовке к собеседованию. Если у вас есть дополнительные вопросы, не стесняйтесь обращаться к нам. Я всегда рад помочь.

Давайте соединим:
LinkedIn
Twitter

Повышение уровня кодирования

Спасибо, что являетесь частью нашего сообщества! Перед тем, как ты уйдешь:

  • 👏 Аплодируйте истории и подписывайтесь на автора 👉
  • 📰 Дополнительную информацию смотрите в публикации Level Up Coding.
  • 💰 Бесплатный курс интервью по программированию ⇒ Просмотреть курс

🔔 Следите за нами: Твиттер | Линкедин | "Новостная рассылка"

🚀👉 Присоединяйтесь к коллективу талантов Level Up и найдите потрясающую работу