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

Senior Frontend Interview: 🎉 Top MNC Questions | #JavaScript, #ReactJS, HTML/CSS (Mock)

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

Сегодня мы разберём собеседование на позицию фронтенд-разработчика, в ходе которого кандидат демонстрирует базовое понимание ключевых веб-технологий — HTTP-протокола, модели событий в браузере, а также основных концепций React, таких как reconciliation, оптимизация рендеринга и мемоизация. В части алгоритмических задач кандидат успешно решает задачу Two Sum, предлагая решение с вложенными циклами и оптимизируя его до линейной сложности с использованием хеш-таблицы, однако испытывает затруднения при работе с объектами и не знаком с продвинутыми темами вроде styled-components или внутренней работы браузера. В целом собеседование выявляет у кандидата практические навыки кодирования и общее понимание экосистемы React, но также показывает пробелы в глубине знаний и уверенности при ответах на более сложные или концептуальные вопросы.

Вопрос 1. Что такое HTTP-запрос и какие основные методы HTTP существуют?

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

Ответ собеседника: Правильный. HTTP — это протокол, который браузер использует для отправки и получения файлов с сервера. HTTP означает Hypertext Transfer Protocol. Этот протокол не является безопасным, поэтому браузеры перешли на HTTPS. Основные методы HTTP: GET, POST, PUT, PATCH и DELETE.

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

HTTP (HyperText Transfer Protocol) — это текстовый протокол прикладного уровня, работающий по модели «клиент — сервер» поверх TCP (или QUIC в HTTP/3). HTTP-запрос — это структурированное сообщение, которое клиент отправляет серверу для выполнения определённого действия над ресурсом, идентифицируемым URI.

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

Запрос состоит из нескольких обязательных и опциональных частей:

  • Стартовая строка (Request Line): содержит метод, путь запроса и версию протокола, например GET /api/users/42 HTTP/1.1.
  • Заголовки (Headers): метаинформация о запре — Host, Content-Type, Authorization, Accept и другие.
  • Пустая строка: отделяет заголовки от тела.
  • Тело запроса (Body): опциональная часть, содержащая данные (например, JSON при POST или PUT).

Основные методы HTTP

GET — получение представления ресурса. Идемпотентный, безопасный метод. Не должен изменять состояние сервера. Параметры передаются в URL (query string).

POST — создание нового ресурса или передача данных для обработки. Не идемпотентный — повторный вызов может создать дубликат. Данные передаются в теле запроса.

PUT — полная замена (создание или обновление) ресурса по указанному URI. Идемпотентный — повторный вызов с теми же данными даёт тот же результат.

PATCH — частичное обновление ресурса. Передаёт только дельту изменений. Не гарантированно идемпотентный (зависит от реализации).

DELETE — удаление ресурса. Идемпотентный — повторное удаление того же ресурса возвращает тот же результат (обычно 404 или 200/204).

Дополнительные методы

  • HEAD — аналог GET, но сервер возвращает только заголовки без тела. Полезен для проверки существования ресурса и получения метаданных (Last-Modified, Content-Length).
  • OPTIONS — запрос информации о доступных методах и возможностях сервера. Используется для CORS preflight-запросов.
  • TRACE — диагностический метод, возвращающий полученный запрос обратно (echo). Часто отключается из соображений безопасности.
  • CONNECT — установка туннеля (обычно для HTTPS через прокси).

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

  • Безопасные (Safe): GET, HEAD, OPTIONS, TRACE — не изменяют состояние сервера.
  • Идемпотентные (Idempotent): GET, HEAD, PUT, DELETE, OPTIONS, TRACE — повторный вызов даёт тот же результат.
  • Кэшируемые (Cacheable): GET, HEAD, POST (при наличии соответствующих заголовков) — ответы могут кэшироваться.

Пример обработки HTTP-запроса в Go

package main

import (
"encoding/json"
"fmt"
"net/http"
)

type User struct {
ID int `json:"id"`
Name string `json:"name"`
}

func handler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
user := User{ID: 1, Name: "Alice"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)

case http.MethodPost:
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
defer r.Body.Close()
fmt.Printf("Created user: %+v\n", user)
w.WriteHeader(http.StatusCreated)

case http.MethodPut:
// Полная замена ресурса
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
defer r.Body.Close()
w.WriteHeader(http.StatusOK)

case http.MethodDelete:
// Удаление ресурса
w.WriteHeader(http.StatusNoContent)

default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

func main() {
http.HandleFunc("/users", handler)
http.ListenAndServe(":8080", nil)
}

HTTPS vs HTTP

HTTPS — это HTTP, передаваемый поверх TLS-шифрования. Это не отдельный протокол, а комбинация HTTP + TLS, обеспечивающая конфиденциальность, целостность и аутентификацию данных. Все современные веб-приложения используют HTTPS как стандарт по умолчанию.

Вопрос 2. Что такое всплытие событий (event bubbling) и как его можно предотвратить?

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

