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

РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ / FRONTEND разработчик - Норникель Middle

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

Сегодня мы разберем собеседование фронтенд-разработчика, в котором интервьюеры системно проверяют базовые знания по веб-архитектуре, JavaScript, React и работе с состоянием. Диалог показывает, как кандидат уверенно ориентируется в ключевых концепциях (event loop, хуки, работа с DOM и Redux), но местами затрудняется с более продвинутыми темами и формулировками, что позволяет трезво оценить его текущий уровень и зоны для роста.

Вопрос 1. Есть ли в опыте серьёзные ошибки в работе и чему они научили?

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

Ответ собеседника: неполный. Кандидат говорит, что серьёзных проблем не было, иногда были задержки сроков, считает это обычным явлением.

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

В контексте профессионального опыта важно не отрицать наличие ошибок, а показать умение:

  • осознать проблему;
  • взять ответственность;
  • разобрать причины (технические и процессные);
  • показать, какие изменения были внесены, чтобы этого не повторять.

Хороший ответ строится вокруг конкретных кейсов.

Примеры сильных кейсов:

  1. Ошибка в критическом сервисе из-за недостаточного анализа нагрузки

Был сервис на Go, отвечающий за обработку транзакций/заявок/событий в реальном времени. На этапе проектирования недооценили пики нагрузки и особенности работы с БД.

Что произошло:

  • При резком росте трафика начали расти:
    • время ответа API,
    • количество таймаутов,
    • блокировки в базе.
  • Частично данные обрабатывались с задержкой, SLA не выполнялся.

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

  • Отсутствие нагрузочного тестирования под реальную пик-нагрузку.
  • Неоптимальные запросы к БД (N+1, отсутствие нужных индексов).
  • Отсутствие механизма backpressure и очередей между сервисами.

Что было сделано:

  • Вынесли тяжёлые операции в асинхронные воркеры с использованием очередей (например, Kafka/RabbitMQ/NATS).
  • Оптимизировали запросы и добавили индексы.
  • Добавили конфигурируемые пуллы подключений к БД и ограничение concurrency.
  • Ввели нагрузочные тесты (k6/locust) как часть процесса.

Условный фрагмент кода до и после:

До (синхронная тяжёлая обработка):

func HandleOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
_ = json.NewDecoder(r.Body).Decode(&req)

// Сразу пишем в БД несколько связанных сущностей
if err := createFullOrder(r.Context(), req); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusCreated)
}

После (асинхронная обработка + очередь):

func HandleOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
_ = json.NewDecoder(r.Body).Decode(&req)

// Валидируем и ставим задачу в очередь
if err := publishOrderTask(r.Context(), req); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusAccepted)
}

// Воркеры обрабатывают задачи в фоне,
// контролируем количество воркеров и коннектов к БД.

SQL-оптимизация (пример):

До:

SELECT * FROM orders WHERE user_id = $1;

Без индекса по user_id на большой таблице — full scan, высокая нагрузка.

После:

CREATE INDEX idx_orders_user_id ON orders(user_id);

Результат и выводы:

  • Производительность стабилизировали.
  • В процесс разработки добавили:
    • дизайн-ревью архитектуры,
    • performance-тесты,
    • явные нефункциональные требования (RPS, latency, SLA).
  • Ошибка превратилась в улучшение всей платформы.
  1. Ошибка при работе с конкурентностью в Go

Сценарий:

  • Написан код, в котором нескольким горутинам отдаётся ссылка на общий mutable-объект без синхронизации.
  • Под нагрузкой появлялись "плавающие" баги, неконсистентные данные и редкие паники.

Пример проблемного кода:

type Config struct {
Timeout time.Duration
// ...
}

var cfg = &Config{Timeout: time.Second}

func handler(w http.ResponseWriter, r *http.Request) {
go func() {
// читаем cfg
time.Sleep(cfg.Timeout)
// ...
}()
}

func updateConfig(newCfg *Config) {
cfg = newCfg // гонка записи/чтения
}

Что не так:

  • Гонки данных (data race), поведение неопределённое.
  • Не были использованы sync.Mutex, atomic или immutable-подход.

Как исправили:

  • Запустили go test -race, подтвердили race.
  • Перешли на потокобезопасный доступ к конфигурации, например через atomic.Value:
var cfg atomic.Value // хранит *Config

func init() {
cfg.Store(&Config{Timeout: time.Second})
}

func handler(w http.ResponseWriter, r *http.Request) {
go func() {
c := cfg.Load().(*Config)
time.Sleep(c.Timeout)
// ...
}()
}

func updateConfig(newCfg *Config) {
cfg.Store(newCfg)
}

Выводы:

  • Любой код с горутинами и общими данными должен:
    • либо быть иммутабельным,
    • либо синхронизироваться явно.
  • Race detector стал обязательной частью CI.
  1. Коммуникационная и продуктовая ошибка (важный аспект)

Технически код работал корректно, но:

  • Неправильно оценили сложность фичи.
  • Недокоммуницировали риски.
  • В результате:
    • сорвали срок релиза,
    • зависели другие команды.

Что правильно делать:

  • Давать оценку с буфером и списком рисков.
  • Явно проговаривать зависимости (от других сервисов, команд, инфраструктуры).
  • Обновлять оценку по мере появления новой информации, а не молчать до дедлайна.

Краткий формат сильного ответа на интервью:

  • Приводите 1–2 конкретных кейса.
  • Показывайте:
    • контекст (что делали),
    • что пошло не так,
    • как диагностировали,
    • что исправили технически/процессно,
    • какие выводы сделали и как это улучшило вашу дальнейшую работу.
  • Не сводите всё к "ошибок не было" — это выглядит как отсутствие рефлексии и реального опыта.

Вопрос 2. Как сравнить Scrum, Kanban и другие методологии разработки и когда что использовать?

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

Ответ собеседника: неполный. Отмечает, что в Scrum понятнее ближайший план работ и структура, однако с Kanban знаком поверхностно и практически не работал.

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

Подход к методологии разработки важно оценивать не на уровне "что нравится", а через влияние на:

  • предсказуемость поставки;
  • скорость реакции на изменения;
  • прозрачность приоритетов;
  • качество и технический долг;
  • эффективность команды разработки (включая backend/Go-команды, интеграции, DevOps).

Нужно уметь чётко объяснить, чем Scrum отличается от Kanban, какие есть плюсы и минусы, и в каких условиях что работает лучше.

Основные подходы:

  1. Scrum

Ключевые характеристики:

  • Фиксированные итерации (спринты) — обычно 1–2 недели.
  • Чётко определённые артефакты:
    • Product Backlog, Sprint Backlog,
    • Definition of Ready, Definition of Done.
  • Роли:
    • Product Owner,
    • Scrum Master,
    • команда разработки.
  • Регулярные события:
    • планирование спринта,
    • ежедневные стендапы,
    • ревью спринта,
    • ретроспектива.

Плюсы:

  • Предсказуемость: команда коммитится на объём задач в спринте.
  • Фокус: минимизация "влезания" новых задач внутрь спринта.
  • Простота коммуникации с бизнесом: понятные циклы, демонстрации результата.
  • Хорошо подходит для продуктовых команд, которые:
    • развивают сложный продукт,
    • хотят итеративно проверять гипотезы,
    • держат стабильный темп поставки.

Минусы:

  • Жёсткость к изменениям в рамках спринта (если бизнесу нужно "прямо сейчас").
  • Риск "ритуализма": митинги ради митингов без реальной пользы.
  • Может быть избыточен для небольших команд или потоковой поддержки.

Контекст для Go/бэкенд-команд:

  • Хорошо подходит, когда есть:
    • roadmap фич (API, сервисы, интеграции),
    • кросс-командные зависимости,
    • регулярные релизы через CI/CD.
  • Удобно планировать техдолг: часть спринта под рефакторинг, оптимизацию, безопасность.
  1. Kanban

Ключевые характеристики:

  • Нет жёстких спринтов; работа идёт непрерывным потоком.
  • Основной инструмент — доска со статусами (To Do, In Progress, Review, Testing, Done).
  • Ограничение WIP (Work In Progress) — максимальное число задач в каждом столбце.
  • Фокус не на ритуалах, а на управлении потоком работы.

Плюсы:

  • Гибкость: можно быстро добавлять и менять приоритеты задач.
  • Прозрачность: видно узкие места по колонкам (застряли на код-ревью, тестировании и т.д.).
  • Хорошо подходит:
    • для команд поддержки,
    • для платформенных команд,
    • при высоком потоке мелких задач и инцидентов.

Минусы:

  • Меньше предсказуемости для бизнеса, если не настроены метрики.
  • Требует дисциплины в ограничении WIP, иначе превращается в хаос.
  • Если нет ясного Product Owner’а и приоритизации, легко утонуть в "реактивщине".

Контекст для Go/бэкенд-команд:

  • Отлично для:
    • SRE/infra команд,
    • команд, которые занимаются эксплуатацией, оптимизацией, реагированием на инциденты,
    • микросервисной архитектуры, где много мелких изменений, конфигураций, rollout'ов.
  1. Сравнение Scrum vs Kanban (по делу)
  • Предсказуемость:

    • Scrum: выше — есть план на спринт, velocity, прогнозирование.
    • Kanban: достигается через Lead Time/Cycle Time, но требует зрелой настройки.
  • Реакция на срочные задачи:

    • Scrum: сложнее, нужно или нарушать спринт, или резервировать capacity.
    • Kanban: естественно, задачи просто попадают в поток по приоритету.
  • Структура и ритуалы:

    • Scrum: формализованные события и роли.
    • Kanban: минимум обязательных ритуалов, можно адаптировать под команду.
  • Тип работы:

    • Scrum: фичи, проекты, релизы.
    • Kanban: поток задач, инциденты, поддержка, интеграции.
  1. Другие подходы и практики

Важно не ограничиваться Scrum/Kanban как "религией", а комбинировать:

  • Scrumban:

    • Используем спринты и планирование (Scrum),
    • но применяем WIP-лимиты и потоковую модель (Kanban).
    • Подходит для команд, у которых есть и проектная работа, и поток инцидентов.
  • Shape Up, Lean, XP-практики:

    • Для зрелых продуктовых команд можно использовать:
      • короткие циклы планирования,
      • парное программирование,
      • code review как обязательный этап,
      • TDD/автотесты, continuous delivery.
  1. Как звучит сильная позиция на интервью

Хороший ответ показывает, что человек:

  • Понимает, что методология — это инструмент под контекст, а не догма.
  • Умеет работать и в Scrum, и в Kanban-подобных процессах.
  • Может аргументированно предложить формат:

Примеры формулировок:

  • "Если команда делает много фич с понятной дорожной картой — выбираем итерационный подход с планированием (Scrum или его адаптацию)."
  • "Если команда занимается эксплуатацией платформы Go-сервисов, реагирует на инциденты и мелкие изменения — Kanban/потоковый подход лучше, с WIP-лимитами и жёсткими SLO/SLI."
  • "Часто оптимален гибрид: спринтовое планирование ключевых фич + Kanban-трек для инцидентов и незапланированных задач."

Такой ответ показывает зрелость, понимание процессов разработки и умение выстраивать эффективную работу команды.

Вопрос 3. Из каких основных частей состоит типичное CRUD веб-приложение и как они взаимодействуют?

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

Ответ собеседника: неправильный. Сначала описывает архитектуру фронтенда, затем после уточнения перечисляет только API, WebSocket и GraphQL как способы взаимодействия, не раскрывая полную структуру фронта, бэка, БД и связей между ними.

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

Типичное веб-приложение с CRUD-функционалом состоит из нескольких логических уровней. Важно уметь описать не только технологии (HTTP, WebSocket, GraphQL), но и архитектуру слоёв и их ответственность.

Базовая схема:

  • Клиент (frontend или другой consumer)
  • API-шлюз / HTTP-интерфейс
  • Серверное приложение (backend)
    • Транспортный слой (HTTP/JSON, gRPC, WebSocket и т.п.)
    • Слой хэндлеров (контроллеры/эндпоинты)
    • Сервисный (бизнес-логика)
    • Слой доступа к данным (репозитории)
  • Хранилище данных (SQL/NoSQL, кеши)
  • Инфраструктурные компоненты
    • аутентификация/авторизация,
    • логирование,
    • мониторинг,
    • очереди, брокеры сообщений (по необходимости).

Разберём по уровням.

  1. Клиентский уровень

Это потребители API:

  • Web frontend (SPA/SSR), mobile-приложения, другие сервисы.
  • Основные задачи:
    • отображение данных,
    • отправка CRUD-запросов:
      • Create: POST /entities
      • Read: GET /entities, GET /entities/{id}
      • Update: PUT/PATCH /entities/{id}
      • Delete: DELETE /entities/{id}
    • управление состоянием интерфейса (но не бизнес-инвариантами данных).

Важно: фронт, как правило, не содержит критичных бизнес-правил, которые должны быть защищены на бэкенде.

  1. Транспортный слой backend-приложения

Отвечает за приём и отдачу данных:

  • HTTP/REST (дефолтный вариант для CRUD).
  • GraphQL — если нужен гибкий выбор полей и агрегация данных из нескольких источников.
  • WebSocket/Server-Sent Events — если требуется real-time обновление (но CRUD всё равно обрабатывается бизнес-слоем).

В Go это обычно HTTP-сервер:

mux := http.NewServeMux()
mux.HandleFunc("/users", createUserHandler) // POST
mux.HandleFunc("/users/", userHandler) // GET/PUT/PATCH/DELETE

log.Fatal(http.ListenAndServe(":8080", mux))
  1. Слой хэндлеров (контроллеров)

Задачи:

  • распарсить запрос (path, query, body, headers),
  • провалидировать входные данные на базовом уровне,
  • вызвать нужный сервисный метод,
  • преобразовать результат в HTTP-ответ (JSON, ошибки, статусы).

Пример:

func createUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}

user, err := userService.CreateUser(r.Context(), req)
if err != nil {
handleError(w, err)
return
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
  1. Сервисный слой (бизнес-логика)

Здесь находится суть приложения:

  • проверка бизнес-правил (уникальность, ограничения, статусы),
  • оркестрация операций над несколькими сущностями,
  • работа с транзакциями,
  • интеграции с внешними сервисами (email, платежи, очереди).

Этот слой не зависит от HTTP напрямую.

type UserService struct {
repo UserRepository
}

func (s *UserService) CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
if req.Email == "" {
return nil, ErrInvalidEmail
}

if exists, _ := s.repo.ExistsByEmail(ctx, req.Email); exists {
return nil, ErrEmailAlreadyUsed
}

user := &User{
Name: req.Name,
Email: req.Email,
}

if err := s.repo.Create(ctx, user); err != nil {
return nil, err
}

return user, nil
}

Сильный ответ подчёркивает, что:

  • бизнес-правила не размазаны по хэндлерам и SQL,
  • есть чёткая декомпозиция ответственности.
  1. Слой доступа к данным (репозитории)

Инкапсулирует работу с БД:

  • CRUD-операции над сущностями,
  • скрывает SQL/конкретный драйвер от остального кода.

Пример на Go + SQL:

type UserRepository interface {
Create(ctx context.Context, u *User) error
GetByID(ctx context.Context, id int64) (*User, error)
ExistsByEmail(ctx context.Context, email string) (bool, error)
Update(ctx context.Context, u *User) error
Delete(ctx context.Context, id int64) error
}

type userRepoSQL struct {
db *sql.DB
}

func (r *userRepoSQL) Create(ctx context.Context, u *User) error {
query := `
INSERT INTO users (name, email)
VALUES ($1, $2)
RETURNING id, created_at
`
return r.db.QueryRowContext(ctx, query, u.Name, u.Email).
Scan(&u.ID, &u.CreatedAt)
}

Пример структуры таблицы под CRUD:

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);

CREATE INDEX idx_users_email ON users(email);
  1. Хранилище и инфраструктура

Сюда входят:

  • Основная БД:
    • PostgreSQL/MySQL для транзакционности и связей.
    • NoSQL, если нужны специфические паттерны (ключ-значение, документы и т.п.).
  • Кеши:
    • Redis для ускорения чтения и rate limiting.
  • Очереди/брокеры:
    • для асинхронных операций (email, уведомления, аналитика).
  • Обязательные кросс-срезы:
    • логирование (structured logs),
    • метрики (Prometheus),
    • трейсинг (OpenTelemetry),
    • аутентификация и авторизация (JWT, OAuth2, session-based),
    • конфигурация, секреты.
  1. Взаимодействие частей (концептуальный поток CRUD-запроса)

На примере операции Create:

  • Пользователь на фронтенде заполняет форму и жмёт "Сохранить".
  • Frontend отправляет POST /users с JSON на backend.
  • HTTP-хэндлер:
    • парсит и валидирует запрос,
    • вызывает UserService.CreateUser.
  • Сервис:
    • проверяет бизнес-правила,
    • вызывает UserRepository.Create.
  • Репозиторий:
    • выполняет SQL INSERT,
    • возвращает созданного пользователя.
  • Сервис возвращает доменную модель.
  • Хэндлер формирует HTTP 201 + JSON.
  • Frontend обновляет UI.

Для Read/Update/Delete — цепочка та же, меняется логика.

  1. Что важно подчеркнуть на интервью

Сильный ответ должен:

  • Чётко разделять:
    • представление (UI),
    • транспорт (HTTP/GraphQL/WebSocket),
    • бизнес-логику,
    • доступ к данным,
    • хранилище.
  • Показать понимание:
    • где размещать бизнес-правила,
    • как обеспечить тестируемость (моки репозиториев, тесты сервисного слоя),
    • как масштабировать (горизонтальное масштабирование бэкенда, пул соединений к БД, кеши).
  • Отметить, что WebSocket и GraphQL — это только способы взаимодействия, а не заменители архитектуры.

Такой ответ демонстрирует системное понимание построения веб-приложений, а не перечисление технологий.

Вопрос 4. Как клиент взаимодействует с сервером в веб-приложении и с какими протоколами и технологиями имеет смысл уметь работать?

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

Ответ собеседника: неполный. Упоминает REST и немного WebSocket, говорит, что с GraphQL и RPC не работал, не раскрывает общую модель взаимодействия и особенности протоколов.

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

Важна не просто осведомлённость о "REST / WebSocket / GraphQL", а понимание:

  • как устроен обмен данными между клиентом и сервером;
  • как выбирать протокол под задачу;
  • как это реализуется на практике (в том числе в Go);
  • как это влияет на масштабирование, безопасность и эволюцию API.

Краткая модель взаимодействия

Типовой путь запроса:

  • Клиент (браузер, мобильное приложение, другой сервис) формирует запрос.
  • Запрос идёт по сети (обычно поверх TCP, чаще всего через HTTPS).
  • Сервер:
    • аутентифицирует/авторизует запрос,
    • валидирует данные,
    • выполняет бизнес-логику,
    • обращается к БД/кешам/очередям,
    • формирует и отправляет ответ.

Ключевой слой — это API контракты: формат данных, эндпоинты, коды ответа, ошибки, backward compatibility.

Основные протоколы и технологии, которые стоит уверенно знать:

  1. HTTP/HTTPS + REST

Самый распространённый способ взаимодействия клиент–сервер.

Основы:

  • Методы:
    • GET (чтение),
    • POST (создание/комплексные операции),
    • PUT/PATCH (обновление),
    • DELETE (удаление).
  • Коды ответов:
    • 2xx — успех,
    • 4xx — ошибка клиента (400, 401, 403, 404, 422),
    • 5xx — ошибка сервера.
  • Форматы данных:
    • JSON (дефолт для веба),
    • иногда XML, protobuf (для межсервисного взаимодействия по HTTP).

Важные моменты:

  • Идемпотентность методов (GET/PUT/DELETE должны быть, POST — нет гарантии).
  • Версионирование API (например, /api/v1/...).
  • Чистый и предсказуемый контракт: схема запросов/ответов.

Пример REST-эндпоинта на Go:

type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}

func getUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

idStr := strings.TrimPrefix(r.URL.Path, "/users/")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}

user, err := userService.GetByID(r.Context(), id)
if err != nil {
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
} else {
http.Error(w, "internal error", http.StatusInternalServerError)
}
return
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(user)
}
  1. WebSocket

Используется, когда нужен постоянный двусторонний канал:

  • realtime-уведомления,
  • чаты,
  • стриминг данных (статусы, котировки, прогресс задач).

Особенности:

  • Работает поверх TCP, апгрейдится из HTTP(S) соединения.
  • После установления соединения — двусторонний канал без "request-response" ограничений.
  • Требует явного управления:
    • авторизацией (часто через токен при установке),
    • ping/pong, reconnect-логикой,
    • ограничениями по сообщениям (размер, rate limit).

Пример простого WebSocket-сервера на Go (gorilla/websocket):

var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}

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

for {
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
// эхо-ответ
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}

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

  • если важна низкая задержка и частые обновления,
  • если polling/long-polling становится неэффективным.
  1. GraphQL

Подход к API, где клиент сам описывает, какие данные ему нужны.

Особенности:

  • Один endpoint (обычно POST /graphql).
  • Клиент описывает запрос языком GraphQL:
    • выбирает только нужные поля,
    • может объединять несколько сущностей в один запрос.
  • Удобно для сложного фронтенда и мобильных клиентов.

Плюсы:

  • Меньше перегрузки данных,
  • гибкие ответы без множества REST эндпоинтов.

Минусы:

  • Более сложный сервер,
  • кеширование и observability сложнее, чем с REST.

Применение:

  • Когда много разных клиентов с разными потребностями к данным.
  • Когда REST разрастается в десятки специализированных эндпоинтов.
  1. RPC (gRPC и другие) — критично для микросервисов

