РЕАЛЬНОЕ FRONTEND СОБЕСЕДОВАНИЕ С ДЕСЯТКАМИ ЛАЙВКОДИНГ-ЗАДАЧ НА ЗП 300К! ФРОНТЕНД СОБЕС.
Вопрос 1. Значения CSS свойства position и их характеристика.
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:00:58"/>
Ответ собеседника: Правильный. Знает значения static, relative, absolute, fixed, sticky. Даёт краткую характеристику каждого: static - по умолчанию; relative - элемент остаётся в потоке, можно сдвигать; absolute - выбивает из потока, позиционируется относительно ближайшего элемента с position не static; fixed - позиционируется относительно окна браузера и остаётся при прокрутке; sticky - смесь relative и fixed, прилипает при прокрутке.
Правильный ответ:
Свойство position в CSS определяет метод позиционирования элемента на странице и является фундаментальным для создания сложных макетов. Его значение влияет на поведение элемента в потоке документа и на то, как работают его смещающие свойства (top, right, bottom, left).
1. Static
Это значение по умолчанию. Элемент позиционируется в соответствии с обычным потоком документа. Свойства top, right, bottom, left и z-index не применяются к статически позиционированным элементам. По сути, это "непозиционированный" элемент.
2. Relative
Элемент позиционируется в соответствии с нормальным потоком, а затем может быть смещён относительно своего исходного положения с помощью свойств top, right, bottom, left. Ключевые моменты:
- Исходное пространство, которое элемент занимал в потоке, сохраняется (остаётся "дырка").
- Смещение не влияет на положение других элементов в потоке, они ведут себя так, как если бы элемент остался на своём месте.
- Часто используется для создания контекста позиционирования для дочерних
absolute-элементов или для незначительных декоративных сдвигов.
3. Absolute
Элемент полностью удаляется из нормального потока документа. Его исходное место не сохраняется, и другие элементы занимают освободившееся пространство. Элемент позиционируется относительно своего ближайшего позиционированного предка (предка, у которого значение position отлично от static). Если такого предка нет, он позиционируется относительно начального контейнера (обычно <html> или <body>). Положение задаётся свойствами top, right, bottom, left. Абсолютное позиционирование часто используется для всплывающих окон, тултипов, сложных наложений и элементов управления.
4. Fixed
Элемент также удаляется из потока, как и absolute. Ключевое отличие: он позиционируется относительно области просмотра (viewport) браузера. Его положение не меняется при прокрутке страницы. Как и для absolute, если у элемента есть предок с transform, perspective или filter, отличным от none, этот предок может стать контейнером для позиционирования. Классическое применение — фиксированные шапки, подвалы или боковые панели.
5. Sticky
Это гибридное значение. Элемент ведёт себя как relative, пока он находится в пределах своего родительского контейнера и области просмотра. Однако, когда пользователь прокручивает страницу до точки, где элемент должен был бы исчезнуть из вида, он "прилипает" к указанной границе (например, к верху окна), ведя себя подобно fixed. Для работы необходимо задать хотя бы одно из смещающих свойств (top: 0; — для прилипания к верху). Исходное пространство элемента в потоке сохраняется. Идеально подходит для прилипающих заголовков таблиц или навигации внутри секции.
Важные концепции для понимания:
Контекст наложения (Stacking Context)
Значения position, отличные от static, вместе со свойством z-index создают новый контекст наложения. Это изолированный "стек" элементов, внутри которого определяется их порядок по оси Z. Элементы из разных контекстов не могут "перемешаться".
Смещающие свойства (Offset Properties)
Свойства top, right, bottom, left определяют окончательное положение позиционированного элемента. Их одновременное использование (например, top и bottom) может привести к неявному изменению размеров элемента, если его ширина или высота не заданы явно.
Пример для иллюстрации разницы:
.parent {
position: relative; /* Создаёт контекст для .child-absolute */
height: 200px;
border: 2px solid blue;
}
.child-relative {
position: relative;
top: 20px;
left: 30px;
/* Оставит пустое место там, где должен был быть, и сдвинется */
}
.child-absolute {
position: absolute;
bottom: 10px;
right: 10px;
/* Будет прижат к правому нижнему углу .parent */
}
.child-fixed {
position: fixed;
top: 0;
right: 0;
/* Всегда будет в правом верхнем углу окна браузера */
}
Вопрос 2. Разница между CSS единицами измерения em и rem.
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:03:08"/>
Ответ собеседника: Правильный. em берёт значение шрифта из родительского элемента. rem берёт значение шрифта из корневого HTML-элемента.
Правильный ответ:
Хотя оба ответа верны по своей сути, разница между em и rem гораздо глубже и имеет критически важные последствия для масштабируемости, предсказуемости и поддержки кода.
Единица em
Эта единица измерения является относительной к вычисленному размеру шрифта (font-size) текущего элемента. Ключевое слово здесь — "текущего". Если font-size самого элемента не задан явно, он наследует его от родителя.
- Для свойств, связанных с шрифтом (например,
font-size):1emравен текущему размеру шрифта элемента. Если задатьfont-size: 1.5em;, новый размер шрифта будет в 1.5 раза больше текущего. - Для всех остальных свойств (например,
margin,padding,width):1emравен текущему вычисленному размеру шрифта элемента.
Главная особенность (и проблема) em — композиция. Поскольку каждое новое значение font-size, заданное в em, меняет базовый размер для всех последующих em внутри этого элемента, это может привести к эффекту "снежного кома" и непредсказуемым результатам.
Пример проблемы с em:
<div class="parent">
Размер шрифта родителя: 16px
<div class="child">
Размер шрифта: 1.2em (16px * 1.2 = 19.2px)
<div class="grandchild">
Размер шрифта: 1.2em (19.2px * 1.2 = 23.04px)
</div>
</div>
</div>
.parent { font-size: 16px; }
.child { font-size: 1.2em; } /* = 19.2px */
.grandchild { font-size: 1.2em; } /* = 23.04px, а не ожидаемые 19.2px */
Единица rem (Root Em)
Эта единица была введена для решения проблемы композиции, присущей em. rem всегда является относительной к вычисленному размеру шрифта корневого элемента (<html>). Это значение является константой для всей страницы.
- Для всех свойств:
1remвсегда равен размеру шрифта, заданному для элемента<html>. По умолчанию в браузерах это значение равно16px.
Пример предсказуемости с rem:
<html style="font-size: 16px;">
<body>
<div class="parent">
<div class="child">
Размер шрифта: 1.2rem (16px * 1.2 = 19.2px)
<div class="grandchild">
Размер шрифта: 1.2rem (16px * 1.2 = 19.2px)
</div>
</div>
</div>
</body>
</html>
Как видно, значение 1.2rem всегда даёт один и тот же результат (19.2px), независимо от вложенности. Это делает вёрстку гораздо более предсказуемой и управляемой.
Когда что использовать?
Используйте rem:
- Для глобального масштабирования всей страницы (изменение
font-sizeу<html>масштабирует всё). - Для большинства размеров: шрифтов, отступов (
padding,margin), размеров блоков (width,height). Это обеспечивает согласованность. - Для создания доступных интерфейсов, так как
remуважает пользовательские настройки браузера по умолчанию.
Используйте em:
- Для свойств, которые должны масштабироваться пропорционально размеру шрифта конкретного элемента. Классический пример — внутренние отступы кнопки. Вы хотите, чтобы
paddingувеличивался, если вы увеличитеfont-sizeсамой кнопки, независимо от глобальных настроек..btn {font-size: 1rem; /* 16px от корня */padding: 0.75em 1.5em; /* 12px и 24px, относительно 16px */}.btn--large {font-size: 1.25rem; /* 20px *//* padding автоматически станет 15px и 30px, относительно нового font-size (20px) */} - Для сложных компонентов, которые должны быть изолированы от глобального контекста и иметь собственный ритм.
Современный подход (CSS Custom Properties):
Сочетание rem и CSS-переменных (Custom Properties) считается золотым стандартом для создания гибких и поддерживаемых дизайн-систем.
:root {
--font-size-base: 16px;
--spacing-unit: 0.5rem; /* 8px */
}
html { font-size: var(--font-size-base); }
.component {
font-size: calc(1.25 * var(--spacing-unit)); /* 10px */
margin-bottom: calc(3 * var(--spacing-unit)); /* 24px */
}
Вопрос 3. Способы центрирования элемента по вертикали и горизонтали.
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:03:55"/>
Ответ собеседника: Правильный. Перечисляет основные способы: 1) position: absolute с top/left 50% и transform: translate(-50%, -50%); 2) display: flex с justify-content и align-items; 3) display: grid с justify-content, align-items или place-items.
Правильный ответ:
Центрирование — одна из самых распространённых задач в веб-разработке. Выбор метода зависит от контекста: поддерживает ли элемент поток документа, известны ли его размеры, нужно ли центрировать текст или блочный элемент. Существует более десятка методов, каждый со своей спецификой.
Современные методы (рекомендуются к использованию)
1. Flexbox (display: flex)
Наиболее универсальный и часто используемый современный метод. Он центрирует дочерние элементы как по горизонтали, так и по вертикали, не влияя на их собственные свойства display.
.container {
display: flex;
justify-content: center; /* Центрирование по горизонтали */
align-items: center; /* Центрирование по вертикали */
height: 100vh; /* Важно задать высоту контейнера */
}
- Преимущества: Простота, контроль над всем контейнером, не требует знания размеров дочернего элемента.
- Особенности:
align-itemsцентрирует элементы вдоль поперечной оси (по умолчанию — вертикаль). Можно использоватьflex-direction: column;для смены осей.
2. CSS Grid (display: grid)
Мощный инструмент для двумерного выравнивания. Для центрирования одного элемента можно использовать свойства для контейнера или свойство-шорткат place-items.
.container {
display: grid;
place-items: center; /* Шорткод для justify-items и align-items */
height: 100vh;
}
/* Или более явный, но эквивалентный способ: */
.container {
display: grid;
justify-content: center; /* Центрирование содержимого по горизонтали */
align-content: center; /* Центрирование содержимого по вертикали */
}
- Преимущества: Краткость (с
place-items), полный контроль над сеткой. - Нюанс:
place-itemsприменяется ко всем grid-элементам внутри контейнера. Для центрирования одного элемента в grid-контейнере это идеально.
Классические и ситуативные методы
3. Абсолютное позиционирование и трансформация Метод, который долгое время был основным до появления Flexbox/Grid. Элемент вырывается из потока.
.container {
position: relative; /* Создаёт контекст для позиционирования */
height: 100vh;
}
.centered-element {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* Сдвигает элемент на половину его собственной ширины и высоты */
}
- Преимущества: Работает с элементами неизвестного размера.
- Недостатки: Элемент удаляется из потока, что может нарушить компоновку других элементов. Может вызывать размытие из-за использования
transformна некоторых устройствах.
4. Абсолютное позиционирование с автоматическими отступами (для элементов с известным размером)
Если размеры элемента известны, можно обойтись без transform.
.centered-element {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto; /* Браузер автоматически вычисляет равные отступы */
width: 300px;
height: 200px;
}
- Применение: Устаревший метод, полезен только в очень специфических случаях, когда
transformнельзя использовать.
5. Выравнивание строчно-блочных элементов (для горизонтального центрирования) Классический метод центрирования блочного элемента по горизонтали внутри другого блока.
.container {
text-align: center; /* Центрирует inline и inline-block элементы */
}
.centered-element {
display: inline-block;
/* Элемент будет центрирован по горизонтали */
/* Для вертикального центрирования этот метод не подходит */
}
6. Центрирование текста и inline-элементов Для центрирования текста или содержимого внутри строчных элементов используются простые свойства.
.container {
text-align: center; /* Горизонтальное центрирование текста/inline-элементов */
line-height: 400px; /* Вертикальное центрирование ОДНОЙ строки текста (высота равна высоте контейнера) */
}
- Внимание:
line-heightдля вертикального центрирования работает только для однострочного текста. Для многострочного текста используйте Flexbox или Grid, обернув текст в тег (например,<p>) и центрируя его.
Сводная таблица выбора метода
| Ситуация | Рекомендуемый метод | Альтернатива |
|---|---|---|
| Центрирование блока неизвестного размера | Flexbox или Grid | Абсолютное позиционирование + трансформация |
| Центрирование блока известного размера | Любой современный метод | Абсолютное позиционирование + margin: auto |
| Горизонтальное центрирование блока | margin: 0 auto; (для block-элементов) | Flexbox/Grid или text-align: center + inline-block |
| Центрирование текста (inline) | text-align: center | - |
| Вертикальное центрирование одной строки текста | line-height (равный высоте контейнера) | - |
Золотое правило: Для сложной компоновки и центрирования всегда отдавайте предпочтение Flexbox или CSS Grid. Эти технологии были созданы для решения подобных задач и предоставляют самый чистый, предсказуемый и поддерживаемый код.
Вопрос 4. Различия между var, let и const в JavaScript.
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:04:47"/>
Ответ собеседника: Правильный. Объясняет область видимости и хостинг. var имеет функциональную область, всплывает, доступен до объявления со значением undefined. let и const имеют блочную область, всплывают, но находятся во временной мёртвой зоне, доступ к ним до объявления вызывает ReferenceError. let можно переприсвоить. const нельзя переприсвоить, но если это ссылочный тип, то внутренние данные можно изменять.
Правильный ответ:
Ответ кандидата точен и охватывает ключевые аспекты. Давайте углубимся в детали, последствия и лучшие практики, связанные с этими различиями.
1. Область видимости (Scope)
var — функциональная область видимости (Function Scope)
Переменная, объявленная через var, видна в пределах функции, в которой она объявлена. Если она объявлена вне функции — она становится глобальной.
function example() {
if (true) {
var x = 10; // Видна ВСЕЙ функции example
}
console.log(x); // 10 — переменная доступна вне блока if
}
let и const — блочная область видимости (Block Scope)
Переменные, объявленные через let и const, видны только в пределах блока {}, в котором они объявлены (например, внутри if, for, while или просто в фигурных скобках).
function example() {
if (true) {
let y = 20;
const z = 30;
console.log(y, z); // 20, 30 — всё ок
}
console.log(y); // ReferenceError: y is not defined
console.log(z); // ReferenceError: z is not defined
}
Это поведение помогает избегать ошибок, изолируя переменные в том контексте, где они используются (например, счётчик цикла for).
2. Поднятие (Hoisting)
var — полное поднятие с инициализацией undefined
Объявление переменной var "всплывает" в начало своей области видимости (функции или глобального контекста), и ей сразу присваивается значение undefined.
console.log(a); // undefined (нет ошибки)
var a = 5;
// Интерпретатор "видит" это так:
// var a = undefined;
// console.log(a); // undefined
// a = 5;
let и const — поднятие без инициализации (Временная мёртвая зона, TDZ)
Объявления let и const тоже всплывают, но они не инициализируются никаким значением. Период от входа в область видимости до момента объявления называется Временной мёртвой зоной (Temporal Dead Zone, TDZ). Попытка обратиться к переменной в этот момент вызовет ReferenceError.
console.log(b); // ReferenceError: Cannot access 'b' before initialization
console.log(c); // ReferenceError: Cannot access 'c' before initialization
let b = 10;
const c = 15;
Это поведение делает код более предсказуемым и помогает отлавливать ошибки на этапе разработки.
3. Повторное объявление и присваивание
var — позволяет повторное объявление в той же области видимости
Это может случайно перезаписать переменную и привести к трудноуловимым багам.
var value = "first";
var value = "second"; // Всё работает, 'value' перезаписана
console.log(value); // "second"
let — запрещает повторное объявление, но позволяет повторное присваивание
let count = 1;
count = 2; // Присваивание — OK
let count = 3; // SyntaxError: Identifier 'count' has already been declared
const — запрещает и повторное объявление, и повторное присваивание
Ключевое слово const создаёт константную ссылку на значение. Это не значит, что само значение становится неизменяемым (immutable). Это значит, что идентификатор (имя переменной) нельзя использовать для присваивания другого значения.
const pi = 3.14;
pi = 3.15; // TypeError: Assignment to constant variable.
// Для объектов и массивов:
const person = { name: "Alice" };
person.name = "Bob"; // OK! Изменяется свойство объекта, а не ссылка 'person'
person = { name: "Charlie" }; // TypeError: Assignment to constant variable.
const numbers = [1, 2, 3];
numbers.push(4); // OK: [1, 2, 3, 4]
numbers = [5, 6]; // TypeError: Assignment to constant variable.
Чтобы сделать объект по-настоящему неизменяемым, нужно использовать Object.freeze() (поверхностно) или более сложные техники.
4. Глобальные переменные и глобальный объект (window)
При объявлении в глобальной области видимости var-переменная становится свойством глобального объекта (window в браузере). let и const — нет.
var globalVar = "I am var";
let globalLet = "I am let";
console.log(window.globalVar); // "I am var"
console.log(window.globalLet); // undefined
Это помогает избегать случайного засорения глобального пространства имён.
Современные best practices (ES6+)
- Всегда используйте
constпо умолчанию. Если вы объявляете переменную, значение которой не должно переназначаться, выбирайтеconst. Это делает код более понятным и предотвращает случайные переприсваивания. - Используйте
letтолько когда переменная должна быть переприсвоена. Например, счётчики циклов, флаги состояния. - Избегайте использования
varв новом коде. Его особенности (область видимости, hoisting) часто приводят к ошибкам.letиconstпредоставляют более строгий и предсказуемый контроль над переменными. Используйтеvarтолько если вам нужно поддерживать очень старые версии браузеров, не понимающие ES6.
Итоговая таблица
| Характеристика | var | let | const |
|---|---|---|---|
| Область видимости | Functional | Block | Block |
| Поднятие (Hoisting) | Да, с инициализацией undefined | Да, но без инициализации (TDZ) | Да, но без инициализации (TDZ) |
| Повторное объявление | Разрешено | Запрещено | Запрещено |
| Повторное присваивание | Разрешено | Разрешено | Запрещено |
Глобальная переменная становится свойством window | Да | Нет | Нет |
| Рекомендуемое использование | Избегать | Переменные, которые нужно переприсваивать | Все остальные переменные (по умолчанию) |
Вопрос 5. Что будет, если обратиться к переменной, которая не была объявлена?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:06:07"/>
Ответ собеседника: Правильный. Будет ошибка ReferenceError с сообщением, что переменная не определена.
Правильный ответ:
Ответ абсолютно верен, но важно понимать контекст, в котором это происходит, и исключения из этого правила. Поведение зависит от режима выполнения кода и контекста обращения.
Строгий режим (Strict Mode) и современная практика
В современном JavaScript, особенно при использовании модулей (ES6 Modules) или директивы "use strict", попытка обратиться к необъявленной переменной всегда приводит к немедленному выбрасыванию исключения ReferenceError: <variable> is not defined.
"use strict"; // Активный строгий режим
console.log(neverDeclaredVariable); // ReferenceError: neverDeclaredVariable is not defined
Это поведение считается правильным и помогает отлавливать опечатки в именах переменных на раннем этапе, делая код более надёжным.
Нестрогий режим (Sloppy Mode) и устаревшее поведение
В устаревшем, нестрогом режиме (который является режимом по умолчанию для скриптов, не использующих модули и без "use strict") поведение иное. При попытке чтения значения необъявленной переменной не происходит ошибки. Вместо этого JavaScript неявно создаёт глобальную переменную с таким именем в момент присваивания ей значения. Однако, попытка прочитать значение переменной, которой никогда не присваивали значение, всё равно вызовет ReferenceError.
// Нестрогий режим (без "use strict")
// Попытка ЧТЕНИЯ необъявленной переменной -> ОШИБКА
console.log(undeclaredVar); // ReferenceError: undeclaredVar is not defined
// Попытка ЗАПИСИ в необъявленную переменную -> СОЗДАЁТСЯ ГЛОБАЛЬНАЯ переменная
undeclaredVar = 42; // Внимание! Теперь undeclaredVar - глобальная переменная!
console.log(undeclaredVar); // 42 (ошибки нет, переменная создана)
console.log(window.undeclaredVar); // 42 (она стала свойством глобального объекта)
Почему создание глобальных переменных через присваивание — это плохо?
Это поведение в нестрогом режиме крайне опасно и считается антипаттерном по нескольким причинам:
- Загрязнение глобальной области видимости: Создаётся переменная в глобальном объекте (
windowв браузере), что может случайно перезаписать другую глобальную переменную или функцию с тем же именем. - Сложность отладки: Ошибка в имени переменной (опечатка) не вызовет исключения, а создаст новую глобальную переменную, что приведёт к непредсказуемому поведению программы, которое очень сложно отследить.
- Непредсказуемость: Код становится сильно зависим от порядка выполнения.
Разница между «необъявленной» (Undeclared) и «объявленной, но неинициализированной» (Undefined)
Это ключевое различие, которое часто вызывает путаницу.
- Необъявленная (Undeclared) переменная — это переменная, которая никогда не была объявлена с помощью
var,letилиconstв текущей области видимости или любой из родительских. Обращение к ней — ошибка. - Объявленная, но неинициализированная (Undefined) переменная — это переменная, которая была объявлена, но ей ещё не было присвоено значение. Для
varэто значениеundefined(из-за hoisting), дляletиconst— это TDZ до момента объявления. Обращение к объявленной переменной со значениемundefined— это валидная операция.
let declaredButUndefined; // Объявлена, но не инициализирована
console.log(declaredButUndefined); // undefined (но это не ошибка!)
// console.log(neverDeclared); // ReferenceError: neverDeclared is not defined (ошибка!)
Проверка на существование переменной
Иногда возникает необходимость проверить, была ли переменная объявлена (особенно при работе с кодом, который может выполняться в разных окружениях). Оператор typeof — единственный безопасный способ сделать это, так как он не выбрасывает исключение при проверке необъявленной переменной.
if (typeof someMaybeExistingVariable !== 'undefined') {
// Переменная объявлена, можно безопасно с ней работать
console.log(someMaybeExistingVariable);
} else {
// Переменная не объявлена
console.log('Variable does not exist.');
}
Вывод и лучшая практика
- Всегда используйте
"use strict";в верхней части своих скриптов или модулей. Это предотвратит случайное создание глобальных переменных и превратит эту ошибку в явныйReferenceError. - Всегда объявляйте переменные явно с помощью
const,letили (в крайнем случае)varперед их использованием. - Помните, что в строгом режиме любая попытка обращения к необъявленной переменной (как на чтение, так и на запись) вызовет
ReferenceError, что является правильным и желаемым поведением для написания качественного кода.
Вопрос 6. Что выведет консоль при выполнении кода с инкрементами (++number, number++)?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:07:38"/>
Ответ собеседника: Правильный. Кандидат рассуждает о работе постфиксного и префиксного инкремента, правильно объясняя, что постфиксный возвращает старое значение, а префиксный — новое. В итоге приходит к правильной последовательности значений.
Правильный ответ:
Рассмотрим классический пример, который часто встречается на собеседованиях:
let number = 0;
console.log(number++); // ?
console.log(++number); // ?
console.log(number); // ?
Чтобы дать полный ответ, необходимо разобрать не только разницу между операторами, но и их приоритет, порядок вычисления выражений и последовательность выполнения кода.
Принцип работы операторов инкремента
1. Постфиксный инкремент (number++)
- Возвращает исходное значение переменной, которое она имела до операции.
- Затем увеличивает значение переменной на 1.
- Его можно мысленно представить как:
function postfixIncrement(num) {const temp = num; // Сохраняем старое значениеnum = num + 1; // Увеличиваем переменнуюreturn temp; // Возвращаем старое значение}
2. Префиксный инкремент (++number)
- Сначала увеличивает значение переменной на 1.
- Затем возвращает новое, увеличенное значение.
- Его можно мысленно представить как:
function prefixIncrement(num) {num = num + 1; // Увеличиваем переменнуюreturn num; // Возвращаем новое значение}
Пошаговый разбор выполнения кода
Шаг 1: Инициализация
let number = 0; -> Переменная number получает значение 0.
Шаг 2: console.log(number++);
- Вычисляется выражение
number++. - Постфиксный оператор возвращает текущее значение
number(то есть0). - Значение
numberувеличивается на1(теперьnumber = 1). console.logполучает возвращённое значение0и выводит его.
Вывод в консоль: 0
Шаг 3: console.log(++number);
- Вычисляется выражение
++number. - Текущее значение
numberравно1. - Префиксный оператор сначала увеличивает
numberна1(теперьnumber = 2). - Затем возвращает новое значение
number(то есть2). console.logполучает возвращённое значение2и выводит его.
Вывод в консоль: 2
Шаг 4: console.log(number);
- Мы просто запрашиваем текущее значение переменной
number. - После всех предыдущих операций
number = 2.
Вывод в консоль: 2
Итоговый вывод консоли:
0
2
2
Важные нюансы и подводные камни
Приоритет операторов Префиксный инкремент имеет более высокий приоритет, чем постфиксный, но в данном примере это не играет роли, так как операторы используются в разных выражениях. Приоритет становится критически важен в составе сложных выражений:
let a = 5;
let b = a++ + ++a; // Антипаттерн! Нечитаемый код.
// Разбор:
// 1. (a++) возвращает 5, затем a становится 6.
// 2. (++a) увеличивает a с 6 до 7 и возвращает 7.
// 3. b = 5 + 7 -> 12.
// 4. Итоговое a = 7.
console.log(a, b); // 7, 12
Использование нескольких инкрементов в одном выражении — это плохая практика, которая приводит к запутанному и трудночитаемому коду.
Изменение исходного значения Оба оператора изменяют (мутируют) исходную переменную. Это важно помнить при использовании их с примитивами, переданными в функции, или при работе со свойствами объектов.
Отличие от простого сложения Важно понимать разницу между оператором инкремента и оператором присваивания:
let count = 0;
count = count + 1; // Простое присваивание. Не возвращает значение, только изменяет переменную.
let result = count++; // Инкремент и возврат значения.
Лучшие практики
- Избегайте использования инкрементов в составе сложных выражений. Разделяйте операции на отдельные строки для ясности.
Плохо:
let result = array[index++] + ++value;Хорошо:let element = array[index];index++;value++;let result = element + value; - Для простых циклов
forиспользованиеi++является идиоматичным и абсолютно приемлемым:for (let i = 0; i < 10; i++) { ... } // OK - Помните, что аналогичным образом работают и операторы декремента:
--number(префиксный) иnumber--(постфиксный).
Вопрос 7. Что выведет консоль при сравнении двух объектов через оператор равенства (==)?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:10:08"/>
Ответ собеседника: Правильный. Будет false, потому что сравниваются две разные ссылки на объекты. В блок if мы не попадём.
Правильный ответ:
Ответ кандидата абсолютно верен для данного контекста. Давайте углубимся в механику сравнения объектов в JavaScript, чтобы понять, почему это так работает, и рассмотрим все возможные нюансы и исключения.
Основной принцип: Сравнение по ссылке
В JavaScript объекты (включая массивы, функции, даты, регулярные выражения и т.д.) являются ссылочными типами данных. Когда вы создаёте объект с помощью литерала {} или new Object(), в памяти выделяется новый участок, и переменная получает ссылку (адрес в памяти) на этот участок.
Операторы равенства (== и ===) при сравнении двух объектов проверяют не их содержимое, а совпадают ли эти ссылки. То есть, ведут ли обе переменные на один и тот же объект в памяти.
const obj1 = { name: "Alice" };
const obj2 = { name: "Alice" }; // Создаётся совершенно новый объект
const obj3 = obj1; // obj3 получает копию ссылки на тот же объект, что и obj1
console.log(obj1 == obj2); // false: разные объекты, разные ссылки
console.log(obj1 === obj2); // false: разные объекты, разные ссылки
console.log(obj1 == obj3); // true: одна и та же ссылка на один объект
console.log(obj1 === obj3); // true: одна и та же ссылка на один объект
Таким образом, код из вопроса console.log({} == {}); выведет false, потому что это два разных, только что созданных объекта.
Разница между == (Abstract Equality) и === (Strict Equality)
Хотя в случае с объектами оба оператора ведут себя идентично (сравнивают ссылки), важно понимать их фундаментальное различие для других типов данных.
Строгое равенство (===)
- Проверяет, что типы операндов совпадают.
- Если типы разные, сразу возвращает
false. - Если типы одинаковые, сравнивает значения.
Нестрогое равенство (==)
Выполняет приведение типов перед сравнением. Алгоритм сложный, но для объектов он сводится к одному правилу: если оба операнда — объекты, сравниваются их ссылки. Приведение типов для объектов не происходит.
// Для примитивов разница очевидна:
console.log(5 == "5"); // true (строка "5" преобразуется в число 5)
console.log(5 === "5"); // false (разные типы)
// Для объектов разницы нет, оба оператора сравнивают ссылки:
console.log([1] == [1]); // false
console.log([1] === [1]); // false
Как сравнить объекты по содержимому?
Если вам нужно проверить, что два объекта имеют одинаковую структуру и значения свойств, необходимо реализовать глубокое сравнение (deep comparison). Это можно сделать несколькими способами.
1. Ручная проверка (для известной структуры) Для простых объектов можно сравнить ключевые свойства по отдельности.
function isUserEqual(user1, user2) {
return user1.name === user2.name && user1.age === user2.age;
}
const userA = { name: "Bob", age: 30 };
const userB = { name: "Bob", age: 30 };
console.log(isUserEqual(userA, userB)); // true
2. Использование JSON.stringify() (с ограничениями)
Можно преобразовать объекты в строки JSON и сравнить их.
const objA = { a: 1, b: { c: 2 } };
const objB = { a: 1, b: { c: 2 } };
console.log(JSON.stringify(objA) === JSON.stringify(objB)); // true
Ограничения этого метода:
- Порядок свойств имеет значение:
{a:1, b:2}и{b:2, a:1}дадут разные строки. - Не работает с циклическими ссылками (объект ссылается сам на себя).
- Теряет специальные типы данных, такие как
Date,Function,undefined,Map,Set(они либо не сериализуются, либо сериализуются не так, как ожидается).
3. Использование сторонних библиотек (рекомендуется) Библиотеки вроде Lodash предоставляют готовые и надёжные функции для глубокого сравнения.
// Использование lodash.isEqual
_.isEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }); // true
Специальные случаи и исключения
1. Примитивы-обёртки
При сравнении объекта-обёртки (созданного через new) с примитивом оператор == выполнит приведение типов, что может привести к неочевидному результату.
const strPrimitive = "hello";
const strObject = new String("hello");
console.log(strPrimitive == strObject); // true (объект приводится к примитиву "hello")
console.log(strPrimitive === strObject); // false (разные типы: string vs object)
2. null и undefined
Эти значения при нестрогом сравнении с объектом дают false.
console.log(null == {}); // false
console.log(undefined == {}); // false
Вывод для практического применения
- Для сравнения объектов всегда используйте строгое равенство (
===), чтобы избежать неявного приведения типов с другими значениями. Поведение для самих объектов будет таким же, как у==. - Помните, что
obj1 === obj2вернётtrueтолько если это один и тот же объект в памяти. - Если вам нужно сравнить содержимое объектов, вы должны реализовать или использовать готовую функцию глубокого сравнения. Не полагайтесь на
==или===для этой задачи. - Избегайте создания объектов в условиях сравнения (как в примере
{} == {}), так как это всегда будетfalse. Создавайте объекты заранее и сохраняйте в переменные, если планируете их сравнивать.
Вопрос 8. Что выведет консоль при вызове методов объекта, один из которых — стрелочная функция?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:10:46"/>
Ответ собеседника: Правильный. Первый вызов (обычный метод) вернёт 20. Второй вызов (стрелочная функция) вернёт NaN, так как у стрелочной функции нет своего this, она берёт this из внешнего контекста, который в данном случае undefined.
Правильный ответ:
Ответ кандидата точен. Давайте подробно разберём механизм работы this в JavaScript, чтобы понять, почему это происходит именно так, и рассмотрим все возможные сценарии.
Контекст выполнения и значение this
Значение this внутри функции в JavaScript определяется не в момент её объявления, а в момент вызова. Это называется поздним связыванием (late binding).
1. Обычная функция (function) в качестве метода объекта
Когда обычная функция вызывается как метод объекта (через точку: obj.method()), this внутри этой функции привязывается к объекту, перед точкой (к объекту, которому принадлежит метод).
const obj = {
value: 10,
regularFunc: function() {
return this.value * 2;
}
};
console.log(obj.regularFunc()); // 20
// this внутри regularFunc — это объект obj, поэтому this.value равно 10.
2. Стрелочная функция (=>) в качестве метода объекта
Стрелочные функции не имеют своего собственного this. Вместо этого они захватывают (заимствуют) значение this из окружающего лексического контекста (контекста, в котором они были объявлены).
В приведённом примере стрелочная функция arrowFunc объявлена внутри тела объекта obj. Но важно понимать, что объектный литерал {} не создаёт новый scope или контекст выполнения для this. Лексическая область видимости для arrowFunc — это та же область, где был объявлен сам объект obj.
В строгом режиме ("use strict"), который используется по умолчанию в модулях ES6 и многих современных окружениях, значением this на верхнем уровне (в глобальной области или области модуля) является undefined.
Следовательно:
- Стрелочная функция захватывает
thisиз внешней области видимости. - Во внешней области (на уровне модуля/скрипта)
thisравенundefined. - При попытке выполнить
undefined * 2получаетсяNaN(Not a Number).
"use strict"; // Предполагаем, что код выполняется в строгом режиме
const obj = {
value: 10,
arrowFunc: () => {
return this.value * 2; // this === undefined
}
};
console.log(obj.arrowFunc()); // NaN
// Эквивалентно: undefined.value * 2 -> TypeError или NaN
Что такое «лексический контекст» для стрелочной функции?
Чтобы лучше понять, какое this захватит стрелочная функция, представьте, где она была бы объявлена, если бы использовалась обычная функция.
// Лексическая область видимости для стрелочной функции — здесь (вне объекта)
const obj = {
value: 10,
arrowFunc: () => { /* ... */ } // Захватывает 'this' отсюда ↑
};
Правильное использование стрелочных функций в объектах
Стрелочные функции плохо подходят для определения методов объекта, если этим методам нужен доступ к this самого объекта. Их основное предназначение — колбэки и функции, где нужно сохранить контекст внешней функции.
Если нужно сохранить контекст в колбэке, лучше использовать стрелочную функцию:
const obj = {
value: 10,
init() {
// Обычная функция, this = obj
setTimeout(() => {
// Стрелочная функция захватила this из init, т.е. obj
console.log(this.value); // 10
}, 100);
}
};
obj.init();
Альтернативы для методов объекта
-
Обычные функции (короткий синтаксис ES6):
const obj = {value: 10,regularFunc() { // Короткий синтаксис методаreturn this.value * 2;}}; -
Явное связывание контекста (bind):
const obj = {value: 10,arrowFunc: (() => this.value * 2).bind(this) // Не сработает, т.к. this уже захвачен};// Этот подход бесполезен для стрелочных функций, так как их this нельзя перепривязать. -
Использование замыкания для доступа к объекту:
const obj = {value: 10,arrowFunc: function() {const self = this; // Сохраняем ссылку на объектreturn () => {return self.value * 2; // Используем сохранённую ссылку};}()};// Сложно и запутанно. Лучше использовать обычный метод.
Выводы и лучшие практики
- Для методов объекта, которым нужен доступ к
thisсамого объекта, всегда используйте обычные функции (или их короткий синтаксисmethod() {}). - Стрелочные функции используйте там, где нужно сохранить лексический контекст внешней функции — внутри колбэков, обработчиков событий, методов
map/filter/reduceи т.д. - Помните, что объектный литерал
{}не создаёт новый контекст дляthis.thisвнутри стрелочной функции, объявленной в объекте, будет таким же, как и снаружи объекта. - В строгом режиме глобальный
this— этоundefined, что приводит к ошибкам при попытке доступа к его свойствам. В нестрогом режиме это был бы глобальный объектwindow(в браузере), и результат мог бы быть другим (но это тоже плохая практика).
Вопрос 9. Каковы отличия стрелочных функций от обычных (function declaration/expression)?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:11:36"/>
Ответ собеседника: Правильный. Перечисляет отличия: 1) У стрелочных функций нет своего this, они берут его из внешнего контекста. 2) У них нет своего объекта arguments. 3) Их нельзя вызвать как конструктор с new. 4) Контекст (this) стрелочной функции нельзя изменить с помощью bind, call, apply.
Правильный ответ:
Ответ кандидата охватывает ключевые, наиболее часто упоминаемые различия. Давайте структурируем их и добавим несколько менее очевидных, но важных особенностей, а также практические последствия этих различий.
Полный список отличий стрелочных функций от обычных
1. Контекст выполнения (this)
Обычные функции: Имеют свой собственный динамический this, который определяется способом вызова функции (как метод объекта, как конструктор, напрямую и т.д.).
Стрелочные функции: Не имеют своего this. Они лексически связывают значение this с окружающим контекстом (т.е. берут this из той области, где были объявлены). Это их главнейшая особенность.
- Следствие: Методы
call(),apply()иbind()не могут изменить значениеthisдля стрелочной функции. Оно навсегда зафиксировано в момент объявления.
2. Объект arguments
Обычные функции: Имеют доступ к псевдомассиву arguments, который содержит все аргументы, переданные в функцию.
Стрелочные функции: Не имеют своего объекта arguments. При попытке доступа к arguments внутри стрелочной функции будет использован arguments из внешней (лексической) области видимости.
- Современная альтернатива: Вместо
argumentsв стрелочных функциях (да и в обычных) рекомендуется использовать Rest-параметры (...args), которые создают настоящий массив.const regular = function(a, b) { console.log(arguments); };const arrow = (...args) => { console.log(args); }; // Лучше и современнее
3. Использование в качестве конструктора (new)
Обычные функции: Могут быть использованы как конструкторы для создания новых объектов с помощью оператора new. У них есть внутренний метод [[Construct]] и свойство prototype.
Стрелочные функции: Не могут быть использованы как конструкторы. Вызов стрелочной функции с new вызовет ошибку TypeError: <function> is not a constructor. У них нет свойства prototype.
function RegularFunc() {} // Можно вызвать с new
const ArrowFunc = () => {};
console.log(RegularFunc.prototype); // {constructor: ƒ}
console.log(ArrowFunc.prototype); // undefined
new RegularFunc(); // OK
new ArrowFunc(); // TypeError: ArrowFunc is not a constructor
4. Возвращаемое значение
Обычные функции: Если возврат не указан явно (return), возвращают undefined.
Стрелочные функции: Имеют сокращённый синтаксис для возврата значения. Если тело функции состоит из одного выражения, его результат возвращается автоматически, без ключевого слова return и фигурных скобок.
const add = (a, b) => a + b; // return подразумевается
// Эквивалентно: const add = (a, b) => { return a + b; };
5. Привязка super (для классов)
Обычные функции: В методах классов обычные функции могут использовать super для доступа к методам родительского класса.
Стрелочные функции: Не имеют своего привязки к super. Как и с this, они используют super из окружающего лексического контекста.
6. Использование в качестве генератора
Обычные функции: Могут быть объявлены как генераторы с помощью function*.
Стрелочные функции: Не могут быть генераторами. Синтаксис () =>* не существует.
7. Поднятие (Hoisting)
Function Declaration: Полностью поднимаются (hoisted), их можно вызвать до объявления.
Function Expression и Стрелочные функции: Не поднимаются. Они являются частью выражения присваивания. Попытка вызвать такую функцию до её объявления приведёт к ReferenceError (для let/const) или TypeError (для var).
foo(); // OK (Function Declaration)
bar(); // ReferenceError: Cannot access 'bar' before initialization
function foo() {} // Function Declaration (поднимается)
const bar = () => {}; // Стрелочная функция (не поднимается)
8. Имя функции
Обычные Function Expression: Могут иметь имя, которое полезно для стека вызовов при отладке.
const namedFunc = function myName() {};
console.log(namedFunc.name); // "myName"
Стрелочные функции: Всегда анонимны. Если переменной присвоена стрелочная функция, её имя будет именем переменной.
const arrow = () => {};
console.log(arrow.name); // "arrow" (имя переменной, а не функции)
Сводная таблица отличий
| Характеристика | Обычная функция | Стрелочная функция |
|---|---|---|
Собственный this | Да (динамический) | Нет (лексический) |
Изменение this через call/apply/bind | Да | Нет |
Объект arguments | Да | Нет (использует внешний) |
Конструктор (вызов с new) | Да | Нет (ошибка) |
Свойство prototype | Да | Нет |
| Сокращённый возврат (implicit return) | Нет | Да (для одного выражения) |
Привязка super | Да (в методах класса) | Нет (лексическая) |
| Поднятие (Hoisting) | Да (Declaration) / Нет (Expression) | Нет (всегда Expression) |
| Имя для отладки | Может быть именованной | Всегда анонимная |
Когда что использовать?
Используйте стрелочные функции:
- Для коротких колбэков (в
setTimeout,promise.then,array.mapи т.д.). - Когда нужно сохранить лексический контекст
thisвнешней функции. - Для лаконичного однострочного возврата значений.
Используйте обычные функции:
- Для методов объектов и классов, которым нужен доступ к
thisсамого объекта. - Когда вам нужен конструктор.
- Когда вам нужен доступ к объекту
arguments(хотя лучше использовать rest-параметры). - Для функций, которые вы хотите вызвать до их объявления (только Function Declaration).
Вопрос 10. Как можно получить аргументы внутри стрелочной функции?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:12:17"/>
Ответ собеседника: Правильный. С помощью rest-оператора (...args).
Правильный ответ:
Ответ кандидата верен и указывает на современный и рекомендуемый способ. Однако, для полноты картины стоит рассмотреть все возможные подходы, их ограничения и лучшие практики.
1. Rest-параметры (Современный и рекомендуемый способ)
Это синтаксис ES6, который создаёт настоящий массив из всех переданных аргументов, начиная с указанного места. Это лучшая и самая читаемая замена устаревшему объекту arguments.
const arrowFunction = (...args) => {
// args — это МАССИВ, содержащий все переданные аргументы
console.log(args); // Например, [1, 2, 3]
console.log(args[0]); // Обращение к первому аргументу
return args.length;
};
arrowFunction(1, 2, 3); // Выведет [1, 2, 3] и вернёт 3
Преимущества rest-параметров:
- Это настоящий массив: с ним можно сразу использовать все методы массива (
map,filter,reduceи т.д.) без предварительного преобразования. - Явное объявление: параметры функции становятся более читаемыми и понятными.
- Гибкость: можно комбинировать с обычными параметрами.
const logDetails = (name, age, ...hobbies) => {console.log(`Name: ${name}, Age: ${age}`);console.log(`Hobbies: ${hobbies.join(', ')}`);};logDetails('Alice', 30, 'reading', 'cycling', 'coding');
2. Доступ к внешнему объекту arguments (Антипаттерн)
Как отметил кандидат, стрелочная функция не имеет своего arguments. Но если она объявлена внутри обычной функции, она получит доступ к её arguments через лексическую область видимости.
function outerRegularFunction(a, b) {
const innerArrow = () => {
// Использует arguments из внешней функции outerRegularFunction
console.log(arguments); // Псевдомассив Arguments(2) [1, 2]
console.log(arguments[0]); // 1
};
innerArrow();
}
outerRegularFunction(1, 2);
Почему это плохая практика:
- Запутанность: Код становится неочевидным.
argumentsвнутри стрелочной функции относится не к её параметрам, а к параметрам внешней функции. - Хрупкость: При рефакторинге или изменении внешней функции логика стрелочной функции может легко сломаться.
- Устаревший синтаксис: Объект
argumentsсчитается устаревшим (legacy) по сравнению с rest-параметрами.
3. Явное объявление параметров
Самый простой и очевидный способ — просто перечислить ожидаемые параметры. Это работает одинаково хорошо как для обычных, так и для стрелочных функций.
const add = (a, b) => a + b;
const greet = (name, greeting = 'Hello') => `${greeting}, ${name}!`;
Сравнение с обычной функцией и объектом arguments
Для наглядности сравним подходы:
| Особенность | Обычная функция (со arguments) | Стрелочная функция (с rest-параметрами) |
|---|---|---|
| Синтаксис | function(a, b) { console.log(arguments); } | (a, b, ...rest) => { console.log(rest); } |
| Тип значения | Псевдомассив Arguments | Настоящий массив Array |
| Доступ к методам массива | Требует преобразования: Array.from(arguments) | Не требует: rest.map(...) |
| Доступ к имени/длине | arguments.callee, arguments.length | functionName.length, rest.length |
| Использование в строгом режиме | arguments.callee запрещён | Нет таких проблем |
| Читаемость | Низкая, неявное поведение | Высокая, явное объявление |
Итог и лучшие практики
- Всегда используйте rest-параметры (
...args) внутри стрелочных функций для работы с переменным числом аргументов. Это современный, чистый и безопасный способ. - Избегайте использования объекта
argumentsдаже в обычных функциях. Rest-параметры предоставляют всю его функциональность в более удобном и мощном виде (настоящий массив). - Для фиксированного числа параметров просто объявляйте их явно в сигнатуре функции — это делает код максимально понятным.
- Помните, что стрелочные функции захватывают
argumentsиз внешней области видимости, но полагаться на это поведение — признак плохого дизайна кода. Всегда явно передавайте нужные данные в стрелочную функцию через параметры или замыкания.
Вопрос 11. Всплывают ли function declaration, function expression и стрелочные функции?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:12:41"/>
Ответ собеседника: Правильный. Function declaration всплывают. Function expression и стрелочные функции не всплывают.
Правильный ответ:
Ответ кандидата верен, но требует более детального разбора, так как поведение при "всплытии" (hoisting) различается не только между типами функций, но и между способами объявления переменных (var, let, const), которым они присваиваются.
Что такое hoisting (поднятие)?
Это механизм в JavaScript, при котором объявления переменных и функций "поднимаются" в верхнюю часть своей области видимости (scope) до выполнения кода. Важно понимать, что поднимается именно объявление, а не инициализация.
1. Function Declaration (Объявление функции)
Полностью всплывает. Это означает, что можно вызвать функцию до её объявления в коде. И объявление, и инициализация функции поднимаются вместе.
// Код работает именно так, как видит его интерпретатор
console.log(square(5)); // 25 (всё работает)
function square(num) { // Объявление поднято наверх
return num * num;
}
2. Function Expression (Функциональное выражение)
Не всплывает. Функциональное выражение — это часть более крупного выражения (обычно присваивания). Поднимается только объявление переменной (если оно есть), но не её значение (сама функция).
Поведение зависит от ключевого слова, использованного для объявления переменной:
С var: Переменная поднимается и инициализируется значением undefined. Попытка вызвать её до присваивания приведёт к TypeError.
console.log(squareVar); // undefined (поднялось объявление var)
console.log(squareVar(5)); // TypeError: squareVar is not a function
var squareVar = function(num) { // Присваивание происходит здесь
return num * num;
};
С let и const: Переменная поднимается, но не инициализируется и попадает во "временную мёртвую зону" (Temporal Dead Zone, TDZ). Попытка обратиться к ней до объявления вызовет ReferenceError.
console.log(squareLet); // ReferenceError: Cannot access 'squareLet' before initialization
console.log(squareConst); // ReferenceError: Cannot access 'squareConst' before initialization
let squareLet = function(num) { return num * num; };
const squareConst = function(num) { return num * num; };
3. Стрелочные функции (Arrow Function Expression)
Являются разновидностью function expression (синтаксический сахар для него), а значит, подчиняются тем же правилам всплытия, что и функциональные выражения.
Поведение также зависит от способа объявления переменной:
С var: Всплывает как undefined.
console.log(arrowVar); // undefined
console.log(arrowVar(5)); // TypeError: arrowVar is not a function
var arrowVar = (num) => num * num; // Присваивание здесь
С let и const: Не инициализируется до объявления, находится в TDZ.
console.log(arrowLet); // ReferenceError: Cannot access 'arrowLet' before initialization
console.log(arrowConst); // ReferenceError: Cannot access 'arrowConst' before initialization
let arrowLet = (num) => num * num;
const arrowConst = (num) => num * num;
Сводная таблица поведения
| Тип функции | Ключевое слово переменной | Поднятие переменной | Доступ до объявления | Итог |
|---|---|---|---|---|
| Function Declaration | (не присваивается) | Полное (декларация + инициализация) | Да (можно вызвать) | Всплывает |
| Function Expression | var | Частичное (=undefined) | TypeError | Не всплывает |
| Function Expression | let / const | Частичное (TDZ) | ReferenceError | Не всплывает |
| Стрелочная функция | var | Частичное (=undefined) | TypeError | Не всплывает |
| Стрелочная функция | let / const | Частичное (TDZ) | ReferenceError | Не всплывает |
Практические последствия и лучшие практики
- Всегда объявляйте функции и переменные перед их использованием. Не полагайтесь на hoisting, даже для Function Declaration. Это делает код более предсказуемым, понятным и легче поддающимся рефакторингу.
- Предпочитайте
constиletвместоvar. Использованиеletиconstдля function expression и стрелочных функций делает ошибки более явными (ReferenceErrorвместо молчаливогоundefined), что помогает быстрее находить баги. - Помните о TDZ.
ReferenceErrorв случае сlet/const— это хорошо, так как она защищает от работы с неинициализированными переменными. - Для большей ясности используйте Function Declaration для функций, которые являются основными элементами вашего кода, и function expression / стрелочные функции для колбэков и анонимных функций. Однако это больше вопрос стиля.
Итог: Ответ кандидата корректен. Всплывает только Function Declaration. Function Expression и Стрелочные функции (являющиеся его частным случаем) не всплывают в том смысле, что их нельзя использовать до места объявления в коде.
Вопрос 12. Что такое опциональная цепочка (?.) в JavaScript и как она работает?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:13:03"/>
Ответ собеседника: Правильный. Опциональная цепочка позволяет безопасно обращаться к свойствам объекта. Если значение слева равно null или undefined, ошибки не будет, вернётся undefined. Если свойство существует, вернётся его значение.
Правильный ответ:
Ответ кандидата точно описывает базовый принцип. Опциональная цепочка (Optional Chaining, ?.) — это оператор, представленный в ES2020, который решает одну из самых частых проблем в JavaScript: безопасный доступ к свойствам вложенных объектов, которые могут быть null или undefined. Давайте разберём её работу глубже.
Как работает оператор ?.
Оператор работает по короткому замыканию (short-circuiting). Если значение слева от ?. равно null или undefined, вычисление выражения немедленно прекращается, и возвращается undefined. В противном случае происходит обращение к свойству, методу или элементу массива, как обычно.
Синтаксис и варианты использования
1. Доступ к свойству (Optional Property Access)
Самая распространённая use case. Заменяет громоздкие проверки с помощью &&.
// Без опциональной цепочки (старый способ)
const street = user && user.address && user.address.street;
// С опциональной цепочкой (новый способ)
const street = user?.address?.street; // undefined, если user или user.address null/undefined
2. Вызов метода (Optional Method Call) Позволяет безопасно вызывать метод, который может не существовать на данном этапе.
// Без опциональной цепочки
const result = someObject.someMethod ? someObject.someMethod() : undefined;
// С опциональной цепочкой
const result = someObject.someMethod?.(); // undefined, если someMethod не существует
// Пример с API, которое может быть не загружено
const data = window.someThirdPartyAPI?.getData?.();
3. Доступ к элементу массива (Optional Array Access) Полезно при работе с массивами, которые могут быть не определены.
// Без опциональной цепочки
const firstItem = (array && array.length) ? array[0] : undefined;
// С опциональной цепочкой
const firstItem = array?.[0]; // undefined, если array null/undefined
4. Комбинирование с оператором нулевого слияния (??)
Часто ?. используется вместе с ?? для предоставления значения по умолчанию вместо undefined.
// Если user?.address?.city равно undefined или null, будет использовано 'City not specified'
const city = user?.address?.city ?? 'City not specified';
// Эквивалентно старой громоздкой записи:
// const city = (user && user.address && user.address.city) || 'City not specified';
Что НЕ делает опциональная цепочка
Критически важно понимать, что оператор ?. защищает только от null и undefined. Он НЕ защищает от других "falsy" значений, таких как:
0''(пустая строка)falseNaN
Если свойство существует и имеет одно из этих значений, опциональная цепочка вернёт это значение, а не undefined.
const obj = { count: 0, name: '' };
console.log(obj.count?.toString()); // "0" (свойство существует, вызывается метод)
console.log(obj.name?.length); // 0 (свойство существует)
console.log(obj.nonexistent?.value); // undefined (свойства нет)
Где нельзя использовать опциональную цепочку
- Не для записи (assignment): Оператор
?.предназначен только для чтения. Нельзя использовать его для присваивания.obj?.property = 5; // SyntaxError - Не в начале цепочки: Оператор должен следовать после потенциально "небезопасного" значения.
?.user.address // SyntaxError
- С осторожностью при удалении свойств: Использование
deleteс опциональной цепочкой возможно, но поведение может быть неочевидным.delete user?.address; // Удалит address, если user существует, иначе ничего не произойдёт (но это редко нужно).
Особенности работы с функциями
При вызове функции через ?.() важно помнить: если функция не существует, ошибки не будет. Но если свойство существует, но не является функцией, возникнет ошибка.
const obj = { method: 42 };
obj.method?.(); // TypeError: obj.method is not a function (42 не функция)
obj.nonexistentMethod?.(); // undefined (свойства нет, ошибки нет)
Исторический контекст и альтернативы
До появления ?. разработчики использовали различные костыли:
- Вложенные проверки (
&&):user && user.address && user.address.street try...catch: Громоздко и не предназначено для этой цели.- Функции типа
getиз Lodash:_.get(user, 'address.street', defaultValue)
Опциональная цепочка предоставляет нативный, лаконичный и легко читаемый синтаксис для решения этой проблемы.
Best Practices
- Не злоупотребляйте
?.. Используйте её только для свойств, которые действительно могут бытьnullилиundefined. Если свойство обязательное, отсутствие ошибки может скрыть реальную проблему в логике приложения. - Используйте в сочетании с
??для удобной подстановки значений по умолчанию. - Помните о поддержке браузеров. Хотя все современные браузеры поддерживают
?., если ваш код должен работать в старых окружениях (например, Internet Explorer), вам понадобится транспилятор (Babel) для преобразования этого синтаксиса.
Вопрос 13. Что такое оператор нулевого слияния (??) и как он работает?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:14:10"/>
Ответ собеседника: Правильный. Оператор нулевого слияния проверяет значение слева. Если оно null или undefined, возвращает значение справа. Иначе возвращает значение слева.
Правильный ответ:
Ответ кандидата верно описывает основную логику оператора. Оператор нулевого слияния (Nullish Coalescing Operator, ??) — это оператор, представленный в ES2020, который предоставляет удобный способ выбора значения по умолчанию. Его ключевая особенность в том, что он обрабатывает только null и undefined как "отсутствующие" значения, в отличие от логического оператора ||.
Принцип работы
Оператор ?? возвращает правый операнд, только если левый операнд равен null или undefined. Во всех остальных случаях возвращается левый операнд.
const result = leftOperand ?? rightOperand;
// Эквивалентно:
// const result = (leftOperand !== null && leftOperand !== undefined) ? leftOperand : rightOperand;
Ключевое отличие от логического ИЛИ (||)
Логический оператор || возвращает правый операнд, если левый операнд является "falsy" значением. К "falsy" значениям в JavaScript относятся: false, 0, '' (пустая строка), null, undefined, NaN.
Это различие критически важно, так как во многих случаях 0, false или пустая строка '' являются валидными, ожидаемыми значениями, которые не должны заменяться на значение по умолчанию.
Сравнение ?? и || на примерах
| Ситуация | Выражение с || | Результат || | Выражение с ?? | Результат ?? | Пояснение |
| :--- | :--- | :--- | :--- | :--- | :--- |
| Значение null | null || 'default' | 'default' | null ?? 'default' | 'default' | Оба оператора работают одинаково |
| Значение undefined | undefined || 'default' | 'default' | undefined ?? 'default' | 'default' | Оба оператора работают одинаково |
| Значение 0 | 0 || 100 | 100 | 0 ?? 100 | 0 | || заменяет 0, ?? — нет |
| Пустая строка '' | '' || 'Hello' | 'Hello' | '' ?? 'Hello' | '' | || заменяет '', ?? — нет |
| Значение false | false || true | true | false ?? true | false | || заменяет false, ?? — нет |
| Значение NaN | NaN || 1 | 1 | NaN ?? 1 | NaN | || заменяет NaN, ?? — нет |
Практические примеры использования
1. Предоставление значения по умолчанию для потенциально отсутствующей переменной
// Допустим, эти данные пришли из API
const userSettings = { theme: null, itemsPerPage: 0 };
// Логический OR может дать неожиданный результат
const theme = userSettings.theme || 'dark'; // 'dark' (ожидаемо)
const items = userSettings.itemsPerPage || 10; // 10 (но мы ожидали 0!)
// Nullish Coalescing ведёт себя предсказуемо
const themeSafe = userSettings.theme ?? 'dark'; // 'dark'
const itemsSafe = userSettings.itemsPerPage ?? 10; // 0 (что и нужно!)
2. Комбинирование с опциональной цепочкой (?.)
Это мощная комбинация для работы с глубоко вложенными структурами данных.
const config = {
database: { host: null, port: 5432 }
};
// Без комбинации (старый способ)
const host = (config.database && config.database.host) || 'localhost';
const port = (config.database && config.database.port) || 8080;
// Современный способ с ?. и ??
const hostModern = config.database?.host ?? 'localhost'; // 'localhost'
const portModern = config.database?.port ?? 8080; // 5432 (порт существует, значение по умолчанию не используется)
3. Эмуляция поведения функций get из библиотек
Раньше для безопасного доступа использовали Lodash, теперь можно обойтись нативными средствами.
// Раньше: const value = _.get(object, 'deep.nested.property', 'default');
// Теперь:
const value = object?.deep?.nested?.property ?? 'default';
Приоритет операторов и необходимость скобок
Оператор ?? имеет очень низкий приоритет, ниже чем у || и &&. Поэтому при использовании его в комбинации с другими операторами почти всегда нужно использовать скобки, чтобы избежать неожиданного поведения.
// Без скобок приоритет может привести к неожиданному результату
let value = a && b ?? c; // SyntaxError: без скобок смешивать && и ?? нельзя
// Всегда используйте скобки для явного указания порядка вычислений
let value = (a && b) ?? c; // Сначала 'a && b', затем результат ?? c
let anotherValue = a ?? (b || c); // Сначала 'b || c', затем a ?? результат
Поддержка браузеров и транспиляция
Как и опциональная цепочка, оператор ?? поддерживается всеми современными браузерами. Однако для поддержки в старых средах (например, Internet Explorer) потребуется использовать транспилятор, такой как Babel, который преобразует этот синтаксис в эквивалентную проверку с помощью null и undefined.
Best Practices
- Используйте
??вместо||для предоставления значений по умолчанию, когда0,false,''(пустая строка) являются валидными значениями, которые не должны быть перезаписаны. - Используйте
||только в случаях, когда вам нужно отфильтровать все "falsy" значения (например, для проверки на наличие любого "истинного" значения). - Всегда используйте скобки при комбинировании
??с другими логическими операторами (&&,||) для ясности и избежания синтаксических ошибок. - Комбинируйте с опциональной цепочкой (
?.) для создания надёжного и лаконичного кода работы с глубокими структурами данных.
Вопрос 14. Что такое оператор логического присваивания (&&=) и как он работает?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:14:49"/>
Ответ собеседника: Неполный. Ответ описывает логику оператора логического И (&&), но не оператора присваивания (&&=). Оператор &&= не возвращает значение справа, а присваивает его переменной слева только при определённом условии.
Правильный ответ:
Оператор логического присваивания И (&&=) — это один из операторов составного присваивания, представленный в ES2021. Он выполняет присваивание значения правого операнда левому операнду только в том случае, если левый операнд является истинным (truthy). В противном случае значение левого операнда остаётся неизменным.
Синтаксис и принцип работы
x &&= y;
// Эквивалентно:
// if (x) {
// x = y;
// }
// Или более кратко:
// x = x && y;
Пошаговый алгоритм работы x &&= y:
- Вычисляется текущее значение
x. - Если значение
xявляется "falsy" (приводится кfalseв логическом контексте:false,0,'',null,undefined,NaN), то выражение завершается, и значениеxостаётся неизменным. - Если значение
xявляется "truthy", то вычисляется значениеy, и результат присваивается переменнойx.
Практические примеры
// Пример 1: Левая часть truthy — присваивание происходит
let a = 10;
a &&= 20; // Эквивалентно: a = a && 20 -> a = 10 && 20 -> a = 20
console.log(a); // 20
// Пример 2: Левая часть falsy — присваивание НЕ происходит
let b = 0;
b &&= 20; // Эквивалентно: b = 0 && 20 -> b = 0 (присваивания не было, значение осталось 0)
console.log(b); // 0
// Пример 3: Левая часть null — присваивание НЕ происходит
let c = null;
c &&= 'new value';
console.log(c); // null
// Пример 4: Работа с объектами
const config = { timeout: 500 };
// Если свойство уже существует и truthy, мы его обновим
config.timeout &&= 1000; // timeout был 500 (truthy), поэтому присваиваем 1000
console.log(config.timeout); // 1000
// Если свойства нет (undefined) или оно falsy, ничего не делаем
config.retries &&= 3; // retries - undefined (falsy), присваивания не происходит
console.log(config.retries); // undefined
Отличие от логического И (&&)
Кандидат в ответе описал работу оператора &&, а не &&=. Важно понимать разницу:
x && y— возвращает значение. Это выражение, его результат можно использовать дальше.x &&= y— изменяет значение переменнойx. Это оператор присваивания.
let val = 10;
const resultOfAnd = val && 20; // Возвращает 20, но не меняет val
console.log(resultOfAnd); // 20
console.log(val); // 10 (не изменилось)
val &&= 20; // Меняет значение val на 20, только если val было truthy
console.log(val); // 20 (изменилось)
Семейство логических операторов присваивания
Наряду с &&=, в ES2021 были представлены и другие операторы:
1. Оператор логического присваивания ИЛИ (||=)
Присваивает значение правого операнда левому операнду только если левый операнд является "falsy".
x ||= y;
// Эквивалентно: x = x || y;
// Или: if (!x) { x = y; }
let name = '';
name ||= 'Anonymous'; // name было '' (falsy), поэтому присваиваем 'Anonymous'
console.log(name); // 'Anonymous'
let count = 10;
count ||= 5; // count было 10 (truthy), поэтому присваивания НЕ происходит
console.log(count); // 10
2. Оператор логического присваивания нулевого слияния (??=)
Присваивает значение правого операнда левому операнду только если левый операнд равен null или undefined.
x ??= y;
// Эквивалентно: x = x ?? y;
// Или: if (x == null) { x = y; }
let score = 0;
score ??= 100; // score было 0 (не null и не undefined), присваивания НЕ происходит
console.log(score); // 0
let userAge = null;
userAge ??= 25; // userAge было null, поэтому присваиваем 25
console.log(userAge); // 25
Преимущества использования
- Лаконичность: Позволяют записывать часто используемые паттерны проверки и присваивания в более краткой форме.
- Удобство: Упрощают код, избавляя от необходимости повторно указывать имя переменной (
x = x && y->x &&= y). - Безопасность: Операторы присваивания не приводят к неожиданным побочным эффектам, так как присваивание происходит только при определённых условиях.
Практические Use Cases
-
Установка значений по умолчанию (с
||=и??=):// Раньше: options.theme = options.theme || 'dark';options.theme ||= 'dark'; // Установить 'dark', только если theme falsy (в т.ч. пустая строка)// Раньше: settings.volume = settings.volume ?? 50;settings.volume ??= 50; // Установить 50, только если volume null или undefined (но не 0) -
Условное изменение существующих свойств (с
&&=):// Обновить текст кнопки, только если он уже был заданsubmitButton.textContent &&= 'Processing...';// Добавить элемент в массив, только если массив существуетuser.logs &&= [...user.logs, newLogEntry];
Итог: Оператор &&= — это не просто "логическое И", а оператор условного присваивания. Он изменяет значение переменной слева на значение справа, но только в том случае, если текущее значение переменной слева является истинным (truthy). Это мощный инструмент для написания лаконичного и выразительного кода, особенно при работе с конфигурациями, настройками и опциональными свойствами объектов.
Вопрос 15. Что такое шаблонные строки (template literals) в JavaScript?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:15:08"/>
Ответ собеседника: Правильный. Шаблонные строки позволяют вставлять переменные и выражения в ${}, а также использовать многострочные строки.
Правильный ответ:
Шаблонные строки (Template Literals) — это синтаксис для работы со строками, представленный в ES6 (ES2015). Они значительно расширяют возможности обычных строк, заключённых в кавычки, и решают множество распространённых проблем.
Основные возможности шаблонных строк
1. Синтаксис
Шаблонные строки заключаются в обратные кавычки (backticks) ` вместо одинарных или двойных кавычек.`
2. Интерполяция выражений
Внутри шаблонной строки можно вставлять любые JavaScript-выражения, оборачивая их в ${}. Выражение вычисляется, и его результат преобразуется в строку и встраивается в общий текст.
const name = 'Анна';
const age = 30;
// Старый способ (конкатенация)
const messageOld = 'Привет, ' + name + '! Тебе ' + age + ' лет.';
// Новый способ (шаблонная строка)
const messageNew = `Привет, ${name}! Тебе ${age} лет.`;
// Можно использовать выражения
const calculation = `Сумма: ${5 + 3}`; // "Сумма: 8"
const isAdult = `Совершеннолетний: ${age >= 18}`; // "Совершеннолетний: true"
// Вызовы функций
function greet(user) { return `Hello, ${user}`; }
const greeting = `${greet('John')}!`; // "Hello, John!"
3. Многострочность
Шаблонные строки могут занимать несколько строк без использования специальных символов \n или конкатенации. Все пробелы и переносы строк сохраняются.
// Старый способ
const multilineOld = 'Первая строка\n' +
'Вторая строка\n' +
'Третья строка';
// Новый способ
const multilineNew = `Первая строка
Вторая строка
Третья строка`;
// Особенно полезно для HTML-шаблонов
const htmlTemplate = `
<div class="user-card">
<h2>${name}</h2>
<p>Возраст: ${age}</p>
</div>
`;
4. Тегированные шаблоны (Tagged Templates) Это расширенная возможность, которая позволяет обрабатывать шаблонную строку с помощью специальной функции (тега). Функция-тег получает части строки и значения выражений, что позволяет выполнять кастомную обработку.
// Функция-тег
function highlight(strings, ...values) {
let result = '';
strings.forEach((string, i) => {
result += string;
if (i < values.length) {
result += `<mark>${values[i]}</mark>`;
}
});
return result;
}
const name = 'Мария';
const score = 95;
// Использование тегированного шаблона
const message = highlight`Участник ${name} набрал ${score} баллов.`;
// Результат: "Участник <mark>Мария</mark> набрал <mark>95</mark> баллов."
Популярные применения тегированных шаблонов:
- Экранирование HTML: предотвращение XSS-атак
- Локализация: динамический перевод текста
- Стилизация: создание CSS-in-JS библиотек (например, styled-components)
- SQL-запросы: безопасное построение запросов
5. Экранирование символов
В шаблонных строках можно использовать обратный слеш \ для экранирования символов, включая сами обратные кавычки и ${}.
const escaped = `Это обратная кавычка: \` и это шаблон: \${notInterpolated}`;
console.log(escaped); // "Это обратная кавычка: ` и это шаблон: ${notInterpolated}"
Различия с обычными строками
| Характеристика | Обычные строки | Шаблонные строки |
|---|---|---|
| Ограничители | ' ' или " " | ` ` |
| Интерполяция | Нет (только конкатенация) | Да (${expression}) |
| Многострочность | Только с \n | Нативная поддержка |
| Тегирование | Нет | Да (функции-теги) |
| Экранирование | \', \", \\ | \`{, \$, \\ |
Практические преимущества
Безопасность (с тегированными шаблонами):
// Безопасное экранирование HTML
function safeHtml(strings, ...values) {
let result = strings[0];
values.forEach((value, i) => {
const escaped = String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
result += escaped + strings[i + 1];
});
return result;
}
const userInput = '<script>alert("XSS")</script>';
const safeMessage = safeHtml`<div>${userInput}</div>`;
// Результат: "<div><script>alert("XSS")</script></div>"
Производительность Шаблонные строки часто более производительны, чем конкатенация, особенно при работе с большими объёмами текста и множеством переменных, так как движки JavaScript могут их лучше оптимизировать.
Лучшие практики
- Используйте шаблонные строки по умолчанию для всех новых проектов вместо конкатенации.
- Для простых строк без интерполяции можно продолжать использовать одинарные/двойные кавычки (это вопрос стиля).
- Используйте тегированные шаблоны для сложной обработки строк (безопасность, интернационализация).
- Избегайте вложенных шаблонных строк большой глубины — это ухудшает читаемость.
- Для динамического создания шаблонов рассмотрите использование функций, которые возвращают шаблонные строки.
Шаблонные строки — это мощный инструмент, который сделал работу со строками в JavaScript значительно удобнее, безопаснее и выразительнее.
Вопрос 16. Что такое structuredClone() в JavaScript?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:15:28"/>
Ответ собеседника: Правильный. structuredClone() - встроенная функция для глубокого копирования объектов.
Правильный ответ:
Ответ кандидата верен, но описывает лишь базовую суть. structuredClone() — это современный API, представленный в стандарте HTML и поддерживаемый всеми основными браузерами и Node.js (начиная с версии 17), который предназначен для создания глубокой копии (deep copy) сложных объектов JavaScript с использованием алгоритма структурного клонирования.
Чем отличается глубокая копия от поверхностной?
-
Поверхностная копия (Shallow Copy): Создаётся новый объект, но свойства, являющиеся ссылками на другие объекты, копируются как ссылки. Изменения во вложенных объектах будут видны и в оригинале, и в копии.
const original = { a: 1, b: { c: 2 } };const shallowCopy = { ...original }; // или Object.assign({}, original)shallowCopy.b.c = 99;console.log(original.b.c); // 99 (неожиданное изменение оригинала!) -
Глубокая копия (Deep Copy): Создаётся полностью независимая копия объекта, включая все уровни вложенности. Все вложенные объекты также рекурсивно копируются. Изменения в копии не затрагивают оригинал.
const original = { a: 1, b: { c: 2 } };const deepCopy = structuredClone(original);deepCopy.b.c = 99;console.log(original.b.c); // 2 (оригинал остался неизменным)console.log(deepCopy.b.c); // 99
Что может клонировать structuredClone()?
Алгоритм структурного клонирования поддерживает широкий спектр встроенных типов JavaScript:
- Примитивы:
Boolean,String,Number,BigInt,undefined,null,Symbol - Структуры данных:
Object,Array,Map,Set,WeakMap,WeakSet - Типизированные массивы:
Int8Array,Uint8Array,Uint8ClampedArray,Int16Arrayи т.д. - Другие объекты:
Date,RegExp,Blob,File,ImageData,ArrayBuffer - Циклические ссылки: Объекты, которые ссылаются друг на друга или сами на себя.
// Клонирование сложных структур
const complexObject = {
set: new Set([1, 2, 3]),
map: new Map([[1, 'one']]),
date: new Date(),
regex: /pattern/gi,
array: [1, 2, { nested: 'value' }]
};
const clonedComplex = structuredClone(complexObject);
Что НЕ может клонировать structuredClone()?
Функция выбрасывает исключение DOMException при попытке клонировать неподдерживаемые типы:
- Функции (Function):
function() {} - DOM-узлы:
HTMLDivElement,TextNodeи т.д. - Дескрипторы свойств:
getter/setterи атрибуты вродеwritable,enumerableтеряются, копируется только текущее значение. - Прототип объекта: Цепочка прототипов не сохраняется. Клонированный объект будет иметь прототип
Object.prototype. - Объекты с методами: Если объект имеет методы, они не будут скопированы.
const objWithFunction = {
method: () => console.log('hello')
};
try {
structuredClone(objWithFunction); // DOMException: function() {} could not be cloned.
} catch (e) {
console.error(e);
}
Синтаксис и параметры
const clone = structuredClone(original[, { transfer }]);
original: Объект, который нужно клонировать.transfer(опционально): Массив transferable-объектов (например,ArrayBuffer), которые будут перемещены в клонированный объект, а не скопированы. Это полезно для оптимизации производительности при работе с большими бинарными данными.
Альтернативы до появления structuredClone()
До этой функции разработчики использовали менее надёжные и более медленные способы:
-
JSON.parse(JSON.stringify(object)): Распространённый хак.- Недостатки: Теряет типы
Date(превращает в строку),RegExp,Map,Set,undefined,Function. Не поддерживает циклические ссылки (вызывает ошибку).
- Недостатки: Теряет типы
-
Рекурсивная функция глубокого копирования: Нужно писать самостоятельно, легко ошибиться, особенно с циклическими ссылками и разными типами данных.
-
Сторонние библиотеки:
_.cloneDeep()из Lodash.
Сравнение с JSON.parse(JSON.stringify())
| Критерий | JSON.parse(JSON.stringify()) | structuredClone() |
|---|---|---|
| Скорость | Медленнее | Быстрее |
| Поддержка типов | Только JSON-совместимые | Гораздо шире (Date, Map, Set и др.) |
| Циклические ссылки | Ломается (ошибка) | Поддерживает |
| Функции | Пропускает | Ошибка (явное поведение) |
| Прототип | Теряет | Теряет |
| Дескрипторы | Теряет | Теряет |
Пример с циклической ссылкой
const original = { name: "Alice" };
original.self = original; // Циклическая ссылка
// JSON.stringify выбросит TypeError: Converting circular structure to JSON
// const jsonCopy = JSON.parse(JSON.stringify(original));
// structuredClone корректно обработает это
const cloned = structuredClone(original);
console.log(cloned.self === cloned); // true (копия ссылается на саму себя)
console.log(cloned.self.name); // "Alice"
Best Practices
- Используйте
structuredClone()как стандартный способ для глубокого копирования объектов в современных приложениях. - Для копирования объектов с методами или особыми прототипами вам потребуется написать собственную функцию-клонировщик или использовать библиотеки.
- Помните об ограничениях с функциями и DOM-узлами.
- Для передачи больших бинарных данных используйте опцию
transferдля оптимизации производительности. - Всегда проверяйте поддержку браузерами, если цель вашего приложения — устаревшие окружения. Однако на сегодняшний день поддержка очень широкая.
Итог: structuredClone() — это мощный, стандартизированный и эффективный инструмент для создания глубоких копий объектов, который пришёл на смену неуклюжим хакам и сторонним библиотекам. Он корректно обрабатывает циклические ссылки и поддерживает большинство встроенных типов JavaScript.
Вопрос 17. Что такое метод at() для массивов в JavaScript?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:15:40"/>
Ответ собеседника: Правильный. Метод at() принимает индекс и возвращает элемент массива по этому индексу. Поддерживает отрицательные индексы для обращения с конца массива.
Правильный ответ:
Метод at() — это современный способ доступа к элементам массивов и строк по индексу, представленный в ES2022. Он решает несколько практических проблем традиционного доступа через квадратные скобки [].
Основная функциональность
Метод принимает целочисленный индекс (положительный или отрицательный) и возвращает элемент по этому индексу.
const array = ['a', 'b', 'c', 'd', 'e'];
// Положительные индексы (работают как обычный доступ через [])
console.log(array.at(0)); // 'a'
console.log(array.at(2)); // 'c'
// Отрицательные индексы (главное преимущество)
console.log(array.at(-1)); // 'e' (последний элемент)
console.log(array.at(-2)); // 'd' (предпоследний элемент)
console.log(array.at(-3)); // 'c'
Проблемы, которые решает at()
1. Доступ к элементам с конца массива
До появления at() для получения последних элементов использовались громоздкие конструкции:
const array = ['a', 'b', 'c', 'd', 'e'];
// Старый способ (неудобный и нечитаемый)
const lastElementOld = array[array.length - 1]; // 'e'
const secondLastOld = array[array.length - 2]; // 'd'
// Новый способ (интуитивно понятный)
const lastElementNew = array.at(-1); // 'e'
const secondLastNew = array.at(-2); // 'd'
2. Безопасность при неверных индексах
При обращении к несуществующему индексу оба метода ведут себя одинаково — возвращают undefined:
const array = ['a', 'b', 'c'];
console.log(array[10]); // undefined
console.log(array.at(10)); // undefined
console.log(array[-10]); // undefined (традиционный доступ не поддерживает отрицательные индексы)
console.log(array.at(-10)); // undefined
Сравнение с традиционным доступом через []
| Характеристика | Квадратные скобки [] | Метод at() |
|---|---|---|
| Положительные индексы | array[0] → 'a' | array.at(0) → 'a' |
| Отрицательные индексы | array[-1] → undefined | array.at(-1) → 'e' |
| Несуществующий индекс | array[10] → undefined | array.at(10) → undefined |
| Читаемость для последних элементов | array[array.length - 1] | array.at(-1) |
| Поддержка в старых браузерах | Всегда | ES2022+ |
Особенности работы
Индексы за пределами массива
Метод всегда возвращает undefined для индексов вне диапазона массива:
const fruits = ['apple', 'banana', 'orange'];
console.log(fruits.at(5)); // undefined (индекс больше длины)
console.log(fruits.at(-5)); // undefined (отрицательный индекс больше длины)
console.log(fruits.at(3)); // undefined (индекс равен длине)
Дробные индексы
Дробные индексы автоматически округляются до целого числа (по правилам Math.trunc()):
const numbers = [10, 20, 30, 40];
console.log(numbers.at(1.3)); // 20 (округляется до 1)
console.log(numbers.at(1.9)); // 20 (округляется до 1)
console.log(numbers.at(-1.7)); // 40 (округляется до -1)
Применение со строками
Метод at() также доступен для строк и работает аналогично:
const message = 'Hello';
console.log(message.at(0)); // 'H'
console.log(message.at(-1)); // 'o'
console.log(message.at(-2)); // 'l'
// Сравнение с традиционными методами
console.log(message[message.length - 1]); // 'o' (старый способ)
console.log(message.slice(-1)); // 'o' (альтернативный способ)
console.log(message.at(-1)); // 'o' (новый способ)
Полифиллы и поддержка браузеров
Метод at() поддерживается всеми современными браузерами (Chrome 92+, Firefox 90+, Safari 15.4+). Для поддержки в старых браузерах можно использовать полифилл:
// Простой полифилл для массивов
if (!Array.prototype.at) {
Array.prototype.at = function(index) {
// Обработка отрицательных индексов
const relativeIndex = index < 0 ? this.length + index : index;
// Проверка выхода за границы массива
if (relativeIndex < 0 || relativeIndex >= this.length) {
return undefined;
}
return this[relativeIndex];
};
}
Лучшие практики использования
- Используйте
at()для доступа к элементам с конца массива — код становится чище и понятнее. - Для обычного доступа по положительным индексам можно продолжать использовать квадратные скобки
[]— это идиоматично и привычно. - При работе с пользовательским вводом используйте
at()для удобной обработки как положительных, так и отрицательных индексов. - Проверяйте поддержку браузеров если целевая аудитория использует старые версии.
Пример практического применения
// Обработка очереди задач с удобным доступом к последним элементам
const taskQueue = ['task1', 'task2', 'task3', 'task4'];
function processRecentTasks() {
const currentTask = taskQueue.at(-1); // Последняя задача
const previousTask = taskQueue.at(-2); // Предыдущая задача
console.log(`Текущая: ${currentTask}, Предыдущая: ${previousTask}`);
}
processRecentTasks(); // "Текущая: task4, Предыдущая: task3"
Метод at() — это небольшое, но очень полезное дополнение к языку, которое делает код более читаемым и выразительным, особенно при работе с последними элементами массивов и строк.
Вопрос 18. Какие способы копирования объектов в JavaScript вы знаете?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:16:06"/>
Ответ собеседника: Правильный. Для поверхностного копирования: Object.assign(), spread оператор. Для глубокого копирования: structuredClone(), рекурсивное копирование, библиотеки типа lodash.cloneDeep(). Отмечает ограничения JSON.parse(JSON.stringify()) - не копирует функции, Date объекты и т.д.
Правильный ответ:
Ответ кандидата полностью корректен и охватывает все основные современные подходы. Давайте детализируем каждый метод, рассмотрим их нюансы, производительность и конкретные use cases.
Поверхностное копирование (Shallow Copy)
Создаёт новый объект, но вложенные объекты остаются ссылками на оригиналы. Изменения во вложенных объектах затрагивают и оригинал, и копию.
1. Spread оператор (...)
Современный и наиболее часто используемый синтаксис ES6+.
const original = { a: 1, b: { c: 2 } };
const copy = { ...original };
copy.a = 10; // Не влияет на оригинал
console.log(original.a); // 1
copy.b.c = 20; // Влияет на оригинал!
console.log(original.b.c); // 20
2. Object.assign() Более старый, но до сих пор используемый метод ES6.
const original = { a: 1, b: { c: 2 } };
const copy = Object.assign({}, original);
copy.a = 10;
console.log(original.a); // 1
copy.b.c = 20;
console.log(original.b.c); // 20
Важные нюансы поверхностного копирования:
- Не копирует прототип исходного объекта.
- Дескрипторы свойств (writable, enumerable, configurable) не копируются.
- Свойства-символы (Symbol) копируются корректно.
Глубокое копирование (Deep Copy)
Создаёт полностью независимую копию объекта со всеми уровнями вложенности.
1. structuredClone() Современный нативный API, представленный в 2021 году. Стандарт для глубокого копирования.
const original = {
a: 1,
b: {
c: 2,
d: new Date(),
e: new Set([1, 2, 3])
}
};
const copy = structuredClone(original);
copy.b.c = 99; // Не влияет на оригинал
console.log(original.b.c); // 2
// Поддерживает сложные типы
console.log(copy.b.d instanceof Date); // true
console.log(copy.b.e instanceof Set); // true
Что клонирует structuredClone():
- Примитивы, объекты, массивы
Date,RegExp,Set,Map,ArrayBuffer- Циклические ссылки
- Типизированные массивы (
Int8Arrayи др.)
Что НЕ клонирует:
- Функции
- DOM-узлы
- Дескрипторы свойств и цепочку прототипов
2. JSON.parse(JSON.stringify()) Старый "хак" для глубокого копирования с серьёзными ограничениями.
const original = {
a: 1,
b: {
c: 2,
d: new Date(),
e: function() { return 42; },
f: undefined,
g: Symbol('test')
}
};
const copy = JSON.parse(JSON.stringify(original));
console.log(copy.b.d); // "2024-...T..." (строка, а не Date)
console.log(copy.b.e); // undefined (функции пропадают)
console.log(copy.b.f); // null (undefined преобразуется в null)
console.log(copy.b.g); // undefined (Symbol пропадает)
Ограничения метода:
- Теряет типы
Date,RegExp,Map,Set - Удаляет функции,
undefined,Symbol - Не поддерживает циклические ссылки (выбрасывает ошибку)
- Неэффективен для больших объектов
3. Рекурсивное копирование (кастомная функция) Позволяет реализовать специфическую логику копирования.
function deepClone(obj, hash = new WeakMap()) {
// Обработка примитивов и null
if (obj === null || typeof obj !== 'object') {
return obj;
}
// Обработка циклических ссылок
if (hash.has(obj)) {
return hash.get(obj);
}
// Обработка специальных типов
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj);
if (obj instanceof Map) return new Map(Array.from(obj, ([k, v]) => [deepClone(k), deepClone(v)]));
if (obj instanceof Set) return new Set(Array.from(obj, v => deepClone(v)));
// Создание нового объекта с сохранением прототипа
const result = Object.create(Object.getPrototypeOf(obj));
hash.set(obj, result);
// Копирование всех свойств
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
result[key] = deepClone(obj[key], hash);
}
}
// Копирование символьных свойств
const symbols = Object.getOwnPropertySymbols(obj);
for (const sym of symbols) {
result[sym] = deepClone(obj[sym], hash);
}
return result;
}
4. Библиотечные методы (Lodash) Производственные решения с обработкой edge cases.
// Использование Lodash
const _ = require('lodash');
const original = {
a: 1,
b: {
c: 2,
d: new Date(),
e: function() { return 42; }
}
};
const copy = _.cloneDeep(original);
console.log(copy.b.d instanceof Date); // true
console.log(typeof copy.b.e); // 'function'
Сравнительная таблица методов
| Метод | Тип копирования | Поддержка типов | Циклические ссылки | Производительность |
|---|---|---|---|---|
Spread ... | Поверхностное | Все | Нет | ⭐⭐⭐⭐⭐ |
| Object.assign() | Поверхностное | Все | Нет | ⭐⭐⭐⭐ |
| structuredClone() | Глубокое | Многие (кроме функций) | ✅ Да | ⭐⭐⭐⭐ |
| JSON методы | Глубокое | Только JSON-совместимые | ❌ Нет | ⭐⭐ |
| Рекурсивное | Глубокое | Настраиваемое | ✅ Да | ⭐⭐ |
| Lodash.cloneDeep() | Глубокое | Практически все | ✅ Да | ⭐⭐⭐ |
Best Practices и рекомендации
-
Для поверхностного копирования используйте spread оператор
{...obj}— он лаконичнее и читаемее, чемObject.assign(). -
Для глубокого копирования используйте
structuredClone()— это современный стандарт, который работает с большинством типов данных. -
Избегайте
JSON.parse(JSON.stringify())в новом коде — этот метод имеет слишком много ограничений и должен считаться устаревшим. -
Для объектов с функциями или особыми требованиями используйте кастомную функцию клонирования или библиотеки типа Lodash.
-
Всегда проверяйте необходимость глубокого копирования — часто поверхностной копии достаточно, и она значительно производительнее.
-
Для иммутабельных обновлений в React/Redux часто достаточно поверхностного копирования с spread оператором:
// Имтабельное обновление состояния в React
setUser(prevUser => ({
...prevUser,
profile: {
...prevUser.profile,
address: {
...prevUser.profile.address,
city: 'New York'
}
}
}));
Вывод: Выбор метода копирования зависит от конкретной задачи. Для большинства случаев поверхностного копирования через spread оператор и глубокого через structuredClone() достаточно. Специфические случаи требуют специализированных решений.
Вопрос 19. В чём разница между получением элемента массива через квадратные скобки и методом at()?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:17:31"/>
Ответ собеседника: Правильный. Основное отличие - метод at() поддерживает отрицательные индексы для обращения к элементам с конца массива.
Правильный ответ:
Ответ кандидата верен, но описывает лишь самое очевидное отличие. Давайте проведём детальное сравнение этих двух подходов, рассмотрев все аспекты их поведения, производительности и использования.
Основные различия
| Характеристика | Квадратные скобки array[index] | Метод array.at(index) |
|---|---|---|
| Синтаксис | Оператор доступа по индексу | Метод массива |
| Отрицательные индексы | Не поддерживает (возвращает undefined) | Поддерживает (обращение с конца) |
| Положительные индексы | array[0] → первый элемент | array.at(0) → первый элемент |
| Несуществующие индексы | array[100] → undefined | array.at(100) → undefined |
| Дробные индексы | array[1.5] → undefined | array.at(1.5) → array[1] (округление) |
| Читаемость для последних элементов | array[array.length - 1] | array.at(-1) |
| Производительность | Выше (нативный оператор) | Немного ниже (вызов метода) |
| Поддержка браузерами | Всегда | ES2022+ (современные браузеры) |
Детальное сравнение с примерами
1. Работа с отрицательными индексами
Это ключевое различие, которое делает at() значительно удобнее для работы с концом массива.
const fruits = ['apple', 'banana', 'orange', 'grape'];
// Доступ через квадратные скобки
const lastFruitBrackets = fruits[fruits.length - 1]; // 'grape'
const secondLastBrackets = fruits[fruits.length - 2]; // 'orange'
// Доступ через at()
const lastFruitAt = fruits.at(-1); // 'grape'
const secondLastAt = fruits.at(-2); // 'orange'
const thirdLastAt = fruits.at(-3); // 'banana'
// Отрицательные индексы в квадратных скобках не работают
console.log(fruits[-1]); // undefined
console.log(fruits[-2]); // undefined
2. Поведение при неверных индексах
Оба метода возвращают undefined для индексов вне границ массива, но с разной логикой для отрицательных значений:
const numbers = [10, 20, 30];
// За пределами массива - поведение одинаковое
console.log(numbers[5]); // undefined
console.log(numbers.at(5)); // undefined
// Отрицательные индексы за пределами массива
console.log(numbers[-4]); // undefined (просто несуществующий индекс)
console.log(numbers.at(-4)); // undefined (индекс -4 = length(3) + (-4) = -1 → невалидный)
3. Обработка дробных индексов
Метод at() автоматически преобразует дробные индексы в целые числа:
const array = ['a', 'b', 'c', 'd'];
// Квадратные скобки - дробные индексы не работают
console.log(array[1.3]); // undefined
console.log(array[1.9]); // undefined
// Метод at() - округляет до целого
console.log(array.at(1.3)); // 'b' (округляется до 1)
console.log(array.at(1.9)); // 'b' (округляется до 1)
console.log(array.at(-1.7)); // 'd' (округляется до -1)
4. Использование со строками
Оба подхода работают со строками, но с теми же различиями:
const message = 'Hello';
// Квадратные скобки
console.log(message[0]); // 'H'
console.log(message[message.length - 1]); // 'o'
console.log(message[-1]); // undefined
// Метод at()
console.log(message.at(0)); // 'H'
console.log(message.at(-1)); // 'o'
console.log(message.at(-2)); // 'l'
5. Производительность
Доступ через квадратные скобки является нативным оператором языка и работает быстрее, чем вызов метода at(). Однако в большинстве практических случаев эта разница незначительна и не должна влиять на выбор метода.
Рекомендации по использованию
Используйте квадратные скобки [] когда:
- Работаете с известными положительными индексами
- Нужна максимальная производительность в критических участках кода
- Пишете код, который должен работать в старых браузерах
Используйте метод at() когда:
- Нужен доступ к элементам с конца массива
- Важен читаемый и выразительный код
- Работаете с индексами, которые могут быть отрицательными
- Целевые браузеры поддерживают ES2022+
Практические примеры выбора
// Ситуация 1: Итерация по массиву (лучше квадратные скобки)
for (let i = 0; i < array.length; i++) {
console.log(array[i]); // array[i] - идиоматично и быстро
}
// Ситуация 2: Получение последнего элемента (лучше at())
const lastElement = array.at(-1); // Читаемо и понятно
// Ситуация 3: Безопасный доступ к возможному undefined
const value = maybeArray?.at?.(index); // Опциональная цепочка с методом
Полифиллы для старых браузеров
Для поддержки at() в старых окружениях можно использовать полифилл:
// Полифилл для Array.prototype.at
if (!Array.prototype.at) {
Array.prototype.at = function(index) {
// Преобразование отрицательных индексов
const n = Math.trunc(index) || 0;
const relativeIndex = n < 0 ? this.length + n : n;
// Проверка границ массива
if (relativeIndex < 0 || relativeIndex >= this.length) {
return undefined;
}
return this[relativeIndex];
};
}
// Полифилл для String.prototype.at
if (!String.prototype.at) {
String.prototype.at = function(index) {
const n = Math.trunc(index) || 0;
const relativeIndex = n < 0 ? this.length + n : n;
if (relativeIndex < 0 || relativeIndex >= this.length) {
return undefined;
}
return this[relativeIndex];
};
}
Заключение
Метод at() не заменяет квадратные скобки, а дополняет их, предоставляя более удобный способ работы с отрицательными индексами. Выбор между ними зависит от конкретной задачи:
array[index]— для обычного доступа по известным индексамarray.at(index)— для удобного доступа к элементам с конца массива и работы с относительной индексацией
Оба метода имеют свои законные use cases и могут использоваться вместе в одном проекте в зависимости от контекста.
Вопрос 20. Как работает Event Loop в JavaScript?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:18:21"/>
Ответ собеседника: Правильный. Event Loop - бесконечный цикл в браузере. Сначала выполняется весь синхронный код. Затем выполняются все микрозадачи (промисы, Mutation Observer). Затем выполняется одна макрозадача (DOM события, таймауты, интервалы). После этого может происходить рендеринг. Стек вызовов работает по принципу LIFO (Last In First Out).
Правильный ответ:
Ответ кандидата верно описывает базовый принцип работы Event Loop. Давайте рассмотрим эту концепцию более детально, включая архитектурные компоненты, приоритеты выполнения и практические последствия.
Архитектура Event Loop
Event Loop — это механизм, который позволяет JavaScript (однопоточному языку) выполнять неблокирующие операции ввода-вывода через использование очередей и цикла событий. Он состоит из нескольких ключевых компонентов:
1. Call Stack (Стек вызовов) Структура данных LIFO (Last In, First Out), которая отслеживает текущую выполняющуюся функцию. Каждый вызов функции помещается в стек, а при завершении — извлекается из него.
function a() {
b();
console.log('a');
}
function b() {
console.log('b');
}
a(); // Порядок выполнения: b -> a
2. Web APIs (Browser APIs)
API, предоставляемые браузером (или Node.js), которые работают асинхронно: setTimeout, setInterval, fetch, DOM events, XMLHttpRequest и др. Когда вызывается такая функция, она передаётся в соответствующее Web API, а основной поток продолжает выполнение.
3. Task Queue (Очередь макрозадач) Очередь FIFO (First In, First Out) для макрозадач (tasks). Сюда попадают колбэки от:
setTimeoutиsetInterval- DOM events (клики, скролл)
- I/O операции
requestAnimationFrame- UI rendering
4. Microtask Queue (Очередь микрозадач) Очередь с высшим приоритетом для микрозадач. Сюда попадают колбэки от:
- Промисы (
Promise.then/catch/finally) queueMicrotask()MutationObserver
Алгоритм работы Event Loop
-
Выполнение синхронного кода: Event Loop начинает с выполнения всего синхронного кода из стека вызовов.
-
Обработка микрозадач: Когда стек вызовов пуст, Event Loop обрабатывает ВСЕ микрозадачи из очереди микрозадач до её полного опустошения.
-
Обработка макрозадачи: После полной обработки микрозадач Event Loop извлекает и выполняет ОДНУ макрозадачу из очереди макрозадач.
-
Рендеринг (при необходимости): После выполнения макрозадачи браузер может (но не обязательно) выполнить перерисовку (rendering), если есть изменения в DOM или стилях.
-
Повтор цикла: Процесс повторяется бесконечно.
Практические примеры
Пример 1: Базовый приоритет
console.log('Start');
setTimeout(() => console.log('Timeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
console.log('End');
// Вывод:
// Start
// End
// Promise (микрозадача)
// Timeout (макрозадача)
Пример 2: Вложенные микрозадачи
console.log('Start');
setTimeout(() => console.log('Timeout'));
Promise.resolve()
.then(() => {
console.log('Promise 1');
return Promise.resolve();
})
.then(() => console.log('Promise 2'));
console.log('End');
// Вывод:
// Start
// End
// Promise 1
// Promise 2 (новая микрозадача выполняется сразу)
// Timeout
Пример 3: Блокирующий код и Event Loop
console.log('Start');
setTimeout(() => console.log('Timeout'), 0);
// Синхронный блокирующий код
let i = 0;
while (i < 1000000000) i++;
console.log('End');
// Несмотря на таймаут 0ms, он выполнится только после завершения синхронного кода
Особенности для разных сред
В браузере:
- Есть фаза рендеринга после макрозадач
requestAnimationFrameвыполняется перед рендерингом- Разные вкладки имеют отдельные Event Loop
В Node.js:
- Нет фазы рендеринга
- Дополнительные фазы: timers, pending callbacks, idle/prepare, poll, check, close callbacks
process.nextTickимеет высший приоритет (выше микрозадач)
Проблемы и best practices
1. Не блокируйте Event Loop Длительные синхронные операции блокируют выполнение всего кода.
// Плохо
function heavyCalculation() {
for (let i = 0; i < 1000000000; i++) {
// Тяжёлые вычисления
}
}
// Лучше - разбить на части
async function optimizedCalculation() {
for (let i = 0; i < 1000000000; i++) {
if (i % 1000 === 0) {
await Promise.resolve(); // Дать возможность выполниться другим задачам
}
// Вычисления
}
}
2. Микрозадачи могут "голодать" макрозадачи Если постоянно создавать новые микрозадачи, макрозадачи никогда не выполнятся.
function infiniteMicrotasks() {
Promise.resolve().then(infiniteMicrotasks);
}
// Браузер зависнет - макрозадачи (включая рендеринг) не смогут выполниться
3. Правильное использование таймаутов
setTimeout(callback, 0) не гарантирует мгновенное выполнение, а лишь помещает колбэк в очередь макрозадач.
Визуализация работы Event Loop
[Синхронный код] → [Стек] → [Выполнение]
↓
[Web APIs] → [Макрозадачи] → [Микрозадачи]
↓ ↓ ↓
[Event Loop] ← [Одна макрозадача] ← [Все микрозадачи]
↓
[Рендеринг]
Отладка и мониторинг
Для отладки асинхронного кода полезны:
- Chrome DevTools: Performance tab, Console
console.trace()для трассировки стека вызововperformance.now()для измерения времени выполнения
Заключение
Понимание Event Loop критически важно для:
- Оптимизации производительности веб-приложений
- Предотвращения блокировки интерфейса
- Правильной работы с асинхронными операциями
- Отладки сложных сценариев выполнения кода
Event Loop — это фундаментальный механизм, который делает возможной асинхронность в JavaScript, позволяя эффективно управлять задачами без блокировки основного потока выполнения.
Вопрос 21. Что выведет консоль при выполнении асинхронного кода с setTimeout и промисами?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:20:29"/>
Ответ собеседника: Правильный. Кандидат правильно определяет порядок выполнения: сначала синхронный код (1, 3), затем микрозадачи (4), затем макрозадачи (2), затем снова микрозадачи (6), и последняя макрозадача (7).
Правильный ответ:
Рассмотрим классический пример, демонстрирующий взаимодействие Event Loop, макрозадач (setTimeout) и микрозадач (Promise):
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => console.log(6));
}, 0);
Promise.resolve().then(() => console.log(4));
setTimeout(() => console.log(7), 0);
Promise.resolve().then(() => console.log(5));
console.log(3);
Пошаговый разбор выполнения
Фаза 1: Выполнение синхронного кода Event Loop начинает с выполнения всего синхронного кода из стека вызовов.
console.log(1)— синхронная операция, выводит1setTimeout(() => { console.log(2); ... }, 0)— регистрирует макрозадачу в Web APIPromise.resolve().then(() => console.log(4))— регистрирует микрозадачуsetTimeout(() => console.log(7), 0)— регистрирует вторую макрозадачуPromise.resolve().then(() => console.log(5))— регистрирует вторую микрозадачуconsole.log(3)— синхронная операция, выводит3
Текущий вывод:
1
3
Состояние очередей после синхронной фазы:
- Очередь микрозадач:
[() => console.log(4), () => console.log(5)] - Очередь макрозадач:
[() => {console.log(2); ...}, () => console.log(7)]
Фаза 2: Обработка микрозадач Стек пуст, Event Loop обрабатывает ВСЕ микрозадачи из очереди микрозадач:
- Первая микрозадача:
() => console.log(4)— выводит4 - Вторая микрозадача:
() => console.log(5)— выводит5
Текущий вывод:
1
3
4
5
Фаза 3: Обработка первой макрозадачи Event Loop извлекает и выполняет ОДНУ макрозадачу из очереди:
- Первая макрозадача:
() => { console.log(2); Promise.resolve().then(() => console.log(6)); }console.log(2)— выводит2Promise.resolve().then(() => console.log(6))— регистрирует новую микрозадачу
Текущий вывод:
1
3
4
5
2
Состояние очередей после первой макрозадачи:
- Очередь микрозадач:
[() => console.log(6)](новая микрозадача) - Очередь макрозадач:
[() => console.log(7)]
Фаза 4: Обработка новой микрозадачи Перед следующей макрозадачей Event Loop проверяет очередь микрозадач и выполняет ВСЕ найденные там задачи:
- Микрозадача:
() => console.log(6)— выводит6
Текущий вывод:
1
3
4
5
2
6
Фаза 5: Обработка второй макрозадачи Event Loop извлекает и выполняет следующую макрозадачу:
- Вторая макрозадача:
() => console.log(7)— выводит7
Итоговый вывод:
1
3
4
5
2
6
7
Визуализация цикла событий
[Начало]
↓
[Синхронный код: 1, 3]
↓
[Микрозадачи: 4, 5]
↓
[Макрозадача 1: 2]
↓ ↓
[Новая микрозадача: 6] ← [регистрируется внутри макрозадачи]
↓
[Макрозадача 2: 7]
↓
[Конец]
Ключевые принципы, демонстрируемые этим примером:
-
Приоритет микрозадач над макрозадачами: Все микрозадачи выполняются перед следующей макрозадачей.
-
Микрозадачи обрабатываются до полного опустошения очереди: Когда Event Loop начинает обрабатывать микрозадачи, он выполняет их все, прежде чем перейти к макрозадачам.
-
Новые микрозадачи, созданные во время выполнения макрозадачи, выполняются сразу после неё: Микрозадача, созданная внутри макрозадачи, добавляется в очередь микрозадач и выполняется перед следующей макрозадачей.
-
setTimeout с нулевой задержкой не гарантирует немедленного выполнения: Он лишь помещает колбэк в очередь макрозадач, который будет выполнен после всех текущих микрозадач.
Вариации и edge cases
Если добавить больше вложенных промисов:
console.log('A');
setTimeout(() => {
console.log('B');
Promise.resolve()
.then(() => {
console.log('C');
Promise.resolve().then(() => console.log('D'));
})
.then(() => console.log('E'));
}, 0);
console.log('F');
// Вывод: A F B C D E
// D выполняется сразу после C, так как это новая микрозадача
Если микрозадачи создают новые микрозадачи:
Promise.resolve().then(() => {
console.log(1);
Promise.resolve().then(() => console.log(2));
}).then(() => console.log(3));
// Вывод: 1 2 3
// Микрозадачи обрабатываются до полного опустошения очереди
Практическое значение
Понимание этого порядка выполнения критически важно для:
- Правильной работы с асинхронными операциями
- Избежания race conditions
- Оптимизации производительности
- Отладки сложных асинхронных сценариев
Вывод: Event Loop в JavaScript гарантирует предсказуемый порядок выполнения: синхронный код → все микрозадачи → одна макрозадача → все новые микрозадачи → следующая макрозадача. Это знание помогает писать корректный асинхронный код и понимать его поведение.
Вопрос 22. Что выведет консоль при выполнении кода с промисами и обработкой ошибок?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:21:54"/>
Ответ собеседника: Правильный. Кандидат правильно анализирует: после резолва промиса код переходит в catch блок из-за ошибки. Правильно определяет последовательность вывода: 1, 4, 5.
Правильный ответ:
Рассмотрим пример, демонстрирующий важные аспекты цепочек промисов и обработки ошибок:
Promise.resolve()
.then(() => {
console.log(1);
throw new Error('Ошибка в then');
})
.catch(() => {
console.log(4);
return 'Значение из catch';
})
.then((value) => {
console.log(5, value);
});
Пошаговый анализ выполнения
Шаг 1: Создание и немедленный резолв промиса
Promise.resolve() создаёт промис, который немедленно переходит в состояние fulfilled со значением undefined.
Шаг 2: Выполнение первого обработчика .then()
.then(() => {
console.log(1); // 1. Выводит 1
throw new Error('Ошибка в then'); // 2. Выбрасывает ошибку
})
- Промис выполнен, поэтому обработчик
thenпомещается в очередь микрозадач. - При выполнении:
console.log(1)выводит1throw new Error(...)выбрасывает ошибку
Важный момент: Когда в обработчике then выбрасывается ошибка, промис, возвращаемый этим then, переходит в состояние rejected с этой ошибкой.
Шаг 3: Обработка ошибки в .catch()
.catch(() => {
console.log(4); // 3. Выводит 4
return 'Значение из catch'; // 4. Возвращает значение
})
Поскольку предыдущий промис был отклонён (rejected), выполняется обработчик catch:
console.log(4)выводит4return 'Значение из catch'возвращает значение
Ключевое правило: Если обработчик catch успешно завершается (без выброса ошибки), он возвращает промис в состоянии fulfilled с возвращённым значением.
Шаг 4: Выполнение следующего .then()
.then((value) => {
console.log(5, value); // 5. Выводит 5 и значение
})
Поскольку catch вернул успешный промис, выполняется следующий обработчик then:
console.log(5, value)выводит5и значение'Значение из catch'
Итоговый вывод:
1
4
5 Значение из catch
Ключевые принципы, демонстрируемые этим примером:
1. Цепочка промисов продолжается после catch
Обработчик catch не прерывает цепочку промисов. Если он успешно завершается (без выброса новой ошибки), следующий then в цепочке будет выполнен.
2. catch обрабатывает ошибки из любого предыдущего промиса в цепочке
Ошибка, выброшенная в любом then (или даже в синхронном коде, который резолвит промис), будет перехвачена ближайшим catch в цепочке.
3. Возвращаемое значение из catch становится значением для следующего then
Promise.reject('начальная ошибка')
.catch(error => {
console.log('Поймана:', error); // "Поймана: начальная ошибка"
return 'восстановленное значение';
})
.then(value => {
console.log(value); // "восстановленное значение"
});
Важные вариации
Вариант 1: Ошибка в catch прерывает цепочку
Promise.resolve()
.then(() => {
throw new Error('Первая ошибка');
})
.catch(() => {
console.log('Поймана первая');
throw new Error('Ошибка в catch'); // Новая ошибка
})
.then(() => {
console.log('Этот then не выполнится');
})
.catch((error) => {
console.log('Поймана вторая:', error.message);
});
// Вывод:
// Поймана первая
// Поймана вторая: Ошибка в catch
Вариант 2: catch в середине цепочки
Promise.resolve()
.then(() => {
console.log(1);
return 'значение';
})
.then((value) => {
console.log(2, value);
throw new Error('Ошибка после 2');
})
.catch(() => {
console.log(3);
return 'новое значение';
})
.then((value) => {
console.log(4, value);
});
// Вывод:
// 1
// 2 значение
// 3
// 4 новое значение
Вариант 3: Несколько catch для разных типов ошибок
Promise.resolve()
.then(() => {
throw new TypeError('TypeError');
})
.catch(error => {
if (error instanceof TypeError) {
console.log('Обработана TypeError');
return 'восстановлено после TypeError';
}
throw error; // Пробрасываем другие ошибки дальше
})
.then(value => {
console.log('Продолжаем:', value);
})
.catch(error => {
console.log('Другие ошибки:', error.message);
});
// Вывод:
// Обработана TypeError
// Продолжаем: восстановлено после TypeError
Практические применения
1. Graceful degradation (Плавная деградация)
fetch('/api/data')
.then(response => {
if (!response.ok) throw new Error('HTTP error');
return response.json();
})
.catch(error => {
console.warn('Не удалось загрузить данные:', error);
return getCachedData(); // Возвращаем закешированные данные
})
.then(data => {
renderUI(data); // UI получит либо свежие, либо кешированные данные
});
2. Последовательная обработка с восстановлением
processUserInput(input)
.then(validateInput)
.then(sendToServer)
.catch(validationError => {
console.log('Ошибка валидации:', validationError);
return getDefaultValue();
})
.then(value => {
// Продолжаем с валидным значением
return processValue(value);
})
.catch(otherError => {
console.error('Критическая ошибка:', otherError);
});
3. Логирование ошибок без прерывания потока
asyncOperation()
.then(result => {
// Основная логика
return transform(result);
})
.catch(error => {
// Логируем, но продолжаем цепочку
console.error('Ошибка в asyncOperation:', error);
return null; // или значение по умолчанию
})
.then(processedResult => {
// Этот код выполнится даже если была ошибка
if (processedResult) {
useResult(processedResult);
}
});
Best Practices
- Всегда возвращайте значения из
catch, если хотите продолжить цепочку. - Используйте конкретные проверки ошибок в
catchдля разных типов ошибок. - Не забывайте о
finallyдля кода, который должен выполниться в любом случае:Promise.resolve().then(() => { /* ... */ }).catch(() => { /* ... */ }).finally(() => {console.log('Выполнится в любом случае');}); - Избегайте вложенных
catch, используйте цепочки для лучшей читаемости.
Вывод: Понимание того, как catch влияет на поток выполнения в цепочке промисов, критически важно для написания надёжного асинхронного кода. Обработчик catch не прерывает выполнение, а позволяет восстановиться после ошибок и продолжить обработку данных, что делает промисы мощным инструментом для обработки асинхронных операций.
Вопрос 23. Как вы относитесь к типу any в TypeScript и чем unknown отличается от any?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:23:04"/>
Ответ собеседника: Правильный. any следует избегать и использовать только в крайних случаях, так как он отключает типизацию и лишает преимуществ TypeScript. unknown более строгий - не позволяет обращаться к свойствам объекта или вызывать переменную как функцию без явного определения типа, что предотвращает потенциальные ошибки.
Правильный ответ:
Ответ кандидата точно отражает основную идею. Давайте углубимся в различия, практические последствия и лучшие практики работы с этими типами.
Тип any — отключение системы типов
any — это тип, который отключает проверку типов TypeScript для переменной. По сути, это способ сказать компилятору: "Не проверяй тип этой переменной, я сам знаю, что делаю".
Проблемы с any:
- Потеря безопасности типов: TypeScript не будет проверять операции с переменной типа
any. - Цепная реакция: Одна переменная
anyможет "заразить" типы других переменных. - Потеря автодополнения: IDE не сможет предоставить подсказки для свойств и методов.
- Скрытие реальных ошибок: Ошибки, которые TypeScript мог бы поймать, останутся незамеченными.
let data: any = "hello";
// TypeScript разрешит эти операции, хотя они приведут к ошибкам времени выполнения
data.nonExistentMethod(); // No error at compile time, runtime error
data = 42;
data.toUpperCase(); // No error at compile time, runtime error
// Цепная реакция - функция теряет информацию о типах
function processValue(value: any) {
return value * 2; // Неизвестно, можно ли умножать value
}
Когда (очень редко) можно использовать any:
- Миграция с JavaScript на TypeScript
- Работа со сторонними библиотеками без типов
- Временное решение при прототипировании
Тип unknown — безопасная альтернатива
unknown — это тип, который означает "значение неизвестного типа". Он более безопасен, чем any, потому что TypeScript заставляет вас проверять тип перед использованием.
Ключевые ограничения unknown:
- Нельзя присвоить другим типам без проверки типа
- Нельзя обращаться к свойствам без проверки типа
- Нельзя вызывать как функцию без проверки типа
- Нельзя использовать операторы без проверки типа
let data: unknown = "hello";
// Все эти операции вызовут ошибки компиляции:
// data.toUpperCase(); // Error: Object is of type 'unknown'
// data * 2; // Error: Object is of type 'unknown'
// const str: string = data; // Error: Type 'unknown' is not assignable to type 'string'
// Правильное использование - с проверкой типа
if (typeof data === "string") {
data.toUpperCase(); // OK - TypeScript знает, что data это string
}
if (typeof data === "number") {
data * 2; // OK
}
Сравнительная таблица
| Характеристика | any | unknown |
|---|---|---|
| Присваивание | Можно присвоить любой тип | Можно присвоить только unknown и any |
| Присваивание другим типам | Можно без проверки | Требуется проверка типа |
| Доступ к свойствам | Разрешён без проверки | Запрещён без проверки |
| Вызов как функции | Разрешён без проверки | Запрещён без проверки |
| Безопасность | Низкая | Высокая |
| Использование | Следует избегать | Рекомендуется для неизвестных типов |
Практические примеры использования unknown
1. Функции, обрабатывающие данные неизвестного типа
function safeStringify(data: unknown): string {
if (typeof data === "string") {
return data;
}
return JSON.stringify(data);
}
function processUserInput(input: unknown): void {
if (typeof input === "object" && input !== null && "name" in input) {
console.log(`Hello, ${(input as { name: string }).name}`);
} else {
console.log("Invalid input");
}
}
2. Обработка данных из внешних источников (API, localStorage)
async function fetchData(url: string): Promise<unknown> {
const response = await fetch(url);
return response.json();
}
// Использование с проверкой типа
const data = await fetchData("/api/user");
if (isUser(data)) { // type guard
console.log(data.name); // Безопасный доступ
}
// Type guard функция
function isUser(obj: unknown): obj is { name: string; age: number } {
return typeof obj === "object" &&
obj !== null &&
"name" in obj &&
"age" in obj;
}
3. Обёртки для функций с any
// Вместо этого:
function dangerousFunction(): any { /* ... */ }
// Используйте это:
function safeFunction(): unknown { /* ... */ }
4. Работа с ошибками в catch-блоках
try {
// какой-то код
} catch (error: unknown) {
if (error instanceof Error) {
console.error(error.message);
} else {
console.error("Unknown error occurred");
}
}
Миграция с any на unknown
// Старая версия (опасная)
function parseConfig(config: any): Config {
return {
name: config.name, // Может быть undefined
timeout: config.timeout // Может быть string вместо number
};
}
// Новая версия (безопасная)
function parseConfigSafe(config: unknown): Config {
if (typeof config !== "object" || config === null) {
throw new Error("Invalid config");
}
const configObj = config as Record<string, unknown>;
if (typeof configObj.name !== "string") {
throw new Error("Name must be a string");
}
if (typeof configObj.timeout !== "number") {
throw new Error("Timeout must be a number");
}
return {
name: configObj.name,
timeout: configObj.timeout
};
}
Правила использования
Используйте unknown когда:
- Тип значения действительно неизвестен (данные из API, пользовательский ввод)
- Нужно безопасно работать с динамическими данными
- Создаёте библиотечные функции, которые могут принимать различные типы
Избегайте any везде, где это возможно:
- Включайте strict mode в tsconfig.json
- Используйте
@ts-expect-errorдля точечного отключения проверки - Рассмотрите использование
unknownили более конкретных типов
Настройки TypeScript для предотвращения использования any
{
"compilerOptions": {
"noImplicitAny": true,
"strict": true,
"noExplicitAny": true // Запрещает явное использование any
}
}
Вывод: unknown — это типобезопасная альтернатива any, которая сохраняет преимущества TypeScript, заставляя разработчика явно проверять типы перед использованием значений. В современных TypeScript проектах следует отдавать предпочтение unknown везде, где тип данных не может быть гарантирован на этапе компиляции.
Вопрос 24. Как использовать методы строки для переменной типа unknown в TypeScript?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:24:29"/>
Ответ собеседника: Правильный. Можно использовать type guard (if typeof variable === 'string') или явное приведение типа через as string.
Правильный ответ:
Ответ кандидата верен и охватывает два основных подхода. Давайте детально рассмотрим оба метода, их преимущества, недостатки и лучшие практики применения.
1. Type Guards (Страж типа) — рекомендуемый подход
Type Guard — это проверка, которая позволяет TypeScript сузить тип переменной в определённой области видимости.
Базовый type guard с typeof:
function processInput(input: unknown): void {
if (typeof input === 'string') {
// TypeScript теперь знает, что input - string
console.log(input.toUpperCase()); // OK
console.log(input.length); // OK
console.log(input.slice(0, 5)); // OK
} else {
console.log('Input is not a string');
}
}
Пользовательские type guards: Для сложных проверок можно создавать собственные функции-стражей.
// Пользовательский type guard
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function processData(data: unknown): void {
if (isString(data)) {
// TypeScript знает, что data - string
console.log(data.trim());
console.log(data.includes('search'));
}
}
2. Type Assertion (Приведение типа) — с осторожностью
Явное приведение типа говорит компилятору: "Я знаю лучше, trust me".
Синтаксис с as:
function riskyProcessing(input: unknown): void {
const str = input as string; // Приведение типа
console.log(str.toUpperCase()); // Компилятор разрешит, но это небезопасно
}
Двойное приведение для дополнительной безопасности:
function saferProcessing(input: unknown): void {
// Двойное приведение с проверкой
if (typeof input === 'string' || typeof input === 'number') {
const str = input as string;
console.log(str.toUpperCase());
}
}
Сравнение подходов
| Подход | Безопасность | Читаемость | Рекомендация |
|---|---|---|---|
| Type Guard | Высокая | Высокая | ✅ Предпочтительный метод |
| Type Assertion | Низкая | Средняя | ⚠️ Только когда уверены на 100% |
Практические примеры
Пример 1: Обработка данных из API
interface ApiResponse {
data: unknown;
}
function handleResponse(response: ApiResponse): void {
if (typeof response.data === 'string') {
// Безопасная работа со строкой
const processed = response.data
.trim()
.toLowerCase()
.replace(/\s+/g, '-');
console.log(processed);
}
}
Пример 2: Работа с ошибками
try {
// какой-то код
} catch (error: unknown) {
if (error instanceof Error) {
// Обработка стандартной ошибки
console.log(error.message.toUpperCase());
} else if (typeof error === 'string') {
// Обработка строковой ошибки
console.log(error.toUpperCase());
} else {
console.log('Unknown error type');
}
}
Пример 3: Комплексная валидация
function isStringOrStringArray(value: unknown): value is string | string[] {
if (typeof value === 'string') return true;
if (Array.isArray(value)) {
return value.every(item => typeof item === 'string');
}
return false;
}
function processContent(content: unknown): void {
if (isStringOrStringArray(content)) {
if (typeof content === 'string') {
console.log(content.toUpperCase());
} else {
content.forEach(str => console.log(str.toUpperCase()));
}
}
}
Пример 4: Работа с union типами
type PossibleInput = string | number | boolean | null;
function handleInput(input: PossibleInput): void {
if (typeof input === 'string') {
console.log(input.padStart(10, ' ')); // Методы строки
} else if (typeof input === 'number') {
console.log(input.toFixed(2)); // Методы числа
}
// boolean и null не имеют интересных методов
}
Ошибки, которых следует избегать
1. Небезопасное приведение типа:
// ❌ ОПАСНО - может привести к runtime ошибке
function unsafeMethod(value: unknown): void {
const str = value as string;
str.toUpperCase(); // Может упасть, если value не строка
}
2. Неполные проверки:
// ❌ Неполная проверка
function incompleteCheck(value: unknown): void {
if (value) { // Не проверяет тип!
const str = value as string;
str.toUpperCase(); // Все ещё опасно
}
}
3. Излишне сложные проверки:
// ❌ Излишне сложно
function overcomplicated(value: unknown): void {
if (typeof value === 'string' ||
(typeof value === 'object' && value !== null && 'toString' in value)) {
// Слишком сложно, лучше использовать type assertion
const str = value as string;
str.toUpperCase();
}
}
Best Practices
1. Всегда предпочитайте type guards assertion'ам:
// ✅ Хорошо
if (typeof value === 'string') {
value.toUpperCase();
}
// ❌ Плохо (если нет абсолютной уверенности)
(value as string).toUpperCase();
2. Создавайте reusable type guards:
// ✅ Переиспользуемый страж
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
3. Используйте правильные проверки для разных типов:
function handleUnknown(value: unknown): void {
if (typeof value === 'string') {
// Строковые методы
value.trim();
} else if (typeof value === 'number') {
// Числовые методы
value.toFixed();
} else if (Array.isArray(value)) {
// Методы массива
value.length;
} else if (value instanceof Date) {
// Методы Date
value.getTime();
}
}
4. Для сложных объектов используйте type predicates:
interface User {
name: string;
age: number;
}
function isUser(obj: unknown): obj is User {
return typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
'age' in obj &&
typeof (obj as User).name === 'string' &&
typeof (obj as User).age === 'number';
}
5. Комбинируйте подходы для сложных сценариев:
function processComplexData(data: unknown): string {
// Первый уровень - тип
if (typeof data !== 'string' && typeof data !== 'number') {
throw new Error('Invalid type');
}
// Второй уровень - приведение с проверкой
const str = data.toString();
// Третий уровень - работа как со строкой
return str.toUpperCase();
}
Вывод: Для работы с методами строки для переменной типа unknown в TypeScript рекомендуется использовать type guards (typeof value === 'string') как основной подход, так как он обеспечивает безопасность типов во время выполнения. Type assertion (as string) следует использовать только в случаях, когда вы абсолютно уверены в типе значения, и всегда комбинировать его с проверками для минимизации рисков runtime ошибок.
Вопрос 25. В чём разница между type и interface в TypeScript?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:25:33"/>
Ответ собеседника: Правильный. type может типизировать любые типы данных (примитивы, юнионы, объекты, функции). interface в основном для объектов/классов, не позволяет типизировать примитивы. Интерфейсы расширяются через extends, типы комбинируются через &. При объявлении интерфейсов с одинаковыми именами они объединяются, type выдаст ошибку.
Правильный ответ:
Ответ кандидата точен и охватывает ключевые различия. Давайте разберём их системно, включая архитектурные последствия, практические сценарии и рекомендации по использованию.
1. Область применения
type — универсальный тип-конструктор
- Может определять любой тип данных в TypeScript.
- Подходит для примитивов, юнионов, кортежей, литералов, функций, частей объектов, а также сложных комбинаций.
// Примитивы
type Status = 'active' | 'inactive' | 'pending';
// Литералы
type Color = 'red' | 'green' | 'blue';
// Кортежи
type Point = [number, number];
// Функции
type Calculator = (a: number, b: number) => number;
// Объединения (union)
type ID = string | number;
// Объекты (как interface)
type User = {
id: number;
name: string;
};
// Условные типы
type IsString<T> = T extends string ? true : false;
interface — специализирован для объектов и классов
- Дизайнирован для описания структур объектов и интерфейсов классов.
- Не поддерживает примитивы, литералы, юнионы напрямую как основной синтаксис.
// Только объекты и классы
interface User {
id: number;
name: string;
email?: string; // опциональное свойство
}
interface Admin extends User {
role: 'admin';
}
// Не работает:
// interface Status = 'active' | 'inactive'; // ❌ SyntaxError
2. Расширение и объединение
interface — расширение через extends
- Позволяет наследовать и дополнять структуры.
- Поддерживает множественное наследование (через перечисление интерфейсов).
interface Base {
id: number;
}
interface Name {
name: string;
}
interface Age {
age: number;
}
// Наследование от нескольких интерфейсов
interface User extends Base, Name, Age {
email: string;
}
const user: User = {
id: 1,
name: 'Alice',
age: 30,
email: 'alice@example.com'
};
type — объединение через & (intersection)
- Объединяет типы, создавая пересечение свойств.
- Можно комбинировать любые типы — объекты, примитивы, функции.
type Base = { id: number };
type Name = { name: string };
type Age = { age: number };
// Пересечение типов
type User = Base & Name & Age;
const user: User = {
id: 1,
name: 'Alice',
age: 30
};
// Можно объединять с примитивами
type ID = string | number;
type Entity = { id: ID } & { createdAt: Date };
const entity: Entity = {
id: 'abc',
createdAt: new Date()
};
3. Мердж (Merge) — ключевое архитектурное различие
interface — позволяет декларативное слияние
- При повторном объявлении интерфейса с тем же именем — они объединяются.
- Это мощный механизм для расширения типов из внешних библиотек.
// Библиотека 1
interface Window {
api: string;
}
// Ваш код
interface Window {
user: {
id: number;
name: string;
};
}
// Результат: Window имеет оба свойства
const window: Window = {
api: 'v1',
user: { id: 1, name: 'Alice' }
};
type — не поддерживает слияние
- Повторное объявление типа с тем же именем вызывает ошибку компиляции.
type User = { id: number };
type User = { name: string }; // ❌ Duplicate identifier 'User'
Почему это важно?
- Это позволяет расширять типы DOM-объектов, библиотек или сторонних модулей без изменения их исходного кода.
- Пример: расширение
RequestизfetchAPI для добавления кастомных свойств.
// Дополняем тип Request из @types/node или браузерного API
interface Request {
customHeader: string;
}
4. Производительность и компиляция
interfaceобрабатывается компилятором как структурный тип, и его расширения оптимизируются.typeс&может создавать сложные типы, которые приводят к глубоким рекурсивным вычислениям, особенно при комбинировании вложенных объектов — это может замедлить типовую проверку в больших проектах.
// Потенциально медленный тип
type DeepNested = {
a: {
b: {
c: {
d: {
e: string;
};
};
};
};
} & {
f: {
g: {
h: string;
};
};
};
5. Использование в классах
interface — идеально для описания контрактов классов
- Определяет, какие методы и свойства должны быть реализованы.
interface Drawable {
draw(): void;
}
class Circle implements Drawable {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
draw() { // ✅ Обязательно реализовать
console.log('Drawing circle');
}
}
type — не может использоваться в implements
typeне является интерфейсом, и его нельзя использовать для реализации.
type DrawableType = {
draw(): void;
};
class Circle implements DrawableType { // ❌ Error: 'DrawableType' only refers to a type, but is being used as a value here.
draw() {}
}
6. Рекомендации по выбору
| Ситуация | Используйте |
|---|---|
| Описание объекта/интерфейса для класса | interface |
| Расширение существующих типов (DOM, библиотек) | interface |
| Определение юнионов, литералов, примитивов | type |
| Определение кортежей, функций, условных типов | type |
| Комбинирование нескольких типов (пересечение) | type |
| Планируете расширять тип в будущем в разных файлах | interface |
| Используете строгий код (strict mode) и хотите предотвратить случайное дублирование | type |
7. Современный подход: когда что использовать
Рекомендация от TypeScript-сообщества и крупных проектов (React, Angular, NestJS):
> ✅ Используйте interface для описания структур объектов и контрактов.
> ✅ Используйте type для всего остального: юнионов, литералов, функций, пересечений.
Пример типичного проекта:
// ✅ Правильно: interface для объектов
interface User {
id: number;
name: string;
email: string;
}
interface ApiResponse<T> {
data: T;
status: number;
timestamp: Date;
}
// ✅ Правильно: type для юнионов и сложных типов
type UserRole = 'admin' | 'user' | 'guest';
type ApiResponseWithUser = ApiResponse<User>;
type UserWithRole = User & { role: UserRole };
// ✅ Правильно: type для функций
type Handler = (event: Event) => void;
// ✅ Правильно: interface для расширения
interface Window {
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: any;
}
Вывод:
interface и type — не конкурирующие, а дополняющие средства.
interface — ваш инструмент для структурных контрактов, особенно когда нужны расширения и наследование.
type — ваш инструмент для любых других типов, включая примитивы, юнионы и сложные комбинации.
Выбор между ними не влияет на производительность выполнения — только на читаемость, поддерживаемость и архитектуру проекта.
Вопрос 26. В каком случае объединение интерфейсов с одинаковыми именами может вызвать проблемы?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:26:49"/>
Ответ собеседника: Правильный. Если в одном файле объявить два интерфейса с одинаковыми именами, они объединятся. TypeScript не выдаст ошибку при обращении к несуществующему свойству, которое появилось из-за объединения, но в браузере может упасть ошибка.
Правильный ответ:
Ответ кандидата верно указывает на фундаментальную особенность интерфейсов — их автоматическое слияние (merging) — и на потенциальную опасность. Однако проблема гораздо глубже, чем просто «упадёт в браузере». Давайте разберём сценарии, где это может вызвать катастрофические последствия, и почему TypeScript не предотвращает их на этапе компиляции.
Механизм слияния интерфейсов
В TypeScript интерфейсы с одинаковыми именами в одной области видимости автоматически объединяются. Это не ошибка — это запроектированная особенность, предназначенная для расширения типов, особенно в библиотеках и при работе с глобальными объектами.
// Файл: user.d.ts
interface User {
id: number;
name: string;
}
// Файл: admin.d.ts — в том же проекте
interface User {
role: 'admin';
}
Результат:
// TypeScript видит User как:
interface User {
id: number;
name: string;
role: 'admin';
}
Это работает хорошо, если вы управляете этим процессом.
Когда слияние интерфейсов становится проблемой?
Сценарий 1: Непреднамеренное слияние из-за опечатки или дублирования
Представьте, что два разработчика в разных файлах случайно объявляют интерфейс с одинаковым именем, но с разными свойствами — из-за опечатки, невнимательности или несогласованности в архитектуре.
// file1.ts
interface ApiResponse {
data: any;
status: number;
}
// file2.ts — разработчик ошибся и перепутал имя
interface ApiResponse {
data: any;
status: string; // ❌ Опечатка: ожидался number, а стал string
message: string; // ❌ Новое свойство, неожиданное для других модулей
}
Что происходит:
- TypeScript не выдаст ошибку — он объединит интерфейсы.
- Тип
ApiResponseтеперь требует:{data: any;status: number | string; // Union! Неожиданный типmessage: string; // Новое свойство, которое не было в исходном контракте}
Проблема:
- Код, который использовал
response.status === 200, теперь не будет работать, потому чтоstatusможет быть строкой. - Свойство
messageне было задокументировано — другие команды могут на него полагаться, но оно не существует в API. - В браузере или сервере — при попытке обратиться к
response.message, вы получитеundefined, и произойдёт runtime-ошибка, например:Cannot read property 'toLowerCase' of undefined.
> 🔥 Это не ошибка компиляции — это ошибка архитектуры, которая маскируется типовой системой.
Сценарий 2: Конфликт с типами библиотеки
Многие библиотеки (например, @types/react, @types/node) предоставляют типы, которые можно расширять. Но если вы случайно объявили интерфейс с таким же именем, вы переопределяете его.
// node_modules/@types/react/index.d.ts
interface CSSProperties {
color?: string;
fontSize?: string;
}
// Ваш файл: custom-types.ts — случайно переопределили
interface CSSProperties {
background: string; // Ожидаете это
// Но упустили color и fontSize!
}
Результат:
- Все свойства, кроме
background, исчезают из типаCSSProperties. - Компоненты, использующие
style={{ color: 'red' }}, теперь не компилируются:<div style={{ color: 'red' }} /> // ❌ Error: Property 'color' does not exist on type 'CSSProperties'
> 🔥 Вы не увидите ошибку в своём коде — TypeScript считает, что CSSProperties теперь имеет только background.
> Это сломает весь проект, использующий стили в React.
Сценарий 3: Динамическое расширение в разных модулях
Допустим, у вас есть модуль auth.ts:
// auth.ts
interface User {
id: number;
token: string;
}
А в profile.ts — другой разработчик:
// profile.ts
interface User {
avatarUrl: string;
bio: string;
}
Всё компилируется. Но в app.ts вы пишете:
function isTokenValid(user: User): boolean {
return !!user.token; // ✅ TypeScript считает, что token есть
}
// В рантайме: user — это объект из профиля, который НЕ содержит token!
// Потому что в момент выполнения загрузился только profile.ts, а auth.ts не импортирован.
Проблема:
- TypeScript не знает, в каком порядке будут загружены файлы в браузере.
- В Node.js — модули загружаются по
import, но еслиauth.tsне импортирован, тоtokenне существует. - TypeScript видит объединённый тип и думает, что
tokenвсегда есть. - В браузере —
user.token—undefined→!!undefined === false, но если вы делаетеuser.token.toUpperCase()—TypeError: Cannot read property 'toUpperCase' of undefined.
> 🔥 Типовая система даёт ложное чувство безопасности. Вы пишете код, который компилируется, но падает в рантайме.
Сценарий 4: Слияние с any или unknown
// Библиотека
interface Config {
apiUrl: string;
}
// Ваш код — неосторожное слияние
interface Config {
options: any; // ❌ Переписывает структуру
}
Теперь TypeScript не проверяет ничего внутри config.options. Вы можете писать:
config.options.someDeeplyNestedProperty.abc.def(); // ✅ Компилируется, но падает в браузере
> 🔥 Вы полностью отключаете типовую безопасность для части объекта.
Почему TypeScript не предотвращает это?
- Это — особенность, а не баг. Слияние интерфейсов было создано для расширения глобальных типов (
Window,Document,NodeJS.Global). - TypeScript не анализирует логику приложения — он только смотрит на структуру типов.
- Он не знает, что вы случайно создали конфликт — он считает, что вы намеренно расширяете тип.
Как избежать проблем?
| Мера | Описание |
|---|---|
Используйте type для локальных типов | Если тип не предназначен для расширения — используйте type. Он не сливаются. |
| Используйте уникальные имена | UserModel, UserResponse, UserProfile — вместо User в разных контекстах. |
| Избегайте глобального расширения | Не расширяйте Window, HTMLElement, Request без крайней необходимости. |
Включите noImplicitAny и strictNullChecks | Это не решит слияние, но снизит другие риски. |
| Используйте IDE-подсказки и "Go to Definition" | Нажмите Ctrl+Click на интерфейсе — увидите, откуда он пришёл. |
| Запретите дублирующиеся интерфейсы через ESLint | Используйте правило no-redeclare или кастомный ESLint-плагин. |
| Пишите документацию типов | Чётко определите, где и зачем используется каждый интерфейс. |
Лучшая практика: Когда использовать interface, а когда type
| Цель | Рекомендация |
|---|---|
| Описание контракта для классов, расширяемых типов | ✅ interface |
| Расширение библиотечных типов (DOM, Node.js) | ✅ interface |
| Локальные типы, не предназначенные для расширения | ✅ type |
| Юнионы, литералы, кортежи, функции | ✅ type |
Пересечения (&) | ✅ type |
| Случайные/непреднамеренные дубликаты | ❌ Избегайте interface — используйте type |
Вывод
Объединение интерфейсов с одинаковыми именами — не ошибка компиляции, а скрытая угроза безопасности типов. Она может привести к:
- Потере свойств
- Появлению несуществующих свойств
- Неверным типам (например,
string | numberвместоnumber) - Runtime-ошибкам в браузере или сервере, даже если код компилируется без предупреждений
TypeScript не спасает вас от архитектурных ошибок — он лишь делает их более скрытыми.
Чтобы избежать катастроф, используйте type для локальных, не расширяемых типов, и будьте предельно осторожны при расширении interface в глобальном или библиотечном контексте.
Вопрос 27. Напишите функцию на TypeScript для получения значения из объекта по ключу с проверкой типа
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:27:58"/>
Ответ собеседника: Правильный. Кандидат предлагает решение с дженериком: function getValue<T extends object, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }
Правильный ответ:
Ответ кандидата корректен и представляет собой идеальное решение для безопасного доступа к свойствам объекта с полной типовой проверкой. Давайте разберём его глубоко, расширим для реальных сценариев и рассмотрим, почему это — золотой стандарт в TypeScript.
Базовое решение: безопасный и типобезопасный getter
function getValue<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
Как это работает:
| Часть | Пояснение |
|---|---|
T extends object | T — тип объекта, к которому мы обращаемся. Ограничиваем object, чтобы исключить примитивы. |
K extends keyof T | K — тип ключа, который должен быть одним из ключей объекта T. Компилятор проверяет, что ключ существует. |
obj: T | Объект, из которого извлекаем значение. |
key: K | Ключ, по которому извлекаем значение. Только ключи, существующие в T. |
: T[K] | Возвращаемый тип — тип значения, соответствующего ключу K в объекте T. |
Примеры использования:
const user = {
id: 1,
name: 'Alice',
email: 'alice@example.com',
isActive: true,
};
// ✅ Все эти вызовы компилируются и типизированы правильно
const id = getValue(user, 'id'); // number
const name = getValue(user, 'name'); // string
const email = getValue(user, 'email'); // string
const active = getValue(user, 'isActive'); // boolean
// ❌ Ошибка компиляции: ключ 'age' не существует в типе User
// const age = getValue(user, 'age'); // Type '"age"' is not assignable to type '"id" | "name" | "email" | "isActive"'
Преимущества:
- Полная типовая безопасность: Вы не можете передать несуществующий ключ.
- Автоматическое определение возвращаемого типа:
getValueвозвращает точно тот тип, который у ключа в объекте. - Автодополнение в IDE: При вводе
keyвы видите только допустимые ключи. - Нет cast-ов (
as), нетany— чистый, безопасный код.
Расширение: Обработка undefined и опциональных свойств
Иногда ключ может быть опциональным (?), и вы хотите, чтобы функция возвращала T[K] | undefined, даже если K — ключ объекта.
interface User {
id: number;
name: string;
avatar?: string; // опциональное свойство
}
const user: User = { id: 1, name: 'Bob' };
// Стандартная функция вернёт string | undefined для avatar
const avatar = getValue(user, 'avatar'); // string | undefined — идеально!
Это работает автоматически, потому что T[K] учитывает опциональность свойств. Если avatar в интерфейсе опционален — тип возвращаемого значения будет string | undefined.
Расширение: Безопасное получение вложенных значений (глубокий getter)
Часто нужно получить значение из вложенного объекта: user.profile.address.city.
Стандартная функция не справляется. Напишем рекурсивный универсальный get:
type Path<T> = T extends object
? { [K in keyof T]: K extends string ? [K] | [K, ...Path<T[K]>] : never }[keyof T]
: [];
function get<T, K extends Path<T>>(obj: T, path: K): unknown {
let current: any = obj;
for (const key of path) {
if (current == null || !(key in current)) {
return undefined;
}
current = current[key];
}
return current;
}
// Использование:
interface Address {
city: string;
street: string;
}
interface Profile {
age: number;
address: Address;
}
interface User {
id: number;
profile: Profile;
}
const user: User = {
id: 1,
profile: {
age: 30,
address: {
city: 'Berlin',
street: 'Main St',
},
},
};
// ✅ Типизировано и безопасно
const city = get(user, ['profile', 'address', 'city']); // string
const street = get(user, ['profile', 'address', 'street']); // string
const zip = get(user, ['profile', 'address', 'zip']); // undefined (не существует)
const age = get(user, ['profile', 'age']); // number
// ❌ Ошибка компиляции: "age" не может быть вторым элементом в пути, если первый — "id"
// const wrong = get(user, ['id', 'name']); // Error: Type '"name"' is not assignable to type 'never'
> Важно: Эта версия использует сложные типы, но сохраняет типовую безопасность — вы не можете указать неверный путь.
Расширение: Возвращение значения по умолчанию
Часто нужно вернуть значение по умолчанию, если ключ не существует. В TypeScript это делается с помощью перегрузки или условного типа.
function getValue<T extends object, K extends keyof T>(
obj: T,
key: K,
defaultValue: T[K]
): T[K];
function getValue<T extends object, K extends keyof T>(
obj: T,
key: K
): T[K];
function getValue<T extends object, K extends keyof T>(
obj: T,
key: K,
defaultValue?: T[K]
): T[K] {
if (key in obj) {
return obj[key];
}
if (defaultValue !== undefined) {
return defaultValue;
}
throw new Error(`Key "${String(key)}" not found in object`);
}
// Примеры:
const user = { id: 1, name: 'Alice' };
const name = getValue(user, 'name'); // string
const email = getValue(user, 'email', 'no-email@example.com'); // string (с дефолтом)
const age = getValue(user, 'age', 0); // number (с дефолтом)
// ❌ Ошибка: дефолт не совпадает по типу
// const invalid = getValue(user, 'name', 123); // Error: number is not assignable to string
Почему это лучше, чем obj[key as any] или obj[key as K]?
| Подход | Безопасность | Читаемость | Рекомендация |
|---|---|---|---|
obj[key as any] | ❌ Нет — отключает типы | ⚠️ Плохая практика | Никогда |
obj[key as K] | ❌ Нет — приведение не проверяет существование | ⚠️ Опасно | Избегать |
getValue(obj, key) | ✅ Полная проверка | ✅ Отличная | ✅ Использовать всегда |
obj[key] (без функции) | ✅ Только если key — литерал | ✅ Хорошо для статики | Использовать, если ключ известен на этапе написания |
Практическое применение: API-клиент
interface ApiResponse<T> {
data: T;
status: number;
timestamp: string;
}
const apiResponse: ApiResponse<User> = {
data: { id: 1, name: 'Alice', email: 'alice@example.com' },
status: 200,
timestamp: '2024-05-10T10:00:00Z',
};
// Безопасное извлечение
const userName = getValue(apiResponse.data, 'name'); // string
const userEmail = getValue(apiResponse.data, 'email'); // string
const userAge = getValue(apiResponse.data, 'age'); // ❌ Ошибка: 'age' не существует в User
Заключение
Функция getValue<T extends object, K extends keyof T>(obj: T, key: K): T[K] — это идеальный шаблон для безопасного доступа к свойствам объекта в TypeScript. Она:
- Использует дженерики для динамической типизации
- Обеспечивает полную проверку ключей на этапе компиляции
- Автоматически определяет тип возвращаемого значения
- Работает с опциональными свойствами, вложенными структурами и union-типами
- Предотвращает runtime-ошибки, связанные с неверными ключами
Это не просто функция — это идиоматичный паттерн TypeScript, который должен быть в арсенале каждого разработчика.
Для реальных проектов рекомендуется:
- Использовать эту функцию как базовую утилиту.
- Расширять её для поддержки вложенных путей.
- Добавлять обработку значений по умолчанию.
- Никогда не использовать
as any,as string, илиobj[key]без проверки, если ключ динамический.
> ✅ TypeScript — не про "написание кода, который компилируется". Он про "написание кода, который не упадёт в продакшене".
> Эта функция — воплощение этого принципа.
Вопрос 28. Для чего нужен хук useEffect в React и какие параметры он принимает?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:31:11"/>
Ответ собеседника: Правильный. useEffect нужен для выполнения сайд-эффектов (запросы на сервер, подписки на события, WebSocket). Принимает функцию-эффект и массив зависимостей. Пустой массив — выполняется один раз при монтировании. Без массива — выполняется при каждом рендере. Есть функция очистки, которая вызывается при размонтировании или изменении зависимостей.
Правильный ответ:
Хук useEffect — один из фундаментальных инструментов React для работы с побочными эффектами (side effects). Он позволяет декларативно описывать действия, которые должны происходить за пределами обычного рендеринга компонента: взаимодействие с внешним миром, изменение DOM, асинхронные операции, подписки и т.д.
Зачем нужен useEffect?
React — функциональная библиотека, и функциональные компоненты не имеют жизненного цикла, как классы. useEffect заменяет методы componentDidMount, componentDidUpdate и componentWillUnmount, объединяя их в один единый интерфейс.
Типичные сайд-эффекты:
- Запросы к API (
fetch,axios) - Подписки на события (
addEventListener,socket.on) - Работа с
localStorage,sessionStorage - Установка таймеров (
setTimeout,setInterval) - Ручное изменение DOM (например, фокусировка поля)
- Подписки на внешние библиотеки (например,
IntersectionObserver,ResizeObserver) - Инициализация библиотек (например, карт, редакторов)
> Важно: useEffect не предназначен для вычислений (для этого есть useMemo), и не для управления состоянием (для этого есть useState, useReducer). Он — для внешнего взаимодействия.
Синтаксис и параметры
useEffect(effect: () => (() => void) | void, deps?: DependencyList): void;
Параметры:
1. effect — функция-эффект
Это функция, которая будет выполнена после рендера компонента. Она может:
- Возвращать функцию очистки (cleanup)
- Не возвращать ничего (если очистка не требуется)
useEffect(() => {
// Эффект: запрашиваем данные
fetch('/api/users')
.then(response => response.json())
.then(data => setData(data));
// Опционально: возвращаем функцию очистки
return () => {
console.log('Cleanup: отмена запроса или отписка');
// Здесь можно отменить таймер, отписаться от события, закрыть соединение
};
}, []);
2. deps — массив зависимостей (опционально)
Определяет, при каких изменениях должен перезапускаться эффект.
| Массив зависимостей | Поведение |
|---|---|
Отсутствует (useEffect(effect)) | Эффект запускается после каждого рендера. |
Пустой массив (useEffect(effect, [])) | Эффект запускается только один раз — после первого монтирования (аналог componentDidMount). |
Непустой массив (useEffect(effect, [dep1, dep2])) | Эффект запускается при первом монтировании и каждый раз, когда один из зависимостей изменяется. |
| Зависимость — объект/массив | React проверяет их на ссылочную идентичность (===). Изменение содержимого без изменения ссылки — не вызовет перезапуск. |
> ⚠️ Важно: Не передавайте в зависимости функции, объекты или массивы, если они создаются на каждом рендере — это приведёт к бесконечным перезапускам эффекта.
// ❌ ПЛОХО: объект создаётся на каждом рендере
useEffect(() => {
fetch('/api/data', { method: 'POST', body: JSON.stringify({ id: userId }) });
}, { id: userId }); // Новая ссылка на каждом рендере → бесконечный цикл!
// ✅ ХОРОШО: используем только примитивы
useEffect(() => {
fetch('/api/data', { method: 'POST', body: JSON.stringify({ id: userId }) });
}, [userId]); // Перезапуск только при изменении userId
Функция очистки (Cleanup)
React автоматически вызывает функцию очистки, возвращаемую из useEffect, в двух случаях:
- Перед запуском нового эффекта (если зависимости изменились).
- При размонтировании компонента.
Это критически важно для предотвращения утечек памяти и некорректного поведения.
Пример: подписка на событие
import { useEffect, useState } from 'react';
function WindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
};
// Подписка
window.addEventListener('resize', handleResize);
// Очистка: отписка при размонтировании или изменении эффекта
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // Нет зависимостей — подписка не должна пересоздаваться
return <div>Ширина окна: {width}px</div>;
}
Пример: таймер
useEffect(() => {
const timer = setInterval(() => {
setCounter(prev => prev + 1);
}, 1000);
// Очистка: уничтожаем таймер
return () => clearInterval(timer);
}, []); // Запускаем таймер один раз
Пример: асинхронный запрос с отменой
useEffect(() => {
let isMounted = true;
const fetchUser = async () => {
const response = await fetch(`/api/user/${id}`);
const data = await response.json();
if (isMounted) {
setUser(data);
}
};
fetchUser();
// Очистка: отменяем эффект при размонтировании
return () => {
isMounted = false;
};
}, [id]);
> ✅ Использование isMounted — старый, но надёжный паттерн.
> В современных приложениях лучше использовать AbortController:
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(response => response.json())
.then(data => setData(data))
.catch(error => {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
});
return () => controller.abort(); // Отмена запроса при размонтировании
}, [id]);
Правила и best practices
| Правило | Объяснение |
|---|---|
Не вызывайте useEffect внутри условий | Он должен вызываться на верхнем уровне компонента. |
| Не вызывайте его внутри других хуков | Хуки должны вызываться в порядке и на верхнем уровне. |
Используйте useCallback для функций в зависимостях | Если функция передаётся как зависимость — оборачивайте её в useCallback, чтобы избежать перезапуска эффекта. |
Не делайте сайд-эффекты в useLayoutEffect без необходимости | useLayoutEffect блокирует рендеринг — используйте только если нужно синхронизировать с DOM перед отрисовкой. |
| Всегда очищайте подписки, таймеры, соединения | Иначе будут утечки памяти и ошибки при работе с размонтированными компонентами. |
Избегайте async внутри useEffect | useEffect не может быть асинхронным напрямую. Объявляйте асинхронную функцию внутри и вызывайте её. |
// ❌ ОШИБКА
useEffect(async () => {
const data = await fetch(...); // Неверно!
}, []);
// ✅ ПРАВИЛЬНО
useEffect(() => {
const fetchData = async () => {
const data = await fetch(...);
setData(data);
};
fetchData();
}, []);
Сравнение с useLayoutEffect
| Хук | Когда вызывается | Использование |
|---|---|---|
useEffect | После рендера, после отрисовки в DOM | Большинство сайд-эффектов (API, подписки, таймеры) |
useLayoutEffect | До отрисовки в DOM, но после обновления состояния | Когда нужно изменить DOM до того, как пользователь его увидит (например, позиционирование, измерение элементов) |
> Используйте useLayoutEffect только если вы видите артефакты рендера (например, элемент моргает). В 95% случаев подходит useEffect.
Заключение
useEffect — это ключевой механизм для управления взаимодействием React-компонента с внешним миром. Его правильное использование обеспечивает:
- Предсказуемость: эффекты запускаются только тогда, когда нужно.
- Безопасность: очистка предотвращает утечки.
- Производительность: избегаются лишние запросы и подписки.
Правильный подход:
> «Делай то, что нужно — и убирай за собой» — это философия useEffect.
Используйте его с пониманием зависимостей, всегда очищайте сайд-эффекты, избегайте any и as в типах, и вы получите стабильный, масштабируемый и надёжный код.
Вопрос 29. В чём разница между useEffect и useLayoutEffect?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:32:10"/>
Ответ собеседника: Правильный. useLayoutEffect выполняется синхронно до отрисовки элементов в браузере. useEffect выполняется асинхронно после отрисовки элементов в браузере.
Правильный ответ:
Ответ кандидата корректно обозначает ключевое различие — время выполнения. Но для полноценного понимания необходимо раскрыть архитектурные последствия, влияние на производительность и практические сценарии применения каждого хука.
Фундаментальное различие: когда выполняются эффекты
| Хук | Время выполнения | Поток выполнения | Влияние на пользовательский опыт |
|---|---|---|---|
useLayoutEffect | Синхронно, после обновления DOM, но до отрисовки визуальных изменений браузером | Блокирует рендеринг — работает в той же фазе, что и обновление DOM | Может вызывать "моргание" или задержки, если эффект тяжёлый |
useEffect | Асинхронно, после завершения отрисовки (после paint) | Не блокирует рендеринг — выполняется в следующей микрозадаче | Безопасен, не влияет на плавность интерфейса |
> 💡 Проще говоря:
> - useLayoutEffect — всё делается до того, как пользователь увидит изменения.
> - useEffect — всё делается после того, как пользователь уже увидел изменения.
Почему это важно?
Визуальное отображение (paint) происходит в отдельной фазе браузера, после того как React обновил DOM. Если вы изменяете стили, позиционирование или размеры элемента после рендера — пользователь может увидеть мигающий, неправильно отображённый интерфейс, а затем он исправляется.
Пример: моргание при изменении размера элемента
function ResizableBox() {
const [size, setSize] = useState(100);
// ❌ useLayoutEffect — исправит моргание
// ✅ useEffect — вызовет моргание
useLayoutEffect(() => {
const element = document.getElementById('box');
if (element) {
element.style.width = `${size * 2}px`; // Мгновенно исправляем размер
}
}, [size]);
// Если бы здесь был useEffect — размер изменился бы, но браузер уже отрисовал исходный размер
// → пользователь видит: 100px → 200px (через 1 кадр) → моргание
return (
<div style={{ width: `${size}px`, height: '50px', background: 'blue' }} id="box" />
);
}
С useEffect:
- React обновляет состояние →
size = 200 - React перерендеривает компонент → DOM обновляется:
width: 200px - Браузер отрисовывает элемент → пользователь видит
width: 100px useEffectзапускается → элемент меняетwidthна400px- Браузер перерисовывает → пользователь видит
width: 400px
→ Пользователь видит: 100px → 200px (на мгновение) → 400px
С useLayoutEffect:
- React обновляет состояние →
size = 200 - React перерендеривает компонент → DOM обновляется:
width: 200px useLayoutEffectзапускается до отрисовки → элемент меняетwidthна400px- Браузер отрисовывает уже исправленный элемент → пользователь видит:
width: 400pxсразу
→ Никакого моргания.
Когда использовать useLayoutEffect?
Только в случаях, когда визуальное несоответствие будет заметно пользователю:
- Измерение размеров элемента перед рендером (например, для адаптивного позиционирования)
- Изменение стилей, которые влияют на отображение (ширина, высота, позиция)
- Инициализация библиотек, требующих точных размеров DOM-элемента (например, Chart.js, D3.js)
- Позиционирование всплывающих окон, тултипов, меню
- Работа с
IntersectionObserverилиResizeObserverпри инициализации
import { useRef, useLayoutEffect, useState } from 'react';
function Tooltip({ children, content }) {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
setPosition({
top: rect.top - 50, // тултип выше элемента
left: rect.left + rect.width / 2 - 50, // по центру
});
}, []); // Только при монтировании
return (
<div>
<div ref={ref}>{children}</div>
<div style={{ position: 'fixed', ...position }}>{content}</div>
</div>
);
}
> Если вы не видите моргания — не используйте useLayoutEffect.
> Это не "более быстрый" useEffect. Это более агрессивный и потенциально тормозящий вариант.
Когда использовать useEffect?
В 95% случаев — именно useEffect.
- Асинхронные запросы (
fetch,axios) - Подписки на события (
addEventListener,socket.on) - Установка таймеров (
setTimeout,setInterval) - Работа с
localStorage,sessionStorage - Логирование, аналитика
- Инициализация сторонних библиотек, не требующих точных размеров DOM
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://analytics.com/script.js';
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}, []);
Производительность и SEO
useLayoutEffectблокирует рендеринг браузера, что может снизить производительность и ухудшить LCP (Largest Contentful Paint).- В SSR (Server-Side Rendering) —
useLayoutEffectвызывает предупреждение в React:
>useLayoutEffect does nothing on the server
Это связано с тем, что на сервере нет DOM.
→ Решение: использоватьuseEffectилиuseLayoutEffectс условием:
const isBrowser = typeof window !== 'undefined';
useEffect(() => {
if (isBrowser) {
// Код, который должен работать только на клиенте
}
}, []);
> Совет: В Next.js и других SSR-фреймворках useLayoutEffect часто заменяют на useEffect с useIsomorphicLayoutEffect (пользовательский хук), чтобы избежать предупреждений.
Рекомендации
| Сценарий | Рекомендуемый хук |
|---|---|
| Запросы API, подписки, таймеры | ✅ useEffect |
| Изменение стилей, размеров, позиций DOM-элементов | ✅ useLayoutEffect |
| Инициализация библиотек, требующих точных размеров | ✅ useLayoutEffect |
Работа с ResizeObserver, IntersectionObserver | ✅ useLayoutEffect (при инициализации) |
| Логирование, аналитика | ✅ useEffect |
| Обновление состояния на основе размера окна | ✅ useLayoutEffect (если нужна точность) |
| Любые другие сайд-эффекты | ✅ useEffect |
Практическое правило: "Пользователь не должен видеть моргание"
> Если вы видите, как элемент "скачет", "меняет размер" или "перепрыгивает" при загрузке — используйте useLayoutEffect.
> Если всё отображается плавно — useEffect достаточно.
Заключение
useLayoutEffect и useEffect — не альтернативы, а инструменты для разных задач.
useEffect— ваш повседневный инструмент. Он безопасен, асинхронен, не блокирует рендеринг. Используйте его по умолчанию.useLayoutEffect— узкоспециализированный инструмент для визуальной целостности. Он требует осторожности: может замедлить интерфейс, вызвать предупреждения в SSR, и его неправильное применение — это оптимизация, которая ломает производительность.
> Правило 80/20:
> 80% эффектов — useEffect.
> 20% — useLayoutEffect.
> Но если вы ошибётесь с 20% — пользователь это заметит.
Используйте useLayoutEffect только тогда, когда ваш интерфейс выглядит нестабильно, а не потому что "это звучит мощнее".
Вопрос 30. Зачем нужен хук useRef если мы не можем хранить состояние в ref?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:32:58"/>
Ответ собеседника: Правильный. useRef не вызывает рендеров компонента при изменении его значения. Используется для хранения ссылок на DOM-элементы или значений, которые не должны вызывать рендер при изменении.
Правильный ответ:
Ответ кандидата верен, но поверхностен. useRef — это не просто "альтернатива useState для избежания рендеров". Это мощный и универсальный инструмент для управления мутабельным состоянием без побочных эффектов в рендеринге. Давайте разберём его назначение, архитектурные возможности и практические сценарии.
Что такое useRef?
useRef возвращает референс-объект с одним полем .current, который сохраняет значение между рендерами компонента.
const ref = useRef(initialValue);
.current— это мутабельное хранилище.- Изменение
ref.currentне вызывает перерендер компонента. - Значение сохраняется на протяжении всего жизненного цикла компонента — даже при повторных рендерах.
useRefне является состоянием в смысле React — он не участвует в системе реактивности.
> ✅ Ключевая идея: useRef — это мутабельный контейнер, который не влияет на рендер.
Зачем это нужно? Почему нельзя просто использовать useState?
Представьте, что вам нужно:
- Сохранить значение, которое изменяется часто (например, таймер, счётчик, флаг).
- Но не хотите, чтобы каждый раз, когда оно меняется, компонент перерендеривался.
С useState это невозможно:
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // ❌ Каждый тик — перерендер!
}, 1000);
return () => clearInterval(timer);
}, []);
Каждую секунду React перерендерит компонент, даже если визуально ничего не изменилось — это накладные расходы.
С useRef — это безопасно и эффективно:
const countRef = useRef(0);
useEffect(() => {
const timer = setInterval(() => {
countRef.current++; // ✅ Нет рендера!
console.log(countRef.current); // Логируем, но не рендерим
}, 1000);
return () => clearInterval(timer);
}, []);
// Если нужно отобразить значение — используем useState для отображения
const [displayCount, setDisplayCount] = useState(0);
useEffect(() => {
if (countRef.current % 5 === 0) { // Только каждые 5 секунд
setDisplayCount(countRef.current);
}
}, [displayCount]); // Зависимость — только для отображения
> Разница:
> - useState — реактивное состояние → рендер при изменении.
> - useRef — мутабельная переменная → никакого рендера.
Основные сценарии использования useRef
1. Доступ к DOM-элементам (самый распространённый)
function InputFocus() {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus(); // ✅ Безопасный доступ к DOM
};
return (
<>
<input ref={inputRef} type="text" placeholder="Нажмите кнопку" />
<button onClick={focusInput}>Фокус</button>
</>
);
}
> ✅ useRef — единственный надёжный способ получить ссылку на DOM-элемент в функциональных компонентах.
2. Хранение таймеров, интервалов, отменяемых операций
function Timer() {
const intervalRef = useRef<number | null>(null);
const [seconds, setSeconds] = useState(0);
useEffect(() => {
intervalRef.current = window.setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
const stopTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null; // ✅ Обнуляем для безопасности
}
};
return (
<div>
<p>Секунд: {seconds}</p>
<button onClick={stopTimer}>Стоп</button>
</div>
);
}
> ✅ intervalRef хранит идентификатор таймера — не вызывает рендер, но позволяет отменить его в cleanup.
3. Сохранение предыдущих значений состояния
Часто нужно сравнить текущее и предыдущее значение состояния.
function Counter() {
const [count, setCount] = useState(0);
const prevCountRef = useRef<number>(0);
useEffect(() => {
prevCountRef.current = count; // Обновляем предыдущее значение
});
const prevCount = prevCountRef.current;
return (
<div>
<p>Текущее: {count}</p>
<p>Предыдущее: {prevCount}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
> ✅ prevCountRef хранит предыдущее значение без вызова рендера.
> Использование useState для хранения предыдущего значения привело бы к бесконечному циклу рендеров.
4. Хранение "флагов", "памяти" для асинхронных операций
function UserProfile({ userId }) {
const [user, setUser] = useState<User | null>(null);
const isMountedRef = useRef(true); // Флаг: компонент всё ещё смонтирован?
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(`/api/user/${userId}`);
const data = await response.json();
// Проверяем, не размонтирован ли компонент
if (!isMountedRef.current) return;
setUser(data);
};
fetchUser();
return () => {
isMountedRef.current = false; // ✅ Отмена при размонтировании
};
}, [userId]);
return <div>{user?.name}</div>;
}
> ✅ Без isMountedRef при быстрой смене userId возможна попытка обновить состояние размонтированного компонента — утечка памяти и ошибки.
5. Кэширование дорогих вычислений (вместо useMemo при неизменных зависимостях)
function ExpensiveComponent({ data }) {
const cacheRef = useRef<Map<string, number> | null>(null);
const [result, setResult] = useState(0);
const compute = () => {
if (!cacheRef.current) {
cacheRef.current = new Map();
}
const key = JSON.stringify(data);
if (cacheRef.current.has(key)) {
return cacheRef.current.get(key)!;
}
// Очень тяжёлое вычисление
const value = heavyCalculation(data);
cacheRef.current.set(key, value);
return value;
};
useEffect(() => {
setResult(compute());
}, [data]); // Пересчитываем только при изменении data
return <div>Результат: {result}</div>;
}
> ✅ cacheRef кэширует результаты, не вызывая рендеров при каждом изменении.
Чем useRef отличается от useState?
| Характеристика | useRef | useState |
|---|---|---|
| Изменение вызывает рендер? | ❌ Нет | ✅ Да |
| Используется для хранения состояния, влияющего на UI? | ❌ Нет | ✅ Да |
| Используется для хранения мутабельных значений? | ✅ Да | ❌ Нет |
| Сохраняет значение между рендерами? | ✅ Да | ✅ Да |
Поддерживает useEffect-зависимости? | ❌ Нет (не реагирует) | ✅ Да |
| Подходит для DOM-ссылок? | ✅ Да | ❌ Нет |
| Подходит для таймеров, флагов, кэшей? | ✅ Да | ❌ Неэффективно |
| Используется в SSR? | ✅ Да (с предупреждением) | ✅ Да |
Лучшие практики
- Не используйте
useRefкакuseState— если значение влияет на отображение — используйтеuseState. - Всегда инициализируйте
.current— не оставляйтеundefined, если не планируете проверять его наличие. - Не читайте
.currentв render-логике без необходимости — если вы читаете его в JSX, возможно, вам нуженuseState. - Всегда очищайте ссылки — особенно таймеры, подписки, асинхронные операции.
- Используйте
useRefдля хранения функций, если они меняются на каждом рендере и используются вuseEffect:const handlerRef = useRef(() => {});handlerRef.current = () => { /* новая реализация */ };useEffect(() => {handlerRef.current();}, []);
Вывод
useRef — это не "запасной вариант для хранения данных", а фундаментальный инструмент для управления мутабельным состоянием без побочных эффектов в рендеринге. Он решает задачи, которые невозможно решить с помощью useState:
- Доступ к DOM
- Хранение идентификаторов таймеров и подписок
- Кэширование вычислений
- Отслеживание предыдущих значений
- Контроль жизненного цикла асинхронных операций
Если вы не используете useRef — вы пропускаете 30% возможностей React.
Он не "меньше", он другой — и в этом его сила.
> ✅ Правило:
> "Если изменение значения не должно менять внешний вид компонента — используйте useRef."
> "Если изменение должно вызвать рендер — используйте useState."
Вопрос 31. Что произойдёт, если нажать на кнопку 5 раз в течение 2 секунд в компоненте с setTimeout?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:33:59"/>
Ответ собеседника: Правильный. Будет значение 1, потому что во всех обработчиках будет 0 + 1 из-за замыкания. Все макрозадачи будут использовать начальное значение count.
Правильный ответ:
Ответ кандидата корректен, но требует углублённого объяснения, поскольку поднимает фундаментальную проблему в React — замыкания в асинхронных колбэках при работе с состоянием. Эта ситуация — классический пример "устаревшего замыкания" (stale closure), которая встречается не только в setTimeout, но и в fetch, setInterval, addEventListener и других асинхронных операциях.
Сценарий: Код, который вызывает проблему
Рассмотрим типичный компонент, который увеличивает счётчик с задержкой:
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(count + 1); // ❌ Проблема: count — устаревшее значение!
}, 2000);
};
return (
<div>
<p>Счётчик: {count}</p>
<button onClick={handleClick}>Увеличить</button>
</div>
);
}
Что происходит, если нажать на кнопку 5 раз за 2 секунды?
- Начальное состояние:
count = 0 - Нажатие 1:
handleClickвызывается →setTimeoutзапланирован → замыкание захватываетcount = 0 - Нажатие 2:
handleClickвызывается →setTimeoutзапланирован → замыкание захватываетcount = 0 - Нажатие 3, 4, 5: То же самое — все 5
setTimeoutзахватываютcount = 0
После 2 секунд:
- Первый
setTimeoutвыполняется:setCount(0 + 1)→count = 1 - Второй
setTimeoutвыполняется:setCount(0 + 1)→count = 1(перезаписывает 1 → 1) - Третий:
setCount(0 + 1)→count = 1 - Четвёртый:
setCount(0 + 1)→count = 1 - Пятый:
setCount(0 + 1)→count = 1
Итог: count = 1, хотя кнопка была нажата 5 раз.
> 🔥 Проблема: Все колбэки setTimeout видят одно и то же значение count — начальное, потому что они были созданы в момент первого рендера, когда count был равен 0. React не обновляет замыкания внутри setTimeout, даже если состояние изменилось.
Почему так происходит?
React-компоненты перерендерятся при вызове setCount. Но:
- Каждый рендер создаёт новую функцию
handleClick. - Каждый вызов
handleClickсоздаёт новое замыкание, захватывающее текущее значениеcountна момент рендера. - Но
setTimeout— асинхронный. Он не ждёт, пока React перерендерится. Он просто запоминает значение переменной в момент создания колбэка. - Когда
setTimeoutвыполняется через 2 секунды, он использует значениеcount, которое было в момент его создания — а не актуальное.
> 💡 Замыкание — это "фотография" переменных в момент создания функции.
> React не "обновляет" замыкания в асинхронных колбэках.
Как это исправить?
✅ Правильное решение: функциональное обновление состояния
Используйте функциональный стиль обновления состояния, передавая функцию в setCount:
const handleClick = () => {
setTimeout(() => {
setCount(prevCount => prevCount + 1); // ✅ Использует актуальное значение!
}, 2000);
};
Как это работает:
setCount(prevCount => prevCount + 1)— не использует замыкание.- React передаёт в функцию актуальное значение состояния на момент выполнения
setCount, а не на момент создания колбэка. - Даже если все 5
setTimeoutзапланированы сcount = 0, при выполненииsetCountReact будет передавать:- Первый:
prevCount = 0→1 - Второй:
prevCount = 1→2 - Третий:
prevCount = 2→3 - Четвёртый:
prevCount = 3→4 - Пятый:
prevCount = 4→5
- Первый:
Итог: count = 5 — как и ожидалось.
> ✅ Это единственный надёжный способ обновлять состояние на основе предыдущего значения в асинхронных колбэках.
Другие способы (не рекомендуются)
1. Использование useRef для хранения актуального значения
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
const handleClick = () => {
setTimeout(() => {
setCount(countRef.current + 1); // ❌ Не работает с последовательными вызовами!
}, 2000);
};
> ❌ Проблема: Если нажать 5 раз быстро, countRef.current будет обновляться, но не в том порядке, в котором срабатывают setTimeout.
> Например, все 5 вызовов setTimeout могут прочитать countRef.current = 4, и все установят 5.
> Результат: count = 5, но это случайность, а не гарантированное поведение.
> ✅ Вывод: useRef может помочь в некоторых случаях, но не заменяет функциональное обновление.
2. Использование useCallback — не решает проблему
const handleClick = useCallback(() => {
setTimeout(() => {
setCount(count + 1);
}, 2000);
}, [count]);
> ❌ Это не решает проблему. useCallback гарантирует, что функция не пересоздаётся при каждом рендере, но если count изменился, а handleClick не обновился — вы получите устаревшее значение.
> Если вы не включите count в зависимости — вы получите старое значение.
> Если включите — вы получите новую функцию, но замыкание всё равно захватывает значение на момент создания.
Важное замечание: Это не только про setTimeout
Проблема устаревшего замыкания возникает в любом асинхронном контексте:
// fetch
fetch('/api/data')
.then(res => res.json())
.then(data => setCount(count + 1)); // ❌ Проблема!
// event listener
useEffect(() => {
const handler = () => {
setCount(count + 1); // ❌ Проблема!
};
window.addEventListener('click', handler);
return () => window.removeEventListener('click', handler);
}, []);
Во всех случаях — используйте функциональное обновление:
setCount(prev => prev + 1);
Почему это важнее, чем кажется?
Эта ошибка — одна из самых частых в React-приложениях. Она:
- Скрывается в разработке, если тестировать медленно.
- Появляется в продакшене, когда пользователь быстро нажимает кнопки.
- Сложно отлаживается, потому что состояние выглядит "правильным", а логика — "неправильной".
Лучшие практики
| Ситуация | Рекомендация |
|---|---|
| Обновление состояния на основе предыдущего значения | ✅ Всегда используйте setState(prev => prev + 1) |
Асинхронные колбэки (setTimeout, fetch, setInterval) | ✅ Всегда используйте функциональное обновление |
| Хранение значений без рендера | ✅ Используйте useRef |
| Хранение функций, которые используют состояние | ✅ Используйте useCallback + функциональное обновление |
| Отладка "устаревших замыканий" | ✅ Включите React DevTools и смотрите, какие значения замыкаются |
Заключение
Если вы нажмёте на кнопку 5 раз за 2 секунды в компоненте с setTimeout и используете setCount(count + 1), счётчик увеличится только на 1, потому что все 5 вызовов setTimeout захватывают начальное значение count = 0 через замыкание.
Решение — простое, но фундаментальное:
> ✅ Всегда используйте функциональное обновление состояния в асинхронных колбэках: setState(prev => prev + 1)
Это не "маленький трюк" — это основа надёжной работы с состоянием в React.
Игнорирование этого правила — путь к багам, которые появляются только в продакшене, когда пользователь действует "неправильно".
Вопрос 32. Как исправить проблему с замыканием в обработчике с setTimeout?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:34:48"/>
Ответ собеседника: Правильный. Использовать колбэк в setCount, чтобы брать актуальное значение состояния: setCount(prevCount => prevCount + 1).
Правильный ответ:
Ответ кандидата абсолютно верен и является единственным надёжным и идиоматичным способом решения проблемы устаревшего замыкания в React. Однако важно понимать, почему это работает, и почему другие подходы — недостаточны или опасны.
Почему возникает проблема?
Рассмотрим типичный неправильный код:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(count + 1); // ❌ Проблема: замыкание захватывает старое значение count
}, 2000);
};
return (
<div>
<p>Счётчик: {count}</p>
<button onClick={handleClick}>Увеличить</button>
</div>
);
}
Если нажать на кнопку 5 раз за 2 секунды:
- Каждый вызов
handleClickсоздаёт новую функцию дляsetTimeout. - Все эти функции захватывают значение
countна момент своего создания — то есть0. - Через 2 секунды все 5
setTimeoutвыполняются последовательно, и каждый из них вызывает:setCount(0 + 1); // → count = 1 - Поскольку
setCountработает асинхронно и не отслеживает предыдущее значение, все 5 вызовов перезаписывают состояние на1.
> 🔥 Итог: count = 1, хотя кнопка была нажата 5 раз.
Это классический случай устаревшего замыкания (stale closure) — когда асинхронный колбэк ссылается на устаревшую копию переменной, а не на актуальное состояние.
Решение: Функциональное обновление состояния
Исправленный код:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(prevCount => prevCount + 1); // ✅ Правильно!
}, 2000);
};
return (
<div>
<p>Счётчик: {count}</p>
<button onClick={handleClick}>Увеличить</button>
</div>
);
}
Как это работает:
setCount(prevCount => prevCount + 1)— это функциональное обновление.- React не использует значение
countиз замыкания. - Вместо этого, React передаёт в функцию актуальное значение состояния на момент выполнения
setCount. - Даже если
setTimeoutбыл запланирован сcount = 0, когда он срабатывает, React знает, какое значениеcountбыло на момент последнего рендера, и передаёт его в качествеprevCount.
Последовательность при 5 нажатиях:
| Время | Действие | prevCount в setCount | Результат |
|---|---|---|---|
| t=0s | 1-е нажатие | — | — |
| t=0.1s | 2-е нажатие | — | — |
| t=0.2s | 3-е нажатие | — | — |
| t=0.3s | 4-е нажатие | — | — |
| t=0.4s | 5-е нажатие | — | — |
| t=2s | 1-й setTimeout | 0 → 1 | count = 1 |
| t=2s | 2-й setTimeout | 1 → 2 | count = 2 |
| t=2s | 3-й setTimeout | 2 → 3 | count = 3 |
| t=2s | 4-й setTimeout | 3 → 4 | count = 4 |
| t=2s | 5-й setTimeout | 4 → 5 | count = 5 |
> ✅ Итог: count = 5 — как и ожидалось.
Почему это работает?
React не хранит значение состояния в замыкании. Вместо этого:
setCount— это асинхронная команда, которая помещается в очередь.- Когда очередь обрабатывается, React вычисляет новое состояние на основе последнего известного значения, а не того, которое было в момент создания колбэка.
- Функция
prevCount => prevCount + 1вызывается в контексте React, который знает актуальное состояние.
> 💡 Ключевая идея:
> setCount(prev => prev + 1) — это декларативное указание:
> «Увеличь состояние на единицу, используя то, что оно сейчас имеет».
>
> А не:
> «Установи состояние в count + 1, где count — это переменная, которую я видел два секунды назад».
Почему другие подходы не работают?
1. Использование useRef (ненадёжно)
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
const handleClick = () => {
setTimeout(() => {
setCount(countRef.current + 1); // ❌ Опасно!
}, 2000);
};
countRef.currentобновляется после рендера.- Если вы нажмёте 5 раз быстро —
countRef.currentможет быть4, но все 5setTimeoutпрочитают его одновременно. - Результат:
count = 5, но только потому, что все 5 вызовов прочитали одно и то же значение (4). - Это не гарантирует последовательность, и может сломаться при изменении порядка.
> ⚠️ Это обходной путь, а не решение. Он работает случайно, а не по дизайну.
2. Использование useCallback (не решает проблему)
const handleClick = useCallback(() => {
setTimeout(() => {
setCount(count + 1); // ❌ Всё ещё устаревшее замыкание!
}, 2000);
}, [count]);
useCallbackгарантирует, что функция не пересоздаётся при каждом рендере.- Но если
countизменился, аhandleClickне обновился — вы захватите старое значение. - Если вы включите
countв зависимости — вы получите новую функцию на каждом рендере, но замыкание всё равно захватывает значение на момент создания.
> ❌ Это не решает проблему замыкания — только маскирует её.
Применение в других асинхронных сценариях
Проблема не ограничивается setTimeout. Она возникает в любом асинхронном колбэке:
// ❌ Опасно
fetch('/api/data')
.then(res => res.json())
.then(data => setCount(count + 1));
// ✅ Безопасно
fetch('/api/data')
.then(res => res.json())
.then(data => setCount(prev => prev + 1));
// ❌ Опасно
const handleResize = () => {
setWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
// ✅ Безопасно
const handleResize = () => {
setWidth(prevWidth => window.innerWidth);
};
> ✅ Правило:
> Всегда используйте функциональное обновление (setState(prev => ...)), когда вы обновляете состояние на основе предыдущего значения в асинхронном коде.
Best Practices и рекомендации
| Ситуация | Рекомендация |
|---|---|
| Обновление состояния на основе предыдущего значения | ✅ Всегда используйте setState(prev => prev + 1) |
Асинхронные операции (setTimeout, fetch, setInterval) | ✅ Всегда используйте функциональное обновление |
| Хранение значений без рендера | ✅ Используйте useRef |
Передача функций в useEffect | ✅ Комбинируйте useCallback + функциональное обновление |
| Отладка "устаревших замыканий" | ✅ Используйте React DevTools, проверяйте замыкания в колбэках |
Заключение
Проблема с замыканием в setTimeout — это не ошибка в React, а следствие фундаментальной архитектуры JavaScript — замыкания и асинхронности. React не может автоматически "обновить" замыкания, потому что это нарушило бы предсказуемость.
Решение — простое, но фундаментальное:
> ✅ Всегда используйте функциональное обновление состояния: setCount(prev => prev + 1)
> вместо setCount(count + 1).
Это не "маленький трюк" — это основа надёжной работы с состоянием в React.
Игнорирование этого правила — путь к багам, которые появляются в продакшене, когда пользователь нажимает кнопку быстро.
Именно поэтому это правило — обязательное для любого React-разработчика уровня senior.
Вопрос 33. Что такое ленивая инициализация состояния в React?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:35:24"/>
Ответ собеседника: Правильный. Ленивая инициализация — передача функции в useState, чтобы она вызывалась только при первом рендере, а не при каждом. Это полезно для тяжёлых вычислений, чтобы не блокировать интерфейс.
Правильный ответ:
Ответ кандидата верно описывает суть, но не раскрывает глубину механизма, его архитектурное значение и практические сценарии. Ленивая инициализация (lazy initialization) — это не просто «оптимизация для тяжёлых вычислений», а ключевой паттерн для обеспечения производительности и предсказуемости в React-приложениях.
Что такое ленивая инициализация?
Ленивая инициализация — это передача функции в useState в качестве начального значения, чтобы React вызвал её только при первом рендере компонента, а не при каждом последующем.
const [state, setState] = useState(() => {
// Эта функция выполнится только один раз — при первом монтировании
return expensiveCalculation();
});
Без ленивой инициализации (неправильно):
const [state, setState] = useState(expensiveCalculation());
Здесь expensiveCalculation() вызывается на каждом рендере, даже если компонент перерендеривается из-за изменения другого состояния.
Проблема: Повторные вызовы при каждом рендере
Рассмотрим пример:
function ExpensiveComponent() {
const [data, setData] = useState(generateLargeArray()); // ❌ Вызывается на каждом рендере!
const [count, setCount] = useState(0);
return (
<div>
<p>Счётчик: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Увеличить</button>
</div>
);
}
function generateLargeArray() {
console.log('Генерирую массив...'); // Будет выводиться при каждом клике!
return new Array(1000000).fill(0).map((_, i) => i * 2);
}
Если пользователь нажмёт кнопку 10 раз — generateLargeArray() выполнится 11 раз (1 при монтировании + 10 при рендерах).
Это может:
- Заблокировать основной поток на сотни миллисекунд.
- Привести к тормозам интерфейса.
- Вызвать переполнение памяти при генерации больших структур.
> 🔥 Реальность: Даже если generateLargeArray() занимает 100 мс, 10 кликов = 1 секунда блокировки интерфейса. Пользователь почувствует "лаг".
Решение: Ленивая инициализация
function ExpensiveComponent() {
const [data, setData] = useState(() => generateLargeArray()); // ✅ Вызывается только один раз!
const [count, setCount] = useState(0);
return (
<div>
<p>Счётчик: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Увеличить</button>
</div>
);
}
Теперь:
generateLargeArray()вызывается только один раз — при первом монтировании компонента.- При последующих рендерах (например, при изменении
count) — не вызывается. - Интерфейс остаётся плавным и отзывчивым.
Консоль при первом рендере:
Генерирую массив...
При каждом клике:
(ничего не выводится)
Как это работает внутри React?
React проверяет тип начального значения:
- Если это значение (
useState(10)илиuseState([])), оно сразу используется. - Если это функция (
useState(() => compute())), React вызывает её только один раз — при первом монтировании. - Результат функции становится начальным значением состояния.
- Функция никогда не вызывается снова, даже если компонент размонтируется и смонтируется заново — тогда она вызовется снова, но только один раз при новом монтировании.
> ✅ Важно: Ленивая инициализация не сохраняет результат между монтированиями. Если компонент размонтируется и смонтируется заново — функция вызовется снова.
Практические сценарии применения
1. Генерация больших структур данных
const [matrix, setMatrix] = useState(() => {
return Array(1000)
.fill(null)
.map(() => Array(1000).fill(0));
});
2. Построение сложных объектов конфигурации
const [theme, setTheme] = useState(() => {
return {
colors: buildColorPalette(),
fonts: loadFontConfig(),
spacing: generateSpacingSystem(),
};
});
3. Инициализация библиотек (например, moment, lodash, chart.js)
const [chart, setChart] = useState(() => {
return new ChartJS(canvas, {
type: 'line',
data: generateChartData(),
});
});
4. Чтение из localStorage (с безопасной инициализацией)
const [savedData, setSavedData] = useState(() => {
const saved = localStorage.getItem('data');
return saved ? JSON.parse(saved) : initialData;
});
> ✅ В этом случае JSON.parse() выполняется только один раз, а не при каждом рендере — что критично для производительности.
Сравнение с useMemo
| Характеристика | useState(() => value) | useMemo(() => value, []) |
|---|---|---|
| Когда вызывается | Только при первом монтировании | Только при первом монтировании (если зависимости не меняются) |
| Вызывается при повторном монтировании? | ✅ Да | ✅ Да |
| Вызывается при рендере? | ❌ Нет | ❌ Нет |
| Используется для состояния? | ✅ Да | ❌ Нет (возвращает значение) |
| Подходит для инициализации состояния? | ✅ Да, идеально | ❌ Не предназначено для этого |
| Подходит для кэширования результата? | ❌ Нет | ✅ Да |
> 💡 Правило:
> Используйте useState(() => ...) — для инициализации состояния.
> Используйте useMemo(() => ...) — для кэширования вычислений, которые используются в рендере.
Лучшие практики
-
Всегда используйте ленивую инициализацию, если начальное значение:
- Тяжёлое в вычислении
- Создаёт большие структуры (массивы, объекты, DOM-узлы)
- Вызывает сторонние API (localStorage, localStorage, Web APIs)
- Зависит от внешних данных (например,
window.innerWidth)
-
Не используйте ленивую инициализацию для простых значений:
// ❌ Избыточноconst [count, setCount] = useState(() => 0);// ✅ Правильноconst [count, setCount] = useState(0); -
Не забывайте о безопасности: В ленивой функции никогда не вызывайте
setState— это вызовет бесконечный рендер. -
Тестируйте производительность: Используйте React DevTools → Components → Profiler, чтобы убедиться, что тяжёлые функции не вызываются лишний раз.
Заключение
Ленивая инициализация — это не оптимизация для энтузиастов, а обязательная практика для любого серьёзного React-приложения.
> ✅ Правило:
> «Если начальное значение состояния требует вычислений — передавайте функцию в useState»
Это простое, но мощное правило:
- Защищает интерфейс от тормозов.
- Повышает производительность.
- Делает код предсказуемым.
Она — часть проактивной оптимизации: вы не ждёте, пока пользователь будет жаловаться на лаги — вы предотвращаете их до того, как они возникнут.
Именно так и строятся высокопроизводительные приложения.
Вопрос 34. Будет ли вызываться функция ленивой инициализации при изменении пропсов?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:36:07"/>
Ответ собеседника: Правильный. Функция ленивой инициализации вызывается только при первом рендере. При изменении пропсов она не вызывается, если только пропсы не используются в функции инициализации.
Правильный ответ:
Ответ кандидата корректен, но требует уточнения и систематизации, поскольку поднимает важный нюанс: когда и почему React повторно вызывает функцию ленивой инициализации.
Краткий ответ
> Нет, функция ленивой инициализации (useState(() => ...)) вызывается только при первом монтировании компонента.
> Изменение пропсов НЕ вызывает её повторный запуск — даже если пропсы используются внутри функции.
Почему так происходит?
Функция, переданная в useState(() => initialValue), не является зависимостью от пропсов. React не отслеживает её связь с пропсами — она выполняется один раз при первом рендере, и результат кэшируется на весь жизненный цикл компонента.
function MyComponent({ initialCount }) {
const [count, setCount] = useState(() => {
console.log('Функция ленивой инициализации вызвана');
return initialCount * 2; // ✅ Использует пропс!
});
return (
<div>
<p>Счётчик: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Увеличить</button>
</div>
);
}
Что происходит:
| Ситуация | Результат |
|---|---|
Первый рендер (initialCount = 5) | console.log выводит → Функция ленивой инициализации вызвана → count = 10 |
Изменение пропса (initialCount = 10) | console.log не выводится → count остаётся 10 (не пересчитывается!) |
> 🔥 Критический момент:
> Даже если вы используете пропс внутри функции ленивой инициализации, React не пересчитывает начальное значение при изменении пропсов.
> Инициализация происходит только один раз — при монтировании.
Почему React так проектировал?
Это сознательное решение, основанное на принципах:
-
Предсказуемость состояния
Начальное значение состояния должно быть стабильным. Если оно зависит от пропсов, то оно не является "начальным" — оно является вычисляемым. В этом случае нужно использоватьuseEffectдля обновления. -
Производительность
Если бы React пересчитывал начальное значение при каждом изменении пропсов — это создало бы непредсказуемые перерендеры, особенно при тяжёлых вычислениях. -
Ясность
Компонент должен явно указывать, когда он хочет обновить состояние на основе пропсов — черезuseEffect, а не через скрытую логику вuseState.
Что делать, если нужно обновлять состояние на основе пропсов?
Если вы хотите, чтобы состояние обновлялось при изменении пропсов — используйте useEffect:
function MyComponent({ initialCount }) {
const [count, setCount] = useState(0); // Начальное значение по умолчанию
useEffect(() => {
// Обновляем состояние при изменении пропса
setCount(initialCount * 2);
}, [initialCount]); // ✅ Зависимость от пропса
return (
<div>
<p>Счётчик: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Увеличить</button>
</div>
);
}
Результат:
- При первом рендере:
initialCount = 5→count = 10 - При изменении
initialCount = 10→count = 20✅
> ✅ Это правильный и предсказуемый паттерн.
> Ленивая инициализация — для первичного установления.
> useEffect — для реакции на изменения.
Когда ленивая инициализация может "выглядеть" как зависящая от пропсов?
Есть два случая, когда кажется, что пропсы влияют на инициализацию — но на самом деле это иллюзия.
Случай 1: Использование пропсов в функции инициализации (но без обновления)
function Component({ theme }) {
const [config, setConfig] = useState(() => {
return loadConfig(theme); // ✅ Использует пропс
});
}
- При первом монтировании:
theme = 'dark'→loadConfig('dark')→configустановлен. - При смене
theme = 'light'→configне меняется! - Вы не увидите вызова
loadConfig('light').
👉 Это ошибка! Вы ожидаете, что config обновится, но он не обновится.
Решение — использовать useEffect, как показано выше.
Случай 2: Передача пропсов в useMemo вместо useState
const config = useMemo(() => loadConfig(theme), [theme]);
Это правильно, потому что useMemo отслеживает изменения пропсов.
Но useMemo — не состояние, а кэш вычисления.
Если вы хотите хранить результат в состоянии — используйте useState + useEffect.
Исключение: Размонтирование и повторное монтирование
Функция ленивой инициализации вызывается снова, если компонент размонтируется и монтируется заново:
function App() {
const [show, setShow] = useState(true);
return (
<div>
<button onClick={() => setShow(!show)}>Toggle</button>
{show && <MyComponent initialCount={10} />}
</div>
);
}
- При первом рендере:
MyComponentмонтируется →useState(() => ...)вызывается. - При скрытии (
show = false): компонент размонтируется → состояние уничтожается. - При повторном отображении (
show = true): компонент монтируется снова →useState(() => ...)вызывается снова.
> ✅ Это ожидаемое поведение — состояние полностью сбрасывается при размонтировании.
Best Practices: Рекомендации по использованию
| Сценарий | Рекомендуемое решение |
|---|---|
| Начальное значение — тяжёлое, но не зависит от пропсов | ✅ useState(() => expensiveCalculation()) |
| Начальное значение — зависит от пропсов, и нужно обновляться при их изменении | ✅ useState(initialValue) + useEffect(() => setCount(prop * 2), [prop]) |
| Начальное значение — зависит от пропсов, но не нужно обновляться | ✅ useState(() => prop * 2) — но предупреждайте команду, что это "одноразовое" значение |
| Кэширование вычисления, зависящего от пропсов | ✅ useMemo(() => compute(prop), [prop]) |
Инициализация из localStorage | ✅ `useState(() => JSON.parse(localStorage.getItem('key') |
Заключение
Ленивая инициализация — одноразовое событие, привязанное к монтированию компонента, а не к пропсам.
Изменение пропсов не вызывает её перезапуск — даже если вы используете пропсы внутри функции.
Это не баг — это особенность дизайна React, направленная на:
- Предотвращение неожиданных перерендеров
- Сохранение стабильности состояния
- Повышение производительности
Если вам нужно, чтобы состояние реагировало на изменения пропсов — не используйте ленивую инициализацию.
Вместо этого:
> ✅ Используйте useState с начальным значением + useEffect для обновления на основе пропсов.
Это делает ваш код явным, предсказуемым и поддерживаемым — что и есть главная цель React.
Вопрос 35. Что такое React.memo и что он принимает в качестве параметров?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:38:18"/>
Ответ собеседника: Правильный. React.memo используется для мемоизации компонентов. Принимает первым аргументом компонент, вторым — функцию для сравнения пропсов (prevProps, nextProps), которая возвращает true/false.
Правильный ответ:
Ответ кандидата верен и точно описывает синтаксис. Однако React.memo — это не просто "оптимизация для производительности", а мощный инструмент управления рендерингом, который требует глубокого понимания его механики, ограничений и лучших практик.
Что такое React.memo?
React.memo — это высокоуровневый хук-обёртка, предназначенная для мемоизации функциональных компонентов. Она предотвращает ненужные перерендеры, если пропсы компонента не изменились.
const MyComponent = React.memo(({ name, age }) => {
console.log('MyComponent перерендерен');
return <div>{name}, {age}</div>;
});
Без React.memo:
- Компонент перерендеривается при каждом рендере родителя, даже если его пропсы не изменились.
С React.memo:
- Компонент перерендеривается только тогда, когда его пропсы изменились.
Параметры React.memo
const MemoizedComponent = React.memo(Component, compareFunction);
1. Первый параметр: Component
- Это функциональный компонент, который вы хотите мемоизировать.
- Обязательно должен быть функциональным компонентом (не классовым).
React.memoне работает с классовыми компонентами.
const MyComponent = ({ name }) => <p>{name}</p>;
const MemoizedMyComponent = React.memo(MyComponent);
2. Второй параметр: compareFunction (опционально)
- Это функция сравнения пропсов, принимающая два аргумента:
prevProps: объект пропсов до измененияnextProps: объект пропсов после изменения
- Возвращает
true, если пропсы одинаковы → React пропустит рендер. - Возвращает
false, если пропсы отличаются → React выполнит рендер.
const MyComponent = ({ user }) => <div>{user.name}</div>;
const areEqual = (prevProps, nextProps) => {
return prevProps.user.name === nextProps.user.name; // Сравниваем только имя
};
const MemoizedMyComponent = React.memo(MyComponent, areEqual);
> ✅ Важно:
> - Если вы не передаёте compareFunction — React использует поверхностное сравнение (shallow comparison) пропсов.
> - Если вы передаёте compareFunction — вы полностью контролируете логику сравнения.
Как работает поверхностное сравнение (по умолчанию)?
React по умолчанию выполняет поверхностное сравнение (shallow comparison) всех пропсов:
const Parent = () => {
const [count, setCount] = useState(0);
const user = { name: 'Alice', age: 30 }; // ❌ Новая ссылка на каждом рендере!
return (
<>
<button onClick={() => setCount(c => c + 1)}>Увеличить</button>
<MemoizedChild user={user} /> {/* ❌ Перерендеривается, даже если user не изменился */}
</>
);
};
const MemoizedChild = React.memo(({ user }) => {
console.log('Child перерендерен');
return <p>{user.name}</p>;
});
Проблема:
Несмотря на то, что user.name не изменился, объект user пересоздаётся на каждом рендере родителя → новая ссылка → поверхностное сравнение возвращает false → компонент перерендеривается.
Решение:
const Parent = () => {
const [count, setCount] = useState(0);
const user = useMemo(() => ({ name: 'Alice', age: 30 }), []); // ✅ Одна и та же ссылка
return (
<>
<button onClick={() => setCount(c => c + 1)}>Увеличить</button>
<MemoizedChild user={user} />
</>
);
};
> ✅ Правило:
> React.memo эффективен только тогда, когда пропсы сохраняют ссылочную идентичность.
> Используйте useMemo для кэширования объектов и массивов, передаваемых в мемоизированные компоненты.
Пример: Кастомная функция сравнения
Допустим, у вас есть сложный объект user, и вы хотите сравнивать только name, а не все поля.
const UserCard = ({ user }) => (
<div>
<h3>{user.name}</h3>
<p>Age: {user.age}</p>
</div>
);
const areUserPropsEqual = (prevProps, nextProps) => {
return prevProps.user.name === nextProps.user.name;
};
const MemoizedUserCard = React.memo(UserCard, areUserPropsEqual);
const Parent = () => {
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: 'Alice', age: 30 });
return (
<>
<button onClick={() => setCount(c => c + 1)}>Обновить счётчик</button>
<button onClick={() => setUser({ name: 'Alice', age: 31 })}>Обновить возраст</button>
<MemoizedUserCard user={user} />
</>
);
};
Результат:
- При клике на "Обновить счётчик" —
userне меняется → нет перерендера ✅ - При клике на "Обновить возраст" —
user.nameне меняется → нет перерендера ✅
(даже еслиageизменился — мы игнорируем его в сравнении)
> 💡 Это мощный способ оптимизации, но требует осознанного выбора — что действительно важно для рендера.
Когда использовать React.memo?
| Ситуация | Рекомендация |
|---|---|
| Компонент часто перерендеривается, но его пропсы редко меняются | ✅ Используйте React.memo |
| Компонент тяжёлый (много дочерних элементов, сложный JSX) | ✅ Используйте React.memo |
| Компонент получает новые объекты/массивы на каждом рендере | ❌ Бесполезно — нужно сначала использовать useMemo |
| Компонент получает пропсы с функциями, которые пересоздаются | ❌ Бесполезно — используйте useCallback |
| Компонент маленький и быстрый | ❌ Не используйте — может замедлить рендер из-за сравнения |
| Вы не уверены, что пропсы стабильны | ❌ Не используйте — может привести к багам |
> ✅ Золотое правило:
> React.memo — это не "оптимизация по умолчанию". Это "оптимизация для конкретных случаев".
Ограничения и предостережения
| Проблема | Почему возникает | Решение |
|---|---|---|
| Перерендеры не устраняются | Пропсы — новые объекты/массивы | Используйте useMemo для кэширования объектов |
| Функции в пропсах пересоздаются | const handleClick = () => {...} в родителе | Используйте useCallback |
| Сравнение не учитывает внутренние изменения | Используется поверхностное сравнение | Напишите кастомную compareFunction |
| Пропуск рендера приводит к устаревшим данным | Вы забыли добавить зависимость в compareFunction | Проверьте, что вы сравниваете все значимые поля |
React.memo не мемоизирует состояние | Это работает только с пропсами | Используйте useMemo/useCallback для состояния |
Сравнение с useMemo и useCallback
| Хук | Назначение | Что мемоизирует | Когда использовать |
|---|---|---|---|
React.memo | Мемоизация компонента | Пропсы | Когда компонент перерендеривается без необходимости |
useMemo | Мемоизация значения | Результат вычисления | Когда вычисление дорогое и зависит от пропсов/состояния |
useCallback | Мемоизация функции | Саму функцию | Когда функция передаётся в мемоизированный компонент или как зависимость |
> ✅ Они работают вместе:
> useMemo + useCallback → стабильные пропсы → React.memo → нет лишних рендеров.
Лучшие практики
- Не используйте
React.memoна маленьких компонентах — накладные расходы на сравнение могут быть больше, чем выигрыш от пропуска рендера. - Всегда измеряйте производительность с помощью React DevTools → Profiler. Не оптимизируйте "на глаз".
- Комбинируйте
React.memoсuseMemoиuseCallback— это три кита производительности. - Избегайте
React.memoс изменяемыми объектами — если пропсы — это объекты, которые вы меняете внутри, они всегда будут новыми. - Используйте кастомные
compareFunctionтолько когда уверены — поверхностное сравнение — надёжнее, чем неправильная логика.
Заключение
React.memo — это не магическая кнопка "сделай быстрее", а инструмент для управления рендерингом. Его правильное применение требует понимания:
- Как работает поверхностное сравнение
- Почему новые ссылки на объекты и функции ломают оптимизацию
- Как
useMemoиuseCallbackдополняют его
> ✅ Правильное использование React.memo:
> "Я знаю, что мой компонент тяжёлый, и его пропсы стабильны — я хочу избежать ненужных рендеров."
> ❌ Неправильное использование React.memo:
> "Я просто добавил React.memo, чтобы всё стало быстрее — надеюсь, это поможет."
Используйте его осознанно — и он станет вашим союзником в создании высокопроизводительных, отзывчивых React-приложений.
Вопрос 36. Для чего нужен хук useCallback и в каких случаях его стоит использовать?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:39:06"/>
Ответ собеседника: Правильный. useCallback нужен для мемоизации функций, чтобы сохранять ссылку на функцию между рендерами. Используется когда:
- функция передаётся как пропс в компонент, обёрнутый в
React.memo; - функция используется в массиве зависимостей других хуков.
Правильный ответ:
Ответ кандидата корректно описывает два ключевых сценария. Однако useCallback — это не просто "оптимизация для React.memo", а фундаментальный инструмент управления ссылочной идентичностью в React, решающий проблему непредсказуемых перерендеров, вызванных новыми функциями на каждом рендере.
Что такое useCallback?
useCallback — это хук, который возвращает мемоизированную версию функции, сохраняющую одну и ту же ссылку между рендерами, если её зависимости не изменились.
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
- Возвращает ту же самую функцию, если зависимости (
[a, b]) не изменились. - Возвращает новую функцию, если хотя бы одна зависимость изменилась.
- Не кэширует результат выполнения — только ссылку на функцию.
> ✅ Ключевая идея:
> useCallback не ускоряет выполнение функции — он предотвращает создание новой функции при каждом рендере.
Зачем это нужно? Почему нельзя просто писать () => {}?
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => {
console.log('Кнопка нажата');
};
return (
<Child onClick={handleClick} />
);
}
Проблема:
- При каждом рендере
Parent(например, при измененииcount) — создаётся новая функцияhandleClick. - Даже если логика функции не изменилась — ссылка на неё новая.
- Это ломает оптимизацию
React.memoв дочернем компоненте.
const Child = React.memo(({ onClick }) => {
console.log('Child перерендерен'); // ❌ Вызывается при каждом рендере Parent!
return <button onClick={onClick}>Клик</button>;
});
> 🔥 Результат:
> Child перерендеривается каждый раз, даже если onClick — одна и та же функция по логике — потому что ссылка на неё меняется.
Решение: useCallback
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('Кнопка нажата');
}, []); // ✅ Пустой массив — функция создаётся один раз
return (
<Child onClick={handleClick} />
);
}
Результат:
handleClick— одна и та же функция на всех рендерах.Childне перерендеривается, потому чтоonClick— стабильная ссылка ✅
Когда использовать useCallback?
1. Передача функции в мемоизированный компонент (React.memo)
Это самый распространённый и правильный сценарий.
const MemoizedList = React.memo(({ items, onItemDelete }) => {
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name}
<button onClick={() => onItemDelete(item.id)}>Удалить</button>
</li>
))}
</ul>
);
});
function Parent() {
const [items, setItems] = useState([
{ id: 1, name: 'Яблоко' },
{ id: 2, name: 'Груша' },
]);
const handleDelete = useCallback((id) => {
setItems(items => items.filter(item => item.id !== id));
}, []); // ✅ Функция не зависит от items — стабильна
return <MemoizedList items={items} onItemDelete={handleDelete} />;
}
> ✅ onItemDelete не меняется — MemoizedList не перерендеривается при добавлении/удалении элементов.
> ❌ Ошибка:
> tsx` > const handleDelete = (id) => setItems(items => items.filter(...)); > > Функция **пересоздаётся** на каждом рендере —MemoizedList` всегда перерендерится.
2. Функция как зависимость в useEffect, useMemo, useCallback
Если функция используется в массиве зависимостей другого хука — необходимо мемоизировать её, чтобы избежать бесконечных циклов.
function MyComponent({ userId }) {
const [data, setData] = useState(null);
const fetchUser = useCallback(async () => {
const response = await fetch(`/api/user/${userId}`);
const result = await response.json();
setData(result);
}, [userId]); // ✅ Зависит от userId — мемоизируем
useEffect(() => {
fetchUser(); // ✅ Функция не меняется — useEffect не запускается бесконечно
}, [fetchUser]); // ✅ Зависимость — стабильная функция
}
> ❌ Без useCallback:
> ```tsx`
> const fetchUser = async () => { ... };
> useEffect(() => { fetchUser(); }, [fetchUser]); // ❌ Новая функция на каждом рендере → бесконечный цикл
> ````
3. Передача функции в сторонние библиотеки, зависящие от ссылочной идентичности
Некоторые библиотеки (например, lodash.debounce, use-debounce, react-router) или кастомные хуки сравнивают функции по ссылке.
import { useDebounce } from 'use-debounce';
function SearchInput() {
const [query, setQuery] = useState('');
const handleSearch = useCallback((q) => {
console.log('Поиск:', q);
}, []);
const [debouncedQuery] = useDebounce(query, 500, { leading: true, trailing: true });
useEffect(() => {
handleSearch(debouncedQuery);
}, [debouncedQuery, handleSearch]); // ✅ Функция стабильна
}
> ❌ Без useCallback — handleSearch меняется — useDebounce пересоздаётся — теряется дебаунс.
Когда НЕ нужно использовать useCallback?
| Ситуация | Почему не нужно |
|---|---|
Функция не передаётся как пропс в React.memo | Избыточная оптимизация |
| Функция небольшая и простая | Накладные расходы на мемоизацию > выигрыш |
| Функция изменяется часто (зависит от изменяемого состояния) | useCallback не устранит рендеры — будет возвращать новую функцию каждый раз |
Используется в обычном компоненте (без React.memo) | Не имеет смысла — React и так будет перерендеривать |
| Используется для вычислений | Используйте useMemo |
> ✅ Правило:
> Используйте useCallback только тогда, когда вы знаете, что ссылка на функцию влияет на поведение другого компонента или хука.
useCallback vs useMemo
| Хук | Что мемоизирует | Когда использовать |
|---|---|---|
useCallback | Функцию (ссылку) | Когда функция передаётся как пропс или используется в зависимостях хуков |
useMemo | Значение (результат вычисления) | Когда вычисление дорогое и его результат кэшируется (например, фильтрация списка) |
const expensiveList = useMemo(() => {
return items.filter(item => item.active); // тяжёлое вычисление
}, [items]);
const handleClick = useCallback(() => {
setItems(items => items.map(item => ({ ...item, active: !item.active })));
}, []);
> 💡 Совместное использование:
> useMemo для данных → useCallback для функций → React.memo для компонентов → полный цикл оптимизации.
Лучшие практики
| Правило | Пояснение |
|---|---|
Не используйте useCallback без причины | Это не "лучшая практика по умолчанию". |
| Всегда указывайте зависимости | Пропуск зависимостей — опасная ошибка. |
| Избегайте циклических зависимостей | Например, useCallback → useEffect → useCallback без осторожности. |
| Используйте React DevTools → Profiler | Проверяйте, устраняются ли рендеры. |
Комбинируйте с React.memo и useMemo | Оптимизация работает только в цепочке. |
| Не мемоизируйте функции, которые всегда меняются | Если функция зависит от count, и count часто меняется — useCallback бесполезен. |
Пример: Ошибочное использование
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, [count]); // ❌ Зависит от count — функция меняется при каждом клике!
return <Child onClick={handleClick} />;
}
> ❌ Это бесполезно — handleClick меняется на каждом клике → Child перерендеривается → нет выигрыша.
> ✅ Правильно:
> ```tsx`
> const handleClick = useCallback(() => {
> setCount(c => c + 1); // ✅ Используем функциональное обновление — не зависит от count
> }, []); // ✅ Пустой массив
> ````
Заключение
useCallback — это инструмент управления ссылочной идентичностью. Он не ускоряет код — он предотвращает ненужные перерендеры, вызванные новыми функциями на каждом рендере.
Используйте useCallback, когда:
- ✅ Передаёте функцию в
React.memoкомпонент; - ✅ Функция используется в массиве зависимостей
useEffect,useMemoили другогоuseCallback; - ✅ Библиотека или хук требует стабильной ссылки на функцию.
Не используйте useCallback, если:
- ❌ Функция не влияет на поведение других компонентов;
- ❌ Вы не уверены, что оптимизация действительно нужна;
- ❌ Вы используете её как "магию" — без понимания, почему она нужна.
> ✅ Правильное применение useCallback — это не про "быстрее", а про "предсказуемо".
> Он помогает сохранить целостность архитектуры — когда вы знаете, когда и почему компонент должен перерендериться.
Вопрос 37. Как решить проблему, когда функция используется в useEffect, но не должна быть в массиве зависимостей?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:40:01"/>
Ответ собеседника: Правильный. Создать функцию внутри useEffect, тогда она не будет попадать в массив зависимостей.
Правильный ответ:
Ответ кандидата частично верен, но недостаточно глубокий и опасно неполный. Создание функции внутри useEffect действительно решает проблему с зависимостями — но это не панацея, а компромисс, который может привести к дублированию логики, утечкам памяти и нарушению принципа единственной ответственности.
Давайте разберём все корректные и безопасные способы решения этой проблемы — и почему неправильный подход может сломать приложение.
Проблема: Почему функция в массиве зависимостей вызывает тревогу?
Часто мы сталкиваемся с ситуацией:
function MyComponent({ userId }) {
const [data, setData] = useState(null);
const fetchUser = async () => {
const response = await fetch(`/api/user/${userId}`);
const result = await response.json();
setData(result);
};
useEffect(() => {
fetchUser(); // ❌ Линтер предупреждает: "fetchUser" не в зависимостях
}, []); // ✅ Пустой массив — хотим выполнить только при монтировании
return <div>{data?.name}</div>;
}
> ⚠️ React линтер (eslint-plugin-react-hooks) выдаёт предупреждение:
> > React Hook useEffect has a missing dependency: 'fetchUser'. > Either include it or remove the dependency array. >
Почему это важно?
React требует, чтобы все значения, используемые внутри useEffect, были в массиве зависимостей — иначе возможны устаревшие замыкания (stale closures).
Если мы проигнорируем предупреждение:
useEffect(() => {
fetchUser(); // ❌ Опасно! Если userId изменится — fetchUser закэширует старое значение!
}, []);
→ fetchUser замыкает userId на момент первого рендера.
→ Если userId меняется — fetchUser всё ещё использует старый userId.
> 🔥 Это — устаревшее замыкание.
> Это не ошибка компиляции — это ошибка логики, которая проявляется в продакшене.
Правильные решения
✅ Решение 1: Используйте функциональное обновление (если функция зависит от состояния)
Если функция использует состояние (например, userId, count, filter), и вы хотите, чтобы она всегда видела актуальное значение — не передавайте её в зависимости, а используйте функциональное обновление.
function MyComponent({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(`/api/user/${userId}`);
const result = await response.json();
setData(result);
};
fetchUser(); // ✅ Функция создана внутри — не замыкает старые значения
}, [userId]); // ✅ Зависимость — userId, а не функция
}
> ✅ Почему это правильно?
> - Функция fetchUser создаётся на каждом рендере — но только внутри useEffect.
> - Она не передаётся наружу, не используется в других местах.
> - Она сразу вызывается и не сохраняется.
> - Она замыкает актуальное значение userId — потому что создаётся после обновления userId.
> 💡 Это лучший и самый безопасный способ, если функция используется только в useEffect.
✅ Решение 2: Используйте useCallback с правильными зависимостями
Если функция используется не только в useEffect, но и в других местах (например, в обработчике кнопки) — мемоизируйте её и включите все зависимости.
function MyComponent({ userId }) {
const [data, setData] = useState(null);
const fetchUser = useCallback(async () => {
const response = await fetch(`/api/user/${userId}`);
const result = await response.json();
setData(result);
}, [userId]); // ✅ Все зависимости указаны
useEffect(() => {
fetchUser();
}, [fetchUser]); // ✅ Зависимость — мемоизированная функция
}
> ✅ Плюсы:
> - Функция не пересоздаётся при каждом рендере.
> - useEffect не запускается бесконечно.
> - Функция безопасна для использования в других местах.
> ⚠️ Минусы:
> - Если fetchUser слишком часто меняется (например, зависит от частого count) — useEffect будет запускаться часто.
> - Это не всегда оптимально — если функция используется только в useEffect, лучше создать её внутри.
✅ Решение 3: Используйте useRef для хранения актуального значения (для сложных случаев)
Если функция не должна зависеть от пропсов/состояния, но должна получать актуальное значение — используйте useRef.
function MyComponent({ userId }) {
const [data, setData] = useState(null);
const userIdRef = useRef(userId);
useEffect(() => {
userIdRef.current = userId; // ✅ Обновляем референс при изменении пропса
}, [userId]);
const fetchUser = useCallback(async () => {
const response = await fetch(`/api/user/${userIdRef.current}`); // ✅ Читаем актуальное значение
const result = await response.json();
setData(result);
}, []); // ✅ Нет зависимостей — функция стабильна
useEffect(() => {
fetchUser();
}, [fetchUser]);
}
> ✅ Когда применять:
> - Когда нужно избежать перезапуска useEffect, но при этом получить актуальное значение из пропса/состояния.
> - Когда функция используется в нескольких местах, и нельзя пересоздавать её.
> ⚠️ Осторожно:
> Это сложный паттерн — используйте только если решение 1 или 2 не подходят.
✅ Решение 4: Вынесите логику за пределы компонента (наиболее чистое)
Если функция не зависит от состояния компонента, вынесите её в отдельный модуль:
// utils/api.js
export const fetchUser = async (userId) => {
const response = await fetch(`/api/user/${userId}`);
return response.json();
};
// MyComponent.jsx
import { fetchUser } from './utils/api';
function MyComponent({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetchUser(userId).then(setData);
}, [userId]); // ✅ Прямая зависимость — чисто и понятно
}
> ✅ Преимущества:
> - Функция не создаётся на каждом рендере.
> - Нет проблем с зависимостями — fetchUser — стабильная чистая функция.
> - Тестируемо, переиспользуемо, разделяет ответственность.
> 💡 Это лучшее решение, если логика не зависит от состояния компонента.
Почему "создать функцию внутри useEffect" — не всегда правильно?
useEffect(() => {
const fetchUser = () => { ... }; // ✅ Создаётся здесь
fetchUser();
}, []);
Плюсы:
- ✅ Не требует зависимостей
- ✅ Всегда видит актуальные значения
- ✅ Лёгкий и понятный код
Минусы:
- ❌ Если
useEffectзапускается несколько раз — функция пересоздаётся - ❌ Невозможно тестировать эту функцию отдельно
- ❌ Если логика сложная — код становится трудно читаемым
- ❌ Не подходит, если функция используется в другом месте
> ✅ Итог:
> Это — правильный способ, если функция простая, используется только внутри useEffect, и не нужна где-то ещё.
> Не используйте его как "универсальный костыль".
Чего НЕ делать
❌ Игнорировать линтер (небезопасно)
useEffect(() => {
fetchUser(); // ⚠️ Линтер ругается — но мы игнорируем
}, []); // ❌ Опасно! Устаревшее замыкание!
❌ Зависеть от useCallback без необходимости
const fetchUser = useCallback(() => { ... }, []); // ❌ Если fetchUser использует userId — это ошибка!
useEffect(() => { fetchUser(); }, [fetchUser]); // ❌ fetchUser не видит новый userId!
> 💡 Помните: useCallback не делает функцию "умнее" — она запоминает ссылку.
> Если внутри функции используется изменяющееся значение — оно должно быть в зависимостях.
❌ Создавать функцию внутри useEffect, но использовать её за его пределами
useEffect(() => {
const handler = () => { ... };
button.addEventListener('click', handler); // ❌ Зависимость не убирается!
}, []);
// ❌ Не убираем обработчик — утечка памяти!
> ✅ Правильно: > ```tsx` > useEffect(() => { > const handler = () => { ... }; > button.addEventListener('click', handler); > return () => button.removeEventListener('click', handler); > }, []); > ````
Рекомендации: Когда что использовать?
| Сценарий | Рекомендация |
|---|---|
Функция простая, используется только в useEffect | ✅ Создайте её внутри useEffect |
| Функция сложная, используется в нескольких местах | ✅ Вынесите её в отдельный модуль |
Функция зависит от пропсов/состояния и используется в useEffect | ✅ Используйте useCallback с всеми зависимостями |
Функция не зависит от состояния, но вызывается в useEffect | ✅ Вынесите её за пределы компонента |
Функция должна читать актуальное значение, но не перезапускать useEffect | ✅ Используйте useRef для хранения значения |
| Линтер требует зависимости — но вы не хотите её включать | ✅ Не игнорируйте! Используйте один из вышеперечисленных паттернов |
Заключение
Проблема "функция в useEffect, но не в зависимостях" — не о том, как избежать предупреждения линтера.
Она — о предотвращении устаревших замыканий и обеспечении предсказуемости.
> ✅ Правильный подход:
> "Если функция использует значения, которые могут меняться — она должна их видеть. Если она не должна их видеть — они не должны быть в её области видимости."
Наилучший паттерн для большинства случаев:
useEffect(() => {
const fetchData = async () => {
// Читаем актуальные пропсы/состояния прямо здесь
const response = await fetch(`/api/data/${userId}`);
const result = await response.json();
setData(result);
};
fetchData();
}, [userId]); // ✅ Зависимость — пропс, а не функция
Это:
- Просто
- Понятно
- Безопасно
- Тестируемо
- Поддерживаемо
Не пытайтесь "обойти" линтер — понимайте, почему он предупреждает.
Он — не враг. Он — ваш союзник в создании надёжных, предсказуемых React-приложений.
Вопрос 37. Как решить проблему, когда функция используется в useEffect, но не должна быть в массиве зависимостей?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:40:01"/>
Ответ собеседника: Правильный. Создать функцию внутри useEffect, тогда она не будет попадать в массив зависимостей.
Правильный ответ:
Ответ кандидата частично верен, но недостаточно глубокий и опасно неполный. Создание функции внутри useEffect действительно решает проблему с зависимостями — но это не панацея, а компромисс, который может привести к дублированию логики, утечкам памяти и нарушению принципа единственной ответственности.
Давайте разберём все корректные и безопасные способы решения этой проблемы — и почему неправильный подход может сломать приложение.
Проблема: Почему функция в массиве зависимостей вызывает тревогу?
Часто мы сталкиваемся с ситуацией:
function MyComponent({ userId }) {
const [data, setData] = useState(null);
const fetchUser = async () => {
const response = await fetch(`/api/user/${userId}`);
const result = await response.json();
setData(result);
};
useEffect(() => {
fetchUser(); // ❌ Линтер предупреждает: "fetchUser" не в зависимостях
}, []); // ✅ Пустой массив — хотим выполнить только при монтировании
return <div>{data?.name}</div>;
}
> ⚠️ React линтер (eslint-plugin-react-hooks) выдаёт предупреждение:
> > React Hook useEffect has a missing dependency: 'fetchUser'. > Either include it or remove the dependency array. >
Почему это важно?
React требует, чтобы все значения, используемые внутри useEffect, были в массиве зависимостей — иначе возможны устаревшие замыкания (stale closures).
Если мы проигнорируем предупреждение:
useEffect(() => {
fetchUser(); // ❌ Опасно! Если userId изменится — fetchUser закэширует старое значение!
}, []);
→ fetchUser замыкает userId на момент первого рендера.
→ Если userId меняется — fetchUser всё ещё использует старый userId.
> 🔥 Это — устаревшее замыкание.
> Это не ошибка компиляции — это ошибка логики, которая проявляется в продакшене.
Правильные решения
✅ Решение 1: Используйте функциональное обновление (если функция зависит от состояния)
Если функция использует состояние (например, userId, count, filter), и вы хотите, чтобы она всегда видела актуальное значение — не передавайте её в зависимости, а используйте функциональное обновление.
function MyComponent({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(`/api/user/${userId}`);
const result = await response.json();
setData(result);
};
fetchUser(); // ✅ Функция создана внутри — не замыкает старые значения
}, [userId]); // ✅ Зависимость — userId, а не функция
}
> ✅ Почему это правильно?
> - Функция fetchUser создаётся на каждом рендере — но только внутри useEffect.
> - Она не передаётся наружу, не используется в других местах.
> - Она сразу вызывается и не сохраняется.
> - Она замыкает актуальное значение userId — потому что создаётся после обновления userId.
> 💡 Это лучший и самый безопасный способ, если функция используется только в useEffect.
✅ Решение 2: Используйте useCallback с правильными зависимостями
Если функция используется не только в useEffect, но и в других местах (например, в обработчике кнопки) — мемоизируйте её и включите все зависимости.
function MyComponent({ userId }) {
const [data, setData] = useState(null);
const fetchUser = useCallback(async () => {
const response = await fetch(`/api/user/${userId}`);
const result = await response.json();
setData(result);
}, [userId]); // ✅ Все зависимости указаны
useEffect(() => {
fetchUser();
}, [fetchUser]); // ✅ Зависимость — мемоизированная функция
}
> ✅ Плюсы:
> - Функция не пересоздаётся при каждом рендере.
> - useEffect не запускается бесконечно.
> - Функция безопасна для использования в других местах.
> ⚠️ Минусы:
> - Если fetchUser слишком часто меняется (например, зависит от частого count) — useEffect будет запускаться часто.
> - Это не всегда оптимально — если функция используется только в useEffect, лучше создать её внутри.
✅ Решение 3: Используйте useRef для хранения актуального значения (для сложных случаев)
Если функция не должна зависеть от пропсов/состояния, но должна получать актуальное значение — используйте useRef.
function MyComponent({ userId }) {
const [data, setData] = useState(null);
const userIdRef = useRef(userId);
useEffect(() => {
userIdRef.current = userId; // ✅ Обновляем референс при изменении пропса
}, [userId]);
const fetchUser = useCallback(async () => {
const response = await fetch(`/api/user/${userIdRef.current}`); // ✅ Читаем актуальное значение
const result = await response.json();
setData(result);
}, []); // ✅ Нет зависимостей — функция стабильна
useEffect(() => {
fetchUser();
}, [fetchUser]);
}
> ✅ Когда применять:
> - Когда нужно избежать перезапуска useEffect, но при этом получить актуальное значение из пропса/состояния.
> - Когда функция используется в нескольких местах, и нельзя пересоздавать её.
> ⚠️ Осторожно:
> Это сложный паттерн — используйте только если решение 1 или 2 не подходят.
✅ Решение 4: Вынесите логику за пределы компонента (наиболее чистое)
Если функция не зависит от состояния компонента, вынесите её в отдельный модуль:
// utils/api.js
export const fetchUser = async (userId) => {
const response = await fetch(`/api/user/${userId}`);
return response.json();
};
// MyComponent.jsx
import { fetchUser } from './utils/api';
function MyComponent({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetchUser(userId).then(setData);
}, [userId]); // ✅ Прямая зависимость — чисто и понятно
}
> ✅ Преимущества:
> - Функция не создаётся на каждом рендере.
> - Нет проблем с зависимостями — fetchUser — стабильная чистая функция.
> - Тестируемо, переиспользуемо, разделяет ответственность.
> 💡 Это лучшее решение, если логика не зависит от состояния компонента.
Почему "создать функцию внутри useEffect" — не всегда правильно?
useEffect(() => {
const fetchUser = () => { ... }; // ✅ Создаётся здесь
fetchUser();
}, []);
Плюсы:
- ✅ Не требует зависимостей
- ✅ Всегда видит актуальные значения
- ✅ Лёгкий и понятный код
Минусы:
- ❌ Если
useEffectзапускается несколько раз — функция пересоздаётся - ❌ Невозможно тестировать эту функцию отдельно
- ❌ Если логика сложная — код становится трудно читаемым
- ❌ Не подходит, если функция используется в другом месте
> ✅ Итог:
> Это — правильный способ, если функция простая, используется только внутри useEffect, и не нужна где-то ещё.
> Не используйте его как "универсальный костыль".
Чего НЕ делать
❌ Игнорировать линтер (небезопасно)
useEffect(() => {
fetchUser(); // ⚠️ Линтер ругается — но мы игнорируем
}, []); // ❌ Опасно! Устаревшее замыкание!
❌ Зависеть от useCallback без необходимости
const fetchUser = useCallback(() => { ... }, []); // ❌ Если fetchUser использует userId — это ошибка!
useEffect(() => { fetchUser(); }, [fetchUser]); // ❌ fetchUser не видит новый userId!
> 💡 Помните: useCallback не делает функцию "умнее" — она запоминает ссылку.
> Если внутри функции используется изменяющееся значение — оно должно быть в зависимостях.
❌ Создавать функцию внутри useEffect, но использовать её за его пределами
useEffect(() => {
const handler = () => { ... };
button.addEventListener('click', handler); // ❌ Зависимость не убирается!
}, []);
// ❌ Не убираем обработчик — утечка памяти!
> ✅ Правильно: > ```tsx` > useEffect(() => { > const handler = () => { ... }; > button.addEventListener('click', handler); > return () => button.removeEventListener('click', handler); > }, []); > ````
Рекомендации: Когда что использовать?
| Сценарий | Рекомендация |
|---|---|
Функция простая, используется только в useEffect | ✅ Создайте её внутри useEffect |
| Функция сложная, используется в нескольких местах | ✅ Вынесите её в отдельный модуль |
Функция зависит от пропсов/состояния и используется в useEffect | ✅ Используйте useCallback с всеми зависимостями |
Функция не зависит от состояния, но вызывается в useEffect | ✅ Вынесите её за пределы компонента |
Функция должна читать актуальное значение, но не перезапускать useEffect | ✅ Используйте useRef для хранения значения |
| Линтер требует зависимости — но вы не хотите её включать | ✅ Не игнорируйте! Используйте один из вышеперечисленных паттернов |
Заключение
Проблема "функция в useEffect, но не в зависимостях" — не о том, как избежать предупреждения линтера.
Она — о предотвращении устаревших замыканий и обеспечении предсказуемости.
> ✅ Правильный подход:
> "Если функция использует значения, которые могут меняться — она должна их видеть. Если она не должна их видеть — они не должны быть в её области видимости."
Наилучший паттерн для большинства случаев:
useEffect(() => {
const fetchData = async () => {
// Читаем актуальные пропсы/состояния прямо здесь
const response = await fetch(`/api/data/${userId}`);
const result = await response.json();
setData(result);
};
fetchData();
}, [userId]); // ✅ Зависимость — пропс, а не функция
Это:
- Просто
- Понятно
- Безопасно
- Тестируемо
- Поддерживаемо
Не пытайтесь "обойти" линтер — понимайте, почему он предупреждает.
Он — не враг. Он — ваш союзник в создании надёжных, предсказуемых React-приложений.
Вопрос 38. Напишите функцию для подсчёта количества гласных в строке.
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:45:43"/>
Ответ собеседника: Правильный. Кандидат предлагает решение: создать массив гласных, итерироваться по строке, проверять наличие символа в массиве гласных и увеличивать счётчик.
Правильный ответ:
Кандидат предложил корректный алгоритм — итерация по строке с проверкой принадлежности символа к множеству гласных. Однако в реализации этого подхода могут скрываться подводные камни, связанные с производительностью, регулярными выражениями, Unicode и локализацией. Ниже — полное, production-ready решение с объяснением всех аспектов.
Базовая реализация (для ASCII/латиницы)
Наиболее простая и понятная версия:
func countVowels(s string) int {
vowels := map[byte]bool{
'a': true, 'e': true, 'i': true, 'o': true, 'u': true,
'A': true, 'E': true, 'I': true, 'O': true, 'U': true,
}
count := 0
for i := 0; i < len(s); i++ {
if vowels[s[i]] {
count++
}
}
return count
}
> ✅ Плюсы:
> - Время работы: O(n) — оптимально.
> - Проверка по map[byte]bool — O(1), быстрее, чем strings.ContainsAny или линейный поиск в срезе.
> - Просто, читаемо, эффективно.
> ⚠️ Ограничение:
> Работает только для латиницы. Не учитывает русские (а, е, и, о, у, э, ю, я), акцентированные символы (é, ë, ï, ö, ü) или другие алфавиты.
Решение для Unicode (правильный подход для международных строк)
В Go строка — это байтовый срез UTF-8. Простой for i := 0; i < len(s); i++ неправильно обрабатывает не-ASCII символы.
Нужно итерировать по руна (Unicode code points), а не байтам.
func countVowels(s string) int {
vowels := map[rune]bool{
'a': true, 'e': true, 'i': true, 'o': true, 'u': true,
'A': true, 'E': true, 'I': true, 'O': true, 'U': true,
// Русские гласные
'а': true, 'е': true, 'и': true, 'о': true, 'у': true, 'ы': true, 'э': true, 'ю': true, 'я': true,
'А': true, 'Е': true, 'И': true, 'О': true, 'У': true, 'Ы': true, 'Э': true, 'Ю': true, 'Я': true,
// Акцентированные латинские
'é': true, 'è': true, 'ê': true, 'ë': true,
'á': true, 'à': true, 'â': true, 'ä': true, 'ã': true,
'í': true, 'ì': true, 'î': true, 'ï': true,
'ó': true, 'ò': true, 'ô': true, 'ö': true, 'õ': true,
'ú': true, 'ù': true, 'û': true, 'ü': true,
'ý': true, 'ÿ': true,
'É': true, 'È': true, 'Ê': true, 'Ë': true,
'Á': true, 'À': true, 'Â': true, 'Ä': true, 'Ã': true,
'Í': true, 'Ì': true, 'Î': true, 'Ï': true,
'Ó': true, 'Ò': true, 'Ô': true, 'Ö': true, 'Õ': true,
'Ú': true, 'Ù': true, 'Û': true, 'Ü': true,
'Ý': true, 'Ÿ': true,
}
count := 0
for _, r := range s {
if vowels[r] {
count++
}
}
return count
}
> ✅ Почему for _, r := range s?
> В Go range над строкой возвращает rune, а не байт. Это правильный способ обходить UTF-8-строки.
> Пример: "café" — 4 символа, но 5 байт. len("café") == 5, но for range пройдёт только 4 раза — по c, a, f, é.
> ✅ Почему map[rune]bool?
> rune — это int32, представляющий Unicode code point. Такой map быстр и точен.
Оптимизация: использование strings.ContainsRune и предварительно заданного множества
Если вы не хотите вручную перечислять все варианты, можно использовать стандартный пакет:
import "strings"
func countVowels(s string) int {
const vowels = "aeiouAEIOUаеиоуыэюяАЕИОУЫЭЮЯéèêëáàâäãíìîïóòôöõúùûüýÿÉÈÊËÁÀÂÄÃÍÌÎÏÓÒÔÖÕÚÙÛÜÝŸ"
count := 0
for _, r := range s {
if strings.ContainsRune(vowels, r) {
count++
}
}
return count
}
> ⚠️ Минус: strings.ContainsRune работает за O(k), где k — длина строки vowels (≈100 символов).
> Это менее эффективно, чем map[rune]bool (O(1)).
> Для частого вызова — лучше map.
Решение с регулярными выражениями (не рекомендуется для производительности)
import "regexp"
func countVowels(s string) int {
re := regexp.MustCompile(`[aeiouAEIOUаеиоуыэюяАЕИОУЫЭЮЯéèêëáàâäãíìîïóòôöõúùûüýÿÉÈÊËÁÀÂÄÃÍÌÎÏÓÒÔÖÕÚÙÛÜÝŸ]`)
return len(re.FindAllString(s, -1))
}
> ⚠️ Недостатки:
> - Создание регулярки дорого — лучше кешировать через sync.Once или var на уровне пакета.
> - Работает медленнее, чем map или strings.ContainsRune.
> - Сложнее отлаживать, читать, тестировать.
> ✅ Использовать только если: > - Требуется динамическое определение гласных (например, из конфига). > - Код пишется для одноразового использования.
Тесты: важная часть решения
Написание тестов показывает глубину понимания:
func TestCountVowels(t *testing.T) {
tests := []struct {
name string
input string
expected int
}{
{"empty", "", 0},
{"no vowels", "bcdfg", 0},
{"latin vowels", "hello", 2},
{"mixed case", "HeLLo", 2},
{"russian", "привет", 2},
{"accented", "café", 2},
{"unicode combo", "naïve", 3}, // 'n', 'a', 'ï', 'v', 'e' → 'a', 'ï', 'e'
{"all vowels", "aeiouAEIOU", 10},
{"complex", "Malmö, São Paulo, Köln", 6}, // ä, o, a, u, o, ö
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := countVowels(tt.input)
if got != tt.expected {
t.Errorf("countVowels(%q) = %d; want %d", tt.input, got, tt.expected)
}
})
}
}
> ✅ Тесты покрывают:
> - Пустую строку
> - Нет гласных
> - Латиница
> - Кейс-сенситивность
> - Киррилица
> - Акцентированные символы
> - Комбинированные UTF-8 символы (например, ï — это один rune, а не i + диакритика)
> - Сложные примеры из жизни (Malmö, São Paulo)
Производительность: benchmark
func BenchmarkCountVowels(b *testing.B) {
s := "The quick brown fox jumps over the lazy dog. Привет, мир! Café, naïve, résumé."
for i := 0; i < b.N; i++ {
countVowels(s)
}
}
Результаты (примерные):
BenchmarkCountVowels-8 10000000 112 ns/op
Это очень быстро — 112 наносекунд на строку длиной ~70 символов.
При 10000 вызовов в секунду — идеально для веб-сервисов.
Рекомендации для production-кода
| Критерий | Рекомендация |
|---|---|
| Простота | Используйте map[rune]bool |
| Производительность | map[rune]bool — лучший выбор |
| Масштабируемость | Поддерживайте Unicode — не ограничивайтесь ASCII |
| Читаемость | Комментируйте, какие гласные включены (особенно для международных систем) |
| Тестирование | Обязательно проверяйте Unicode, акценты, смешанные языки |
| Кеширование | Если список гласных не меняется — не пересоздавайте map внутри функции. Вынесите в var |
Финальная версия (production-ready)
var vowels = map[rune]bool{
'a': true, 'e': true, 'i': true, 'o': true, 'u': true,
'A': true, 'E': true, 'I': true, 'O': true, 'U': true,
'а': true, 'е': true, 'и': true, 'о': true, 'у': true, 'ы': true, 'э': true, 'ю': true, 'я': true,
'А': true, 'Е': true, 'И': true, 'О': true, 'У': true, 'Ы': true, 'Э': true, 'Ю': true, 'Я': true,
'é': true, 'è': true, 'ê': true, 'ë': true,
'á': true, 'à': true, 'â': true, 'ä': true, 'ã': true,
'í': true, 'ì': true, 'î': true, 'ï': true,
'ó': true, 'ò': true, 'ô': true, 'ö': true, 'õ': true,
'ú': true, 'ù': true, 'û': true, 'ü': true,
'ý': true, 'ÿ': true,
'É': true, 'È': true, 'Ê': true, 'Ë': true,
'Á': true, 'À': true, 'Â': true, 'Ä': true, 'Ã': true,
'Í': true, 'Ì': true, 'Î': true, 'Ï': true,
'Ó': true, 'Ò': true, 'Ô': true, 'Ö': true, 'Õ': true,
'Ú': true, 'Ù': true, 'Û': true, 'Ü': true,
'Ý': true, 'Ÿ': true,
}
func countVowels(s string) int {
count := 0
for _, r := range s {
if vowels[r] {
count++
}
}
return count
}
> ✅ Почему это идеально?
> - Оптимальная скорость: O(n), без лишних аллокаций.
> - Поддерживает Unicode, латиницу, кириллицу, акценты.
> - Читаемый и тестируемый.
> - Не требует внешних зависимостей.
> - Безопасен для многопоточного использования (иммутабельный map).
Заключение
Подсчёт гласных — задача, на которой видно, насколько разработчик понимает Unicode, работу со строками в Go и производительность.
> 💡 Важный принцип:
> Ни одна строка в современном приложении не должна обрабатываться как байтовый массив.
> Всегда думайте: это Unicode, а не ASCII.
Тот, кто пишет for i := 0; i < len(s); i++ — не готов к международным проектам.
Тот, кто использует map[rune]bool + range + тесты на naïve и Malmö — демонстрирует уровень senior-разработчика.
Вопрос 39. Что такое чистая функция?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:48:21"/>
Ответ собеседника: Правильный. Чистая функция — функция, которая при одинаковых аргументах всегда возвращает одинаковый результат и не имеет побочных эффектов (не мутирует внешние данные, не обращается к глобальным переменным).
Правильный ответ:
Чистая функция — фундаментальное понятие в функциональном программировании, но её значение выходит далеко за рамки парадигмы. Это ключевой принцип для написания надёжного, тестируемого, параллелизуемого и предсказуемого кода — вне зависимости от того, используете ли вы Go, JavaScript, Haskell или Rust.
Определение чистой функции: два строгих условия
Чистая функция удовлетворяет двум обязательным условиям:
- Одинаковые входы → одинаковые выходы (детерминированность)
- Нет побочных эффектов (отсутствие взаимодействия с внешним миром)
1. Детерминированность: результат зависит только от входных параметров
Функция не должна полагаться на что-либо, что может измениться вне её области видимости:
// ❌ НЕ чистая функция — зависит от глобальной переменной
var counter int
func addWithCounter(a, b int) int {
counter++ // ← побочный эффект
return a + b + counter
}
// ❌ НЕ чистая функция — зависит от времени
func getCurrentTimePlus(a int) int {
return a + int(time.Now().Unix()) // ← результат меняется каждую миллисекунду
}
// ✅ Чистая функция — результат зависит ТОЛЬКО от входных параметров
func add(a, b int) int {
return a + b
}
> 🔍 Почему это важно?
> Если функция детерминирована, вы можете запомнить (закэшировать) её результат для заданных аргументов — это называется мемоизацией.
> Это критически важно в вычислительно дорогих операциях: парсинге, шифровании, вычислении хешей, рекурсивных вычислениях.
func fibonacci(n int) int {
if n <= 1 {
return n
}
return fibonacci(n-1) + fibonacci(n-2)
}
// ❌ Медленно: O(2^n) — пересчитывает одни и те же значения тысячи раз
// ✅ С мемоизацией (чистая функция + кэш)
var fibCache = make(map[int]int)
func fibonacciMemo(n int) int {
if n <= 1 {
return n
}
if result, exists := fibCache[n]; exists {
return result
}
result := fibonacciMemo(n-1) + fibonacciMemo(n-2)
fibCache[n] = result
return result
}
> ⚠️ Обратите внимание: сама функция fibonacciMemo не чистая, потому что fibCache — мутабельный глобальный объект.
> Чтобы сделать её настоящим чистым, нужно передавать кэш как параметр:
func fibonacciPure(n int, cache map[int]int) (int, map[int]int) {
if n <= 1 {
return n, cache
}
if result, exists := cache[n]; exists {
return result, cache
}
result, cache := fibonacciPure(n-1, cache)
result2, cache := fibonacciPure(n-2, cache)
finalResult := result + result2
cache[n] = finalResult
return finalResult, cache
}
> ✅ Теперь — абсолютно чистая функция.
> Она ничего не мутирует вне себя, ничего не читает извне, и возвращает всё, что изменила, в виде выходных значений.
2. Отсутствие побочных эффектов
Побочный эффект — любое наблюдаемое изменение состояния внешнего мира, кроме возврата значения.
❌ Примеры побочных эффектов:
// ✖ Мутирует внешний объект
func addToSlice(slice []int, x int) []int {
slice = append(slice, x) // ← мутирует переданный срез, если он не скопирован
return slice
}
// ✖ Пишет в файл
func logMessage(msg string) {
f, _ := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
f.WriteString(msg + "\n")
f.Close() // ← побочный эффект: запись в файл
}
// ✖ Делает HTTP-запрос
func getUser(id int) User {
resp, _ := http.Get("https://api.example.com/users/" + strconv.Itoa(id))
// ← побочный эффект: сетевой вызов
}
// ✖ Выводит на консоль
func printAndReturn(s string) string {
fmt.Println(s) // ← побочный эффект: вывод в stdout
return s
}
> 🔥 Все эти функции нельзя тестировать изолированно.
> В тесте вы не можете "перехватить" запись в файл, не можете замокать HTTP-запрос, не можете проверить вывод в консоль без сложных инструментов.
✅ Как сделать чистой?
// ✅ Чистая: ничего не мутирует, ничего не пишет
func appendToSlice(slice []int, x int) []int {
newSlice := make([]int, len(slice)+1)
copy(newSlice, slice)
newSlice[len(slice)] = x
return newSlice
}
// ✅ Чистая: возвращает результат, но не пишет в файл
type Logger interface {
Log(msg string)
}
func processUser(user User, logger Logger) User {
// ... логика
logger.Log("User processed: " + user.Name) // ← передаём зависимость, не создаём её внутри
return user
}
// ✅ Чистая: возвращает запрос, но не выполняет его
type HTTPClient interface {
Get(url string) (*http.Response, error)
}
func fetchUser(client HTTPClient, id int) (User, error) {
resp, err := client.Get("https://api.example.com/users/" + strconv.Itoa(id))
// ... парсинг
return user, err
}
> ✅ Здесь мы инвертируем зависимости — не создаём HTTP-клиент или логгер внутри функции, а получаем их как параметры.
> Это позволяет в тестах подменять их на заглушки (mocks) — и проверять только логику, а не побочные эффекты.
Преимущества чистых функций
| Преимущество | Объяснение |
|---|---|
| Предсказуемость | Вы всегда знаете, что функция вернёт. Нет сюрпризов. |
| Тестируемость | Не нужны фреймворки для мокинга. Тест — это просто assert(f(2,3) == 5). |
| Рефакторинг | Можно перемещать, переименовывать, выносить — ничего не сломается. |
| Параллелизм | Чистые функции не требуют блокировок. Их можно запускать в Goroutines без риска гонок. |
| Кэширование (мемоизация) | Можно кэшировать результаты без риска неактуальности. |
| Отладка | Можно легко воспроизвести проблему — просто передайте те же аргументы. |
Чистые функции в Go: реальные примеры
// ✅ Чистая: никаких зависимостей, только вход-выход
func square(n int) int {
return n * n
}
// ✅ Чистая: работает с иммутабельными структурами
type Point struct{ X, Y float64 }
func addPoints(p1, p2 Point) Point {
return Point{X: p1.X + p2.X, Y: p1.Y + p2.Y}
}
// ✅ Чистая: возвращает ошибку как часть результата (Go-стиль)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
> ✅ Все эти функции можно тестировать без net/http, без os, без time, без fmt — только вход → выход.
Чистые функции и чистый код
Чистая функция — это не только про синтаксис, но и про архитектурный стиль.
- Вместо того чтобы вызывать
log.Println()внутри обработчика HTTP — передавайтеLoggerинтерфейс. - Вместо
os.Getenv("DB_URL")— передавайте строку подключения как параметр. - Вместо
time.Now()— передавайтеtime.Timeкак аргумент.
> Это называется инъекцией зависимостей — и это основа чистой архитектуры (Clean Architecture, Hexagonal Architecture).
Когда можно нарушать чистоту?
Никогда — если вы хотите сохранить надёжность.
Но в реальных системах не всё может быть чистым. Ввод-вывод, сеть, база данных, файлы — это неизбежные грязные точки.
Правило:
> Создавайте чистые функции как можно дальше от внешнего мира.
> Грязные операции — только на краю системы (например, в HTTP-хендлерах, в репозиториях, в сервисах).
> Всю бизнес-логику — чистой.
// ❌ Грязный: логика в хендлере
func handleUser(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
user, err := db.GetUser(id) // ← грязно: база данных
if err != nil {
log.Printf("Error: %v", err) // ← грязно: логирование
http.Error(w, "Internal error", 500)
return
}
user.Name = strings.ToUpper(user.Name) // ← это можно сделать чисто!
json.NewEncoder(w).Encode(user) // ← грязно: вывод в HTTP
}
// ✅ Чистый вариант
func processUser(user User) User {
user.Name = strings.ToUpper(user.Name)
return user
}
func handleUser(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
user, err := userRepository.Get(id) // ← грязная точка: репозиторий
if err != nil {
log.Printf("Error fetching user: %v", err) // ← грязная точка: лог
http.Error(w, "Internal error", 500)
return
}
user = processUser(user) // ✅ чистая функция
json.NewEncoder(w).Encode(user) // ← грязная точка: HTTP
}
> ✅ Бизнес-логика (processUser) — чистая.
> Интерфейсы с внешним миром — грязные, но изолированные.
Заключение
Чистая функция — это не мода, а инженерный принцип, который снижает сложность на уровне кода, тестов и поддержки.
> 🌟 Чистая функция = Надёжность + Предсказуемость + Масштабируемость
В Go, где простота и эффективность — главные ценности, чистые функции — это не опционально, а стандарт.
- Используйте иммутабельность.
- Избегайте глобальных состояний.
- Передавайте все зависимости как параметры.
- Тестируйте только вход-выход.
- Делайте всё чистое как можно дальше от I/O.
Тот, кто понимает чистые функции — понимает, как писать системы, которые не ломаются.
Именно это отличает опытного разработчика от начинающего.
Вопрос 40. Какие тест-кейсы вы бы добавили для функции подсчёта гласных?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:49:02"/>
Ответ собеседника: Правильный. Проверка на пустую строку, строку без гласных, строку со всеми гласными, строку с цифрами, строку с разным регистром букв.
Правильный ответ:
Собеседник перечислил ключевые базовые случаи, что уже говорит о хорошем понимании тестирования. Однако для production-ready функции подсчёта гласных — особенно в многоязычной, Unicode-совместимой среде — требуется глубокий и системный подход к покрытию. Ниже — полный, архитектурно продуманный набор тест-кейсов, отражающий реальные сценарии использования и скрытые риски, с которыми сталкиваются системы в продакшене.
1. Пустая строка (edge case)
{"empty string", "", 0}
> ✅ Проверяет, что функция не паникует и корректно обрабатывает нулевой ввод.
> Частая причина ошибок — попытки итерировать по nil или не инициализированным строкам.
2. Строка без гласных (минимальный случай)
{"no vowels", "bcdfg123!@#", 0}
> ✅ Убеждается, что функция не ложноположительно считает символы как гласные.
> Особенно важно, если используется strings.ContainsAny или регулярка — они могут захватить не то, что нужно.
3. Строка, содержащая ТОЛЬКО гласные (максимальный случай)
{"all vowels", "aeiouAEIOUаеиоуыэюяАЕИОУЫЭЮЯ", 20}
> ✅ Проверяет максимальную нагрузку на логику подсчёта.
> Важно убедиться, что все гласные (латиница + кириллица) считаются, и счётчик не пропускает ни один символ.
4. Строка с цифрами, символами, пробелами (смешанный ввод)
{"mixed with digits and symbols", "h3ll0 w0rld!", 2}
> ✅ Проверяет, что функция игнорирует не-буквенные символы.
> Частая ошибка — использование unicode.IsLetter без проверки на принадлежность к гласным, или наоборот — нефильтруется 0-9, !@#$%.
5. Строка с разным регистром (case sensitivity)
{"mixed case", "HeLLo WoRLd", 2}
> ✅ Проверяет, что регистр не влияет на результат.
> Если используется map[byte]bool, то нужно включать и прописные, и строчные буквы.
> Если используется strings.ToLower() — проверить, что он не ломает Unicode.
6. Строка с акцентированными символами (Unicode диакритики)
{"accented latin", "café naïve résumé", 5} // é, a, i, é, u, é → 5 гласных
> ✅ Критически важный кейс.
> Символ é — это один rune (U+00E9), а не e + ´.
> Если функция итерирует по байтам (for i := 0; i < len(s); i++) — é будет разбит на два байта и не распознан как гласная.
> Это самая частая ошибка в Go-коде при работе с UTF-8.
> 💡 Тест должен упасть, если используется for i := 0; i < len(s); i++ вместо for _, r := range s.
7. Строка с комбинированными символами (Unicode combining marks)
{"combining marks", "n\u0303a", 1} // "n" + ̃ + "a" → "ña" → 1 гласная (a)
> ✅ Проверяет обработку комбинируемых символов — когда диакритика представлена отдельным rune.
> В этом примере n + U+0303 (тильда) + a → визуально ña, но в памяти — три rune.
> Функция должна игнорировать диакритические знаки и считать только основную гласную (a).
> ⚠️ Если функция считает U+0303 как гласную — это ошибка.
8. Строка с кириллицей, греческими, арабскими символами (многоязычность)
{"cyrillic", "привет", 2}, // и, е
{"greek", "ελληνικά", 4}, // ε, η, ι, α
{"arabic", "مرحبا", 1}, // ا
> ✅ Проверяет расширяемость функции.
> В современных системах (мессенджеры, CRM, локализованные сервисы) строка может содержать любой алфавит.
> Функция должна быть дизайном на расширение — а не жёстко привязана к латинице.
> ✅ Решение: Использовать map[rune]bool с явно заданными гласными для нужных языков.
9. Строка с эмодзи и не-буквенными Unicode символами
{"emoji", "hello 🌍❤️", 2}
> ✅ Проверяет, что функция не падает на эмодзи (🌍, ❤️) и не считает их как гласные.
> Эмодзи — это много-байтовые Unicode-коды, и если функция не использует rune-итерацию — может повести себя неожиданно.
10. Очень длинная строка (производительность и память)
{"long string", strings.Repeat("aeiou", 10000), 50000}
> ✅ Проверяет производительность и отсутствие утечек памяти.
> Если внутри функции создаётся копия строки, или используется strings.Builder, или регулярка — это может привести к OOM.
> Правильная реализация — один проход по rune, без аллокаций.
> 📊 Бенчмарк обязателен: > ```go` > func BenchmarkCountVowels(b *testing.B) { > s := strings.Repeat("The quick brown fox jumps over the lazy dog.", 100) > for i := 0; i < b.N; i++ { > countVowels(s) > } > } > ````
> ✅ Цель: < 100 ns/op на строку из 1000 символов.
11. Некорректная кодировка UTF-8 (некорректный ввод)
{"invalid utf8", "hello\xffworld", 2}
> ✅ Проверяет устойчивость к некорректным данным.
> Go-строки могут содержать некорректный UTF-8 — например, при чтении из сети или файла.
> Функция не должна паниковать.
> for _, r := range s автоматически заменяет некорректные байты на U+FFFD (), и это ожидаемое поведение — функция должна его корректно обрабатывать.
> ✅ Это не ошибка, а защита от атак.
12. Случайная генерация (property-based тесты)
// Используя пакет github.com/stretchr/testify/require + генератор
for i := 0; i < 1000; i++ {
s := generateRandomStringWithVowels()
count := countVowels(s)
expected := countManually(s) // простая реализация для проверки
assert.Equal(t, expected, count)
}
> ✅ Проверяет свойства функции — не только конкретные случаи, а обобщённое поведение.
> Например:
> - Если удалить гласную из строки — счётчик уменьшится на 1.
> - Если удвоить строку — счётчик удвоится.
> Это выявляет скрытые логические ошибки, которые не видны в ручных тестах.
Итоговый набор тест-кейсов (рекомендуемый)
func TestCountVowels(t *testing.T) {
tests := []struct {
name string
input string
expected int
}{
{"empty string", "", 0},
{"no vowels", "bcdfg123!@#", 0},
{"all vowels", "aeiouAEIOUаеиоуыэюяАЕИОУЫЭЮЯ", 20},
{"mixed with digits and symbols", "h3ll0 w0rld!", 2},
{"mixed case", "HeLLo WoRLd", 2},
{"accented latin", "café naïve résumé", 5},
{"combining marks", "n\u0303a", 1}, // ña
{"cyrillic", "привет", 2},
{"greek", "ελληνικά", 4},
{"arabic", "مرحبا", 1},
{"emoji", "hello 🌍❤️", 2},
{"long string", strings.Repeat("aeiou", 10000), 50000},
{"invalid utf8", "hello\xffworld", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := countVowels(tt.input)
if got != tt.expected {
t.Errorf("countVowels(%q) = %d; want %d", tt.input, got, tt.expected)
}
})
}
}
Заключение
Тестирование функции подсчёта гласных — это не про буквы, а про понимание Unicode, UTF-8, безопасность, производительность и надёжность.
> ✅ Правильный тест-кейс — это не просто "проверка на 'hello'", а модель реального мира, в котором строка может быть:
> - Пришедшей из API
> - Введённой пользователем
> - Загруженной из файла
> - Содержать эмодзи, кириллицу, диакритики
> - Быть некорректно закодированной
> 💡 Совет для senior-разработчика:
> Если вы не протестировали функцию на naïve и Malmö — вы не готовы к международному продукту.
> Если вы не написали бенчмарк — вы не понимаете, как ваш код ведёт себя под нагрузкой.
> Если вы не проверили invalid utf8 — вы открываете вектор атаки.
Чистая, надёжная, тестируемая функция — это не реализация, а архитектурное решение.
Вопрос 41. Как бы вы решили задачу с группировкой анаграмм?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:51:53"/>
Ответ собеседника: Правильный. Кандидат предлагает два подхода: 1) Через Map — использовать отсортированные строки как ключи; 2) Более сложный — отсортировать все элементы, создать новый массив с отсортированными версиями, затем искать соответствия по индексам между оригинальным и отсортированным массивами.
Правильный ответ:
Группировка анаграмм — классическая задача на хеширование и преобразование данных, часто встречающаяся на собеседованиях. Она проверяет не только знание структур данных, но и понимание эффективности, масштабируемости и устойчивости к граничным случаям.
Собеседник правильно указал на основной и правильный подход — использование отсортированных символов в качестве ключа. Однако второй предложенный им подход (создание отдельного отсортированного массива и поиск по индексам) — избыточный, неэффективный и подвержен ошибкам. Ниже — полный, глубокий, production-ready анализ решения.
Что такое анаграмма?
Анаграммы — это слова или фразы, состоящие из одних и тех же символов в одном и том же количестве, но в разном порядке.
Пример:
"eat", "tea", "ate" → анаграммы
"hello", "bello" → не анаграммы
Цель: сгруппировать все строки из входного массива по анаграммным семействам.
✅ Оптимальное решение: хеширование через отсортированный ключ
Идея:
Если два слова — анаграммы, то при сортировке их символов они превращаются в одинаковую строку.
Пример:
"listen"→"eilnst""silent"→"eilnst""enlist"→"eilnst"
→ Все они имеют одинаковый ключ → можно использовать его как ключ в map, а значения — списки анаграмм.
Реализация на Go:
package main
import (
"sort"
"strings"
)
func groupAnagrams(strs []string) [][]string {
groups := make(map[string][]string)
for _, s := range strs {
// Преобразуем строку в rune-срез для корректной сортировки Unicode
runes := []rune(s)
sort.Slice(runes, func(i, j int) bool {
return runes[i] < runes[j]
})
sorted := string(runes)
// Добавляем исходную строку в группу по отсортированному ключу
groups[sorted] = append(groups[sorted], s)
}
// Преобразуем map в результат
result := make([][]string, 0, len(groups))
for _, group := range groups {
result = append(result, group)
}
return result
}
Почему это правильно?
| Характеристика | Обоснование |
|---|---|
| Временная сложность | O(N × K log K), где N — количество строк, K — средняя длина строки. Сортировка каждой строки — доминирующая операция. |
| Пространственная сложность | O(N × K) — храним все строки и их отсортированные версии. |
| Поддержка Unicode | Используется []rune — корректно обрабатывает emoji, кириллицу, акценты (café, résumé). |
| Идемпотентность | Одинаковые анаграммы всегда дают один ключ — гарантирует правильную группировку. |
| Читаемость и простота | Логика ясна, код легко поддерживать, нет сложных индексов или побочных структур. |
❌ Второй подход кандидата — зачем он неверен?
> "Отсортировать все элементы, создать новый массив с отсортированными версиями, затем искать соответствия по индексам между оригинальным и отсортированным массивами."
Этот подход технически возможен, но непрактичен и опасен:
Пример неправильной реализации:
// ❌ НЕПРАВИЛЬНЫЙ ПОДХОД — избегать!
sorted := make([]string, len(strs))
for i, s := range strs {
sorted[i] = sortString(s) // отсортированная версия
}
result := [][]string{}
for i := 0; i < len(strs); i++ {
for j := i + 1; j < len(strs); j++ {
if sorted[i] == sorted[j] {
// ... добавить strs[i] и strs[j] в группу
}
}
}
Проблемы этого подхода:
| Проблема | Объяснение |
|---|---|
| O(N²) сложность | Поиск по индексам требует вложенных циклов → при 1000 строк — 500 000 сравнений. Вместо O(N) с map — становится неприемлемым. |
| Дублирование данных | Хранятся две копии массива — оригинальный и отсортированный — без необходимости. |
| Сложность группировки | Как собрать все анаграммы в одну группу? Нужно отслеживать, какие индексы уже обработаны — легко ошибиться. |
| Нарушение порядка | Результат может быть неупорядочен, и восстановить исходную группировку — сложно. |
| Уязвимость к повторам | Если строка встречается 3 раза — как избежать дублирования? Нужна дополнительная логика. |
> 🔥 Это как пытаться найти все одинаковые ключи в замке, перебирая все пары замков — вместо того, чтобы просто положить их в коробки по форме ключа.
✅ Альтернативные подходы (для контекста)
1. Частотный счёт (Counting Characters)
Использовать массив частот (для ASCII) как ключ:
func getSignature(s string) string {
count := make([]int, 26)
for _, r := range s {
if r >= 'a' && r <= 'z' {
count[r-'a']++
} else if r >= 'A' && r <= 'Z' {
count[r-'A']++
}
}
return strings.Join(strings.Fields(fmt.Sprint(count)), "")
}
> ✅ Плюсы: Не требует сортировки — O(K) на строку.
> ❌ Минусы: Не работает с Unicode, не поддерживает не-латиницу, сложность кода растёт.
> → Подходит только для ограниченных наборов символов (например, только английские буквы).
2. Хеш-сумма по простым числам
Сопоставить каждой букве простое число (a=2, b=3, c=5...), перемножить — произведение для анаграмм будет одинаково.
primes := []int{2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101}
hash := 1
for _, r := range s {
hash *= primes[r-'a']
}
> ✅ Плюсы: О(1) на сравнение, очень быстрый.
> ❌ Минусы:
> - Переполнение — даже для 20-символьных слов произведение выходит за пределы int64.
> - Не работает с Unicode.
> - Требует предварительной таблицы.
> → Только для академических задач, не для продакшена.
✅ Тестирование: что нужно покрыть
func TestGroupAnagrams(t *testing.T) {
tests := []struct {
name string
input []string
expected [][]string
}{
{
name: "basic case",
input: []string{"eat", "tea", "tan", "ate", "nat", "bat"},
expected: [][]string{{"eat", "tea", "ate"}, {"tan", "nat"}, {"bat"}},
},
{
name: "empty",
input: []string{},
expected: [][]string{},
},
{
name: "single",
input: []string{"hello"},
expected: [][]string{{"hello"}},
},
{
name: "all same",
input: []string{"abc", "bca", "cab"},
expected: [][]string{{"abc", "bca", "cab"}},
},
{
name: "unicode",
input: []string{"café", "féca", "écaf"},
expected: [][]string{{"café", "féca", "écaf"}},
},
{
name: "with spaces",
input: []string{"listen", "silent", "enlist"},
expected: [][]string{{"listen", "silent", "enlist"}},
},
{
name: "mixed case",
input: []string{"Eat", "TAE", "tea"},
expected: [][]string{{"Eat", "TAE", "tea"}}, // важно: регистр сохраняется!
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := groupAnagrams(tt.input)
// Проверяем, что все группы присутствуют (порядок групп не важен)
if !compareGroups(result, tt.expected) {
t.Errorf("got %v, want %v", result, tt.expected)
}
})
}
}
// Вспомогательная функция для сравнения групп (независимо от порядка)
func compareGroups(got, want [][]string) bool {
if len(got) != len(want) {
return false
}
wantSet := make(map[string]bool)
for _, group := range want {
sort.Strings(group)
wantSet[strings.Join(group, ",")] = true
}
gotSet := make(map[string]bool)
for _, group := range got {
sort.Strings(group)
gotSet[strings.Join(group, ",")] = true
}
return reflect.DeepEqual(gotSet, wantSet)
}
> ✅ Тесты покрывают:
> - Пустой ввод
> - Unicode (акценты)
> - Регистр (не должен влиять на группировку, но сохраняться в результате)
> - Порядок групп — не важен, но содержимое групп — обязательно
✅ Производительность: бенчмарк
func BenchmarkGroupAnagrams(b *testing.B) {
strs := []string{"eat", "tea", "tan", "ate", "nat", "bat"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
groupAnagrams(strs)
}
}
> 📊 Ожидаемый результат:
> BenchmarkGroupAnagrams-8 1000000 1200 ns/op — быстро и предсказуемо.
📌 Заключение: Почему первый подход — единственно правильный
- ✅ Один проход по массиву.
- ✅ O(N × K log K) — оптимально для сравнения перестановок.
- ✅ Map как хеш-таблица — идеальная структура для группировки.
- ✅ Не требует дополнительных структур.
- ✅ Работает с любыми Unicode-символами.
- ✅ Легко читается, тестируется, масштабируется.
> 💡 Совет для senior-разработчика:
> Не выбирайте "более сложный" подход просто потому, что он кажется "глубже".
> Лучшее решение — самое простое, которое работает.
>
> Если вы предлагаете O(N²) решение вместо O(N log K) — вы не понимаете базовую асимптотику.
> Если вы не используете []rune — вы не умеете работать с Unicode.
> Если вы не тестируете café или résumé — вы не готовы к международным продуктам.
Правильное решение — это не код, а архитектурный выбор.
И в этом случае — map с отсортированным ключом — это золотой стандарт.
Вопрос 42. Как бы вы реализовали мини-jQuery библиотеку с методами chainable API?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="00:57:56"/>
Ответ собеседника: Правильный. Кандидат предлагает реализовать через класс или функцию. Создать конструктор, который принимает селектор и возвращает объект с методами. Методы должны возвращать this для chainable вызовов. Для html() метода добавить санитизацию через регулярные выражения для защиты от XSS.
Правильный ответ:
Реализация мини-jQuery — это не просто упражнение на синтаксис. Это глубокий тест на понимание DOM-манипуляций, проектирования API, безопасность, производительность и принципов ООП/функционального стиля в JavaScript. Собеседник правильно отметил ключевые аспекты: цепочечность (chainable), конструктор, санитизация — но для production-grade решения этого недостаточно.
Ниже — полная, архитектурно продуманная реализация, соответствующая реальным требованиям современных фронтенд-систем, с акцентом на безопасность, масштабируемость и читаемость.
✅ 1. Основные требования
- Конструктор, принимающий CSS-селектор или DOM-элемент.
- Chainable API: каждый метод возвращает
this(или новый экземпляр). - Поддержка базовых методов:
html(),text(),addClass(),removeClass(),on(),find(),each(). - Защита от XSS — обязательно для
html(). - Поддержка множественных элементов — не только первый, а все совпадающие.
- Нет зависимостей — чистый JavaScript, без внешних библиотек.
✅ 2. Архитектурное решение
Мы реализуем функцию-конструктор (как в оригинальном jQuery), а не класс — потому что:
- Это лучше совместимо с историческим кодом и стилем.
- Позволяет избежать
new— можно вызывать как$(selector), а неnew $(selector). - Легче оптимизировать через прототипное наследование.
✅ 3. Полная реализация
(function(global) {
// Приватная функция для безопасной санитизации HTML
function sanitizeHTML(html) {
if (typeof html !== 'string') return '';
// Экранируем все теги, превращая их в текст
const div = document.createElement('div');
div.textContent = html;
return div.innerHTML;
}
// Конструктор $ — возвращает объект с методами
function $(selector) {
// Поддержка DOM-элементов, строк, массивов
let elements = [];
if (typeof selector === 'string') {
// Используем querySelectorAll для поддержки сложных селекторов
elements = Array.from(document.querySelectorAll(selector));
} else if (selector && selector.nodeType) {
// Одиночный DOM-элемент
elements = [selector];
} else if (Array.isArray(selector)) {
elements = selector.filter(el => el && el.nodeType);
}
// Создаём объект с прототипом, чтобы методы были общими
const api = Object.create($prototype);
api.elements = elements;
return api;
}
// Прототип методов — ключ к chainable API
const $prototype = {
// Получить/установить HTML — с санитизацией при записи
html: function(content) {
if (content === undefined) {
// Геттер: возвращаем HTML первого элемента
return this.elements.length > 0 ? this.elements[0].innerHTML : '';
}
// Сеттер: устанавливаем HTML для ВСЕХ элементов
this.elements.forEach(el => {
el.innerHTML = sanitizeHTML(content);
});
return this; // ← chainable!
},
// Получить/установить текст — безопасен по умолчанию
text: function(content) {
if (content === undefined) {
return this.elements.length > 0 ? this.elements[0].textContent : '';
}
this.elements.forEach(el => {
el.textContent = content;
});
return this;
},
// Добавить класс
addClass: function(className) {
if (!className || typeof className !== 'string') return this;
const classes = className.split(/\s+/).filter(c => c.length > 0);
this.elements.forEach(el => {
el.classList.add(...classes);
});
return this;
},
// Удалить класс
removeClass: function(className) {
if (!className || typeof className !== 'string') return this;
const classes = className.split(/\s+/).filter(c => c.length > 0);
this.elements.forEach(el => {
el.classList.remove(...classes);
});
return this;
},
// Добавить обработчик событий
on: function(eventType, handler) {
if (typeof eventType !== 'string' || typeof handler !== 'function') return this;
this.elements.forEach(el => {
el.addEventListener(eventType, handler);
});
return this;
},
// Поиск по дочерним элементам (аналог find())
find: function(selector) {
if (typeof selector !== 'string') return $([]);
let found = [];
this.elements.forEach(el => {
found = found.concat(Array.from(el.querySelectorAll(selector)));
});
return $(found); // ← возвращаем новый экземпляр, сохраняя цепочку
},
// Итерация по элементам (аналог each())
each: function(callback) {
if (typeof callback !== 'function') return this;
this.elements.forEach((el, index) => {
callback.call(el, index, el); // this внутри callback — текущий элемент
});
return this;
},
// Удалить элемент из DOM
remove: function() {
this.elements.forEach(el => {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
});
this.elements = []; // очищаем список — цепочка больше не актуальна
return this;
},
// Получить количество элементов
length: function() {
return this.elements.length;
}
};
// Экспорт в глобальную область
global.$ = $;
})(typeof window !== 'undefined' ? window : global);
✅ 4. Почему это правильно?
| Характеристика | Обоснование |
|---|---|
| Chainable API | Все методы возвращают this — позволяет писать: $('#btn').addClass('active').html('Click me').on('click', handler) |
| Безопасность (XSS) | html() всегда санитизирует входные данные через textContent → innerHTML — никогда не использует innerHTML = rawInput |
| Поддержка множественных элементов | querySelectorAll() возвращает NodeList — мы превращаем его в массив и обрабатываем все элементы |
| Гибкость селекторов | Использует querySelectorAll() — поддерживает .class, #id, [data-id], div > span, и т.д. |
| Поддержка DOM-элементов | Можно передать $(document.getElementById('foo')) — как в jQuery |
| Нет прототипных загрязнений | Все методы — на prototype, не на каждом экземпляре |
Корректная this в each() | callback.call(el, ...) — внутри колбэка this = текущий DOM-элемент — поведение как в jQuery |
| Типизация и защита от ошибок | Проверки на undefined, typeof, пустые строки — предотвращают TypeError |
| Нет зависимостей | Чистый Vanilla JS — работает в IE11+ (при полифиллах), в любых средах |
✅ 5. Примеры использования
// Создание и цепочка
$('#myButton')
.addClass('btn-primary')
.html('<strong>Click me!</strong>')
.on('click', function() {
alert('Button clicked!');
});
// Поиск вложенных элементов
$('.container')
.find('.item')
.addClass('highlight')
.each(function(index, el) {
console.log(`Item ${index}:`, el.textContent);
});
// Безопасность: XSS защита
const userInput = '<script>alert("XSS")</script>';
$('#content').html(userInput); // → Показывает текст: "<script>alert("XSS")</script>" — НЕ выполняется!
// Работа с DOM-элементом
const elem = document.querySelector('#header');
$(elem).css('color', 'red'); // ← в реальной реализации можно добавить .css()
> ⚠️ Важно: в этом примере мы не реализовали .css(), но можно легко добавить, используя el.style.property = value — и он тоже должен возвращать this.
✅ 6. Улучшения для production
| Улучшение | Обоснование |
|---|---|
Поддержка .css() | Добавить метод, который работает с el.style и поддерживает camelCase (backgroundColor). |
Поддержка .attr() / .prop() | Разделение атрибутов DOM и свойств объекта. |
Поддержка .val() для форм | Для <input>, <select>, <textarea>. |
| Полифиллы для старых браузеров | Array.from, classList, forEach — если нужна IE11-совместимость. |
| Модульная загрузка | Экспорт через module.exports / export default для ES6+ модулей. |
| Тесты | Покрыть Jest/Chai: проверка XSS, цепочки, find(), each() с this. |
| Tree-shaking | Если используется сборщик (Webpack, Vite) — экспортировать как модуль, а не глобальную переменную. |
✅ 7. Почему не использовать class?
class $ {
constructor(selector) { ... }
html() { return this; }
}
→ Можно. Но:
- Требует
new $(...)— не совместимо с jQuery. - Не позволяет писать
$(selector)безnew— нарушает ожидания. - В старых браузерах
classможет быть проблематичен.
jQuery сделал выбор в пользу функции — и он остался лучшим.
✅ 8. Безопасность: почему sanitizeHTML() работает
const div = document.createElement('div');
div.textContent = html; // ✅ Все теги экранируются как текст
return div.innerHTML; // ✅ Возвращаем безопасный HTML-текст
> 🔒 Это 100% защита от XSS — потому что браузер никогда не исполняет скрипты внутри textContent.
> ❌ innerHTML = html — всегда опасно, даже если вы "фильтруете" теги через регулярки.
> Регулярки не могут корректно парсить HTML — см. OWASP XSS Prevention Cheat Sheet.
✅ 9. Заключение: что делает это решение senior-level
- ✅ Понимание DOM API — не просто
querySelector, аquerySelectorAll,classList,textContent. - ✅ Понимание безопасности — не "регулярка", а браузерная санитизация.
- ✅ Понимание chainable API — не просто
return this, а сохранение состояния, возврат новых экземпляров приfind(). - ✅ Понимание контекста —
thisвeach()должен быть DOM-элементом. - ✅ Понимание производительности — один проход, нет лишних аллокаций.
- ✅ Понимание масштабируемости — легко расширить, не ломая API.
> 💡 Совет для senior-разработчика:
> Настоящий jQuery — это не только методы. Это соглашение, безопасность, обратная совместимость и уважение к платформе.
>
> Если вы пишете библиотеку, которая не защищает от XSS — вы не разработчик, вы риск-менеджер, который игнорирует угрозы.
>
> Если вы не возвращаете this — вы не умеете проектировать API.
>
> Если вы используете innerHTML без санитизации — вы не готовы к продакшену.
✅ Итог
Это не "мини-jQuery" — это реалистичная, безопасная, производительная и профессиональная реализация, которую можно вставить в реальный проект и доверить её работу с пользовательским контентом.
> ✅ Цель достигнута: chainable, безопасный, масштабируемый, без зависимостей.
> ✅ Уровень: Senior Frontend Engineer.
> ✅ Бонус: вы можете расширить её до полноценного фреймворка — с on, off, delegate, animate, promise — это уже следующий уровень.
Вопрос 43. Как реализовать компонент React для отображения данных с API?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:07:58"/>
Ответ собеседника: Правильный. Создать состояние для данных. В useEffect сделать запрос к API с помощью fetch, преобразовать ответ в JSON с помощью .json(), установить данные в состояние. Отобразить данные в виде списка.
Правильный ответ:
Реализация компонента React для загрузки и отображения данных с API — одна из наиболее частых задач на собеседованиях, но именно в её простоте скрыты десятки нюансов, которые разделяют новичка от senior-разработчика. Собеседник дал базовый, рабочий ответ — и он технически корректен. Однако в продакшене такой код приведёт к утечкам памяти, ошибкам в пользовательском интерфейсе, проблемам с производительностью и уязвимостям безопасности.
Ниже — полная, production-ready реализация, соответствующая современным стандартам React (Hooks, TypeScript, лучшим практикам), с глубоким объяснением каждого решения.
✅ 1. Основные требования к компоненту
- Загружать данные с внешнего API (например,
/api/users). - Отображать состояние загрузки, ошибки и успеха.
- Отображать данные в виде списка (например, пользователей).
- Корректно обрабатывать отмену запроса при размонтировании.
- Избегать уязвимостей (например, отображения сырых данных без валидации).
- Поддерживать тестирование.
- Использовать TypeScript для типизации (рекомендуется в 90% современных проектов).
✅ 2. Полная реализация на React + TypeScript
import React, { useState, useEffect } from 'react';
// ——————— ТИПЫ ———————
interface User {
id: number;
name: string;
email: string;
avatar?: string; // опционально
}
type ApiResponse = {
data: User[];
total: number;
page: number;
};
type FetchState<T> =
| { status: 'idle'; data: null; error: null }
| { status: 'loading'; data: null; error: null }
| { status: 'success'; data: T; error: null }
| { status: 'error'; data: null; error: string };
// ——————— КОМПОНЕНТ ———————
const UserList: React.FC = () => {
// Состояние загрузки
const [fetchState, setFetchState] = useState<FetchState<ApiResponse>>({
status: 'idle',
data: null,
error: null,
});
// Загрузка данных
useEffect(() => {
// ——— Отмена при размонтировании ———
let isMounted = true;
const fetchUsers = async () => {
setFetchState({ status: 'loading', data: null, error: null });
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
// ——— Проверка на монтированность перед обновлением состояния ———
if (isMounted) {
setFetchState({ status: 'success', data, error: null });
}
} catch (err) {
if (isMounted) {
setFetchState({
status: 'error',
data: null,
error: err instanceof Error ? err.message : 'Unknown error',
});
}
}
};
fetchUsers();
// ——— Очистка при размонтировании ———
return () => {
isMounted = false;
};
}, []); // Пустой массив — запрос только при монтировании
// ——— Отображение UI в зависимости от состояния ———
if (fetchState.status === 'idle' || fetchState.status === 'loading') {
return (
<div className="user-list">
<p>Loading users...</p>
</div>
);
}
if (fetchState.status === 'error') {
return (
<div className="user-list">
<div className="error">
<p>Failed to load users: {fetchState.error}</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
</div>
);
}
// Успешный статус
const { data } = fetchState;
if (!data || !data.data.length) {
return (
<div className="user-list">
<p>No users found.</p>
</div>
);
}
return (
<div className="user-list">
<h2>Users ({data.total})</h2>
<ul>
{data.data.map((user) => (
<li key={user.id} className="user-item">
<img
src={user.avatar || 'https://via.placeholder.com/40'}
alt={`${user.name}'s avatar`}
width="40"
height="40"
onError={(e) => {
(e.target as HTMLImageElement).src = 'https://via.placeholder.com/40';
}}
/>
<div className="user-info">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
</li>
))}
</ul>
</div>
);
};
export default UserList;
✅ 3. Почему это правильно? — Глубокий разбор
✅ 1. Использование FetchState с типизацией
type FetchState<T> =
| { status: 'idle'; data: null; error: null }
| { status: 'loading'; data: null; error: null }
| { status: 'success'; data: T; error: null }
| { status: 'error'; data: null; error: string };
> 💡 Зачем?
> Это состояние с явным перечислением всех возможных состояний — не isLoading, isError, data — а одно состояние, которое описывает всё.
> Это предотвращает гонки состояний, упрощает логику отображения и обязывает разработчика обрабатывать все кейсы.
> Это лучшая практика в React-экосистеме (см. React Query, SWR, TanStack).
✅ 2. isMounted флаг для отмены запросов
let isMounted = true;
// ...
return () => { isMounted = false; };
> 💡 Зачем?
> Если компонент размонтируется до завершения fetch(), а вы пытаетесь вызвать setFetchState(...), React выдаст предупреждение:
> Can't perform a React state update on an unmounted component
> Это утечка памяти и потенциальный баг.
> isMounted — стандартный и надёжный паттерн в хуках.
> Примечание: в будущем можно использовать AbortController, но он сложнее и не всегда нужен.
✅ 3. Валидация ответа API
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
> 💡 Зачем?
> fetch() не отклоняется при HTTP 4xx/5xx — только при сетевых ошибках.
> Без проверки response.ok вы получите null или {} в data, а пользователь увидит пустой список — и не поймёт почему.
> Это ключевая ошибка в 80% учебных примеров.
✅ 4. Обработка ошибок
error: err instanceof Error ? err.message : 'Unknown error'
> 💡 Зачем?
> fetch может бросить TypeError (например, при отсутствии интернета), а не только Error.
> Без проверки — err.message может быть undefined → ошибка рендеринга.
✅ 5. Безопасное отображение изображений
<img
src={user.avatar || 'https://via.placeholder.com/40'}
onError={(e) => { (e.target as HTMLImageElement).src = 'https://via.placeholder.com/40'; }}
/>
> 💡 Зачем?
> Если avatar — неправильный URL, браузер может показать broken image — плохой UX.
> Мы автоматически подставляем fallback.
> Используем as HTMLImageElement, чтобы TypeScript знал, что e.target — это <img>.
✅ 6. Отсутствие key={index}
<li key={user.id} ...>
> 💡 Зачем?
> key должен быть уникальным и стабильным.
> Использование index как ключа — крайне опасно при динамическом списке (сортировка, фильтрация, добавление).
> user.id — идеальный ключ, потому что он уникален и не меняется.
✅ 7. Тестирование
Вы можете легко протестировать этот компонент с помощью React Testing Library:
test('renders users when data is loaded', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: [{ id: 1, name: 'John', email: 'john@example.com' }],
total: 1,
page: 1,
}),
})
);
render(<UserList />);
expect(await screen.findByText('John')).toBeInTheDocument();
});
✅ 4. Альтернативы: когда использовать React Query / SWR?
Для реальных проектов — не пишите useEffect + fetch вручную, если только это не обучение.
Вместо этого используйте:
| Библиотека | Преимущества |
|---|---|
| React Query | Автоматическое кэширование, рефреш, пагинация, отмена, оптимизация запросов, синхронизация между окнами. |
| SWR (by Vercel) | Минималистичный, с фокусом на скорость и стриминг, идеален для SSR. |
Пример с React Query:
import { useQuery } from '@tanstack/react-query';
const UserList = () => {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{data?.map(user => <li key={user.id}>{user.name}</li>)}</ul>;
};
> 💡 Совет:
> Если на собеседовании вам задают вопрос о useEffect + fetch — ответьте сначала так, как выше.
> Затем добавьте:
> "В реальном проекте я бы использовал React Query, потому что он решает кэширование, рефреш, отмену и конкурентные запросы автоматически. Но я понимаю, как это работает на низком уровне — и могу реализовать вручную, если нужно."
✅ 5. Заключение: что делает это решение senior-level
| Критерий | Почему важно |
|---|---|
| ✅ Типизация (TypeScript) | Предотвращает 50% багов, улучшает читаемость и поддержку |
| ✅ Четкие состояния | Нет гонок, всё предсказуемо |
| ✅ Отмена запросов | Нет утечек памяти |
| ✅ Валидация ответов | Не показывает пустоту как успех |
| ✅ Обработка ошибок | Пользователь не остаётся в неведении |
| ✅ Безопасный рендеринг | Правильные key, fallback-изображения |
| ✅ Тестируемость | Код можно покрыть тестами |
| ✅ Соответствие best practices | Не учится по старым туториалам 2018 года |
> 💡 Золотое правило senior-разработчика:
> "Если вы не обрабатываете ошибку — вы не пишете код, вы пишете бомбу с таймером."
Этот компонент — не просто "отобразить данные". Это взаимодействие с внешним миром.
И в этом взаимодействии — ответственность, предвидение и профессионализм.
> ✅ Итог: вы не просто реализовали компонент.
> Вы защитили пользователей, систему и команду от будущих проблем.
Вопрос 44. Можно ли использовать index в качестве key в React и почему?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:12:04"/>
Ответ собеседника: Правильный. Использование index как key не рекомендуется. Это допустимо только если список никогда не меняется и данные не удаляются. Лучше использовать уникальные идентификаторы из данных.
Правильный ответ:
Использование индекса (index) в качестве key в React — технически возможно, и React не выдаст ошибку. Но это крайне опасная практика, которая подрывает основу эффективной работы React — управление диффом (reconciliation). Неправильное использование key приводит к непредсказуемым багам, потере состояния компонентов, замедлению рендеринга и некорректному поведению форм, анимаций и эффектов.
Разберёмся почему, когда это ломается, и как правильно.
✅ 1. Что делает key в React?
key — это уникальный идентификатор, который React использует для отслеживания элементов списка между рендерами. Он помогает React понять:
- Какие элементы добавлены?
- Какие удалены?
- Какие перемещены?
- Какие остались неизменными?
React использует key для оптимизации обновления DOM — он не пересоздаёт компоненты, если key не изменился.
Если key совпадает — React переиспользует существующий DOM-узел и его состояние.
Если key изменился — React уничтожает старый и создаёт новый.
> 🔑 Ключевое правило:
> key должен быть стабильным, уникальным и предсказуемым в рамках списка.
✅ 2. Почему index — плохой ключ? Примеры поломки
🚫 Пример 1: Удаление элемента из середины списка
const TodoList = ({ todos }) => {
const handleDelete = (index) => {
// Удаляем элемент по индексу
todos.splice(index, 1);
setTodos([...todos]);
};
return (
<ul>
{todos.map((todo, index) => (
<TodoItem
key={index} // ❌ ПЛОХО: ключ — индекс
todo={todo}
onDelete={() => handleDelete(index)}
/>
))}
</ul>
);
};
Сценарий:
- Изначально:
["A", "B", "C", "D"] - Пользователь удаляет
"B"(индекс 1) - После удаления:
["A", "C", "D"]
React видит:
- Элемент с
key=0— остался → пересоздавать не надо → сохраняет состояние - Элемент с
key=1— был"B", теперь"C"→ React переиспользует компонентTodoItem, который был для"B"→ и передаёт ему данные"C" - Элемент с
key=2— был"C", теперь"D"→ переиспользует компонент, который был для"C"
👉 Результат:
- Компонент, который был для
"B", теперь отображает"C" - Компонент, который был для
"C", теперь отображает"D" - Состояние компонентов (например, чекбокс "выполнено") перепуталось!
Если TodoItem содержит форму, инпут с фокусом, анимацию или useEffect с зависимостями — они потеряют корректное поведение.
> 💥 Это не теоретическая проблема — это реальные баги, которые возникают в продакшене, и их сложно отлаживать.
🚫 Пример 2: Сортировка списка
const [todos, setTodos] = useState([
{ id: 1, text: "Завтрак" },
{ id: 2, text: "Ужин" },
{ id: 3, text: "Обед" },
]);
// Пользователь нажимает "Сортировать по алфавиту"
setTodos([...todos].sort((a, b) => a.text.localeCompare(b.text)));
// → ["Обед", "Ужин", "Завтрак"]
С key={index}:
"Завтрак"(был key=0) → стал key=2 → React уничтожает его → сбрасывает состояние"Ужин"(был key=1) → стал key=1 → React переиспользует → сохраняет состояние"Обед"(был key=2) → стал key=0 → React уничтожает → сбрасывает состояние
👉 Результат:
Состояния компонентов перемешиваются, а не сортируются.
Пользователь видит, что "выполнено" для "Завтрака" теперь "применилось" к "Обеду".
🚫 Пример 3: Добавление элемента в начало списка
setTodos([{ text: "Новое дело" }, ...todos]);
Все индексы сдвигаются на 1.
React видит:
key=0→ был элемент A, теперь элемент B → переиспользует → всё состояние элемента B теперь относится к Akey=1→ был элемент B, теперь элемент C → переиспользует → и так далее
👉 Всё состояние в списке теряется или перепутывается.
✅ 3. Когда можно использовать index? (Очень редко!)
Есть единственный безопасный сценарий:
> ✅ Список статичен, не изменяется, не сортируется, не фильтруется и не удаляется.
Пример:
const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
return (
<ul>
{days.map((day, index) => (
<li key={index}>{day}</li>
))}
</ul>
);
Здесь:
- Нет изменений в структуре
- Нет состояния внутри элементов (
<li>— просто текст) - Никаких эффектов или форм
👉 Это допустимо.
Но даже здесь — лучше использовать key={day}, чтобы код был более семантичным и устойчивым к будущим изменениям.
> 💡 Правило:
> Если вы не уверены, что список никогда не изменится — не используйте индекс.
✅ 4. Как правильно? — Лучшие практики
✅ Используйте уникальный идентификатор из данных
const todos = [
{ id: 'a1b2c3', text: 'Завтрак', completed: false },
{ id: 'd4e5f6', text: 'Ужин', completed: true },
];
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
> ✅ Почему это правильно?
> - id не меняется даже при сортировке, удалении, добавлении
> - React точно знает, какой элемент какой
> - Состояние компонентов сохраняется корректно
> - Работает с любыми операциями над списком
✅ Если данных нет — сгенерируйте уникальный ключ
Если вы работаете с данными, где нет ID (например, пользователь вводит текст в форму и создаёт список):
import { v4 as uuidv4 } from 'uuid';
const [items, setItems] = useState([]);
const addItem = (text) => {
setItems([...items, { id: uuidv4(), text }]);
};
> ✅ Используйте библиотеки вроде uuid, nanoid — они гарантируют уникальность и производительность.
✅ Никогда не используйте Math.random() или Date.now()
// ❌ ОЧЕНЬ ПЛОХО
{items.map(item => <Item key={Math.random()} />)}
> 💥 key должен быть стабильным между рендерами.
> Math.random() генерирует новое значение каждый раз → React пересоздаёт компонент на каждом рендеринге → разрушает производительность и состояние.
✅ 5. Как React работает под капотом? — Кратко
Представьте список из трёх компонентов:
| Рендер 1 | Рендер 2 (удалён элемент 1) |
|---|---|
| A (key=0) | A (key=0) |
| B (key=1) | C (key=1) ← был на 2 |
| C (key=2) | — |
С key=index:
- React видит:
A— key=0 → пересоздавать не надо B— key=1 → но теперь на его местеC→ React переиспользует компонент B для данных CC— key=2 → исчез → React удаляет
👉 Компонент B, который был для данных B, теперь отображает данные C — и сохраняет своё состояние!
С key=uniqueId:
- A (id=1) → остался → пересоздавать не надо
- B (id=2) → удалён → React уничтожает его
- C (id=3) → новый → React создаёт новый компонент
👉 Состояния не перепутываются.
✅ 6. Как проверить, что key работает правильно?
- Добавьте
console.logвuseEffectвнутри компонента списка:useEffect(() => {console.log('TodoItem mounted:', todo.id);return () => console.log('TodoItem unmounted:', todo.id);}, [todo.id]); - Удалите элемент из середины.
- Посмотрите:
- Если компонент не уничтожается, а просто обновляется — значит, вы используете неправильный ключ.
- Если компонент уничтожается и создаётся заново — значит,
keyкорректен.
✅ 7. Заключение: что делает это решение senior-level
| Критерий | Почему важно |
|---|---|
| ✅ Понимание reconciliation | Не просто "не использовать index" — а понимать как React управляет DOM |
| ✅ Понимание состояния компонентов | key влияет не только на рендер — он влияет на всё состояние внутри компонента |
| ✅ Понимание производительности | Неправильные ключи = лишние пересоздания = тормоза |
| ✅ Понимание масштабируемости | Когда список растёт, начинаются сортировки, фильтры — индекс ломает всё |
| ✅ Понимание тестирования | Баги с ключами — одни из самых скрытых и трудноуловимых |
| ✅ Профессиональная ответственность | Вы не пишете "код, который работает". Вы пишете код, который не ломается |
> 💡 Совет для senior-разработчика:
> "Если вы не можете гарантировать, что key будет стабильным и уникальным — вы не имеете права использовать его."
✅ Итог
- ❌ Нельзя использовать
indexкакkey, если список может изменяться (удаляться, сортироваться, фильтроваться, добавляться). - ✅ Можно использовать
index— только если список статичен и неизменяем. - ✅ Всегда используйте уникальные идентификаторы (
id,uuid,nanoid) — это стандарт. - ✅ Никогда не генерируйте ключи динамически (
Math.random(),Date.now()). - ✅ Проверяйте поведение компонентов при изменениях списка — это обязательная часть тестирования UI.
> Правильный key — это не оптимизация. Это гарантия корректности.
> Игнорировать это — значит писать код, который работает сегодня, но сломается завтра.
Вопрос 45. Зачем нужен key в React и какие к нему требования?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:12:53"/>
Ответ собеседника: Правильный. Key нужен для оптимизации алгоритма сравнения React. Помогает определить какие элементы добавились, удалились или изменились между рендерами. Требования: уникальное примитивное значение, стабильное между рендерами.
Правильный ответ:
key в React — это не просто рекомендация, а фундаментальный механизм, на котором строится эффективность и предсказуемость обновления интерфейса. Его роль — не оптимизация ради оптимизации, а гарантия корректности работы компонентов при динамическом изменении списков.
✅ 1. Зачем нужен key? — Глубокая суть
React использует алгоритм диффинга (reconciliation) для минимизации операций с DOM. При каждом рендеринге React сравнивает предыдущее дерево (виртуальный DOM) с новым, и вычисляет, какие узлы нужно:
- Добавить (insert)
- Удалить (remove)
- Обновить (update)
- Переместить (move)
Без key React по умолчанию использует индекс элемента в массиве как идентификатор.
Это неявное поведение — и оно катастрофически ломается при любых изменениях в списке.
🧩 Пример без key (или с index):
const List = ({ items }) => (
<ul>
{items.map((item, index) => (
<Item key={index} item={item} />
))}
</ul>
);
Представьте список:
| Индекс | Элемент | Состояние (например, чекбокс) |
|---|---|---|
| 0 | A | ✅ Отмечено |
| 1 | B | ❌ Не отмечено |
| 2 | C | ✅ Отмечено |
Вы удаляете элемент B. Новый список:
| Индекс | Элемент | Состояние (до) | Состояние (после, с key=index) |
|---|---|---|---|
| 0 | A | ✅ | ✅ (сохранено) |
| 1 | C | ✅ | ❌ (переиспользован компонент из индекса 1) |
👉 React думает:
> “Элемент на позиции 1 был B — теперь это C. Но ключ остался 1 → значит, это тот же компонент. Обновлю его пропсы, но сохраню его внутреннее состояние.”
Результат:
- Компонент, который был для B, теперь отображает C — и его чекбокс остаётся неотмеченным, хотя у C он был отмечен.
- Состояние перепуталось.
Это не баг в вашем коде — это баг в логике рендеринга, вызванный неправильным использованием key.
✅ 2. Требования к key — Три кита надёжности
✅ 1. Уникальность (Uniqueness)
key должен быть уникальным среди сестринских элементов в одном списке.
// ✅ Правильно
{users.map(user => <User key={user.id} user={user} />)}
// ❌ Неправильно — дубликаты
{items.map(item => <Item key="item" />)} // все key="item"
> 💡 React не проверяет уникальность на этапе рендеринга — он молча игнорирует дубликаты, и внутренняя логика диффинга начинает работать некорректно.
✅ 2. Стабильность (Stability)
key должен оставаться неизменным между рендерами для одного и того же элемента.
// ❌ ОЧЕНЬ ПЛОХО — ключ генерируется каждый раз
{items.map(item => <Item key={Math.random()} />)}
// ❌ ПЛОХО — ключ зависит от порядка
{items.map((item, index) => <Item key={index} />)}
> 💡 Если key меняется — React уничтожает старый компонент и создаёт новый с нуля.
> Это разрушает состояние (useState, useRef, фокус, анимации, таймеры, и т.д.).
> Производительность падает, потому что React вынужден пересоздавать DOM-узлы.
✅ 3. Примитивный тип (Primitive)
key должен быть строкой или числом — не объектом, не массивом, не функцией.
// ✅ Правильно
key="user-123"
key={123}
// ❌ НЕЛЬЗЯ
key={{ id: 123 }} // объект
key={[1, 2, 3]} // массив
key={someFunction()} // функция
> 💡 React использует key как хеш-ключ в своей внутренней структуре.
> Объекты и массивы не могут быть корректно сравнены по значению — React не может определить, изменился ли key или нет.
✅ 3. Как правильно выбрать key? — Практические паттерны
✅ Случай 1: Данные приходят с бэкенда (с ID)
Идеальный сценарий.
const posts = [
{ id: 'post_abc', title: 'React', author: 'Alice' },
{ id: 'post_def', title: 'GraphQL', author: 'Bob' },
];
{posts.map(post => <Post key={post.id} post={post} />)}
> ✅ Идеально: уникально, стабильно, не зависит от порядка.
✅ Случай 2: Данные генерируются на фронтенде (нет ID)
Генерируйте ключ вручную.
import { nanoid } from 'nanoid';
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
setTodos([...todos, { id: nanoid(), text, completed: false }]);
};
{todos.map(todo => <Todo key={todo.id} todo={todo} />)}
> ✅ nanoid — легковесный, быстрый, гарантированно уникальный.
> Не используйте uuid — он тяжеловесен для простых случаев.
✅ Случай 3: Статичный список (например, меню)
const menuItems = ['Главная', 'Каталог', 'Контакты'];
{menuItems.map(item => <li key={item}>{item}</li>)}
> ✅ Здесь key={item} — семантически корректен, стабилен, уникален.
> Даже если список не меняется — лучше использовать значение, а не index.
✅ Случай 4: Вложенные списки
{categories.map(category => (
<ul key={category.id}>
{category.items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
))}
> ✅ Каждый уровень имеет свои уникальные ключи.
> Никогда не используйте один и тот же key на разных уровнях вложенности.
✅ 4. Что будет, если нарушить требования? — Реальные последствия
| Нарушение | Последствия |
|---|---|
❌ Дубликаты key | React не предупреждает, но не может корректно отслеживать элементы → баги в состоянии, анимациях, формах |
❌ Нестабильный key | Каждый рендер → пересоздание компонентов → теряется фокус, таймеры, состояние ввода |
❌ Объект/массив в key | React не падает, но не может эффективно сравнивать → предсказуемость нарушена |
❌ key={index} при удалении/сортировке | Состояние компонентов перепутывается → пользователь видит «неправильные» данные, чекбоксы «перепрыгивают» |
> 💥 В продакшене такие баги происходят раз в неделю, не воспроизводятся в дев-окружении, и требуют дней на отладку.
✅ 5. Как протестировать правильность key?
-
Добавьте
useEffectс логгированием монтирования/размонтирования:const Item = ({ item }) => {useEffect(() => {console.log(`Mounted: ${item.id}`);return () => console.log(`Unmounted: ${item.id}`);}, [item.id]);return <div>{item.text}</div>;}; -
Удалите элемент из середины списка.
-
Проверьте логи:
- Если не уничтожается компонент, который должен исчезнуть —
keyнекорректен. - Если уничтожаются и создаются только нужные —
keyправильный.
- Если не уничтожается компонент, который должен исчезнуть —
✅ 6. Заключение: Что делает это решение senior-level?
| Критерий | Почему важно |
|---|---|
| ✅ Понимание reconciliation | Не просто “используй key”, а понимание, как React решает, что менять в DOM |
| ✅ Понимание состояния компонентов | key влияет на всё внутреннее состояние — не только на рендер |
| ✅ Понимание производительности | Неправильные ключи = тормоза в рендеринге при 1000+ элементов |
| ✅ Понимание масштабируемости | Когда приложение растёт — index ломается. Уникальные ID — фундамент |
| ✅ Понимание надёжности | Вы не пишете “код, который работает”. Вы пишете код, который не сломается при изменении данных |
| ✅ Профессиональная ответственность | Баги из-за key — одни из самых скрытых, трудноуловимых и дорогостоящих |
> 💡 Правило senior-разработчика:
> "Если ты не можешь гарантировать, что key будет уникален и стабилен — ты не имеешь права рендерить список."
✅ Итог: Кратко, но полно
-
Зачем нужен
key?
Чтобы React точно знал, какой элемент какой, и не перепутал состояние между компонентами. -
Требования к
key:- Уникальный в рамках списка
- Стабильный — не меняется между рендерами
- Примитивный — строка или число
-
Как выбирать?
→ Используйidиз данных
→ Генерируйnanoid(), еслиidнет
→ Никогда —index, если список изменяется
→ Никогда —Math.random() -
Следствие ошибки?
→ Потеря состояния
→ Некорректные формы
→ Сломанные анимации
→ Падение производительности
→ Невозможность отладки
> key — это не оптимизация. Это гарантия корректности.
> Игнорировать его — значит писать код, который работает сегодня, но сломается завтра.
Вопрос 46. Расскажите о жизненном цикле компонента в React
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:13:12"/>
Ответ собеседника: Правильный. Жизненный цикл включает три основные фазы: монтирование, обновление и размонтирование. На этапе монтирования строится Virtual DOM, рассчитывается layout, происходит отрисовка (painting) и композиция (composition).
Правильный ответ:
Жизненный цикл компонента в React — это последовательность этапов, через которые проходит компонент от момента его создания в DOM до полного удаления. Понимание этих этапов критически важно для корректной работы с эффектами, управления состоянием, оптимизации производительности и избежания утечек памяти.
Однако важно сразу уточнить: ответ собеседника содержит частичную искажённую информацию. Упоминание layout, painting, composition — это браузерные этапы рендеринга, а не фазы жизненного цикла React. React работает на уровне виртуального DOM, и его цикл — это логическая последовательность вызовов хуков и колбэков, а не низкоуровневая отрисовка браузера.
Разберём жизненный цикл правильно, глубоко, с учётом современного React (18+) и функциональных компонентов — именно они являются стандартом сегодня.
✅ 1. Три фазы жизненного цикла функционального компонента
Функциональные компоненты в React (с хуками) проходят через три логические фазы:
| Фаза | Описание | Основные хуки |
|---|---|---|
| Монтирование (Mounting) | Компонент впервые создаётся и добавляется в DOM | useEffect, useState, useContext, useRef |
| Обновление (Updating) | Компонент перерендеривается из-за изменения пропсов, состояния или родительского рендеринга | useEffect, useLayoutEffect, useState, useCallback, useMemo |
| Размонтирование (Unmounting) | Компонент удаляется из DOM | useEffect с функцией очистки |
> ⚠️ Важно: useLayoutEffect и useEffect вызываются после каждого рендеринга, включая монтирование и обновление.
> Не существует отдельных "лайфсайклоу-методов" вроде componentDidMount, как в классах — всё объединено в хуки.
✅ 2. Монтирование: Что происходит на самом деле?
Когда React впервые встречает компонент (например, <MyComponent />), он:
- Вызывает функцию компонента → выполняет весь код внутри (включая
useState,useContext,useMemo,useCallback). - Создаёт виртуальный DOM — дерево элементов, описывающее, что должно быть отображено.
- Выполняет
useEffectс пустым массивом зависимостей[]— это эквивалентcomponentDidMount:useEffect(() => {console.log('Монтирование завершено');// Загрузка данных, подписка на события, инициализация сторонних библиотек}, []); - Выполняет
useLayoutEffectс пустым массивом — синхронно, до браузерной отрисовки (для измерений, предотвращения визуальных артефактов). - React передаёт виртуальный DOM в браузер, и только после этого начинаются
painting,layout,composition— это уже не часть React, а часть браузерного рендерера.
> ✅ Совет:
> Если вам нужно измерить размер элемента при монтировании — используйте useLayoutEffect, потому что useEffect запускается после отрисовки, и вы увидите мигание или сдвиг.
const ResizableComponent = () => {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
console.log('Размер при монтировании:', rect.width, rect.height);
}
}, []);
return <div ref={ref}>Измеряю себя</div>;
};
✅ 3. Обновление: Когда и почему происходит перерендер?
Компонент перерендерится при:
- Изменении состояния (
setState,useState) - Изменении пропсов
- Изменении контекста (
useContext) - Рендеринге родительского компонента (даже если его пропсы не изменились)
Что происходит при обновлении?
- React снова вызывает функцию компонента.
- Выполняются все хуки (
useState,useMemo,useCallback,useRef). - React сравнивает новый виртуальный DOM с предыдущим (алгоритм диффинга).
- Если обнаружены различия — React обновляет реальный DOM.
- Выполняются все
useEffectиuseLayoutEffect, кроме тех, что имеют пустой массив зависимостей.
> 💡 Особенность:
> useEffect не блокирует рендеринг — он асинхронен.
> useLayoutEffect блокирует — он выполняется до рисования браузером.
✅ Пример: Подписка на события при обновлении
const SearchInput = ({ query, onSearch }) => {
const [inputValue, setInputValue] = useState(query);
// ✅ Используем useEffect для подписки на изменения query (пропса)
useEffect(() => {
setInputValue(query); // Синхронизируем состояние с пропсом
}, [query]); // Зависимость — query
// ✅ Подписка на ввод — только один раз
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Enter') onSearch(inputValue);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onSearch, inputValue]); // Зависимости: onChange может меняться
return <input value={inputValue} onChange={(e) => setInputValue(e.target.value)} />;
};
> ⚠️ Ошибка: если забыть query в зависимостях useEffect — компонент не синхронизируется с внешним состоянием.
✅ 4. Размонтирование: Очистка — не опционально, а обязательна
Когда компонент удаляется из DOM (например, при смене маршрута, условном рендеринге if (show) <Component />), React:
- Вызывает функции очистки из
useEffectиuseLayoutEffect— в порядке обратном их вызову. - Уничтожает все внутренние состояния (
useState,useRef,useMemo). - Удаляет все слушатели, очищает таймеры, отписывается от событий, закрывает соединения.
✅ Пример: Утечка памяти без очистки
// ❌ ОПАСНО: утечка памяти
useEffect(() => {
const interval = setInterval(() => {
console.log('Тик');
}, 1000);
// Нет return — таймер не очищается, даже если компонент удалился!
}, []);
✅ ✅ Правильно:
useEffect(() => {
const interval = setInterval(() => {
console.log('Тик');
}, 1000);
return () => {
clearInterval(interval); // ✅ Очистка — обязательна!
};
}, []);
> 💡 Правило:
> Каждый useEffect, который создаёт побочный эффект (таймер, подписка, сокет, слушатель) — должен иметь функцию очистки.
✅ 5. Что такое useLayoutEffect и когда его использовать?
useEffect— асинхронный. Выполняется после отрисовки браузера.useLayoutEffect— синхронный. Выполняется до отрисовки, но после обновления DOM.
✅ Когда использовать useLayoutEffect?
| Сценарий | Почему useLayoutEffect? |
|---|---|
| Измерение размеров элементов (offsetWidth, clientHeight) | Чтобы избежать визуального "подпрыгивания" |
| Анимации, требующие точного позиционирования | Чтобы не было "мерцания" |
| Интеграция с библиотеками, зависящими от DOM (например, Chart.js) | Чтобы избежать перерисовки |
const AnimatedCard = () => {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
if (ref.current) {
setWidth(ref.current.offsetWidth);
}
}, []);
return (
<div ref={ref} style={{ width: `${width}px`, transition: 'width 0.3s' }}>
Ширина: {width}px
</div>
);
};
> ❌ Если использовать useEffect — компонент сначала отрисуется с width=0, потом увеличится → визуальный артефакт.
✅ 6. Важные нюансы: Что React не делает
| Миф | Реальность |
|---|---|
“React вызывает componentDidMount один раз” | В функциональных компонентах — useEffect с [] вызывается один раз после первого рендера, но если компонент уничтожен и создан заново — он вызовется снова. |
| “useMemo и useCallback — это оптимизация” | Они не гарантируют оптимизацию — они предотвращают пересоздание значений и функций. Их нужно использовать только когда это критично для производительности. |
| “React рендерит только изменённые компоненты” | Нет. React рекурсивно рендерит всё дерево под компонентом, если родитель перерендерился. Это нормально — React.memo, useMemo, useCallback нужны для оптимизации. |
✅ 7. Практический совет: Как структурировать жизненный цикл в реальном коде
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const ref = useRef(null);
// ✅ Монтирование: загрузка данных
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const data = await api.getUser(userId);
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]); // Зависит от userId — перезагружаем при смене
// ✅ Монтирование: измерение элемента
useLayoutEffect(() => {
if (ref.current) {
console.log('Высота профиля:', ref.current.offsetHeight);
}
}, []);
// ✅ Размонтирование: отписка от событий (если бы были)
useEffect(() => {
const handleResize = () => {
// ...
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// ✅ Обновление: логика при изменении user
useEffect(() => {
if (user) {
document.title = `Профиль: ${user.name}`;
}
}, [user]);
if (loading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error}</div>;
return (
<div ref={ref}>
<h1>{user?.name}</h1>
<p>{user?.email}</p>
</div>
);
};
✅ 8. Заключение: Что делает это решение senior-level?
| Критерий | Почему важно |
|---|---|
| ✅ Понимание фаз | Не просто “монтирование, обновление, размонтирование”, а что происходит внутри React и браузера |
✅ Различие useEffect и useLayoutEffect | Это ключевое различие для производительности и UX |
| ✅ Очистка эффектов | Это не опционально — утечки памяти в React — один из самых распространённых багов в продакшене |
| ✅ Зависимости в хуках | Неправильные зависимости — самая частая причина багов в функциональных компонентах |
| ✅ Понимание рендеринга | React не оптимизирует рендеры по умолчанию — вы должны это делать вручную |
| ✅ Практическое применение | Не теория, а реальные кейсы: подписки, таймеры, измерения, асинхронные запросы |
> 💡 Совет от senior-разработчика:
> “Если вы не пишете return () => cleanup() в useEffect — вы пишете уязвимый код.”
✅ Итог: Кратко, но полно
-
Жизненный цикл функционального компонента — это вызовы хуков в определённом порядке:
Монтирование → Обновление → Размонтирование. -
useEffect— для побочных эффектов (API, подписки, таймеры).
useLayoutEffect— для DOM-измерений и предотвращения артефактов. -
Всегда очищайте эффекты, если они создают ресурсы (таймеры, слушатели, сокеты).
-
Зависимости в
useEffectдолжны точно отражать все внешние значения, которые влияют на эффект. -
React не рендерит “только изменённые” компоненты — он рендерит всё дерево. Оптимизация — ваша задача.
-
layout,painting,composition— это браузерные этапы, а не фазы React.
React работает на уровне виртуального DOM, и его цикл — это логика, а не графика.
> Правильное понимание жизненного цикла — это фундамент надёжного, производительного и масштабируемого React-приложения.
> Игнорировать его — значит писать код, который работает случайно, а не работает правильно.
Вопрос 47. Чем отличается useEffect от useLayoutEffect?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:14:50"/>
Ответ собеседника: Правильный. useLayoutEffect выполняется синхронно до отрисовки компонентов в браузере (когда элементы уже в DOM дереве но ещё не отрисованы). useEffect выполняется асинхронно после отрисовки компонентов.
Правильный ответ:
Разница между useEffect и useLayoutEffect — не в функциональности, а в времени выполнения относительно браузерной отрисовки. Это фундаментальное различие, которое напрямую влияет на производительность, визуальную стабильность и пользовательский опыт.
✅ 1. Ключевое различие: Время выполнения
| Хук | Когда выполняется | Блокирует рендер? | Используется для |
|---|---|---|---|
useLayoutEffect | Синхронно, после обновления DOM, но до того, как браузер нарисует изменения на экране | ✅ Да — блокирует визуальную отрисовку | Измерения DOM, предотвращение артефактов, анимации, позиционирование |
useEffect | Асинхронно, после того, как браузер завершил отрисовку и отдал управление потоку | ❌ Нет — не блокирует рендер | Подписки, API-запросы, логирование, таймеры |
> 💡 Простая аналогия:
> Представьте, что вы строите дом.
> - useLayoutEffect — это проверка углов стен перед тем, как покрасить. Если стена кривая — вы исправляете её до покраски, чтобы не было видно ошибки.
> - useEffect — это покупка мебели после завершения строительства. Вы можете делать это в фоне — никто не ждёт.
✅ 2. Что значит "до отрисовки" и "после отрисовки"?
React работает в два этапа:
-
Рендер (Render)
— Вызывается функция компонента → создаётся виртуальный DOM.
— React вычисляет, какие узлы изменились. -
Мутация (Commit)
— React применяет изменения к реальному DOM.
— Здесь происходит разделение:useLayoutEffect→ выполняется после мутации DOM, но до браузерной отрисовки (paint).useEffect→ выполняется после отрисовки (paint), когда пользователь уже видит результат.
📌 Визуализация процесса:
[Рендер] → [Мутация DOM] → [useLayoutEffect] → [Браузер отрисовывает] → [useEffect]
> ✅ useLayoutEffect — последняя возможность исправить DOM до того, как пользователь его увидит.
> ✅ useEffect — первая возможность реагировать после того, как пользователь увидел изменения.
✅ 3. Пример: Почему useLayoutEffect критичен для измерений
Представьте компонент, который должен автоматически подстраивать высоту под содержимое:
const AutoResizeBox = ({ children }) => {
const ref = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
// ❌ useLayoutEffect: измеряем до отрисовки — нет мигания
useLayoutEffect(() => {
if (ref.current) {
setHeight(ref.current.offsetHeight);
}
}, [children]);
// ✅ Рендерим с уже известной высотой
return (
<div ref={ref} style={{ height: `${height}px`, overflow: 'hidden' }}>
{children}
</div>
);
};
Что будет, если использовать useEffect?
useEffect(() => {
if (ref.current) {
setHeight(ref.current.offsetHeight); // 👈 Сработает ПОСЛЕ отрисовки!
}
}, [children]);
→ Первый рендер: компонент отрисуется с height=0.
→ После отрисовки: useEffect запускается, height обновляется → компонент "подпрыгивает".
→ Пользователь видит мигание или сдвиг контента — это визуальный артефакт, который недопустим в продакшене.
> 💥 Это не баг в коде, это ошибка выбора хука.
> useLayoutEffect устраняет этот артефакт — он обновляет состояние до того, как пользователь видит DOM.
✅ 4. Пример: Подписка на события — только useEffect
const ClickTracker = () => {
const [clicks, setClicks] = useState(0);
// ✅ Правильно: подписка — асинхронна, не блокирует отрисовку
useEffect(() => {
const handleClick = () => setClicks(c => c + 1);
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, []); // Никаких DOM-измерений — только событие
return <div>Кликов: {clicks}</div>;
};
→ Здесь useLayoutEffect не нужен и даже вреден:
— Это не влияет на визуальный результат.
— Блокирует рендер без пользы.
— Может замедлить отрисовку.
> ✅ Правило:
> Используйте useLayoutEffect только если вы читаете или изменяете DOM, и это влияет на визуальное отображение.
> Во всех остальных случаях — useEffect.
✅ 5. Когда использовать useLayoutEffect — практические кейсы
| Сценарий | Почему useLayoutEffect? |
|---|---|
Измерение размеров элемента (offsetWidth, clientHeight, getBoundingClientRect()) | Чтобы избежать "подпрыгивания" при первом рендере |
| Позиционирование по DOM (например, popup под курсором) | Чтобы элемент не "появлялся" в неправильном месте, а сразу — в нужном |
| Анимации с началом в момент монтирования | Чтобы избежать "мерцания" или "прыжка" анимации |
| Интеграция с библиотеками, зависящими от размеров DOM (Chart.js, D3, Monaco) | Чтобы инициализация проходила до отрисовки |
| Изменение стилей, зависящих от вычислений (например, динамическая ширина) | Чтобы пользователь не увидел исходное состояние |
> ⚠️ Не используйте useLayoutEffect для:
> - API-запросов
> - Логгирования
> - Подписок на события (если они не влияют на визуальный результат)
> - Таймеров
> - Установки localStorage
> — Это всё useEffect.
✅ 6. Производительность: useLayoutEffect может замедлить приложение
Поскольку useLayoutEffect блокирует отрисовку, его частое использование может:
- Замедлить рендеринг
- Ухудшить отзывчивость интерфейса
- Вызвать "джиттер" (jank) на слабых устройствах
> 💡 Золотое правило:
> Используйте useLayoutEffect только тогда, когда вы видите визуальный артефакт и не можете его устранить иным способом.
> ✅ Альтернатива:
> Если вы не можете использовать useLayoutEffect (например, в SSR), используйте состояние с начальным значением и постепенное обновление:
> ```tsx`
> const [width, setWidth] = useState(0); // начальное значение — 0
> const [isHydrated, setIsHydrated] = useState(false);
>
> useEffect(() => {
> setIsHydrated(true);
> }, []);
>
> // Рендерим с placeholder, пока не загрузится
> return <div style={{ width: isHydrated ? width : '100%' }}>...</div>;
> ````
✅ 7. SSR (Server-Side Rendering) — важное ограничение
useLayoutEffectне работает на сервере, потому что там нет DOM.- React предупреждает в консоли при его использовании в SSR-среде.
- При гидратации (на клиенте) он выполняется после
useEffect.
> ⚠️ Решение:
> ```tsx> const isBrowser = typeof window !== 'undefined'; > > if (isBrowser) \{ > useLayoutEffect(() => \{ > // ... > \}, []); > \} else \{ > useEffect(() => \{ > // ... > \}, []); > \} > ```` > > Или используйте **библиотеки вродеuse-isomorphic-layout-effect`**, которые автоматически переключаются.
✅ 8. Заключение: Что делает это решение senior-level?
| Критерий | Почему важно |
|---|---|
| ✅ Понимание браузерного рендеринга | Не просто "один синхронный, другой асинхронный" — а как именно работает цикл отрисовки |
| ✅ Умение диагностировать визуальные артефакты | Умение отличить "баг в коде" от "неправильного хука" — ключевой навык фронтенд-инженера |
| ✅ Знание, когда оптимизация критична | useLayoutEffect — не "лучший хук", а инструмент для решения конкретной проблемы |
| ✅ Понимание SSR | Не все приложения — SPA. Важно знать, как хуки ведут себя на сервере |
| ✅ Производительность как приоритет | Не просто "код работает" — как он работает для пользователя |
✅ Использование useEffect как стандартного хука | Большинство эффектов — не требуют блокировки — и должны быть useEffect |
> 💡 Правило senior-разработчика:
> "Если вы не можете объяснить, почему вы используете useLayoutEffect — вы, скорее всего, ошибаетесь. Используйте useEffect."
✅ Итог: Кратко, но полно
-
useLayoutEffect— синхронно, до отрисовки браузером, блокирует рендер.
Используется для измерений DOM и предотвращения визуальных артефактов. -
useEffect— асинхронно, после отрисовки, не блокирует рендер.
Используется для всего остального: API, события, таймеры, логика. -
Не используйте
useLayoutEffectбез необходимости — он замедляет интерфейс. -
Всегда очищайте эффекты, если они создают ресурсы.
-
На сервере
useLayoutEffectне работает — будьте готовы к SSR.
> ✅ Запомните:
> useLayoutEffect — это хук для точной, визуально бесшовной работы с DOM.
> useEffect — это хук для всего остального.
> Выбирайте не по "как лучше", а по "как правильно для пользователя".
Вопрос 48. С какими React хуками вы обычно работаете?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:15:11"/>
Ответ собеседника: Правильный. useState, useEffect, useRef, useCallback, useMemo, useContext, useId.
Правильный ответ:
В современной React-разработке хуки — это основной способ управлять состоянием, эффектами и побочными операциями в функциональных компонентах. Использование правильных хуков на правильных уровнях — ключ к чистому, поддерживаемому и производительному коду. Ниже — подробный разбор тех хуков, которые я регулярно применяю в продакшен-проектах, с объяснением когда, почему и как они используются на уровне senior-разработчика.
✅ 1. useState — базовый инструмент управления локальным состоянием
const [count, setCount] = useState(0);
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
- Применение: Любое изменяемое состояние, которое влияет на рендер.
- Глубина: Не просто “храню число”, а структурирую сложные состояния:
- Использую функциональное обновление для зависимых состояний:
setCount(prev => prev + 1);
- Разделяю состояния по логическим зонам — не складываю всё в один объект.
- Вместо
useState({ a: 1, b: 2 })— часто использую несколько отдельных состояний, чтобы избежать ненужных рендеров при частичном обновлении.
- Использую функциональное обновление для зависимых состояний:
> ✅ Совет:
> Если состояние зависит от предыдущего — всегда используйте функциональное обновление.
> Это предотвращает race conditions в асинхронных операциях.
✅ 2. useEffect — асинхронные побочные эффекты
useEffect(() => {
const fetchUser = async () => {
const data = await api.getUser(id);
setUser(data);
};
fetchUser();
}, [id]);
useEffect(() => {
document.title = `Профиль: ${user?.name}`;
}, [user]);
- Применение:
- API-запросы
- Подписки на события (WebSocket, window.resize)
- Изменения DOM вне React (библиотеки, например, Chart.js)
- Логгирование, аналитика
- Глубина:
- Всегда возвращаю функцию очистки, если эффект создаёт ресурсы.
- Использую множественные
useEffectвместо одного — каждый эффект должен отвечать за одну логическую задачу. - Не смешиваю логику состояния и эффектов — чёткое разделение ответственности.
> ⚠️ Ошибка:
> ```tsx> useEffect(() => \{ > if (user) \{ > fetchPosts(user.id); > document.title = user.name; > \} > \}, [user]); > ```` > → **Нарушение SRP (Single Responsibility Principle)**. > ✅ Лучше: два отдельныхuseEffect`.
✅ 3. useRef — мутабельный контейнер без рендеров
const timerId = useRef<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const prevProps = useRef<Props>(null);
- Применение:
- Доступ к DOM-элементам — фокус, измерения, управление скроллом.
- Хранение изменяемых значений, которые не должны вызывать рендер (таймеры, счетчики, предыдущие пропсы).
- Сохранение ссылок на функции в
useCallback/useMemo, чтобы избежать рекурсивного изменения.
> ✅ Пример: сохранение предыдущего значения для сравнения
const usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// Использование:
const prevCount = usePrevious(count);
if (count !== prevCount && prevCount !== undefined) {
console.log(`Счётчик изменился с ${prevCount} на ${count}`);
}
> 💡 Почему не useState?
> Потому что useRef не вызывает рендер. Это не состояние — это мутированный объект, доступный в течение всего жизненного цикла компонента.
✅ 4. useCallback — мемоизация функций для оптимизации дочерних компонентов
const handleButtonClick = useCallback(() => {
dispatch(updateUser({ id: userId, name: newName }));
}, [dispatch, userId, newName]);
-
Применение:
- Передача функций в
React.memo,useMemo,useEffectс зависимостями. - Когда дочерний компонент использует
React.memo, и вы хотите избежать ненужного повторного создания функции.
- Передача функций в
-
Глубина:
- Не мемоизирую всё подряд — только если дочерний компонент оптимизирован (
React.memo) и это влияет на производительность. - Если функция не передаётся в мемоизированный компонент —
useCallbackне нужен. - Учитывая стоимость создания функции — если это просто стрелочная функция, то разница в производительности незначительна.
- Не мемоизирую всё подряд — только если дочерний компонент оптимизирован (
> ❌ Частая ошибка:
> tsx` > const handleClick = useCallback(() => \{\}, []); // ❌ Нет зависимости — но зачем? > ```` > → Это **избыточная оптимизация**. > ✅ Правильно: > tsx`
> const handleClick = useCallback(() => {
> doSomething(dependency1, dependency2);
> }, [dependency1, dependency2]);
> ````
✅ 5. useMemo — мемоизация вычислений
const expensiveValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]);
const filteredList = useMemo(() => {
return items.filter(item => item.name.includes(searchTerm));
}, [items, searchTerm]);
-
Применение:
- Тяжелые вычисления (фильтрация, сортировка, конвертация данных)
- Создание объектов/массивов, которые передаются в
React.memo - Кэширование значений, которые не меняются между рендерами
-
Глубина:
- Не использую
useMemoдля простых значений — это избыточно. - Не использую
useMemoдля функций — этоuseCallback. - Проверяю, выигрывает ли вычисление — если массив из 5 элементов — фильтрация не требует мемоизации.
- Пример из практики:
// ✅ Вариант: мемоизированная сортировка + фильтрацияconst sortedAndFiltered = useMemo(() => {return products.filter(p => p.category === selectedCategory).sort((a, b) => a.price - b.price);}, [products, selectedCategory]);
- Не использую
> ⚠️ Важно:
> useMemo не гарантирует кэширование — он только предлагает оптимизацию.
> React может проигнорировать его при перерендеринге (например, при нехватке памяти).
> → Используйте useMemo для производительности, а не для логики.
✅ 6. useContext — передача состояния без пропсов
const theme = useContext(ThemeContext);
const auth = useContext(AuthContext);
-
Применение:
- Темы (light/dark)
- Аутентификация, локализация
- Конфигурация приложения (API-базовый URL, флаги фич)
-
Глубина:
- Использую
useContextкак инструмент глобального состояния, но только если это логически глобальная сущность. - Не использую
useContextдля передачи данных, которые нужны только одному компоненту — это нарушение инкапсуляции. - Выношу
Providerкак можно выше — избегаю множестваProviderна одном уровне. - Разделяю контексты по зонам ответственности:
→ Один контекст — одна ответственность.const UserContext = createContext<UserContextType>({} as UserContextType);const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType);
- Использую
> ✅ Альтернатива:
> Для сложного глобального состояния — Redux Toolkit, Zustand, Jotai.
> useContext — это лёгкий способ передачи конфигурации, а не замена состоянию.
✅ 7. useId — генерация уникальных идентификаторов для доступности
const id = useId();
<input id={id} type="text" />
<label htmlFor={id}>Имя</label>
-
Применение:
- Генерация
idдля<label htmlFor>,<input>,<div aria-describedby> - Уникальные
idв списках, формах, модальных окнах
- Генерация
-
Глубина:
- Работает как на сервере, так и на клиенте — не использует
Math.random(), а генерирует стабильный ID на основе позиции в дереве. - Используется в библиотеках UI-компонентов (например, в
@headlessui/react,shadcn/ui) - Избегает конфликтов ID при SSR + гидратации
- Работает как на сервере, так и на клиенте — не использует
> ✅ Пример из реального проекта: > ```tsx` > const FormField = ({ label, children }) => { > const fieldId = useId(); > return ( > <> > <label htmlFor={fieldId}>{label}</label> > {React.cloneElement(children, { id: fieldId })} > </> > ); > }; > ````
> 💡 Зачем не просто Math.random()?
> Потому что на сервере Math.random() даёт один ID, а на клиенте — другой → ошибка гидратации.
> useId — гарантирует идентичность между сервером и клиентом.
✅ 8. Дополнительные хуки, которые я применяю в продакшене
| Хук | Применение |
|---|---|
useReducer | Когда логика состояния слишком сложна для useState — например, форма с множеством валидаций, шагами, состояниями. |
useImperativeHandle | Когда нужно экспонировать методы родительскому компоненту через ref (редко, но критично для библиотек). |
useTransition | Для не блокирующих обновлений в React 18 — например, при поиске с задержкой. |
useDeferredValue | Для отложенной обработки быстро меняющихся значений (например, поисковый запрос). |
useSyncExternalStore | Для интеграции с внешними хранилищами (Redux, Zustand, Observable) в React 18. |
> 💡 Пример с useTransition:
> ```tsx`
> const [isPending, startTransition] = useTransition();
>
> const handleSearch = (query: string) => {
> startTransition(() => {
> setSearchTerm(query);
> });
> };
> ````
> → Пользователь может продолжать вводить, а UI не “подвисает” при фильтрации списка.
✅ 9. Что я НЕ использую без веских причин
| Хук | Почему не использую |
|---|---|
useDebugValue | Только для разработки библиотек — не для приложений. |
useLayoutEffect | Только если видна визуальная артефакт — иначе useEffect. |
forwardRef с useImperativeHandle | Только если компонент — это UI-библиотека. В приложениях — лучше on* пропсы. |
useCallback/useMemo без React.memo | Это избыточная оптимизация, может замедлить код из-за вычислений мемоизации. |
✅ 10. Заключение: Какие хуки я использую и почему
| Хук | Использую? | Почему |
|---|---|---|
useState | ✅ Всегда | Основа реактивного состояния |
useEffect | ✅ Всегда | Единственный способ работать с побочными эффектами |
useRef | ✅ Часто | Незаменим для DOM и мутабельных значений |
useCallback | ✅ Выборочно | Только если передаю функцию в React.memo |
useMemo | ✅ Выборочно | Только для тяжёлых вычислений |
useContext | ✅ Ограниченно | Только для глобальных конфигов — не для данных |
useId | ✅ Всегда в формах/компоненты доступности | Обеспечивает SEO и WCAG-совместимость |
> ✅ Принцип senior-разработчика:
> “Не используй хук, потому что он есть. Используй его, потому что он решает конкретную проблему.”
> ✅ Дополнительный совет:
> Всегда спрашивайте себя:
> - Для чего я использую этот хук?
> - Что будет, если я его уберу?
> - Будет ли это влиять на производительность, доступность или UX?
✅ Итог: Кратко, но полно
useState— управление состоянием.useEffect— побочные эффекты.useRef— мутабельные значения без рендеров.useCallback— мемоизация функций для оптимизации.useMemo— мемоизация вычислений.useContext— глобальные конфиги, не данные.useId— уникальные ID для доступности.
> ✅ Правило:
> Используй useState, useEffect, useRef как основу.
> Используй useCallback, useMemo только при доказанной необходимости.
> Используй useContext и useId как инструменты для архитектуры, а не для данных.
> Хуки — не инструменты для красивого кода. Они — инструменты для надёжного, производительного и доступного пользовательского опыта.
> Используй их с осознанием, а не по шаблону.
Вопрос 49. В каких случаях целесообразно применять useCallback и useMemo?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:15:30"/>
Ответ собеседника: Правильный. useCallback: когда функция передаётся как пропс в компонент обёрнутый в React.memo, или когда функция используется в массиве зависимостей других хуков. useMemo: для мемоизации вычислений чтобы не выполнять их при каждом рендере, при передаче ссылочных типов в массивы зависимостей, для оптимизации тяжёлых компонентов со сложной фильтрацией.
Правильный ответ:
useCallback и useMemo — это оптимизационные хуки, предназначенные для предотвращения избыточных вычислений и перерендеров. Однако их применение не является автоматической хорошей практикой — неправильное использование может замедлить приложение, увеличить объём памяти и усложнить код. Правильное применение требует понимания механизма рендеров React, сравнения по ссылке и издержек мемоизации.
✅ 1. useCallback — мемоизация функций
🔹 Что делает useCallback?
Он возвращает ту же самую функцию между рендерами, если её зависимости не изменились.
Это не делает функцию быстрее — он делает её стабильной по ссылке.
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, [setCount]);
✅ Когда использовать useCallback — 3 ключевых сценария
A. Передача функции в мемоизированный дочерний компонент (React.memo)
const Child = React.memo(({ onClick }) => {
console.log('Child rendered');
return <button onClick={onClick}>Click me</button>;
});
const Parent = () => {
const [count, setCount] = useState(0);
// ❌ Без useCallback — новая функция на каждом рендере
const handleClick = () => setCount(c => c + 1);
// ✅ С useCallback — стабильная функция
const handleClick = useCallback(() => setCount(c => c + 1), [setCount]);
return <Child onClick={handleClick} />;
};
> 💡 Почему это важно?
> React.memo сравнивает пропсы по ссылке. Если onClick — новая функция, то Child перерендерится, даже если логика не изменилась.
> useCallback предотвращает это.
B. Использование функции в зависимостях useEffect, useMemo, useCallback
const fetchData = useCallback(async () => {
const res = await api.get(`/data?filter=${filter}`);
setData(res);
}, [filter]);
useEffect(() => {
fetchData();
}, [fetchData]); // ✅ Стабильная ссылка — эффект не перезапускается лишний раз
> ⚠️ Без useCallback:
> ```tsx`
> const fetchData = async () => { ... };
> useEffect(() => fetchData(), [fetchData]); // ❌ Новая функция на каждом рендере → бесконечный цикл!
> ````
> ✅ С useCallback:
> Функция стабильна → useEffect запускается только при изменении filter.
C. Передача функции в библиотеки, ожидающие стабильные колбэки
Пример: useEvent (React 19+), useDebounce, useThrottle, useIntersectionObserver, react-router (в некоторых кейсах).
const handleScroll = useCallback(() => {
// обработка скролла
}, []);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]); // ✅ Безопасная подписка
> 🔍 Важно:
> useCallback не ускоряет выполнение функции — он предотвращает её пересоздание.
> Если функция простая и не передаётся в React.memo — useCallback не нужен.
> ❌ Распространённая ошибка: > ```tsx` > const handleClick = useCallback(() => {}, []); // ❌ Зачем? Нет зависимости, нет оптимизации. > ````
✅ 2. useMemo — мемоизация вычислений
🔹 Что делает useMemo?
Он кэширует результат вычисления, если зависимости не изменились.
const expensiveValue = useMemo(() => {
return complexCalculation(a, b, c);
}, [a, b, c]);
✅ Когда использовать useMemo — 4 ключевых сценария
A. Тяжёлые вычисления (массивы, объекты, фильтрация, сортировка)
const filteredProducts = useMemo(() => {
return products
.filter(p => p.category === selectedCategory)
.sort((a, b) => a.price - b.price);
}, [products, selectedCategory]);
> 💡 Критерий применения:
> Если вычисление занимает >1–5ms — имеет смысл кэшировать.
> Если массив из 10 элементов — фильтрация не требует useMemo.
> Если массив из 10 000 элементов — без useMemo будет тормозить.
> ✅ Проверка:
> Используйте React DevTools → включите "Highlight updates when components render" → если компонент перерендеривается часто, а данные не меняются — нужна мемоизация.
B. Передача объектов/массивов в зависимости других хуков
const filters = { category: 'books', price: 100 };
// ❌ Без useMemo — новый объект на каждом рендере
useEffect(() => {
fetchProducts(filters);
}, [filters]); // ❌ Перезапускается каждый раз — потому что filters — новая ссылка
// ✅ С useMemo — стабильная ссылка
const filters = useMemo(() => ({ category: 'books', price: 100 }), []);
> 💡 Почему так происходит?
> { a: 1 } !== { a: 1 } — в JavaScript объекты сравниваются по ссылке, а не по содержимому.
> useMemo создаёт одну и ту же ссылку между рендерами.
C. Создание сложных объектов для передачи в React.memo
const ProductCard = React.memo(({ product }) => {
return <div>{product.name}</div>;
});
const ProductList = () => {
const products = [...]; // большой массив
const productCards = useMemo(() => {
return products.map(p => ({
...p,
formattedPrice: formatCurrency(p.price), // тяжёлая функция
isOnSale: p.price < p.originalPrice,
}));
}, [products]);
return productCards.map(card => <ProductCard key={card.id} product={card} />);
};
> ✅ Здесь ProductCard получает новый объект при каждом рендере — без useMemo он всегда перерендерится, даже если product.id не изменился.
D. Оптимизация компонентов с дорогой логикой в render
const ExpensiveChart = ({ data }) => {
const chartData = useMemo(() => {
return processData(data); // 100ms+ вычислений
}, [data]);
return <Chart data={chartData} />;
};
> ✅ Если ExpensiveChart часто перерендеривается из-за родительских изменений, но data стабильна — useMemo избавляет от повторных вычислений.
✅ 3. Когда НЕ использовать useCallback и useMemo
| Ситуация | Почему не нужно |
|---|---|
Функция передаётся в обычный компонент (без React.memo) | React и так перерендерит — мемоизация не даст выигрыша |
Простые функции (() => {}, () => x + 1) | Создание стрелочной функции — очень дешёвое |
| Маленькие массивы/объекты (<100 элементов) | Время вычисления <1ms — мемоизация дороже |
| Компоненты не мемоизированы | useMemo не поможет, если родитель перерендерит всё равно |
Использование useMemo для логики | Это не для управления состоянием — только для оптимизации |
| Постоянно меняющиеся зависимости | Если a, b, c меняются каждый рендер — useMemo не кэширует ничего |
> 💡 Золотое правило:
> Не оптимизируйте, пока не видите проблему.
> Используйте React DevTools → Highlight updates → убедитесь, что перерендеры избыточны → только тогда применяйте useCallback/useMemo.
✅ 4. Производительность: что происходит под капотом
| Хук | Расходы |
|---|---|
useCallback | Создание и хранение функции в памяти + сравнение зависимостей |
useMemo | Выполнение функции при изменении зависимостей + хранение результата + сравнение зависимостей |
> ✅ Если вычисление занимает 0.1ms, а мемоизация — 0.5ms — вы проиграли.
> ✅ Если вычисление — 50ms, а мемоизация — 0.5ms — вы выиграли 49.5ms.
> 🔍 Измеряйте!
> Используйте console.time() или React DevTools Profiler:
> ```tsx`
> const expensive = useMemo(() => {
> console.time('expensive');
> const result = heavyCalculation();
> console.timeEnd('expensive');
> return result;
> }, [deps]);
> ````
✅ 5. Пример: Правильное применение в реальном проекте
// Компонент с фильтрацией и сортировкой списка из 10 000 товаров
const ProductList = ({ products, filter, sortBy }) => {
// ✅ Мемоизируем результат тяжелой фильтрации
const filteredAndSorted = useMemo(() => {
return products
.filter(p => p.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
if (sortBy === 'price') return a.price - b.price;
if (sortBy === 'name') return a.name.localeCompare(b.name);
return 0;
});
}, [products, filter, sortBy]);
// ✅ Мемоизируем функцию для кнопки
const handleSortChange = useCallback((newSort) => {
setSortBy(newSort);
}, [setSortBy]);
return (
<>
<SortSelector onSort={handleSortChange} />
{filteredAndSorted.map(product => (
<ProductCard key={product.id} product={product} />
))}
</>
);
};
// Дочерний компонент, оптимизированный
const ProductCard = React.memo(({ product }) => {
return (
<div>
<h3>{product.name}</h3>
<span>${product.price}</span>
</div>
);
});
> ✅ Здесь:
> - useMemo предотвращает пересчёт 10 000 товаров при каждом рендере.
> - useCallback предотвращает перерендер SortSelector, если он React.memo.
> - ProductCard не перерендерится, если product не изменился (поскольку он мемоизирован и ссылка на объект стабильна).
✅ 6. Заключение: Правильная философия использования
| Хук | Когда использовать | Когда не использовать |
|---|---|---|
useCallback | ✅ Передача функции в React.memo<br>✅ Использование в зависимостях useEffect/useMemo<br>✅ Внешние библиотеки (например, useDebounce) | ❌ Простые функции без React.memo<br>❌ Функции, которые не передаются вниз |
useMemo | ✅ Тяжёлые вычисления (>1–5ms)<br>✅ Кэширование объектов/массивов для React.memo<br>✅ Избежание перезапуска эффектов из-за ссылочных типов | ❌ Простые значения (a + b)<br>❌ Маленькие массивы/объекты<br>❌ Когда зависимости постоянно меняются |
> ✅ Правило senior-разработчика:
> “Используй useCallback и useMemo не для красоты кода, а для устранения реальных проблем производительности.”
> ✅ Дополнительный совет:
> Используйте React DevTools Profiler для записи рендеров.
> Если компонент перерендеривается часто, но без изменений в пропсах/состоянии — ищите неоптимизированные функции или объекты.
> 💡 Финальный тест:
> Если вы не можете ответить на вопрос:
> «Что именно я оптимизирую, и как это влияет на UX?» —
> то, скорее всего, вы не оптимизируете — вы усложняете.
> Оптимизация — это наука, а не магия.
> Используйте useCallback и useMemo как скальпель — не как молоток.
Вопрос 50. В каких случаях происходит рендер компонента в React?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:19:08"/>
Ответ собеседника: Правильный. При изменении состояния, при изменении пропсов, при рендере родительского компонента (если дочерний не обёрнут в React.memo), при изменении key, при изменении контекста.
Правильный ответ:
В React рендер компонента — это не просто перерисовка DOM, а выполнение функции компонента с целью получения нового дерева JSX. Понимание того, когда именно происходит рендер, — это основа оптимизации производительности и предотвращения лишних вычислений. Ниже — полный, глубокий и практический разбор всех случаев, с акцентом на реальные сценарии, подводные камни и лучшие практики.
✅ 1. Изменение состояния (setState, useReducer)
🔹 Что происходит?
Когда вы вызываете setState (из useState) или dispatch (из useReducer) — React планирует рендер компонента, в котором этот хук был вызван.
const Counter = () => {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
};
> ✅ Ключевое правило:
> Любое изменение состояния внутри компонента → запускает его рендер.
💡 Глубина:
- React не рендерит немедленно — он объединяет несколько
setStateв одном цикле (batching). - В React 18 batching работает даже в асинхронных колбэках (
setTimeout,fetch). - Если
setStateвызывается внутри дочернего компонента, это не вызывает рендер родителя, если родитель не зависит от этого состояния.
> ❌ Распространённое заблуждение:
> “Если я изменю состояние в дочернем компоненте — родитель перерендерится.”
> → Нет. Родитель перерендерится только если он сам изменяет состояние или его пропсы меняются.
✅ 2. Изменение пропсов (props)
🔹 Что происходит?
Если родительский компонент перерендерился и передал новую ссылку на пропс — дочерний компонент тоже перерендерится, даже если его внутреннее состояние не изменилось.
const Parent = () => {
const [count, setCount] = useState(0);
// ❌ Новая функция на каждом рендере — новая ссылка
const handler = () => setCount(c => c + 1);
return <Child onClick={handler} />;
};
const Child = ({ onClick }) => {
console.log('Child rendered'); // Будет вызван каждый раз
return <button onClick={onClick}>Click</button>;
};
> ✅ Почему?
> React сравнивает пропсы по ссылке, а не по содержимому.
> () => {} !== () => {} — даже если логика одинакова.
💡 Глубина:
- Если
Childне обёрнут вReact.memo— он всегда перерендерится при любом изменении пропсов. - Если
Childобёрнут вReact.memo— он перерендерится только при изменении значений пропсов. - Но если пропс — новый объект или массив — даже
React.memoне спасёт:
const Parent = () => {
const [count, setCount] = useState(0);
// ❌ Новый объект на каждом рендере — новая ссылка
const config = { theme: 'dark', version: 1 };
return <Child config={config} />;
};
const Child = React.memo(({ config }) => {
console.log('Child rendered'); // ❌ Будет вызван каждый раз!
return <div>{config.theme}</div>;
});
> ✅ Решение:
> Используйте useMemo для мемоизации объектов/массивов в пропсах:
const config = useMemo(() => ({ theme: 'dark', version: 1 }), []);
✅ 3. Рендер родительского компонента (дочерний без React.memo)
🔹 Что происходит?
React работает по дереву. Если родитель перерендерился — все его дочерние компоненты по умолчанию тоже перерендерятся, даже если их пропсы не изменились.
const Parent = () => {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>Update Parent</button>
<Child /> {/* ✅ Этот компонент перерендерится, даже если ничего не передаётся! */}
</>
);
};
const Child = () => {
console.log('Child rendered'); // ❌ Будет вызван при каждом рендере Parent!
return <p>Child</p>;
};
> ✅ Почему?
> React не знает, может ли Child быть стабильным. Он не делает предположений — он всегда вызывает функцию компонента, если родитель перерендерился.
💡 Глубина:
- Это не баг — это фича. Так React гарантирует предсказуемость.
React.memo— это оптимизация, а не поведение по умолчанию.- В больших приложениях без
React.memoдаже простой клик по кнопке может вызывать сотни ненужных рендеров.
> ✅ Практический совет:
> Всегда оборачивайте компоненты, которые не меняются часто, в React.memo, особенно если они:
> - Содержат сложный JSX
> - Используют useMemo/useCallback
> - Получают много пропсов
> - Отображают списки (>20 элементов)
✅ 4. Изменение key
🔹 Что происходит?
Если вы изменяете key у компонента — React удаляет старый экземпляр и создаёт новый — это полный рендер с нуля, включая useEffect, useState, useRef — всё сбрасывается.
const UserCard = ({ userId }) => {
const [data, setData] = useState(null);
useEffect(() => {
fetch(`/api/user/${userId}`).then(setData);
}, [userId]);
return <div>{data?.name}</div>;
};
const App = () => {
const [currentUserId, setCurrentUserId] = useState(1);
return (
<div>
<button onClick={() => setCurrentUserId(currentUserId === 1 ? 2 : 1)}>
Switch User
</button>
<UserCard key={currentUserId} userId={currentUserId} />
{/* 👆 key меняется → компонент полностью пересоздаётся */}
</div>
);
};
> ✅ Почему это важно?
keyуправляет жизненным циклом компонента.- Изменение
key— это самый сильный способ “перезагрузить” компонент. - Используется для:
- Сброса формы
- Переключения табов
- Принудительной перезагрузки сложных компонентов (например, чартов)
> ⚠️ Ошибка:
> Не используйте индекс массива как key, если элементы могут переставляться:
> ```tsx`
> {items.map((item, index) => <Item key={index} />)} // ❌ Плохо!
> ````
> → При добавлении/удалении элементов React неправильно сопоставляет компоненты → баги состояния.
> ✅ Правильно:
> ```tsx`
> {items.map(item => <Item key={item.id} />)} // ✅ Уникальный и стабильный ключ
> ````
✅ 5. Изменение контекста (context)
🔹 Что происходит?
Когда значение в Context.Provider меняется — все компоненты, которые используют useContext(YourContext), перерендерятся.
const ThemeContext = createContext('light');
const App = () => {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Header /> // ✅ Перерендерится
<Sidebar /> // ✅ Перерендерится
<Content /> // ✅ Перерендерится
</ThemeContext.Provider>
);
};
const Header = () => {
const theme = useContext(ThemeContext); // 👈 Подписался
return <header className={theme}>Header</header>;
};
> ✅ Почему это происходит?
> React не знает, какие именно компоненты используют контекст — он рекурсивно ищет все useContext и инвалидирует их при изменении значения.
💡 Глубина: Подводные камни
❌ Проблема:
const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});
const App = () => {
const [theme, setTheme] = useState('light');
const contextValue = {
theme,
toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
};
return (
<ThemeContext.Provider value={contextValue}> {/* ❌ Новый объект на каждом рендере */}
<Header />
</ThemeContext.Provider>
);
};
> → Header будет перерендериваться при каждом рендере App, даже если theme не изменился — потому что contextValue — новая ссылка.
✅ Решение:
const contextValue = useMemo(() => ({
theme,
toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
}), [theme]);
> ✅ Теперь Header перерендерится только при изменении theme, а не при каждом рендере.
> 💡 Продвинутый совет:
> Для сложных контекстов используйте раздельные контексты:
> ```tsx`
> const ThemeContext = createContext('light');
> const ToggleContext = createContext(() => {});
> ````
> → Это снижает количество компонентов, которые реагируют на изменения.
✅ 6. Другие случаи, вызывающие рендер
| Случай | Пояснение |
|---|---|
Вызов forceUpdate() | Устаревший API (в классах), не работает в функциональных компонентах. Используйте setState(() => state) для принудительного рендера. |
Изменение хука useReducer | Аналогично setState — вызывает рендер компонента, где он объявлен. |
Изменение useTransition/useDeferredValue | Не вызывают рендер, но откладывают его — компонент всё равно перерендерится, но с приоритетом ниже. |
| Смена маршрута (React Router) | При смене пути React Router заменяет компоненты — это создание нового экземпляра, а не рендер старого. |
Изменение children в React.Children.map или cloneElement | Если children — это новый элемент (например, <div key={newKey}>), React пересоздаст его. |
✅ 7. Когда рендер НЕ происходит — важные исключения
| Ситуация | Почему нет рендера |
|---|---|
React.memo с теми же пропсами | React сравнивает пропсы и пропускает рендер. |
useMemo/useCallback | Не влияют на рендер — они оптимизируют вычисления внутри. |
Изменение useRef | ref.current меняется — не вызывает рендер. |
| Изменение состояния в другом компоненте | Если он не является родителем, потомком или подписчиком контекста — никакого влияния. |
setState с тем же значением | React автоматически оптимизирует — если state === newState — рендер не запускается. |
> ✅ Пример: > ```tsx` > const [count, setCount] = useState(0); > setCount(0); // ❌ Нет рендера — значение то же > setCount(1); // ✅ Рендер > setCount(1); // ❌ Нет рендера > ````
> ⚠️ Важно:
> Это работает только для примитивов и поверхностного сравнения.
> Для объектов:
> ```tsx`
> const [user, setUser] = useState({ id: 1 });
> setUser({ id: 1 }); // ✅ Рендер будет — потому что это новый объект!
> ````
✅ 8. Как проверить, происходит ли рендер?
🔧 Инструменты для диагностики:
-
React DevTools → “Highlight updates when components render”
→ Показывает, какие компоненты перерендериваются при каждом действии. -
Консоль в компоненте
const MyComponent = () => {console.log('MyComponent rendered');return <div>...</div>;}; -
React Profiler (в DevTools)
→ Записывает время рендера, частоту, причину.
→ Позволяет увидеть, кто именно вызывает рендер. -
useDebugValue+useMemoдля отслеживания измененийconst value = useMemo(() => {console.log('Value recalculated');return complexCalculation();}, [deps]);
✅ 9. Заключение: Полный алгоритм принятия решения
> 🔍 Задайте себе 3 вопроса перед оптимизацией:
-
Почему компонент перерендерился?
→ Проверьте DevTools. Это состояние? Пропсы? Контекст? Родитель? -
Нужно ли ему перерендериваться?
→ Если он не использует изменённые данные — это лишняя работа. -
Как я могу предотвратить это?
→React.memoдля пропсов
→useMemoдля объектов/массивов в пропсах
→useCallbackдля функций
→useContextсuseMemoвProvider
→ Уникальныйkeyдля стабильной идентификации
✅ Итог: Кратко, но полно
Рендер компонента в React происходит при:
- ✅ Изменении собственного состояния (
useState,useReducer) - ✅ Изменении пропсов (если компонент не
React.memo) - ✅ Рендере родительского компонента (если дочерний не оптимизирован)
- ✅ Изменении
key— полный пересоздание компонента - ✅ Изменении значения контекста, к которому компонент подписан через
useContext
Рендер НЕ происходит при:
- ❌ Изменении
useRef - ❌
setStateс тем же значением (для примитивов) - ❌
React.memoс теми же пропсами - ❌ Побочным эффектам вне рендер-фазы
> ✅ Правило senior-разработчика:
> Рендер — не враг. Лишние рендеры — враг.
> Оптимизируйте не для красоты, а для предсказуемости и скорости.
> Всегда измеряйте — не предполагайте.
> 💡 Финальный совет:
> Если вы не можете объяснить, почему компонент перерендерился — вы не контролируете своё приложение.
> React DevTools — ваш лучший союзник. Используйте его как сканер.
Вопрос 51. Проведите рефакторинг React компонента с проблемным кодом
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:20:48"/>
Ответ собеседника: Правильный. Кандидат проводит рефакторинг: добавляет массив зависимостей в useEffect, перемещает функцию внутрь useEffect чтобы избежать пересоздания, добавляет finally для обработки завершения запроса, убирает лишнее состояние, добавляет ключи для списка, улучшает семантику разметки, исправляет именование переменных.
Правильный ответ:
Рефакторинг — это не просто «исправление ошибок», а системная работа над архитектурой компонента: устранение побочных эффектов, обеспечение предсказуемости, улучшение производительности, доступности и поддерживаемости. Ниже представлен полный, production-ready рефакторинг типичного проблемного компонента с детальным объяснением каждого решения.
Исходный проблемный код (типичный пример)
// ❌ ПРОБЛЕМНЫЙ КОД
import React, { useState, useEffect } from 'react';
const UserList = ({ filter }) => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filterValue, setFilterValue] = useState(filter); // Дублирование пропса в стейт
const fetchUsers = async () => { // Функция вне useEffect — пересоздаётся каждый рендер
setLoading(true);
try {
const response = await fetch(`/api/users?search=${filterValue}`);
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
}
setLoading(false); // Нет finally — если ошибка в setUsers, loading застрянет
};
useEffect(() => { // Нет массива зависимостей — запускается КАЖДЫЙ рендер
fetchUsers();
});
const handleFilterChange = (e) => {
const value = e.target.value;
setFilterValue(value);
fetchUsers(); // Ручной вызов — race condition, лишний запрос
};
return (
<div className="user-list">
<input
type="text"
value={filterValue}
onChange={handleFilterChange}
placeholder="Поиск..."
/>
{loading && <div>Загрузка...</div>}
{error && <div className="error">Ошибка: {error}</div>}
<ul>
{users.map((user, index) => ( // index как key — ломает состояние при сортировке/удалении
<li key={index}>
<span>{user.name}</span>
<button onClick={() => alert(user.email)}>Email</button>
</li>
))}
</ul>
</div>
);
};
export default UserList;
Рефакторенный код (Senior Level)
// ✅ РЕФАКТОРЕННЫЙ КОД
import React, { useState, useEffect, useCallback } from 'react';
interface User {
id: string;
name: string;
email: string;
}
interface UserListProps {
initialFilter?: string;
}
const UserList: React.FC<UserListProps> = ({ initialFilter = '' }) => {
// 1. Состояние: только необходимое, с правильными типами
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<string>(initialFilter); // Один источник правды
// 2. Функция загрузки: useCallback + AbortController для отмены
const fetchUsers = useCallback(async (searchTerm: string) => {
// AbortController — отмена запроса при размонтировании или смене фильтра
const controller = new AbortController();
const signal = controller.signal;
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users?search=${encodeURIComponent(searchTerm)}`, {
signal, // Привязка к сигналу отмены
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data: User[] = await response.json();
// Проверка актуальности: если запрос устарел — игнорируем результат
if (!signal.aborted) {
setUsers(data);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return; // Игнорируем отмену — это нормально
}
if (!signal.aborted) {
setError(err instanceof Error ? err.message : 'Неизвестная ошибка');
}
} finally {
if (!signal.aborted) {
setLoading(false); // Гарантированное завершение загрузки
}
}
// Cleanup: отмена запроса при размонтировании или смене searchTerm
return () => controller.abort();
}, []);
// 3. Эффект: правильные зависимости, вызов внутри
useEffect(() => {
fetchUsers(filter);
}, [filter, fetchUsers]); // Зависимости: filter + стабильная fetchUsers
// 4. Обработчик фильтра: только обновление состояния, эффект сам подхватит
const handleFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
}, []);
// 5. Рендер: семантика, ключи, доступность, условный рендер
return (
<section aria-label="Список пользователей" className="user-list">
<label htmlFor="user-search" className="visually-hidden">
Поиск пользователей
</label>
<input
id="user-search"
type="search"
value={filter}
onChange={handleFilterChange}
placeholder="Поиск по имени..."
aria-describedby="search-hint"
/>
<span id="search-hint" className="visually-hidden">
Введите имя для фильтрации списка
</span>
{loading && (
<div role="status" aria-live="polite" className="loading">
Загрузка пользователей...
</div>
)}
{error && (
<div role="alert" className="error">
Ошибка загрузки: {error}
</div>
)}
{!loading && !error && users.length === 0 && (
<p className="empty-state">Пользователи не найдены</p>
)}
<ul className="user-list__items">
{users.map((user) => (
<li key={user.id} className="user-list__item">
<span className="user-list__name">{user.name}</span>
<button
type="button"
className="user-list__email-btn"
onClick={() => navigator.clipboard.writeText(user.email)}
aria-label={`Скопировать email ${user.name}`}
>
Email
</button>
</li>
))}
</ul>
</section>
);
};
export default UserList;
Детальный разбор изменений
1. Устранение дублирования состояния (Props vs State)
- Было:
filter(пропс) +filterValue(стейт) → рассинхронизация, баги. - Стало: Один
filterв стейте, инициализированный изinitialFilter. - Принцип: Single Source of Truth. Пропсы — для входа, стейт — для внутренней логики.
2. useCallback для fetchUsers + AbortController
- Проблема: Функция вне хука пересоздавалась каждый рендер → ломала зависимости
useEffect. - Решение:
useCallbackс пустым массивом зависимостей (функция чистая, зависит только от аргумента). - Критично:
AbortControllerпредотвращает race conditions (старый запрос приходит после нового) и утечки памяти (setState на размонтированном компоненте).
3. Правильный useEffect с зависимостями
- Было: Без массива → запуск каждый рендер → бесконечный цикл запросов.
- Стало:
[filter, fetchUsers]→ запуск только при смене фильтра.fetchUsersстабилен благодаряuseCallback.
4. Обработка ошибок и finally
- Было:
setLoading(false)только вtry→ при ошибке вsetUsersлоадер застревает. - Стало:
finallyблок + проверкаsignal.aborted→ гарантированный сбросloading.
5. Удаление ручного вызова fetchUsers в хендлере
- Было:
handleFilterChangeвызывалfetchUsersнапрямую → дублирование логики, риск вызова до обновления стейта. - Стало: Хендлер только обновляет стейт.
useEffectреактивно подхватывает изменениеfilter→ декларативный подход.
6. Ключи списка: user.id вместо index
- Проблема:
indexломает состояние при сортировке, фильтрации, удалении (React переиспользует не те DOM-узлы). - Решение: Стабильный уникальный идентификатор из данных (
user.id).
7. Семантика и доступность (a11y)
<section aria-label>— landmark для скринридеров.<label htmlFor>+visually-hidden— доступное поле поиска без визуального лейбла.role="status" aria-live="polite"— анонс загрузки.role="alert"— анонс ошибки.type="search"— семантический инпут.aria-labelна кнопке — контекст действия.navigator.clipboard.writeTextвместоalert— современный UX.
8. Типизация (TypeScript)
- Интерфейсы
User,UserListProps— контракт компонента. - Строгая типизация стейта (
User[],string | null,boolean).
9. Улучшение UX
encodeURIComponent— защита от спецсимволов в URL.- Проверка
response.ok—fetchне кидает ошибку на 4xx/5xx. - Состояние «пустой список» (
!loading && !error && users.length === 0).
10. Производительность
useCallbackдляhandleFilterChange— стабильность для дочерних компонентов (если будут).users.mapсkey={user.id}— эффективный диффинг.
Что можно улучшить дальше (Next Level)
1. Вынос логики в кастомный хук
const useUsers = (searchTerm: string) => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
// ... вся логика fetchUsers ...
return () => controller.abort();
}, [searchTerm]);
return { users, loading, error };
};
→ Компонент становится чисто презентационным.
2. Дебаунс поиска
const debouncedFilter = useDebounce(filter, 300); // Кастомный хук
useEffect(() => { fetchUsers(debouncedFilter); }, [debouncedFilter]);
→ Не спамить API на каждый ввод символа.
3. React Query / SWR
const { data, isLoading, error } = useQuery(['users', filter], () => fetchUsers(filter));
→ Кэширование, дедупликация, рефетчинг, пагинация — из коробки.
4. Виртуализация списка
Если пользователей 10 000+ — react-window или react-virtualized.
5. Error Boundary
Обертка компонента в <ErrorBoundary> для изоляции крашей.
Чек-лист рефакторинга (для код-ревью)
| Категория | Проверка |
|---|---|
| Состояние | Нет дублирования пропсов в стейт? Состояние минимально? |
| Эффекты | Все useEffect имеют правильные зависимости? Есть cleanup? |
| Асинхронность | Используется AbortController? Обработаны race conditions? |
| Производительность | Стабильные ссылки (useCallback, useMemo) там, где нужно? |
| Списки | Ключи — уникальные ID, не индексы? |
| Доступность | Семантическая разметка? ARIA-атрибуты? Фокус-менеджмент? |
| Типизация | TypeScript интерфейсы для пропсов, стейта, API? |
| Обработка ошибок | try/catch/finally? Проверка response.ok? User-friendly сообщения? |
| UX | Состояния: loading, error, empty, success? Дебаунс при вводе? |
Заключение
Рефакторинг — это инвестиция в будущее.
Каждое изменение выше решает конкретную проблему:
AbortController→ нет утечек памяти и race conditions.useCallback+ зависимости → нет лишних рендеров и запросов.key={id}→ нет багов состояния в списке.- Семантика + ARIA → приложение работает для всех пользователей.
- TypeScript → ошибки ловятся на этапе сборки, а не в проде.
> ✅ Правило Senior-разработчика:
> «Не пиши код, который «работает». Пиши код, который «не может сломаться»».
> Рефакторинг — это не косметика. Это архитектурная гигиена.
Вопрос 52. Реализуйте функцию для поиска пары чисел в массиве, дающих заданную сумму
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:30:54"/>
Ответ собеседника: Правильный. Кандидат предлагает решение через Map для оптимизированного поиска. Создаёт Map, итерируется по массиву, для каждого элемента вычисляет разницу между целевой суммой и текущим элементом. Если разница есть в Map, возвращает пару чисел. Если нет, добавляет текущий элемент в Map.
Правильный ответ:
Задача «Two Sum» — классическая алгоритмическая проблема, проверяющая понимание сложности алгоритмов, структур данных и граничных случаев. На уровне Senior/Tech Lead ожидается не просто «работающий код», а production-ready решение с правильной типизацией, обработкой ошибок, тестами и обоснованием выбора алгоритма.
1. Анализ сложности и выбор подхода
| Подход | Временная сложность | Пространственная сложность | Применимость |
|---|---|---|---|
| Brute Force (вложенные циклы) | O(n²) | O(1) | Только для обучения / массивы < 50 элементов |
| Сортировка + два указателя | O(n log n) | O(1) или O(n) | Если не нужны исходные индексы и массив можно мутировать |
| Hash Map (Hash Set / Object / Map) | O(n) | O(n) | Стандарт для продакшена — оптимально по времени, сохраняет индексы |
> ✅ Выбор: Hash Map (ES6 Map).
> В JavaScript/TypeScript Map предпочтительнее Object/Record:
> - Ключи любого типа (не только строки)
> - Гарантированный порядок вставки
> - Лучшая производительность при частых добавлениях/удалениях
> - Нет риска коллизий с прототипом (__proto__, constructor)
2. Production-Ready Реализация (TypeScript)
/**
* Находит индексы двух чисел в массиве, которые в сумме дают target.
*
* @param nums - Массив целых чисел (может содержать дубликаты, отрицательные значения)
* @param target - Целевая сумма
* @returns Кортеж [index1, index2] (index1 < index2) или null, если пара не найдена
*
* @complexity Time: O(n), Space: O(n)
* @throws {TypeError} Если nums не является массивом или target не число
*/
function twoSum(nums: readonly number[], target: number): [number, number] | null {
// 1. Валидация входных данных (Fail-fast)
if (!Array.isArray(nums)) {
throw new TypeError('Первый аргумент должен быть массивом');
}
if (typeof target !== 'number' || !Number.isFinite(target)) {
throw new TypeError('Target должен быть конечным числом');
}
// 2. Map: число -> его индекс (первого вхождения)
// Используем Map, а не Object, для безопасности и производительности
const numToIndex = new Map<number, number>();
// 3. Одиночный проход (Single Pass)
for (let i = 0; i < nums.length; i++) {
const current = nums[i];
// Защита от нечисловых значений в массиве
if (typeof current !== 'number' || !Number.isFinite(current)) {
continue; // или throw, в зависимости от контракта
}
const complement = target - current;
// 4. Проверка: видели ли мы дополнение РАНЬШЕ?
if (numToIndex.has(complement)) {
// Возвращаем индексы в порядке возрастания
return [numToIndex.get(complement)!, i];
}
// 5. Сохраняем ТЕКУЩЕЕ число для будущих итераций
// Важно: только если его еще нет (сохраняем первый индекс для стабильности)
if (!numToIndex.has(current)) {
numToIndex.set(current, i);
}
}
// 6. Пара не найдена
return null;
}
3. Разбор ключевых моментов (Deep Dive)
Почему Single Pass (один цикл) лучше, чем Two Pass (сначала заполнить Map, потом искать)?
// ❌ Two Pass — ПРОБЛЕМА с дубликатами: [3, 3], target = 6
const map = new Map(nums.map((num, i) => [num, i])); // map = { 3: 1 } — второй индекс перезаписал первый!
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i];
if (map.has(complement) && map.get(complement) !== i) { // Нужна доп. проверка != i
return [i, map.get(complement)!];
}
}
// ✅ Single Pass — РЕШАЕТ проблему естественным образом
// Итерация 0: current=3, complement=3, map={} -> не найдено -> map={3:0}
// Итерация 1: current=3, complement=3, map={3:0} -> НАЙДЕНО! -> return [0, 1]
> Single Pass гарантирует, что мы не используем один и тот же элемент дважды (i !== j) и корректно обрабатываем дубликаты.
4. Обработка граничных случаев (Edge Cases)
| Входные данные | Ожидаемый результат | Комментарий |
|---|---|---|
[] или [1] | null | Недостаточно элементов |
[3, 3], target 6 | [0, 1] | Дубликаты — валидная пара |
[1, 2, 3], target 7 | null | Пары нет |
[-1, 5, 2], target 1 | [0, 2] | Отрицательные числа |
[1.5, 2.5], target 4 | [0, 1] | Float (если контракт позволяет) |
null, undefined, "string" | TypeError | Fail-fast валидация |
5. Вариации задачи и адаптация решения
А. Вернуть сами числа (не индексы), массив не отсортирован
function twoSumValues(nums: number[], target: number): [number, number] | null {
const seen = new Set<number>();
for (const num of nums) {
const complement = target - num;
if (seen.has(complement)) return [complement, num];
seen.add(num);
}
return null;
}
Б. Массив ОТСОРТИРОВАН — Two Pointers (O(1) Space)
function twoSumSorted(nums: number[], target: number): [number, number] | null {
let left = 0;
let right = nums.length - 1;
while (left < right) {
const sum = nums[left] + nums[right];
if (sum === target) return [left, right];
if (sum < target) left++;
else right--;
}
return null;
}
> ⚠️ Важно: Two Pointers не работает для возврата исходных индексов несортированного массива (сортировка ломает индексы).
В. Найти ВСЕ уникальные пары (не только первую)
function allTwoSumPairs(nums: number[], target: number): [number, number][] {
const seen = new Set<number>();
const pairs = new Set<string>(); // для уникальности пар значений
const result: [number, number][] = [];
for (const num of nums) {
const complement = target - num;
if (seen.has(complement)) {
const key = [complement, num].sort().join(',');
if (!pairs.has(key)) {
pairs.add(key);
result.push([complement, num]);
}
}
seen.add(num);
}
return result;
}
6. Тестирование (Jest / Vitest)
describe('twoSum', () => {
test('базовый случай', () => {
expect(twoSum([2, 7, 11, 15], 9)).toEqual([0, 1]);
});
test('дубликаты', () => {
expect(twoSum([3, 3], 6)).toEqual([0, 1]);
});
test('отрицательные числа', () => {
expect(twoSum([-3, 4, 3, 90], 0)).toEqual([0, 2]);
});
test('пара не найдена', () => {
expect(twoSum([1, 2, 3], 7)).toBeNull();
});
test('пустой массив', () => {
expect(twoSum([], 5)).toBeNull();
});
test('один элемент', () => {
expect(twoSum([5], 5)).toBeNull();
});
test('валидация типов', () => {
expect(() => twoSum(null as any, 5)).toThrow(TypeError);
expect(() => twoSum([1, 2], '5' as any)).toThrow(TypeError);
});
test('не использует один элемент дважды', () => {
// target = 10, есть 5, но только один раз
expect(twoSum([5, 1, 2], 10)).toBeNull();
});
});
7. Производительность и память в реальных системах
Для очень больших массивов (10M+ элементов):
Mapимеет накладные расходы. Альтернатива: сортировка копии + Two Pointers (O(n log n) время, O(n) память под копию, но без хеш-таблицы).- Если память критична и массив уже отсортирован — Two Pointers O(1) Space.
Для потоковых данных (Stream):
- Нельзя использовать Two Pointers (нужен Random Access).
- Single Pass Map — единственный вариант для стриминга.
8. Частые ошибки на собеседованиях
| Ошибка | Последствие | Исправление |
|---|---|---|
for (let i = 0; i < nums.length; i++) { for (let j = i + 1; j < nums.length; j++) } | O(n²) — провал по производительности | Использовать Map |
const map = {}; map[num] = i (Object) | Баги с ключами "__proto__", "constructor", приведение ключей к строке | Использовать Map |
if (map.has(complement)) return [i, map.get(complement)] (Two Pass) | Ошибка на [3, 3] target 6 (индекс перезапишется) | Single Pass или проверка index !== i |
Нет валидации target / nums | Runtime ошибки в проде | Fail-fast проверки в начале |
Возврат undefined вместо null | Нарушение контракта, баги в вызывающем коде | Явный return null |
9. Заключение: Чек-лист Senior-решения
- O(n) Time / O(n) Space — оптимально для общего случая
- Single Pass — корректная работа с дубликатами
- TypeScript типы —
readonly number[], кортеж возврата[number, number] | null - Валидация входов — Fail-fast, понятные ошибки
- ES6
Map— безопасность ключей, производительность - Покрытие тестами — Happy path, Edge cases, Error cases
- Документация (JSDoc) — Сложность, контракт, исключения
- Понимание альтернатив — Когда Two Pointers, когда Sort, когда Stream
> ✅ Правило Tech Lead:
> «Код пишется один раз, читается сотни». Решение Two Sum — это не про «найти пару», а про демонстрацию: я думаю о сложности, памяти, типах, граничных случаях и сопровождаемости.»
Вопрос 53. С какими браузерными хранилищами вы работали и в чём их отличия?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:37:42"/>
Ответ собеседника: Правильный. Local Storage, Session Storage, Cookies, IndexedDB. Local Storage и Session Storage до 5MB, Cookies до 4KB. Cookies отправляются на сервер с запросами, имеют флаги: Secure (только HTTPS), HttpOnly (недоступны из JavaScript).
Правильный ответ:
Работа с клиентским хранилищем — это не просто «куда положить токен», а архитектурное решение, влияющее на безопасность, производительность (Critical Rendering Path), UX (оффлайн-режим) и соответствие стандартам (GDPR, ePrivacy). На уровне Senior/Tech Lead требуется понимание внутренней механики, ограничений квот, политики удаления и векторов атак.
1. Сравнительная матрица (Cheat Sheet)
| Хранилище | Емкость | Доступность | Отправка на сервер | Срок жизни | API | Потокобезопасность |
|---|---|---|---|---|---|---|
| Cookies | ~4 KB (на куку) | Все вкладки/окна origin | Автоматически с каждым HTTP-запросом | Expires / Max-Age / Session | document.cookie (строка) | Нет (Race conditions) |
| LocalStorage | ~5-10 MB (на origin) | Все вкладки/окна origin | Никогда (только JS) | Бессрочный (до ручной очистки) | Синхронный, ключ-значение | Нет (Race conditions) |
| SessionStorage | ~5-10 MB (на вкладку) | Только текущая вкладка (изолировано) | Никогда | До закрытия вкладки | Синхронный, ключ-значение | Да (изоляция вкладки) |
| IndexedDB | Сотни MB / ГБ (квота браузера) | Все вкладки/окна origin | Никогда | Бессрочный | Асинхронный, транзакционный, NoSQL | Да (транзакции, курсоры) |
| Cache API | Сотни MB / ГБ | Все вкладки/окна origin | Никогда (Service Worker) | Бессрочный (до очистки) | Асинхронный, Request/Response | Да |
| OPFS (Origin Private File System) | ГБ | Все вкладки/окна origin | Никогда | Бессрочный | Асинхронный, файловый доступ | Да (файловые дескрипторы) |
2. Deep Dive: Каждое хранилище детально
Cookies (HTTP Cookies)
Назначение: Изначально — идентификация сессии на сервере. Сейчас — только для Server-Side Auth (Refresh Tokens, Session ID) и трекинга. Критические атрибуты (Security First):
Set-Cookie: session_id=abc123; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600; Domain=example.com
HttpOnly— Обязателен для auth-кук. Защищает от XSS (JS не имеет доступа).Secure— Только HTTPS. Обязателен в проде.SameSite— Защита от CSRF.Strict— Кука не отправляется при любых cross-site переходах (самый безопасный, но ломает внешние ссылки на логин).Lax(Default в современных браузерах) — Отправляется при безопасных top-level навигациях (клик по ссылке). Баланс безопасности и UX.None— Отправляется всегда. ТребуетSecure. Для кросс-доменных iframe/embedded виджетов.
Domain/Path— Scope видимости.Domain=.example.comделает куку доступной на поддоменах.__Host-/__Secure-префиксы — Защита от подмены кук поддоменами (Cookie Prefixes RFC).
Подводные камни:
- Размер заголовка: 4 KB на куку. 20 кук = 80 KB заголовков на каждый запрос (включая статику, если домен общий). → Вынос статики на отдельный домен (CDN) без кук — must have.
- Синхронность:
document.cookieблокирует главный поток. - Нет нативного JSON: Только строки. Сериализация вручную.
LocalStorage / SessionStorage (Web Storage API)
Назначение: Клиентский кэш UI-состояния, пользовательские настройки, нечувствительные токены доступа (Access Tokens — только если нет HttpOnly альтернативы). API:
// Только строки!
localStorage.setItem('theme', 'dark');
const theme = localStorage.getItem('theme'); // 'dark' | null
localStorage.removeItem('theme');
localStorage.clear(); // ВСЕ данные origin
Критические нюансы:
- Синхронность & Главный поток: Блокирует рендеринг. Чтение/запись > 1-2 МБ вызывает Jank (дёрганье UI). Для больших данных — только IndexedDB.
- Storage Event: Событие
storageсрабатывает только в других вкладках (не в той, где изменили). Полезно для синхронизации состояния (logout во всех табах).window.addEventListener('storage', (e) => {if (e.key === 'auth_token' && !e.newValue) logout();}); - Квота:
QuotaExceededError(DOMException code 22). Обязателен try/catch.try { localStorage.setItem(key, value); }catch (e) { if (e.name === 'QuotaExceededError') { /* cleanup / fallback */ } } - Private Browsing (Incognito): В Safari (iOS/macOS) в приватном режиме
localStorage.setItemбросает ошибку (SecurityError). Нужен фолбэк в память. - Нет TTL: Данные живут вечно. Реализация TTL — на вас (хранить
{ value, expiry }и проверять при чтении).
IndexedDB (Transactional NoSQL Database)
Назначение: Тяжелая артиллерия. Оффлайн-приложения, кэширование больших JSON, файлов, поиск по индексам, синхронизация с бэкендом (RxDB, Dexie, WatermelonDB). Архитектура:
- Database → Object Stores (таблицы) → Records (объекты с Key Path) → Indexes (поиск по полям).
- Транзакции:
readonly|readwrite|versionchange. ACID гарантии внутри транзакции. - Курсоры: Итерация без загрузки всего в память. Современный API (Promise-based wrapper):
// Рекомендуется: idb (Jake Archibald) или Dexie.js вместо нативного event-based API
import { openDB } from 'idb';
const db = await openDB('MyApp', 1, {
upgrade(db) {
const store = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
store.createIndex('by-email', 'email', { unique: true });
},
});
// Запись
await db.put('users', { name: 'Alex', email: 'alex@example.com' });
// Чтение по индексу
const user = await db.getFromIndex('users', 'by-email', 'alex@example.com');
// Транзакция (атомарность)
const tx = db.transaction(['users', 'logs'], 'readwrite');
await Promise.all([tx.store.put(...), tx.store.put(...)]);
await tx.done;
Преимущества перед LocalStorage:
- Асинхронное (не блокирует Main Thread).
- Индексация и поиск (Range queries).
- Поддержка бинарных данных (Blobs, Files, ArrayBuffers) без Base64 overhead (+33% размер).
- Транзакционность.
Cache API (Service Worker Cache)
Назначение: Кэширование HTTP-ответов (Request/Response пары). Основа PWA / Offline First. Особенности:
- Доступен в
WindowиService Worker. - Ключ —
Requestобъект (URL + Headers + Method), Значение —Response. - Управление версиями кэша (
caches.open('v1')). - Стратегии:
Cache First,Network First,Stale While Revalidate(Workbox). - Не для произвольных JSON — только валидные HTTP-ответы.
OPFS (Origin Private File System) — Modern Standard (2022+)
Назначение: Высокопроизводительная работа с файлами/бинарными данными в песочнице. Видео-редакторы, IDE, игры, ML-модели в браузере. Фичи:
navigator.storage.getDirectory()→FileSystemDirectoryHandle.- Синхронный доступ в Web Workers (
FileSystemSyncAccessHandle) — Game Changer для WASM/Heavy compute. - Потоковое чтение/запись без загрузки всего в RAM.
- Изолировано от реальной ФС пользователя (безопасность).
3. Архитектурные паттерны использования (Best Practices)
| Сценарий | Рекомендуемое хранилище | Обоснование |
|---|---|---|
| Access Token (JWT) | Memory (JS Variable) + HttpOnly Cookie (Refresh) | Access Token в памяти — защита от XSS. Refresh в HttpOnly Cookie — защита от кражи и CSRF (SameSite=Lax/Strict). Никогда LocalStorage для токенов. |
| Refresh Token | HttpOnly Cookie (Secure, SameSite=Strict/Lax) | Сервер контролирует жизненный цикл, отзыв, ротацию. Недоступен JS. |
| Тема UI, настройки языка, сайдбар | LocalStorage | Малые данные, нужны сразу при загрузке (синхронное чтение допустимо), выживают перезагрузку. |
| Черновик формы / Wizard шаги | SessionStorage | Изолировано в табе. Не должно утекать в другие табы. Пропадает при закрытии — правильно для черновиков. |
| Оффлайн-кэш статей, каталог товаров | IndexedDB (через Dexie/RxDB) | Много данных, нужен поиск/фильтрация, фоновая синхронизация. |
| Кэширование ассетов / API ответов для PWA | Cache API (Service Worker) | Стандарт браузкий контроль сети, стратегии обновления, работа без сети. |
| Большие бинарные блобы (видео, WASM, ML) | OPFS | Производительность, стриминг, доступ из Web Workers. |
| Cross-tab синхронизация (Logout, настройки) | LocalStorage + storage event или Broadcast Channel API | BroadcastChannel современнее, работает внутри origin, не загрязняет хранилище. |
4. Безопасность: Threat Modeling
| Вектор атаки | LocalStorage / SessionStorage | Cookies (HttpOnly) | IndexedDB / Cache API |
|---|---|---|---|
| XSS (Кража данных) | Критично уязвимо (любой script имеет доступ) | Защищено (HttpOnly) | Уязвимо (доступно JS) |
| CSRF (Подделка запроса) | Не применимо (не отправляется авто) | Риск (нужен SameSite, CSRF Token) | Не применимо |
| XS-Leaks (Утечка через Storage Event) | Возможен (тайминг, квота) | Нет | Нет |
| MITM (Перехват) | Только если нет HTTPS | Только если нет Secure | Только если нет HTTPS |
> Золотое правило 2024: Auth Tokens → HttpOnly Cookies. Всё остальное (UI State, Cache) → LocalStorage / IndexedDB / OPFS.
5. Очистка и Eviction Policies (Browser Internals)
- Cookies: Удаляются автоматически по
Expires/Max-Age. Можно удалить вручную (установив прошлое). - LocalStorage: Никогда не удаляется браузером автоматически. Только пользователем (Clear Site Data) или скриптом.
- SessionStorage: Удаляется при закрытии последней вкладки origin.
- IndexedDB / Cache API / OPFS: Подчиняются Storage Quota Management (Browser Quota).
- Best Effort: Браузер может удалить всё хранилище origin под давлением диска (Pressure:
navigator.storage.persist()). - Persistent: Если пользователь дал разрешение (
persist()) или сайт установлен как PWA — удаляется только вручную. - LRU (Least Recently Used): При нехватке места браузеры (Chrome, Firefox) удаляют все данные наименее недавно используемых origin.
- Best Effort: Браузер может удалить всё хранилище origin под давлением диска (Pressure:
// Проверка и запрос постоянного хранения
if (await navigator.storage.persisted()) {
console.log('Storage is persistent (protected from eviction)');
} else {
const granted = await navigator.storage.persist(); // Запрос у пользователя (не всегда показывается)
}
6. Performance Checklist (Core Web Vitals Impact)
- Не читайте LocalStorage синхронно в
<head>или при инициализации React/Vue — блокирует LCP/TTI.- Решение: Отложите (
requestIdleCallback/useEffect/setTimeout(0)), используйтеsessionStorageдля критического рендера (читается быстрее из-за изоляции), или рендерите скелетон/дефолт, потом гидратируйте.
- Решение: Отложите (
- Не храните большие JSON в LocalStorage — парсинг
JSON.parseна 5 МБ строки = 100-300 мс блокировки Main Thread.- Решение: IndexedDB (асинхронный парсинг) или OPFS.
- Куки на статическом домене — убивают производительность (лишние байты в каждом запросе к
.js,.css,.png).- Решение: Статика на
cdn.example.com/static.example.comбез кук (отдельный поддомен, не поддомен основного сайта с куками).
- Решение: Статика на
- IndexedDB транзакции — держите короткими. Длинная
readwriteтранзакция блокирует другие вкладки (database lock).
7. Заключение: Senior Mindset
Выбор хранилища — это трейдофф между:
- Безопасностью (HttpOnly vs XSS)
- Производительностью (Sync vs Async, Main Thread vs Worker)
- Надежностью (Eviction policies, Quota)
- UX (Offline, Cross-tab sync, Persistence)
- Архитектурной чистотой (Separation of Concerns: Auth ≠ UI State ≠ Cache)
> ✅ Правило Tech Lead:
> «Если вы кладете JWT в LocalStorage — вы не знаете, что такое XSS. Если вы кладете 10 МБ JSON в LocalStorage — вы не знаете, что такое Event Loop. Если вы не знаете про navigator.storage.persist() — вы не готовитесь к продакшену».
> Аудит хранилищ — обязательная часть Security Review и Performance Audit перед релизом.
Вопрос 54. Как реализовать авторизацию с JWT токенами?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:39:02"/>
Ответ собеседника: Правильный. Использовать пару access и refresh токенов. Access токен сохранять в cookies с флагами Secure/HttpOnly или в Local Storage. Refresh токен в Local Storage. Использовать Axios interceptors для добавления токена в заголовки и обработки 401 ошибок - при ошибке использовать refresh токен для получения новой пары токенов и повторения запроса.
Правильный ответ:
Реализация JWT-авторизации на уровне Senior/Tech Lead — это не просто «получить токен и положить в заголовок». Это комплексная система безопасности, включающая: защиту от XSS/CSRF, управление жизненным циклом токенов (ротация, отзыв), устойчивость к гонкам данных (race conditions) и архитектурную изоляцию секретов. Хранение токенов в LocalStorage — критическая уязвимость (XSS), недопустимая в продакшене для чувствительных приложений.
1. Архитектура токенов: Access + Refresh (Token Pair Pattern)
| Характеристика | Access Token (JWT) | Refresh Token (Opaque / JWT) |
|---|---|---|
| Срок жизни | Короткий: 5–15 мин | Длинный: 7–30 дней (или бессрочный с ротацией) |
| Хранение на клиенте | Только в памяти (JS переменная / Closure / React Context) | Только в HttpOnly Cookie |
| Хранение на сервере | Stateless (проверка подписи + exp) | Stateful (БД/Redis): хэш токена, user_id, user_agent, ip, revoked, expires_at |
| Алгоритм подписи | RS256 / ES256 (асимметричный, JWKS) | Не критично (опaque — случайная строка 64+ байт) |
| Назначение | Авторизация API запросов (Authorization: Bearer ...) | Получение новой пары Access/Refresh (POST /auth/refresh) |
| Отзыв (Revocation) | Через короткий TTL + Blocklist (Redis) при критических событиях | Мгновенный (удаление/пометка в БД) |
> ✅ Золотое правило 2024: Никогда не кладите Access Token в LocalStorage/SessionStorage. Любая XSS-уязвимость (уязвимая зависимость, dangerouslySetInnerHTML, уязвимость в UI-библиотеке) приводит к полному компромиссу аккаунта. HttpOnly Cookie + Memory — единственный безопасный паттерн для браузерных приложений.
2. Безопасное хранение: HttpOnly Cookies + Memory Pattern
Сценарий: Классическое SPA (React/Vue/Svelte) + Backend API (Node/Go/Java/Python) на одном домене или субдоменах (api.example.com, app.example.com).
Backend (Set-Cookie Headers)
HTTP/1.1 200 OK
Set-Cookie: refreshToken=xyz123; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh; Max-Age=2592000; Domain=.example.com
Set-Cookie: accessToken=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900; Domain=.example.com
Примечание: Access Token в куке удобен для SSR/Next.js/Remix. Для чистого SPA часто возвращают Access Token в теле ответа JSON, а клиент держит его только в памяти (переменная модуля/Store), не записывая в LocalStorage. Refresh Token — всегда только в HttpOnly Cookie.
Client-Side (TypeScript): Access Token в памяти
// authStore.ts (Zustand / Pinia / Redux Toolkit / Context)
// Токен НИКОГДА не попадает в LocalStorage/SessionStorage
class AuthService {
private accessToken: string | null = null;
private refreshPromise: Promise<string> | null = null; // Mutex для ротации
// Инициализация при старте приложения (проверка сессии)
async init() {
try {
// Вызов эндпоинта, который читает HttpOnly Refresh Cookie и выдает новый Access Token
const { accessToken } = await api.post('/auth/refresh');
this.setAccessToken(accessToken);
} catch {
this.logout(); // Refresh просрочен/отозван -> чистый логаут
}
}
setAccessToken(token: string | null) {
this.accessToken = token;
}
getAccessToken(): string | null {
return this.accessToken;
}
// Интерцептор Axios / Fetch
async interceptRequest(config: AxiosRequestConfig) {
const token = this.getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
}
async interceptResponseError(error: AxiosError) {
const originalRequest = error.config;
// 401 + не повторный запрос + есть refresh кука (проверяем флаг _retry)
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newAccessToken = await this.refreshAccessToken(); // Mutex внутри
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api.request(originalRequest); // Повтор оригинального запроса
} catch (refreshError) {
this.logout(); // Refresh не прошел -> полный логаут
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
// Refresh с защитой от Race Condition (Mutex)
private async refreshAccessToken(): Promise<string> {
// Если запрос на обновление уже идет — ждем его результат
if (this.refreshPromise) return this.refreshPromise;
this.refreshPromise = (async () => {
try {
// Кука refreshToken уйдет автоматически (HttpOnly)
const { accessToken } = await api.post('/auth/refresh');
this.setAccessToken(accessToken);
return accessToken;
} finally {
this.refreshPromise = null; // Сброс мьютекса
}
})();
return this.refreshPromise;
}
async logout() {
this.setAccessToken(null);
// Вызов бэкенда для инвалидации Refresh Token в БД
await api.post('/auth/logout');
// Редирект на логин
}
}
3. Серверная реализация: Ротация Refresh Token (Rotation & Reuse Detection)
Критично для безопасности: При каждом использовании Refresh Token — старый отзывается, выдается новый. Если перехваченный токен используют дважды (атакер и легаitimate пользователь) — обоим доступ запрещается, сессия сбрасывается, событие логируется в SIEM.
Схема БД (PostgreSQL / Redis)
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL, -- bcrypt/scrypt/argon2 хэш токена (НЕ plain text!)
user_agent TEXT,
ip INET,
revoked BOOLEAN DEFAULT FALSE,
replaced_by UUID REFERENCES refresh_tokens(id), -- Цепочка ротации для детекта reuse
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_rt_user ON refresh_tokens(user_id);
CREATE INDEX idx_rt_hash ON refresh_tokens(token_hash); -- Для быстрого поиска при верификации
Логика эндпоинта POST /auth/refresh (Псевдокод/TypeScript)
// POST /auth/refresh
// Кука refreshToken приходит автоматически (HttpOnly)
async function refreshHandler(req, res) {
const incomingToken = req.cookies.refreshToken;
if (!incomingToken) throw new UnauthorizedError('No refresh token');
// 1. Найти токен в БД (по хэшу — идеально, или полным сканированием с bcrypt compare)
// Оптимизация: храним prefix токена (первые 8 символов) в отдельном поле для SELECT WHERE prefix=...
const storedToken = await db.refreshTokens.findByTokenPrefix(incomingToken.slice(0, 8));
if (!storedToken || storedToken.revoked || storedToken.expiresAt < new Date()) {
// Токен не найден, отозван или просрочен
await revokeTokenFamily(storedToken?.id); // Безопасность: сжигаем всю цепочку
throw new UnauthorizedError('Invalid refresh token');
}
// 2. Верификация хэша (bcrypt/argon2)
const valid = await bcrypt.compare(incomingToken, storedToken.tokenHash);
if (!valid) {
// Неверный токен -> возможная атака (подбор) -> отзываем семью
await revokeTokenFamily(storedToken.id);
throw new UnauthorizedError('Invalid refresh token');
}
// 3. ROTATION: Помечаем старый как отозванный, создаем НОВЫЙ
const newRawToken = crypto.randomBytes(64).toString('hex'); // 128 бит энтропии
const newHash = await bcrypt.hash(newRawToken, 12);
const newTokenRecord = await db.refreshTokens.create({
userId: storedToken.userId,
tokenHash: newHash,
userAgent: req.headers['user-agent'],
ip: req.ip,
replacedBy: storedToken.id, // Линкуем на предка
expiresAt: addDays(new Date(), 30),
});
// 4. Отзыв старого (Soft delete)
await db.refreshTokens.update(storedToken.id, { revoked: true, replacedBy: newTokenRecord.id });
// 5. Генерация нового Access Token (JWT RS256)
const accessToken = generateAccessToken(storedToken.userId); // TTL 10-15 min
// 6. Установка новых кук
res.cookie('refreshToken', newRawToken, {
httpOnly: true,
secure: true,
sameSite: 'strict', // или 'lax' если нужно с внешних ссылок
path: '/auth/refresh', // Кука уходит ТОЛЬКО на эндпоинт рефреша!
maxAge: 30 * 24 * 60 * 60 * 1000,
domain: '.example.com', // Для субдоменов
});
// Access Token в теле ответа (для SPA в памяти) ИЛИ в куке (для SSR)
res.json({ accessToken });
}
// Функция детекта Reuse (Token Theft Detection)
async function revokeTokenFamily(tokenId: UUID) {
// Рекурсивно или итеративно отзываем всю цепочку replaced_by
let currentId = tokenId;
while (currentId) {
const token = await db.refreshTokens.findById(currentId);
if (!token || token.revoked) break;
await db.refreshTokens.update(currentId, { revoked: true });
currentId = token.replacedBy; // Идем по цепочке "кто кем заменен"
}
// Логирование Security Event: "Refresh Token Reuse Detected - Possible Token Theft"
await securityLogger.warn({ userId: token.userId, ip: token.ip, event: 'TOKEN_REUSE_DETECTED' });
}
4. Подписание Access Token: RS256 + JWKS (JSON Web Key Set)
Почему не HS256?
- HS256 использует общий секрет. Утечка секрета на любом микросервисе = компромисс всего кластера.
- RS256 (RSA) / ES256 (ECDSA): Приватный ключ только у Auth Server (подпись). Публичный ключ — у всех Resource Servers (верификация). Компрометация Resource Server не позволяет подделать токены.
Реализация (Node.js / jose library)
// Auth Server: Генерация ключей (один раз при деплое/ротации)
import { generateKeyPair, exportJWK, SignJWT, jwtVerify, createRemoteJWKSet } from 'jose';
// Приватный ключ (только Auth Server)
const privateKey = await generateKeyPair('RS256', { extractable: true });
// Публичный JWKS эндпоинт: GET /.well-known/jwks.json
const jwks = { keys: [await exportJWK(privateKey.publicKey)] };
// Подписание Access Token
function generateAccessToken(userId: string): string {
return new SignJWT({ sub: userId, roles: ['user'] })
.setProtectedHeader({ alg: 'RS256', kid: 'key-id-1' }) // Key ID для ротации ключей
.setIssuedAt()
.setExpirationTime('15m')
.setIssuer('https://auth.example.com')
.setAudience('https://api.example.com')
.sign(privateKey.privateKey);
}
// Resource Server (API Gateway / Middleware): Верификация
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.sendStatus(401);
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
clockTolerance: '30s', // Допуск часов
});
req.user = { id: payload.sub, roles: payload.roles };
next();
} catch (err) {
// TokenExpiredError, JWSSignatureVerificationFailed, JWTClaimValidationFailed
return res.status(401).json({ error: 'Invalid token' });
}
}
5. CSRF Защита для Cookie-Based Auth
Поскольку куки отправляются автоматически, SameSite — основная защита.
SameSite: Strict— Максимальная защита. Кука не отправляется при переходе по ссылке с внешнего сайта. Проблема: Пользователь кликает по ссылке в почте -> попадает на сайт неавторизованным.SameSite: Lax(Default) — Кука отправляется при Top-Level Navigation (клик по<a href>,window.location). Не отправляется приfetch,iframe,img,script. Рекомендуемый баланс.SameSite: None; Secure— Только для кросс-доменных iframe/embedded виджетов. Требует дополнительной защиты CSRF Token (Double Submit Cookie Pattern).
Дополнительная защита (Defense in Depth): Custom Header
api.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; // Или кастомный 'X-CSRF-Token'
// Server Middleware
app.use((req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
// Проверяем наличие заголовка, которого нет у простых форм/IMG
if (!req.headers['x-requested-with']) {
return res.status(403).send('CSRF Check Failed');
}
}
next();
});
> Браузеры не позволяют установить кастомные заголовки при "Simple Requests" (формы, картинки), поэтому это надежный маркер "своего" AJAX-запроса.
6. Logout, Revocation & Session Management
| Событие | Действие с Access Token | Действие с Refresh Token |
|---|---|---|
| User Logout | Удаление из памяти клиента | Удаление из БД (revoked=true) + Очистка куки (Max-Age=0) |
| Password Change / 2FA Enable / Security Settings Change | Добавление jti (JWT ID) в Blocklist (Redis) с TTL = оставшееся время Access Token | Полный отзыв всех Refresh Token пользователя (revokeAllUserTokens(userId)) |
| Suspicious Activity (New Geo/IP/Device) | Blocklist Access Token | Отзыв конкретного Refresh Token (или всех, кроме текущего) |
| Admin Ban / Account Delete | Blocklist | Каскадный DELETE в БД (ON DELETE CASCADE) |
Access Token Revocation (Blocklist Pattern):
// Middleware проверки блоклиста (Redis SET с TTL = exp токена)
async function checkRevocation(payload) {
const jti = payload.jti; // Обязательно добавляйте 'jti' (UUID) в Access Token при генерации
if (await redis.exists(`blocklist:${jti}`)) {
throw new Error('Token revoked');
}
}
// При логауте/смене пароля:
await redis.set(`blocklist:${jti}`, '1', 'EX', remainingTtlSeconds);
7. Чек-лист Production Readiness (Security Audit)
- Access Token: В памяти (JS переменная), НЕ в LocalStorage. TTL ≤ 15 мин.
- Refresh Token: Только HttpOnly Cookie (
Secure,SameSite=Lax/Strict,Path=/auth/refresh). - Signing: RS256/ES256 + JWKS Endpoint (
/.well-known/jwks.json). Ротация ключей (kidheader). - Refresh Rotation: Обязательна. Reuse Detection -> полный отзыв цепочки + алерт в SIEM.
- Token Storage (Server): Refresh Token хранится только в виде хэша (bcrypt/argon2).
- Revocation: Redis Blocklist для Access Token (по
jti). Мгновенный отзыв Refresh Token в БД. - CSRF:
SameSite=Lax+ Custom Header Check (X-Requested-With) для мутирующих методов. - CORS: Строгий
Access-Control-Allow-Origin(конкретный домен, не*),Allow-Credentials: true. - Security Headers:
Content-Security-Policy(mitigates XSS),X-Frame-Options: DENY,Referrer-Policy: strict-origin-when-cross-origin. - Logging/Audit: Логирование выдачи/обновления/отзыва токенов (UserID, IP, UserAgent, TokenID, Result).
- Rate Limiting: На
/auth/login,/auth/refresh,/auth/register(по IP + по UserID).
8. Альтернативы и современные подходы
-
BFF (Backend for Frontend) / Server-Side Sessions:
- SPA общается только с BFF (Next.js API Routes, Remix, Astro, Go/Node Proxy).
- BFF хранит сессию в подписанной HttpOnly Cookie (или Redis Session ID).
- BFF проксирует запросы к микросервисам, подставляя внутренние токены (mTLS / Service Mesh).
- Плюсы: Токены никогда не попадают в браузер. Максимальная безопасность. Упрощение клиента.
- Минусы: Дополнительный хоп, состояние на бэкенде (Sticky Sessions или Shared Redis).
-
DPoP (Demonstrating Proof of Possession) / RFC 9449:
- Access Token привязывается к паре ключей клиента (Public Key в заголовке
DPoP). - Даже при краже токена (XSS/Log leakage) атакуер не может его использовать без приватного ключа (хранящегося в Web Crypto API / Secure Enclave).
- Будущее стандарт для High-Security (Banking, Gov).
- Access Token привязывается к паре ключей клиента (Public Key в заголовке
-
Passkeys / WebAuthn (Passwordless):
- Полная замена паролей и часто Refresh Token на криптографические учетные данные (Private Key в TPM/Secure Enclave устройства).
- Auth Server верифицирует Assertion -> выдает короткий Access Token.
> ✅ Правило Tech Lead:
> «JWT в LocalStorage — это технический долг, который превращается в инцидент безопасности. HttpOnly Cookie + Rotation + RS256 + Reuse Detection — это минимальный базовый стандарт для любого приложения, где есть деньги или личные данные. Если команда говорит "это сложно" — они не готовы к продакшену».
> Инвестируйте в библиотеку (jose, django-oauth-toolkit, Spring Security, ASP.NET Core Identity) или управляемый сервис (Auth0, Keycloak, FusionAuth, Clerk, Ory Kratos), а не в велосипед.
Вопрос 55. Как удалить cookie из браузера?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:40:34"/>
Ответ собеседника: Правильный. Через document.cookie можно манипулировать строкой cookies. Установить cookie с истёкшим сроком действия (expires в прошлом). Или через бэкенд запрос с соответствующими заголовками.
Правильный ответ:
Удаление куки — это не просто установка даты в прошлом. Это операция, требующая точного совпадения атрибутов (Domain, Path, SameSite, Secure), которые использовались при создании. На уровне Senior/Tech Lead необходимо понимать механику Set-Cookie заголовка, отличия клиентского и серверного удаления, а также нюансы работы с HttpOnly куками и поддоменами.
1. Фундаментальный принцип: Кука удаляется перезаписью
Браузер не имеет API deleteCookie(name). Удаление — это установка новой куки с тем же ключевым набором идентификаторов (name, domain, path), но с Expires в прошлом (или Max-Age=0).
Ключевое правило: Чтобы удалить куку, вы обязаны повторить все атрибуты, определяющие её область видимости (Domain, Path, SameSite, Secure), которые были при установке. Если хоть один атрибут отличается — браузер создаст новую куку вместо удаления старой.
2. Клиентское удаление (JavaScript / document.cookie)
Доступно только для НЕ HttpOnly кук.
function deleteCookie(name: string, path: string = '/', domain?: string) {
// 1. Базовые атрибуты удаления
let cookieString = `${encodeURIComponent(name)}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;
// 2. КРИТИЧНО: Path должен совпадать (по умолчанию '/')
cookieString += `; Path=${path}`;
// 3. Domain:
// - Если кука ставилась с Domain=.example.com -> нужно указать Domain=.example.com
// - Если кука ставилась БЕЗ Domain (host-only) -> НЕЛЬЗЯ указывать Domain при удалении!
if (domain) {
cookieString += `; Domain=${domain}`;
}
// 4. Secure & SameSite: Должны совпадать с оригиналом!
// Если оригинал был Secure; SameSite=Strict -> нужно повторить.
// Безопаснее всегда ставить Secure (если сайт на HTTPS) и SameSite=Lax/Strict.
cookieString += `; Secure; SameSite=Lax`;
document.cookie = cookieString;
}
// Примеры использования:
deleteCookie('session_id'); // Host-only кука на текущем домене, Path=/
deleteCookie('refreshToken', '/auth/refresh', '.example.com'); // Кука на поддоменах с конкретным Path
Частые ошибки (Anti-patterns):
- Пропуск
Path: Кука установлена на/admin, удаляете с/-> Не удалится. - Лишний
Domainпри host-only куке: Кука установлена безDomain(толькоapi.example.com). Пытаетесь удалить сDomain=.example.com-> Создастся новая кука на поддоменах, старая останется. - Отсутствие
Secureна HTTPS: Если кука былаSecure, а вы удаляете безSecure(на HTTPS странице) -> Не удалится (Chrome/Firefox игнорируют небезопасное удаление безопасной куки). - Попытка удалить
HttpOnly:document.cookieне видитHttpOnlyкуки -> Ошибка/тишина, кука жива.
3. Серверное удаление (HTTP Header Set-Cookie)
Единственный способ удалить HttpOnly куки (Refresh Tokens, Session ID).
Бэкенд должен вернуть заголовок Set-Cookie с теми же атрибутами идентификации.
Пример (Node.js / Express / Fastify / Go / Java):
HTTP/1.1 200 OK
Set-Cookie: refreshToken=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/auth/refresh; Domain=.example.com; Secure; HttpOnly; SameSite=Strict
Best Practice: Утилита для генерации заголовка удаления (TypeScript/JS)
interface CookieOptions {
name: string;
path?: string;
domain?: string;
sameSite?: 'Strict' | 'Lax' | 'None';
secure?: boolean;
httpOnly?: boolean;
}
function getDeleteCookieHeader(options: CookieOptions): string {
const { name, path = '/', domain, sameSite = 'Lax', secure = true, httpOnly = true } = options;
const parts = [
`${encodeURIComponent(name)}=`,
'Max-Age=0',
`Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
`Path=${path}`,
];
if (domain) parts.push(`Domain=${domain}`);
if (secure) parts.push('Secure');
if (httpOnly) parts.push('HttpOnly');
parts.push(`SameSite=${sameSite}`);
return parts.join('; ');
}
// Использование в хендлере логаута:
app.post('/auth/logout', (req, res) => {
// Удаляем Access Token (если он в куке)
res.setHeader('Set-Cookie', [
getDeleteCookieHeader({ name: 'accessToken', path: '/', httpOnly: true }),
// Удаляем Refresh Token (критично: тот же Path и Domain!)
getDeleteCookieHeader({ name: 'refreshToken', path: '/auth/refresh', domain: '.example.com', httpOnly: true }),
]);
res.json({ success: true });
});
4. Сложные сценарии и подводные камни
А. Удаление куки на поддоменах (Domain=.example.com)
Если кука установлена на .example.com (видна на app.example.com, api.example.com):
- Удалить её можно только с того же домена уровня выше или с любого поддомена, указав
Domain=.example.com. - Нельзя удалить "супер-куку" с поддомена, не указывая
Domain(станет host-only кука на поддомене).
Б. SameSite=None (Cross-site куки)
Если кука была SameSite=None; Secure (для iframe/виджетов):
- При удалении обязательно повторить
SameSite=None; Secure. - Без
Secureбраузер отвергнет удаление.
В. __Host- и __Secure- префиксы (Cookie Prefixes RFC)
__Host-кука: ОбязательноSecure,Path=/, НетDomain. Удалять только сPath=/безDomain.__Secure-кука: ОбязательноSecure. Удалять сSecure.
Г. Кэширование заголовков Set-Cookie (CDN / Proxy)
Опасность: Если эндпоинт логаута (POST /auth/logout) закеширован CDN (Cloudflare, Nginx, Varnish) как 200 OK с заголовком Set-Cookie: ...=deleted:
- Все пользователи получат заголовок удаления куки -> Массовый логаут.
- Решение: Эндпоинты, меняющие состояние авторизации (login, logout, refresh), должны иметь
Cache-Control: no-store, private, max-age=0, must-revalidateи не кэшироваться прокси.
5. Программная проверка удаления (DevTools / Console)
// 1. Проверка текущих кук
document.cookie.split('; ').map(c => c.split('=')[0]).filter(n => n.includes('token'));
// 2. Попытка удаления с логированием
function debugDeleteCookie(name, path, domain) {
const before = document.cookie;
deleteCookie(name, path, domain); // Ваша функция удаления
const after = document.cookie;
if (before === after) {
console.warn(`[Cookie Debug] Кука "${name}" НЕ удалена. Проверьте Path/Domain/Secure/SameSite.`);
console.log('Попробуйте варианты:',
`Path=/, Domain=`,
`Path=/, Domain=.${location.hostname.split('.').slice(-2).join('.')}`,
`Path=/auth/refresh, Domain=.${location.hostname.split('.').slice(-2).join('.')}`
);
} else {
console.log(`[Cookie Debug] Кука "${name}" успешно удалена.`);
}
}
6. Архитектурный паттерн: Logout Flow (Single Sign-Out)
При реализации Global Logout (выход из всех систем / SSO):
- Frontend: Вызывает
POST /auth/logout(With Credentials). - Backend (Auth Server):
- Инвалидирует Refresh Token в БД (Revocation List / Delete).
- Инвалидирует Access Token (добавляет
jtiв Redis Blocklist). - Возвращает
Set-Cookieзаголовки для удаления всех auth-кук (accessToken,refreshToken,sessionId). - Возвращает URL для редиректа на Identity Provider (если SSO/OIDC).
- Frontend: Получает ответ -> Очищает стейт в памяти (Access Token) -> Редиректит на Login / SSO Logout URL.
- Service Worker (PWA): Обязательно
registration.unregister()илиcaches.keys().then(keys => keys.forEach(key => caches.delete(key)))для очистки оффлайн-кэша авторизованных данных.
7. Резюме: Чек-лист корректного удаления
| Атрибут | При установке | При удалении (Client JS) | При удалении (Server Header) |
|---|---|---|---|
| Name | token | token | token |
| Value | abc123 | "" (пустая строка) | "" (пустая строка) |
| Expires / Max-Age | Max-Age=3600 | Max-Age=0; Expires=Thu, 01 Jan 1970... | Max-Age=0; Expires=Thu, 01 Jan 1970... |
| Path | /auth/refresh | Обязательно /auth/refresh | Обязательно /auth/refresh |
| Domain | .example.com | Обязательно .example.com | Обязательно .example.com |
| Domain | (нет — host-only) | НЕ указывать | НЕ указывать |
| Secure | Secure | Обязательно Secure | Обязательно Secure |
| HttpOnly | HttpOnly | Невозможно удалить из JS | Обязательно HttpOnly |
| SameSite | Strict | Обязательно Strict | Обязательно Strict |
> ✅ Правило Tech Lead:
> «Если document.cookie не удаляет куку — не подбирай параметры наугад. Открой DevTools -> Application -> Cookies, посмотри точные значения Domain, Path, SameSite, Secure у существующей куки и скопируй их 1-в-1 в функцию удаления. Разница в слэше в Path или точке в Domain — это разница между рабочим кодом и багом в продакшене».
> Для HttpOnly кук (Refresh Tokens) единственный способ — серверный Set-Cookie заголовок на эндпоинте /logout или /refresh (при ротации).
Вопрос 56. Какой опыт у вас с адаптивной вёрсткой и какими инструментами пользуетесь?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:42:57"/>
Ответ собеседника: Правильный. Опыт с адаптивной вёрсткой через медиазапросы и брейкпоинты. Использование Grid и Flexbox. Работа с CSS модулями, LESS, Styled Components. Предпочтение отдаёт CSS модулям из-за меньшего веса бандла по сравнению с Styled Components.
Правильный ответ:
Адаптивная вёрстка на уровне Senior/Tech Lead — это не просто «медиазапросы и Flexbox». Это системный подход к дизайну макетов: Intrinsic Web Design (внутренне-адаптивный дизайн), где компоненты сами подстраиваются под контейнер, а не только под вьюпорт. Это работа с Container Queries, CSS Layers, современными единицами измерения, стратегиями Mobile-First / Desktop-First, производительностью рендеринга (CLS, Layout Shift) и архитектурой стилей в масштабируемых командах.
1. Эволюция подходов: От Media Queries к Container Queries
Классический подход (Viewport-based Media Queries)
/* Mobile First — стандарт де-факто */
.card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
/* Брейкпоинты, согласованные с дизайн-системой (Design Tokens) */
@media (min-width: 768px) { /* Tablet */
.card { flex-direction: row; }
}
@media (min-width: 1024px) { /* Desktop */
.card { gap: 24px; padding: 24px; }
}
@media (min-width: 1440px) { /* Large Desktop */
.card { max-width: 1200px; margin: 0 auto; }
}
Проблема: Компонент не знает, где он находится. Если .card положить в узкую сайдбар-колонку на большом экране — он сломается, так как смотрит на viewport, а не на ширину родителя.
Современный стандарт: Container Queries (CQ) — «Holy Grail» адаптивности
Компонент реагирует на ширину своего контейнера.
/* 1. Объявляем контейнер (родитель) */
.card-container {
container-type: inline-size; /* Отслеживаем только ширину */
container-name: card-wrapper; /* Именованный контейнер для точечной адресации */
}
/* 2. Стили компонента зависят от контейнера, а не от экрана */
.card {
display: grid;
grid-template-columns: 1fr; /* Базовый: 1 колонка */
gap: 16px;
padding: 16px;
}
/* Срабатывает, когда .card-container становится шире 400px */
@container card-wrapper (min-width: 400px) {
.card {
grid-template-columns: 120px 1fr; /* Иконка + Контент */
align-items: center;
}
}
@container card-wrapper (min-width: 700px) {
.card {
grid-template-columns: 1fr auto 1fr; /* Сложный макет */
padding: 24px;
}
}
> ✅ Senior Insight: В 2024+ Container Queries — это default для UI-компонентов (Cards, Forms, Widgets). Media Queries оставляем только для глобальных лэйаутов (Header, Footer, Sidebar, Grid-страницы) и печати (@media print).
Fallbacks и Progressive Enhancement
/* Fallback для старых браузеров (без @supports) */
.card { grid-template-columns: 1fr; }
@supports (container-type: inline-size) {
.card-container { container-type: inline-size; }
@container (min-width: 400px) {
.card { grid-template-columns: 120px 1fr; }
}
}
2. Intrinsic Layouts: Компоненты, которые «думают» сами
Использование Flexbox/Grid + min(), max(), clamp(), auto-fit / auto-fill для создания макетов без медиазапросов.
A. «Holy Grail» резиновой сетки без брейкпоинтов
.grid {
display: grid;
/* Минимум 280px, максимум 1fr, автоматически столько колонок, сколько влезет */
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
gap: clamp(16px, 2vw, 24px); /* Адаптивный gap без MQ */
}
B. Fluid Typography & Spacing (clamp)
Отказ от жестких px в пользу относительных единиц, ограниченных минимумом/максимумом.
:root {
/* Fluid Type Scale: min, preferred (vw), max */
--fs-sm: clamp(0.875rem, 0.85rem + 0.125vw, 1rem); /* 14-16px */
--fs-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem); /* 16-18px */
--fs-lg: clamp(1.125rem, 1rem + 0.625vw, 1.5rem); /* 18-24px */
--fs-xl: clamp(1.5rem, 1.25rem + 1.25vw, 2rem); /* 24-32px */
--fs-2xl: clamp(2rem, 1.5rem + 2.5vw, 3rem); /* 32-48px */
/* Fluid Space Scale */
--space-xs: clamp(4px, 0.5vw, 8px);
--space-sm: clamp(8px, 1vw, 12px);
--space-md: clamp(16px, 2vw, 24px);
--space-lg: clamp(24px, 3vw, 32px);
--space-xl: clamp(32px, 4vw, 48px);
}
.card {
padding: var(--space-md);
gap: var(--space-sm);
font-size: var(--fs-base);
}
h1 { font-size: var(--fs-2xl); }
> ✅ Плюсы: Никаких «скачков» на брейкпоинтах, идеальная читаемость на любом экране, меньше CSS-кода, соблюдение WCAG 1.4.4 Resize Text (пользователь может зуммить).
C. CSS Math Functions: min(), max(), clamp() в действии
/* Ширина контента: не уже 320px, не шире 1200px, идеально 90% вьюпорта */
.wrapper {
width: min(90vw, 1200px);
margin-inline: auto;
}
/* Отступы: минимум 16px, но 5% от ширины контейнера, макс 80px */
.section {
padding-inline: max(16px, min(5%, 80px));
}
3. Архитектура стилей: CSS Modules vs CSS-in-JS vs Tailwind (2024 View)
Кандидат упомянул CSS Modules из-за размера бандла. Это правильный прагматичный выбор для производительности (Core Web Vitals) и SSR/Streaming.
| Критерий | CSS Modules | Tailwind CSS | CSS-in-JS (Styled Components, Emotion) | Vanilla Extract / Panda CSS (Zero-runtime) |
|---|---|---|---|---|
| Runtime Cost | 0 KB (Compile-time) | 0 KB (Compile-time) | ~12-15 KB (Runtime parsing/injection) | 0 KB (Type-safe compile-time) |
| SSR / Streaming | Нативная поддержка | Нативная поддержка | Проблемы с nonce, hydration mismatch, streaming | Идеальная поддержка (CSS выносится в файлы) |
| DX / Типизация | styles.className (TS плагины) | Классные имена в HTML | css``... (Template literals) | Type-Safe API (sprinkles, recipes) |
| Theming | CSS Variables (:root) | theme в конфиге | ThemeProvider (Context) | CSS Variables + TS Config |
| Bundle Size Impact | Минимальный (только хэши) | Минимальный (PurgeCSS) | Высокий (Runtime + стили в JS) | Минимальный |
| Container Queries | Нативно в .css | @container в JIT | Сложно (нужен styled wrapper) | Нативно |
| Кривая обучения | Низкая (CSS + композиция) | Средняя (Utility classes) | Низкая (JS синтаксис) | Высокая (TS + API) |
Рекомендация Tech Lead 2024:
- Greenfield / Design System / High Perf: Vanilla Extract или Panda CSS. Type-safe, Zero-runtime, отличная темизация, работа с токенами как с кодом.
- Standard Product / Team Mix (Junior/Senior): CSS Modules + CSS Variables (Custom Properties). Стандарт веба, нет рантайма, понятно всем, работает с любым фреймворком (React, Vue, Svelte, Astro).
- Rapid Prototyping / Admin Panels / Internal Tools: Tailwind CSS. Скорость разработки, консистентность без дизайн-системы.
- Legacy / Migration: Styled Components / Emotion — только если уже есть. Планировать миграцию на Zero-runtime решения из-за INP (Interaction to Next Paint) и Streaming SSR проблем.
Best Practice: CSS Modules + Design Tokens (CSS Variables)
/* tokens.css (генерируется из Figma/Style Dictionary) */
:root {
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--radius-md: 8px;
--shadow-card: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--transition-fast: 150ms ease;
}
/* Card.module.css */
.card {
/* Композиция через composes (CSS Modules feature) */
composes: reset from global/reset.css;
composes: visuallyHidden from global/a11y.css;
display: flex;
flex-direction: column;
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
background: var(--color-surface);
transition: box-shadow var(--transition-fast);
/* Container Query внутри модуля */
container-type: inline-size;
}
@container (min-width: 400px) {
.card { flex-direction: row; }
}
.card:hover { box-shadow: var(--shadow-hover); }
4. Производительность и Core Web Vitals (CLS, LCP, INP)
Адаптивность напрямую влияет на метрики.
A. Cumulative Layout Shift (CLS) — Враг №1 адаптивки
Причина: Изображения без размеров, шрифты (FOUT/FOIT), динамический контент, анимации width/height/top/left.
Решение:
/* 1. Aspect Ratio для медиа (зарезервировать место) */
.img-wrapper {
aspect-ratio: 16 / 9; /* Или 1 / 1, 4 / 3 */
width: 100%;
overflow: hidden;
}
.img-wrapper img { width: 100%; height: 100%; object-fit: cover; }
/* 2. Font Display Swap + Size Adjust (для минимизации сдвига при загрузке шрифта) */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap; /* Текст виден сразу системным шрифтом */
/* Подгонка метрик системного шрифта под кастомный (Chrome 104+) */
size-adjust: 100.06%;
ascent-override: 90%;
descent-override: 20%;
line-gap-override: 0%;
}
/* 3. Анимации только transform/opacity (не вызывают Layout) */
.card { transition: transform 0.2s, box-shadow 0.2s; }
.card:hover { transform: translateY(-4px); } /* Хорошо */
.card:hover { width: 100%; } /* ПЛОХО: вызывает Reflow/Layout Shift */
B. LCP (Largest Contentful Paint)
- Hero-изображение:
fetchpriority="high",preloadв<head>, безlazy-loading. - Критический CSS: Инлайн стили для Above-the-fold (Hero, Header, First Card). Остальное —
media="print" onload="this.media='all'"илиpreload.
C. INP (Interaction to Next Paint)
- CSS-in-JS Runtime = лишние JS на главном потоке при гидратации/интеракциях -> плохой INP.
- CSS Modules / Zero-runtime = 0 JS для стилей -> лучший INP.
5. Доступность (a11y) в адаптивке
Senior разработчик не забывает про пользователей с зумом 200%+ и скринридерами.
/* 1. Респект к настройкам пользователя */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 2. Высокий контраст (Windows High Contrast Mode) */
@media (forced-colors: active) {
.card { border: 1px solid CanvasText; } /* Видна граница */
.btn:focus { outline: 2px solid Highlight; } /* Фокус виден */
}
/* 3. Текст не обрезается при зумме 200% (WCAG 1.4.10 Reflow) */
.container {
/* Не фиксируем height в px! Только min-height или auto */
max-width: 100vw;
overflow-x: hidden; /* Защита от случайного горизонтального скролла */
}
/* 4. Focus Visible — критично для клавиатурной навигации на мобильных (внешние клавиатуры) */
:focus:not(:focus-visible) { outline: none; }
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
6. Инструментарий и Workflow (Tooling)
| Задача | Инструменты (2024 Standard) |
|---|---|
| Линтинг / Форматирование | Stylelint (с stylelint-config-standard, stylelint-config-css-modules), Prettier |
| CI/CD Gates | stylelint --max-warnings 0, npm run test:visual (Chromatic / Playwright Visual Regression) |
| Design Tokens | Style Dictionary / Specify / Tokens Studio (Figma Plugin) -> JSON -> CSS Variables / TS / Swift / Kotlin |
| Visual Regression | Playwright (toHaveScreenshot) / Chromatic (Storybook) / Percy |
| Bundle Analysis | webpack-bundle-analyzer, vite-bundle-visualizer, css-bundle-size |
| Проверка CLS/LCP | Lighthouse CI (GitHub Actions), Web Vitals (Real User Monitoring) |
| Рефакторинг / Dead Code | PurgeCSS (для Tailwind/Global CSS), unused-css.com, Knip (для JS/TS/CSS Modules imports) |
7. Чек-лист Senior Code Review для PR с вёрсткой
- Container Queries используются для изолированных компонентов (Card, Form, Dropdown)?
- Media Queries только для глобальных лэйаутов (Header, Footer, Page Grid)?
- Fluid Typography/Spacing (
clamp) вместо жестких брейкпоинтов для текста и отступов? -
aspect-ratioзадан для всех изображений, видео, iframe, карт? -
font-display: swap+size-adjustдля кастомных шрифтов? - Нет анимаций свойств Layout (
width,height,top,left,margin,padding) — толькоtransform,opacity,filter? - CSS Variables для темизации (Light/Dark/HCM) вместо пропсов в JS?
- Zero-runtime CSS (CSS Modules / Vanilla Extract / Tailwind) — нет Styled Components в новом коде?
- Адаптивность проверена при зумме 200%+ (DevTools -> Rendering -> Emulate CSS Media -> Forced Colors / Zoom)?
- Focus-visible стили есть у всех интерактивных элементов?
- Visual Regression Tests прошли (нет неожиданных изменений пикселей)?
8. Пример идеального компонента (Card) — Senior Level
// Card.tsx (React + CSS Modules)
import styles from './Card.module.css';
import { clsx } from 'clsx'; // Утилита для условных классов
interface CardProps {
variant?: 'default' | 'outlined' | 'elevated';
interactive?: boolean;
children: React.ReactNode;
}
export const Card = ({ variant = 'default', interactive, children }: CardProps) => (
<article
className={clsx(
styles.card,
styles[variant], // BEM-like модификаторы через CSS Modules
interactive && styles.interactive
)}
// Container Query рождается здесь
style={{ containerType: 'inline-size', containerName: 'card' } as React.CSSProperties}
>
{children}
</article>
);
/* Card.module.css */
.card {
/* Base: Mobile First, Intrinsic */
display: grid;
grid-template-rows: auto 1fr auto; /* Header, Body, Footer */
gap: var(--space-sm);
padding: var(--space-md);
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--fs-base);
line-height: 1.5;
/* Container Query Ready */
container-type: inline-size;
container-name: card;
/* Анимации только cheap properties */
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
/* Variants (BEM-style via composition or separate classes) */
.default { box-shadow: var(--shadow-sm); border: 1px solid var(--color-border); }
.outlined { border: 2px solid var(--color-primary); box-shadow: none; }
.elevated { box-shadow: var(--shadow-xl); border: none; }
/* Interactive State */
.interactive { cursor: pointer; }
.interactive:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.interactive:hover:not(:focus-visible) {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.interactive:active:not(:focus-visible) { transform: translateY(0); }
/* Container Query: Меняем структуру, когда место позволяет */
@container card (min-width: 480px) {
.card {
grid-template-columns: 1fr 2fr; /* Sidebar | Content */
grid-template-rows: auto auto; /* Header+Footer на всю ширину? Нет, лучше subgrid */
align-items: start;
}
/* Subgrid для идеального выравнивания внутренних частей (если поддерживается) */
.cardHeader, .cardBody, .cardFooter {
display: grid;
grid-template-columns: subgrid; /* FF, Chrome, Safari 16+ */
}
}
/* Dark Mode / High Contrast через CSS Variables — не нужно JS! */
@media (prefers-color-scheme: dark) {
:root {
--color-surface: #1e293b;
--color-text: #f1f5f9;
--color-border: #334155;
}
}
> ✅ Итог: На собеседовании Senior/Tech Lead ожидает не список технологий («знаю Flexbox»), а демонстрацию системного мышления: как вы решаете CLS, как выбираете инструменты под архитектуру, как поддерживаете дизайн-систему, как делаете вёрстку доступной и производительной без лишнего JS.
Вопрос 57. Знакомы ли вы с методологией БЭМ и какие предпочтения в CSS подходах?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:44:05"/>
Ответ собеседника: Правильный. Знаком с БЭМ (Блок-Элемент-Модификатор). Предпочитает CSS модули над Styled Components из-за производительности - Styled Components требует дополнительной обработки кода и увеличивает размер бандла.
Правильный ответ:
Знание БЭМ на уровне Senior/Tech Lead — это не просто «знаю про блок__элемент--модификатор». Это понимание причин возникновения (борьба со специфичностью, изоляция, предсказуемость), практического применения в современном стеке (CSS Modules, Tailwind, CSS-in-JS) и умения адаптировать методологию под инструменты (где она нужна в чистом виде, а где инструмент решает проблемы за вас).
Выбор подхода к стилизации — это архитектурное решение, влияющее на: Bundle Size, Runtime Performance (INP), SSR/Streaming, DX (Developer Experience), Type Safety, Theming, Maintainability.
1. БЭМ: Суть, Нюансы и Современный контекст
Классическое именование (Origin Spec)
/* Блок: самостоятельная сущность */
.menu { }
/* Элемент: часть блока, не имеет смысла вне блока */
.menu__item { }
.menu__link { }
/* Модификатор: состояние/вариация блока или элемента */
.menu--vertical { }
.menu__item--active { }
.menu__link--disabled { }
/* Миксы (Mixes): Комбинация блоков/элементов для позиционирования/контекста */
/* <div class="menu header__menu"> — меню внутри хедера имеет отступы хедера */
.header__menu { margin-top: 16px; }
Зачем БЭМ в 2024+ году? (Проблемы, которые он решает)
- Плоская специфичность (Flat Specificity): Все селекторы — классы (0,1,0). Нет войн
header nav ul li avs.sidebar .link. Критично для масштабируемости. - Изоляция (Encapsulation): Блок не ломается при переносе.
menu__itemне конфликтует сheader__item. - Читаемость HTML: Видна структура компонента без открытия CSS/DevTools.
- Детерминизм: Имя класса однозначно говорит: «Я элемент
itemблокаmenuв состоянииactive».
Где БЭМ нужен явно, а где инструмент делает за вас?
| Инструмент | Нужен ли ручной БЭМ? | Как работает изоляция |
|---|---|---|
| Vanilla CSS / Sass / Less / PostCSS | ДА (Обязательно) | Только через именование (БЭМ / SMACSS / ITCSS). |
| CSS Modules | Частично | Хеширование (Menu_item__a1b2c) дает гарантированную изоляцию. БЭМ нужен для семантики в DevTools и логики модификаторов (menu__item--active -> styles['item--active']). |
| Tailwind CSS | НЕТ | Нет классов компонентов. Стили в className. БЭМ не применяется. Архитектура — через @apply (редко) или компоненты фреймворка. |
| Styled Components / Emotion | НЕТ | Генерируют уникальные хеши. Логика модификаторов — через props (${p => p.$active && ...}). |
| Vanilla Extract / Panda CSS | НЕТ (Type-safe) | Стили в .css.ts. Классы генерируются. Типизация модификаторов через variants / recipes. |
> ✅ Senior Insight: В проекте на CSS Modules (выбор кандидата) — пишем модифицированный БЭМ: Блок = файл (Menu.module.css), Элементы = классы без префикса блока (.item, .link), Модификаторы = --active, --disabled. Хеш модуля и так дает уникальность блоку. Дублировать menu__ внутри файла Menu.module.css — шум.
/* Menu.module.css — Правильный подход с CSS Modules */
.root { display: flex; flex-direction: column; gap: 8px; } /* Блок */
.item { padding: 8px 12px; border-radius: 4px; } /* Элемент */
.link { color: inherit; text-decoration: none; } /* Элемент */
/* Модификаторы элемента */
.item--active { background: var(--color-primary-bg); font-weight: 600; }
.item--disabled { opacity: 0.5; pointer-events: none; }
/* Модификаторы блока (на корне) */
.root--vertical { flex-direction: column; }
.root--horizontal { flex-direction: row; }
/* Респонсив / Container Queries внутри модуля */
@container (min-width: 400px) {
.root--horizontal { gap: 16px; }
}
// Menu.tsx — Использование с clsx для типобезопасности
import styles from './Menu.module.css';
import { clsx } from 'clsx';
interface MenuProps {
orientation?: 'vertical' | 'horizontal';
items: { id: string; label: string; active?: boolean; disabled?: boolean }[];
}
export const Menu = ({ orientation = 'vertical', items }: MenuProps) => (
<nav className={clsx(styles.root, styles[`root--${orientation}`])}>
{items.map(item => (
<a
key={item.id}
href={`#${item.id}`}
className={clsx(
styles.item,
styles.link,
item.active && styles['item--active'],
item.disabled && styles['item--disabled']
)}
>
{item.label}
</a>
))}
</nav>
);
2. Архитектурный выбор CSS-подхода: Decision Matrix (2024)
Кандидат правильно указал на Bundle Size / Runtime как аргумент против Styled Components. Расширим контекст для принятия решения на проекте.
Сравнительная таблица подходов
| Критерий | CSS Modules | Tailwind CSS | Vanilla Extract / Panda CSS | Styled Components / Emotion |
|---|---|---|---|---|
| Runtime Cost | 0 KB | 0 KB | 0 KB (Compile-time) | ~12-15 KB (Parser + Injection) |
| SSR / Streaming (React 18+) | Native Support | Native Support | Native Support (CSS файлы) | Проблемно (nonce, hydration mismatch, useServerInsertedHTML) |
| Bundle Size (CSS) | Оптимизируется бандлером | PurgeCSS / JIT (только используемое) | Оптимизируется бандлером | В JS бандле (или извлекается плагинами, но сложно) |
| Type Safety | Через typed-css-modules / d.ts | Нет (строки) | First-class (TypeScript API) | Хорошо (DefaultTheme), но рантайм |
| Theming (Dark Mode, Brand) | CSS Variables (Native) | dark: variant / CSS Variables | CSS Variables + TS Config | ThemeProvider (React Context) |
| Dynamic Styles (Props-based) | Классные имена (clsx) | Условные классы (clsx) | Recipes / Variants (Type-safe) | Template Literals (Интуитивно) |
| Container Queries | Нативно в .css | @container (JIT) | Нативно | Сложно (обертки) |
| Learning Curve | Низкий (CSS + Composes) | Средний (Utility API) | Высокий (TS + Build config) | Низкий (JS Template Strings) |
| Migration / Legacy | Легко внедрить по файлам | Требует рефакторинга HTML | Требует настройки сборки | Легко начать, сложно уйти |
Рекомендация Tech Lead по выбору (2024):
- Greenfield / Design System / High-Load / Enterprise → Vanilla Extract или Panda CSS.
- Почему: Zero-runtime, Full Type Safety (темы, токены, variants в TS), идеальный SSR/Streaming, работа с токенами как с кодом (Design Tokens -> TS -> CSS).
- Standard Product / Mixed Team (Juniors/Seniors) / Migration → CSS Modules + CSS Variables.
- Почему: Стандарт веба, 0 runtime, понятно любому верстальщику, работает везде (React, Vue, Svelte, Astro, Vanilla), легко мигрировать с Legacy CSS/SCSS.
- Admin Panels / Internal Tools / Rapid Prototyping / No Design System → Tailwind CSS.
- Почему: Скорость, консистентность «из коробки», не нужно придумывать имена классам, отличный JIT.
- Legacy Styled Components → Планировать миграцию.
- Почему: Влияние на INP (Interaction to Next Paint) — рантайм парсит стили при гидратации/интеракциях. Проблемы с Streaming SSR (
renderToPipeableStream). Сложность с CSP (nonce).
- Почему: Влияние на INP (Interaction to Next Paint) — рантайм парсит стили при гидратации/интеракциях. Проблемы с Streaming SSR (
3. Глубина по CSS Modules: Best Practices (Senior Level)
Поскольку кандидат выбрал CSS Modules, покажем экспертизу в этом стеке.
А. Композиция (composes) — замена @extend / миксинам
/* base/ButtonBase.module.css */
.base {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
border-radius: var(--radius-md);
font: var(--font-button);
cursor: pointer;
transition: background var(--transition-fast), box-shadow var(--transition-fast);
}
/* components/Button/Button.module.css */
.primary {
composes: base from '../base/ButtonBase.module.css';
background: var(--color-primary);
color: var(--color-on-primary);
}
.primary:hover { background: var(--color-primary-hover); }
.secondary {
composes: base from '../base/ButtonBase.module.css';
background: var(--color-secondary);
color: var(--color-on-secondary);
}
> ✅ Плюс: Никакого дублирования CSS в бандле (бандлер объединит селекторы). Работает со Source Maps.
Б. Type-Safe CSS Modules (Автогенерация .d.ts)
Настройка typed-css-modules или vite-plugin-css-modules-types / webpack loader.
// tsconfig.json
"compilerOptions": {
"plugins": [{ "name": "typescript-plugin-css-modules" }] // или настройка IDE
}
// Button.module.css.d.ts (генерируется автоматически)
export const base: string;
export const primary: string;
export const secondary: string;
export const 'primary--loading': string;
// Button.tsx — Полный IntelliSense и проверка типов!
import styles from './Button.module.css'; // Тип: { readonly base: string; readonly primary: string; ... }
<button className={styles.primary}>Click</button> // OK
<button className={styles.primery}>Click</button> // TS Error: Property 'primery' does not exist
В. CSS Variables (Custom Properties) — Основа темизации
Никаких ThemeProvider из React Context для цветов/шрифтов/отступов. Только нативные CSS Variables.
/* global/tokens.css (генерируется из Style Dictionary / Figma Tokens) */
:root {
/* Light Theme (Default) */
--color-bg: #ffffff;
--color-text: #1e293b;
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--radius-md: 8px;
--space-md: 16px;
--font-body: system-ui, sans-serif;
}
[data-theme="dark"] { /* или @media (prefers-color-scheme: dark) */
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-primary: #60a5fa;
--color-primary-hover: #93c5fd;
}
/* High Contrast Mode (Windows) */
@media (forced-colors: active) {
:root {
--color-primary: CanvasText;
--color-primary-hover: Highlight;
}
}
/* Button.module.css — Использование токенов */
.root {
background: var(--color-primary);
color: white; /* или var(--color-on-primary) если токен есть */
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
}
> ✅ Плюсы: Мгновенное переключение темы (нет ре-рендера React), работает в Shadow DOM, работает в @container, работает в print стилях, 0 JS.
4. Производительность: Почему Styled Components проигрывает (Детали)
Кандидат сказал «увеличивает размер бандла». Senior должен знать все точки боли:
- Runtime Parsing Overhead: При монтировании компонента
styled.div``...`` библиотека парсит template literal, создает стильшит, инжектит<style>в<head>. На больших приложениях — **месяцы Main Thread времени** (Long Tasks -> плохой **INP**). - Serialization Cost: Передача пропсов в стили (
${p => p.$variant === 'primary' ? ...}) происходит при каждом рендере (если не мемоизировано). В React 18 Concurrent Mode это ломает приоритеты. - SSR Hydration Mismatch: Сервер рендерит HTML + собирает
<style data-styled>. Клиент гидратирует, Styled Components снова инжектит стили. Часто порядок отличается -> Warning / FOUC. Решение —StyleSheetManager+ServerStyleSheet+nonce-> Большой boilerplate. - Streaming SSR (React 18
renderToPipeableStream): Styled Components не поддерживает нативно стриминг стилей в<head>до завершения рендера шелла. БлокируетTime to First Byte (TTFB)или требует хаков. - Bundle Size:
styled-components~13KB gzip +react-is+@emotion/is-prop-validи т.д. CSS Modules = 0 KB runtime.
5. Чек-лист Code Review для PR с CSS Modules / БЭМ
- Файловая структура:
Component/Component.module.css+Component.tsx. Нет глобальных.cssкромеglobal/tokens.cssиglobal/reset.css. - Именование: Внутри модуля — без префикса блока (
.root,.item,.item--active). Префикс дает хеш (Component_root__a1b2). - Модификаторы: Используются
--modifier(двойное тире) для состояний/вариантов. Не_modifier(подчеркивание — для элементов в классическом БЭМ, в модулях не нужно). - Композиция:
composes: base from '../../base/ButtonBase.module.css'вместо копипаста или@extend. - Токены: Используются
var(--token-name)вместо хардкодаpx,rem,hex,rgb. Нет магических чисел. - Container Queries: Для адаптивности компонента используется
@container (min-width: ...)внутри модуля. Media Queries только для лэйаута страницы. - Type Safety: Подключен плагин генерации
.d.ts. В TSX используютсяstyles.root, а не строки'Component_root__a1b2'. - Анимации: Только
transition: transform, opacity, filter, box-shadow. Нетwidth,height,left,top,margin,paddingв transition (CLS/Performance). - Accessibility:
:focus-visibleстили есть.@media (prefers-reduced-motion)отключает анимации.forced-colorsрежимы поддерживаются. - Dead Code: Неиспользуемые классы удаляются (бандлер это делает автоматически при
sideEffects: falseвpackage.jsonдля CSS файлов).
6. Пример идеальной архитектуры стилей в проекте (Monorepo / Feature-Sliced)
src/
├── app/
│ ├── providers/ThemeProvider.tsx // Только для переключения data-theme атрибута на <html>
│ └── styles/global.css // @import 'tokens'; @import 'reset'; :root { vars }
├── shared/
│ ├── ui/ // UI Kit (Button, Input, Card, Modal)
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.module.css // composes: base from '../../base/ButtonBase'
│ │ │ ├── Button.stories.tsx
│ │ │ └── Button.test.tsx
│ │ └── base/
│ │ └── ButtonBase.module.css // Базовая логика (reset, focus, disabled)
│ ├── lib/
│ │ └── clsx.ts // Утилита для классов
│ └── config/
│ └── tokens.ts // Экспорт токенов в TS (для JS логики, напр. Chart.js цвета)
│ // export const colors = { primary: 'var(--color-primary)' } — НЕТ!
│ // Токены в JS — только для Canvas/Chart/Non-CSS контекстов.
└── features/
└── auth/
└── ui/LoginForm/
├── LoginForm.tsx
└── LoginForm.module.css // Локальные стили формы, использует shared/ui компоненты
> ✅ Итог: На собеседовании Tech Lead ждет: «Я использую CSS Modules + CSS Variables, потому что это Zero-runtime, Native SSR/Streaming, Type-Safe (с генерацией типов), Standard Web Platform. БЭМ использую модифицированный: внутри модуля префикс блока избыточен (хеш делает уникальность), пишу .root, .element, .element--modifier. Для Design System смотрю в сторону Vanilla Extract / Panda CSS за Full Type Safety токенов».
Вопрос 58. В каких случаях целесообразно использовать Styled Components?
Таймкод: <YouTubeSeekTo id="hV44Qpleq4w" time="01:45:22"/>
Ответ собеседника: Правильный. Styled Components удобен для работы с темами в приложении, когда нужно несколько тем. Также подходит для приложений где не критична скорость загрузки, например внутренние системы для сотрудников. Удобен для стилизации через пропсы компонентов.
Правильный ответ:
Кандидат верно подметил нишу внутренних инструментов (Admin Panels, Internal Tools) и DX (Developer Experience) для динамических стилей через пропсы. Однако на уровне Tech Lead ответ требует расширения: понимание трейдоффов (trade-offs), стратегий миграции и современных альтернатив, которые закрывают те же боли без рантайма.
Styled Components в 2024+ — это Leagacy Choice для новых проектов, но обоснованный выбор в специфических сценариях.
1. Единственные валидные кейсы для Styled Components в 2024+
А. Brownfield / Legacy Migration (Постепенная миграция)
Есть большой кодовой базе на Styled Components. Полная перезапись — риск.
- Стратегия: Новые компоненты пишем на CSS Modules / Vanilla Extract. Старые оставляем. Используем
styled()обертку для интеропа. - Пример:
styled(ExistingStyledComponent)илиstyled.divвнутри новой архитектуры.
Б. Критическая зависимость от ThemeProvider в Runtime
Редкий кейс: Темы загружаются динамически с бэкенда (CMS-driven theming) во время работы приложения и должны применяться мгновенно без ребилда/редеплоя, включая изменение CSS-переменных, которые не могут быть статически извлечены.
- Почему не CSS Variables? Если структура токенов меняется (добавляются новые цвета, меняется иерархия) и это должно прокинуться в
styled(Component)интерполяции без редеплоя фронтенда. - Альтернатива: Panda CSS / Vanilla Extract + CSS Variables + динамический импорт токенов — часто решает это лучше.
В. Сложная логика стилей, невыразимая через CSS (Edge Cases)
- Генерация Keyframes на лету на основе сложных вычислений (математика анимаций, зависящая от пользовательских данных в реальном времени).
- Server-Side Rendering критических стилей с извлечением
Critical CSSтолько для используемых на странице компонентов (хотя современные бандлеры + CSS Modules/Vanilla Extract делают это нативно черезsideEffects: falseи статический анализ).
Г. Прототипирование / Hackathons / Throwaway Code
Максимальная скорость написания «здесь и сейчас» без настройки конфигов, постпроцессоров, файлов модулей. Колокация стилей и логики в одном файле.
2. Почему НЕ стоит выбирать Styled Components для Greenfield (Новых проектов)
А. Производительность Runtime (Core Web Vitals: INP, TBT)
// Что происходит при рендере <StyledButton $primary={true} />
// 1. Парсинг Template Literal (строка -> AST)
// 2. Интерполяция функций (${props => ...}) -> выполнение JS
// 3. Генерация уникального хэша класса
// 4. Инъекция <style> в <head> (если первый раз) или обновление CSSOM
// 5. Рендек React с новым className
- Влияние на INP (Interaction to Next Paint): При интеракциях (клик, ввод), вызывающих ре-рендер с новыми пропсами — основной поток блокируется на стилизацию.
- Bundle Size: ~13-15 KB gzip (
styled-components+react-is+shallowequal+hoist-non-react-statics). Для сравнения: CSS Modules / Vanilla Extract = 0 KB runtime.
Б. Проблемы SSR / Streaming (React 18+)
renderToPipeableStream(Streaming SSR): Styled Components не умеет стримить стили в<head>параллельно с HTML шеллом. Стили собираются после завершения рендера дерева -> блокирует TTFB или требуетrenderToString(блокирующий).- Hydration Mismatch / FOUC: Необходимость
StyleSheetManager,ServerStyleSheet,nonceдля CSP. Сложная инфраструктура. - CSS Modules / Vanilla Extract: Статические
.cssфайлы. Бандлер (Vite/Webpack/Rspack) автоматически делаетpreload/prefetch/ инлайн критического CSS / стриминг черезimport.meta.globилиentrypoints. Нативно работает со Streaming SSR.
В. Type Safety & DX (Developer Experience)
- Styled Components: Типизация через
DefaultThemeinterface +asполиморфизм. Пропсы стилей ($primary) — вTransientProps. Легко ошибиться в строке интерполяции (color: ${p => p.theme.colors.primary}— нет автокомплита внутри строки). - Vanilla Extract / Panda CSS: Type-Safe API.
// Vanilla Extract - Recipe APIexport const buttonRecipe = recipe({base: { padding: '8px 16px', borderRadius: 4 },variants: {visual: {primary: { background: vars.color.primary, color: 'white' },secondary: { background: 'transparent', border: '1px solid', borderColor: vars.color.primary },},size: { sm: { fontSize: 12 }, md: { fontSize: 16 } },},// Тип пропсов: { visual?: 'primary' | 'secondary'; size?: 'sm' | 'md' } — ГЕНЕРИРУЕТСЯ АВТОМАТИЧЕСКИ});
Г. Контейнерные запросы (Container Queries) & Modern CSS
- В Styled Components сложно писать
@containerзапросы, привязанные к обертке компонента. Нужно рендерить обертку сcontainer-typeчерезstyled.div, а внутри — компонент. В CSS Modules / Vanilla Extract — нативно в.css/.css.tsфайле рядом с логикой.
3. Современные альтернативы, закрывающие «боли» Styled Components
| Боль Styled Components | Решение в Vanilla Extract / Panda CSS | Решение в CSS Modules |
|---|---|---|
| Theming (Dark Mode, Multi-brand) | CSS Variables + Type-safe Tokens. createTheme / createGlobalTheme. Переключение через data-theme на <html>. 0 runtime. | CSS Variables (Native). Переключение класса на <html>. composes для базовых стилей. |
| Динамические стили через пропсы | recipe / sprinkles / styleVariants. Полная типизация вариантов. Компилируется в атомарные классы. | clsx / classnames + модификаторы --variant. Явный контроль классов. |
| Колокация (Styles near Component) | Component.css.ts рядом с Component.tsx. Импорт: import styles from './Component.css.ts'. | Component.module.css рядом с Component.tsx. Стандарт экосистемы. |
| Извлечение Critical CSS / Zero Runtime | Нативно. Никакого JS в бандле стилей. | Нативно. Статические файлы. |
Полиморфизм (as prop) | styled factory в Vanilla Extract или Slot Pattern / asChild (Radix UI style). | Slot Pattern / asChild (Рекомендуется W3C / ARIA). |
4. Миграция с Styled Components (План для Tech Lead)
Если проект на Styled Components и нужно уйти:
- Включить
babel-plugin-styled-componentsсssr: true,displayName: true,fileName: true(для читаемых классов в DevTools). - Заморозить написание НОВЫХ компонентов на Styled Components. Правило: New Code = CSS Modules / Vanilla Extract.
- Утилита для интеропа:
// shared/lib/styled-interop.tsimport { styled as scStyled } from 'styled-components';import type { ComponentPropsWithoutRef, ForwardRefExoticComponent } from 'react';// Обертка для использования Legacy Styled компонента как обычного React компонентаexport const asStyledComponent = <T extends React.ElementType>(Component: T): ForwardRefExoticComponent<ComponentPropsWithoutRef<T>> => {// Просто форвардим реф и пропсы, стили уже внутри Componentreturn Component as any; // Типизация требует аккуратности};
- Постепенная замена листьев дерева (Leaf Components):
Button,Input,Card,Tooltip-> переписываются на CSS Modules/Vanilla Extract. Родители остаются на SC, пока не дойдет очередь. - Удаление
ThemeProvider: Перенос токенов в CSS Variables (:root,[data-theme]). Заменаprops.theme.colorнаvar(--color)в новых компонентах. В старых SC компонентах можно прокинуть CSS Variables черезThemeProviderс темой, генерируемой из тех же токенов (Single Source of Truth). - Финал: Когда SC компонентов < 5% ->
codemod/ ручная замена оставшихся -> удаление зависимости.
5. Чек-лист: Решение о выборе Styled Components на проекте (Decision Log)
Если команда предлагает Styled Components — требуем заполнить ADR (Architecture Decision Record):
- Почему не Vanilla Extract / Panda CSS? (Ответ: «Команда не знает TS» — невалиден, учим; «Нет времени на настройку» — невалиден, шаблоны готовы).
- Почему не CSS Modules + CSS Variables? (Ответ: «Нужны динамические темы из CMS в runtime» — валидно, но редко).
- Приняты риски: INP degradation, Streaming SSR blocking, Bundle Size +15KB, CSP
style-srcnonce complexity. - План миграции на Zero-runtime решение заложен в роадмап (Technical Debt Ticket).
- Конфиг:
babel-plugin-styled-componentsнастроен для production (minification, dead code elimination).
6. Резюме для собеседования (Talking Points)
> «Styled Components — мощный инструмент 2016-2020 годов, решивший проблемы колокации и темизации тогда. Сегодня (2024+) это технический долг "из коробки" из-за рантайма. > > Я выбираю его только в двух случаях: > 1. Поддержка Legacy: Большой кодовой базе, где стоимость миграции > боли от рантайма. Пишу стратегию ухода. > 2. Экзотические Runtime-темы: Когда структура токенов приходит с бэкенда в реальном времени и не может быть смоделирована CSS Variables статически. > > Для всего остального (Greenfield, Design Systems, High-load, Modern Stack): > * Vanilla Extract / Panda CSS — если нужна Full Type Safety токенов и вариантов (Enterprise/DS). > * CSS Modules + CSS Variables — Gold Standard для продуктовых команд (Standard Web Platform, 0 runtime, Native Streaming SSR, понятно Juniors). > * Tailwind CSS — для админок/прототипов/команд без дизайн-системы. > > Мой выбор по умолчанию — CSS Modules + CSS Variables. Это баланс производительности, DX, стандартов и порога входа».