Ответ собеседника: Правильный. При всплытии событий, когда событие срабатывает на элементе, оно сначала обрабатывается на самом вложенном элементе (фаза захвата — от window вниз), а затем поднимается обратно вверх по DOM-дереву (фаза всплытия). Например, при клике на дочерний элемент внутри родительского, событие сначала отразится на дочернем, затем всплывёт к родителю. Для предотвращения всплытия можно использовать метод event.stopPropagation().

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

Механизм распространения событий в DOM

Распространение событий в DOM состоит из трёх фаз, определённых в спецификации W3C:

Фаза 1 — Захват (Capturing Phase): событие движется от корня документа (windowdocumenthtmlbody → ... → целевой элемент). Обработчики, зарегистрированные с useCapture: true, срабатывают на этом этапе.

Фаза 2 — Целевая фаза (Target Phase): событие достигает целевого элемента, на котором произошло действие. Все обработчики на целевом элементе срабатывают независимо от способа регистрации.

Фаза 3 — Всплытие (Bubbling Phase): событие поднимается обратно от целевого элемента к корню документа (целевой элемент → bodyhtmldocumentwindow). Это поведение по умолчанию для большинства событий.

Всплытие (Event Bubbling) — это механизм, при котором событие, возникшее на вложенном элементе, последовательно распространяется вверх по цепочке предков. Название «bubbling» (пузырение) отражает аналогию с пузырьком воздуха, поднимающимся со дна вверх.

Пример всплытия

<div id="grandparent">
<div id="parent">
<button id="child">Click me</button>
</div>
</div>

<script>
document.getElementById('grandparent').addEventListener('click', () => {
console.log('grandparent');
});
document.getElementById('parent').addEventListener('click', () => {
console.log('parent');
});
document.getElementById('child').addEventListener('click', () => {
console.log('child');
});
</script>

При клике на кнопку в консоли будет: childparentgrandparent.

Способы предотвращения всплытия

event.stopPropagation() — останавливает дальнейшее распространение события по DOM-дереву. Обработчики на текущем элементе по-прежнему выполняются, но событие не достигнет предков.

document.getElementById('child').addEventListener('click', (e) => {
e.stopPropagation();
console.log('child — всплытие остановлено');
});
// В консоли будет только: child — всплытие остановлено

event.stopImmediatePropagation() — останавливает распространение и предотвращает выполнение остальных обработчиков на том же элементе.

element.addEventListener('click', (e) => {
e.stopImmediatePropagation();
console.log('первый обработчик');
});
element.addEventListener('click', () => {
console.log('этот обработчик НЕ выполнится');
});

События, которые НЕ всплывают

Некоторые события по спецификации не всплывают: focus, blur, mouseenter, mouseleave, load, unload, scroll, resize. Для focus и blur существуют всплывающие аналоги focusin и focusout.

Делегирование событий — использование всплытия

Всплытие лежит в основе паттерна «делегирование событий», когда обработчик регистрируется на родительском элементе и обрабатывает события от дочерних элементов:

document.getElementById('parent').addEventListener('click', (e) => {
if (e.target.matches('button.action')) {
console.log('Нажата кнопка с классом action:', e.target.textContent);
}
});

Это особенно эффективно для динамически создаваемых элементов и больших списков, так как вместо сотен обработчиков используется один.

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

  • stopPropagation() не предотвращает действия браузера по умолчанию (для этого нужен preventDefault()).
  • В React синтетические события имеют свою систему делегирования — все обработчики привязываются к корневому элементу, а не к DOM-узлам напрямую.
  • Чрезмерное использование stopPropagation() может привести к проблемам с совместимостью библиотек и непредсказуемому поведению в сложных приложениях.

Вопрос 3. Дан массив чисел и целевое значение. Найти индексы двух элементов массива, сумма которых равна целевому значению.

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

Ответ собеседника: Правильный. Задача решена двумя способами. Первое решение — с использованием вложенных циклов с временной сложностью O(n²). Второе оптимизированное решение — с использованием объекта (хеш-таблицы) для хранения ранее просмотренных значений и их индексов, что позволяет найти ответ за один проход по массиву. Оба решения дали правильный результат.

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

Это классическая задача «Two Sum», одна из наиболее популярных задач на технических собеседованиях.

Условие: дан массив nums и целевое значение target. Нужно вернуть индексы двух элементов, сумма которых равна target. Гарантируется, что существует ровно одно решение, и один элемент нельзя использовать дважды.

Подход 1 — Полный перебор (Brute Force)

Временная сложность: O(n²), пространственная: O(1).

func twoSumBruteForce(nums []int, target int) []int {
for i := 0; i < len(nums); i++ {
for j := i + 1; j < len(nums); j++ {
if nums[i]+nums[j] == target {
return []int{i, j}
}
}
}
return nil
}

Простое, но неэффективное решение для больших массивов.

Подход 2 — Хеш-таблица (оптимальный)

Временная сложность: O(n), пространственная: O(n).

Идея: при проходе по массиву для каждого элемента проверяем, есть ли в хеш-таблице комплемент (target - nums[i]). Если есть — нашли пару. Если нет — сохраняем текущий элемент и его индекс в таблицу.