RPC (Remote Procedure Call) удобен для взаимодействия сервис–сервис:

  • gRPC поверх HTTP/2:
    • бинарный протокол (protobuf),
    • высокая производительность,
    • стриминг (client, server, bidi),
    • строгие схемы.
  • Используется между backend-сервисами:
    • для низкой латентности,
    • контрактного взаимодействия,
    • генерации кода (Go-клиенты/серверы из .proto).

Пример объявления gRPC-сервиса:

syntax = "proto3";

service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

message GetUserRequest {
int64 id = 1;
}

message GetUserResponse {
int64 id = 1;
string name = 2;
string email = 3;
}

Дальше генерируем Go-код и реализуем интерфейс.

Почему важно для Go-разработчика:

  • gRPC — один из стандартов де-факто во внутренних распределённых системах.
  • Позволяет строить чётко типизированные API, легко поддерживаемые и эволюционируемые.
  1. Дополнительные моменты

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

  • Аутентификация и авторизация:
    • JWT (Bearer токены в Authorization header),
    • OAuth2/OpenID Connect,
    • session cookies.
  • Безопасность:
    • HTTPS везде,
    • CORS,
    • rate limiting,
    • защита от CSRF/XSS/SQL injection (валидация, подготовленные выражения).
  • Версионирование и эволюция API:
    • v1/v2,
    • backward-compatible изменения,
    • депрекейшн стратегия.
  • Наблюдаемость:
    • логирование запросов/ответов,
    • метрики (latency, error rate),
    • трассировка (correlation id, trace id).
  1. Как должен звучать сильный ответ

Сильный ответ показывает, что:

  • Понимаете HTTP и REST как базу.
  • Умеете использовать WebSocket для realtime сценариев и понимаете его отличия.
  • Знаете, зачем нужны RPC/gRPC во внутренних сервисах.
  • Понимаете, в каких кейсах GraphQL имеет смысл.
  • Учитываете безопасность, версионирование, наблюдаемость и контракт между клиентом и сервером.

Например:

"Основное взаимодействие — по HTTP(S) с REST API: чистые эндпоинты, корректные методы и коды ответов, JSON-схемы. Для realtime-сценариев — WebSocket или SSE. Для межсервисного взаимодействия предпочтителен gRPC с protobuf для эффективности и строгих контрактов. Важно проектировать API так, чтобы он был стабильным, версионируемым, безопасным, с нормальной наблюдаемостью и без утечек внутренних деталей реализации."

Вопрос 5. Что такое REST и какие у него ключевые принципы?

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

Ответ собеседника: неполный. Определяет REST как набор правил взаимодействия с сервером, перечисляет методы GET, POST, PUT, DELETE и их связь с CRUD, но не раскрывает остальные принципы REST.

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

REST (Representational State Transfer) — архитектурный стиль проектирования распределенных систем поверх HTTP, основанный на ряде чётких принципов. Это не "просто CRUD по HTTP" и не "любой JSON-ответ". Сильный ответ должен описывать ключевые ограничения REST и показать, как они влияют на дизайн API.

Основные принципы REST:

  1. Клиент–сервер
  • Четкое разделение:
    • Клиент: UI, представление, взаимодействие с пользователем.
    • Сервер: бизнес-логика, безопасность, хранение данных.
  • Это позволяет:
    • независимо развивать клиент и сервер,
    • масштабировать их отдельно.

В контексте Go:

  • Backend-сервис реализует REST API.
  • Web/Mobile/другие сервисы — клиенты этого API.
  1. Отсутствие состояния (stateless)
  • Каждый запрос содержит всю необходимую информацию для его обработки:
    • аутентификация (например, Bearer токен),
    • контекст операции,
    • идентификаторы ресурсов.
  • Сервер:
    • не хранит состояние сессии между запросами в памяти процесса,
    • не должен полагаться на "контекст" из предыдущих запросов.

Плюсы:

  • Простое горизонтальное масштабирование (любая нода может обработать запрос).
  • Упрощение отказоустойчивости.

Важно:

  • Сессии могут существовать (JWT, session id в cookie), но сервер обрабатывает каждый запрос как самостоятельный, используя внешнее хранилище (БД, Redis) или сам токен.
  1. Единообразный интерфейс (Uniform Interface)

Это ключевое отличие REST. Он включает:

  • Идентификация ресурсов:
    • Каждый ресурс имеет уникальный URL.
    • Пример:
      • /users
      • /users/123
      • /users/123/orders
  • Манипуляция ресурсами через представления:
    • Работаем с "представлениями" ресурса (обычно JSON).
    • Клиент не управляет внутренней моделью БД — только через API.
  • Использование стандартных HTTP-методов по назначению:
    • GET — безопасный и идемпотентный: чтение данных.
    • POST — создание ресурса или сложные действия (не обязателен идемпотентен).
    • PUT — полная замена ресурса (идемпотентен).
    • PATCH — частичное обновление.
    • DELETE — удаление ресурса (идемпотентен по контракту).
  • Соответствие HTTP-кодов состоянию операции:
    • 200 OK, 201 Created, 204 No Content,
    • 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found,
    • 409 Conflict, 422 Unprocessable Entity,
    • 500+ для ошибок сервера.

Пример на Go:

func deleteUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

idStr := strings.TrimPrefix(r.URL.Path, "/users/")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}

err = userService.Delete(r.Context(), id)
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusNoContent)
}
  1. Кэшируемость
  • Ответы сервера должны явно указывать, можно ли их кэшировать и на сколько:
    • заголовки Cache-Control, ETag, Last-Modified.
  • GET-ответы для неизменяемых или редко меняющихся ресурсов могут кэшироваться:
    • браузером,
    • CDN,
    • прокси-серверами.

Плюсы:

  • Снижение нагрузки на backend и БД.
  • Ускорение ответа для клиентов.

Пример заголовков:

w.Header().Set("Cache-Control", "max-age=60")
w.Header().Set("ETag", `W/"user-123-v1"`)
  1. Многоуровневая архитектура (Layered System)
  • Между клиентом и сервером могут быть:
    • балансировщики,
    • прокси,
    • API-шлюзы,
    • сервисы аутентификации,
    • кеши.
  • Клиент не должен зависеть от конкретной топологии — он работает с единым REST API.

Это важно для:

  • масштабирования,
  • безопасности,
  • управления трафиком (rate limiting, auth, observability на gateway).
  1. Код по требованию (необязательное ограничение)
  • Сервер может передавать исполняемый код (например, JavaScript) клиенту.
  • В вебе это обычное дело, но в backend REST API обычно игнорируется как принцип.
  1. HATEOAS (Hypermedia As The Engine Of Application State)

Чистый REST предполагает, что клиент может навигировать по API через ссылки в ответах.

Пример:

{
"id": 123,
"name": "Alice",
"links": {
"self": "/users/123",
"orders": "/users/123/orders"
}
}

На практике:

  • Полный HATEOAS используется редко,
  • но идея полезна: возвращать ссылки на связанные ресурсы, чтобы клиент не "хардкодил" все маршруты.
  1. Что важно подчеркнуть на интервью

Сильный ответ:

  • Даёт определение REST как архитектурного стиля с набором ограничений.
  • Отличает "RESTful API" от "просто JSON по HTTP".
  • Упоминает:
    • ресурсы и URI,
    • правильное использование HTTP-методов и кодов,
    • stateless,
    • кэширование,
    • возможность многоуровневой архитектуры,
    • важность стабильного, версионируемого, предсказуемого контракта.

Пример concise-формулировки:

"REST — это архитектурный стиль поверх HTTP, где система представлена как набор ресурсов с устойчивыми URI, над которыми выполняются стандартные операции через HTTP-методы. Важные принципы: разделение клиент/сервер, stateless-запросы, единообразный интерфейс (ресурсы, методы, коды ответа), кэшируемость ответов и поддержка многоуровневой архитектуры. Просто использовать GET/POST/PUT/DELETE недостаточно, важно корректно моделировать ресурсы, контракты и поведение."

Вопрос 6. В чем разница между авторизацией на сессиях и токенах, и когда какой подход лучше использовать?

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

Ответ собеседника: неправильный. Упоминает только, что токены позволяют оставаться авторизованным после закрытия вкладки, приводит пример с Instagram, но не раскрывает механизмы сессий, отличия хранения, безопасности и типичные сценарии применения.

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

Корректный ответ должен:

  • четко разделять аутентификацию и авторизацию;
  • объяснять, как устроены session-based и token-based подходы;
  • показать, какие есть риски и trade-off'ы;
  • уметь привязать это к веб-/mobile-/API-сценариям и микросервисам.

Важно: обычно сравнивают два подхода управления аутентифицированным состоянием клиента.

  1. Базовые определения
  • Аутентификация — установление личности пользователя (кто это?).
  • Авторизация — определение прав доступа (что ему можно?).
  • Сессии и токены — механизмы хранения и проверки факта аутентификации и/или прав.

Дальше под "на сессиях" — session-based auth, под "на токенах" — token-based (часто JWT, но не только).

  1. Авторизация на сессиях (session-based)

Механика:

  • Пользователь логинится (логин/пароль, OAuth и т.п.).
  • Сервер проверяет данные и:
    • создает запись сессии в хранилище (БД, Redis, in-memory + sticky sessions),
    • генерирует session ID (случайный непрозрачный токен).
  • Клиенту отправляется session ID, обычно в httpOnly cookie.
  • На каждый следующий запрос браузер автоматически отправляет cookie.
  • Сервер по session ID находит данные сессии и понимает, кто пользователь и какие у него права.

Ключевые свойства:

  • Сессионное состояние хранится на серверной стороне.
  • Сервер в любой момент:
    • может принудительно завершить сессию (logout, revoke),
    • может изменить права без перевыдачи каких-либо токенов.
  • Хорошо работает с классическими веб-приложениями (SSR, один домен).

Плюсы:

  • Простой revoke: удалили или изменили сессию — она тут же невалидна.
  • Данные о пользователе не "утекают" в клиент в виде подписанного payload.
  • Контролируемый lifecycle: таймауты неактивности, жесткие TTL, одновременные сессии.

Минусы:

  • Нужен общий стор для сессий при горизонтальном масштабировании:
    • Redis, распределенная БД.
  • Сложнее для чисто API-сценариев между сервисами.
  • Уязвимость к CSRF, если неправильно настроены cookie:
    • лечится SameSite, CSRF-токенами, проверкой Origin/Referer.
  1. Авторизация на токенах (token-based)

Типичный вариант — Bearer токены, часто JWT.

Механика (на примере JWT):

  • Пользователь логинится.
  • Сервер выдает токен (например, JWT), который содержит:
    • идентификатор пользователя,
    • роли/права (claims),
    • срок жизни (exp),
    • подпись сервера.
  • Токен хранится на клиенте (localStorage, cookie, secure storage в мобильном приложении).
  • Клиент отправляет токен в Authorization: Bearer <token> или cookie.
  • Сервер:
    • валидирует подпись и срок действия,
    • извлекает данные из токена.
  • При "self-contained" токенах (JWT) серверу не нужно ходить в БД для каждой проверки (но есть нюансы).

Плюсы:

  • Хорошо подходит для:
    • публичных REST/GraphQL API,
    • мобильных приложений,
    • SPA, когда фронт и бэк разнесены по доменам,
    • микросервисов, где разные сервисы могут валидировать один и тот же токен.
  • Масштабируемость:
    • нет центрального session storage для проверки (в случае self-contained токенов).
    • удобно для распределённых систем.

Минусы:

  • Revocation сложнее:
    • если токен самодостаточен и живет, напр., 1 час — нельзя просто "стереть" его на сервере,
    • нужно:
      • короткий срок жизни access-токена,
      • refresh-токены,
      • blacklist/allowlist,
      • rotation.
  • При компрометации токена атакующий авторизован до истечения срока или отзыва.
  • Некачественная реализация JWT часто приводит к уязвимостям:
    • отсутствие проверки алгоритма,
    • слишком долгий TTL,
    • хранение секретов в токене.

Пример в Go: валидация Bearer-токена (упрощённо)

func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

tokenStr := strings.TrimPrefix(auth, "Bearer ")
claims, err := validateJWT(tokenStr)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

