Когда речь заходит о технических собеседованиях по JavaScript, статистика может напугать даже бывалого разработчика. По данным за последние годы, около 67% кандидатов проваливают собеседования по JavaScript при первой попытке. И это неудивительно - ведь язык только кажется простым на поверхности, а копнешь глубже - и оказываешся в кроличьей норе особенностей и нюансов.
Я работал интервьюером в нескольких компаниях и могу с уверенностью сказать: главный страх кандидатов - не какие-то супер-сложные алгоритмы, а элементарное непонимание того, как JavaScript работает "под капотом". Замыкания, прототипное наследование, асинхронность - вот три всадника апокалипсиса, которые губят шансы на успех. Второй по распространенности страх - это "синдром самозванца". Когда приходит на собеседование разработчик с 3-5 годами опыта и впадает в ступор от простейших вопросов о событийном цикле или this. А ведь это происходит не потому, что человек плохой программист - просто многие пишут код не задумываясь о механизмах его работы.
Что происходит у нас в голове на техническом интервью
Когда интервьюер задаёт вопрос, в нашей голове запускается целая каскадная реакция. Сначало мы пытаемся понять, что именно от нас хотят услышать (а не просто ответить на вопрос!). Затем лихорадочно перебираем в памяти все, что знаем по теме. И вот тут начинается самое интересное: активируется зона префронтальной коры, отвечающая за социальные взаимодействия, и она конфликтует с частью мозга, отвечающей за технические знания.
Представте ситуацию: вас спрашивают о замыканиях в JavaScript. Вы используете их каждый день, но в момент вопроса ваш мозг выдаёт: "Замыкания? А что это такое?" И дело не в том, что вы не знаете ответа. Проблема в том, что под давлением ваш мозг переключается в режим "бей или беги", блокируя доступ к долговременной памяти.
Нейрофизиологи из Стэнфордского университета обнаружили, что уровень кортизола (гормона стресса) может снижать активность гиппокампа - области мозга, отвечающей за извлечение воспоминаний. Вот почему вы можете прекрасно знать материал, но "зависнуть" при ответе на простой вопрос. Ещё один интересный эффект - "туннельное мышление". Когда интервьюер задаёт каверзный вопрос, мы часто зацикливаемся на первом пришедшем в голову подходе, игнорируя альтернативные и часто более простые решения.
Собеседование на фронтэнд-разработку Привет всем,
кто пробовал устроиться на полный стек фронтэнд-разработку?
Расскажите,... Опыт в JS Ребятки, подскажите, что написать что бы получить реальный опыт в JS??? Опыт Рейнольдса на JS Здравствуйте, попросили меня помочь с созданием скрипта для проведения опыта Рейнольдса, бьюсь уже... Объектно-ориентированное программирование – достоинство и опыт использования Объектно ориентированное программирование – перечисляем достоинство и опыт использования!
В этой...
Вопросы про основы языка - от var до const
Начнем с базовых концепций, которые задают тон всему техническому интервью. Я заметил, что многие собеседования начинаются с безобидного "Расскажите о переменных в JavaScript", а затем стремительно переходят к подводным камням, о которых мало кто задумывается в повседневной работе.
Что такое JavaScript?
Самый первый вопрос может показаться детским, но он часто вызывает неожиданные затруднения. Вместо простого "JavaScript - это язык программирования для веба", лучше продемонстрировать глубину понимания:
"JavaScript - это высокоуровневый, интерпретируемый, динамически типизированный язык с функциями первого класса, поддерживающий мультипарадигменный подход к программированию: объектно-ориентированный, императивный и функциональный стили. Изначально созданный для браузеров, сегодня он работает практически везде - от серверов до микроконтроллеров."
Звучит солидно, но главное - дальше быть готовым к уточняющим вопросам про каждый из упомянутых аспектов.
Переменные и их объявление
Здесь начинается самое интересное. Помню свой провал на собеседовании в 2018 году, когда я не смог чётко объяснить различия между var, let и const. А ведь на этом часто и спотыкаются даже опытные разработчики. Рассмотрим пример:
| JavaScript | 1
2
3
4
5
6
7
| console.log(a); // undefined
console.log(b); // ReferenceError: b is not defined
console.log(c); // ReferenceError: c is not defined
var a = 1;
let b = 2;
const c = 3; |
|
Почему переменная a выводится как undefined, а остальные вызывают ошибку? Дело в механизме поднятия (hoisting), который применяется по-разному к различным способам объявления переменных.
Идём дальше:
| JavaScript | 1
2
3
4
5
6
7
8
| var x = 10;
var x = 20; // Полностью легально
let y = 10;
let y = 20; // SyntaxError: Identifier 'y' has already been declared
const z = 10;
z = 20; // TypeError: Assignment to constant variable |
|
Переменные, объявленные через var, можно переобъявлять сколько угодно раз. С let такой фокус не пройдет. А константы, объявленные через const, вообще нельзя изменять после инициализации. Но тут есть тонкий момент с объектами:
| JavaScript | 1
2
3
| const user = { name: "John" };
user.name = "Mike"; // Полностью работает!
user = {}; // TypeError: Assignment to constant variable |
|
const защищает только саму ссылку на объект, но не его содержимое. Это часто вызывает путаницу на собеседованиях.
Область видимости переменных
Еще один камень преткновения - понимание области видимости. Сравните:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| function varTest() {
var x = 1;
if (true) {
var x = 2; // та же переменная!
console.log(x); // 2
}
console.log(x); // 2
}
function letTest() {
let x = 1;
if (true) {
let x = 2; // другая переменная
console.log(x); // 2
}
console.log(x); // 1
} |
|
Переменные, объявленные через var, имеют функциональную область видимости, а let и const - блочную. Вот почему в первом примере значение x изменяется для всей функции, а во втором остается изолированным в блоке.
Временная мертвая зона (Temporal Dead Zone)
Этот термин звучит устрашающе, но на деле просто означает период между входом в область видимости, где объявлена переменная через let или const, и фактическим объявлением переменной.
| JavaScript | 1
2
3
4
5
6
7
| function testTDZ() {
console.log(letVar); // ReferenceError
console.log(varVar); // undefined
let letVar = 3;
var varVar = 2;
} |
|
Почему так происходит? Потому что переменные, объявленные через var, поднимаются (hoisting) вместе со значением undefined, а переменные, объявленные через let и const, хоть и поднимаются, но остаются недоступными до момента объявления.
Глобальный объект и переменные
Еще один интересный момент, который часто всплывает на собеседованиях:
| JavaScript | 1
2
3
4
5
| var globalVar = "I'm global var";
let globalLet = "I'm global let";
console.log(window.globalVar); // "I'm global var"
console.log(window.globalLet); // undefined |
|
Переменные, объявленные через var в глобальной области, становятся свойствами глобального объекта (window в браузере). Переменные let и const, даже объявленные глобально, не добавляются к глобальному объекту.
Распространенные ловушки и вопросы на собеседованиях
1. "Что выведет следующий код?"
| JavaScript | 1
2
3
| for (var i = 0; i < 5; i++) {
setTimeout(function() { console.log(i); }, 100);
} |
|
Многие ошибочно полагают, что код выведет числа от 0 до 4. На самом деле он выведет пять раз число 5. Причина в том, что переменная i имеет функциональную область видимости, и к моменту выполнения функций в setTimeout цикл уже завершится, а i будет равно 5. Как исправить?
| JavaScript | 1
2
3
| for (let i = 0; i < 5; i++) {
setTimeout(function() { console.log(i); }, 100);
} |
|
Теперь код действительно выведет числа от 0 до 4, потому что let создает новую переменную для каждой итерации цикла.
2. "Как создать константный объект в JavaScript?"
| JavaScript | 1
2
3
4
5
6
7
| const user = Object.freeze({
name: "John",
age: 30
});
user.name = "Mike"; // Не вызовет ошибку, но изменения не произойдет
console.log(user.name); // "John" |
|
Object.freeze() делает объект действительно неизменяемым, но только на первом уровне вложенности. Для глубокой заморозки нужны рекурсивные решения или специальные библиотеки.
3. "Что такое IIFE и как его использовать?"
Immediately Invoked Function Expression (немедленно вызываемое функциональное выражение) - это функция, которая выполняется сразу после создания:
| JavaScript | 1
2
3
4
5
6
| (function() {
var privateVar = "Я недоступна извне";
console.log(privateVar);
})();
console.log(privateVar); // ReferenceError: privateVar is not defined |
|
IIFE часто использовались до появления ES6 для создания приватных переменных и избежания загрязнения глобальной области видимости.
4. "Как работает оператор typeof?"
Этот вопрос кажется простым, но на деле скрывает интересные особенности:
| JavaScript | 1
2
3
4
5
6
7
8
| typeof 42; // "number"
typeof "строка"; // "string"
typeof true; // "boolean"
typeof undefined; // "undefined"
typeof null; // "object" - исторический баг JavaScript!
typeof {}; // "object"
typeof []; // "object" - массивы тоже объекты!
typeof function(){}; // "function" |
|
Обратите внимание на typeof null - он возвращает "object", что является историческим багом языка, который решили не исправлять ради обратной совместимости.
5. "Разница между undefined и null?"
Это классический вопрос:
undefined означает, что переменная объявлена, но не инициализирована,
null - явно присвоенное отсутствие значения.
| JavaScript | 1
2
3
4
| let test;
console.log(test); // undefined
test = null;
console.log(test); // null |
|
6. "Что такое строгий режим и зачем он нужен?"
Строгий режим (strict mode) - это способ выбрать более строгий вариант парсинга JavaScript:
| JavaScript | 1
2
3
| "use strict";
x = 10; // ReferenceError: x is not defined |
|
В строгом режиме нельзя использовать необъявленные переменные, удалять нерасширяемые свойства, дублировать параметры в функциях и многое другое. Это защищает от распространённых ошибок и делает код более предсказуемым.
7. "Как проверить, является ли значение массивом?"
| JavaScript | 1
2
3
4
5
6
7
| const arr = [1, 2, 3];
console.log(typeof arr); // "object" - не очень полезно!
// Правильные способы:
console.log(Array.isArray(arr)); // true
console.log(arr instanceof Array); // true
console.log(Object.prototype.toString.call(arr) === '[object Array]'); // true |
|
Каждый из этих методов имеет свои нюансы, особенно при работе с iframe и кросс-оконными объектами.
8. "Что такое NaN и как его проверить?"
NaN (Not a Number) - специальное значение, представляющее результат неудачной числовой операции:
| JavaScript | 1
2
3
4
5
6
7
8
9
| console.log(parseInt("строка")); // NaN
// Проверка на NaN:
console.log(isNaN(NaN)); // true
console.log(Number.isNaN(NaN)); // true
// Но:
console.log(isNaN("строка")); // true - глобальный isNaN сначала конвертирует
console.log(Number.isNaN("строка")); // false - более строгая проверка |
|
Различия между let, const и var в контексте блочной области видимости
Блочная область видимости - это один из тех аспектов JavaScript, которые регулярно всплывают на собеседованиях и вызывают путаницу. Я постоянно сталкиваюсь с разработчиками, которые не до конца понимают, как переменные ведут себя в разных блоках кода. Блок в JavaScript - это любой фрагмент кода, заключенный в фигурные скобки {}. Сюда относятся тела функций, условные операторы, циклы и даже просто произвольные блоки. Вот наглядный пример, демонстрирующий ключевое различие:
| JavaScript | 1
2
3
4
5
6
7
8
9
| {
var x = 1;
let y = 2;
const z = 3;
}
console.log(x); // 1
console.log(y); // ReferenceError: y is not defined
console.log(z); // ReferenceError: z is not defined |
|
Видите? Переменная x, объявленная через var, "утекает" за пределы блока, тогда как y и z остаются заключенными внутри. Это поведение критично для понимания багов в циклах:
| JavaScript | 1
2
3
4
5
6
7
8
9
| for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Выведет: 3, 3, 3
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 100);
}
// Выведет: 0, 1, 2 |
|
Почему так? Когда мы используем var, существует только одна переменная i для всего цикла. А вот let создаёт новую переменную j для каждой итерации.
Забавный факт: до ES6 разработчики использовали IIFE для эмуляции блочной области видимости:
| JavaScript | 1
2
3
4
5
6
| for (var i = 0; i < 3; i++) {
(function(index) {
setTimeout(() => console.log(index), 100);
})(i);
}
// Выведет: 0, 1, 2 |
|
Довольно громоздко, не правда ли? Неудивительно, что появление let и const было встречено с таким энтузиазмом.
Hoisting и временная мертвая зона - скрытые механизмы работы
Hoisting (поднятие) - один из самых мистических механизмов JavaScript, который заставляет чесать затылок даже опытных разработчиков. Представьте себе: интерпретатор JavaScript просматривает весь ваш код перед выполнением и как бы "поднимает" все объявления переменных и функций в начало их области видимости.
Я как-то проводил техническое интервью, где спросил кандидата с пятилетним опытом о механизме hoisting. Он уверенно ответил: "Это когда переменные поднимаются в начало кода". Частично верно, но в этом и скрывается дьявол.
| JavaScript | 1
2
3
4
5
6
7
8
9
10
| console.log(hoistedFunction()); // Работает!
console.log(hoistedVar); // undefined
console.log(hoistedLet); // ReferenceError
function hoistedFunction() {
return "Я поднят полностью!";
}
var hoistedVar = "Я поднят частично";
let hoistedLet = "А я застрял в TDZ"; |
|
Видите разницу? Функции поднимаются полностью, со всем содержимым. Переменные, объявленные через var, поднимаются, но инициализируются как undefined. А вот переменные, объявленные через let и const, попадают в "временную мертвую зону" (Temporal Dead Zone) - лимб между поднятием и фактическим объявлением. TDZ - не просто абстрактное понятие. Это реальный механизм защиты от использования переменных до их объявления, что помогает избегать нелогичного поведения кода. Фактически, ES6 ввел TDZ, чтобы сделать JavaScript более предсказуемым.
Один из коварных вопросов на собеседованиях связан с function expressions:
| JavaScript | 1
2
3
4
| sayHi(); // TypeError: sayHi is not a function
var sayHi = function() {
console.log("Привет!");
}; |
|
Здесь поднимается только объявление переменной sayHi, но не присваивание ей функции.
Примитивные типы данных против объектных ссылок - тонкости сравнения
Одна из самых коварных ловушек на JavaScript-собеседованиях - это сравнение разных типов данных. Многие разработчики, даже с опытом, спотыкаются именно здесь. В JavaScript есть примитивные типы (string, number, boolean, null, undefined, symbol, bigint) и объекты. Ключевое различие: примитивы сравниваются по значению, а объекты - по ссылке.
| JavaScript | 1
2
3
4
5
6
7
8
9
| // Примитивы
let a = 10;
let b = 10;
console.log(a === b); // true
// Объекты
let obj1 = { value: 10 };
let obj2 = { value: 10 };
console.log(obj1 === obj2); // false! |
|
Почему второе сравнение вернуло false? Потому что obj1 и obj2 - это разные объекты в памяти, хотя их содержимое идентично. А сравнение === проверяет, указывают ли переменные на один и тот же объект.
| JavaScript | 1
2
| let obj3 = obj1;
console.log(obj1 === obj3); // true, обе переменные указывают на один объект |
|
Для сравнения структуры объектов можно использовать JSON:
| JavaScript | 1
| console.log(JSON.stringify(obj1) === JSON.stringify(obj2)); // true |
|
Этот метод работает для простых случаев, но имеет ограничения при работе с циклическими ссылками или специальными типами вроде Map и Set.
Type coercion и неявные преобразования - источник 70% ошибок новичков
Если хотите увидеть боль в глазах начинающего JavaScript-разработчика, спросите его о type coercion. Неявное приведение типов в JavaScript вызывает столько путаницы, что я без преувеличения могу сказать: это источник до 70% всех ошибок новичков. И неудивительно, что эта тема - любимая карта интервьюеров, которые хотят проверить реальное понимание языка.
Но что же такое type coercion? По сути, это процесс автоматического преобразования значения из одного типа в другой. JavaScript делает это постоянно, причем зачастую без вашего явного разрешения.
Явное vs неявное преобразование
Начнем с разницы между явным и неявным преобразованием:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| // Явное преобразование - вы сами говорите JS что делать
const num = Number("42"); // 42
const str = String(42); // "42"
const bool = Boolean(1); // true
// Неявное преобразование - JS решает за вас
const sum = "42" + 10; // "4210" (строковая конкатенация, а не сложение)
const isTrue = "42" == 42; // true (числовое сравнение)
if ("hello") { // строка преобразуется в true
console.log("Эта строка 'истинна'");
} |
|
Именно неявные преобразования чаще всего становятся источником головной боли и багов.
Основные правила преобразования типов
Преобразование к строке
Помню, как-то на собеседовании кандидату дали простую задачу: сложить число с массивом. Он нервно засмеялся, думая, что это какая-то шутка. А потом выдал неправильный ответ.
| JavaScript | 1
| console.log(1 + [2, 3]); // "12,3" |
|
Почему так происходит? JavaScript сначала преобразует массив к строке ("2,3"), а затем конкатенирует число как строку. Вот основные правила:- Примитивы преобразуются довольно предсказуемо:
String(42) дает "42", String(true) дает "true".
- Объекты сначала пытаются использовать метод
toString() или valueOf().
- Массивы преобразуются через join(','), поэтому [1, 2, 3] становится "1,2,3".
- null становится
"null", undefined - "undefined".
Преобразование к числу
Здесь начинается настоящее веселье:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
| Number("42"); // 42
Number("42px"); // NaN
Number(""); // 0 (а не NaN, как могли бы ожидать!)
Number(null); // 0 (сюрприз!)
Number(undefined); // NaN
Number(true); // 1
Number(false); // 0
Number([]); // 0 (еще один сюрприз!)
Number([1]); // 1
Number([1, 2]); // NaN |
|
Я часто спрашиваю на собеседованиях, чему равно Number([]). И поверьте, правильно отвечают немногие.
Преобразование к логическому значению
Здесь действует простое правило: есть список "ложных" значений, а все остальное - "истинное".
Ложные значения в JavaScript:
false
0, -0, 0n (BigInt zero)
""
null
undefined
NaN
Всё остальное, включая любые объекты и массивы (даже пустые!), считается истинным.
| JavaScript | 1
2
3
| Boolean([]); // true
Boolean({}); // true
Boolean(new Date()); // true |
|
Этот момент часто вызывает недоумение у тех, кто приходит из других языков, где пустые коллекции обычно считаются "ложными".
Оператор == vs ===
Оператор нестрогого равенства == - настоящая кроличья нора для новичков. Он пытается привести операнды к одному типу перед сравнением, и делает это по довольно сложным правилам.
| JavaScript | 1
2
3
4
5
6
| "" == 0; // true
0 == "0"; // true
null == undefined; // true
[1] == 1; // true
true == 1; // true
false == 0; // true |
|
И вот что интересно - в большинстве случаев я рекомендую использовать строгое равенство ===, которое не выполняет приведение типов. Но понимание того, как работает ==, остаётся важным для диагностики ошибок и прохождения собеседований.
Коварные примеры с приведением типов
Вот мои любимые примеры, которые я иногда использую на собеседованиях:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // Пример 1: Сложение против конкатенации
console.log(1 + 2 + "3"); // "33" (сначала 1+2=3, потом "3"+3="33")
console.log("1" + 2 + 3); // "123" (сначала "1"+2="12", потом "12"+3="123")
// Пример 2: Вычитание и другие математические операции
console.log("5" - 2); // 3 (строка преобразуется в число)
console.log("5" + 2); // "52" (число преобразуется в строку)
// Пример 3: Сравнение массивов
console.log([] == []); // false (разные объекты в памяти)
console.log([] == 0); // true (пустой массив преобразуется в 0)
console.log([0] == 0); // true (массив с одним элементом преобразуется в "0", затем в 0)
// Пример 4: Пустой массив - "истинное" значение
console.log(Boolean([])); // true
if ([]) {
console.log("Пустой массив истинный!"); // Это выполнится
}
// Пример 5: Но при сравнении...
console.log([] == true); // false ([] преобразуется в 0, true в 1)
console.log(![] == false); // true (сначала ![] дает false, затем false == false) |
|
Последний пример особенно коварен: пустой массив считается истинным значением при преобразовании в логический тип, но при сравнении с true через == дает false!
Оператор + и его двойная роль
Оператор + в JavaScript выполняет две разные функции: сложение чисел и конкатенацию строк. Это создает массу возможностей для неявного преобразования типов.
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| // Если один операнд строка, + действует как конкатенация
console.log("5" + 3); // "53"
console.log(3 + "5"); // "35"
console.log(null + "5"); // "null5"
// Если оба операнда числа, + действует как сложение
console.log(3 + 5); // 8
// Но если операнды не строки и не числа, происходит магия
console.log(true + true); // 2 (true преобразуется в 1)
console.log([] + {}); // "[object Object]" (обе стороны преобразуются в строки)
console.log({} + []); // 0 в некоторых браузерах! {} интерпретируется как пустой блок |
|
Последний пример показывает, насколько хитрым может быть JavaScript. В выражении {} + [] первые фигурные скобки могут интерпретироваться как пустой блок кода, а не как объект, поэтому выражение становится +[], что преобразуется в 0.
Абсурдная математика JavaScript
Вот еще немного сумасшедшей математики, которая иногда всплывает на собеседованиях:
| JavaScript | 1
2
3
4
5
6
| console.log([] + []); // "" (пустая строка)
console.log({} + {}); // "[object Object][object Object]"
console.log([] - {}); // NaN
console.log(true - null); // 1 (true -> 1, null -> 0)
console.log("2" * "3"); // 6 (строки преобразуются в числа)
console.log("5" - "2"); // 3 (строки преобразуются в числа) |
|
Лучшие практики для избежания ошибок
После десятков проведенных собеседований я выработал несколько рекомендаций:
1. Используйте строгое равенство (===) вместо нестрогого (==), чтобы избежать неявного преобразования типов при сравнении.
2. Делайте явное преобразование типов вместо того, чтобы полагаться на неявное. Например, Number(value) вместо +value.
3. Будьте особенно осторожны с оператором +, помня о его двойной роли для сложения и конкатенации.
4. Используйте строгий режим ("use strict";), который поможет выявить некоторые проблемы на ранних стадиях.
5. Применяйте инструменты статического анализа кода вроде ESLint, которые могут предупреждать о потенциально опасных местах.
А еще одна неочевидная особенность, которую я часто встречаю на собеседованиях - это различное поведение операторов сравнения:
| JavaScript | 1
2
3
| console.log(null == 0); // false
console.log(null >= 0); // true
console.log(null > 0); // false |
|
Ничего не понимаете? Я тоже долго мучился. Дело в том, что операторы сравнения >, <, >=, <= и операторы равенства ==, != работают по-разному. Сравнения преобразуют null в число (0), а проверка равенства - нет!
Случаи с объектами и символами
Особую путаницу вызывает преобразование объектов и символов:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| const sym = Symbol("description");
console.log(String(sym)); // "Symbol(description)"
console.log(sym + ""); // TypeError: Cannot convert a Symbol value to a string
const obj = {
toString() { return "строка"; },
valueOf() { return 42; }
};
console.log(obj + ""); // "42" - вызывается valueOf
console.log(`${obj}`); // "строка" - в шаблонных строках вызывается toString |
|
Видите, как меняется поведение в зависимости от контекста? Для шаблонных строк используется toString(), а для оператора + сначала пробуется valueOf().
Для чего вообще нужно неявное преобразование?
При всех проблемах, которые оно создает, неявное преобразование типов делает код более лаконичным в простых случаях. Сравните:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| // С явным преобразованием
const count = Number(document.getElementById("counter").value);
if (Boolean(count) && count > Number(localStorage.getItem("threshold"))) {
// код
}
// С неявным преобразованием
const count = document.getElementById("counter").value;
if (count && count > localStorage.getItem("threshold")) {
// код
} |
|
Второй вариант короче и, если вы понимаете механизмы JavaScript, вполне читабельный. Проблема в том, что многие не понимают эти механизмы глубоко, что приводит к неожиданным багам.
Я по опыту знаю: лучше потратить время на изучение этих особенностей, чем потом часами отлаживать непонятные баги в продакшене.
Замыкания и области видимости - камень преткновения
Замыкания в JavaScript - это концепция, которую понимают многие, но по-настоящему осознают единицы. Мой опыт показывает, что именно здесь спотыкается большинство кандидатов на собеседованиях, даже с солидным опытом работы.
Простыми словами, замыкание - это когда функция "помнит" и может получать доступ к переменным из области, где она была создана, даже после того, как эта функция вызывается вне этой области. Звучит запутанно? Посмотрим на примере:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| function createCounter() {
let count = 0; // Приватная переменная
return function() {
return ++count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3 |
|
Функция counter имеет доступ к переменной count, хотя выполняется вне функции createCounter, где эта переменная была объявлена. Это и есть замыкание - функция "замкнула" в себе внешнюю область видимости.
Я часто задаю кандидатам вопрос: "Почему значение count сохраняется между вызовами?". И многие путаются, отвечая что-то вроде "это же глобальная переменная". Но нет! Она абсолютно локальна, просто ссылка на нее сохраняется в лексическом окружении функции. Замыкания - не просто академическая концепция. Они формируют основу для модульности, инкапсуляции приватных данных и функционального программирования в JavaScript.
Лексическое окружение и его влияние на производительность
Когда я копаюсь в недрах JavaScript движков, всегда поражаюсь тому, насколько дорого обходятся замыкания с точки зрения производительности. Лексическое окружение (Lexical Environment) - это внутренняя структура данных, хранящая переменные и их значения, доступные функции во время её выполнения. Фактически, это невидимый для нас механизм, обеспечивающий работу замыканий.
Каждое замыкание создаёт отдельную копию лексического окружения, и это может существенно влиять на потребление памяти. Движок V8 вынужден хранить все переменные из замкнутого окружения, даже если функция использует только часть из них.
| JavaScript | 1
2
3
4
5
6
7
8
9
| function heavyClosure() {
const bigArray = new Array(1000000).fill('memory leak');
const actuallyUsed = 'small value';
return function() {
return actuallyUsed; // использует только одну переменную
// но bigArray всё равно сохраняется в памяти!
};
} |
|
В таких случаях движок не всегда может оптимизировать память. Некоторые современные JS движки пытаются применять оптимизации вроде "замыкание по переменным" (variable capturing), но это не универсальное решение. Интересно, что создание большого количества маленьких функций с замыканиями может оказаться более затратным, чем несколько крупных функций без замыканий - противоречие функциональному подходу, который я так люблю.
Практические примеры замыканий в модулях и фабричных функциях
В реальной разработке замыкания - не просто теоретический концепт, а мощный инструмент. Возьмем модульный паттерн, которым я часто пользуюсь:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| const counter = (function() {
let count = 0; // Приватная переменная
return {
increment: () => ++count,
value: () => count
};
})();
counter.increment(); // 1
console.log(counter.value()); // 1 |
|
Переменная count недоступна извне, но функции в возвращаемом объекте "замкнули" её в своём лексическом окружении. Фабричные функции также активно используют замыкания:
| JavaScript | 1
2
3
4
5
6
7
8
| function createLogger(prefix) {
return message => console.log(`${prefix}: ${message}`);
}
const debugLog = createLogger("[DEBUG]");
const errorLog = createLogger("[ERROR]");
debugLog("Инициализация"); // "[DEBUG]: Инициализация" |
|
Замыкания неоценимы при работе с асинхронностью и кешированием:
| JavaScript | 1
2
3
4
5
6
7
8
9
| function getData(url) {
let cache = null;
return async function() {
if (cache) return cache;
cache = await fetch(url).then(r => r.json());
return cache;
}
} |
|
Module pattern и IIFE - архитектурные решения через замыкания
Module pattern и IIFE (Immediately Invoked Function Expression) - два архитектурных подхода, которые я использую практически в каждом проекте. Оба основаны на мощи замыканий, но с немного разными целями.
IIFE - это самовызывающаяся функция, которая исполняется сразу после создания:
| JavaScript | 1
2
3
4
5
6
| (function() {
const privateVar = "Не видна снаружи";
console.log(privateVar); // Доступно внутри
})();
console.log(privateVar); // ReferenceError |
|
Module pattern - его более продвинутая версия, возвращающая публичный API:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| const myModule = (function() {
const privateVar = "Секретные данные";
function privateFunction() {
return privateVar;
}
return {
publicMethod: function() {
return privateFunction() + " доступны через интерфейс";
}
};
})();
myModule.publicMethod(); // "Секретные данные доступны через интерфейс"
myModule.privateVar; // undefined |
|
До появления ES6-модулей это был мой основной инструмент для создания инкапсулированных компонентов. Модуль получает доступ к приватным данным через замыкание, но внешний код видит только публичный интерфейс.
Асинхронность: промисы, async/await и их подводные камни
Асинхронность в JavaScript - это та часть языка, которая заставила меня по-настоящему седеть. И я не шучу! За последние 7 лет как технический интервьюер я видел, как опытные разработчики с многолетним стажем путались в простейших вопросах о промисах. Асинхронщина - это темный лес даже для ветеранов.
Давайте начнем с истории. Помните эпоху колбеков? Тот самый легендарный callback hell:
| JavaScript | 1
2
3
4
5
6
7
8
9
| getUserData(userId, function(userData) {
getFriendsList(userData.id, function(friends) {
getPhotos(friends[0].id, function(photos) {
savePhoto(photos[0], function(result) {
// и так далее... вглубь кроличьей норы
});
});
});
}); |
|
Это был настоящий кошмар! Код превращался в пирамиду из скобок, которую невозможно поддерживать. И тогда на сцену вышли промисы - наше спасение. Но как оказалось, и с ними не всё так просто.
Промисы: базовые концепции
Промис - это объект, представляющий асинхронную операцию, которая может завершиться успехом или провалом. У него три состояния:
pending (ожидание),
fulfilled (выполнено),
rejected (отклонено)
| JavaScript | 1
2
3
4
5
6
7
8
| const myPromise = new Promise((resolve, reject) => {
// Здесь асинхронная работа
if (всеОк) {
resolve('Данные');
} else {
reject(new Error('Что-то пошло не так'));
}
}); |
|
Выглядит просто, правда? Но копнем глубже. Вот типичный вопрос с собеседования:
| JavaScript | 1
2
3
4
5
6
7
8
9
| console.log('Начало');
Promise.resolve().then(() => console.log('Промис 1'));
setTimeout(() => console.log('Таймер 1'), 0);
Promise.resolve().then(() => console.log('Промис 2'));
console.log('Конец'); |
|
Какой будет порядок вывода? Многие отвечают неправильно. Правильный ответ:
| JavaScript | 1
2
3
4
5
| Начало
Конец
Промис 1
Промис 2
Таймер 1 |
|
Почему так? Потому что промисы используют очередь микрозадач (microtask queue), которая обрабатывается раньше, чем очередь макрозадач (task queue), куда попадают таймеры.
Цепочки промисов и обработка ошибок
Одно из главных преимуществ промисов - возможность создавать цепочки:
| JavaScript | 1
2
3
4
5
| fetchUser(userId)
.then(user => fetchUserPosts(user.id))
.then(posts => filterImportantPosts(posts))
.then(importantPosts => console.log(importantPosts))
.catch(error => console.error('Что-то пошло не так:', error)); |
|
Но тут есть подводный камень! Посмотрите на этот код:
| JavaScript | 1
2
3
4
5
6
| fetchData()
.then(data => {
const processedData = processData(data); // Может выбросить исключение!
return processedData;
})
.catch(error => console.error(error)); |
|
Знаете ли вы, что .catch поймает ошибки не только из промиса fetchData(), но и из функции processData? Эта неочевидная особенность часто становится причиной путаницы.
Promise.all и его друзья
Для работы с несколькими промисами у нас есть целый набор инструментов:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Ждем выполнения всех промисов
Promise.all([fetchUsers(), fetchPosts(), fetchComments()])
.then(([users, posts, comments]) => {
// Все данные получены
})
.catch(error => {
// Если хотя бы один промис отклонен
});
// Ждем первый выполненный промис
Promise.race([fetchFromServer1(), fetchFromServer2()])
.then(fastestResult => {
// Получен результат от самого быстрого сервера
});
// Ждем завершения всех промисов, независимо от результата
Promise.allSettled([riskyOperation1(), riskyOperation2()])
.then(results => {
// results - массив объектов со статусом и значением
}); |
|
На собеседовании меня часто спрашивали: "Что произойдет, если один из промисов в Promise.all отклонится?" Ответ: вся операция завершится с ошибкой, и .catch получит эту ошибку. Это называется fail-fast поведением.
Революция async/await
ES2017 принес нам async/await - синтаксический сахар над промисами, который делает асинхронный код почти похожим на синхронный:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
| async function getUserData() {
try {
const user = await fetchUser(userId);
const posts = await fetchUserPosts(user.id);
const comments = await fetchPostComments(posts[0].id);
return { user, posts, comments };
} catch (error) {
console.error('Ошибка:', error);
}
} |
|
Выглядит прекрасно, но и тут есть свои каверзы. Самая распространенная ошибка - случайное последовательное выполнение операций:
| JavaScript | 1
2
3
4
5
6
7
| // Неэффективный код - операции выполняются последовательно
async function fetchAllData() {
const users = await fetchUsers();
const posts = await fetchPosts();
const comments = await fetchComments();
return { users, posts, comments };
} |
|
Тут каждый запрос ждет завершения предыдущего, хотя они могли бы выполняться параллельно! Правильный вариант:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| async function fetchAllData() {
const usersPromise = fetchUsers();
const postsPromise = fetchPosts();
const commentsPromise = fetchComments();
const users = await usersPromise;
const posts = await postsPromise;
const comments = await commentsPromise;
return { users, posts, comments };
}
// Или еще короче
async function fetchAllData() {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments()
]);
return { users, posts, comments };
} |
|
Частые ошибки с async/await
Помню случай, когда кандидат с пятилетним опытом не смог объяснить, что не так с этим кодом:
| JavaScript | 1
2
3
4
| function getData() {
const data = await fetchData(); // Синтаксическая ошибка!
return data;
} |
|
Ключевое слово await можно использовать только внутри async функций! Но даже опытные разработчики иногда забывают об этом в пылу кодинга.
Еще одна распространенная ошибка - использование try/catch вокруг await, но забывать, что код после await тоже может выбросить исключение:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| async function processData() {
try {
const rawData = await fetchData();
// Следующая строка может выбросить исключение,
// и оно будет поймано в этом же блоке try/catch
const processedData = JSON.parse(rawData);
return processedData;
} catch (error) {
console.error("Ошибка:", error);
}
} |
|
Работа с forEach и async/await
Вот еще один коварный пример:
| JavaScript | 1
2
3
4
5
6
7
| async function processItems(items) {
items.forEach(async item => {
const result = await processItem(item);
console.log(result);
});
console.log('Все обработано!');
} |
|
Догадываетесь, что не так? forEach не ждет выполнения асинхронных операций! Сообщение "Все обработано!" появится до того, как будет обработан хотя бы один элемент. Правильный вариант:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| async function processItems(items) {
for (const item of items) {
const result = await processItem(item);
console.log(result);
}
console.log('Теперь действительно все обработано!');
}
// Или для параллельной обработки:
async function processItems(items) {
await Promise.all(items.map(async item => {
const result = await processItem(item);
console.log(result);
}));
console.log('Все обработано параллельно!');
} |
|
Возвращение значений из async функций
Еще один нюанс: любая async функция всегда возвращает промис. Даже если в теле нет await, результат будет обернут в Promise.resolve():
| JavaScript | 1
2
3
4
5
| async function giveMeNumber() {
return 42; // на самом деле вернется Promise.resolve(42)
}
giveMeNumber().then(num => console.log(num)); // 42 |
|
И наконец, один из моих любимых вопросов на собеседовании:
| JavaScript | 1
2
3
4
5
6
7
8
| async function chainExample() {
return Promise.resolve(1)
.then(x => x + 1)
.then(x => Promise.resolve(x + 1))
.then(x => x + 1);
}
chainExample().then(console.log); // Что будет выведено? |
|
Ответ: 4. Каждый .then увеличивает значение на 1, независимо от того, возвращает ли он обычное значение или промис.
Микрозадачи против макрозадач - понимание приоритетов
Если вы когда-нибудь пытались предсказать порядок выполнения асинхронного кода в JavaScript, то наверняка сталкивались с путаницей между микро- и макрозадачами. Это та область, где даже разработчики с многолетним опытом часто оказываются в замешательстве на собеседованиях.
Чтобы разобраться с этим раз и навсегда, давайте представим, что движок JavaScript — это ресторан с двумя очередями посетителей: VIP-клиенты (микрозадачи) и обычные посетители (макрозадачи). Движок всегда обслуживает всех VIP-клиентов, прежде чем перейти к обычным, даже если обычные посетители пришли раньше.
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| console.log('Официант принимает заказ'); // Синхронный код
setTimeout(() => {
console.log('Макрозадача: заказ обычного клиента');
}, 0);
Promise.resolve().then(() => {
console.log('Микрозадача: заказ VIP-клиента');
});
console.log('Официант уходит на кухню'); // Синхронный код |
|
Вывод будет таким:
| JavaScript | 1
2
3
4
| Официант принимает заказ
Официант уходит на кухню
Микрозадача: заказ VIP-клиента
Макрозадача: заказ обычного клиента |
|
Когда я провожу собеседования, я обожаю давать такие примеры с несколькими вложеными микро- и макрозадачами. Видели бы вы лица кандидатов, пытающихся мысленно выстроить порядок выполнения!
Что относится к микрозадачам?
К микрозадачам относятся:- Обработчики
.then(), .catch() и .finally() промисов;
- Коллбэки, запланированные через
queueMicrotask();
- Обработка
await в async-функциях (по сути, это замаскированный .then());
Что относится к макрозадачам?
К макрозадачам относятся:- Таймеры (
setTimeout, setInterval);
- Операции ввода-вывода;
- Обработчики событий UI (клики, скроллы и т.д.);
- setImmediate (в Node.js).
Важно понимать: между каждой макрозадачей движок полностью опустошает очередь микрозадач. И это объясняет, почему следующий код может сломать мозг:
| JavaScript | 1
2
3
4
5
6
7
8
9
| setTimeout(() => {
console.log('Таймер 1');
Promise.resolve().then(() => console.log('Промис внутри таймера'));
}, 0);
Promise.resolve().then(() => {
console.log('Промис 1');
setTimeout(() => console.log('Таймер внутри промиса'), 0);
}); |
|
Результат:
| JavaScript | 1
2
3
4
| Промис 1
Таймер 1
Промис внутри таймера
Таймер внутри промиса |
|
Я помню, как один разработчик с 8-летним стажем ошибся в этом вопросе — так что не стисняйтесь, если и вы запутались. Понимание этого механизма требует практики и глубокого погружения в Event Loop.
Обработка ошибок в асинхронном коде - try/catch против .catch()
Самый нелепый баг, с которым я когда-либо сталкивался, произошел в крупном финтех-проекте. Мы потеряли несколько дней, разбираясь с мистическим падением приложения в продакшне, пока не обнаружили, что асинхронная ошибка тихо "проглатывалась" в одном из обработчиков. Простой вопрос - как правильно обрабатывать ошибки в асинхронном коде - стал причиной серьезных проблем.
Когда дело доходит до собеседований, я часто спрашиваю кандидатов о разнице между try/catch и .catch() при обработке ошибок. И вы бы удивились, сколько опытных разработчиков путаются в этом вопросе!
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // Подход 1: try/catch с async/await
async function fetchDataWithTryCatch() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return data;
} catch (error) {
console.error('Произошла ошибка:', error);
return null;
}
}
// Подход 2: промисы с .catch()
function fetchDataWithPromiseCatch() {
return fetch('https://api.example.com/data')
.then(response => response.json())
.catch(error => {
console.error('Произошла ошибка:', error);
return null;
});
} |
|
Оба подхода выглядят равноценными, но имеют тонкие различия, которые могут стать причиной коварных багов.
Зона действия try/catch vs .catch()
Главное отличие заключается в зоне действия этих конструкций:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| async function problemDemo1() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return processData(data); // Может выбросить исключение!
} catch (error) {
console.error('Перехвачена ошибка:', error);
return null;
}
}
function problemDemo2() {
return fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => {
return processData(data); // Может выбросить исключение!
})
.catch(error => {
console.error('Перехвачена ошибка:', error);
return null;
});
} |
|
В обоих примерах функция processData может выбросить исключение. В первом случае try/catch перехватит это исключение. Во втором случае .catch() также перехватит ошибку. Однако есть важное различие: try/catch перехватит любое исключение в своем блоке, включая синхронные ошибки, а .catch() перехватит только ошибки из промисной цепочки.
Каверзные случаи с асинхронными ошибками
Вот коварный пример, который я часто даю на собеседованиях:
| JavaScript | 1
2
3
4
5
6
7
8
9
| function problematicErrorHandling() {
try {
fetchData().then(data => {
console.log(data.nonExistentProperty.something); // Потенциальная ошибка
});
} catch (error) {
console.error('Эта ошибка никогда не будет перехвачена!', error);
}
} |
|
Большинство кандидатов считают, что ошибка доступа к несуществующему свойству будет перехвачена блоком catch. Но это не так! К моменту возникновения ошибки блок try/catch уже завершил свою работу, потому что .then() выполняется асинхронно. Правильный подход:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| function correctErrorHandling() {
fetchData()
.then(data => {
console.log(data.nonExistentProperty.something);
})
.catch(error => {
console.error('Теперь ошибка будет перехвачена', error);
});
}
// Или с async/await
async function anotherCorrectApproach() {
try {
const data = await fetchData();
console.log(data.nonExistentProperty.something);
} catch (error) {
console.error('И здесь ошибка будет перехвачена', error);
}
} |
|
Непойманные отклонения промисов (Unhandled Promise Rejections)
Одна из самых распространенных проблем в асинхронном JavaScript - непойманные отклонения промисов. Раньше они тихо исчезали, что приводило к кошмарным сценариям отладки. В современных браузерах и Node.js такие ошибки генерируют предупреждение в консоли, а в будущих версиях могут привести к аварийному завершению программы.
| JavaScript | 1
2
3
4
5
6
| // Потенциальная проблема
const promise = fetchData();
// Забыли добавить .catch() или обработать ошибку
// Решение - всегда добавляйте обработку ошибок
fetchData().catch(error => console.error(error)); |
|
В Node.js можно добавить глобальный обработчик:
| JavaScript | 1
2
3
4
5
| process.on('unhandledRejection', (reason, promise) => {
console.error('Непойманное отклонение промиса:', reason);
// Для отладки
console.error('Проблемный промис:', promise);
}); |
|
Но не стоит полностью полагаться на этот подход в продакшн-коде - это скорее костыль для отлавливания ошибок во время разработки.
Грануляция обработки ошибок
Еще один аспект, который часто упускают из виду - это грануляция обработки ошибок. Слишком общий блок try/catch может скрыть важные детали проблемы:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| async function tooGenericErrorHandling() {
try {
await authenticateUser();
await fetchUserProfile();
await updateUserSettings();
return "Успех!";
} catch (error) {
// Какая операция вызвала ошибку? Трудно понять!
console.error("Что-то пошло не так:", error);
return "Ошибка";
}
} |
|
Более детальный подход:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| async function detailedErrorHandling() {
try {
await authenticateUser();
} catch (error) {
console.error("Ошибка аутентификации:", error);
throw new AuthError("Не удалось авторизоваться", { cause: error });
}
try {
await fetchUserProfile();
} catch (error) {
console.error("Ошибка загрузки профиля:", error);
throw new ProfileError("Не удалось загрузить профиль", { cause: error });
}
try {
await updateUserSettings();
} catch (error) {
console.error("Ошибка обновления настроек:", error);
throw new SettingsError("Не удалось обновить настройки", { cause: error });
}
return "Успех!";
} |
|
Этот подход дает более детальное понимание источника проблемы и позволяет создавать пользовательские ошибки с более информативными сообщениями.
Стратегия повторных попыток (Retry Strategy)
В реальных приложениях часто требуется не просто обработать ошибку, но и предпринять повторную попытку. Вот мой любимый паттерн для этого:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| async function fetchWithRetry(url, options = {}, retries = 3, backoff = 300) {
try {
return await fetch(url, options);
} catch (error) {
if (retries <= 0) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, backoff));
return fetchWithRetry(url, options, retries - 1, backoff * 2);
}
} |
|
Эта функция автоматически повторяет запрос при ошибке, с экспоненциальным увеличением задержки между попытками.
На собеседованиях я часто спрашиваю, как бы кандидат улучшил этот код. Сильные ответы включают:- Добавление различной логики для разных типов ошибок (не повторять при 401 Unauthorized, но повторять при 503 Service Unavailable),
- Добавление случайного "джиттера" к времени ожидания для предотвращения "штормового эффекта" при множественных одновременных сбоях,
- Реализацию схемы "circuit breaker" для предотвращения каскадных отказов.
В конечном счете, обработка ошибок в асинхронном коде - это искусство баланса между детализацией, производительностью и читаемостью. И помните - я обнаружил, что по-настоящему умелые разработчики тратят столько же времени на обработку исключительных ситуаций, сколько на реализацию основного функционала. Не экономьте на этом!
Race conditions и их предотвращение через правильную работу с Promise.all
Если вы когда-нибудь писали асинхронный код, то, скорее всего, попадали в ловушку состояния гонки (race condition), даже не подозревая об этом. Мне до сих пор снятся кошмары о том случае, когда на боевом сервере у крупного банковского клиента пользователи начали получать данные других клиентов из-за неправильной обработки параллельных запросов. Мы буквально всей командой не спали трое суток, разбираясь в причинах. Race condition возникает, когда результат выполнения кода зависит от порядка или времени выполнения асинхронных операций. Классический пример — два запроса к API, запущенные одновременно, но не ясно, какой завершится первым:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| let userData = null;
fetch('/api/user')
.then(response => response.json())
.then(data => userData = data);
fetch('/api/settings')
.then(response => response.json())
.then(settings => {
// Используем userData, но уверены ли мы, что он уже загружен?
console.log(userData?.name, settings);
}); |
|
В этом коде мы предполагаем, что данные пользователя загрузятся раньше настроек, но это совершенно не гарантировано! Сеть — штука непредсказуемая, и первый запрос может выполняться дольше второго.
Решение проблемы с Promise.all
Promise.all — наш спасательный круг в море асинхронного хаоса. Он гарантирует, что код выполнится только после завершения всех промисов:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| Promise.all([
fetch('/api/user').then(response => response.json()),
fetch('/api/settings').then(response => response.json())
])
.then(([userData, settings]) => {
// Теперь мы точно знаем, что оба запроса завершились
console.log(userData.name, settings);
})
.catch(error => {
console.error('Что-то пошло не так:', error);
}); |
|
Этот код избегает race condition, поскольку блок .then() выполнится только когда оба промиса разрешатся. Однако у Promise.all есть важная особенность — если хотя бы один промис отклоняется, весь вызов немедленно завершается с ошибкой, игнорируя даже успешно завершившиеся промисы.
Когда Promise.all не спасает
На одном собеседовании я задал каверзный вопрос: "Как избежать race condition при обновлении данных на сервере, если обновление зависит от текущего состояния?" Многие кандидаты отвечали что-то вроде "использовать Promise.all", но это неверно! Вот реальный пример проблемы:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| async function incrementCounter() {
// Получаем текущее значение
const { count } = await fetch('/api/counter').then(r => r.json());
// Увеличиваем на 1
return fetch('/api/counter', {
method: 'POST',
body: JSON.stringify({ count: count + 1 }),
headers: { 'Content-Type': 'application/json' }
});
}
// Что если эти функции выполняются параллельно?
incrementCounter();
incrementCounter(); |
|
Если эти функции выполняются почти одновременно, обе могут получить одинаковое начальное значение счетчика, и в итоге вместо увеличения на 2 счетчик увеличится только на 1!
Promise.all тут не поможет, потому что проблема не в порядке выполнения, а в наличии транзакции. Решение — использовать атомарные операции на сервере или блокировки:
| JavaScript | 1
2
3
| async function incrementCounter() {
return fetch('/api/counter/increment', { method: 'POST' });
} |
|
Последовательное выполнение vs параллельное
Иногда для избежания race condition нужно явно выполнять операции последовательно, а не параллельно:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Последовательное выполнение
async function sequentialExecution() {
const result1 = await operation1();
const result2 = await operation2(result1); // Зависит от result1
const result3 = await operation3(result2); // Зависит от result2
return result3;
}
// Параллельное выполнение (когда операции независимы)
async function parallelExecution() {
const [result1, result2, result3] = await Promise.all([
operation1(),
operation2(),
operation3()
]);
return combineResults(result1, result2, result3);
} |
|
На собеседованиях я часто спрашиваю, какой подход лучше, и правильный ответ — "зависит от задачи". Если операции независимы, параллельное выполнение даст лучшую производительность. Если есть зависимости между операциями, последовательное выполнение — единственный вариант.
Продвинутые техники с Promise.all
Одна из моих любимых техник — запуск операций параллельно, но обработка результатов в определенном порядке:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| async function processInOrder(urls) {
// Запускаем все запросы параллельно, но не ждем их завершения
const promises = urls.map(url => fetch(url).then(r => r.json()));
// Обрабатываем результаты в нужном порядке
for (let i = 0; i < promises.length; i++) {
const data = await promises[i];
console.log(`Обработка данных из ${urls[i]}`);
processData(data);
}
} |
|
Этот код максимизирует параллелизм, но гарантирует порядок обработки результатов.
Баланс между производительностью и безопасностью
Когда на собеседовании заходит речь о race conditions, я всегда акцентирую внимание на поиске баланса между производительностью и безопасностью:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // Высокая производительность, но риск race condition
async function riskyApproach() {
const [users, permissions] = await Promise.all([
fetchUsers(),
fetchPermissions()
]);
return users.map(user => ({
...user,
permissions: permissions[user.id]
}));
}
// Безопаснее, но менее эффективно
async function saferApproach() {
const users = await fetchUsers();
// Последовательная обработка каждого пользователя
const enrichedUsers = [];
for (const user of users) {
const userPermissions = await fetchUserPermissions(user.id);
enrichedUsers.push({
...user,
permissions: userPermissions
});
}
return enrichedUsers;
} |
|
Опытный разработчик знает, что иногда стоит пожертвовать производительностью ради надежности, особенно в критически важных системах.
Реальные примеры из практики
Один из самых коварных случаев race condition, с которым я столкнулся, был связан с авторизацией. Пользователь мог войти в систему на двух устройствах одновременно, и на сервере возникала путаница с сессиями. Решение потребовало внедрения распределенной блокировки и переосмысления всего процесса авторизации.
Другой случай был связан с обновлением корзины в интернет-магазине. Пользователь мог добавить товар в корзину, а затем быстро изменить количество, прежде чем завершится первая операция. Это приводило к непредсказуемым состояниям корзины. Решение включало в себя очередь операций на клиенте и идемпотентные операции на сервере.
Эти примеры показывают, что понимание race conditions и умение их предотвращать — критически важный навык для современного JavaScript-разработчика. И именно этот навык часто проверяют на технических собеседованиях, хотя и не всегда напрямую.
AbortController и отмена промисов - современные подходы к управлению запросами
Долгое время в JavaScript была серьезная проблема: мы не могли отменять промисы и fetch-запросы. Представьте, пользователь нажал кнопку, которая запускает загрузку большого файла, а потом передумал и нажал "Отмена". Раньше у нас не было стандартного способа прервать этот запрос - он продолжал выполняться в фоне, потребляя ресурсы и потенциально приводя к неожиданным результатам.
Я однажды столкнулся с ужасным багом в продакшне, когда пользователи быстро переключались между вкладками в приложении, каждая из которых инициировала свои API-запросы. Из-за разной скорости сети более поздние запросы иногда завершались раньше ранних, что приводило к отображению устаревших данных. Классический случай "race condition", но с дополнительной проблемой - мы не могли просто отменить старые запросы при переключении вкладок!
К счастью, сейчас у нас есть AbortController - мощный API для отмены асинхронных операций. Вот базовый пример:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| const controller = new AbortController();
const signal = controller.signal;
fetch('https://api.example.com/data', { signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('Запрос был отменен!');
} else {
console.error('Произошла ошибка:', error);
}
});
// Где-то в другом месте кода, например, по нажатию кнопки "Отмена"
controller.abort(); |
|
Когда вызывается controller.abort(), запрос прерывается, и промис переходит в состояние отклонения (rejected) с ошибкой типа AbortError. Красота этого подхода в том, что мы можем явно обрабатывать отмену запроса отдельно от других ошибок.
Таймауты и автоматическая отмена
Одна из самых полезных возможностей AbortController - реализация таймаутов для запросов. Я обнаружил, что это отличный способ предотвратить "подвисание" интерфейса, когда API не отвечает:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const { signal } = controller;
// Создаем таймер, который отменит запрос через указанное время
const timeoutId = setTimeout(() => controller.abort(), timeout);
return fetch(url, { ...options, signal })
.then(response => {
clearTimeout(timeoutId); // Очищаем таймер, если запрос успешен
return response;
})
.catch(error => {
clearTimeout(timeoutId); // Очищаем таймер в случае ошибки
if (error.name === 'AbortError') {
throw new Error(`Запрос превысил таймаут в ${timeout}мс`);
}
throw error;
});
}
// Использование
fetchWithTimeout('https://api.example.com/data', {}, 3000)
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error.message)); |
|
Эта функция позволяет установить максимальное время ожидания ответа от сервера, после которого запрос автоматически отменяется. Особенно полезно для мобильных приложений, где качество соединения может быть нестабильным.
Отмена нескольких запросов одновременно
Еще одна мощная особенность AbortController - возможность отменять несколько запросов одним действием. Я часто использую этот подход при реализации поиска с автодополнением:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| let searchController = new AbortController();
async function search(query) {
// Отменяем предыдущий поисковый запрос, если он еще выполняется
searchController.abort();
// Создаем новый контроллер для текущего запроса
searchController = new AbortController();
const { signal } = searchController;
try {
const response = await fetch(`/api/search?q=${query}`, { signal });
const results = await response.json();
displayResults(results);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Ошибка поиска:', error);
}
// Мы просто игнорируем AbortError, так как это ожидаемое поведение
}
}
// Привязываем к полю ввода
searchInput.addEventListener('input', () => {
search(searchInput.value);
}); |
|
В этом примере каждый новый ввод в поле поиска отменяет предыдущий запрос, предотвращая гонку условий и обеспечивая отображение только результатов последнего запроса.
Интеграция с async/await
AbortController отлично работает с async/await, но есть несколько неочевидных моментов. Например, как обрабатывать отмену в блоке try/catch:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| async function fetchData(url, signal) {
try {
const response = await fetch(url, { signal });
const data = await response.json();
return data;
} catch (error) {
if (error.name === 'AbortError') {
console.log('Запрос был отменен');
// Не забудьте обработать этот случай!
// Некоторые разработчики ошибочно просто выбрасывают исключение дальше
// throw error; // Это может привести к необработанным исключениям
// Лучше вернуть специальное значение или null
return { aborted: true };
}
throw error; // Прокидываем другие ошибки дальше
}
} |
|
Важно помнить, что AbortError - это тоже ошибка, и если вы не обработаете её должным образом, она может "всплыть" выше и привести к непредвиденным последствиям.
AbortController - это мощный инструмент, который наконец-то решает одну из самых давних проблем асинхронного JavaScript. Правильное использование этого API делает ваши приложения более отзывчивыми, менее подверженными гонкам условий и более эффективными в использовании сетевых ресурсов.
Прототипы и наследование - классика жанра
Если вы хотите увидеть как люди съеживаются на собеседовании, просто спросите их о прототипах в JavaScript. Я не преувеличиваю - эта тема вызывает настоящий ужас даже у разработчиков с пятилетним стажем. Вспоминаю случай, когда я собеседовал парня, который писал на JavaScript почти 7 лет, но не смог объяснить разницу между __proto__ и prototype. И знаете что? Это нормально! Система прототипов в JavaScript настолько своеобразна, что многие разработчики могут годами писать код, не понимая её глубинных механизмов.
Но тут-то и кроется ловушка. На собеседовании от вас ожидают понимания фундаментальных принципов языка, а не только API-интерфейсов популярных фреймворков. Давайте разберемся с этой темой раз и навсегда.
Что такое прототип в JavaScript?
В основе JavaScript лежит прототипное наследование, а не классическое, как в Java или C#. Это значит, что вместо создания "чертежей" в виде классов, мы работаем напрямую с объектами и связями между ними.
Каждый объект в JavaScript имеет скрытое свойство [[Prototype]] (в браузерах доступное как __proto__), которое указывает на другой объект - его прототип. Когда вы пытаетесь получить свойство объекта, которого нет у самого объекта, JavaScript автоматически ищет его в прототипе, потом в прототипе прототипа, и так далее, формируя "цепочку прототипов".
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| const animal = {
eats: true,
walk() {
console.log('Животное идет');
}
};
const rabbit = {
jumps: true,
__proto__: animal // устанавливаем animal как прототип для rabbit
};
rabbit.walk(); // "Животное идет" - метод взят из прототипа
console.log(rabbit.eats); // true - свойство взято из прототипа |
|
В этом примере объект rabbit наследует свойства и методы от объекта animal. Когда мы вызываем rabbit.walk(), JavaScript не находит метод walk в самом объекте rabbit, поэтому идет по цепочке прототипов и находит его в animal.
Функции-конструкторы и свойство prototype
Теперь перейдем к более сложной теме, которая часто становится камнем преткновения на собеседованиях. В JavaScript функции имеют специальное свойство prototype. Оно используется только когда функция вызывается с оператором new, то есть как конструктор.
| JavaScript | 1
2
3
4
5
6
7
8
9
10
| function User(name) {
this.name = name;
}
User.prototype.sayHi = function() {
console.log(`Привет, ${this.name}!`);
};
const john = new User('John');
john.sayHi(); // "Привет, John!" |
|
Вот что происходит при вызове new User('John'):
1. Создается новый пустой объект.
2. Этот объект становится значением this внутри функции.
3. Выполняется код функции.
4. Свойство [[Prototype]] нового объекта устанавливается равным User.prototype.
5. Функция возвращает этот объект (если нет явного return).
Важно понимать: свойство prototype существует только у функций, и используется только при создании новых объектов через new. А вот свойство __proto__ (или внутреннее [[Prototype]]) есть у любого объекта и указывает на его прототип.
Наследование через прототипы
Одна из самых частых задач на собеседованиях - реализовать наследование между двумя конструкторами:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function() {
console.log(`${this.name} ест.`);
};
function Rabbit(name) {
Animal.call(this, name); // вызываем конструктор родителя
this.jumps = true;
}
// Создаем новый объект с прототипом Animal.prototype
Rabbit.prototype = Object.create(Animal.prototype);
Rabbit.prototype.constructor = Rabbit; // восстанавливаем свойство constructor
Rabbit.prototype.jump = function() {
console.log(`${this.name} прыгает!`);
};
const peter = new Rabbit('Питер');
peter.eat(); // "Питер ест."
peter.jump(); // "Питер прыгает!" |
|
Здесь мы создали два конструктора и настроили прототипное наследование между ними. Строчка Rabbit.prototype = Object.create(Animal.prototype) - ключевая! Она создает новый объект с прототипом Animal.prototype и присваивает его свойству Rabbit.prototype. Многие разработчики делают ошибку, пытаясь напрямую присвоить:
| JavaScript | 1
| Rabbit.prototype = Animal.prototype; // НЕПРАВИЛЬНО! |
|
Это создаст проблемы, потому что изменения в Rabbit.prototype будут также влиять на Animal.prototype.
Класс в ES6 - синтаксический сахар над прототипами
С ES6 в JavaScript появились классы, но это всего лишь "синтаксический сахар" над уже существующей системой прототипов:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| class Animal {
constructor(name) {
this.name = name;
}
eat() {
console.log(`${this.name} ест.`);
}
}
class Rabbit extends Animal {
constructor(name) {
super(name);
this.jumps = true;
}
jump() {
console.log(`${this.name} прыгает!`);
}
}
const peter = new Rabbit('Питер');
peter.eat(); // "Питер ест."
peter.jump(); // "Питер прыгает!" |
|
Под капотом это работает точно так же, как и предыдущий пример с функциями-конструкторами. Для меня, это прекрасный пример того, как JavaScript эволюционирует, делая сложные концепции более доступными для понимания.
Проверка прототипных связей
Еще один популярный вопрос на собеседованиях - как проверить, является ли один объект прототипом другого:
| JavaScript | 1
2
3
4
5
6
| console.log(peter instanceof Rabbit); // true
console.log(peter instanceof Animal); // true
console.log(peter instanceof Object); // true
console.log(Object.getPrototypeOf(peter) === Rabbit.prototype); // true
console.log(Rabbit.prototype.isPrototypeOf(peter)); // true |
|
Есть несколько способов проверки, и важно понимать разницу между ними:
instanceof проверяет всю цепочку прототипов
Object.getPrototypeOf() возвращает непосредственный прототип объекта
isPrototypeOf() проверяет, входит ли объект в цепочку прототипов
Изменение прототипов "на лету"
Один из самых мощных (и опасных!) аспектов прототипного наследования в JavaScript - возможность изменять прототипы существующих объектов:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| function User(name) {
this.name = name;
}
const john = new User('John');
// Позже в коде...
User.prototype.sayHi = function() {
console.log(`Привет, ${this.name}!`);
};
john.sayHi(); // "Привет, John!" - работает, хотя метод был добавлен после создания объекта! |
|
Это создает огромные возможности для расширения функциональности, но также может привести к непредсказуемому поведению, особенно если вы изменяете прототипы встроенных объектов:
| JavaScript | 1
2
3
4
5
6
| // Никогда не делайте так в продакшн-коде!
Array.prototype.shuffle = function() {
return this.sort(() => Math.random() - 0.5);
};
[1, 2, 3].shuffle(); // [3, 1, 2] или другой случайный порядок |
|
Такое расширение встроенных прототипов называется "monkey patching" и обычно считается плохой практикой, потому что может конфликтовать с другими библиотеками или будущими версиями языка.
Множественное наследование и миксины
В JavaScript нет прямой поддержки множественного наследования, но мы можем имитировать его с помощью миксинов:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| const swimMixin = {
swim() {
console.log(`${this.name} плавает.`);
}
};
const flyMixin = {
fly() {
console.log(`${this.name} летает.`);
}
};
class Duck {
constructor(name) {
this.name = name;
}
}
// Копируем методы из миксинов в прототип Duck
Object.assign(Duck.prototype, swimMixin, flyMixin);
const donald = new Duck('Дональд');
donald.swim(); // "Дональд плавает."
donald.fly(); // "Дональд летает." |
|
Это не настоящее множественное наследование, так как мы просто копируем методы, но для многих задач такого подхода достаточно.
Наследование и this в прототипных цепочках
Один из самых каверзных вопросов, который я задаю на собеседованиях: "А как ведет себя this в методах, унаследованных от прототипа?" Многие застывают в ступоре. Давайте разберемся:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| const animal = {
name: "Животное",
eat() {
console.log(`${this.name} ест.`);
}
};
const rabbit = {
name: "Кролик",
__proto__: animal
};
rabbit.eat(); // "Кролик ест." - а не "Животное ест."! |
|
Ключевой момент: this всегда указывает на объект перед точкой, независимо от того, где находится метод в цепочке прототипов. Это очень мощная концепция, которая позволяет методам работать с данными конкретных объектов, а не их прототипов.
Циклический просмотр свойств объекта
Еще одна тонкость, которая часто всплывает на собеседованиях - как правильно перебирать свойства объекта с учетом прототипного наследования:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| const animal = {
eats: true
};
const rabbit = {
jumps: true,
__proto__: animal
};
// Перебирает только собственные свойства
for (let prop in rabbit) {
if (rabbit.hasOwnProperty(prop)) {
console.log(prop); // только "jumps"
}
}
// Перебирает ВСЕ свойства, включая унаследованные
for (let prop in rabbit) {
console.log(prop); // "jumps", затем "eats"
} |
|
Метод hasOwnProperty() - ваш надежный друг, когда нужно отфильтровать свойства, унаследованные от прототипа. И кстати, сам этот метод тоже наследуется от Object.prototype!
Свойство F.prototype и Object.create()
Различия между разными способами создания объектов с заданным прототипом часто вызывают путаницу:
| JavaScript | 1
2
3
4
5
6
7
8
| // Способ 1: через F.prototype и new
function Animal() {}
Animal.prototype.eats = true;
const rabbit1 = new Animal();
// Способ 2: через Object.create()
const animalProto = { eats: true };
const rabbit2 = Object.create(animalProto); |
|
Результат в обоих случаях похожий, но есть тонкие различия. При использовании конструктора вы можете инициализировать объект с параметрами. Object.create() же позволяет напрямую указать прототип без вызова какой-либо функции.
| JavaScript | 1
2
3
4
5
6
7
8
9
| // Еще одно преимущество Object.create() - второй параметр
const rabbit3 = Object.create(animalProto, {
jumps: {
value: true,
enumerable: true,
writable: true,
configurable: true
}
}); |
|
Второй параметр Object.create() позволяет задать свойства с детальными дескрипторами, что дает намного больше контроля.
"Чистые" объекты без прототипа
Иногда на собеседованиях спрашивают, как создать объект без прототипа вообще. Это может быть полезно для создания чистых хеш-таблиц без унаследованных методов:
| JavaScript | 1
2
3
4
5
6
| const cleanObject = Object.create(null);
console.log(cleanObject.__proto__); // undefined
console.log(Object.getPrototypeOf(cleanObject)); // null
// Такой объект не имеет вообще никаких методов!
// cleanObject.toString(); // TypeError: cleanObject.toString is not a function |
|
Я часто использую этот прием в реальных проектах, когда нужен чистый объект для хранения данных, без риска коллизий с методами из Object.prototype.
Наследование встроенных объектов
Особая часть собеседований - наследование от встроенных объектов JavaScript. Это мощная техника, но с множеством подводных камней:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| // Наследуемся от Array
function MyArray(...args) {
// Вызываем родительский конструктор с текущим this
Array.apply(this, args);
// Но это НЕ работает как ожидается!
}
MyArray.prototype = Object.create(Array.prototype);
MyArray.prototype.constructor = MyArray;
const arr = new MyArray(1, 2, 3);
console.log(arr.length); // 0 - что-то пошло не так! |
|
Проблема в том, что встроенные объекты, такие как Array, имеют внутреннюю реализацию, которая не полностью доступна через прототипы. В современном JavaScript лучше использовать класс-обертку:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| class MyBetterArray extends Array {
first() {
return this[0];
}
last() {
return this[this.length - 1];
}
}
const arr2 = new MyBetterArray(1, 2, 3);
console.log(arr2.length); // 3 - работает!
console.log(arr2.first()); // 1
console.log(arr2.last()); // 3 |
|
Сравнение прототипного и классического наследования
Я часто задаю кандидатам вопрос: "В чем основные отличия прототипного наследования от классического?" Вот ключевые моменты:
1. Прототипное наследование работает с экземплярами напрямую. Новые объекты могут наследовать от любых существущих объектов.
2. Классическое наследование требует предварительного определения классов (чертежей), от которых затем создаются экземпляры.
3. В прототипном наследовании можно менять прототипы "на лету", что влияет на все объекты в цепочке наследования.
4. Прототипное наследование легче расширять динамически, а классическое обычно более структурированное и статичное.
Несмотря на появление классов в ES6, JavaScript все равно использует прототипы под капотом, просто в более удобной упаковке.
Производительность при работе с прототипами
Наследование через прототипы дает значительные преимущества по производительности и использованию памяти:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // Метод в прототипе - создается один раз
function User(name) {
this.name = name;
}
User.prototype.sayHi = function() {
console.log(`Привет, ${this.name}!`);
};
const user1 = new User("Алиса");
const user2 = new User("Боб");
// user1.sayHi и user2.sayHi указывают на один и тот же метод в памяти!
// Метод в конструкторе - создается для каждого экземпляра
function BadUser(name) {
this.name = name;
this.sayHi = function() {
console.log(`Привет, ${this.name}!`);
};
}
// Здесь каждый экземпляр получает свою копию метода - неэффективно! |
|
Размещение методов в прототипе, а не в самом объекте, может значительно снизить потребление памяти при создании множества экземпляров.
Заключительные мысли о прототипах
Прототипное наследование - это не просто техническая особенность JavaScript, а мощная парадигма программирования. Оно позволяет создавать гибкие структуры объектов с разделяемым поведением, экономя при этом память.
Понимание прототипов - то, что отличает новичка от опытного JavaScript-разработчика. На собеседованиях эта тема продолжает оставаться одним из главных критериев оценки глубины знаний кандидата. Классы в ES6 сделали синтаксис более привычным для разработчиков, пришедших из других языков, но под капотом все тот же механизм прототипов. Настоящее мастерство приходит тогда, когда вы понимаете, что происходит на самом деле, а не только умеете использовать удобный синтаксический сахар.
Создание кастомных конструкторов и их оптимизация через Object.create
Конструкторы в JavaScript - это та область, где многие разработчики демонстрируют поверхностные знания на собеседованиях. И я их понимаю! Когда-то я сам представлял конструкторы просто как "функции с большой буквы, которые вызываются с new". Но копнув глубже, я обнаружил целый мир тонкостей и оптимизаций.
Вспоминаю один случай, когда на собеседовании в крупную финтех-компанию мне предложили оптимизировать код, создающий тысячи однотипных объектов. Кандидат до меня предложил использовать классы ES6. Я же пошел другим путем - через низкоуровневую работу с прототипами и Object.create(). Мое решение оказалось в 3 раза быстрее. Давайте разберемся, почему.
Традиционный подход к конструкторам
Стандартный способ создания конструкторов выглядит так:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| function User(name, age) {
this.name = name;
this.age = age;
this.isAdult = age >= 18;
this.sayHello = function() {
console.log(`Привет! Меня зовут ${this.name}`);
};
}
const user1 = new User('Алексей', 30);
const user2 = new User('Мария', 25);
user1.sayHello(); // "Привет! Меня зовут Алексей" |
|
Проблема этого подхода в том, что метод sayHello создается заново для каждого экземпляра. Если у вас тысячи пользователей - это тысячи идентичных функций в памяти. Неэффективно, правда?
Оптимизация через прототип
Более эффективный подход - вынести методы в прототип:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| function User(name, age) {
this.name = name;
this.age = age;
this.isAdult = age >= 18;
}
User.prototype.sayHello = function() {
console.log(`Привет! Меня зовут ${this.name}`);
};
const user1 = new User('Алексей', 30);
const user2 = new User('Мария', 25); |
|
Теперь все экземпляры User используют один и тот же метод sayHello из прототипа, что значительно экономит память.
Продвинутая оптимизация через Object.create()
Но можно пойти еще дальше! Object.create() позволяет создавать объекты с заданным прототипом без вызова конструктора:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // Создаем объект-прототип с методами
const userMethods = {
sayHello() {
console.log(`Привет! Меня зовут ${this.name}`);
},
isAdult() {
return this.age >= 18;
}
};
// Фабричная функция вместо конструктора
function createUser(name, age) {
// Создаем объект с userMethods в качестве прототипа
const user = Object.create(userMethods);
// Инициализируем собственные свойства
user.name = name;
user.age = age;
return user;
}
const user1 = createUser('Алексей', 30);
user1.sayHello(); // "Привет! Меня зовут Алексей" |
|
Этот подход имеет несколько преимуществ:
1. Не нужно беспокоиться о вызове с new - функция createUser работает как обычная функция.
2. Можно легко реализовать множественное наследование, используя несколько прототипов.
3. Код становится более явным - видно, что именно наследуется.
Дескрипторы свойств и Object.create()
Одна из самых мощных (и редко используемых) возможностей Object.create() - второй параметр, позволяющий настраивать дескрипторы свойств:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| function createUserWithConfig(name, age) {
return Object.create(userMethods, {
name: {
value: name,
writable: true,
enumerable: true,
configurable: true
},
age: {
value: age,
writable: true,
enumerable: true,
configurable: true
},
createdAt: {
value: new Date(),
writable: false, // Нельзя изменить
enumerable: false, // Не перечисляется в циклах
configurable: false // Нельзя удалить
}
});
}
const user = createUserWithConfig('Иван', 35);
console.log(user.name); // "Иван"
console.log(user.createdAt); // [текущая дата]
// Попытка изменить неизменяемое свойство
user.createdAt = new Date(2020, 0, 1);
console.log(user.createdAt); // все равно [исходная дата]
// createdAt не появится при перечислении
for (let key in user) {
console.log(key); // только "name" и "age"
} |
|
Такой уровень контроля над свойствами объектов дает огромные возможности для создания надежных и безопасных конструкций.
Хак для оптимизации производительности
В проектах, требующих максимальной производительности, я иногда использую следующий хак:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // Создаем "шаблонный" объект со всеми необходимыми свойствами
const userTemplate = {
name: null,
age: null,
posts: [],
comments: [],
// ... другие общие свойства
};
// Фабричная функция
function createUserFast(name, age) {
// Клонируем шаблон через Object.create
const user = Object.create(
Object.getPrototypeOf(userTemplate),
Object.getOwnPropertyDescriptors(userTemplate)
);
// Устанавливаем только те свойства, которые отличаются от шаблона
user.name = name;
user.age = age;
return user;
} |
|
Это может дать существенный прирост производительности при создании тысяч объектов со сложной структурой, так как не требует многократного определения дескрипторов свойств.
Композиция вместо наследования
Когда меня спрашивают на собеседованиях о лучших практиках работы с конструкторами, я всегда упоминаю принцип "композиция вместо наследования". Вместо построения глубоких цепочек наследования, часто эффективнее собирать объекты из мелких функциональных частей:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| // Набор поведений как отдельные объекты
const hasName = (name) => ({
getName: () => name,
setName: (newName) => name = newName
});
const hasAge = (age) => ({
getAge: () => age,
setAge: (newAge) => age = newAge,
isAdult: () => age >= 18
});
const canSpeak = () => ({
sayHello: function() {
console.log(`Привет! Меня зовут ${this.getName()}`);
}
});
// Создаем пользователя путем композиции
function createComplexUser(name, age) {
return Object.assign(
{},
hasName(name),
hasAge(age),
canSpeak()
);
}
const user = createComplexUser('Александр', 28);
user.sayHello(); // "Привет! Меня зовут Александр"
console.log(user.isAdult()); // true |
|
Такой подход делает код более модульным и гибким. Вы можете легко комбинировать поведения в любых сочетаниях без сложных иерархий наследования.
Приватные данные в конструкторах
До появления приватных полей в классах ES2022, я использовал замыкания для создания приватных переменных в конструкторах:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| function SecureUser(name, password) {
// Приватные данные, недоступные извне
const _password = password;
const _token = generateToken();
// Публичный интерфейс
this.name = name;
this.authenticate = function(inputPassword) {
return inputPassword === _password;
};
this.getToken = function() {
return _token;
};
// Приватная функция
function generateToken() {
return Math.random().toString(36).substr(2);
}
}
const user = new SecureUser('Админ', 'супер-секрет');
console.log(user.name); // "Админ"
console.log(user.authenticate('супер-секрет')); // true
console.log(user.authenticate('неверный-пароль')); // false
console.log(user._password); // undefined - приватная переменная! |
|
Однако это снова создает проблему с дублированием методов. Гибридное решение:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| // Общие методы в прототипе
SecureUser.prototype.getName = function() {
return this.name;
};
// Фабричная функция для создания экземпляров
function createSecureUser(name, password) {
// Приватные данные через замыкание
const privateData = {
password: password,
token: Math.random().toString(36).substr(2)
};
// Создаем объект с прототипом SecureUser.prototype
const user = Object.create(SecureUser.prototype);
// Публичные свойства
user.name = name;
// Методы с доступом к приватным данным
user.authenticate = function(inputPassword) {
return inputPassword === privateData.password;
};
user.getToken = function() {
return privateData.token;
};
return user;
} |
|
Это дает нам лучшее из обоих миров: приватные данные через замыкания и общие методы через прототип.
Тонкости instanceof с кастомными конструкторами
Одна из распространенных проблем с фабричными функциями - поведение оператора instanceof. Вот пример с подвохом, который я иногда даю на собеседованиях:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| function User(name) {
this.name = name;
}
const user1 = new User('Алиса');
console.log(user1 instanceof User); // true
// А теперь фабричный метод
function createUser(name) {
return { name };
}
const user2 = createUser('Боб');
console.log(user2 instanceof User); // false - неожиданно? |
|
Но можно это исправить, если это действительно необходимо:
| JavaScript | 1
2
3
4
5
6
7
8
9
| function createUserWithCorrectPrototype(name) {
// Создаем объект с правильным прототипом
const user = Object.create(User.prototype);
user.name = name;
return user;
}
const user3 = createUserWithCorrectPrototype('Чарли');
console.log(user3 instanceof User); // true |
|
Конечно, в реальном коде стоит задуматься, действительно ли вам нужна проверка через instanceof, или лучше использовать duck typing (утиная типизация). Создание и оптимизация конструкторов - это не просто технический навык, это демонстрация вашего глубокого понимания JavaScript. На собеседованиях я всегда ценю кандидатов, которые могут не только писать код, но и объяснить, почему они выбрали тот или иной подход, учитывая производительность, удобство использования и сопровождаемость.
Object.create() - это мощный инструмент, который открывает доступ к низкоуровневым механизмам создания объектов в JavaScript. Понимание этого API и умение его применять может значительно расширить ваш арсенал решений и впечатлить интервьюеров своей глубиной знаний.
Разница между __proto__ и prototype - те нюансы, которые нужно знать назубок
__proto__ и prototype - это два совершенно разных свойства, которые живут своей жизнью и играют разные роли в системе прототипного наследования JavaScript. Давайте разберем это на конкретном примере:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| function Animal(name) {
this.name = name;
}
Animal.prototype.makeSound = function() {
console.log(`${this.name} издает звук`);
};
const cat = new Animal('Мурзик');
console.log(cat.__proto__ === Animal.prototype); // true
console.log(cat.prototype); // undefined
console.log(Animal.__proto__ === Function.prototype); // true |
|
Что здесь происходит? prototype - это свойство функции-конструктора, которое определяет, какой объект будет использован как прототип для создаваемых экземпляров. А __proto__ - это ссылка на прототип конкретного объекта.
Еще один показательный пример:
| JavaScript | 1
2
3
4
5
6
7
8
| const parent = {
value: 42
};
const child = Object.create(parent);
console.log(child.__proto__ === parent); // true
console.log(child.prototype); // undefined - у обычных объектов нет prototype! |
|
Ключевой момент: свойство prototype есть только у функций, а __proto__ есть у всех объектов (хотя использовать его напрямую не рекомендуется - лучше применять Object.getPrototypeOf()).
Цепочки прототипов в действии
Вот более сложный пример, который я люблю давать на собеседованиях:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| function Grandparent() {}
function Parent() {}
function Child() {}
Parent.prototype = Object.create(Grandparent.prototype);
Child.prototype = Object.create(Parent.prototype);
const child = new Child();
console.log(
child.__proto__ === Child.prototype, // true
Child.prototype.__proto__ === Parent.prototype, // true
Parent.prototype.__proto__ === Grandparent.prototype // true
); |
|
Обратите внимание на важный нюанс: когда мы устанавливаем Parent.prototype = Object.create(Grandparent.prototype), мы теряем исходное свойство constructor. Его нужно восстанавливать вручную:
| JavaScript | 1
2
| Parent.prototype.constructor = Parent;
Child.prototype.constructor = Child; |
|
И вот еще один коварный момент - изменение prototype после создания объектов:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| function Dog(name) {
this.name = name;
}
const dog1 = new Dog('Бобик');
// Меняем prototype ПОСЛЕ создания объекта
Dog.prototype = {
bark() {
console.log('Гав!');
}
};
const dog2 = new Dog('Шарик');
dog2.bark(); // "Гав!"
dog1.bark(); // TypeError - метод не найден! |
|
Почему так происходит? Потому что __proto__ объекта dog1 все еще указывает на старый прототип, существовавший на момент создания объекта. Изменение prototype конструктора влияет только на объекты, создаваемые после этого изменения.
Практические следствия
Понимание разницы между __proto__ и prototype критически важно для эффективной работы с прототипами. Вот несколько практических моментов:
1. Добавление методов в прототип после создания объектов:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| function User(name) {
this.name = name;
}
const user = new User('Алекс');
// Это работает - добавляем в существующий прототип
User.prototype.sayHi = function() {
console.log(`Привет, я ${this.name}`);
};
user.sayHi(); // "Привет, я Алекс"
// А это не сработает - заменяем весь прототип
User.prototype = {
sayBye() {
console.log(`Пока, ${this.name}`);
}
};
user.sayBye(); // TypeError! |
|
2. Проверка принадлежности к цепочке прототипов:
| JavaScript | 1
2
3
4
| function isInPrototypeChain(obj, constructor) {
return obj instanceof constructor ||
Object.getPrototypeOf(obj) === constructor.prototype;
} |
|
3. Создание "чистых" объектов без прототипа:
| JavaScript | 1
2
3
| const pureObject = Object.create(null);
console.log(pureObject.__proto__); // undefined
console.log(Object.getPrototypeOf(pureObject)); // null |
|
Главное что нужно запомнить:- prototype - это свойство функций, определяющее прототип для создаваемых объектов,
- __proto__ - это ссылка на прототип конкретного объекта,
- Object.getPrototypeOf() - современный способ получить прототип объекта,
- Object.setPrototypeOf() - современный способ установить прототип (но используйте его с осторожностью из-за проблем с производительностью).
Как определить свой объективный опыт, если ты врач? Здравствуйте!
Возможно задам немного странный вопрос: как рационально определить(объективно, не с... Первый опыт в кодировании , но почему-то результат не выводит. Что не так? function converterDollar(){
const euro = document.getElementById("funcEuro").value;
... javascript внутри javascript Здравствуйте.
Помогите решить задачу. Нужно на html странице под спойлером в textarea поставить... Вставка элементов меню (содержащих javascript) через javascript Пишу курсовой проект по JavaScript в ходе которого потребовалось создать небольшой локальный сайт,... Javascript - классы, они есть или их нету в Javascript? Скажите, в Джаваскрипт есть классы как в пхп например?
Я так толкового ответа порывшись по... javascript в javascript имеется скрипт:
<style type="text/css">
#informationbar{
position:fixed;
left:0; ... Java и javascript. Передать переменную из Java в Javascript Здравствуйте,уважаемые форумчане!
Я начинающий программист. Разбираюсь в создании JSP страниц.... Как перезагрузить javascript, javascript-ом? как с помощью javascript перезагрузить javascript ? Смысл в том что один из моих скриптов выполняет... Запуск JavaScript из под другого скрипта JavaScript или PHP Здравствуйте! Я новичок, помогите мне, пожалуйста.
У меня имеется 1.php:
<?php
include... Выполнение Javascript файла в котором присутствуют javascript теги text1.js
<link href='http://alexgorbatchev.com/pub/sh/2.1.364/styles/shCore.css'... Javascript (codeacademy - "'WHILE' LOOPS IN JAVASCRIPT(Dragon Slayer!)") Пожалуйста, помогите прочитать этот код!!!
var slaying = true;
// A bit of new math magic to... Выбор n-го тега <tr> через JavaScript; Разрешение и запрет редактирования таблицы через JavaScript Уважаемые форумчане, я пока еще новичок в JavaScript и хотел попросить у вас помощи.
Никак не...
|