func twoSum(nums []int, target int) []int {
seen := make(map[int]int) // значение -> индекс

for i, num := range nums {
complement := target - num
if j, ok := seen[complement]; ok {
return []int{j, i}
}
seen[num] = i
}
return nil
}

Пример работы:

nums = [2, 7, 11, 15], target = 9

i=0: num=2, complement=7, seen={} → не найдено → seen={2:0}
i=1: num=7, complement=2, seen={2:0} → найдено! → return [0, 1]

Подход 3 — Сортировка с двумя указателями

Временная сложность: O(n log n), пространственная: O(n).

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

func twoSumSorted(nums []int, target int) []int {
// Создаём массив пар (значение, исходный индекс)
type pair struct {
val int
index int
}
pairs := make([]pair, len(nums))
for i, v := range nums {
pairs[i] = pair{v, i}
}

// Сортируем по значению
sort.Slice(pairs, func(i, j int) bool {
return pairs[i].val < pairs[j].val
})

left, right := 0, len(pairs)-1
for left < right {
sum := pairs[left].val + pairs[right].val
if sum == target {
return []int{pairs[left].index, pairs[right].index}
} else if sum < target {
left++
} else {
right--
}
}
return nil
}

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

ПодходВремяПамятьСохраняет индексы
Brute ForceO(n²)O(1)Да
Хеш-таблицаO(n)O(n)Да
Сортировка + два указателяO(n log n)O(n)Да (с модификацией)

Оптимальным решением является подход с хеш-таблицей — один проход по массиву с O(n) по времени и памяти. Это стандартное решение, которое ожидается на собеседовании.

Граничные случаи, которые стоит учесть:

  • Массив содержит отрицательные числа — решение с хеш-таблицей работает корректно.
  • Два одинаковых элемента (например, [3, 3], target=6) — хеш-таблица перезапишет индекс, но к моменту проверки второй тройки первый индекс уже сохранён и будет найден как комплемент.
  • Пустой массив или массив из одного элемента — стоит добавить проверку в начале функции.

Вопрос 4. Почему не рекомендуется использовать индекс элемента массива в качестве ключа (key) при рендеринге списков в React?

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

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

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

Как React использует ключи

Ключи (keys) — это механизм, который React использует в алгоритме согласования (reconciliation) для определения того, какие элементы списка были добавлены, удалены, перемещены или изменены. Ключ должен однозначно идентифицировать конкретный элемент данных на протяжении всего жизненного цикла компонента.

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

1. Неправильное обновление состояния компонентов

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

// Список: [A, B, C] с ключами [0, 1, 2]
// После добавления X в начало: [X, A, B, C] с ключами [0, 1, 2, 3]

// React думает:
// ключ 0 был A, стал X → обновляет содержимое
// ключ 1 был B, стал A → обновляет содержимое
// ключ 2 был C, стал B → обновляет содержимое
// ключ 3 — новый элемент → создаёт C

Если компоненты списка имеют внутреннее состояние (input, чекбокс, анимация), оно привязывается к ключу, а не к данным. В результате состояние «перескакивает» на другой элемент.

2. Проблемы с управляемыми компонентами (controlled inputs)

function TodoList() {
const [todos, setTodos] = useState([
{ id: 'a', text: 'First' },
{ id: 'b', text: 'Second' },
{ id: 'c', text: 'Third' },
]);

return (
<ul>
{todos.map((todo, index) => (
<li key={index}>
<input value={todo.text} readOnly />
</li>
))}
</ul>
);
}

Если удалить первый элемент, React переиспользует DOM-узлы: ключ 0 теперь ссылается на { id: 'b', text: 'Second' }, но если в input было введено пользовательское значение, оно может остаться от предыдущего элемента.

3. Субоптимальная производительность

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

4. Проблемы с анимациями и переходами

Библиотеки анимаций (React Transition Group, Framer Motion) используют ключи для отслеживания элементов. При использовании индексов анимация удаления/перемещения будет работать некорректно.

Когда использование индекса допустимо

Есть ограниченный набор случаев, когда индекс как ключ не вызовет проблем:

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

Правильный подход — использовать уникальный идентификатор данных

// Правильно: ключ привязан к данным
{todos.map(todo => (
<li key={todo.id}>
<input value={todo.text} />
</li>
))}

// Неправильно: ключ привязан к позиции
{todos.map((todo, index) => (
<li key={index}>
<input value={todo.text} />
</li>
))}

Если у данных нет естественного уникального идентификатора, его стоит сгенерировать при создании записи (UUID, хэш, комбинация полей). Использование библиотек вроде uuid или nanoid предпочтительнее генерации случайных чисел через Math.random().

Итог: индекс массива — это идентификатор позиции, а не идентификатор данных. Ключ в React должен идентифицировать данные, чтобы алгоритм reconciliation мог корректно сопоставлять элементы между рендерами.

Вопрос 5. Что такое реконсиляция (reconciliation) в React?

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

Ответ собеседника: Правильный. Реконсиляция — это процесс, при котором React при изменении компонента или UI создаёт новую виртуальную копию DOM и сравнивает её с предыдущей. С помощью оптимизированного подхода React обновляет только ту часть реального DOM, где произошли изменения, а не перерисовывает весь DOM-дерево целиком.

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