ctx := context.WithValue(r.Context(), "userID", claims.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
  1. Ключевые отличия: сессии vs токены

С точки зрения архитектуры:

  • Где хранится состояние:

    • Сессии: состояние (кто залогинен, с какими правами) — на сервере.
    • Токены: состояние зашито в токен (self-contained) или используется как ключ к состоянию.
  • Масштабирование:

    • Сессии: нужен общий стор (Redis/DB), иначе sticky sessions.
    • Токены: проще горизонтально масштабировать (особенно JWT, валидация по подписи).
  • Revocation:

    • Сессии: мгновенный отзыв (удалили запись).
    • Токены: если self-contained и без blacklist — отзыв сложен.
  • Безопасность:

    • Сессии:
      • часто в httpOnly Secure cookie — недоступны JS, ниже риск XSS-воровства.
      • уязвимы к CSRF, если не настроить SameSite/токены.
    • Токены:
      • если хранить в localStorage — уязвимы к XSS.
      • важно использовать короткий TTL + httpOnly cookie или secure storage.
  1. Когда что лучше использовать

Session-based (сессии):

  • Подходит, когда:
    • классическое веб-приложение (SSR),
    • один или несколько фронтов, но контролируемый домен,
    • важна простота управления сессиями и ревокацией,
    • нет жестких требований к независимой валидации в разных сервисах.
  • Типичный пример:
    • админка,
    • внутренних порталы,
    • B2C веб, где back и front тесно связаны.

Token-based (особенно JWT):

  • Подходит, когда:
    • SPA + мобильные клиенты + сторонние интеграции,
    • публичный API,
    • микросервисная архитектура:
      • несколько сервисов должны доверять одному центру аутентификации,
      • удобна проверка подписи токена на каждом сервисе.
  • Желательно:
    • короткоживущие access-токены,
    • refresh-токены для обновления,
    • rotation и revoke-list для безопасности.

Гибридный подход:

  • В реальных системах часто:
    • фронт для пользователя — сессионный (cookie-сессия),
    • внутренняя коммуникация между сервисами — token-based (JWT/gRPC + metadata),
    • OAuth2/OpenID Connect как базовый протокол.
  1. Как звучит сильный ответ на практике

Пример формулировки:

"Session-based авторизация хранит состояние на сервере: клиент получает session ID в httpOnly cookie, сервер по нему находит сессию. Это удобно для классических веб-приложений — легко отзывать, контролировать, реализовать logout. Token-based подход (чаще JWT) переносит часть состояния на клиент: токен подписан, содержит идентификатор пользователя и права, сервер валидирует подпись без обращения к общему стору. Это хорошо для распределённых систем, мобильных клиентов, публичных API и микросервисов. При выборе подхода важно учитывать масштабирование, безопасность хранения, простоту отзыва и длительность жизни токенов. Для сложных систем часто комбинируем: короткоживущий access-токен + refresh-токен, а также централизованный auth-сервис."

Вопрос 7. Что такое схлопывание внешних отступов (margin collapsing) в CSS и в чем его суть?

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

Ответ собеседника: правильный. Описывает ситуацию, когда вертикальные margin соседних элементов объединяются в один больший отступ.

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

Схлопывание внешних отступов (margin collapsing) — это механизм CSS, при котором вертикальные внешние отступы (margin-top и margin-bottom) у соседних блоков не суммируются, а образуют один общий отступ, равный максимальному из участвующих значений (с учётом знака).

Ключевые случаи схлопывания:

  1. Соседние блоки на одном уровне вложенности

Если два блочных элемента идут подряд:

  • у первого есть margin-bottom,
  • у второго есть margin-top,
  • между ними нет границы (border), внутреннего отступа (padding) или других "разделителей",

то итоговый вертикальный интервал между ними будет не суммой, а:

  • max(margin-bottom первого, margin-top второго).

Пример:

<div class="a">A</div>
<div class="b">B</div>
.a {
margin-bottom: 40px;
}

.b {
margin-top: 20px;
}

Результат:

  • Расстояние будет 40px, а не 60px.
  1. Вложенные блоки (родитель и первый/последний потомок)

Схлопывание происходит:

  • между верхним margin первого внутреннего элемента и верхним margin родителя, если:
    • у родителя нет border-top, padding-top, overflow с особым значением и т.п.;
  • между нижним margin последнего внутреннего элемента и нижним margin родителя — по тем же правилам.

Пример:

<div class="parent">
<div class="child">Text</div>
</div>
.parent {
margin-top: 10px;
/* нет padding, border, overflow */
}

.child {
margin-top: 30px;
}

Результат:

  • Итоговый отступ сверху будет 30px, а не 10 + 30.
  1. Пустые блоки

Если блочный элемент:

  • не имеет border, padding, содержимого,
  • только вертикальные margin,

то его верхний и нижний margin тоже схлопываются между собой.

  1. Работа с отрицательными margin

Если участвуют отрицательные значения:

  • при схлопывании берется:
    • максимальный положительный margin
    • и минимальный (наиболее отрицательный) отрицательный,
    • результат — их комбинация (фактически: max из положительных и отрицательных по модулю, но с учетом знака).

Зачем это нужно понимать:

  • Корректная верстка: избежать "странных" отступов, которые не совпадают с ожидаемыми суммами.
  • Прогнозируемое поведение layout’а:
    • понимать, почему изменение margin у дочернего элемента визуально двигает весь родитель.
  • Управление схлопыванием:
    • можно предотвратить схлопывание, если:
      • добавить padding или border родителю,
      • задать overflow: auto/hidden и т.п.

Хотя вопрос фронтендовый, ожидается чёткое, формальное объяснение механизма, а не только интуитивное описание эффекта. Кратко:

"Схлопывание margin — это когда вертикальные внешние отступы соседних или вложенных блоков не складываются, а объединяются в один отступ по определённым правилам (обычно равный максимальному). Это влияет только на вертикальные margin в нормальном потоковом расположении блочных элементов."

Вопрос 8. В каких случаях схлопывание внешних отступов (margin collapsing) не происходит?

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

Ответ собеседника: неправильный. Говорит, что с такими случаями не сталкивался и не отвечает по сути.

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

Чтобы уверенно работать с layout’ом, важно не только знать, когда margin схлопываются, но и чётко понимать, как этот механизм отключить. Схлопывание вертикальных отступов не происходит, если между потенциально схлопывающимися margin появляется "барьер" — свойства или контекст, которые разрывают цепочку.

Ключевые случаи, когда margin collapsing НЕ происходит:

  1. Наличие padding у контейнера

Если между margin вложенного элемента и границей родителя есть ненулевой внутренний отступ (padding), схлопывания не будет.

Пример:

<div class="parent">
<div class="child">Text</div>
</div>
.parent {
padding-top: 1px; /* Барьер */
}

.child {
margin-top: 20px;
}

Результат:

  • Отступ сверху будет: 1px padding + 20px margin.
  • Margin ребёнка не схлопнется с внешним окружением родителя.
  1. Наличие border у контейнера

Border (верхний или нижний) родителя также разрывает схлопывание.

.parent {
border-top: 1px solid transparent; /* Барьер */
}

.child {
margin-top: 20px;
}

Margin-top у child не схлопнется с внешним margin родителя.

  1. Определенные значения overflow

Если у родителя установлено:

  • overflow: hidden;
  • overflow: auto;
  • overflow: scroll;

то вертикальные margin вложенного элемента не схлопываются с margin родителя.

.parent {
overflow: hidden; /* Барьер */
}

.child {
margin-top: 20px;
}
  1. Блочные формирующие контекст форматирования (BFC)

Создание нового блока форматирования (Block Formatting Context) мешает схлопыванию снаружи.

BFC создаётся, например, при:

  • float: left/right;
  • position: absolute/fixed;
  • display: flow-root;
  • overflow: hidden/auto/scroll;
  • у flex-контейнеров (display: flex/inline-flex);
  • у grid-контейнеров (display: grid/inline-grid).

Если элемент или его родитель формируют новый контекст, margin не "проталкивается" наружу.

  1. Flex и Grid элементы

У flex-элементов и grid-элементов:

  • вертикальные margin между элементами внутри flex/grid-контейнера не схлопываются так, как в обычном блочном потоке.
  • margin между дочерним элементом и контейнером также не ведут себя как классическое схлопывание блочных элементов.

То есть внутри flex/grid-контейнера классического margin collapsing нет.

  1. Inline, inline-block, absolutely positioned элементы

Схлопывание margin относится к вертикальным margin блочных элементов в нормальном потоке.

Не схлопываются margin:

  • у inline-элементов,
  • у inline-block,
  • у элементов с position: absolute/fixed,
  • между такими элементами и их контейнерами.
  1. Промежуточный контент между элементами

Между вертикальными margin двух блоков схлопывание не произойдёт, если:

  • есть содержимое,
  • есть border,
  • есть padding,
  • есть другой элемент, который разрывает контакт margin’ов.
  1. Пустые блоки, которые перестали быть "идеально пустыми"

Margin пустого блока сами с собой схлопываются, пока он:

  • не имеет border, padding, содержимого и спец-свойств.

Как только добавляется:

  • border,
  • padding,
  • overflow: hidden/auto/scroll,
  • или он становится flex/grid/BFC-контейнером —

поведение схлопывания меняется/отключается.

Краткая формулировка для интервью:

"Margin collapsing не происходит, когда между элементами есть любой 'барьер': padding, border, содержимое, новые контексты форматирования (overflow: hidden/auto, display: flex/grid/flow-root, position: absolute, float), а также для inline/inline-block/absolute элементов. То есть, если элемент или родитель используют такие свойства, вертикальные отступы уже не схлопываются и считаются отдельно."

Вопрос 9. Как работает специфичность CSS-селекторов и порядок приоритетов стилей?

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

Ответ собеседника: правильный. Перечисляет порядок: сначала !important, затем инлайновые стили, затем селекторы по id, затем по классу, затем по тегу.

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

Специфичность (specificity) — это механизм CSS, который определяет, какие из конфликтующих правил будут применены к элементу. Важно понимать не только общий порядок, но и точный принцип расчёта.

Приоритет в общем виде:

  1. !important
  2. Специфичность селектора
  3. Порядок следования правила в коде (ниже — сильнее при равной специфичности)
  4. Источник стилей (user agent / user / author) — в обычной практике доминируют стили разработчика.

Ключевые уровни специфичности

Специфичность можно представить как "число" из четырёх разрядов: (a, b, c, d)

  • a — стили автора с !important / пользовательские стили и т.п.
  • b — количество селекторов по id
  • c — количество:
    • классов (.btn),
    • атрибутов ([type="text"]),
    • псевдоклассов (:hover, :focus, :nth-child и т.п.).
  • d — количество:
    • селекторов по тегам (div, span),
    • псевдоэлементов (::before, ::after).

Чем больше значение на старшем разряде, тем выше приоритет. При равенстве сравниваем следующий разряд.

Примеры:

  • #header:
    • (0, 1, 0, 0)
  • .menu .item.active:
    • классы: .menu, .item, .active → (0, 0, 3, 0)
  • header nav a:
    • теги: header, nav, a → (0, 0, 0, 3)
  • a.button.primary:hover:
    • классы: .button, .primary, псевдокласс :hover → 3 "c"
    • тег a → 1 "d"
    • итог: (0, 0, 3, 1)

Сравнения:

  • #header (0,1,0,0) сильнее, чем .menu .item.active (0,0,3,0) — id почти всегда сильнее любых классов.
  • .btn.primary (0,0,2,0) сильнее, чем button (0,0,0,1).
  • Если селекторы имеют одинаковую специфичность — побеждает правило, объявленное позже в CSS.

Инлайновые стили:

  • Стиль, заданный атрибутом style на элементе (например, <div style="color: red">) имеет очень высокую специфичность:
    • логически это (0, 1, 0, 0) "над" id и обычными правилами, но без !important.
  • Поэтому:
    • инлайн без !important сильнее любого внешнего селектора без !important,
    • но !important в CSS-файле может перебить обычный инлайн, если источник и каскад позволяют.

!important:

  • Повышает приоритет конкретного свойства в данном правиле.
  • Работает в своей "плоскости":
    • сначала сравниваются все правила с !important между собой по специфичности и порядку,
    • если ни одно не подходит — рассматриваются обычные правила.
  • !important из стилей автора перекрывает почти всё, но использование стоит минимизировать:
    • затрудняет поддержку,
    • ломает ожидаемый каскад.

Каскад и порядок:

Если специфичность одинаковая и нет !important, применяется правило, которое объявлено позже в итоговом CSS.

Пример:

.button {
color: red;
}

.button {
color: blue;
}

Итог:

  • .button будет color: blue, т.к. второе правило идёт позже при одинаковой специфичности.

Практические нюансы, которые важно понимать:

  • Универсальный селектор *, селекторы по потомкам (div span), комбинаторы (>, +, ~) сами по себе не повышают специфичность.
  • Псевдоклассы (:hover, :focus, :not(...)) считаются как "c":
    • внутри :not() селектор влияет на применение, но не увеличивает специфичность, учитывается только сам :not.
  • Стили библиотек и фреймворков:
    • часто построены на классах (Bootstrap, Tailwind),
    • чтобы их переопределить, достаточно:
      • добавить более специфичные селекторы,
      • или подключить свои стили после,
      • или в крайнем случае использовать !important.

Краткая формулировка:

"При конфликте стилей сначала учитываем !important, дальше — специфичность: инлайновые стили, затем селекторы с id, потом классы/атрибуты/псевдоклассы, затем теги и псевдоэлементы. При одинаковой специфичности побеждает стиль, определенный позже в коде. Понимание этой модели позволяет предсказуемо управлять переопределением стилей, не злоупотребляя !important."

Вопрос 10. Что такое JavaScript, как работает модель выполнения кода и чем отличаются микрозадачи от макрозадач?

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

Ответ собеседника: правильный. Указывает, что JavaScript однопоточный, использует event loop; описывает порядок: сначала стек, затем микрозадачи, затем макрозадачи; относит промисы к микрозадачам, setTimeout/setInterval — к макрозадачам.

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

JavaScript в контексте браузера и Node.js — однопоточный язык исполнения (один поток выполнения JS-кода), работающий поверх среды, которая предоставляет асинхронные возможности (Web APIs в браузере, libuv в Node.js). Ключ к пониманию — модель исполнения: call stack, heap, event loop и очереди задач.

Основные компоненты модели выполнения:

  1. Call Stack (стек вызовов)
  • Стек, в который добавляются (push) и из которого удаляются (pop) кадры вызовов функций.
  • Пока стек не пуст, движок выполняет синхронный код.
  • Ошибка "Maximum call stack size exceeded" — следствие слишком глубокой или рекурсивной цепочки.
  1. Heap
  • Память для объектов, замыканий и прочих структур.
  • Сборщик мусора управляет освобождением памяти.
  1. Event Loop (цикл событий)

Event Loop координирует:

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

Общая идея:

  • Выполняем весь синхронный код (пока стек не опустеет).
  • Затем:
    • обрабатываем все микрозадачи из соответствующей очереди,
    • при необходимости обновляем рендер (в браузере),
    • затем берём следующую макрозадачу.
  • Цикл повторяется.

Важно: "однопоточность" относится к JS-коду, но асинхронные операции (I/O, таймеры) выполняются средой параллельно и возвращают результат обратно через очереди задач.

  1. Макрозадачи (macrotasks)

Макрозадачи — это крупные единицы работы, которые планируются в основную очередь событий.

Типичные источники макрозадач:

  • setTimeout
  • setInterval
  • setImmediate (Node.js)
  • I/O колбэки (сетевые запросы, файловые операции)
  • некоторые события DOM (в браузерах)
  • обработчики сообщений postMessage (в реализации — тоже макротаски)

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

  • После выполнения текущего стека и всех микрозадач движок берёт одну макрозадачу из очереди и выполняет её колбэк.
  • Между макрозадачами браузер может перерисовывать интерфейс.

Пример:

console.log('A');

setTimeout(() => {
console.log('B');
}, 0);

console.log('C');

Порядок:

  • Сначала синхронный код: A, затем C.
  • Затем берётся макрозадача setTimeout → B.
  • Итог: A, C, B.
  1. Микрозадачи (microtasks)

Микрозадачи — задачи более высокого приоритета, которые выполняются:

  • сразу после текущего стека вызовов,
  • до перехода к следующей макрозадаче.

Основные источники микрозадач:

  • Promise callbacks:
    • then / catch / finally
  • queueMicrotask()
  • process.nextTick() (в Node.js — особый приоритет, но концептуально близко)

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

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

Пример:

console.log('A');

setTimeout(() => {
console.log('B (macrotask)');
}, 0);

Promise.resolve().then(() => {
console.log('C (microtask)');
});

console.log('D');

Порядок:

  • Синхронно: A, D
  • Затем микрозадачи: C
  • Затем макрозадачи: B

Итог: A, D, C, B.

  1. Ключевое различие: микрозадачи vs макрозадачи
  • Макрозадачи:
    • формируют "большие" шаги event loop;
    • после каждой макрозадачи могут выполняться рендер/перерисовка.
  • Микрозадачи:
    • выполняются батчом сразу после текущего стека;
    • имеют более высокий приоритет;
    • используются для "быстрых" реакций на изменения состояния (особенно Promise-based код).

Практический вывод:

Если в микрозадачах (Promise.then/queueMicrotask) в цикле постоянно добавлять новые микрозадачи, можно "зажать" event loop, не давая макрозадачам и перерисовкам выполниться — это критично для производительности фронтенда.

  1. Асинхронность в однопоточной модели

Важно понимать:

  • JavaScript не создаёт фоновые потоки самостоятельно для setTimeout/fetch/I/O:
    • этим занимается среда исполнения (браузер, Node.js),
    • по завершении операции результат ставится в очередь задач.
  • JS-движок берёт задачи из очередей, когда стек свободен, и исполняет колбэки по правилам event loop.
  1. Как объяснить кратко на интервью

Сильная формулировка:

"JavaScript — однопоточный язык с моделью выполнения, основанной на стеке вызовов и event loop. Синхронный код выполняется в стеке. Асинхронные операции (таймеры, I/O, промисы) планируются средой и возвращают колбэки в очереди задач. Сначала всегда отрабатывает текущий стек, затем все микрозадачи (Promise.then, queueMicrotask), и только после этого берётся следующая макрозадача (setTimeout, I/O и т.п.). Микрозадачи имеют более высокий приоритет и исполняются перед макрозадачами, что важно учитывать при проектировании асинхронного поведения и избежании блокировок."

Вопрос 11. В чём разница между поверхностным и глубоким копированием объектов и как выполнить глубокое копирование?

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

Ответ собеседника: правильный. Объясняет, что поверхностное копирование дублирует только верхний уровень без вложенных структур (spread, Object.assign), глубокое копирование копирует всё дерево; упоминает JSON.stringify/parse с ограничениями и structuredClone.

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

Поверхностное (shallow) и глубокое (deep) копирование — это вопрос о том, как мы работаем со ссылочными типами данных (объекты, массивы, структуры с вложенными объектами). Понимание важно и в JavaScript, и в Go, и в любых языках, где есть ссылки и составные структуры.

Суть различий:

  1. Поверхностное копирование (shallow copy)
  • Копируется только "верхний уровень" объекта.
  • Все вложенные объекты/массивы остаются теми же самыми ссылками.
  • Изменения вложенных структур через копию отражаются в оригинале.

Пример на JavaScript:

const original = {
name: 'Alice',
meta: { age: 30 }
};

const copy = { ...original }; // или Object.assign({}, original)

copy.name = 'Bob';
copy.meta.age = 40;

console.log(original.name); // 'Alice' — примитив скопирован
console.log(original.meta.age); // 40 — ссылка общая, изменения видны

Поверхностное копирование подходит, если:

  • вас устраивает разделение только верхнего уровня,
  • вы точно знаете, что вложенные объекты не будут мутироваться,
  • или структура плоская.
  1. Глубокое копирование (deep copy)
  • Копируется вся структура целиком:
    • новые объекты и массивы создаются рекурсивно на всех уровнях.
  • Вложенные ссылки не разделяются между оригиналом и копией.
  • Изменения в копии не влияют на оригинал и наоборот.

Пример на JavaScript (концептуально):

const original = {
name: 'Alice',
meta: { age: 30, tags: ['a', 'b'] }
};

const deep = structuredClone(original); // современный стандарт

deep.meta.age = 40;
deep.meta.tags.push('c');

console.log(original.meta.age); // 30
console.log(original.meta.tags); // ['a', 'b']
  1. Практические способы глубокого копирования (JavaScript)

Основные варианты:

  • structuredClone(value)

    • Встроенный стандартный метод.
    • Клонирует большинство типов: объекты, массивы, Map, Set, Date, RegExp, Blob, File, FileList, ArrayBuffer и др.
    • Обрабатывает вложенные структуры, циклические ссылки.
    • Не копирует функции, прототипы могут быть изменены.
    • Предпочтительный способ, если доступен в среде.
  • JSON.stringify/JSON.parse

    • Рабочий, но ограниченный трюк:
      • не копирует методы, прототипы, non-enumerable свойства,
      • теряет типы (Date → строка, Map/Set → объект/массива, Infinity/NaN → null),
      • не работает с циклами (будет ошибка).
    • Использовать только для простых, "данных-под-JSON":
const deep = JSON.parse(JSON.stringify(original));
  • Ручная рекурсивная функция
    • Полный контроль, можно учитывать:
      • массивы, объекты,
      • специальные типы,
      • циклические ссылки (через WeakMap/Map),
      • прототипы.
    • Хороша для продвинутых сценариев или библиотек.

Пример (упрощённый):

function deepClone(obj, visited = new Map()) {
if (obj === null || typeof obj !== 'object') return obj;

if (visited.has(obj)) return visited.get(obj);

let result = Array.isArray(obj) ? [] : {};
visited.set(obj, result);

for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = deepClone(obj[key], visited);
}
}

return result;
}
  1. Аналогия и важность для Go

Хотя вопрос про JavaScript, для сильного инженера важно проводить аналогии.

В Go:

  • Примитивы (int, float, bool, string) копируются по значению.
  • Структуры копируются по значению — но если внутри есть поля-ссылки (maps, slices, pointers), то:
    • копирование структуры — это shallow copy этих ссылок.
  • Для глубокого копирования нужно:
    • явно создавать новые слайсы/maps/структуры,
    • рекурсивно проходить вложенные элементы.

Пример:

type Profile struct {
Name string
Tags []string
}

func ShallowCopy(p Profile) Profile {
return p // Tags ссылаются на тот же underlying array
}

func DeepCopy(p Profile) Profile {
tags := make([]string, len(p.Tags))
copy(tags, p.Tags)

return Profile{
Name: p.Name,
Tags: tags,
}
}
  1. Выбор подхода
  • Используйте поверхностное копирование:
    • когда структура простая или вы осознанно делите вложенные ссылки;
    • для иммутабельных или условно иммутабельных вложенных данных.
  • Используйте глубокое копирование:
    • когда данные могут независимо изменяться в разных частях системы;
    • при работе с шаблонами конфигураций, DTO, кэшами, где изменение копии не должно затрагивать оригинал;
    • при изоляции состояния (например, в тестах).

Важно:

  • Глубокое копирование дороже по памяти и времени.
  • В продакшн-коде лучше стремиться к иммутабельным структурам и явному копированию, чем к "магическим" непредсказуемым клонерам.

Краткая формулировка для интервью:

"Поверхностное копирование создает новый объект только верхнего уровня, но вложенные объекты остаются общими ссылками. Глубокое копирование рекурсивно копирует всю структуру, обеспечивая полную независимость. В JavaScript для глубокого копирования корректнее использовать structuredClone или свою рекурсивную реализацию, учитывая типы и циклы; JSON.stringify/parse годится только для простых данных. В любой системе с ссылочными типами важно явно понимать, разделяете ли вы состояние или нет."

Вопрос 12. Что такое Local Storage и Session Storage в браузере, чем они отличаются и для чего используются?

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

Ответ собеседника: правильный. Оба описаны как браузерные хранилища ключ-значение (строки); Session Storage живёт до закрытия вкладки, Local Storage — до явной очистки; приведён пример хранения токена и данных, которые должны сохраняться при перезагрузке.

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

Local Storage и Session Storage — это часть Web Storage API: простые персистентные хранилища ключ-значение в браузере, работающие по принципу "origin-based" (привязаны к схеме, домену и порту). Они удобны для хранения небольших порций данных на клиенте, но критично понимать их ограничения и риски.

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

  1. Общие свойства Web Storage
  • Формат:
    • ключ и значение — строки.
    • Для сложных структур — обычно сериализация в JSON.
  • Объём:
    • как правило, около 5–10 МБ на origin (зависит от браузера).
  • Доступ:
    • синхронный API (операции блокируют основной поток JS при больших объёмах данных).
    • доступен только в JS в контексте страницы того же origin.
  • Безопасность:
    • данные доступны любому JS-коду на странице (включая потенциально вредоносный, при XSS).
    • не отправляются автоматически с HTTP-запросами (в отличие от cookie).

Пример базовых операций:

// запись
localStorage.setItem('key', 'value');

// чтение
const v = localStorage.getItem('key');

// удаление
localStorage.removeItem('key');

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

Особенности:

  • Жизненный цикл:
    • живёт только в рамках одной вкладки (browser tab) и одного origin;
    • очищается при закрытии вкладки или окна;
    • при обновлении страницы данные сохраняются;
    • открытие новой вкладки через ссылку/адрес — новое независимое sessionStorage.
  • Изоляция:
    • не шарится между вкладками даже одного домена.
  • Типичные сценарии:
    • временное состояние UI, которое не должно переживать закрытие вкладки:
      • состояние пошаговых форм,
      • временные фильтры/выборы,
      • данные, относящиеся к текущей "сессии просмотра".
    • хранение промежуточных данных, которые не критичны с точки зрения безопасности.
  1. Local Storage

Особенности:

  • Жизненный цикл:
    • данные хранятся до:
      • явной очистки через JS,
      • очистки пользователем в настройках браузера,
      • очистки браузером в рамках политики хранения.
    • переживает:
      • перезагрузку страницы,
      • закрытие и повторное открытие браузера.
  • Разделение:
    • общее для всех вкладок одного origin.
  • Типичные сценарии:
    • настройки пользователя:
      • тема (dark/light),
      • выбранный язык,
      • layout интерфейса;
    • кэширование несекретных данных:
      • результаты запросов,
      • справочники,
      • флаги "показывать ли подсказки".
    • сохранение state для offline-first.
  1. Вопрос безопасности: можно ли хранить токены?

Технически:

  • Да, можно положить токен в localStorage или sessionStorage.
  • Но это:
    • подвержено XSS: любой JS на странице может прочитать и украсть токен;
    • не имеет флагов httpOnly и Secure, как cookie.

Практически:

  • Для чувствительных токенов (access/refresh, особенно с широкими правами):
    • предпочтительнее httpOnly Secure Cookie с корректной настройкой:
      • защищает от XSS-чтения токена,
      • но требует защиты от CSRF.
  • Local/Session Storage можно использовать:
    • для менее критичных токенов,
    • при наличии жёсткого контроля над XSS,
    • или в сочетании с дополнительными мерами (короткий TTL, привязка к устройству и т.п.).

Сильный ответ на интервью обычно подчёркивает:

  • Web Storage — это:
    • удобный механизм хранения нефинансовых/несверхсекретных данных на клиенте;
    • не транспортный механизм, в отличие от cookie.
  • Различия:
    • Session Storage:
      • живёт в рамках вкладки и сессии просмотра.
    • Local Storage:
      • живёт, пока явно не очищен,
      • общедоступен для всех вкладок одного origin.
  • Риски:
    • не хранить в них высокочувствительные данные без необходимости,
    • помнить об XSS как ключевой угрозе.

Краткая формулировка:

"Оба — браузерные key-value хранилища (строки), привязанные к origin. Session Storage живёт только в текущей вкладке и очищается при её закрытии; Local Storage — персистентен и общий для всех вкладок данного origin до явной очистки. Их используют для сохранения настроек, кеша и временного состояния интерфейса. Для хранения чувствительных токенов нужно осторожно оценивать риски XSS и зачастую предпочтительнее использовать httpOnly cookie."

Вопрос 13. Как работает явное и неявное преобразование типов в JavaScript на примере булевых значений?

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

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

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

В JavaScript приведение типов бывает:

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

На булевых значениях (true/false) и truthy/falsy значениях удобно объяснить оба механизма. Для сильного ответа важно показать понимание:

  • что является truthy/falsy,
  • как работают операторы сравнения, логические операторы, if, ==/===,
  • чем явное преобразование отличается от неявного.
  1. Явное преобразование к Boolean

Это случаи, когда вы прямо говорите: "Сделай из этого булево значение".

Основные способы:

  • Функция-конструктор как преобразователь: Boolean(value)
  • Двойное отрицание: !!value (часто используется на практике, в том числе в продакшн-коде)

Примеры:

Boolean(1)        // true
Boolean(0) // false
Boolean('text') // true
Boolean('') // false
Boolean(null) // false
Boolean(undefined)// false
Boolean([]) // true
Boolean({}) // true

!!'hello' // true
!!'' // false
!!42 // true
!!0 // false

Особо важно помнить:

  • Пустая строка '' → false
  • 0, NaN → false
  • null, undefined → false
  • Всё остальное (включая [], {}, '0', 'false') → true
  1. Неявное преобразование к Boolean

Происходит тогда, когда JavaScript ожидает логическое значение:

  • в условии if, while, for;
  • в тернарном операторе condition ? a : b;
  • в логических операторах && и || (хотя они возвращают не обязательно Boolean, но условие truthiness/falsiness используется для выбора результата).

Примеры:

if ('hello') {
// выполнится, потому что 'hello' -> truthy
}

if ('') {
// не выполнится, '' -> falsy
}

if (0) {
// не выполнится, 0 -> falsy
}

if ([]) {
// выполнится, [] -> truthy
}

То есть внутри if (...) движок делает неявное Boolean(value).

  1. Логические операторы и "ленивость"

Нужно понимать, что:

  • && возвращает первый falsy или последний truthy.
  • || возвращает первый truthy или последний falsy.

Они используют неявное преобразование к Boolean для принятия решения, но возвращают исходное значение.

Примеры:

true && 'OK'      // 'OK'
false && 'OK' // false

0 || 'default' // 'default'
'text' || 'default' // 'text'
  1. Неявное преобразование в других контекстах (важно отличать)

Хотя вопрос про Boolean, на интервью полезно показать системное понимание:

  • В арифметике (+, -, *, /) значения приводятся к числам (кроме + со строками).
  • В конкатенации строк (+, если один операнд — строка) — к строкам.
  • В нестрогом сравнении == — сложные правила приведения типов.

Но ключевое: для булевой логики важны именно правила truthy/falsy.

  1. Разница == и === на фоне преобразований

Кратко:

  • === — строгое сравнение:
    • без неявного преобразования типов;
    • true только если тип и значение совпадают.
1 === '1'   // false
0 === false // false
  • == — нестрогое сравнение:
    • выполняет неявные преобразования типов
    • и поэтому часто даёт "магические" результаты.

Примеры:

1 == '1'    // true    ('1' -> 1)
0 == false // true (false -> 0)
'' == false // true ('' -> 0, false -> 0)
null == undefined // true (особое правило)

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

  • В нормальном коде предпочтительно использовать ===, чтобы избежать неожиданных эффектов неявного приведения.
  1. Типовые вопросы-ловушки по булевым значениям

Примеры, которые стоит уверенно разбирать:

Boolean('false')   // true  (не пустая строка → truthy)
Boolean('0') // true
Boolean([]) // true
Boolean({}) // true

!!null // false
!!NaN // false
!!' ' // true (есть пробел → не пустая строка)

Это показывает, что вы не путаете "семантику строки" с правилами приведения.

  1. Краткая формулировка для интервью

"В JavaScript есть явное приведение к Boolean — через Boolean() или !!value, и неявное — когда значение используется в логическом контексте (if, while, &&, ||, тернарный оператор). Falsy значения: 0, -0, NaN, '', null, undefined, false. Всё остальное — truthy, включая '0', 'false', [], {}. Неявные преобразования используются при проверках условий и логических операциях, поэтому важно чётко помнить правила truthy/falsy и чаще использовать строгое сравнение ===, чтобы избежать неожиданных эффектов автоматического приведения типов."

Вопрос 14. Как работает явное и неявное преобразование типов в JavaScript на примере булевых значений?

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

Ответ собеседника: правильный. Перечисляет значения, приводимые к false (пустая строка, 0, undefined, null, NaN) и значения, приводимые к true (непустая строка, числа, объекты и др.).

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

Ключ уже был раскрыт ранее: важно понимать два аспекта — явное преобразование к Boolean и неявное (в логическом контексте), а также список falsy и truthy значений.

Дополняя и структурируя:

  1. Явное преобразование к Boolean
  • Используем прямо:
Boolean(value)
!!value
  • Falsy (преобразуются в false):

    • false
    • 0, -0
    • NaN
    • '' (пустая строка)
    • null
    • undefined
  • Все остальные значения → true:

    • непустые строки ('0', 'false', ' ')
    • любые объекты ({}, [], функции)
    • любые ненулевые числа.
  1. Неявное преобразование к Boolean

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

  • if (value) {} / while (value) {} / тернарный value ? a : b
  • при работе логических операторов && и || (для выбора ветки).

Примеры:

if ('') {}         // не выполнится
if ('0') {} // выполнится (truthy)
if ([]) {} // выполнится (truthy)
if (0) {} // не выполнится
if (123) {} // выполнится
  1. Логические операторы
  • && и || используют неявное булево значение только для выбора, но возвращают исходные операнды:
'hello' && 42      // 42
'' || 'default' // 'default'
0 || 10 // 10

Это важно для паттернов типа "значение по умолчанию" и условного вычисления.

Кратко:

"Явное приведение — через Boolean() или !!. Неявное — когда значение проверяется в логическом контексте. В JavaScript всего несколько falsy значений (false, 0, -0, NaN, '', null, undefined), всё остальное truthy, включая '0', 'false', [], {}. Понимание этого критично для предсказуемого поведения условий и логических выражений."

Вопрос 15. Что представляет собой тип данных Symbol в JavaScript и как он используется?

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

Ответ собеседника: неполный. Указывает, что это примитивный тип, но не объясняет назначение, свойства и практические сценарии использования.

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

Symbol — это примитивный тип данных в JavaScript, представляющий собой уникальное и неизменяемое значение-идентификатор. Его ключевая идея — возможность создавать такие "ключи", которые гарантированно не пересекутся с другими свойствами объекта, даже если у них одинаковые строки-имена.

Основные свойства Symbol:

  1. Примитивный тип
  • Symbol — один из примитивов наряду с string, number, boolean, null, undefined, bigint.
  • Создаётся через функцию Symbol():
const s1 = Symbol();
const s2 = Symbol();
console.log(s1 === s2); // false — каждый Symbol уникален
  1. Уникальность
  • Даже если задать одинаковое описание (description), значения будут разными:
const s1 = Symbol('userId');
const s2 = Symbol('userId');
console.log(s1 === s2); // false

Описание — только метка для дебага, на логику не влияет.

  1. Использование как ключи свойств

Главное практическое применение — ключи свойств объектов, которые:

  • не конфликтуют с обычными строковыми ключами;
  • по умолчанию не участвуют в большинстве операций перечисления.

Пример:

const ID = Symbol('id');

const user = {
name: 'Alice',
[ID]: 123, // символ как ключ
};

console.log(user[ID]); // 123
console.log(user.id); // undefined
console.log(Object.keys(user)); // ['name'] — символ не виден

Это позволяет:

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

Свойства с ключами-символами:

  • не появляются в:
    • for...in
    • Object.keys()
    • JSON.stringify()
  • но доступны через:
    • Object.getOwnPropertySymbols(obj)
    • Reflect.ownKeys(obj) (возвращает и строки, и символы).

Пример:

const ID = Symbol('id');
const obj = { name: 'Test', [ID]: 42 };

console.log(Object.keys(obj)); // ['name']
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(id)]

Это делает Symbol удобным для "скрытых" метаданных.

  1. Глобальный реестр Symbol

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

const s1 = Symbol.for('session');
const s2 = Symbol.for('session');

console.log(s1 === s2); // true
console.log(Symbol.keyFor(s1)); // 'session'

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

  1. Well-known symbols (встроенные символы языка)

В JavaScript есть набор "well-known symbols", которые позволяют изменять поведение встроенных операций и протоколов. Некоторые важные:

  • Symbol.iterator — задаёт итератор объекта (for...of).
  • Symbol.asyncIterator — итератор для async-итерации.
  • Symbol.toStringTag — настраивает вывод Object.prototype.toString.
  • Symbol.hasInstance — влияет на поведение instanceof.
  • Symbol.toPrimitive — кастомизация приведения к примитиву.
  • Symbol.match, Symbol.replace, Symbol.search, Symbol.split — интеграция с String.prototype.* методами.

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

const collection = {
items: [1, 2, 3],
[Symbol.iterator]() {
let i = 0;
const items = this.items;
return {
next() {
if (i < items.length) {
return { value: items[i++], done: false };
}
return { value: undefined, done: true };
},
};
},
};

for (const v of collection) {
console.log(v); // 1, 2, 3
}
  1. Практические сценарии использования

Где Symbol реально полезен:

  • Инкапсуляция:
    • "частные" или служебные свойства объектов/классов, которые не должны конфликтовать с внешними ключами.
const _state = Symbol('state');

class Store {
constructor() {
this[_state] = { count: 0 };
}

increment() {
this[_state].count++;
}

get value() {
return this[_state].count;
}
}
  • Расширение объектов из внешних библиотек:
    • можно добавить своё поведение, не боясь пересечений с их полями.
  • Настройка протоколов:
    • итераторы, приведение типов, взаимодействие с стандартной библиотекой.
  1. Что важно упомянуть на интервью

Сильный ответ:

  • Чётко говорит, что Symbol — уникальный, неизменяемый примитив.
  • Объясняет использование в качестве безопасных ключей свойств.
  • Упоминает неперечисляемость в стандартных механизмах перебора.
  • Знает про well-known symbols и то, что через них настраивается поведение объектов (итерация, toString, instanceof и т.д.).
  • Понимает, что Symbol не сериализуется привычно через JSON и используется скорее для внутренних контрактов и метаданных, чем для бизнес-данных.

Краткая формулировка:

"Symbol — примитивный тип для создания уникальных идентификаторов, часто используемых как ключи свойств без риска конфликта имён и без появления в стандартных обходах. Кроме того, существует набор встроенных символов (Symbol.iterator и др.), через которые можно интегрироваться с базовыми протоколами языка и переопределять стандартное поведение объектов."

Вопрос 16. Что такое политика одинакового источника (Same-Origin Policy) и как она связана с CORS?

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

Ответ собеседника: неполный. Говорит, что это политика браузера, ограничивающая доступ к данным с других доменов, и что разрешённые адреса настраиваются на сервере, но не поясняет разницу между Same-Origin Policy и CORS и их роли.

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

Политика одинакового источника (Same-Origin Policy, SOP) и CORS — связанные, но разные механизмы. Важно чётко разделять:

  • SOP — базовое правило безопасности в браузерах.
  • CORS — контролируемый протокол, который позволяет ослабить ограничения SOP.
  1. Политика одинакового источника (Same-Origin Policy, SOP)

Same-Origin Policy — это фундаментальное правило безопасности браузера, которое ограничивает доступ скриптов одной веб-страницы к данным другой, если их "origin" различается.

Origin определяется тройкой:

  • схема (протокол): http / https
  • хост (домен): example.com
  • порт: 80, 443, 3000 и т.д.

Два URL считаются одного источника (same-origin), если все три компонента совпадают.

Примеры:

Что ограничивает SOP:

  • JS на странице с origin A:
    • не может свободно читать содержимое ответа от origin B (например, через fetch, XHR), если B не дал явное разрешение;
    • не может читать DOM iframe с другим origin;
    • не может произвольно читать данные из других origin (cookies, localStorage, ответы запросов).

Важно:

  • SOP не запрещает отправлять запросы на другие origin.
    • Браузер может сделать GET/POST куда угодно.
    • Ограничение в том, что JS-код не сможет прочитать ответ, если нет разрешения.
  • SOP — защита от атак типа:
    • "загрузи любую страницу пользовательской сессии и прочитай её содержимое через JS".
  1. Что такое CORS (Cross-Origin Resource Sharing)

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

Ключевой момент:

  • SOP — по умолчанию блокирует чтение cross-origin ответов.
  • CORS — способ сказать браузеру: "этим origin можно доверять, разреши им доступ к ответу".

Как работает CORS (упрощённо):

  • Браузер при cross-origin запросе:
  • Сервер в ответе может добавить, например:
Access-Control-Allow-Origin: https://client.example
Access-Control-Allow-Credentials: true

Если:

  • Origin на стороне клиента совпадает с разрешённым в ответе:
    • браузер отдаёт ответ JS-коду.
  • Если нет:
    • браузер заблокирует доступ к ответу (JS увидит CORS error), хотя запрос технически ушёл.

Preflight-запросы:

  • Для "нестандартных" запросов (нестандартные заголовки, методы, кроме простых GET/POST/HEAD) браузер сначала делает OPTIONS-запрос (preflight).
  • Сервер должен ответить заголовками:
    • Access-Control-Allow-Origin
    • Access-Control-Allow-Methods
    • Access-Control-Allow-Headers
  • Если всё ок — браузер выполняет основной запрос.
  1. Взаимосвязь SOP и CORS

Кратко:

  • SOP — базовый запрет: "JS не может читать данные с чужого origin".
  • CORS — официально стандартизованный способ конфигурируемо ослабить этот запрет на стороне сервера.

Важно:

  • CORS настраивается ТОЛЬКО на сервере (на уровне ответов).
  • Браузер, видя CORS-заголовки, решает:
    • разрешить ли доступ к ответу конкретному фронтенду.
  • JS-код не может "обойти" SOP/CORS; любые "обходы" — через:
    • прокси на сервере,
    • правильную настройку ответа backend-сервиса.
  1. Практический пример (Go + CORS)

Простой пример настройки CORS в Go (ручной вариант):

func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")

// Разрешаем конкретному origin (или делаем валидацию)
if origin == "https://client.example.com" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
}

// Обработка preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}

next.ServeHTTP(w, r)
})
}

На интервью важно показать:

  • Понимание, что:
    • SOP — механизм безопасности браузера,
    • CORS — протокол поверх HTTP, который говорит браузеру "этот фронтенд может читать этот ответ".
  • Знание типичных ошибок:
    • Access-Control-Allow-Origin: * вместе с Allow-Credentials: true — нельзя.
    • CORS не защищает backend; это не firewall. Это правило для браузера.
    • Postman/сервер-сервер запросы SOP/CORS не касаются.
  1. Краткая формулировка для интервью

"Same-Origin Policy — это правило браузера, которое запрещает JavaScript-странице произвольно читать данные с другого origin (другой домен/порт/схема). CORS — это механизм, с помощью которого сервер явно сообщает браузеру, каким origin разрешён доступ к его ресурсам. SOP — базовое ограничение, CORS — контролируемое ослабление этого ограничения через заголовки ответов. Запросы уходят всегда, но без корректных CORS-заголовков браузер не даст JS-коду прочитать ответ."

Вопрос 17. Как обеспечить возможность запросов от веб-приложения к серверу на другом домене с учётом ограничений CORS?

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

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

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

Важно понимать: "обойти CORS" в браузере в лоб нельзя и не нужно. CORS — политика исполнения в браузере, а не баг. Зрелый ответ показывает не хаки, а корректные архитектурные решения.

Ключевые подходы:

  1. Корректная настройка CORS на backend-сервере

Базовый и правильный путь — сервер, к которому ходит фронтенд, должен явно разрешить доступ для нужных origin.

На стороне сервера настраиваются заголовки:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Access-Control-Allow-Credentials (при необходимости)
  • и обработка preflight (OPTIONS)

Пример для Go (минимальный рабочий вариант):

func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")

// Разрешаем конкретный origin
if origin == "https://app.example.com" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}

if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}

next.ServeHTTP(w, r)
})
}

Принципы:

  • Не использовать Access-Control-Allow-Origin: * вместе с Allow-Credentials: true.
  • Явно ограничивать доверенные origin.
  • Не "отключать CORS", а конфигурировать доступ.
  1. Проксирование запросов (backend-for-frontend / dev-proxy)

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

Схема:

  • Браузер → (same-origin запрос к вашему backend) → ваш backend → внешний API.
  • CORS нужен только между браузером и вашим backend (и вы его контролируете).
  • Внешний API для вашего backend — обычный сервер-сервер запрос, на него CORS не распространяется.

Применение:

  • В продакшене: backend-for-frontend (BFF), API Gateway, nginx/Envoy как reverse-proxy.
  • В разработке: dev-сервер с прокси-конфигурацией.

Пример (Go reverse proxy, упрощённо):

func proxyHandler(w http.ResponseWriter, r *http.Request) {
targetURL := "https://external-api.example.com" + r.URL.Path
req, err := http.NewRequest(r.Method, targetURL, r.Body)
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
return
}
req.Header = r.Header.Clone()

resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
return
}
defer resp.Body.Close()

for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}

В dev-инструментах:

  • create-react-app: настройка "proxy" в package.json.
  • Vite/Webpack dev server: proxy-конфигурация.
  • Nginx/Traefik: location/proxy_pass.

Это не "обход" безопасности, а архитектурно корректный паттерн: браузер общается только с доверенным origin, который вы контролируете.

  1. Настройка домена / поддоменов

Если фронтенд и backend находятся под одним origin или согласованной схемой доменов, CORS не нужен.

Варианты:

  • Раздавать SPA и API с одного origin:
  • Или использовать один домен и разные пути через reverse proxy:
    • Nginx/Envoy маршрутизирует /api на backend, / на фронтенд.

Если необходимо использование поддоменов:

  • Можно использовать тот же домен и разные поддомены через корректную конфигурацию:
    • С точки зрения SOP origin всё равно будет отличаться — тут либо CORS, либо прокси.
  1. JSONP, iframe и прочие "хаки" (как НЕ надо)

JSONP, iframe-постсообщения и подобные техники — исторические способы работы до стандартизации CORS. В современном продакшене:

  • JSONP считается устаревшим и небезопасным.
  • postMessage + iframe — спецкейс, не общий способ обхода CORS.

На сильном собеседовании важно:

  • прямо сказать, что это не является нормальным решением для API во внутренних системах.
  1. Важно понимать границы CORS
  • CORS — механизм только браузера.
  • Сервер-сервер запросы, curl, Postman не подчиняются CORS: они получают ответ независимо от заголовков.
  • "Обойти CORS на фронте" невозможно легитимно:
    • если сервер не разрешил доступ и нет своего backend-прокси — браузер заблокирует доступ к ответу.
  • Настройка CORS — ответственность backend-команд и API gateway.
  1. Краткая формулировка для интервью

"Корректный способ обеспечить запросы на другой домен — не 'обходить CORS', а настроить его. Сервер должен вернуть правильные заголовки Access-Control-Allow-* для нужных origin, методов и заголовков. Если изменять внешний сервер нельзя, используем прокси/Backend-for-Frontend: фронт ходит на свой origin, а наш backend пересылает запросы к внешнему API. Также можно сводить фронт и backend под один origin через reverse proxy. CORS — политика браузера, она не отключается на фронте, только конфигурируется на сервере и через архитектуру."

Вопрос 18. В чём разница между реальным DOM и виртуальным DOM и как обрабатываются изменения?

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

Ответ собеседника: неполный. Описывает виртуальный DOM как лёгкую копию реального, упоминает сравнение и частичную перерисовку в React, но не раскрывает механизм диффинга, батчинг обновлений и причины эффективности.

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

Реальный DOM и виртуальный DOM — это разные уровни представления интерфейса. Важно понимать не только определение, но и как именно виртуальный DOM помогает оптимизировать обновления и почему он вообще нужен.

  1. Реальный DOM (Browser DOM)
  • DOM (Document Object Model) — дерево объектов, отражающее структуру HTML-документа.
  • Каждый узел DOM — объект с массой свойств и связей (стили, layout, события и т.д.).
  • Операции с DOM:
    • дорогие по производительности:
      • изменение структуры (append/remove),
      • изменение стилей/классов,
      • приводят к перерасчёту стилей, layout, возможному reflow/repaint.
  • Частые прямые изменения DOM из JS (особенно в разных частях дерева) могут приводить к:
    • дерганию интерфейса,
    • избыточным перерисовкам,
    • проблемам с производительностью.
  1. Виртуальный DOM (Virtual DOM)
  • Виртуальный DOM — это абстрактное, легковесное представление UI в памяти:
    • дерево обычных JS-объектов, описывающих структуру (тип узла, props, children).
  • Основная идея:
    • мы работаем не напрямую с живым DOM браузера,
    • а описываем, "каким должен быть UI" в виде виртуального дерева.
    • библиотека (React, Preact и др.) сама решает, как эффективно применить изменения к реальному DOM.

Пример виртуального DOM-представления (концептуально):

const vdom = {
type: 'ul',
props: {},
children: [
{ type: 'li', props: { className: 'item' }, children: ['Item 1'] },
{ type: 'li', props: { className: 'item' }, children: ['Item 2'] },
],
};

Это не настоящий DOM, а структура данных, с которой легко работать в JS.

  1. Как работает обновление с Virtual DOM (на примере React-подхода)

Общий алгоритм:

  • Шаг 1. Рендер нового виртуального дерева
    • После изменения состояния/props React вызывает render и строит новое виртуальное дерево.
  • Шаг 2. Diff (сравнение)
    • Новое виртуальное дерево сравнивается с предыдущим:
      • вычисляется список минимальных изменений (patches), необходимых для приведения реального DOM к новому состоянию.
  • Шаг 3. Применение патчей к реальному DOM
    • Все найденные изменения применяются пакетно (batched), с минимальным числом реальных DOM-операций.

Ключевые принципы диффинга:

  • Сравнение по дереву:
    • если тип узла (tag/component) не изменился — обновляются только пропсы/атрибуты;
    • если изменился — узел пересоздаётся целиком.
  • Для списков:
    • используются ключи (key), чтобы понять, какие элементы были добавлены/удалены/перемещены без пересоздания всех.
  • Алгоритм оптимизирован:
    • не делает полный O(n^3) diff,
    • использует эвристики: в большинстве случаев достаточно O(n).

Пример (упрощённо):

Было:

<ul>
<li key="1">A</li>
<li key="2">B</li>
</ul>

Стало:

<ul>
<li key="2">B</li>
<li key="3">C</li>
</ul>

React по key понимает:

  • элемент с key="2" остаётся (можно переиспользовать DOM-узел),
  • элемент с key="1" удалён,
  • элемент с key="3" добавлен, а не перерисовывает весь список.
  1. Батчинг (batching) обновлений

Для эффективности изменения состояния группируются:

  • В одном цикле event loop или внутри транзакции рендера:

    • несколько вызовов setState/useState не приводят к множественным перерисовкам.
  • Библиотека:

    • аккумулирует изменения,
    • один раз считает новый виртуальный DOM,
    • один раз применяет diff к реальному DOM.

Это:

  • уменьшает количество layout/reflow,
  • повышает производительность.
  1. Почему Virtual DOM эффективен

Важно понимать trade-off:

  • Операции с JS-объектами (виртуальное дерево, diff) дешевле, чем частые прямые операции с реальным DOM.
  • Virtual DOM:
    • позволяет декларативно описывать UI как функцию от состояния,
    • библиотека берёт на себя оптимизацию "как именно" обновить DOM.
  • Однако:
    • Virtual DOM не "магически ускоряет всё" — он добавляет свой runtime-оверход,
    • но даёт предсказуемую модель и оптимизации по сравнению с хаотичным ручным DOM-манипулированием.
  1. Важно разграничить
  • Реальный DOM:
    • предоставляется браузером,
    • сложные, тяжёлые объекты,
    • дорого обновлять часто и по чуть-чуть.
  • Виртуальный DOM:
    • структура в памяти,
    • живёт в JS-рантайме,
    • используется фреймворками для вычисления минимального набора изменений.
  1. Краткая формулировка для интервью

"Реальный DOM — это дерево объектов браузера, обновление которого дорого. Виртуальный DOM — это лёгкое JS-представление этого дерева. Фреймворк при изменении состояния строит новое виртуальное дерево, дифферентует его с предыдущим и применяет только необходимые изменения к реальному DOM, обычно батча их в один проход. Это уменьшает количество прямых операций с DOM и даёт более предсказуемую и эффективную модель обновления интерфейса, особенно при сложных состояниях и частых обновлениях."

Вопрос 19. Почему использование виртуального DOM даёт выигрыш по производительности?

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

Ответ собеседника: неполный. Говорит, что виртуальный DOM быстрее, потому что легковесный, но не объясняет, как именно уменьшается количество операций с реальным DOM и за счёт чего достигается оптимизация.

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