Реконсиляция (Reconciliation) — это алгоритм, посредством которого React определяет, какие части реального DOM нужно обновить при изменении состояния или пропсов компонента. Это ядро декларативного подхода React: разработчик описывает, как UI должен выглядеть при определённом состоянии, а React эффективно приводит реальный DOM в соответствие с этим описанием.

Виртуальный DOM (Virtual DOM)

Virtual DOM — это легковесное JavaScript-представление реального DOM-дерева. Каждый компонент React возвращает JSX, который преобразуется в объекты Virtual DOM. При каждом изменении состояния React строит новое дерево Virtual DOM и сравнивает его с предыдущим снимком (snapshot).

Алгоритм сравнения (Diffing Algorithm)

React использует эвристический алгоритм O(n) для сравнения двух деревьев, основанный на двух допущениях:

  • Два элемента с разными типами производят разные деревья.
  • Разработчик может указать стабильные элементы с помощью пропа key.

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

Разные типы элементов — React уничтожает старое дерево полностью (включая дочерние компоненты) и создаёт новое. Состояние старых компонентов теряется.

// С div на span — полная пересборка
<div><Counter /></div>
// ↓
<span><Counter /></span> // Counter пересоздаётся с начальным состоянием

Одинаковые типы элементов — React обновляет только изменившиеся атрибуты, сохраняя DOM-узел. Рекурсивно сравнивает дочерние элементы.

<div className="old" title="a" />
// ↓
<div className="new" title="a" />
// Обновляется только className в реальном DOM

Сравнение списков дочерних элементов

Без ключей React сравнивает элементы попарно по позиции. При добавлении элемента в начало списка React считает, что все элементы изменились.

С ключами React может определить, какие элементы были перемещены, добавлены или удалены, и выполнить минимальный набор операций.

Этапы обновления в React

  1. Render: вызывается функция компонента, создаётся новое дерево Virtual DOM (React Elements).
  2. Reconciliation: сравнение нового дерева с предыдущим, вычисление минимального набора изменений (diff).
  3. Commit: применение вычисленных изменений к реальному DOM. Вызов жизненного цикла (useEffect, componentDidMount и т.д.).

Fiber Architecture (React 16+)

Начиная с React 16, алгоритм реконсиляции реализован через Fiber — архитектуру, позволяющую:

  • Приостанавливать работу: рендеринг можно прервать для обработки более приоритетных задач (пользовательский ввод, анимация).
  • Разбивать работу на чанки: вместо блокировки основного потока на весь рендер, React работает по кусочкам в рамках доступных кадров (16мс для 60fps).
  • Приоритизировать обновления: срочные обновления (клик, ввод) обрабатываются раньше несрочных (загрузка данных).

Каждый Fiber-узел — это JavaScript-объект, соответствующий элементу Virtual DOM, содержащий ссылки на родителя, первого потомка, следующий сиблинг, а также данные о типе, состоянии и эффектах.

Практические следствия для разработчика

  • Не мутировать состояние напрямую — React сравнивает по ссылке. Нужно создавать новые объекты/массивы.
  • Использовать key для списков — это критически важно для корректности и производительности.
  • Размещать состояние как можно ближе к месту использования — это уменьшает область реконсиляции.
  • Использовать React.memo, useMemo, useCallback для предотвращения лишних рендеров, когда пропсы не изменились.

Вопрос 6. В чём разница между серверным рендерингом (SSR) и клиентским рендерингом (CSR)?

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

Ответ собеседника: Правильный. При CSR сервер отправляет клиенту JavaScript-бандлы и минимальный пустой HTML-документ, а весь HTML генерируется на стороне клиента с помощью JavaScript. При SSR сервер полностью формирует HTML-документ и отправляет его клиенту. SSR лучше для SEO, так как поисковые системы могут сразу сканировать готовый HTML. CSR снижает нагрузку на сервер, так как рендеринг происходит на стороне клиента.

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

Клиентский рендеринг (CSR — Client-Side Rendering)

При CSR сервер откликает минимальным HTML-шаблоном (обычно пустой <div id="root"></div>) и JavaScript-бандлами. Браузер загружает и выполняет JavaScript, который строит DOM-дерево, запрашивает данные через API и рендерит интерфейс.

Поток работы CSR:

  1. Браузер запрашивает URL → сервер отдаёт минимальный HTML + <script> теги.
  2. Браузер загружает и парсит JavaScript.
  3. Приложение монтируется, выполняет API-запросы.
  4. Данные получены → React рендерит компоненты → DOM обновляется.

Серверный рендеринг (SSR — Server-Side Rendering)

При SSR сервер для каждого запроса выполняет рендеринг React-компонентов в HTML-строку и отправляет полностью сформированную страницу. После загрузки HTML клиент «гидрирует» страницу — привязывает обработчики событий к существующему DOM.

Поток работы SSR:

  1. Браузер запрашивает URL → сервер рендерит React-компоненты в HTML.
  2. Браузер получает полный HTML с контентом → отображает страницу.
  3. Браузер загружает JavaScript-бандлы.
  4. React выполняет гидратацию (hydration) — привязывает интерактивность к существующему DOM.

Детальное сравнение

КритерийCSRSSR
Первая отрисовка (FCP)Медленная — нужно загрузить и выполнить JSБыстрая — HTML уже содержит контент
Индексация (SEO)Проблемная — поисковики видят пустой HTMLОтличная — полный HTML доступен сразу
Нагрузка на серверМинимальная — только статика и APIВысокая — рендеринг на каждый запрос
Нагрузка на клиентВысокая — весь рендеринг в браузереУмеренная — гидратация + интерактивность
Time to Interactive (TTI)Позднее — зависит от размера бандлаРаньше — контент виден сразу, интерактивность после гидратации
КэшированиеЛегко через CDN (статика)Сложнее — динамический контент на сервере
Сложность разработкиПроще — одна кодовая базаСложнее — нужно учитывать серверное окружение

Проблема «гидратационного разрыва» (Hydration Mismatch)

При SSR сервер генерирует HTML, а клиент при гидратации строит своё виртуальное дерево. Если они не совпадают (разные данные, зависимость от window, Date.now() и т.д.), React выдаст предупреждение и переключится на клиентский рендеринг, теряя преимущества SSR.

Гибридные подходы

Static Site Generation (SSG): HTML генерируется на этапе сборки (build time), а не на каждый запрос. Подходит для контента, который редко меняется. Next.js поддерживает SSG через getStaticProps.

Incremental Static Regeneration (ISR): гибрид SSG и SSR — страница генерируется при сборке, но может быть перегенерирована на сервере по расписанию или по запросу.

Streaming SSR (React 18+): сервер отправляет HTML частями (chunks) по мере готовности, не дожидаясь рендеринга всей страницы. Позволяет показать статическую часть сразу, а динамическую подгрузить позже.

Selective Hydration (React 18+): React может начать гидратацию частей страницы по мере их загрузки, а не ждать весь JavaScript.

Пример SSR в Next.js

// pages/product/[id].js — серверный рендеринг
export async function getServerSideProps(context) {
const { id } = context.params;
const product = await fetch(`https://api.example.com/products/${id}`)
.then(res => res.json());

return {
props: { product },
};
}

export default function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>{product.price}</span>
</div>
);
}

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

  • CSR: внутренние дашборды, админки, приложения с авторизацией, где SEO не важен.
  • SSR: публичные страницы с динамическим контентом, где важны SEO и скорость первой загрузки.
  • SSG: блоги, документация, лендинги — контент меняется редко.
  • ISR: каталоги товаров, новостные сайты — контент обновляется периодически.

Вопрос 7. Как можно оптимизировать рендеринг больших списков в React?

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

Ответ собеседния: Неполный. Для оптимизации можно использовать атрибут key, чтобы React понимал, какие компоненты уже были созданы, и пропускал их повторный рендеринг при ре-рендеринге родительского компонента. Также можно применять пагинацию или бесконечную прокручку (infinite scroll), загружая данные порциями с сервера по мере необходимости.

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

Рендеринг больших списков — одна из наиболее распространённых проблем производительности в React-приложениях. Когда список содержит тысячи элементов, создание и поддержка DOM-узлов для каждого из них приводит к заметным задержкам.

1. Виртуализация списка (Windowing)

Ключевая идея: рендерить только те элементы, которые видны в viewport, плюс небольшой буфер сверху и снизу. Вместо 10 000 DOM-узлов создаётся 20–30.

react-window — легковесная библиотека от Brian Vaughn:

import { FixedSizeList } from 'react-window';

function Row({ index, style }) {
return (
<div style={style}>
Row {index}: {data[index]}
</div>
);
}

function List() {
return (
<FixedSizeList
height={600}
itemCount={10000}
itemSize={35}
width="100%"
>
{Row}
</FixedSizeList>
);
}

react-virtuoso — более продвинутая альтернатива с поддержкой переменной высоты, группировки, бесконечной прокрутки:

import { Virtuoso } from 'react-virtuoso';

function List() {
return (
<Virtuoso
style={{ height: '600px' }}
totalCount={10000}
itemContent={(index) => (
<div>Row {index}: {data[index]}</div>
)}
endReached={() => loadMore()} // бесконечная прокрутка
/>
);
}

2. Мемоизация компонентов списка

React.memo предотвращает повторный рендер элемента, если его пропсы не изменились:

const ListItem = React.memo(function ListItem({ item, onSelect }) {
return (
<div onClick={() => onSelect(item.id)}>
{item.name}
</div>
);
});

function List({ items, onSelect }) {
return items.map(item => (
<ListItem key={item.id} item={item} onSelect={onSelect} />
));
}

Важно: onSelect должен быть стабильной ссылкой (через useCallback), иначе React.memo не даст эффекта — новый объект функции при каждом рендере будет считаться изменением пропса.

const onSelect = useCallback((id) => {
setSelectedId(id);
}, []);

3. Пагинация и бесконечная прокрутка

Пагинация — загрузка данных страницами (по 20–50 элементов). Простая реализация, предсказуемое поведение, но требует явного действия пользователя.