Виртуальный DOM сам по себе не "магическая быстрая штука". Он даёт выигрыш за счёт:

  • снижения количества прямых операций с реальным DOM;
  • батчинга (группировки) изменений;
  • применения оптимизированного diff-алгоритма для вычисления минимального набора обновлений.

Ключевые моменты, которые важно показать.

  1. Операции с реальным DOM дорогие

Обновление реального DOM в браузере:

  • может приводить к:
    • перерасчёту стилей (recalculate style),
    • перерасчёту раскладки (layout/reflow),
    • перерисовке элементов (repaint),
  • особенно дорого, если изменения затрагивают высоко в дереве или часто дергают layout.

Если в "ручном" подходе код часто и бессистемно:

  • добавляет/удаляет элементы,
  • меняет стили по одному,
  • триггерит reflow в циклах,

это приводит к ощутимым проблемам производительности.

  1. Виртуальный DOM как буфер изменений

Virtual DOM — это слой абстракции:

  • вместо того чтобы при каждом изменении состояния сразу лезть в DOM,
  • фреймворк:
    • строит новое виртуальное представление UI (дерево JS-объектов),
    • сравнивает его с прошлым виртуальным деревом (diff),
    • вычисляет минимальный список патчей для реального DOM.

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

  • большинство работы (diff, вычисление) происходит в JS-памяти,
  • реальные DOM-операции выполняются только по итогам анализа,
  • тем самым уменьшается их количество и устраняется "дёргание" верстки.
  1. Diff-алгоритм и минимизация DOM-операций

Современные фреймворки используют оптимизированные эвристики:

  • Сравнивают деревья сверху вниз:
    • если тип узла не изменился — обновляют только props/атрибуты;
    • если изменился — заменяют узел.
  • Для списков используют ключи (key):
    • чтобы переиспользовать DOM-элементы при перестановках,
    • не пересоздавать весь список заново.

Вместо наивного:

  • "Очистить контейнер и заново отрисовать всё"

получаем:

  • "Обновить текст этого узла,
  • добавить один элемент в конец,
  • удалить один элемент из середины"

— что существенно дешевле для DOM и рендеринга.

  1. Батчинг (группировка обновлений)

Фреймворки на базе виртуального DOM:

  • не делают re-render при каждом setState/useState по отдельности;
  • они:
    • копят изменения в рамках одного tick/event loop или транзакции,
    • один раз пересчитывают виртуальное дерево,
    • один раз применяют diff.

В результате:

  • вместо десятков/сотен последовательных DOM-апдейтов за короткий промежуток
  • получается один согласованный набор изменений.

Это критично для:

  • сложных форм,
  • списков,
  • анимаций и частых апдейтов.
  1. Контролируемость и детерминированность

Virtual DOM:

  • задаёт декларативную модель: "UI = f(state)".
  • Фреймворк сам решает, как эффективно привести DOM в соответствие с новым состоянием.

Это:

  • уменьшает риск случайных лишних DOM-операций,
  • облегчает оптимизации внутри фреймворка без изменения прикладного кода,
  • позволяет внедрять дополнительные оптимизации:
    • мемоизация компонентов,
    • пропуск рендера при неизменившихся props/state,
    • concurrent rendering и приоритизация (в новых версиях React и аналогах).
  1. Важно понимать ограничения

Сильный ответ должен отметить:

  • Virtual DOM не всегда быстрее ручного, идеально оптимизированного DOM-кода.
  • Но:
    • в реальных больших приложениях ручная оптимизация сложна и дорогая,
    • Virtual DOM даёт "хорошую по умолчанию" производительность и предсказуемость.
  • В современных подходах (React 18+, Solid, Svelte) есть альтернативы (компиляция, fine-grained reactivity), но принцип остаётся:
    • минимизировать и упорядочивать реальные DOM-операции.

Краткая формулировка:

"Выигрыш от виртуального DOM не в том, что он 'сам по себе быстрее DOM', а в том, что он позволяет:

  • вычислять изменения в памяти, а не через реальные DOM-манипуляции,
  • применять только минимально необходимый набор операций к реальному DOM,
  • батчить обновления и избегать лишних reflow/repaint.

Это радикально снижает количество тяжёлых DOM-операций и даёт стабильную производительность при сложных и часто меняющихся интерфейсах."

Вопрос 20. Для чего используются ref в React и где их целесообразно применять?

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

Ответ собеседника: правильный. Говорит, что ref используют для прямого доступа к DOM-элементам и для хранения значений без перерисовки; приводит пример с инпутами.

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

Ref в React — это способ получить "ссылку" на:

  • конкретный DOM-элемент,
  • или экземпляр компонента/значение,

в обход обычного реактивного цикла "props → state → render". Важно понимать, что ref:

  • не участвуют в механизме рендера (изменение ref не вызывает перерисовку),
  • используются для императивных операций и побочных эффектов.

Ключевые сценарии использования.

  1. Доступ к DOM-элементам

Основной и самый частый кейс:

  • Фокус на инпут:
  • Прокрутка до элемента (scrollIntoView).
  • Измерение размеров/позиции элемента.
  • Работа с canvas, video, сложными third-party виджетами.

Пример:

import { useRef, useEffect } from 'react';

function SearchInput() {
const inputRef = useRef(null);

useEffect(() => {
inputRef.current?.focus();
}, []);

return <input ref={inputRef} placeholder="Search..." />;
}

Здесь ref используется императивно, но контролируемо, только в эффектах.

  1. Хранение изменяемого значения без триггера рендера

useRef в функциональных компонентах даёт "контейнер", который:

  • сохраняет значение между рендерами,
  • изменение ref.current не вызывает новый render.

Типичные применения:

  • счетчики, таймеры, id-шники,
  • ссылка на текущий запрос/подписку,
  • сохранение предыдущих значений для сравнения,
  • флаг "смонтирован ли компонент" и т.п.

Пример:

function Timer() {
const intervalId = useRef(null);
const count = useRef(0);

useEffect(() => {
intervalId.current = setInterval(() => {
count.current += 1;
console.log('tick', count.current);
}, 1000);

return () => clearInterval(intervalId.current);
}, []);

return <div>См. лог в консоли</div>;
}

Здесь:

  • count хранится в ref, обновляется каждую секунду,
  • компонент не перерисовывается, но логика работает.
  1. Управление внешними библиотеками и императивным кодом

Ref нужен, когда:

  • интегрируем сторонние виджеты (map, chart, slider), которые ожидают "сырые" DOM-элементы,
  • используем низкоуровневые API (Canvas, WebGL),
  • оборачиваем не-React код в React-компоненты.

Пример:

function ChartWrapper({ data }) {
const containerRef = useRef(null);

useEffect(() => {
const el = containerRef.current;
const chart = createChart(el, data); // внешняя библиотека

return () => chart.destroy();
}, [data]);

return <div ref={containerRef} />;
}
  1. Императивные хэндлы между компонентами (forwardRef, useImperativeHandle)

Ref можно пробрасывать в дочерние компоненты через forwardRef и управлять императивным API компонента:

const Input = forwardRef((props, ref) => {
const inputRef = useRef(null);

useImperativeHandle(ref, () => ({
focus() {
inputRef.current?.focus();
}
}));

return <input ref={inputRef} {...props} />;
});

// Использование:
function Form() {
const inputRef = useRef(null);

return (
<>
<Input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>
Focus input
</button>
</>
);
}

Это даёт контролируемый "императивный интерфейс", не ломая инкапсуляцию компонента.

  1. Когда ref лучше не использовать

Важно показать зрелость и ограничения:

  • Не использовать ref вместо состояния там, где нужен ререндер UI.
    • Если изменение важно для отображения — это state, а не ref.
  • Не использовать ref для обхода однонаправленного потока данных React.
    • Не строить архитектуру на "мутируем всё через ref".
  • Ref — инструмент точечной императивной логики и интеграции, а не основа бизнес-логики.

Краткая формулировка:

"Ref в React используется, когда нужно императивно обратиться к DOM-элементу или хранить изменяемое значение между рендерами без перерисовки. Это фокус, scroll, измерения, интеграция с внешними библиотеками, таймеры, ссылки на текущие операции. Изменения ref не триггерят рендер, поэтому для отображения данных по-прежнему используем state, а ref — для побочных эффектов и низкоуровневого контроля."

Вопрос 21. Что такое компонент высшего порядка (Higher-Order Component) в React?

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

Ответ собеседника: правильный. Определяет как обёртку над компонентом, добавляющую функциональность; приводит примеры с React.memo и forwardRef.

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

Компонент высшего порядка (Higher-Order Component, HOC) — это паттерн в React, при котором функция:

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

Формальное определение:

HOC: (Component) => EnhancedComponent

Ключевые идеи:

  • Это аналог "функций высшего порядка" для компонентов.
  • Используется для:
    • повторного использования логики,
    • инжекции props,
    • обёртывания в кросс-срезы: логирование, авторизация, данные, обработка ошибок и т.п.
  • Исходный компонент остаётся декларативным и "чистым", HOC берёт на себя инфраструктурную часть.
  1. Базовый пример HOC

Допустим, нужно логировать монтирование и размонтирование разных компонентов.

function withMountLog(WrappedComponent) {
return function WithMountLog(props) {
useEffect(() => {
console.log(`Mounted: ${WrappedComponent.name || 'Component'}`);
return () => {
console.log(`Unmounted: ${WrappedComponent.name || 'Component'}`);
};
}, []);

return <WrappedComponent {...props} />;
};
}

Использование:

function UserList(props) {
return <div>{/* ... */}</div>;
}

const UserListWithLog = withMountLog(UserList);

HOC:

  • оборачивает исходный компонент,
  • добавляет побочный эффект,
  • не меняет реализацию UserList.
  1. Инъекция данных и поведения

Классический сценарий HOC до появления хуков:

  • подключение к Redux (connect),
  • работа с данными (запросы, подписки),
  • авторизационные проверки.

Пример (упрощённый):

function withAuth(WrappedComponent) {
return function WithAuth(props) {
const isAuth = useIsAuthenticated(); // кастомный хук

if (!isAuth) {
return <div>Access denied</div>;
}

return <WrappedComponent {...props} />;
};
}

Такой HOC можно применить к разным защищённым компонентам.

  1. React.memo и forwardRef как частные примеры функций высшего порядка
  • React.memo(Component):
    • принимает компонент,
    • возвращает новый компонент, оптимизированный по сравнению props,
    • формально соответствует паттерну HOC.
  • React.forwardRef(renderFn):
    • принимает функцию рендера,
    • возвращает компонент, умеющий прокидывать ref.
  • Они технически реализуют идею HOC (функция → новый компонент), но обычно рассматриваются как встроенные вспомогательные API, а не "бизнес-HOC".
  1. Важные практические моменты

При реализации HOC стоит учитывать:

  • Корректная передача props:
    • всегда прокидывать {...props} в WrappedComponent.
  • Отладка и DevTools:
    • полезно задавать displayName:
function withSomething(WrappedComponent) {
function Enhanced(props) {
return <WrappedComponent {...props} />;
}

Enhanced.displayName = `withSomething(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
return Enhanced;
}
  • Не мутировать исходный компонент:
    • не добавлять к нему поля снаружи (антипаттерн),
    • HOC должен быть чистой функцией.
  1. Современный контекст: HOC vs Hooks

Сейчас основной рекомендованный способ переиспользования логики — хуки (custom hooks):

  • HOC всё еще валидный паттерн,
  • но custom hooks:
    • проще по композиции,
    • не создают вложенных деревьев компонентов,
    • лучше для читаемости и типизации.

Сильный ответ показывает:

  • понимание HOC как архитектурного паттерна,
  • отсутствие путаницы: HOC — это не просто "компонент-обёртка", а именно функция, принимающая компонент и возвращающая новый,
  • понимание, что React.memo/forwardRef концептуально являются функциями высшего порядка,
  • осознание, что в современном коде многие случаи использования HOC заменены хуками, но знание паттерна остаётся важным.

Вопрос 22. Как работает React Router и как он управляет отображением страниц?

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

Ответ собеседника: неполный. Говорит, что приложение оборачивается в Router, настраиваются роуты и по пути отрисовывается соответствующий компонент; не раскрывает работу с history API, типы роутинга, механизм сопоставления и перерисовки.

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

React Router реализует клиентский роутинг: управление отображением компонентов в зависимости от URL без полной перезагрузки страницы. Важно понимать:

  • какие типы роутеров есть,
  • как работает сопоставление (matching),
  • как используется History API,
  • как достигается декларативность и предсказуемость.
  1. Базовая идея

В SPA (Single Page Application):

  • Браузер загружает один HTML-файл и JS-бандл.
  • При переходах по "страницам" меняется URL, но:
    • страница целиком не перезагружается,
    • вместо этого React Router:
      • отслеживает изменения адреса,
      • сопоставляет текущий путь с конфигурацией маршрутов,
      • рендерит соответствующие компоненты.

Это даёт:

  • быстрые переходы,
  • контроль логики на клиенте,
  • возможность тонко управлять состоянием, анимациями, правами доступа.
  1. Типы роутеров

Основные реализации в React Router:

  • BrowserRouter
    • Использует History API браузера: pushState, replaceState, popstate.
    • Даёт "красивые" URL вида /users/123.
    • Требует корректной серверной конфигурации:
      • сервер должен для всех маршрутов отдавать index.html, а не 404.
  • HashRouter
    • Использует часть URL после # (hash): /#/users/123.
    • Не требует настройки сервера:
      • всё после # не уходит на сервер.
    • Используется в простых или legacy-сценариях.
  • MemoryRouter
    • Хранит историю в памяти JS, без привязки к реальному URL.
    • Удобен для тестов, встраиваемых виджетов.

Пример базовой конфигурации:

import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/users" element={<UsersList />} />
<Route path="/users/:id" element={<UserDetails />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
  1. Как работает сопоставление маршрутов

React Router:

  • следит за текущим location (pathname, search, hash),
  • для каждого Route проверяет:
    • паттерн path,
    • параметры (например, :id),
    • вложенность маршрутов,
  • выбирает подходящий маршрут и рендерит соответствующий element.

Особенности:

  • Поддержка динамических параметров:
    • /users/:iduseParams() вернёт { id: '123' }.
  • Поддержка вложенных маршрутов:
    • маршруты можно вкладывать, формируя иерархию layout'ов.

Пример вложенных маршрутов:

<Route path="/users" element={<UsersLayout />}>
<Route index element={<UsersList />} />
<Route path=":id" element={<UserDetails />} />
</Route>

Внутри UsersLayout используется <Outlet />, куда подставляется подходящий дочерний маршрут.

  1. Использование History API

Для BrowserRouter:

  • При навигации через <Link> или navigate():
    • вместо перезагрузки страницы вызывается history.pushState или history.replaceState,
    • обновляется location в контексте React Router,
    • срабатывает перерендер, сопоставляется новый маршрут, рендерится другая "страница".
  • При нажатии кнопок "назад"/"вперёд" в браузере:
    • срабатывает событие popstate,
    • React Router реагирует, обновляя отображаемый компонент.

Переходы:

import { Link, useNavigate } from 'react-router-dom';

function Menu() {
const navigate = useNavigate();

return (
<nav>
<Link to="/users">Users</Link>
<button onClick={() => navigate('/users/123')}>
Go to user 123
</button>
</nav>
);
}
  1. Управление отображением, состояние и переходы

Ключевые моменты:

  • Маршруты декларативны:
    • конфиг из <Routes>/<Route> или объектной схемы определяет, что и где рендерить.
  • Компоненты получают доступ к:
    • useParams() — параметры URL,
    • useLocation() — полный объект location (pathname, search),
    • useSearchParams() — query-параметры,
    • useNavigate() — императивная навигация.
  • Это позволяет:
    • строить страницы с динамическими id,
    • реагировать на изменения query-параметров,
    • реализовывать guarded routes (требуют авторизации), редиректы и т.д.

Пример защищённого маршрута:

function RequireAuth({ children }) {
const isAuth = useIsAuthenticated();
const navigate = useNavigate();

if (!isAuth) {
return <Navigate to="/login" replace />;
}

return children;
}

// Использование:
<Route
path="/dashboard"
element={
<RequireAuth>
<Dashboard />
</RequireAuth>
}
/>
  1. Как это ложится на архитектуру

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

  • Router:
    • не делает реальных HTTP-редиректов для внутренних переходов;
    • управляет UI-транзишнами на клиенте.
  • Сервер:
    • для BrowserRouter:
      • должен уметь обслуживать все маршруты SPA одной точкой входа (fallback на index.html).
  • React Router:
    • не занимается загрузкой данных сам по себе (до v6.4),
    • с v6.4+ добавил data routers (loader/action), но принцип:
      • URL → конфиг → компоненты/данные остаётся.
  1. Краткая формулировка для интервью

"React Router реализует клиентский роутинг: приложение оборачивается в Router (чаще BrowserRouter), который используя History API отслеживает изменения URL и без перезагрузки страницы выбирает и рендерит соответствующие компоненты по конфигурации маршрутов. Маршруты могут быть вложенными, поддерживают динамические параметры, работу с query, защищённые маршруты. BrowserRouter использует pushState/replaceState и popstate, HashRouter — хэш-часть URL. Ключевая идея — декларативное сопоставление URL с компонентами и управление навигацией на клиенте."

Вопрос 23. Какие типы компонентов в React можно выделить по наличию состояния и роли, и что такое «глупые» компоненты?

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

Ответ собеседника: неполный. Упоминает идею презентационных и контейнерных компонентов и определяет «глупый» компонент как простой, без логики, получающий данные через пропсы, но не даёт структурированной классификации и акцентов.

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

В современном React формально все компоненты — функциональные (hooks) или классовые (legacy), но архитектурно полезно разделять их по роли и ответственности. Сильный ответ показывает не только термины, но и связь с принципами разделения ответственности, переиспользуемости и тестируемости.

Основные оси классификации:

  1. По наличию состояния и логики:
  • компоненты без состояния (presentational / dumb),
  • компоненты с состоянием и логикой (container / smart).
  1. По уровню ответственности:
  • презентационные (UI-компоненты),
  • контейнерные (управляют данными и поведением),
  • layout-компоненты,
  • переиспользуемые "building blocks" (inputs, buttons, widgets).

Рассмотрим ключевые типы.

  1. Презентационные («глупые») компоненты

Суть:

  • Отвечают только за отображение.
  • Не принимают бизнес-решений, не знают "откуда взялись" данные.
  • Получают всё через props:
    • данные,
    • колбэки для действий (onClick, onChange).
  • Могут иметь минимальное локальное состояние для UI (например, раскрыт/свёрнут), но не хранят доменную логику.

Признаки «глупого» компонента:

  • Детально контролируемый через props.
  • Легко переиспользовать в разных местах.
  • Легко тестировать: подставили разные props — проверили вывод.
  • Не тянет за собой запросы к API, работу с router, глобальные сторы.

Пример:

function UserCard({ name, email, onSelect }) {
return (
<div className="user-card" onClick={onSelect}>
<div>{name}</div>
<div>{email}</div>
</div>
);
}

Это «глупый» компонент:

  • не грузит пользователей,
  • не решает, что значит select,
  • просто отображает и вызывает переданный обработчик.
  1. Контейнерные («умные») компоненты

Суть:

  • Отвечают за:
    • получение данных (API-запросы, запросы к глобальному состоянию),
    • управление состоянием,
    • бизнес-логику (фильтрация, валидацию, side effects),
    • маршрутизацию, авторизацию на уровне интерфейса.
  • Передают данные и колбэки вниз в презентационные компоненты.

Пример:

function UsersContainer() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers)
.finally(() => setLoading(false));
}, []);

const handleSelect = (user) => {
console.log('Selected user', user.id);
};

if (loading) {
return <div>Loading...</div>;
}

return users.map(user => (
<UserCard
key={user.id}
name={user.name}
email={user.email}
onSelect={() => handleSelect(user)}
/>
));
}

Здесь:

  • UsersContainer — "умный", знает про API, loading, обработку.
  • UserCard — "глупый".
  1. Компоненты с локальным состоянием (stateful) vs без состояния (stateless)

Исторически:

  • Stateless components:
    • функциональные без собственного состояния (до хуков).
  • Stateful:
    • классовые компоненты с this.state, lifecycle.

Сейчас с хуками:

  • Любой функциональный компонент может быть:
    • чистым (без useState/useEffect) — по сути презентационный,
    • stateful — хранить локальное состояние и эффекты.

Важно не путать:

  • "без состояния" ≠ "бессмысленный".
  • Презентационный компонент может иметь локальное UI-состояние (открыт dropdown, активная вкладка), но не доменную бизнес-логику.
  1. Дополнительные архитектурные категории

Для зрелой архитектуры полезно явно выделять:

  • Layout-компоненты:
    • отвечают за разметку и композицию:
      • Page, SidebarLayout, Grid, Section.
  • Shared UI components:
    • кнопки, инпуты, модалки, таблицы,
    • переиспользуемые, стилизованные, без бизнес-логики.
  • Domain-specific компоненты:
    • UserForm, OrderList, ProductCard,
    • могут быть связаны с доменной моделью, но всё ещё желательно разделять:
      • где "как рисуем" vs "как получаем данные".
  1. Почему разделение на "умные"/"глупые" полезно

Это не догма, а инженерный приём:

  • Улучшает переиспользуемость:
    • «глупые» компоненты можно использовать в разных экранах.
  • Упрощает тестирование:
    • "умные" тестируем логикой,
    • "глупые" — снапшотами/рендером.
  • Снижает связность:
    • бизнес-логика не размазана по всем уровням UI.
  • Облегчает миграции и рефакторинг:
    • можно менять источник данных/стор/API, не трогая верстку.
  1. Связь с современным стеком

В современных проектах:

  • Многие задачи HOC/«контейнеров» перенесены в:
    • кастомные хуки (useUsers, useAuth),
    • context providers.
  • Но концептуально разделение остаётся:
    • хуки и контейнеры — источник данных,
    • презентационные компоненты — потребители через props.

Краткая формулировка:

"Условно выделяют презентационные («глупые») компоненты, которые отвечают только за отображение и получают всё через props, и контейнерные («умные»), которые работают с данными, состоянием, API, роутингом и передают результат вниз. Такое разделение уменьшает связность, повышает переиспользуемость и упрощает тестирование. Локальное UI-состояние допустимо и в «глупых» компонентах; важно, чтобы бизнес-логика и источники данных были сконцентрированы в выделенных слоях."

Вопрос 24. В чём суть паттернов MVC и Pub/Sub и какой из них используется в React?

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

Ответ собеседника: неправильный. Лишь расшифровывает Pub/Sub как publisher-subscriber и признаётся, что не готов ответить; не объясняет MVC, не сравнивает подходы и не связывает их с React.

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

Вопрос проверяет понимание архитектурных паттернов и умение сопоставить их с современными фронтенд-фреймворками. Важно:

  • чётко описать MVC;
  • чётко описать Pub/Sub;
  • показать, что React напрямую не является ни классическим MVC, ни "чистым Pub/Sub", но использует их идеи и хорошо ложится в унидирекциональные потоки данных.

Разберём по пунктам.

  1. Паттерн MVC (Model–View–Controller)

Классический MVC разделяет ответственность на три компонента:

  • Model:
    • бизнес-логика и данные,
    • правила, валидация, доступ к хранилищу.
  • View:
    • отображение данных пользователю,
    • подписано на изменения модели (в классических GUI-фреймворках).
  • Controller:
    • принимает ввод пользователя (клики, формы, события),
    • интерпретирует его,
    • вызывает изменения в модели,
    • выбирает, какую View показать.

Ключевые идеи:

  • Разделение ответственности: UI, бизнес-логика, обработка ввода — отдельно.
  • Меньше связности, легче сопровождать.
  • Изначально применялся в серверных фреймворках и десктопных UI:
    • ASP.NET MVC, Ruby on Rails, ранние GUI-фреймворки.

Проблемы прямого применения MVC в сложном фронтенде:

  • При множестве двусторонних связей View ↔ Model легко получить "спагетти" из событий и обновлений.
  • Масштабируемость страдает, когда много состояний и сложные зависимости.
  1. Паттерн Pub/Sub (Publisher–Subscriber)

Pub/Sub — это паттерн взаимодействия компонентов через события:

  • Publisher (издатель):
    • генерирует события (messages), но не знает, кто их слушает.
  • Subscriber (подписчик):
    • подписывается на интересующие события и реагирует.
  • Между ними:
    • брокер/шина/event bus, который управляет подписками и доставкой.

Ключевые свойства:

  • Слабая связность:
    • издатель не знает о подписчиках.
  • Гибкость:
    • легко добавить новых подписчиков.
  • Используется:
    • в UI-событиях, шинах данных,
    • в микросервисах (message broker),
    • во внутренних евент-системах.

Минусы при злоупотреблении:

  • Трудно отслеживать поток данных ("кто на что подписан"),
  • сложнее дебажить,
  • при большом числе событий можно получить непредсказуемые цепочки.
  1. Как это связано с React

React сам по себе:

  • не является реализацией MVC;
  • не навязывает Pub/Sub напрямую;
  • реализует декларативный view-слой с однонаправленным потоком данных (one-way data flow).

Основные принципы React:

  • View как функция от state/props:
    • UI = f(state).
  • Данные текут сверху вниз:
    • от родителя к дочерним компонентам через props.
  • Изменения инициируются событиями:
    • пользовательский ввод → обработчик → изменение состояния → новый рендер.

Что ближе по сути:

  • React больше похож на "V" из MVC:
    • он отвечает за отображение и частично обработку событий.
  • Логику модели и контроллеров обычно выносят:
    • в state-management (Redux, MobX, Zustand, RTK Query),
    • в сервисы/хуки,
    • в отдельный слой.
  1. Где здесь Pub/Sub в контексте React

Идеи Pub/Sub встречаются:

  • В событиях DOM и synthetic events React:
    • компонент "подписывается" на onClick/onChange; браузер/React выступает как диспетчер событий.
  • В менеджерах состояния:
    • Redux:
      • store выступает как источник истины;
      • компоненты «подписываются» на изменения части состояния;
      • dispatch (publish) → reducers → новое состояние → уведомление подписчиков.
    • Context API:
      • компоненты-потребители подписаны на контекст,
      • изменение значения контекста нотифицирует подписчиков.
  • Во внешних событиях:
    • WebSocket-сообщения, EventEmitter, абстракции поверх Pub/Sub.

То есть:

  • React-экосистема активно использует идею Pub/Sub:
    • централизованное состояние,
    • подписчики-компоненты.
  1. Как корректно ответить на вопрос "какой используется в React?"

Сильный, точный ответ:

  • React сам по себе — это прежде всего view-слой с однонаправленным потоком данных.

  • Он:

    • не реализует "чистый" MVC (нет явного Controller/Model в самом React),
    • но хорошо сочетается с архитектурами поверх него (Flux, Redux), где используется:
      • один источник истины (Store),
      • действия (actions),
      • подписки компонентов на изменения состояния → это уже сильно похоже на Pub/Sub + унидирекциональный поток.

Краткая, но точная формулировка:

  • MVC:
    • разделение на Model/View/Controller,
    • исторически для серверных и десктопных приложений.
  • Pub/Sub:
    • издатели шлют события,
    • подписчики слушают, слабая связность.
  • React:
    • это декларативный View с однонаправленным потоком данных.
    • Внутри экосистемы (Redux, Context) используются механизмы, близкие к Pub/Sub:
      • компоненты подписаны на изменения стора или контекста.
    • Поэтому корректнее говорить, что React-приложения строятся вокруг унидирекционального data flow (Flux-подобная архитектура), а не классического MVC, и активно используют идеи подписки на изменения, характерные для Pub/Sub.

Такой ответ показывает, что вы:

  • знаете базовую теорию архитектурных паттернов,
  • понимаете, что React — это не просто "MVC-фреймворк",
  • умеете связать паттерны с практикой: Flux/Redux ≈ Pub/Sub + детерминированное обновление состояния.

Вопрос 25. Как работает сборщик мусора в JavaScript и по какому принципу освобождается память?

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

Ответ собеседника: неполный. Говорит, что сборщик мусора автоматически удаляет объекты и функции, на которые нет ссылок; идея верная, но без деталей алгоритмов и особенностей.

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

Сборка мусора в JavaScript основана на идее достижимости (reachability), а не на простом подсчёте ссылок, и реализуется через семейство алгоритмов "mark-and-sweep" с оптимизациями под реальные нагрузки. Важно уметь объяснить концептуально, без привязки к конкретной реализации одного движка, но с корректной терминологией.

Ключевые моменты:

  1. Что именно управляется GC в JavaScript

JavaScript — язык с автоматическим управлением памятью:

  • Разработчик:
    • не освобождает память вручную (нет free, delete для объектов).
  • Сборщик мусора:
    • отвечает за освобождение памяти, занимаемой объектами, которые больше не могут быть использованы программой.

GC управляет:

  • объектами,
  • массивами,
  • функциями и замыканиями,
  • вложенными структурами в heap.

Примитивы (number, boolean, string и т.п.) обычно живут в стек/heap в зависимости от контекста, но GC их тоже обрабатывает как часть общих структур.

  1. Базовый принцип: достижимость (reachability)

Современные реализации (V8, SpiderMonkey и др.) используют модель:

"Объект считается 'живым', если доступен по цепочке ссылок от корней (roots). Остальное — мусор."

Корни (roots), от которых начинается обход:

  • глобальный объект (window в браузере, global в Node.js),
  • текущий стек вызовов (локальные переменные, параметры функций),
  • замыкания,
  • зарегистрированные колбэки, event handlers и т.п.

Алгоритм на концептуальном уровне:

  • Старт: есть множество корней.
  • Mark (пометка):
    • рекурсивно обходим все объекты, достижимые от корней по ссылкам,
    • помечаем их как "живые".
  • Sweep (очистка):
    • все непомеченные объекты считаются недостижимыми,
    • их память освобождается.

Пример:

function createUser() {
const user = { name: 'Alice', meta: { age: 30 } };
return user;
}

let u = createUser(); // объект достижим через переменную u
u = null; // ссылка убрана
// Объект { name, meta } и вложенный meta становятся недостижимыми → GC может их собрать
  1. Почему не "подсчёт ссылок"

Наивный reference counting ломается на циклических ссылках:

function createCycle() {
const a = {};
const b = {};
a.b = b;
b.a = a;
return { a, b };
}

let cycle = createCycle();
cycle = null;

Хотя a и b ссылаются друг на друга, от корней они больше недостижимы, значит:

  • по reachability — это мусор,
  • по простому подсчёту ссылок — нет (были бы "вечные утечки").

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

  1. Поколенческий GC и оптимизации

Реальные движки используют продвинутые техники:

  • Generational GC:
    • объекты делятся на "молодые" и "старые" поколения.
    • предположение: большинство объектов "живут недолго".
    • молодые собираются чаще и дешевле,
    • старые — реже.
  • Incremental, concurrent, parallel GC:
    • сборка разбивается на маленькие шаги,
    • часть работы выполняется параллельно/фоново,
    • чтобы минимизировать "стоп-мир" паузы и лаги UI.
  • Write barriers и card marking:
    • оптимизации для отслеживания изменений в старых/молодых объектах.

На собеседовании важно:

  • не уходить в детали реализации V8, если не спрашивают,
  • но показать понимание, что GC не всегда "моментальный" и не бесплатный.
  1. Утечки памяти в JavaScript: откуда берутся, если есть GC

Автоматический GC не защищает от логических утечек:

Объект не будет собран, если:

  • на него всё ещё есть достижимая ссылка,
  • даже если по смыслу он "не нужен".

Типичные источники:

  • Глобальные переменные.
  • Замыкания, держащие ссылки на тяжёлые объекты.
  • Неочищенные обработчики событий (event listeners).
  • Кеши, карты, массивы, в которые добавляют, но не удаляют элементы.
  • Держание ссылок в структурах типа Map/Set, когда забыли удалить.

Пример проблемного кода:

const cache = [];

function addToCache(obj) {
cache.push(obj);
}

// Если никогда не очищать cache, объекты останутся достижимыми → GC их не соберёт.

Важно: GC не "угадывает", что вам "кажется, объект уже не нужен"; он смотрит только на граф достижимости.

  1. Практические рекомендации

Для зрелого ответа стоит упомянуть:

  • Избегать неявных глобальных переменных.
  • Очищать таймеры и подписки:
useEffect(() => {
const id = setInterval(...);
return () => clearInterval(id);
}, []);
  • Отключать обработчики событий при размонтировании.
  • Осторожно с долгоживущими структурами (кеши, синглтоны).
  • Использовать WeakMap / WeakSet / WeakRef там, где нужно хранить "слабые" ссылки, не мешающие сборке:
const metaStore = new WeakMap();

function track(obj, meta) {
metaStore.set(obj, meta);
}
// Когда obj становится недостижим, данные в WeakMap тоже могут быть собраны.
  1. Краткая формулировка для интервью

"В JavaScript сборка мусора основана на достижимости: движок периодически строит граф объектов от корней (глобальные объекты, стек, замыкания), помечает все достижимые, а остальные освобождает. Это вариации mark-and-sweep с поколенческими и инкрементальными оптимизациями. Циклические ссылки не проблема, если вся компонента недостижима. Утечки памяти возникают не из-за отсутствия GC, а из-за того, что мы продолжаем держать ссылки на ненужные объекты — например, в глобальном состоянии, кешах или незакрытых подписках."

Вопрос 26. В чём различия между обычной функцией и стрелочной функцией в JavaScript?

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

Ответ собеседника: правильный. Указывает на различия: разные синтаксисы, hoisting для function declaration, отсутствие у стрелочных собственного this и arguments, лексический this.

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

Различия между обычными (function declaration / function expression) и стрелочными функциями носят не только синтаксический, но и семантический характер. Важно понимать их для корректного поведения this, работы с методами объектов, коллбэками и при использовании в классах и замыканиях.

Ключевые отличия.

  1. Синтаксис
  • Обычная функция:
function sum(a, b) {
return a + b;
}

const mul = function (a, b) {
return a * b;
};
  • Стрелочная функция:
const sum = (a, b) => a + b;
const toObj = (x) => ({ value: x });

Стрелочный синтаксис короче и читаем для коллбэков и простых выражений.

  1. Hoisting
  • Function Declaration:
foo(); // работает

function foo() {
console.log('ok');
}

Декларации функций поднимаются (hoisted) целиком — можно вызывать до определения.

  • Function Expression и стрелочные:
bar(); // TypeError: bar is not a function

const bar = () => {};

Переменная поднимается, но до инициализации имеет значение undefined. Вызов до присваивания приводит к ошибке.

  1. this (главное отличие)
  • Обычная функция:
    • имеет динамический this, который зависит от способа вызова:
      • как метод объекта,
      • через call/apply/bind,
      • как конструктор (через new),
      • как простой вызов (в нестрогом режиме — window / global, в строгом — undefined).

Пример:

const obj = {
x: 42,
getX() {
return this.x;
}
};

obj.getX(); // this === obj → 42
  • Стрелочная функция:
    • не имеет собственного this;
    • захватывает (лексически) this из внешней области видимости в момент определения.

Пример:

const obj = {
x: 42,
getX: () => {
return this.x;
},
};

obj.getX(); // this не obj, а внешний (например, window/undefined) → не то, что ожидаем

Правильный вариант с обычной функцией:

const obj = {
x: 42,
getX() {
return this.x;
},
};

В React-классах, обработчиках событий и замыканиях стрелочные функции удобны тем, что не теряют внешний this.

  1. arguments
  • Обычная функция:
    • имеет псевдомассив arguments (во всех нестрелочных функциях).
function f() {
console.log(arguments);
}
  • Стрелочная функция:
    • не имеет своего arguments;
    • если обратиться к arguments внутри неё, будет взят из внешней функции (если есть),
    • вместо arguments используют rest-параметры:
const f = (...args) => {
console.log(args);
};
  1. Использование с new
  • Обычные функции (function declaration/expression):
    • могут выступать как конструкторы (если вызываются через new).
function User(name) {
this.name = name;
}
const u = new User('Alice');
  • Стрелочные функции:
    • не могут быть конструкторами;
    • при попытке new (() => {}) будет ошибка TypeError.
  1. prototype
  • У обычных функций:

    • есть свойство prototype (используется при конструировании).
  • У стрелочных функций:

    • прототипа для конструктора нет (prototype не для создания экземпляров).
  1. Подходящие сценарии

Когда использовать обычные функции:

  • Методы объектов и классов, где нужен свой this, зависящий от объекта.
  • Функции-конструкторы (если вообще нужны; в современном коде чаще классы).
  • Места, где требуется собственный arguments или контроль над this через call/apply/bind.

Когда использовать стрелочные функции:

  • Коллбэки (Array.map/filter/reduce, промисы, обработчики внутри других функций).
  • Вложенные функции, которым нужен лексический this из внешнего контекста.
  • В функциональных компонентах/хуках и современном декларативном коде.

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

function Timer() {
this.seconds = 0;

setInterval(() => {
this.seconds++; // this берётся из Timer, не теряется
}, 1000);
}

Если бы использовали обычную функцию в setInterval, this внутри коллбэка не указывал бы на экземпляр Timer без явного bind.

Краткая формулировка:

"Стрелочные функции отличаются не только синтаксисом. У них нет собственного this, arguments, prototype и они не могут быть конструкторами. this и arguments в стрелке лексически наследуются из внешнего контекста. Обычные функции имеют динамический this, поддерживают hoisting (для деклараций), могут использоваться как конструкторы. Стрелочные удобны для коллбэков и замыканий, обычные — для методов, конструкторов и случаев, когда важно управлять контекстом вызова."

Вопрос 27. Как работает мемоизация в React с использованием useMemo и useCallback и для чего она нужна?

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

Ответ собеседника: неполный. Говорит, что React запоминает колбэки и значения при неизменных зависимостях, отличает useMemo для данных и useCallback для функций, но неточно формулирует ключевую цель — оптимизацию производительности и снижение лишних ререндеров/вычислений.

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

Мемоизация в React через useMemo и useCallback — это инструмент оптимизации. Цель не "экономить память", а:

  • уменьшить количество лишних вычислений,
  • стабилизировать ссылки (reference equality) между рендерами,
  • избежать ненужных ререндеров дочерних компонентов и перезапуска эффектов.

Важно применять эти хуки осознанно, а не "по всему коду".

  1. Базовые определения
  • useMemo:
    • мемоизирует результат вычисления (значение).
    • Сигнатура: const memoizedValue = useMemo(fn, deps).
    • fn будет выполнена только при изменении значений из deps.
  • useCallback:
    • мемоизирует функцию.
    • Сигнатура: const memoizedFn = useCallback(fn, deps).
    • Возвращает ту же самую ссылку на функцию между рендерами, пока deps не изменились.
    • По сути: useCallback(fn, deps) эквивалентен useMemo(() => fn, deps).
  1. Зачем это нужно (ключевые сценарии)

Основные проблемы, которые решают useMemo/useCallback:

  • Дорогие вычисления:
    • не хотим пересчитывать сложную функцию на каждый рендер, если входные данные не изменились.
  • Стабильные ссылки:
    • props-функции и объекты передаются в дочерние компоненты;
    • без мемоизации на каждом рендере создаются новые функции/объекты;
    • это ломает оптимизации вида React.memo, useEffect/useLayoutEffect с зависимостями и т.п.

Другими словами:

  • Мемоизация позволяет:
    • не делать работу заново, если входные данные те же,
    • дать React и своим проверкам (React.memo, ===) шанс "увидеть" отсутствие изменений.
  1. useMemo: мемоизация значений

Применяем, когда:

  • есть вычисление, потенциально дорогое по CPU,
  • либо результат (объект/массив) передаётся в дочерний компонент, зависящий от ссылочного сравнения.

Пример: дорогой расчёт.

function heavyCompute(items) {
// допустим, O(n^2) или сложная агрегация
return items.reduce((acc, x) => acc + x, 0);
}

function Stats({ items }) {
const total = useMemo(() => heavyCompute(items), [items]);

return <div>Total: {total}</div>;
}

Без useMemo:

  • heavyCompute вызывается при каждом рендере Stats,
  • даже если items не изменился по ссылке.

С useMemo:

  • heavyCompute вызывается только при изменении deps (items),
  • между рендерами возвращается закэшированное значение.

Пример: стабилизация объекта для дочернего компонента:

const Filters = React.memo(function Filters({ config }) {
// Ререндер только если config изменилась по ссылке
return <div>{config.query}</div>;
});

function Page({ query }) {
const config = useMemo(() => ({ query, limit: 10 }), [query]);

return <Filters config={config} />;
}

Без useMemo:

  • { query, limit: 10 } создаётся заново каждый рендер → Filters ререндерится всегда.
  1. useCallback: мемоизация функций

Основной кейс:

  • передаём колбэк в дочерний компонент (часто мемоизированный через React.memo),
  • без useCallback на каждом рендере создаётся новая функция → дочерний компонент видит новые props и ререндерится.

Пример:

const ListItem = React.memo(function ListItem({ item, onSelect }) {
// Ререндерится только если item или onSelect изменились по ссылке
return <li onClick={() => onSelect(item.id)}>{item.label}</li>;
});

function List({ items, onSelectItem }) {
const handleSelect = useCallback(
(id) => {
onSelectItem(id);
},
[onSelectItem], // зависит от пропса, при его изменении создаётся новая функция
);

return (
<ul>
{items.map((item) => (
<ListItem key={item.id} item={item} onSelect={handleSelect} />
))}
</ul>
);
}

Без useCallback:

  • handleSelect новая на каждый рендер;
  • React.memo у ListItem не помогает, потому что onSelect всегда "новый".

С useCallback:

  • пока onSelectItem не меняется, handleSelect стабильный,
  • ListItem не ререндерится без необходимости.
  1. Связь с React.memo и эффектами

Мемоизация особенно важна в связке:

  • React.memo:
    • мемоизирует результат рендера компонента, сравнивая props по ссылке.
    • useMemo/useCallback помогают обеспечить стабильные props.
  • useEffect/useLayoutEffect:
    • зависят от ссылок.
    • Если передать "сырую" функцию/объект без useCallback/useMemo, эффект будет срабатывать каждый рендер, даже без логических изменений.

Пример:

function Component({ value }) {
const handler = useCallback(() => {
console.log(value);
}, [value]);

useEffect(() => {
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, [handler]); // с useCallback handler стабилен

return null;
}

Без useCallback:

  • на каждом рендере новый handler,
  • эффект постоянно пересоздаёт обработчик.
  1. Когда не стоит использовать useMemo/useCallback

Сильный ответ обязательно отмечает:

  • Мемоизация — не бесплатна:
    • хранение значения,
    • сравнение зависимостей,
    • вызов функции мемоизации.
  • Если вычисление дешёвое и компонент небольшой:
    • useMemo/useCallback могут только усложнить код и даже ухудшить производительность.
  • Правило:
    • оптимизируем там, где есть реальная проблема или вероятность её появления:
      • большие списки,
      • тяжёлые вычисления,
      • глубокое дерево компонент,
      • сложные зависимости.
  1. Краткая формулировка

"Мемоизация в React через useMemo и useCallback используется для оптимизации. useMemo кеширует результат вычисления до тех пор, пока не изменятся зависимости, полезен для тяжёлых вычислений и стабильных объектов, передающихся вниз по дереву. useCallback кеширует саму функцию, чтобы её ссылка не менялась между рендерами без изменения зависимостей — это помогает React.memo-компонентам и эффектам не срабатывать лишний раз. Цель — уменьшить ненужные пересчёты и рендеры, а не просто 'запомнить значение'. Использовать эти хуки стоит точечно и осознанно."

Вопрос 28. Что даёт использование мемоизации и каких проблем в работе приложения она позволяет избежать?

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

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

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

Мемоизация — это оптимизация, направленная не на экономию памяти, а прежде всего на:

  • сокращение количества повторных вычислений;
  • сокращение количества лишних перерисовок компонентов;
  • стабилизацию ссылок на функции и объекты, от которых зависят дочерние компоненты и эффекты.

В контексте React (и в целом фронтенда/бэкенда) она помогает избежать нескольких типов проблем.

Основные эффекты мемоизации:

  1. Предотвращение лишних тяжёлых вычислений

Если не мемоизировать результат дорогостоящей операции:

  • она будет выполняться на каждом рендере или вызове, даже если входные данные не изменились;
  • это приводит к:
    • избыточной загрузке CPU,
    • росту времени ответа/рендера,
    • фризам UI при больших объёмах данных.

Мемоизация позволяет:

  • один раз вычислить результат для конкретных входных параметров;
  • при повторном использовании тех же параметров вернуть кешированное значение.

Пример (React, useMemo):

function heavyCalc(data) {
// имитация тяжёлой логики
for (let i = 0; i < 1e7; i++) {}
return data.reduce((acc, x) => acc + x, 0);
}

function Stats({ data }) {
const total = useMemo(() => heavyCalc(data), [data]);
return <div>Total: {total}</div>;
}

Без useMemo heavyCalc будет выполняться при каждом рендере Stats, даже если data не меняется.

  1. Снижение количества лишних перерисовок (React.memo + стабильные ссылки)

В React одна из ключевых практических выгод:

  • Если каждый рендер создаёт новые объекты и функции (новые ссылки), то:
    • дочерние компоненты, завязанные на ссылочное сравнение (React.memo), будут считать props изменившимися,
    • эффекты с зависимостями будут срабатывать повторно.

Мемоизация:

  • стабилизирует ссылки (useMemo/useCallback),
  • позволяет React.memo и хукам сравнивать значения по ===,
  • предотвращает каскадные рендеры по дереву.

Пример:

const Row = React.memo(function Row({ item, onSelect }) {
// Ререндерится только если item или onSelect изменились по ссылке
return <div onClick={() => onSelect(item.id)}>{item.name}</div>;
});

function List({ items, onSelectItem }) {
const handleSelect = useCallback(
(id) => onSelectItem(id),
[onSelectItem],
);

return items.map((item) => (
<Row key={item.id} item={item} onSelect={handleSelect} />
));
}

Здесь:

  • useCallback гарантирует стабильный onSelect;
  • React.memo у Row предотвращает ререндер, если item не изменился;
  • без мемоизации Row мог бы бессмысленно перерисовываться сотни/тысячи раз.
  1. Снижение ненужных побочных эффектов

Если зависимости эффектов (useEffect/useLayoutEffect) включают:

  • функции,
  • объекты,
  • массивы,

которые создаются заново без мемоизации, эффект будет:

  • срабатывать при каждом рендере,
  • отписываться/подписываться снова,
  • дергать API, вешать обработчики и т.д.

Мемоизация:

  • удерживает стабильные зависимости,
  • предотвращает лишние side-effects (сетевые запросы, подписки, таймеры).

Пример:

function Component({ value }) {
const handler = useCallback(() => {
console.log(value);
}, [value]);

useEffect(() => {
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, [handler]);

return null;
}

Без useCallback handler был бы каждый раз новым → лишние remove/add слушателей.

  1. В backend/алгоритмическом контексте

Вне React мемоизация:

  • уменьшает асимптотические/фактические затраты на повторяющиеся вычисления:

Пример (Go):

type FibCache struct {
m map[int]int
}

func NewFibCache() *FibCache {
return &FibCache{m: make(map[int]int)}
}

func (c *FibCache) Fib(n int) int {
if n <= 1 {
return n
}
if v, ok := c.m[n]; ok {
return v
}
v := c.Fib(n-1) + c.Fib(n-2)
c.m[n] = v
return v
}
  • Без мемоизации рекурсивный Fib — экспоненциальный,
  • с мемоизацией — линейный.

Та же идея: не считать одно и то же многократно.

  1. Чего мемоизация НЕ делает сама по себе

Важно явно проговорить, чтобы не повторять ошибку:

  • Мемоизация не "экономит память" — наоборот, тратит память под кеш:
    • это trade-off: память в обмен на скорость.
  • Она не нужна "везде":
    • накладные расходы на хранение и сравнение deps могут быть больше, чем выигрыш.
  • Её задача — оптимизация производительности и предсказуемости поведения, а не решение логических ошибок.

Краткая формулировка:

"Мемоизация позволяет:

  • не пересчитывать тяжёлые вещи при тех же входных данных;
  • стабилизировать ссылки на функции и объекты;
  • уменьшить лишние рендеры компонентов и перезапуски эффектов.

Это уменьшает нагрузку на CPU и улучшает отзывчивость приложения. Её цель — оптимизация вычислений и рендера, а не сокращение использования памяти. Использовать стоит точечно, там где есть реальные или ожидаемые проблемы с производительностью."

Вопрос 29. Что такое React Portals и для каких задач они применяются?

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

Ответ собеседника: правильный. Объясняет, что портал позволяет рендерить элемент вне основного DOM-дерева, приводит пример с модальными окнами и попапами.

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

React Portals — механизм, который позволяет рендерить дочерние элементы в другой DOM-узел, отличающийся от того, где находится корневой React-компонент, при этом:

  • с точки зрения React-иерархии компонент остаётся частью родителя;
  • с точки зрения DOM он расположен в другом месте документа.

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

  1. Как это работает

Стандартный React-рендер:

  • всё приложение монтируется, например, в:
<div id="root"></div>
  • все компоненты в итоге рендерятся внутрь этого контейнера.

С Portal:

  • мы можем отрендерить часть дерева в отдельный DOM-узел, например:
<div id="root"></div>
<div id="modal-root"></div>

Код:

import ReactDOM from 'react-dom';

function Modal({ children }) {
const modalRoot = document.getElementById('modal-root');
return ReactDOM.createPortal(children, modalRoot);
}

Использование:

function App() {
const [open, setOpen] = useState(false);

return (
<>
<button onClick={() => setOpen(true)}>Open modal</button>
{open && (
<Modal>
<div className="modal">
<h2>Title</h2>
<button onClick={() => setOpen(false)}>Close</button>
</div>
</Modal>
)}
</>
);
}

Здесь:

  • С точки зрения React:
    • Modal и содержимое модалки — дети App.
  • С точки зрения DOM:
    • разметка модалки попадёт внутрь #modal-root, вне #root.
  1. Почему Portals полезны

Основные сценарии:

  • Модальные окна, диалоги.
  • Выпадающие списки, контекстные меню, тултипы.
  • Toast-уведомления, оверлеи.
  • Любые элементы, которые:
    • должны визуально перекрывать другие,
    • не должны ломаться из-за overflow/position/z-index родителей,
    • или должны быть вынесены в верхний слой DOM.

Без Portals:

  • Модалка в глубоко вложенном компоненте может:
    • оказаться внутри контейнера с overflow: hidden, position: relative и т.п.,
    • быть "обрезана" или иметь проблемы с z-index.

С Portals:

  • мы рендерим модалку рядом с body (через отдельный контейнер),
  • избавляясь от ограничений layout'а родительских контейнеров.
  1. Важный момент: события и контекст

Хотя DOM-узел портала физически находится в другом месте:

  • React-события (onClick, onKeyDown и т.д.) продолжают всплывать по дереву React-компонентов, а не по DOM-иерархии.
  • Контекст (Context API), состояние и пропсы работают так, как если бы портал был обычным потомком.

Это критично:

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

Пример:

function Parent() {
const [count, setCount] = useState(0);

return (
<>
<button onClick={() => setCount(c => c + 1)}>Inc</button>
<Modal>
<div onClick={() => setCount(c => c + 1)}>
Count from portal: {count}
</div>
</Modal>
</>
);
}
  • Клик в портале меняет состояние Parent,
  • хотя DOM-потомок физически в другом месте.
  1. Практические рекомендации
  • Создавайте отдельные контейнеры:
    • #modal-root, #tooltip-root, #overlay-root.
  • Управляйте фокусом и доступностью (a11y):
    • при модалках:
      • ловите Esc,
      • возвращайте фокус обратно,
      • используйте aria-атрибуты.
  • Не злоупотребляйте порталами:
    • они нужны тогда, когда действительно мешают ограничения layout/stacking context,
    • не для "любого удобства".

Краткая формулировка:

"React Portals позволяют рендерить часть компонента в произвольный DOM-узел вне основного корня приложения, сохраняя при этом связь с родительским React-деревом (контекст, стейт, обработчики). Это используется для модалок, попапов, тултипов и оверлеев, чтобы избежать проблем с вложенностью, overflow и z-index, не ломая архитектуру компонентов."

Вопрос 30. Что такое синтетические события в контексте React?

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

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

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

Синтетические события (Synthetic Events) в React — это абстракция над нативными событиями браузера. React не передаёт в обработчики "сырые" объекты событий DOM, а оборачивает их в кросс-браузерный унифицированный объект SyntheticEvent с единым интерфейсом и собственной системой обработки.

Ключевые моменты, которые нужно понимать.

  1. Зачем React использует SyntheticEvent

Основные цели:

  • Единое поведение во всех поддерживаемых браузерах:
    • убираются различия в API и свойствах нативных событий.
  • Оптимизация и управление жизненным циклом событий:
    • централизованный event delegation,
    • контроль за подписками,
    • возможность внутренних оптимизаций.

То есть SyntheticEvent — это "нормализованный" объект события плюс прослойка над системой событий.

  1. Event Delegation (делегирование событий)

В React (особенно до React 17):

  • обработчики событий "логически" вешаются на элементы (onClick, onChange и т.д.),
  • фактически React регистрирует один или несколько слушателей на корневом контейнере (например, document или root-элемент),
  • события всплывают до верхнего уровня, где React:
    • перехватывает нативное событие,
    • создает SyntheticEvent,
    • запускает соответствующие обработчики для компонентов.

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

  • Меньше реальных обработчиков в DOM → лучше производительность.
  • Централизованная обработка → проще кросс-браузерная логика.
  • Проще управлять подписками при монтировании/размонтировании компонентов.

В новых версиях (React 17+) стратегия привязки несколько изменилась, но принцип synthetic events и делегирования сохраняется.

  1. Свойства SyntheticEvent

SyntheticEvent:

  • имеет интерфейс, похожий на нативное событие:
    • event.target
    • event.currentTarget
    • event.type
    • event.preventDefault()
    • event.stopPropagation()
    • и т.д.
  • работает одинаково во всех браузерах.

Важно:

  • SyntheticEvent — не "подделка", он обёрнут вокруг реального события и предоставляет унифицированный API.
  1. Пуллинг (event pooling) и его последствия

В старых версиях React (до 17):

  • SyntheticEvent использовал пуллинг:
    • после вызова обработчика объект события "рециклировался",
    • его свойства обнулялись для повторного использования,
    • это экономило аллокации, но создавало распространённую ловушку.

Классическая проблема:

function handleClick(e) {
setTimeout(() => {
console.log(e.target); // в старых версиях React: e будет уже обнулен
}, 0);
}

Решение было:

  • либо вызывать e.persist() для исключения события из пула,
  • либо сохранять нужные значения в отдельные переменные:
function handleClick(e) {
const { target } = e;
setTimeout(() => {
console.log(target);
}, 0);
}

В современных версиях React пуллинг выключен по умолчанию, но понимание этой истории показывает глубину знания.

  1. Отличия от нативных событий

Главные отличия, которые стоит уметь назвать:

  • Синтетические события:
    • управляются React,
    • работают поверх единого механизма, обеспечивая единый контракт.
  • Нативные события:
    • напрямую из DOM (addEventListener),
    • могут иметь разные особенности в разных браузерах.

При необходимости всегда можно:

  • обратиться к нативному событию через event.nativeEvent,
  • или использовать addEventListener вручную на DOM-элементе (например, через ref), если нужно поведение вне системы React.
  1. Почему это важно на практике

Понимание Synthetic Events помогает:

  • предсказуемо работать с обработчиками:
    • onClick, onChange, onKeyDown и т.п. в JSX — это не прямой addEventListener.
  • правильно обрабатывать события в асинхронном коде:
    • не полагаться на "вечную живучесть" объекта события в старых версиях,
    • не удивляться, почему события ведут себя одинаково в разных браузерах.
  • диагностировать баги:
    • отличать поведение React-событий от нативных (например, в интеграции с сторонними библиотеками).

Краткая формулировка для интервью:

"В React синтетические события — это обёртка над нативными событиями DOM. React использует их для кросс-браузерной совместимости и централизованного делегирования событий. Обработчики в JSX получают SyntheticEvent с единым API, а React управляет подписками и жизненным циклом этих объектов. При необходимости можно обратиться к nativeEvent, но по умолчанию мы работаем именно с синтетическими событиями, а не напрямую с DOM-событиями."

Вопрос 31. Каковы преимущества использования Redux для управления состоянием приложения?

Таймкод: 00:28:41

Ответ собеседника: правильный. Отмечает неизменяемость состояния, предсказуемый поток данных через actions и reducers и избавление от глубокой передачи пропсов за счёт централизованного стора.

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

Redux решает классическую проблему растущих фронтенд-приложений: когда состояние размазано по компонентам, сложно отслеживать, откуда пришли данные, кто их меняет и почему всё внезапно пересчитывается. Его ценность не в "модной библиотеке", а в четкой архитектурной модели.

Ключевые преимущества.

  1. Единый источник истины (Single Source of Truth)
  • Всё глобальное состояние приложения хранится в одном store (или в логически разделённых слайсах внутри него).
  • Это упрощает:
    • понимание текущего состояния,
    • отладку,
    • интеграцию с инструментами разработчика.

Практический эффект:

  • легко ответить на вопрос "что сейчас происходит с состоянием приложения и почему оно такое".
  1. Предсказуемость и детерминированность

Основная формула:

  • состояние нового шага = reducer(состояние, действие)

Ридьюсеры — чистые функции:

  • зависят только от входных данных (state, action),
  • не имеют побочных эффектов,
  • не мутируют исходный state, а возвращают новый.

Это дает:

  • детерминированное поведение:
    • при одинаковой последовательности action-ов состояние всегда одинаковое;
  • возможность:
    • тайм-тревел дебага,
    • реплея багов,
    • unit-тестирования логики состояния без привязки к UI.
  1. Неизменяемость состояния (immutability)

Redux поощряет/требует:

  • не мутировать state,
  • создавать новые объекты при изменениях.

Плюсы:

  • простое определение "что изменилось":
    • shallow-compare по ссылкам (===) достаточно, чтобы понять, нужно ли перерендерить компонент;
  • отсутствие скрытых побочных эффектов:
    • нельзя "тихо" изменить объект, на который ссылаются разные части приложения;
  • предсказуемость ререндеров, хорошая интеграция с React.memo.

Современный Redux Toolkit:

  • использует Immer под капотом:
    • можно писать "как будто мутация",
    • фактически создаётся новый immutable state.

Пример:

const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment(state) {
state.value += 1; // выглядит как мутация, но под капотом immutable
},
},
});
  1. Централизованный поток данных (однонаправленный data flow)

Поток:

  • UI инициирует действие → dispatch(action)
  • middleware (опционально) обрабатывают сайд-эффекты / логирование / API
  • reducers обновляют store
  • подписанные компоненты получают новое состояние и перерисовываются.

Это:

  • устраняет хаос двусторонних биндингов,
  • делает явными все изменения состояния,
  • заставляет описывать, "что произошло" (action) вместо "сделай вот это непонятно как".
  1. Отсутствие prop drilling для глобальных данных

Без централизованного состояния:

  • общие данные приходится "проталкивать" через цепочки компонентов:
    • родитель → дети → внуки → и т.д.
  • это:
    • шум в пропсах,
    • высокая связность компонентов.

С Redux:

  • компонент читает только те части store, которые ему нужны (через useSelector / connect),
  • не зависит от всей иерархии над ним.

Это особенно полезно для:

  • авторизации/пользователя,
  • настроек,
  • данных, используемых во многих частях приложения.
  1. Богатая экосистема и инструменты

Redux даёт не только паттерн, но и инфраструктуру:

  • Redux DevTools:
    • просмотр истории action-ов,
    • состояние до/после,
    • тайм-тревел,
    • экспорт/импорт сессий.
  • Middleware:
    • логирование (redux-logger),
    • асинхронщина (redux-thunk, redux-saga, redux-observable),
    • централизованная обработка ошибок, метрик, трейсинга.
  • Redux Toolkit:
    • снижает шаблонный код,
    • делает конфигурацию стандартизированной и безопасной,
    • внедряет лучшие практики по умолчанию.

Эти инструменты важны в крупных продуктивных системах, особенно когда:

  • много разработчиков,
  • сложная бизнес-логика,
  • нужна трассируемость действий.
  1. Применимость и здравый смысл

Зрелый ответ должен отметить:

  • Redux имеет смысл:
    • в средних и больших приложениях,
    • когда есть сложное общее состояние и долгоживущие данные,
    • когда нужна наблюдаемость и предсказуемость.
  • В небольших проектах:
    • можно обойтись локальным state и Context или легкими менеджерами.
  • Важно:
    • не превращать Redux в "божественный объект" для всего,
    • держать slice-ы изолированными,
    • выносить побочные эффекты (запросы к API, кеширование, ретраи) в специализированные слои (например, RTK Query).

Краткая формулировка:

"Redux даёт централизованный, предсказуемый и детерминированный способ управления состоянием: единый стор, неизменяемый state, чистые редьюсеры и явные actions. Это упрощает отладку, тестирование, отслеживание изменений, устраняет prop drilling для общих данных и позволяет использовать мощные инструменты и middleware. Его сила особенно проявляется в сложных приложениях с большим количеством состояний и участников разработки."

Вопрос 32. Как тестировать Redux-логику и управлять асинхронными запросами в этом контексте?

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

Ответ собеседника: неполный. Уходит в общие преимущества Redux, не раскрывая конкретные подходы к тестированию редьюсеров, сайд-эффектов и организации асинхронных операций.

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

Сильная сторона Redux — то, что его легко тестировать и формализованно управлять асинхронностью. Ключевая идея: отделить чистую бизнес-логику от побочных эффектов (I/O, HTTP, таймеры и т.д.) и тестировать каждый уровень изолированно.

Разобьём на три части:

  • тестирование редьюсеров;
  • тестирование селекторов;
  • управление асинхронностью: thunk / saga / RTK Query и их тесты.
  1. Тестирование редьюсеров

Редьюсер в Redux — чистая функция вида:

stateNext = reducer(statePrev, action)

Особенности:

  • не зависит от внешнего окружения (network, DOM и т.п.);
  • при одинаковых входах даёт одинаковый выход;
  • не мутирует исходный state.

Поэтому редьюсер тестируется как обычная функция.

Пример (JS):

// counterSlice.js с Redux Toolkit
import { createSlice } from '@reduxjs/toolkit';

const slice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
inc(state) {
state.value += 1;
},
add(state, action) {
state.value += action.payload;
},
},
});

export const { inc, add } = slice.actions;
export default slice.reducer;

Тест:

import reducer, { inc, add } from './counterSlice';

test('inc increments value', () => {
const initial = { value: 0 };
const result = reducer(initial, inc());
expect(result).toEqual({ value: 1 });
});

test('add adds payload', () => {
const initial = { value: 10 };
const result = reducer(initial, add(5));
expect(result.value).toBe(15);
});

Ключевой момент: никакого мокинга стора не нужно; мы тестируем бизнес-логику в изоляции.

  1. Тестирование селекторов

Selectors:

  • функции, которые берут state и возвращают производные данные.
  • могут быть простыми или мемоизированными (reselect).

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

  • концентрируют правила выборки и агрегации в одном месте;
  • отлично тестируются как чистые функции.

Пример:

export const selectCompletedTodos = (state) =>
state.todos.items.filter(t => t.completed);

Тест:

import { selectCompletedTodos } from './selectors';

test('selectCompletedTodos returns only completed', () => {
const state = {
todos: {
items: [
{ id: 1, completed: true },
{ id: 2, completed: false },
],
},
};

expect(selectCompletedTodos(state)).toEqual([{ id: 1, completed: true }]);
});

Это особенно важно в больших приложениях: селекторы становятся контрактом между state-слоем и UI.

  1. Управление асинхронностью: общие принципы

Асинхронные операции (HTTP-запросы, таймеры, WebSocket, сложные сайд-эффекты) не должны:

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

Правильный подход:

  • асинхронность выносится в отдельный слой:
    • thunk-функции,
    • saga,
    • observable-стримы,
    • или высокоуровневые инструменты вроде RTK Query;
  • этот слой диспатчит обычные actions, которые уже обрабатывают редьюсеры.

Рассмотрим популярные варианты.

  1. Redux Thunk (асинхронность через функции)

Thunk — функция, возвращающая другую функцию:

  • вместо объекта action диспатчится функция,
  • мидлвар thunk перехватывает её и даёт доступ к dispatch и getState,
  • внутри выполняем асинхронный код и диспатчим обычные actions.

Пример thunks c Redux Toolkit:

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

export const fetchUsers = createAsyncThunk(
'users/fetch',
async (_, { rejectWithValue }) => {
try {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed');
return await res.json();
} catch (err) {
return rejectWithValue(err.message);
}
}
);

const usersSlice = createSlice({
name: 'users',
initialState: { items: [], loading: false, error: null },
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || 'Error';
});
},
});

Тестирование:

  • Логику редьюсеров мы уже тестируем как обычно.
  • Для асинхронных thunks есть 2 подхода:
    • тестировать их как функции с замоканным fetch / API,
    • или интеграционно — с тестовым store.

Пример unit-теста thunk без полного стора:

import { fetchUsers } from './usersSlice';

test('fetchUsers success', async () => {
const dispatch = jest.fn();
const getState = () => ({});

global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 1 }],
});

await fetchUsers()(dispatch, getState, undefined);

expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'users/fetch/pending' })
);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'users/fetch/fulfilled',
payload: [{ id: 1 }],
})
);
});

Так мы проверяем, что thunk при успехе диспатчит правильные actions.

  1. Redux-Saga / другие middleware (реактивное управление сайд-эффектами)

Redux-Saga:

  • основан на генераторах;
  • описывает асинхронную логику декларативно:
    • take, call, put, race, all;
  • даёт хорошую композицию и тестируемость за счёт пошагового выполнения генераторов.

Пример:

function* fetchUsersSaga() {
try {
const users = yield call(api.fetchUsers);
yield put({ type: 'users/fetchSuccess', payload: users });
} catch (e) {
yield put({ type: 'users/fetchFailure', payload: e.message });
}
}

Тест:

import { fetchUsersSaga } from './sagas';
import { call, put } from 'redux-saga/effects';
import api from './api';

test('fetchUsersSaga success flow', () => {
const gen = fetchUsersSaga();

expect(gen.next().value).toEqual(call(api.fetchUsers));
const fakeUsers = [{ id: 1 }];
expect(gen.next(fakeUsers).value).toEqual(
put({ type: 'users/fetchSuccess', payload: fakeUsers })
);
expect(gen.next().done).toBe(true);
});

Преимущество:

  • мы тестируем последовательность effect-ов, а не реальные HTTP-вызовы.
  1. RTK Query — высокий уровень для асинхронных запросов

Redux Toolkit Query:

  • надстройка над Redux Toolkit;
  • решает задачи:
    • фетчинг данных,
    • кеширование,
    • рефетчинг,
    • инвалидация,
    • состояния loading/error;
  • минимизирует ручной код thunks/редьюсеров.

Пример:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getUsers: builder.query({
query: () => 'users',
}),
}),
});

export const { useGetUsersQuery } = api;

В компоненте:

function Users() {
const { data, isLoading, error } = useGetUsersQuery();
// ...
}

Тестирование:

  • можно мокать baseQuery или HTTP-слой;
  • бизнес-логика Redux остаётся чистой и узкой.
  1. Итоговая позиция для интервью

Сильный ответ должен показать:

  • Редьюсеры и селекторы:
    • тестируются как чистые функции:
      • простые, надёжные unit-тесты.
  • Асинхронность:
    • выносится в мидлвары (thunk, saga, RTK Query),
    • тестируется:
      • либо через мокинг dispatch/getState,
      • либо через проверку effect-ов (в случае saga),
      • либо через интеграционные тесты стора.
  • Компоненты:
    • в идеале остаются тонкими:
      • подписываются на стор,
      • диспатчат actions,
      • сложная логика вынесена в слой стора/сайд-эффектов.

Краткая формулировка:

"Redux легко тестировать, потому что редьюсеры и селекторы — чистые функции. Асинхронность выносится в отдельный слой (thunks, saga, RTK Query), где мы управляем запросами и побочными эффектами, диспатчим обычные actions и можем тестировать этот код изолированно: либо через проверки dispatch-последовательностей, либо через анализ saga-эффектов. Такой подход делает поведение состояния предсказуемым, тестируемым и хорошо масштабируемым."

Вопрос 32. Как тестировать Redux-логику и управлять асинхронными запросами в этом контексте?

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

Ответ собеседника: неполный. Упоминает санки и middleware для асинхронных операций и DevTools для просмотра действий, но не раскрывает системный подход к модульному тестированию редьюсеров, селекторов и санок, а также архитектуру управления асинхронностью.

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

Тестирование Redux-логики и корректная организация асинхронности строятся на ключевых принципах:

  • редьюсеры и селекторы — чистые функции, тестируются изолированно;
  • побочные эффекты (HTTP, таймеры, WebSocket) выносятся в отдельный слой (thunk, saga, RTK Query и др.);
  • асинхронные операции управляются так, чтобы их можно было детерминированно проверять: через dispatch-последовательности или декларативные эффекты.

Ниже — практический, боевой подход.

  1. Тестирование редьюсеров: как обычных функций

Редьюсер в Redux:

  • не должен иметь побочных эффектов,
  • не должен мутировать state,
  • не должен обращаться к внешним API.

Поэтому он идеально подходит для unit-тестов.

Пример:

// usersSlice.js
import { createSlice } from '@reduxjs/toolkit';

const usersSlice = createSlice({
name: 'users',
initialState: { items: [], loading: false, error: null },
reducers: {
fetchStart(state) {
state.loading = true;
state.error = null;
},
fetchSuccess(state, action) {
state.loading = false;
state.items = action.payload;
},
fetchFailure(state, action) {
state.loading = false;
state.error = action.payload;
},
},
});

export const { fetchStart, fetchSuccess, fetchFailure } = usersSlice.actions;
export default usersSlice.reducer;

Тест:

import reducer, { fetchStart, fetchSuccess, fetchFailure } from './usersSlice';

test('fetchStart sets loading and clears error', () => {
const initial = { items: [], loading: false, error: 'oops' };
const next = reducer(initial, fetchStart());
expect(next).toEqual({ items: [], loading: true, error: null });
});

test('fetchSuccess sets items and disables loading', () => {
const initial = { items: [], loading: true, error: null };
const payload = [{ id: 1 }];
const next = reducer(initial, fetchSuccess(payload));
expect(next.items).toEqual(payload);
expect(next.loading).toBe(false);
});

Это делается быстро, стабильно, без моков стора.

  1. Тестирование селекторов

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

  • Они тоже чистые функции: (state) → value.
  • Их тестирование гарантирует, что UI опирается на корректное API состояния.

Пример:

export const selectUserById = (state, id) =>
state.users.items.find(u => u.id === id);

Тест:

import { selectUserById } from './selectors';

test('selectUserById returns user', () => {
const state = {
users: { items: [{ id: 1 }, { id: 2 }] },
};
expect(selectUserById(state, 2)).toEqual({ id: 2 });
});
  1. Управление асинхронностью через thunk (Redux Thunk / createAsyncThunk)

Асинхронные операции выносятся в thunk-и:

  • thunk — это функция, возвращающая другую функцию с сигнатурой (dispatch, getState, extra) => Promise|void;
  • внутри выполняются:
    • HTTP-запросы,
    • логика ретраев,
    • диспатч последовательности синхронных action-ов.

Пример с Redux Toolkit:

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

export const fetchUsers = createAsyncThunk(
'users/fetch',
async (_, { rejectWithValue }) => {
try {
const res = await fetch('/api/users');
if (!res.ok) {
throw new Error('Request failed');
}
return await res.json();
} catch (err) {
return rejectWithValue(err.message);
}
}
);

const usersSlice = createSlice({
name: 'users',
initialState: { items: [], loading: false, error: null },
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload ?? 'Error';
});
},
});

export default usersSlice.reducer;

Unit-тест thunk-а:

  • Мокаем fetch/API;
  • Проверяем, какие actions были задиспатчены.
import { fetchUsers } from './usersSlice';

test('fetchUsers dispatches pending and fulfilled on success', async () => {
const dispatch = jest.fn();
const getState = () => ({});

global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 1 }],
});

await fetchUsers()(dispatch, getState, undefined);

const types = dispatch.mock.calls.map(call => call[0].type);
expect(types).toEqual([
'users/fetch/pending',
'users/fetch/fulfilled',
]);
});

Таким образом, мы тестируем:

  • что логика асинхронного экшена приводит к правильной последовательности событий;
  • редьюсеры уже покрыты своими тестами.
  1. Использование middleware уровня saga/observable для сложной асинхронщины

Когда логика сложнее:

  • параллельные запросы,
  • отмена, дебаунс, race, сложные бизнес-потоки,

используют:

  • redux-saga (на основе генераторов),
  • redux-observable (RxJS),
  • собственные кастомные middleware.

Пример redux-saga:

import { call, put, takeLatest } from 'redux-saga/effects';
import api from '../api';

function* fetchUsersWorker() {
try {
const users = yield call(api.fetchUsers);
yield put({ type: 'users/fetchSuccess', payload: users });
} catch (e) {
yield put({ type: 'users/fetchFailure', payload: e.message });
}
}

export function* usersSaga() {
yield takeLatest('users/fetchRequest', fetchUsersWorker);
}

Тестирование:

  • итерируем генератор,
  • проверяем, какие эффекты он возвращает.
import { fetchUsersWorker } from './sagas';
import { call, put } from 'redux-saga/effects';
import api from '../api';

test('fetchUsersWorker success flow', () => {
const gen = fetchUsersWorker();

expect(gen.next().value).toEqual(call(api.fetchUsers));
const fakeUsers = [{ id: 1 }];
expect(gen.next(fakeUsers).value).toEqual(
put({ type: 'users/fetchSuccess', payload: fakeUsers })
);
expect(gen.next().done).toBe(true);
});

Плюс:

  • полностью детерминированные тесты без реальных запросов;
  • сложная логика описана декларативно, а не размазана по компонентам.
  1. RTK Query: высокоуровневое управление асинхронными запросами

Redux Toolkit Query:

  • берёт на себя:
    • fetch,
    • кеширование,
    • рефетчинг,
    • инвалидацию,
    • статусы загрузки/ошибок,
  • позволяет компонента ориентироваться на useXXXQuery / useYYYMutation вместо ручных санок.

Пример:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getUsers: builder.query({
query: () => 'users',
}),
}),
});

export const { useGetUsersQuery } = api;

В компоненте:

function Users() {
const { data, isLoading, error } = useGetUsersQuery();
// ...
}

Тестирование:

  • мокается HTTP-слой или baseQuery,
  • RTK Query даёт предсказуемую модель состояний.
  1. Общий паттерн: разделение зон ответственности

Хорошая архитектура Redux-слоя:

  • reducers:
    • чистые функции, unit-тесты.
  • selectors:
    • чистые функции, unit-тесты.
  • async layer (thunk/saga/RTK Query):
    • изолированный код, который:
      • дергает API,
      • диспатчит actions,
      • легко тестируется через моки и проверку эффектов.
  • компоненты:
    • тонкие:
      • используют useSelector/useDispatch или хуки RTK Query,
      • не содержат тяжёлой бизнес-логики,
      • их проще тестировать отдельно (через react-testing-library).
  1. Краткая формулировка для интервью

"Redux-логика хорошо тестируется, потому что редьюсеры и селекторы — чистые функции: мы подаём state и action, проверяем ожидаемый state. Асинхронные операции выносятся в middleware-слой (thunks, saga, RTK Query): там мы вызываем API, диспатчим обычные actions и можем тестировать либо последовательность dispatch-вызовов, либо эффекты генераторов. Такой подход даёт детерминированность, прозрачные тесты и предсказуемое управление сложной асинхронностью."

Вопрос 33. Как организована ленивая загрузка в React-приложении?

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

Ответ собеседника: правильный. Описывает использование React.lazy и Suspense для отложенной загрузки компонентов; на уточняющий вопрос про ленивую загрузку API признаётся, что не реализовывал.

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

Ленивая загрузка (code splitting + lazy loading) в React — это подход, при котором:

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

Цели:

  • ускорить initial load,
  • уменьшить размер бандла,
  • лучше использовать сетевые ресурсы,
  • особенно важна для SPA с большим количеством роутов.

В React ленивую загрузку можно условно разделить на два слоя:

  • ленивая загрузка компонентов (UI-код),
  • ленивая и отложенная загрузка данных (API-запросы).
  1. Ленивая загрузка компонентов через React.lazy и Suspense

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

Базовый пример:

import React, { Suspense } from 'react';

const UserPage = React.lazy(() => import('./UserPage'));

function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserPage />
</Suspense>
);
}

Как это работает:

  • import('./UserPage') — динамический импорт, который бандлер (Webpack, Vite, etc.) превращает в отдельный chunk.
  • При первом рендере:
    • React видит ленивый компонент,
    • начинает асинхронную загрузку чанка,
    • пока он грузится — рендерит fallback из ближайшего Suspense.
  • После загрузки:
    • модуль и компонент подставляются,
    • Suspense снимается, пользователь видит реальный контент.

Типичный продакшен-кейс — ленивая загрузка роутов:

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Suspense, lazy } from 'react';

const HomePage = lazy(() => import('./pages/HomePage'));
const UsersPage = lazy(() => import('./pages/UsersPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));

function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading page...</div>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}

Плюсы:

  • каждый маршрут — отдельный чанк,
  • первая загрузка — минимальный объём кода,
  • остальные страницы подтягиваются при первом визите.
  1. Гранулярность и best practices

Сильный подход:

  • Ленивая загрузка по роутам:
    • страницы, большие модули, тяжёлые админки — по требованию.
  • Ленивая загрузка тяжёлых виджетов:
    • графики, редакторы, карты, сложные таблицы:
      • грузить только когда они действительно нужны (по условию).
  • Локальный Suspense:
    • использовать fallback поближе к ленивому компоненту:
      • лучше UX, чем глобальный "экран загрузки" на всё.

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

const Chart = lazy(() => import('./Chart'));

function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading chart...</div>}>
<Chart />
</Suspense>
</div>
);
}
  1. Ленивая загрузка данных (API): правильная организация

Важно разделять:

  • ленивая загрузка кода компонента,
  • и загрузку данных.

React.lazy сам по себе не управляет API-запросами. Но грамотная архитектура сочетает их:

Подходы:

  • Загрузка данных при монтировании ленивого компонента:
    • компонент грузится → в useEffect/useQuery делаем запрос;
  • Использование data-fetching библиотек:
    • React Query, RTK Query, SWR:
      • под капотом поддерживают кеширование, refetch, deduplication.
  • Ленивая инициализация запросов:
    • не вызывать API до тех пор, пока компонент/страница реально не нужна (например, пока роут не активен).

Пример с RTK Query (ленивая загрузка страницы + данных):

const UserPage = lazy(() => import('./UserPage'));

// внутри UserPage.jsx
import { useGetUserQuery } from '../api/users';

function UserPage() {
const { data, isLoading } = useGetUserQuery();

if (isLoading) return <div>Loading user...</div>;
return <div>{data.name}</div>;
}

Результат:

  • код UserPage и его логика загрузки данных подтянутся только при переходе на соответствующий маршрут.

Ленивая загрузка API в более узком смысле:

  • не инициализировать тяжёлые SDK/клиенты до момента первого использования:
    • например, отложенная инициализация analytics, карт, чатов, если пользователь вообще туда не заходит;
  • использовать динамические импорты для модулей, которые инкапсулируют сетевую логику.
  1. Edge-cases и продвинутые моменты

Для сильного ответа полезно отметить:

  • ErrorBoundary:

    • вместе с lazy/Suspense использовать ErrorBoundary для обработки ошибок загрузки чанков (сеть, 404).
  • Prefetch:

    • можно заранее подгрузить чанк страницы, если ожидаем переход (hover по ссылке, видимость ссылки):
      • большинство бандлеров и роутеров поддерживают prefetch/presload.
  • SSR:

    • при серверном рендеринге ленивые компоненты требуют доп. конфигурации:
      • сборка манифеста чанков,
      • гидратация с учётом лейзи-чанков,
      • фреймворки (Next.js, Remix) берут большую часть на себя.
  1. Краткая формулировка

"Ленивая загрузка в React реализуется через динамические импорты и React.lazy в связке с Suspense. Компонент или страница не попадает в основной бандл и загружается только при первом рендере, пока показывается fallback. Это используется для разбиения кода, особенно на уровне роутов и тяжёлых виджетов. Для данных ленивость достигается на уровне логики запросов: не дергать API, пока компонент не нужен, и использовать инструменты вроде React Query/RTK Query. Цель — уменьшить initial bundle, ускорить загрузку и не тянуть лишнее до тех пор, пока пользователь действительно не дойдёт до соответствующей функциональности."

Вопрос 34. Что такое «глубокий перенос состояния» (deep state transfer) в React и как он используется?

Таймкод: 00:31:38

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

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

Термин "deep state transfer" не является официальным термином React-экосистемы, но в интервью под ним обычно имеют в виду один из двух близких по смыслу контекстов:

  1. перенос/прокидывание сложного состояния через глубоко вложенное дерево компонентов;
  2. перенос состояния между маршрутами/страницами (в том числе при SSR/гидратации или при клиентской навигации).

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

Ниже — системное объяснение с учётом практики.

  1. Проблема: глубокая передача состояния (prop drilling)

Наивный подход:

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

Пример:

function App() {
const [user, setUser] = useState({ id: 1, name: 'Alice' });

return <Layout user={user} setUser={setUser} />;
}

function Layout({ user, setUser }) {
return <Sidebar user={user} setUser={setUser} />;
}

function Sidebar({ user, setUser }) {
return <UserMenu user={user} setUser={setUser} />;
}

function UserMenu({ user }) {
return <div>{user.name}</div>;
}

Минусы:

  • "глубокий перенос" через props делает дерево сильно связным;
  • каждый промежуточный компонент становится "транзитным" — его сложнее менять, переиспользовать, тестировать;
  • любое изменение формы состояния тянет правки по всей цепочке.
  1. Решения: как правильно организовать глубокий доступ к состоянию

Вместо ручного "deep transfer" через props используются механизмы, которые позволяют разделить:

  • место хранения и управления состоянием;
  • место потребления состояния.

Основные подходы:

а) Context API

React Context позволяет:

  • определить значение (state + методы) на верхнем уровне,
  • потреблять его в любом глубоко вложенном компоненте, минуя prop drilling.

Пример:

const AuthContext = React.createContext(null);

function App() {
const [user, setUser] = useState({ id: 1, name: 'Alice' });

return (
<AuthContext.Provider value={{ user, setUser }}>
<Layout />
</AuthContext.Provider>
);
}

function UserMenu() {
const { user } = useContext(AuthContext);
return <div>{user.name}</div>;
}

Теперь:

  • Layout, Sidebar, и прочие промежуточные компоненты не знают о user/ setUser;
  • "глубокий перенос" состояния реализован декларативно через контекст.

б) Глобальные сторы и state management (Redux, Zustand, Jotai и др.)

Для более сложных систем:

  • выносят состояние в централизованный стор;
  • компоненты подписываются на нужные срезы через хуки (useSelector, useStore);
  • состояние "глубоко доступно" без передачи через props.

Пример (Zustand, упрощённо):

import create from 'zustand';

const useAuthStore = create((set) => ({
user: { id: 1, name: 'Alice' },
setUser: (user) => set({ user }),
}));

function UserMenu() {
const user = useAuthStore((state) => state.user);
return <div>{user.name}</div>;
}

Это и есть корректная архитектурная реализация "deep state transfer": состояние централизовано, доступ — локальный и целевой.

  1. Перенос состояния между маршрутами (router state / navigation state)

Ещё один практический смысл "deep state transfer":

  • перенос сложного состояния при навигации:
    • между страницами внутри SPA,
    • между сервером и клиентом в SSR-приложениях.

Подходы:

а) React Router: state в навигации

React Router позволяет передавать состояние при переходе:

// переход
navigate('/details', { state: { from: 'dashboard', filter } });

// чтение
import { useLocation } from 'react-router-dom';

function DetailsPage() {
const { state } = useLocation();
// state?.from, state?.filter
}

Можно "глубоко" передать объект без сериализации в URL. Но:

  • это состояние не глобальное,
  • живёт в history,
  • не стоит тащить туда огромные структуры (для этого лучше стор/кеш).

б) SSR и гидратация: перенос состояния с сервера на клиент

В SSR-приложениях (Next.js, Remix, custom-SSR):

  • на сервере загружаются данные,
  • формируется HTML + "initial state" (например, для Redux или React Query),
  • на клиенте этот state гидратируется, чтобы не делать повторный запрос сразу.

Это можно рассматривать как "deep state transfer":

  • состояние доменной модели переносится через границу (server → client),
  • и становится источником истины на клиенте.

Пример (Redux + SSR, концептуально):

<script>
window.__PRELOADED_STATE__ = {...серверный state...};
</script>

На клиенте:

const preloadedState = window.__PRELOADED_STATE__;
const store = configureStore({ preloadedState });

Такой подход критичен для производительности и UX в сложных системах.

  1. Глубокая передача состояния в компоненты: когда НЕ надо

Важно показать зрелость:

  • Избыточный "deep state transfer" через props — запах архитектуры.
  • Не стоит передавать вниз сложный state только "на всякий случай".
  • Локальное состояние должно оставаться локальным:
    • то, что нужно одному компоненту — хранится в нём.
  • Общие и кросс-секомпонентные данные:
    • поднимаются в контекст или глобальный стор,
    • а не просто проталкиваются на 5 уровней вниз.
  1. Краткая формулировка

Если в интервью спрашивают про "deep state transfer", сильный ответ звучит так:

"Сам по себе 'deep state transfer' — это не официальный термин React, а описание проблемы передачи состояния вглубь деревьев компонентов или между частями приложения. Наивный вариант — prop drilling, когда мы протягиваем состояние через множество уровней. Правильные решения:

  • Context API — для общих настроек, auth, тем, локальных глобальных состояний.
  • Менеджеры состояния (Redux, Zustand, RTK Query и т.п.) — для сложного, кросс-командного, кэшируемого состояния.
  • Передача state через навигацию (React Router) — когда нужно передать контекст перехода.
  • В SSR — перенос initial state с сервера на клиент для гидратации.

Задача — сделать доступ к состоянию предсказуемым и локальным для потребителя, а не тащить его глубоко через props и не дублировать."