Бесконечная прокрутка (Infinite Scroll) — автоматическая подгрузка при прокрутке к концу списка. Используется с IntersectionObserver:

function useInfiniteScroll(callback) {
const observerRef = useRef(null);

const lastElementRef = useCallback((node) => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) callback();
});
if (node) observerRef.current.observe(node);
}, [callback]);

return lastElementRef;
}

4. Ленивый рендеринг (Lazy Rendering)

Загрузка и рендеринг элементов по мере необходимости через React.lazy и Suspense для тяжёлых компонентов:

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

function ListItem({ item }) {
const [showChart, setShowChart] = useState(false);

return (
<div>
<span>{item.name}</span>
<button onClick={() => setShowChart(true)}>Show chart</button>
{showChart && (
<Suspense fallback={<Spinner />}>
<HeavyChart data={item.chartData} />
</Suspense>
)}
</div>
);
}

5. Оптимизация состояния

  • Выносить состояние вниз (colocation): не хранить состояние выделенного элемента в родительском компоненте — это вызывает ре-рендер всего списка. Лучше управлять выделением внутри каждого элемента.
  • Использовать виртуализацию состояния: хранить данные в useRef или внешнем хранилище (Zustand, Redux) и подписываться только на нужные части.

6. Избегать анонимных функций в пропсах

// Плохо: новая функция при каждом рендере
items.map(item => <Item onClick={() => handleClick(item.id)} />)

// Хорошо: передаём только данные, обработчик внутри компонента
items.map(item => <Item item={item} />)

7. CSS-оптимизации

  • Использовать will-change: transform для элементов виртуализированного списка.
  • Избегать сложных CSS-селекторов и дорогих свойств (box-shadow, border-radius) на каждом элементе списка.
  • Использовать content-visibility: auto для элементов за пределами viewport.

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

  • До 100 элементов: обычный рендер + React.memo при необходимости.
  • 100–1000 элементов: пагинация или витуализация.
  • Более 1000 элементов: виртуализация обязательна, в комбинации с бесконечной прокруткой.

Вопрос 8. Что такое мемоизация и зачем она нужна?

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

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

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

Мемоизация — это техника оптимизации, при которой результат вычисления функции сохраняется в кэше, и при повторном вызове с теми же аргументами возвращается сохранённый результат вместо повторного выполнения. Это частный случай кэширования, применимый к чистым функциям (pure functions) — функциям, которые при одних и тех же входных данных всегда возвращают одинаковый результат и не имеют побочных эффектов.

Принцип работы

  1. Функция вызывается с определёнными аргументами.
  2. Проверяется, есть ли в кэше результат для этого набора аргументов.
  3. Если есть — возвращается кэшированный результат.
  4. Если нет — выполняется вычисление, результат сохраняется в кэш и возвращается.

Реализация мемоизации в JavaScript

function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}

// Пример использования
const factorial = memoize(function (n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
});

console.log(factorial(5)); // вычисление
console.log(factorial(5)); // из кэша

Мемоизация в React

React предоставляет три основных API для мемоизации:

React.memo — мемоизация компонента. Предотвращает повторный рендер, если пропсы не изменились:

const ExpensiveComponent = React.memo(function ExpensiveComponent({ data }) {
// Тяжёлые вычисления или рендеринг
return <div>{/* ... */}</div>;
});

React.memo по умолчанию делает поверхностное (shallow) сравнение пропсов. Можно передать кастомную функцию сравнения:

const MemoizedComponent = React.memo(
function Component({ user }) {
return <div>{user.name}</div>;
},
(prevProps, nextProps) => {
return prevProps.user.id === nextProps.user.id;
}
);

useMemo — мемоизация вычисленного значения:

function Component({ items, filter }) {
const filteredItems = useMemo(() => {
return items.filter(item => item.category === filter);
}, [items, filter]);

return <List items={filteredItems} />;
}

useCallback — мемоизация ссылки на функцию. Эквивалентен useMemo(() => fn, deps):

function Parent() {
const [count, setCount] = useState(0);

const handleClick = useCallback(() => {
console.log('clicked');
}, []); // стабильная ссылка между рендерами

return <Child onClick={handleClick} />;
}

Когда мемоизация действительно нужна

  • Тяжёлые вычисления (сортировка больших массивов, сложные математические операции, рекурсивные алгоритмы).
  • Рендеринг дорогих компонентов, которые часто получают те же пропсы.
  • Функции, передаваемые в зависимости useEffect, useMemo, useCallback дочерних компонентов.
  • Предотвращение лишних ре-рендеров в глубоких деревьях компонентов.

Когда мемоизация избыточна

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

Ограничения и подводные камни

  • Потребление памяти: кэш растёт с каждым уникальным набором аргументов. Для функций с большим количеством уникальных входов нужна стратегия вытеснения (LRU-кэш).
  • Сравнение аргументов: по умолчанию используется строгое равенство (===). Для объектов и массивов это сравнение по ссылке, а не по значению.
  • Чистота функций: мемоизация корректна только для чистых функций. Функции с побочными эффектами или зависимостью от внешнего состояния дадут некорректные результаты при кэшировании.

LRU-кэш для контролируемого потребления памяти

function memoizeWithLRU(fn, maxSize = 100) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
// Перемещаем в конец (самый свежий)
const value = cache.get(key);
cache.delete(key);
cache.set(key, value);
return value;
}
const result = fn.apply(this, args);
cache.set(key, result);
if (cache.size > maxSize) {
// Удаляем самый старый (первый в Map)
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
return result;
};
}

Вопрос 9. Как предотвратить ненужные ре-рендеры в React?

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

Ответ собеседника: Неполный. Для предотвращения ненужных ре-рендеров можно использовать React.memo — высший порядок компонент (HOC), который оборачивает компонент и ре-рендерит его только при изменении пропсов. Также он принимает функцию сравнения для более точного контроля.

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

Ненужные ре-рендеры — одна из главных причин проблем с производительностью в React. Важно понимать, что ре-рендер сам по себе не всегда плох — React эффективно обрабатывает виртуальный DOM. Проблема возникает, когда ре-рендер запускает дорогие вычисления, создаёт новые объекты в DOM или вызывает каскадные обновления в глубоких деревьях компонентов.

1. React.memo — мемоизация компонента

Оборачивает функциональный компонент и пропускает ре-рендер, если пропсы не изменились (поверхностное сравнение):

const Child = React.memo(function Child({ name, age }) {
console.log('Child render');
return <div>{name}{age}</div>;
});

function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child name="Alice" age={30} />
</div>
);
}
// Child НЕ ре-рендерится при изменении count

2. useCallback — стабильные ссылки на функции

Без useCallback новая функция создаётся при каждом рендере, что делает React.memo бесполезным:

function Parent() {
const [items, setItems] = useState([]);

// Без useCallback: новая ссылка каждый рендер → memo не работает
// const handleDelete = (id) => setItems(items.filter(i => i.id !== id));

// С useCallback: стабильная ссылка
const handleDelete = useCallback((id) => {
setItems(prev => prev.filter(i => i.id !== id));
}, []);

return items.map(item => (
<MemoizedItem key={item.id} item={item} onDelete={handleDelete} />
));
}

3. useMemo — мемоизация вычислений и объектов

Предотвращает повторные вычисления и создание новых объектов, которые ломают React.memo:

function Parent({ items }) {
// Без useMemo: новый объект каждый рендер → Child с memo всё равно ре-рендерится
// const config = { theme: 'dark', items };

// С useMemo: объект пересоздаётся только при изменении зависимостей
const config = useMemo(() => ({
theme: 'dark',
items,
}), [items]);

return <MemoizedChild config={config} />;
}

4. Подъём состояния вверх (Lifting State Up) vs спуск состояния вниз (Colocation)

Частая ошибка — хранение состояния слишком высоко в дереве. Изменение состояния в корневом компоненте вызывает ре-рендер всего дерева:

// Плохо: всё ре-рендерится при вводе текста
function App() {
const [text, setText] = useState('');
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<ExpensiveChart /> {/* ре-рендерится при каждом символе */}
<ExpensiveTable /> {/* тоже */}
</div>
);
}

// Хорошо: состояние ввода изолировано
function App() {
return (
<div>
<SearchInput /> {/* состояние внутри, не влияет на соседей */}
<ExpensiveChart />
<ExpensiveTable />
</div>
);
}

5. Разделение контекста (Context Splitting)

Избегайте одного большого контекста с разнородными данными. Изменение одного поля вызывает ре-рендер всех потребителей:

// Плохо: все потребители ре-рендерятся при любом изменении
const AppContext = createContext();

// Хорошо: разделённые контексты
const ThemeContext = createContext();
const UserContext = createContext();
const NotificationsContext = createContext();

6. Использование key для сброса состояния

Иногда нужен полный сброс компонента — смена key заставляет React пересоздать компонент:

function Parent() {
const [version, setVersion] = useState(0);
return (
<div>
<button onClick={() => setVersion(v => v + 1)}>Reset</button>
<Form key={version} /> {/* пересоздаётся при смене version */}
</div>
);
}

7. Внешние хранилища состояния

Библиотеки вроде Zustand или Jotai позволяют компонентам подписываться только на нужные части состояния, избегая лишних ре-рендеров без ручной мемоизации:

// Zustand: компонент ре-рендерится только при изменении name
const useUserStore = create((set) => ({
name: 'Alice',
age: 30,
setName: (name) => set({ name }),
}));

function NameDisplay() {
const name = useUserStore(state => state.name);
return <div>{name}</div>;
}

8. Профилирование для выявления реальных проблем

Прежде чем оптимизировать, нужно измерить:

  • React DevTools Profiler — показывает, какие компоненты рендерятся и сколько времени это занимает.
  • «Highlight updates» в React DevTools — визуально подсвечивает ре-рендеры в реальном времени.
  • console.count или performance.mark для точных замеров.

Порядок действий при оптимизации:

  1. Профилировать и найти реальные узкие места.
  2. Изолировать состояние (colocation).
  3. Разделить контексты.
  4. Применить React.memo + useCallback/useMemo там, где это подтверждено профилированием.
  5. Рассмотреть внешние хранилища для сложных случаев.

Главное правило: не оптимизировать преждевременно. Ре-рендер дешёвый — дорогой может быть только работа внутри компонента или каскадные эффекты.

Вопрос 10. Как браузер понимает и обрабатывает код?

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

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

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

Браузер — это сложная программа, которая проходит через несколько этапов для преобразования HTML, CSS и JavaScript в видимую интерактивную страницу. Понимание этого процесса критически важно для оптимизации производительности веб-приложений.

1. Загрузка ресурсов (Networking)

Браузер отправляет HTTP-запросы и получает ответы:

  • DNS-резолвинг: доменное имя преобразуется в IP-адрес.
  • TCP-соединение (три-way handshake) + TLS-рукопожатие для HTTPS.
  • Загрузка HTML-документа, затем CSS, JavaScript, изображений и других ресурсов.
  • Браузер начинает парсить HTML до полной загрузки всех ресурсов — это называется инкрементальный рендеринг.

2. Парсинг HTML → DOM (Document Object Model)

HTML-парсер преобразует текст HTML в дерево узлов — DOM:

  • Токенизация: разметка разбивается на токены (теги, атрибуты, текст).
  • Построение дерева: токены преобразуются в узлы, связанные в древовидную структуру.
  • DOM — это API для программного доступа к структуре документа.

При встрече с <script> без async/defer парсинг HTML блокируется до загрузки и выполнения JavaScript. Это ключевая проблема производительности.

3. Парсинг CSS → CSSOM (CSS Object Model)

CSS парсится отдельно и строится CSSOM — дерево стилей:

  • Каждое правило обрабатывается с учётом специфичности и каскадирования.
  • CSS является блокирующим ресурсом для рендеринга — браузер не начнёт отрисовку, пока CSSOM не построен.
  • Внешние таблицы стилей (<link rel="stylesheet">) блокируют рендеринг.

4. Вычисление стилей и Layout (Reflow)

DOM и CSSOM объединяются в Render Tree — дерево видимых элементов:

  • Из DOM исключаются невидимые элементы (display: none, <head>, <script>).
  • Для каждого видимого узла вычисляются финальные стили (computed styles).
  • Layout (Reflow): вычисляются точные позиции и размеры каждого элемента в viewport.

5. Отрисовка (Paint)

Браузер превычисляет стили в пиксельные данные:

  • Заполнение цветов, градиенты, тени, границы.
  • Создаются слои (layers) для оптимизации — элементы с transform, opacity, will-change могут попасть на отдельный композитный слой.

6. Композитинг (Compositing)

Отдельные слои объединяются в финальное изображение и выводятся на экран. Композитинг выполняется на GPU и обычно самый быстрый этап.

7. Выполнение JavaScript

JavaScript-движок (V8 в Chrome, SpiderMonkey в Firefox) обрабатывает код:

  • Парсинг: исходный код → AST (Abstract Syntax Tree) → байткод/машинный код.
  • JIT-компиляция: «горячие» функции компилируются в оптимизированный машинный код.
  • Обработка событий: через Event Loop — цикл событий с очередями макро- и микрозадач.

Event Loop в браузере:

┌───────────────────────────┐
│ Call Stack │
│ (синхронный код) │
└─────────────┬─────────────┘


┌───────────────────────────┐
│ Microtask Queue │
│ (Promise, queueMicrotask)│
└─────────────┬─────────────┘


┌───────────────────────────┐
│ Macrotask Queue │
│ (setTimeout, events, │
│ requestAnimationFrame) │
└─────────────┬─────────────┘


┌───────────────────────────┐
│ Render Phase │
│ (style, layout, paint, │
│ composite) │
└───────────────────────────┘

Критический путь рендеринга (Critical Rendering Path)

Последовательность: HTML → DOM → CSSOM → Render Tree → Layout → Paint → Composite

Оптимизация этого пути — ключ к быстрой загрузке:

  • Минимизировать блокирующие CSS и JavaScript.
  • Использовать async/defer для скриптов.
  • Встраивать критический CSS в <style> в <head>.
  • Использовать preload, prefetch, preconnect для приоритизации ресурсов.

Типичные события жизненного цикла страницы:

  • DOMContentLoaded — DOM полностью построен (без ожидания CSS, изображений).
  • load — все ресурсы загружены.
  • beforeunload — пользователь покидает страницу.

Производительность и частота кадров

Для плавной анимации браузер должен выполнить полный цикл рендеринга за ~16мс (60fps). Дорогие операции:

  • Reflow (Layout): изменение геометрии (width, height, position) — самая дорогая операция.
  • Repaint: изменение визуальных свойств без геометрии (color, background) — дешевле reflow.
  • Composite: изменение transform и opacity — самое дешёвое, обрабатывается GPU.

Оптимизация рендеринга:

// Плохо: вызывает reflow на каждой итерации
for (let i = 0; i < 1000; i++) {
element.style.width = i + 'px';
}

// Хорошо: батчинг изменений
element.classList.add('expanded'); // одно изменение

// Для анимаций: использовать transform вместо top/left
element.style.transform = 'translateX(100px)'; // composite, без reflow