Senior/Lead Frontend: Глубокая подготовка к техническому собеседованию

Практический курс для опытных frontend-разработчиков, готовящихся к senior/lead собеседованиям. Каждая глава — глубокий разбор механизмов «под капотом» с примерами кода, подводными камнями и типичными вопросами интервью. Без воды: только то, что реально спрашивают и что отличает senior от middle.

Execution Context и интерпретатор JavaScript

Execution Context и интерпретатор JavaScript

Почему один и тот же код ведёт себя по-разному в зависимости от того, где он написан? Почему переменная, объявленная внутри функции, недоступна снаружи, а глобальная переменная вдруг оказывается undefined вместо ожидаемого значения? Всё это — следствие того, как JavaScript-движок создаёт и управляет контекстами выполнения (execution contexts). Понимание этого механизма — фундамент, без которого невозможно объяснить ни замыкания, ни this, ни асинхронность.

Как движок читает ваш код

JavaScript-движок (V8 в Chrome/Node.js, SpiderMonkey в Firefox) не выполняет код построчно с первой строки. Перед запуском он проходит две фазы: фазу компиляции (compilation phase) и фазу выполнения (execution phase).

В фазе компиляции движок сканирует весь код, находит объявления переменных и функций, выделяет под них память и создаёт структуры данных — лексическое окружение (lexical environment) и запись окружения (environment record). Именно здесь происходит то, что называют hoisting — подъём объявлений. Но об этом подробнее в следующей статье.

В фазе выполнения движок идёт по коду уже последовательно, присваивает значения, вызывает функции, вычисляет выражения.

Что такое Execution Context

Execution Context — это абстрактная среда, в которой выполняется JavaScript-код. Думайте о нём как о «пузыре», внутри которого живёт конкретный кусок кода: у него есть свои переменные, своё значение this и ссылка на внешнее окружение.

Каждый раз, когда вызывается функция, движок создаёт новый execution context. Когда функция завершается — контекст уничтожается.

Существует три вида контекстов:

  • Global Execution Context — создаётся один раз при запуске скрипта. В браузере this здесь равен window, в Node.js — global.
  • Function Execution Context — создаётся при каждом вызове функции.
  • Eval Execution Context — создаётся при вызове eval(), на практике почти не используется.

Каждый контекст содержит три ключевых компонента:

  1. Variable Environment — хранит переменные, объявленные через var, и объявления функций.
  2. Lexical Environment — хранит переменные let и const, а также ссылку на внешнее лексическое окружение (outer).
  3. This Binding — значение this для данного контекста.

Call Stack: стек вызовов

Call Stack (стек вызовов) — это структура данных типа LIFO (Last In, First Out), которая отслеживает, какой контекст выполнения активен в данный момент. Движок всегда выполняет контекст, находящийся на вершине стека.

function greet(name) {
  return `Hello, ${name}`;
}

function main() {
  const result = greet('Alice');
  console.log(result);
}

main();

Вот что происходит пошагово:

  1. Движок создаёт Global Execution Context и помещает его в стек.
  2. Вызывается main() — создаётся новый контекст, помещается поверх глобального.
  3. Внутри main вызывается greet('Alice') — ещё один контекст ложится на стек.
  4. greet возвращает строку — её контекст снимается со стека.
  5. console.log выполняется в контексте main.
  6. main завершается — её контекст снимается.
  7. Стек содержит только глобальный контекст.
[  greet context  ]  ← вершина стека (активный)
[  main context   ]
[ global context  ]  ← дно стека

Именно поэтому в стектрейсе ошибки вы видите цепочку вызовов снизу вверх — это буквально снимок call stack в момент исключения.

Stack Overflow — это не просто название сайта. Это реальная ошибка, которая возникает, когда стек переполняется из-за бесконечной рекурсии. Движок имеет лимит на глубину стека (в V8 — около 10 000–15 000 фреймов в зависимости от платформы).

function infinite() {
  return infinite(); // RangeError: Maximum call stack size exceeded
}

Лексическое окружение и цепочка областей видимости

Каждый execution context имеет ссылку outer на лексическое окружение родительского контекста. Это и есть механизм scope chain (цепочки областей видимости).

const x = 10;

function outer() {
  const y = 20;

  function inner() {
    const z = 30;
    console.log(x + y + z); // 60
  }

  inner();
}

outer();

Когда inner обращается к x, движок не находит её в собственном лексическом окружении, идёт по ссылке outer в окружение функции outer, не находит там, идёт дальше — в глобальное окружение, находит x = 10. Это статическое (лексическое) связывание — цепочка определяется в момент написания кода, а не в момент вызова.

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

function makeCounter() {
  let count = 0;
  return function() {
    count++;
    return count;
  };
}

const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3

После завершения makeCounter её контекст снят со стека, но лексическое окружение с переменной count не уничтожено — на него держит ссылку возвращённая функция. Это и есть замыкание (closure).

Как создаётся Execution Context: детали

Создание контекста происходит в два этапа.

Этап создания (creation phase):

  • Создаётся LexicalEnvironment и VariableEnvironment.
  • Переменные var инициализируются значением undefined.
  • Объявления функций (function declarations) полностью помещаются в память.
  • Переменные let и const регистрируются, но остаются в Temporal Dead Zone (TDZ) — обращение к ним до строки объявления вызовет ReferenceError.
  • Определяется this.

Этап выполнения (execution phase):

  • Код выполняется построчно.
  • Переменным присваиваются значения.
  • Вызываются функции, создавая новые контексты.

Глобальный контекст и его особенности

В браузере глобальный контекст создаёт объект window и связывает с ним глобальные переменные, объявленные через var. Это одна из причин, почему var в глобальной области видимости — антипаттерн: вы загрязняете глобальный объект.

var legacy = 'I am on window'; // window.legacy === 'I am on window'
let modern = 'I am NOT on window'; // window.modern === undefined

В модулях ES (ES Modules) ситуация другая: каждый модуль получает собственный лексический контекст, и переменные не попадают в window даже при использовании var. Это одна из ключевых причин перехода на модульную систему.

Практический кейс: отладка через понимание контекстов

На реальном проекте встречается такая ситуация: разработчик жалуется, что функция возвращает undefined вместо значения.

function getUserData() {
  if (isLoggedIn) {
    var userData = fetchUser();
  }
  return userData; // undefined, если isLoggedIn === false
}

Причина — var поднимается на уровень функции (function scope), а не блока. В фазе создания контекста userData инициализируется как undefined. Если условие не выполняется, присвоение не происходит, и функция возвращает undefined. Замена var на let сделала бы ошибку явной: ReferenceError в строке return userData сразу указал бы на проблему.

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

Execution Context в асинхронном коде

Важный нюанс: когда колбэк из setTimeout или промис выполняется, для него создаётся новый execution context. Но его лексическое окружение (outer) по-прежнему указывает на то место, где функция была определена, а не где она была вызвана.

function setup() {
  const message = 'Hello from closure';
  setTimeout(function() {
    console.log(message); // 'Hello from closure' — замыкание работает
  }, 1000);
}
setup();

Колбэк выполняется через секунду, контекст setup давно уничтожен — но message доступна, потому что замыкание удерживает ссылку на лексическое окружение. Это фундаментальный механизм, на котором строится вся асинхронная работа с состоянием в JavaScript.

Execution Context — это не просто теория для собеседований. Это модель, которая объясняет поведение кода в любой ситуации: от простого замыкания до сложного асинхронного потока данных.

Понимание execution context, call stack и лексического окружения — это то, что отличает разработчика, который «знает JavaScript», от того, кто «понимает JavaScript». На senior-собеседовании вас не спросят «что такое execution context» в лоб — вас попросят объяснить поведение конкретного кода, и именно эта модель даст вам ответ.

Hoisting, TDZ и механизм поднятия переменных

Hoisting, TDZ и механизм поднятия переменных

Представьте: вы вызываете функцию до её объявления в коде — и она работает. Но когда вы обращаетесь к переменной let до её объявления — получаете ReferenceError. При этом var в той же ситуации возвращает undefined. Три разных поведения, одна причина — и большинство разработчиков объясняют это словом «хойстинг», не понимая, что именно происходит под капотом.

Что на самом деле означает Hoisting

Hoisting (подъём) — это не перемещение кода. Движок не переписывает ваш файл, поднимая объявления наверх. Это метафора для описания того, что происходит в фазе создания execution context: движок сканирует код, находит объявления и выделяет под них память до начала выполнения.

Как было разобрано в предыдущей статье, execution context создаётся в два этапа. В фазе создания движок обрабатывает объявления по-разному в зависимости от того, как они написаны.

Есть три принципиально разных сценария:

Тип объявления Что происходит в фазе создания Значение до строки объявления
function declaration Полностью помещается в память Доступна как функция
var Регистрируется, инициализируется undefined undefined
let / const Регистрируется, но не инициализируется ReferenceError (TDZ)

Function Declaration: полный подъём

sayHello(); // 'Hello!' — работает!

function sayHello() {
  console.log('Hello!');
}

В фазе создания глобального контекста движок находит function sayHello и полностью записывает её в Variable Environment: имя, параметры, тело. К моменту выполнения первой строки функция уже существует в памяти целиком.

Это единственный случай «полного» хойстинга. Именно поэтому в старом JavaScript-коде можно встретить вызовы функций в начале файла, а их объявления — в конце. Это работало намеренно.

var: частичный подъём и ловушки

console.log(name); // undefined — не ReferenceError!
var name = 'Alice';
console.log(name); // 'Alice'

В фазе создания var name регистрируется и инициализируется значением undefined. Присвоение 'Alice' происходит только в фазе выполнения, когда движок доходит до этой строки. Поэтому первый console.log видит undefined, а не ошибку.

Это поведение — источник реальных багов. Классический пример с циклом:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Выводит: 3, 3, 3 — а не 0, 1, 2

Почему? var i поднимается на уровень функции (или глобального контекста). Все три колбэка замыкаются на одну и ту же переменную i. К моменту выполнения таймеров цикл завершился, i === 3. Замена var на let решает проблему: let создаёт новую переменную для каждой итерации блока.

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Выводит: 0, 1, 2

Temporal Dead Zone: зона смерти

Temporal Dead Zone (TDZ, временная мёртвая зона) — это период между началом выполнения блока и строкой объявления переменной let или const, в течение которого переменная существует в памяти, но недоступна.

console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;

Ключевое слово здесь — initialization. Движок знает о существовании x (он зарегистрировал её в фазе создания), но намеренно запрещает к ней доступ. Это не «переменная не существует» — это «переменная существует, но находится в TDZ».

Почему это важно понимать именно так? Потому что typeof — оператор, который обычно безопасен для несуществующих переменных — тоже бросает ошибку в TDZ:

console.log(typeof undeclaredVar); // 'undefined' — безопасно
console.log(typeof x);             // ReferenceError — x в TDZ!
let x = 5;

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

TDZ в классах и параметрах функций

TDZ возникает не только с let/const в блоках. Есть менее очевидные случаи.

Параметры функции по умолчанию вычисляются слева направо, и каждый параметр входит в TDZ до своей инициализации:

function greet(name, greeting = `Hello, ${name}`) {
  return greeting;
}
greet('Alice'); // 'Hello, Alice' — работает

function broken(a = b, b = 1) {
  return a + b;
}
broken(); // ReferenceError: b is not defined — b в TDZ при вычислении a

Классы тоже подчиняются TDZ:

const obj = new MyClass(); // ReferenceError
class MyClass {}

В отличие от function declaration, объявление класса не поднимается полностью. Класс регистрируется, но остаётся в TDZ до строки объявления.

Function Expression и Arrow Function: не путайте с Declaration

sayHi(); // TypeError: sayHi is not a function

var sayHi = function() {
  console.log('Hi!');
};

Здесь var sayHi поднимается и инициализируется как undefined. Вызов sayHi() — это попытка вызвать undefined как функцию, отсюда TypeError. Само выражение функции не поднимается — поднимается только переменная.

То же самое с let/const и стрелочными функциями:

greet(); // ReferenceError: Cannot access 'greet' before initialization

const greet = () => console.log('Hello');

Это одна из самых частых ошибок на собеседованиях: кандидат знает про хойстинг function declarations, но путается с function expressions.

Хойстинг в блоках и функциях

var игнорирует блочную область видимости (if, for, {}), но уважает границы функций:

function example() {
  if (true) {
    var blockVar = 'I am hoisted to function scope';
    let blockLet = 'I stay in block';
  }
  console.log(blockVar); // 'I am hoisted to function scope'
  console.log(blockLet); // ReferenceError
}

blockVar поднимается на уровень функции example. blockLet ограничена блоком if.

Практический антипаттерн — объявление var внутри if с намерением использовать её только в блоке. Это работает «случайно», но создаёт неочевидные зависимости:

function processUser(user) {
  if (user.isAdmin) {
    var permissions = getAdminPermissions();
  }
  // permissions здесь undefined, если user.isAdmin === false
  // но переменная существует — нет ReferenceError
  return permissions; // тихий баг
}

С let этот баг стал бы явным ReferenceError сразу.

Порядок хойстинга при конфликтах имён

Что происходит, если var и function declaration имеют одно имя?

console.log(typeof foo); // 'function'

var foo = 'bar';

function foo() {
  return 'I am a function';
}

console.log(typeof foo); // 'string'

В фазе создания function declarations обрабатываются после var, и побеждают: foo становится функцией. В фазе выполнения присвоение foo = 'bar' перезаписывает значение. Это поведение — ещё один аргумент против var и function declarations в современном коде.

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

На реальных проектах правило простое: всегда используй const по умолчанию, let когда нужно переприсвоение, никогда var. Это не просто стилистика — это устранение целого класса багов, связанных с хойстингом и областью видимости.

При код-ревью стоит обращать внимание на function declarations в условиях и циклах — их поведение нестандартно и зависит от движка:

if (condition) {
  function doSomething() {} // Поведение зависит от strict mode и движка!
}

В строгом режиме ('use strict') такая функция ограничена блоком. Без строгого режима — поведение не определено стандартом и различается между браузерами. Безопасная альтернатива — const doSomething = () => {} внутри блока.

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

Промисы, async/await и генераторы

Промисы, async/await и генераторы

Колбэк-ад — не просто эстетическая проблема. Когда логика обработки ошибок размазана по пяти уровням вложенности, а порядок выполнения асинхронных операций зависит от того, какой сервер ответил быстрее, код становится невозможно поддерживать. Промисы, async/await и генераторы — это три последовательных ответа JavaScript на эту проблему, каждый со своими нюансами и подводными камнями.

Промисы: состояния и механизм

Promise — это объект, представляющий результат асинхронной операции, который может быть получен в будущем. У промиса есть три состояния:

  • Pending — начальное состояние, операция ещё выполняется.
  • Fulfilled — операция завершилась успешно, есть результат.
  • Rejected — операция завершилась с ошибкой.

Переход из pending в fulfilled или rejected необратим. Промис нельзя «сбросить» обратно в pending.

const promise = new Promise((resolve, reject) => {
  // Executor выполняется СИНХРОННО
  console.log('Executor running'); // Выводится сразу

  setTimeout(() => {
    resolve('Success'); // Переводит в fulfilled
  }, 1000);
});

promise
  .then(value => console.log(value))   // 'Success' через 1 сек
  .catch(err => console.error(err))
  .finally(() => console.log('Done')); // Всегда выполняется

Важный нюанс: executor (функция, переданная в new Promise) выполняется синхронно. Асинхронным является только вызов .then/.catch колбэков — они всегда помещаются в очередь микрозадач.

Цепочки промисов и обработка ошибок

Каждый .then возвращает новый промис. Это позволяет строить цепочки:

fetchUser(userId)
  .then(user => fetchPosts(user.id))   // возвращает промис
  .then(posts => filterPublished(posts)) // получает результат предыдущего
  .then(published => render(published))
  .catch(err => handleError(err));      // ловит ошибки из любого шага

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

// ПЛОХО: ошибка в первом .then не поймается вторым .catch
promise
  .then(data => {
    throw new Error('Oops');
  })
  .catch(err => {
    console.log('Caught:', err.message); // Поймается здесь
    // Если здесь тоже бросить ошибку — она уйдёт в unhandledRejection!
    throw new Error('Another error');
  });
  // Нет .catch после — unhandledRejection

Ещё опаснее — возврат промиса без return в цепочке:

// ПЛОХО: забыли return
fetchUser()
  .then(user => {
    fetchPosts(user.id); // Промис создан, но не возвращён!
    // .then продолжит выполнение с undefined, не дожидаясь fetchPosts
  })
  .then(posts => console.log(posts)); // undefined

Promise.all, Promise.race и другие комбинаторы

// Параллельное выполнение — ждёт ВСЕ
const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()]);
// Если хотя бы один реджектится — весь Promise.all реджектится

// Первый завершившийся (fulfilled или rejected)
const fastest = await Promise.race([fetchFromCDN1(), fetchFromCDN2()]);

// Ждёт все, не падает при ошибках
const results = await Promise.allSettled([req1(), req2(), req3()]);
results.forEach(result => {
  if (result.status === 'fulfilled') console.log(result.value);
  if (result.status === 'rejected') console.log(result.reason);
});

// Первый успешный (ES2021)
const first = await Promise.any([mirror1(), mirror2(), mirror3()]);
// Реджектится только если ВСЕ реджектятся — AggregateError

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

async/await: синтаксический сахар над промисами

async/await не вводит новый механизм — это синтаксис поверх промисов. Функция с async всегда возвращает промис. await приостанавливает выполнение текущей async-функции (не всего потока!) до разрешения промиса.

async function loadDashboard(userId) {
  try {
    const user = await fetchUser(userId);
    const [posts, stats] = await Promise.all([
      fetchPosts(user.id),
      fetchStats(user.id)
    ]);
    return { user, posts, stats };
  } catch (err) {
    logger.error('Dashboard load failed', err);
    throw err; // Пробрасываем дальше
  }
}

Типичная ошибка — последовательное await там, где нужна параллельность:

// МЕДЛЕННО: запросы выполняются последовательно
const user = await fetchUser(id);    // ждём
const posts = await fetchPosts(id);  // ждём ещё раз
// Итого: время(user) + время(posts)

// БЫСТРО: параллельно
const [user, posts] = await Promise.all([fetchUser(id), fetchPosts(id)]);
// Итого: max(время(user), время(posts))

На реальном проекте такая ошибка в критическом пути рендеринга может добавить 200–500 мс к времени загрузки страницы.

Обработка ошибок в async/await: подводные камни

// Антипаттерн: try/catch вокруг каждого await
async function bad() {
  try {
    const a = await stepA();
  } catch (e) { /* ... */ }

  try {
    const b = await stepB();
  } catch (e) { /* ... */ }
}

// Лучше: один try/catch с гранулярной обработкой
async function better() {
  try {
    const a = await stepA();
    const b = await stepB(a);
    return b;
  } catch (e) {
    if (e instanceof NetworkError) return fallback();
    throw e;
  }
}

Паттерн «Go-style» для избежания try/catch:

const [error, data] = await fetchUser(id)
  .then(data => [null, data])
  .catch(err => [err, null]);

if (error) return handleError(error);
// data гарантированно существует

Генераторы: функции с паузой

Генератор — это функция, которую можно приостановить и возобновить. Объявляется через function*, приостанавливается через yield.

function* counter() {
  let i = 0;
  while (true) {
    yield i++;
  }
}

const gen = counter();
gen.next(); // { value: 0, done: false }
gen.next(); // { value: 1, done: false }
gen.next(); // { value: 2, done: false }

Вызов counter() не выполняет тело функции — он создаёт объект-генератор (generator object). Выполнение начинается только при первом вызове .next() и останавливается на каждом yield.

Генераторы реализуют протокол итератора: объект с методом next(), возвращающим { value, done }. Это позволяет использовать их в for...of:

function* range(start, end, step = 1) {
  for (let i = start; i < end; i += step) {
    yield i;
  }
}

for (const num of range(0, 10, 2)) {
  console.log(num); // 0, 2, 4, 6, 8
}

// Spread тоже работает
const nums = [...range(1, 6)]; // [1, 2, 3, 4, 5]

Двусторонняя коммуникация через yield

yield может не только отдавать значения наружу, но и принимать их внутрь через аргумент .next(value):

function* dialog() {
  const name = yield 'What is your name?';
  const age = yield `Hello, ${name}! How old are you?`;
  return `${name} is ${age} years old`;
}

const gen = dialog();
gen.next();           // { value: 'What is your name?', done: false }
gen.next('Alice');    // { value: 'Hello, Alice! How old are you?', done: false }
gen.next(30);         // { value: 'Alice is 30 years old', done: true }

Именно этот механизм использовала библиотека redux-saga для управления сайд-эффектами: генератор описывает последовательность операций, а раннер передаёт результаты обратно через .next().

Асинхронные генераторы и for await...of

Асинхронный генератор (async function*) объединяет оба мира: можно использовать await внутри генератора и yield для передачи значений:

async function* streamData(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    yield new TextDecoder().decode(value);
  }
}

// Обработка стримингового ответа
for await (const chunk of streamData('/api/large-dataset')) {
  processChunk(chunk);
}

Это реальный паттерн для работы со стриминговыми API — например, при реализации чата с LLM, где ответ приходит по частям. for await...of ждёт каждый yield из асинхронного генератора.

Когда что использовать

Сценарий Инструмент
Один асинхронный запрос async/await
Несколько параллельных запросов Promise.all + await
Независимые запросы, нужны все результаты Promise.allSettled
Ленивые последовательности данных Генераторы
Стриминговые данные Асинхронные генераторы
Сложные сайд-эффекты (saga-паттерн) Генераторы

Генераторы редко используются напрямую в прикладном коде, но понимание их механизма объясняет, как работают redux-saga, как устроены итераторы в ES6 и как реализован for...of под капотом. На senior-собеседовании вопрос «как работает for...of» — это вопрос про итераторы и протокол Symbol.iterator, который генераторы реализуют автоматически.

Микро- и макрозадачи в Event Loop JavaScript

Микро- и макрозадачи в Event Loop JavaScript

Почему Promise.resolve().then(...) выполняется раньше setTimeout(..., 0)? Почему иногда UI «замерзает», хотя вы уверены, что вынесли тяжёлую работу в асинхронный код? Ответ на оба вопроса — в архитектуре Event Loop и в том, как JavaScript разделяет задачи на два принципиально разных типа.

JavaScript — однопоточный, но не блокирующий

JavaScript выполняет код в одном потоке. Это означает, что в каждый момент времени выполняется ровно одна операция. Но браузер при этом не замирает: он обрабатывает клики, рисует анимации, получает данные от серверов. Это возможно благодаря Event Loop — механизму, который координирует выполнение кода, обработку событий и асинхронные операции.

Модель выглядит так:

  • Call Stack — стек вызовов, где выполняется синхронный код (разобран в первой статье).
  • Web APIs — браузерные API (таймеры, fetch, DOM-события), которые работают вне стека.
  • Macrotask Queue — очередь макрозадач.
  • Microtask Queue — очередь микрозадач.

Event Loop работает по простому принципу: пока call stack пуст, взять следующую задачу из очереди и выполнить её.

Макрозадачи: крупные единицы работы

Макрозадача (macrotask, иногда называемая просто task) — это единица работы, которую браузер ставит в очередь для выполнения в отдельном «тике» Event Loop.

Источники макрозадач:

  • setTimeout и setInterval
  • События DOM (click, keydown, load)
  • MessageChannel
  • setImmediate (только Node.js)
  • Парсинг HTML-скрипта

После выполнения каждой макрозадачи браузер получает возможность перерисовать экран. Это важно: если макрозадача выполняется долго, браузер не может обновить UI, и страница «замерзает».

Микрозадачи: высокоприоритетная очередь

Микрозадача (microtask) — это задача, которая выполняется сразу после завершения текущей задачи (или текущего синхронного кода), но до следующей макрозадачи и до перерисовки.

Источники микрозадач:

  • .then(), .catch(), .finally() промисов
  • queueMicrotask()
  • MutationObserver
  • await (каждый await создаёт минимум одну микрозадачу)

Критическое правило: очередь микрозадач полностью опустошается перед каждой следующей макрозадачей. Если микрозадача добавляет новую микрозадачу — она тоже выполнится до следующей макрозадачи.

console.log('1: sync start');

setTimeout(() => console.log('2: macrotask'), 0);

Promise.resolve()
  .then(() => console.log('3: microtask 1'))
  .then(() => console.log('4: microtask 2'));

console.log('5: sync end');

// Вывод:
// 1: sync start
// 5: sync end
// 3: microtask 1
// 4: microtask 2
// 2: macrotask

Разберём пошагово:

  1. Синхронный код выполняется: 1, затем 5.
  2. setTimeout регистрирует колбэк в Web APIs → через 0 мс попадёт в очередь макрозадач.
  3. Promise.resolve() создаёт уже выполненный промис → .then колбэки попадают в очередь микрозадач.
  4. Call stack пуст → Event Loop проверяет очередь микрозадач: выполняет 3, затем 4.
  5. Очередь микрозадач пуста → берётся следующая макрозадача: 2.

Бесконечные микрозадачи: ловушка

Поскольку очередь микрозадач опустошается полностью, бесконечная рекурсия через микрозадачи заблокирует Event Loop навсегда — браузер не сможет ни перерисовать страницу, ни обработать события:

function infiniteMicrotasks() {
  Promise.resolve().then(infiniteMicrotasks);
}
infiniteMicrotasks(); // Страница зависает

Это отличается от setTimeout с рекурсией: там каждый вызов — новая макрозадача, между которыми браузер успевает дышать.

async/await и микрозадачи

Каждый await создаёт точку приостановки, после которой продолжение функции ставится в очередь микрозадач:

async function asyncFunc() {
  console.log('A');
  await Promise.resolve();
  console.log('B'); // Это микрозадача
}

console.log('1');
asyncFunc();
console.log('2');

// Вывод: 1, A, 2, B

asyncFunc() начинает выполняться синхронно до первого await. После await продолжение (console.log('B')) ставится в очередь микрозадач. Синхронный код продолжается: 2. Затем микрозадача: B.

Более сложный пример с несколькими await:

async function first() {
  console.log('first: start');
  await Promise.resolve();
  console.log('first: after await 1');
  await Promise.resolve();
  console.log('first: after await 2');
}

async function second() {
  console.log('second: start');
  await Promise.resolve();
  console.log('second: after await');
}

first();
second();

// Вывод:
// first: start
// second: start
// first: after await 1
// second: after await
// first: after await 2

Каждый await — это точка, где другие микрозадачи могут «вклиниться». Функции чередуются, как кооперативная многозадачность.

setTimeout(0): не то, что вы думаете

setTimeout(fn, 0) не означает «выполни немедленно». Это означает «поставь в очередь макрозадач как можно скорее». Но:

  1. Минимальная задержка в браузерах — 4 мс (для вложенных таймеров \geq 5 уровней).
  2. Перед выполнением колбэка выполнятся все текущие микрозадачи.
  3. Браузер может перерисовать экран между макрозадачами.

Реальный кейс: разработчик хочет обновить DOM и сразу прочитать размеры элемента:

element.classList.add('expanded');
// Размеры ещё не обновились — браузер не перерисовал
const height = element.offsetHeight; // Принудительный reflow

// Правильно: дать браузеру перерисовать
requestAnimationFrame(() => {
  const height = element.offsetHeight; // Актуальные размеры
});

queueMicrotask: явное управление

queueMicrotask() позволяет явно поставить функцию в очередь микрозадач без создания промиса:

queueMicrotask(() => {
  console.log('This is a microtask');
});

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

Порядок выполнения: полная картина

Алгоритм Event Loop в браузере:

  1. Выполнить одну макрозадачу из очереди (или синхронный скрипт при старте).
  2. Полностью опустошить очередь микрозадач (включая новые, добавленные в процессе).
  3. Если нужна перерисовка — выполнить requestAnimationFrame колбэки, затем перерисовать.
  4. Если есть время — выполнить requestIdleCallback колбэки.
  5. Вернуться к шагу 1.
Макрозадача → [Все микрозадачи] → [rAF + Render] → [rIC] → Макрозадача → ...

Это объясняет, почему requestAnimationFrame идеален для анимаций: он выполняется синхронно с циклом перерисовки браузера, гарантируя 60 кадров в секунду при отсутствии тяжёлых вычислений.

Практический кейс: батчинг и производительность

На реальном проекте встречается паттерн, когда нужно выполнить множество обновлений состояния и применить их за один рендер. React 18 делает это автоматически (batching), но понимание Event Loop объясняет, почему это работает:

// Без батчинга: три отдельных рендера
setState1(value1); // рендер
setState2(value2); // рендер
setState3(value3); // рендер

// С батчингом через микрозадачу:
queueMicrotask(() => {
  applyAllUpdates([update1, update2, update3]); // один рендер
});

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

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

this, контекст и стрелочные функции

this, контекст и стрелочные функции

Ни одна тема в JavaScript не вызывает столько путаницы на собеседованиях, как this. Разработчики, которые годами пишут на React, вдруг обнаруживают, что не могут объяснить, почему this в колбэке равен undefined. Причина в том, что this в JavaScript — не то, чем кажется. Это не переменная и не свойство объекта. Это динамическая привязка, которая определяется в момент вызова функции, а не в момент её определения.

Четыре правила привязки this

Значение this определяется по четырём правилам, которые применяются в порядке приоритета от низшего к высшему.

Привязка по умолчанию

Если функция вызывается без какого-либо контекста — this равен глобальному объекту (window в браузере) или undefined в строгом режиме:

function showThis() {
  console.log(this);
}

showThis(); // window (в браузере, non-strict)
// или undefined (в strict mode)

В современном коде с ES-модулями и 'use strict' привязка по умолчанию всегда даёт undefined. Это важно: если вы видите TypeError: Cannot read properties of undefined (reading 'someMethod') — скорее всего, функция потеряла контекст.

Неявная привязка

Если функция вызывается как метод объекта — this равен этому объекту:

const user = {
  name: 'Alice',
  greet() {
    console.log(`Hello, ${this.name}`);
  }
};

user.greet(); // 'Hello, Alice' — this === user

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

const greet = user.greet;
greet(); // 'Hello, undefined' — this === window/undefined

setTimeout(user.greet, 100); // Тоже потеряет контекст!

Это один из самых частых багов в JavaScript. Функция greet «отрывается» от объекта user и вызывается без контекста.

Явная привязка: call, apply, bind

call, apply и bind позволяют явно указать this:

function introduce(greeting, punctuation) {
  console.log(`${greeting}, I'm ${this.name}${punctuation}`);
}

const alice = { name: 'Alice' };

introduce.call(alice, 'Hello', '!');   // 'Hello, I'm Alice!'
introduce.apply(alice, ['Hi', '.']);   // 'Hi, I'm Alice.'

const boundIntroduce = introduce.bind(alice);
boundIntroduce('Hey', '?');            // 'Hey, I'm Alice?'

Разница: call и apply вызывают функцию немедленно, bind возвращает новую функцию с зафиксированным this. apply принимает аргументы массивом — удобно, когда аргументы уже в массиве.

bind создаёт жёсткую привязку (hard binding): даже повторный bind или call не смогут изменить this у уже связанной функции:

const bound = introduce.bind(alice);
const bob = { name: 'Bob' };

bound.call(bob, 'Hello', '!'); // 'Hello, I'm Alice!' — bob игнорируется

Привязка через new

Когда функция вызывается с new, создаётся новый объект, и this внутри конструктора указывает на него:

function Person(name) {
  this.name = name;
  this.greet = function() {
    console.log(`Hi, I'm ${this.name}`);
  };
}

const alice = new Person('Alice');
alice.greet(); // 'Hi, I'm Alice'

new делает четыре вещи: создаёт новый объект, устанавливает его прототип, привязывает this к новому объекту, возвращает объект (если конструктор не возвращает другой объект явно).

Стрелочные функции: лексический this

Стрелочные функции (arrow functions) — это не просто короткий синтаксис. Они принципиально отличаются от обычных функций: у них нет собственного this. Вместо этого они захватывают this из лексического окружения — того места, где функция была определена.

const timer = {
  seconds: 0,
  start() {
    // this здесь === timer (неявная привязка)
    setInterval(() => {
      this.seconds++; // this захвачен из start() — это timer
      console.log(this.seconds);
    }, 1000);
  }
};

timer.start(); // 1, 2, 3...

Если бы вместо стрелочной функции использовалась обычная — this внутри setInterval был бы window (или undefined в strict mode), и this.seconds не работало бы.

Именно поэтому стрелочные функции стали стандартным решением для колбэков в методах класса. До их появления разработчики использовали const self = this или .bind(this).

Приоритет правил

Правила применяются в следующем порядке (от высшего приоритета к низшему):

  1. new — наивысший приоритет
  2. Явная привязка (call, apply, bind)
  3. Неявная привязка (метод объекта)
  4. Привязка по умолчанию
function test() { console.log(this.x); }
const obj1 = { x: 1, test };
const obj2 = { x: 2 };

obj1.test();              // 1 — неявная
obj1.test.call(obj2);    // 2 — явная побеждает неявную
new obj1.test();         // undefined — new создаёт новый объект без x

this в классах

В ES6-классах методы по умолчанию работают в строгом режиме, поэтому потеря контекста даёт undefined, а не window:

class Button {
  constructor(label) {
    this.label = label;
  }

  // Обычный метод — теряет this при передаче как колбэк
  handleClick() {
    console.log(this.label);
  }

  // Class field arrow function — this всегда привязан
  handleClickArrow = () => {
    console.log(this.label);
  };
}

const btn = new Button('Submit');
const handler = btn.handleClick;
handler(); // TypeError: Cannot read properties of undefined

const arrowHandler = btn.handleClickArrow;
arrowHandler(); // 'Submit' — работает

Class field arrow functions (handleClickArrow = () => {}) — это не метод прототипа, а свойство экземпляра. Каждый экземпляр получает свою копию функции. Это важно для производительности: если у вас тысячи экземпляров, тысячи копий функции в памяти. Обычные методы прототипа — одна копия на всех.

В React-компонентах на классах это был стандартный паттерн для обработчиков событий. С переходом на функциональные компоненты проблема исчезла — у стрелочных функций и хуков нет этой проблемы.

Подводные камни: когда this неожиданный

Деструктуризация метода:

const { greet } = user;
greet(); // Потеря контекста — this не user

Вложенные функции:

const obj = {
  value: 42,
  outer() {
    function inner() {
      console.log(this.value); // undefined — inner вызвана без контекста
    }
    inner();

    // Решение 1: стрелочная функция
    const innerArrow = () => console.log(this.value); // 42
    innerArrow();
  }
};

Опциональная цепочка и this:

const method = obj?.someMethod;
method?.(); // Вызов без контекста — this потерян!
// Правильно:
obj?.someMethod?.();

Getter и setter:

const obj = {
  _name: 'Alice',
  get name() {
    return this._name; // this === obj при доступе через obj.name
  }
};

const { name } = obj; // Вызывает getter, возвращает строку 'Alice'
// Это не метод — деструктуризация вычисляет значение

Как объяснить this на собеседовании

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

  1. Вызывается ли функция с new? → this = новый объект.
  2. Вызывается ли с call/apply/bind? → this = первый аргумент.
  3. Вызывается ли как метод объекта (obj.fn())? → this = объект слева от точки.
  4. Ни одно из выше? → this = undefined (strict) или window (non-strict).

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

Это не просто теория для собеседования. Понимание this объясняет, почему React-хуки не используют классы, почему Vue 3 перешёл на Composition API, и почему в современном JavaScript стрелочные функции стали предпочтительным выбором для большинства колбэков.

Прототипы, prototype chain и наследование

Прототипы, prototype chain и наследование

Когда вы пишете [].map(...) или 'hello'.toUpperCase(), вы используете методы, которых нет непосредственно на массиве или строке. Откуда они берутся? Ответ — прототипное наследование, механизм, который лежит в основе всей объектной системы JavaScript. Понимание этого механизма объясняет не только «магию» встроенных методов, но и то, как работают классы ES6 под капотом.

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

Каждый объект в JavaScript имеет внутреннее свойство [[Prototype]] — ссылку на другой объект, называемый его прототипом. Когда вы обращаетесь к свойству объекта, движок сначала ищет его на самом объекте, а если не находит — идёт по цепочке прототипов вверх.

const animal = {
  breathe() {
    return 'breathing';
  }
};

const dog = Object.create(animal);
dog.bark = function() { return 'woof'; };

dog.bark();    // 'woof' — найдено на самом объекте
dog.breathe(); // 'breathing' — найдено на прототипе animal
dog.toString(); // '[object Object]' — найдено на Object.prototype

Object.create(animal) создаёт новый объект, у которого [[Prototype]] установлен в animal. Это самый явный и чистый способ создать прототипную связь.

Доступ к [[Prototype]] через код: Object.getPrototypeOf(dog) === animaltrue. Устаревший dog.__proto__ тоже работает, но в продакшн-коде использовать не стоит.

Prototype Chain: цепочка до null

Цепочка прототипов всегда заканчивается на null. Стандартная цепочка для обычного объекта:

dog → animal → Object.prototype → null

Object.prototype — вершина иерархии для всех объектов. Именно здесь живут toString, hasOwnProperty, valueOf и другие универсальные методы.

Для массивов цепочка длиннее:

[] → Array.prototype → Object.prototype → null

Array.prototype содержит map, filter, reduce и другие методы массивов. Когда вы вызываете [1,2,3].map(...), движок не находит map на самом массиве, идёт к Array.prototype и находит там.

Функции-конструкторы и свойство prototype

У каждой функции есть свойство .prototype — это не [[Prototype]] самой функции. Это объект, который станет [[Prototype]] для объектов, созданных через new.

function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  return `Hi, I'm ${this.name}`;
};

const alice = new Person('Alice');
const bob = new Person('Bob');

alice.greet(); // 'Hi, I'm Alice'
bob.greet();   // 'Hi, I'm Bob'

// Оба объекта разделяют ОДИН метод greet через прототип
Object.getPrototypeOf(alice) === Person.prototype; // true
alice.hasOwnProperty('name');  // true — собственное свойство
alice.hasOwnProperty('greet'); // false — на прототипе

Это ключевой момент для производительности: методы на прототипе существуют в одном экземпляре и разделяются всеми объектами. Если бы greet определялась в конструкторе (this.greet = function() {}), каждый объект получал бы свою копию.

Классы ES6: синтаксический сахар

Классы ES6 — это не новая система объектов. Это синтаксический сахар поверх прототипного наследования:

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    return `${this.name} makes a sound`;
  }
}

class Dog extends Animal {
  speak() {
    return `${this.name} barks`;
  }
}

const d = new Dog('Rex');
d.speak(); // 'Rex barks'

Под капотом это эквивалентно:

function Animal(name) {
  this.name = name;
}
Animal.prototype.speak = function() {
  return `${this.name} makes a sound`;
};

function Dog(name) {
  Animal.call(this, name); // super()
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.speak = function() {
  return `${this.name} barks`;
};

Цепочка прототипов для Dog:

d → Dog.prototype → Animal.prototype → Object.prototype → null

extends устанавливает эту цепочку автоматически. super() в конструкторе вызывает родительский конструктор с правильным this.

Важные отличия классов от функций-конструкторов

Несмотря на то что классы — синтаксический сахар, есть реальные отличия:

  • Классы всегда работают в строгом режиме.
  • Классы не поднимаются (TDZ, как let/const).
  • Методы класса не перечисляемы (enumerable: false), в отличие от методов, добавленных на прототип вручную.
  • Вызов класса без new бросает TypeError.
class Foo {}
const f = Foo(); // TypeError: Class constructor Foo cannot be invoked without 'new'

function Bar() {}
const b = Bar(); // undefined — просто вызов функции

Проверка принадлежности: instanceof и isPrototypeOf

instanceof проверяет, есть ли Constructor.prototype в цепочке прототипов объекта:

d instanceof Dog;    // true
d instanceof Animal; // true — Animal.prototype в цепочке
d instanceof Object; // true — Object.prototype тоже в цепочке

Подводный камень: instanceof смотрит на Constructor.prototype, а не на сам конструктор. Если заменить Dog.prototype, старые объекты перестанут быть instanceof Dog:

const rex = new Dog('Rex');
Dog.prototype = {}; // Заменяем прототип
rex instanceof Dog; // false! rex связан со старым прототипом

Object.prototype.isPrototypeOf — более надёжная альтернатива:

Animal.prototype.isPrototypeOf(d); // true

Полифиллы и расширение прототипов

Полифиллы — это реализации новых методов для старых окружений через добавление на прототип:

// Полифилл для Array.prototype.flat
if (!Array.prototype.flat) {
  Array.prototype.flat = function(depth = 1) {
    return depth > 0
      ? this.reduce((acc, val) =>
          acc.concat(Array.isArray(val) ? val.flat(depth - 1) : val), [])
      : this.slice();
  };
}

Расширение встроенных прототипов в продакшн-коде — антипаттерн. Причины:

  1. Конфликты с будущими стандартами (именно так Array.prototype.flatten пришлось переименовать в flat из-за конфликта с библиотекой MooTools).
  2. Конфликты между библиотеками.
  3. Неожиданное поведение в for...in циклах (если метод перечисляемый).

Правильный подход — использовать утилитные функции или полифилл-библиотеки (core-js), которые добавляют методы безопасно.

Object.create(null): объект без прототипа

Иногда нужен объект без прототипной цепочки — например, для использования как чистого словаря:

const dict = Object.create(null);
dict.key = 'value';

dict.toString;       // undefined — нет Object.prototype
dict.hasOwnProperty; // undefined — нет Object.prototype

// Безопасная проверка наличия ключа:
'key' in dict; // true

Это полезно, когда ключи могут совпадать с именами методов Object.prototype (constructor, toString, valueOf). В обычном объекте {} такие ключи конфликтуют с унаследованными свойствами.

Миксины: горизонтальное переиспользование

Прототипное наследование — вертикальное (цепочка). Для горизонтального переиспользования используют миксины:

const Serializable = {
  serialize() {
    return JSON.stringify(this);
  },
  deserialize(data) {
    return Object.assign(this, JSON.parse(data));
  }
};

const Validatable = {
  validate() {
    return Object.keys(this).every(key => this[key] !== null);
  }
};

class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

Object.assign(User.prototype, Serializable, Validatable);

const user = new User('Alice', 'alice@example.com');
user.serialize();  // '{"name":"Alice","email":"alice@example.com"}'
user.validate();   // true

Миксины — альтернатива множественному наследованию, которого в JavaScript нет. Это паттерн, который используется в реальных проектах для добавления поведения без создания глубоких иерархий наследования.

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

Объекты, ссылки, копирование и мутации

Объекты, ссылки, копирование и мутации

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

Примитивы и объекты: фундаментальное различие

В JavaScript все значения делятся на два типа по способу хранения и передачи.

Примитивы (string, number, boolean, null, undefined, symbol, bigint) хранятся по значению. При присвоении или передаче в функцию создаётся независимая копия:

let a = 42;
let b = a;
b = 100;
console.log(a); // 42 — не изменилось

Объекты (включая массивы и функции) хранятся по ссылке. Переменная содержит не сам объект, а адрес в памяти, где он находится. При присвоении копируется адрес, а не данные:

const user = { name: 'Alice', age: 30 };
const copy = user;

copy.name = 'Bob';
console.log(user.name); // 'Bob' — изменился оригинал!

copy и user — две переменные, указывающие на один и тот же объект в памяти. Изменение через любую из них меняет один объект.

Поверхностное копирование

Поверхностное копирование (shallow copy) создаёт новый объект и копирует в него свойства верхнего уровня. Вложенные объекты при этом по-прежнему копируются по ссылке.

Три способа поверхностного копирования:

const original = { name: 'Alice', address: { city: 'Moscow' } };

// Способ 1: Object.assign
const copy1 = Object.assign({}, original);

// Способ 2: spread operator
const copy2 = { ...original };

// Способ 3: для массивов
const arr = [1, 2, { x: 3 }];
const arrCopy = [...arr];
// или
const arrCopy2 = arr.slice();

Проблема поверхностного копирования:

copy1.name = 'Bob';
console.log(original.name); // 'Alice' — примитив скопирован

copy1.address.city = 'SPb';
console.log(original.address.city); // 'SPb' — объект по ссылке!

address — это вложенный объект. Поверхностная копия скопировала ссылку на него, а не сам объект. Изменение copy1.address.city меняет тот же объект, на который указывает original.address.

Глубокое копирование

Глубокое копирование (deep copy) рекурсивно копирует все уровни вложенности, создавая полностью независимую структуру.

structuredClone: современный стандарт

structuredClone — нативный API, появившийся в браузерах в 2022 году и в Node.js 17+:

const original = {
  name: 'Alice',
  address: { city: 'Moscow' },
  scores: [1, 2, 3],
  date: new Date(),
  map: new Map([['key', 'value']])
};

const deep = structuredClone(original);
deep.address.city = 'SPb';
console.log(original.address.city); // 'Moscow' — не изменилось

structuredClone поддерживает: Date, Map, Set, ArrayBuffer, RegExp, циклические ссылки. Это его главное преимущество перед другими методами.

Ограничения structuredClone:

  • Не копирует функции (бросает DataCloneError)
  • Не копирует Symbol-ключи
  • Не копирует прототипы (результат — plain object)
  • Не копирует геттеры/сеттеры
const obj = {
  fn: () => 'hello',    // DataCloneError!
  [Symbol('id')]: 123   // Потеряется
};
structuredClone(obj); // Ошибка из-за функции

JSON.parse(JSON.stringify(...)): старый трюк с ограничениями

const deep = JSON.parse(JSON.stringify(original));

Работает для простых данных, но имеет серьёзные ограничения:

Тип данных Результат
undefined Свойство теряется
function Свойство теряется
Date Превращается в строку
Map, Set Превращаются в {}
Infinity, NaN Превращаются в null
Циклические ссылки TypeError

На практике этот метод допустим только для данных, которые точно сериализуемы в JSON — например, ответы от API без специальных типов.

Рекурсивное копирование вручную

Когда нужен контроль над процессом:

function deepClone(value, visited = new WeakMap()) {
  if (value === null || typeof value !== 'object') return value;
  if (visited.has(value)) return visited.get(value); // Циклические ссылки

  if (value instanceof Date) return new Date(value);
  if (value instanceof RegExp) return new RegExp(value);
  if (value instanceof Map) {
    const mapCopy = new Map();
    visited.set(value, mapCopy);
    value.forEach((v, k) => mapCopy.set(deepClone(k, visited), deepClone(v, visited)));
    return mapCopy;
  }

  const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
  visited.set(value, copy);

  for (const key of Reflect.ownKeys(value)) {
    copy[key] = deepClone(value[key], visited);
  }

  return copy;
}

WeakMap для отслеживания уже скопированных объектов решает проблему циклических ссылок. Reflect.ownKeys включает Symbol-ключи.

Мутации: когда они опасны

Мутация (mutation) — изменение объекта на месте. В JavaScript объекты мутабельны по умолчанию. Мутации опасны в нескольких контекстах:

В React: мутация стейта напрямую не вызывает ре-рендер, потому что ссылка на объект не меняется:

// ПЛОХО: мутация — React не увидит изменение
const [items, setItems] = useState([1, 2, 3]);
items.push(4); // Мутируем массив
setItems(items); // Та же ссылка — React пропустит ре-рендер!

// ХОРОШО: новый массив
setItems([...items, 4]);

В функциях: функция, мутирующая аргумент — это побочный эффект (side effect), который нарушает предсказуемость:

// ПЛОХО: мутирует входной массив
function addItem(arr, item) {
  arr.push(item); // Мутация!
  return arr;
}

// ХОРОШО: возвращает новый массив
function addItem(arr, item) {
  return [...arr, item];
}

В Redux: иммутабельность — обязательное требование. Редьюсер должен возвращать новый объект состояния, а не мутировать старый. Именно поэтому появился Immer — библиотека, которая позволяет писать «мутирующий» код, а под капотом создаёт иммутабельные копии через Proxy.

Object.freeze и Object.seal

Object.freeze делает объект полностью неизменяемым (поверхностно):

const config = Object.freeze({
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  nested: { retries: 3 }
});

config.apiUrl = 'https://other.com'; // Молча игнорируется (TypeError в strict mode)
config.nested.retries = 10; // РАБОТАЕТ — freeze поверхностный!

Object.seal запрещает добавление/удаление свойств, но позволяет изменять существующие:

const obj = Object.seal({ x: 1, y: 2 });
obj.x = 10;    // OK
obj.z = 3;     // Игнорируется
delete obj.x;  // Игнорируется

Для глубокой заморозки нужна рекурсия:

function deepFreeze(obj) {
  Object.getOwnPropertyNames(obj).forEach(name => {
    const value = obj[name];
    if (typeof value === 'object' && value !== null) {
      deepFreeze(value);
    }
  });
  return Object.freeze(obj);
}

Performance: когда копирование дорого

Глубокое копирование больших объектов — дорогая операция. На реальных проектах это проявляется в нескольких сценариях:

Нормализация данных: вместо глубоко вложенных объектов хранить плоскую структуру с идентификаторами (как в базе данных). Тогда обновление одной записи не требует копирования всего дерева.

Immer: использует Proxy для отслеживания изменений и создаёт минимально необходимые копии — только изменённые ветки дерева. Неизменённые части разделяются по ссылке (structural sharing).

import produce from 'immer';

const nextState = produce(currentState, draft => {
  draft.users[0].name = 'Bob'; // Выглядит как мутация
  // Immer создаст новый объект только для изменённых узлов
});
// currentState.users[1] === nextState.users[1] — та же ссылка

Это структурное разделение (structural sharing) — ключевой паттерн для эффективной работы с иммутабельными данными в больших приложениях. Именно его использует Immutable.js и большинство state-менеджеров под капотом.

Понимание ссылок и мутаций — это не просто знание для собеседования. Это навык, который напрямую влияет на корректность React-приложений, производительность state-менеджеров и предсказуемость функций в любой кодовой базе.

Парсинг страницы и Critical Rendering Path

Парсинг страницы и Critical Rendering Path

Пользователь открывает страницу и видит белый экран. Через секунду появляется контент. Ещё через полсекунды — стили. Это не случайность и не «медленный сервер». Это следствие того, как браузер строго последовательно обрабатывает ресурсы, и каждый шаг этого процесса может стать узким местом. Critical Rendering Path — это последовательность шагов, которую браузер должен пройти, прежде чем отрисует первый пиксель.

Шаг 1: Парсинг HTML и построение DOM

Браузер получает HTML как поток байт. Первое, что он делает — токенизация: преобразование байт в токены (<html>, <body>, <div>, текстовые узлы и т.д.). Затем из токенов строится DOM (Document Object Model) — дерево объектов, представляющих структуру документа.

Парсинг HTML — инкрементальный: браузер не ждёт загрузки всего документа. Он строит DOM по мере получения данных и может начать загружать ресурсы (CSS, изображения) ещё до завершения парсинга. Это называется preload scanner — отдельный поток, который смотрит вперёд по HTML и инициирует загрузку ресурсов.

Критический момент: парсинг HTML останавливается при встрече тега <script> (без атрибутов async/defer). Браузер должен загрузить и выполнить скрипт, прежде чем продолжить. Причина: скрипт может вызвать document.write() и изменить структуру документа.

<html>
<head>
  <link rel="stylesheet" href="styles.css"> <!-- Не блокирует парсинг HTML -->
  <script src="app.js"></script>            <!-- БЛОКИРУЕТ парсинг HTML -->
</head>
<body>
  <!-- Этот контент не будет обработан, пока app.js не загрузится и не выполнится -->
  <h1>Hello</h1>
</body>
</html>

Шаг 2: Парсинг CSS и построение CSSOM

Параллельно с HTML браузер загружает CSS и строит CSSOM (CSS Object Model) — дерево, аналогичное DOM, но для стилей. Каждый узел CSSOM содержит вычисленные стили для соответствующего элемента.

CSSOM строится атомарно: браузер не может использовать частично построенный CSSOM, потому что каскад CSS требует знания всех правил. Если правило в конце файла переопределяет правило в начале — нужно знать оба.

CSS блокирует рендеринг (render-blocking), но не блокирует парсинг HTML. Браузер не отрисует ничего, пока не построит CSSOM. Это означает: большой CSS-файл задерживает первую отрисовку, даже если HTML уже готов.

Менее очевидный факт: CSS блокирует выполнение JavaScript. Если после <link rel="stylesheet"> идёт <script>, браузер не выполнит скрипт, пока не загрузит и не обработает CSS. Причина: скрипт может читать вычисленные стили (getComputedStyle), и они должны быть актуальными.

<link rel="stylesheet" href="heavy.css">  <!-- Загружается -->
<script>
  // Этот скрипт НЕ выполнится, пока heavy.css не загружен!
  const color = getComputedStyle(document.body).color;
</script>

Шаг 3: Построение Render Tree

После того как DOM и CSSOM готовы, браузер строит Render Tree — дерево, которое содержит только видимые элементы с их вычисленными стилями.

Render Tree ≠ DOM. Ключевые отличия:

  • Элементы с display: none не включаются в Render Tree (в отличие от visibility: hidden — они включаются, просто невидимы).
  • Псевдоэлементы (::before, ::after) включаются в Render Tree, хотя их нет в DOM.
  • Элементы <head>, <script>, <meta> не включаются.
DOM:                    CSSOM:              Render Tree:
html                    html { ... }        html
├── head                body { ... }        └── body
│   └── style           h1 { color: red }      ├── h1 (color: red)
└── body                p { display: none }    └── div
    ├── h1                                         └── span
    ├── p (display:none)
    └── div
        └── span

Шаг 4: Layout (Reflow)

На этом шаге браузер вычисляет геометрию каждого элемента Render Tree: точные координаты, размеры, позиции. Это называется layout или reflow.

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

Шаг 5: Paint и Composite

После layout браузер рисует (paint) пиксели на экране. Затем, если страница использует слои (CSS transforms, opacity, will-change), слои компонуются (composite) в финальное изображение.

Подробнее о repaint, reflow и composite — в следующей статье. Здесь важно понять место этих шагов в общей цепочке.

Что такое «критический» в Critical Rendering Path

Критические ресурсы — это ресурсы, которые блокируют первую отрисовку страницы. К ним относятся:

  • CSS-файлы в <head> (render-blocking)
  • JavaScript-файлы без async/defer (parser-blocking)

Критический путь — это минимальная последовательность шагов от получения HTML до первой отрисовки. Оптимизация CRP — это сокращение числа критических ресурсов, уменьшение их размера и сокращения числа round-trips до сервера.

Метрики, которые напрямую связаны с CRP:

  • FCP (First Contentful Paint) — когда браузер отрисовал первый контент.
  • LCP (Largest Contentful Paint) — когда отрисован самый большой элемент в viewport.
  • TTI (Time to Interactive) — когда страница стала интерактивной.

Практические оптимизации CRP

Встраивание критического CSS (Critical CSS inlining): вместо загрузки всего CSS-файла встроить в <style> только стили, необходимые для отрисовки контента выше линии сгиба (above the fold). Остальной CSS загрузить асинхронно.

<head>
  <!-- Критический CSS встроен — нет сетевого запроса -->
  <style>
    body { margin: 0; font-family: sans-serif; }
    .hero { background: #f0f0f0; padding: 2rem; }
  </style>

  <!-- Остальной CSS загружается асинхронно -->
  <link rel="preload" href="full.css" as="style" onload="this.rel='stylesheet'">
</head>

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

<link rel="preload" href="hero-image.webp" as="image">
<link rel="preload" href="critical-font.woff2" as="font" crossorigin>

Минимизация render-blocking CSS: разделить CSS на медиа-запросы. Браузер загружает все CSS-файлы, но блокирует рендеринг только теми, которые применимы к текущему устройству:

<link rel="stylesheet" href="base.css">
<link rel="stylesheet" href="print.css" media="print">       <!-- Не блокирует -->
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)"> <!-- Блокирует только на мобильных -->

Реальный кейс: диагностика медленного FCP

На проекте FCP составлял 3.2 секунды. Анализ в Chrome DevTools (вкладка Performance) показал:

  1. HTML получен за 200 мс.
  2. Браузер обнаружил <link rel="stylesheet" href="bundle.css"> — 1.8 МБ CSS.
  3. Пока CSS загружался (1.4 сек), рендеринг заблокирован.
  4. После CSS — <script src="app.js"> без defer — ещё 600 мс.
  5. Только после этого — первая отрисовка.

Решение:

  • Разделить CSS на критический (встроить) и некритический (async).
  • Добавить defer к скриптам.
  • Включить HTTP/2 для параллельной загрузки ресурсов.

Результат: FCP снизился до 0.8 секунды. Это реальные числа с реального проекта — и все они объясняются пониманием Critical Rendering Path.

Знание CRP — это не просто теория для собеседования. Это инструмент диагностики, который позволяет смотреть на waterfall-диаграмму загрузки и сразу видеть, где и почему тормозит страница.

Загрузка ресурсов: async, defer и приоритеты

Загрузка ресурсов: async, defer и приоритеты

Разработчик добавляет скрипт аналитики в <head> — и страница начинает загружаться на 800 мс дольше. Другой разработчик ставит async на скрипт, который зависит от jQuery — и получает ReferenceError в продакшне. Оба случая — следствие непонимания того, как браузер управляет загрузкой ресурсов и что именно делают атрибуты async и defer.

Поведение скрипта по умолчанию

Обычный тег <script src="app.js"> без атрибутов работает так:

  1. Парсер HTML встречает тег — останавливается.
  2. Браузер загружает файл скрипта.
  3. Движок выполняет скрипт.
  4. Парсер HTML возобновляется.

Это называется parser-blocking: скрипт блокирует и загрузку, и выполнение. Если скрипт в <head> и весит 500 КБ — пользователь видит белый экран, пока файл не загрузится и не выполнится.

<!-- Блокирует парсинг HTML на время загрузки + выполнения -->
<script src="heavy-library.js"></script>
<p>Этот текст не будет обработан, пока скрипт не выполнится</p>

defer: загрузка параллельно, выполнение после HTML

Атрибут defer говорит браузеру: «загружай скрипт параллельно с парсингом HTML, но выполни его только после того, как HTML полностью распарсен».

<head>
  <script defer src="app.js"></script>
  <script defer src="analytics.js"></script>
</head>
<body>
  <!-- Парсится немедленно, не ждёт скриптов -->
  <h1>Hello</h1>
</body>

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

  • Загрузка начинается немедленно, параллельно с парсингом HTML.
  • Выполнение происходит после DOMContentLoaded (точнее — непосредственно перед его срабатыванием).
  • Порядок выполнения сохраняется: app.js выполнится раньше analytics.js, даже если analytics.js загрузился быстрее.
  • Работает только для внешних скриптов (с src).

defer — правильный выбор для большинства скриптов приложения, которые работают с DOM.

async: загрузка параллельно, выполнение немедленно

Атрибут async говорит браузеру: «загружай параллельно и выполни сразу, как только загрузится».

<script async src="analytics.js"></script>
<script async src="widget.js"></script>

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

  • Загрузка параллельна с парсингом HTML.
  • Выполнение происходит сразу после загрузки, прерывая парсинг HTML.
  • Порядок выполнения не гарантирован: какой скрипт загрузится первым — тот и выполнится первым.
  • Скрипт может выполниться до или после DOMContentLoaded.

async подходит для независимых скриптов, которые не зависят от DOM и от других скриптов: счётчики аналитики, виджеты чата, A/B-тесты.

Сравнение трёх режимов

Визуально разница выглядит так (время идёт слева направо):

Обычный:
HTML: ████░░░░░░░░░░░░░░░░░░░░░░░░░░████
JS:        ████████████████
           ↑загрузка       ↑выполнение

defer:
HTML: ████████████████████████████████
JS:        ████████████████|████
                           ↑выполнение после HTML

async:
HTML: ████████░░░░░░░░░░░░░░░░░░░░████
JS:        ████████████████
                           ↑выполнение прерывает HTML
Атрибут Блокирует парсинг Порядок выполнения Когда выполняется
Нет Да (загрузка + выполнение) По порядку в HTML Сразу при встрече
defer Нет Гарантирован После парсинга HTML
async Только выполнение Не гарантирован Сразу после загрузки

Типичные ошибки с async и defer

Ошибка 1: async для скрипта, зависящего от другого скрипта:

<script async src="jquery.js"></script>
<script async src="jquery-plugin.js"></script>
<!-- jquery-plugin.js может выполниться раньше jquery.js! -->
<!-- ReferenceError: $ is not defined -->

Ошибка 2: defer для скрипта, который должен выполниться до парсинга DOM (например, полифилл для CSS-переменных, который нужен до рендеринга):

<!-- Полифилл нужен ДО рендеринга — defer здесь неправильно -->
<script defer src="css-variables-polyfill.js"></script>

Ошибка 3: async/defer для инлайн-скриптов — они игнорируются:

<script async>
  // async здесь не работает — скрипт выполняется синхронно
  console.log('I am blocking');
</script>

Приоритеты загрузки ресурсов

Браузер не загружает все ресурсы с одинаковым приоритетом. Chrome использует систему приоритетов:

Ресурс Приоритет
HTML Highest
CSS (в <head>) Highest
Шрифты High
Скрипты (в <head>, без async/defer) High
Скрипты (с defer) Low
Скрипты (с async) Low
Изображения (в viewport) High
Изображения (вне viewport) Low

rel="preload": явное управление приоритетом

<link rel="preload"> позволяет загрузить ресурс с высоким приоритетом заранее, не блокируя рендеринг:

<head>
  <!-- Загрузить шрифт заранее, до того как CSS его запросит -->
  <link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>

  <!-- Загрузить hero-изображение заранее -->
  <link rel="preload" href="hero.webp" as="image">

  <!-- Загрузить критический скрипт заранее -->
  <link rel="preload" href="critical.js" as="script">
</head>

Атрибут as обязателен — он указывает тип ресурса и определяет приоритет. Без as браузер загрузит ресурс с низким приоритетом.

crossorigin обязателен для шрифтов, даже если они с того же домена — иначе браузер загрузит шрифт дважды.

rel="prefetch" и rel="preconnect"

prefetch — загрузить ресурс с низким приоритетом для использования на следующей странице:

<!-- Пользователь, вероятно, перейдёт на /dashboard — загрузим скрипт заранее -->
<link rel="prefetch" href="/dashboard/bundle.js" as="script">

preconnect — установить соединение с доменом заранее (DNS + TCP + TLS):

<!-- Установить соединение с CDN заранее -->
<link rel="preconnect" href="https://cdn.example.com">
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>

preconnect экономит 100–300 мс на каждый новый домен. Особенно важно для шрифтов Google Fonts, CDN и API-серверов.

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

Скрипты, добавленные через JavaScript, по умолчанию ведут себя как async:

const script = document.createElement('script');
script.src = 'module.js';
document.head.appendChild(script); // async по умолчанию!

// Чтобы сохранить порядок:
script.async = false;

Это важно при динамической загрузке зависимостей: если нужен порядок — явно устанавливайте async = false.

Практический кейс: оптимизация загрузки SPA

На реальном проекте (React SPA) типичная структура <head> после оптимизации:

<head>
  <meta charset="UTF-8">

  <!-- Preconnect к API и CDN -->
  <link rel="preconnect" href="https://api.example.com">
  <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>

  <!-- Критический CSS встроен -->
  <style>/* critical CSS */</style>

  <!-- Preload критических ресурсов -->
  <link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
  <link rel="preload" href="/images/hero.webp" as="image">

  <!-- Основной бандл с defer -->
  <script defer src="/static/js/main.chunk.js"></script>

  <!-- Аналитика с async — независима от DOM -->
  <script async src="https://analytics.example.com/tracker.js"></script>

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

Такая структура обеспечивает: нет блокирующих ресурсов в <head>, критические ресурсы загружаются с высоким приоритетом, аналитика не влияет на время загрузки основного контента. Разница в FCP на медленных соединениях — от 1 до 3 секунд.

Repaint, Reflow, Composite и Layers в браузере

Repaint, Reflow, Composite и Layers в браузере

Анимация, которая работает на 60 FPS на мощном MacBook, превращается в слайд-шоу на бюджетном Android-устройстве. Причина почти всегда одна: анимация вызывает reflow — самую дорогую операцию в пайплайне рендеринга. Понимание того, что происходит после построения Render Tree, позволяет писать анимации и UI-обновления, которые работают плавно на любом устройстве.

Три этапа после Render Tree

После построения Render Tree (разобранного в предыдущей статье) браузер проходит три этапа при каждом обновлении:

Layout (Reflow) — вычисление геометрии: размеры, позиции, координаты каждого элемента. Это самый дорогой этап, потому что изменение одного элемента может каскадно изменить расположение других.

Paint (Repaint) — заполнение пикселями: цвета, тени, текст, изображения. Браузер рисует каждый слой отдельно.

Composite — объединение слоёв в финальное изображение и вывод на экран. Этот этап выполняется на GPU и работает очень быстро.

Не каждое изменение проходит все три этапа. Это ключевое знание для оптимизации.

Что вызывает Reflow

Reflow (layout) запускается при изменении чего-либо, что влияет на геометрию элементов:

  • Изменение размеров: width, height, padding, margin, border
  • Изменение позиционирования: top, left, position
  • Изменение содержимого: добавление/удаление DOM-узлов, изменение текста
  • Изменение шрифта: font-size, font-family, line-height
  • Изменение display
  • Чтение геометрических свойств: offsetWidth, getBoundingClientRect(), scrollTop

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

Forced Synchronous Layout: главный антипаттерн

// ПЛОХО: чтение после записи в цикле — forced synchronous layout
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
  const width = box.offsetWidth; // Принудительный reflow!
  box.style.width = (width * 2) + 'px'; // Запись
  // На следующей итерации: снова чтение → снова reflow
});
// N элементов = N reflow-ов
// ХОРОШО: сначала все чтения, потом все записи
const widths = Array.from(boxes).map(box => box.offsetWidth); // Один reflow
boxes.forEach((box, i) => {
  box.style.width = (widths[i] * 2) + 'px'; // Только запись
});
// Итого: 1 reflow вместо N

Этот паттерн называется layout thrashing (дёргание layout). В реальных проектах он встречается в коде, который анимирует множество элементов или динамически подстраивает размеры.

Что вызывает только Repaint

Repaint без reflow происходит при изменении визуальных свойств, не влияющих на геометрию:

  • color, background-color
  • visibility
  • box-shadow, border-radius
  • outline

Repaint дешевле reflow, но всё равно не бесплатен — браузер должен перерисовать пиксели затронутой области.

Composite: только GPU, без CPU

Некоторые CSS-свойства обрабатываются только на этапе composite, минуя layout и paint. Это самые быстрые анимации:

  • transform (translate, scale, rotate)
  • opacity
  • filter (в большинстве браузеров)
  • will-change
/* МЕДЛЕННО: вызывает reflow + repaint */
.box {
  transition: left 0.3s, top 0.3s;
}

/* БЫСТРО: только composite */
.box {
  transition: transform 0.3s;
}

Анимация left/top пересчитывает геометрию на каждом кадре. Анимация transform: translate() — только composite на GPU. Разница в производительности — в разы, особенно на мобильных устройствах.

Слои и GPU Acceleration

Браузер разбивает страницу на слои (layers) и рендерит их независимо. Слои, которые часто меняются, выгодно изолировать — тогда их обновление не затрагивает остальную страницу.

Браузер автоматически создаёт новый слой для:

  • Элементов с position: fixed или position: sticky
  • Элементов с transform или opacity в анимации
  • Элементов с will-change
  • <video>, <canvas>, <iframe>
  • Элементов с overflow: scroll

Свойство will-change — явная подсказка браузеру создать слой заранее:

.animated-element {
  will-change: transform; /* Браузер создаёт слой до начала анимации */
}

Подводный камень: слои потребляют память GPU. Если добавить will-change: transform всем элементам — можно исчерпать память GPU, особенно на мобильных устройствах. Используйте только для элементов, которые действительно анимируются.

requestAnimationFrame: правильный способ анимации

requestAnimationFrame (rAF) синхронизирует выполнение кода с циклом перерисовки браузера (обычно 60 раз в секунду):

function animate(timestamp) {
  const progress = (timestamp - startTime) / duration;

  element.style.transform = `translateX(${progress * 300}px)`;

  if (progress < 1) {
    requestAnimationFrame(animate);
  }
}

const startTime = performance.now();
requestAnimationFrame(animate);

rAF гарантирует, что изменения применятся перед следующей перерисовкой. setTimeout(fn, 16) — не эквивалент: таймер может сработать в середине кадра, вызвав лишний repaint, или пропустить кадр.

Практические правила минимизации reflow/repaint

Батчинг DOM-изменений: вместо множества отдельных изменений — одно:

// ПЛОХО: три отдельных reflow
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';

// ХОРОШО: один reflow через класс
element.classList.add('expanded');
// CSS: .expanded { width: 100px; height: 200px; margin: 10px; }

// Или через cssText:
element.style.cssText = 'width: 100px; height: 200px; margin: 10px;';

DocumentFragment для вставки множества элементов:

// ПЛОХО: N reflow-ов
items.forEach(item => {
  const li = document.createElement('li');
  li.textContent = item;
  list.appendChild(li); // Reflow на каждой итерации
});

// ХОРОШО: один reflow
const fragment = document.createDocumentFragment();
items.forEach(item => {
  const li = document.createElement('li');
  li.textContent = item;
  fragment.appendChild(li);
});
list.appendChild(fragment); // Один reflow

Скрытие элемента перед изменениями:

element.style.display = 'none'; // Убираем из Render Tree
// Множество изменений — без reflow, элемент не в дереве
element.style.display = 'block'; // Один reflow при возврате

CSS Containment: изоляция reflow

CSS-свойство contain позволяет изолировать элемент от остального дерева — изменения внутри не вызывают reflow снаружи:

.widget {
  contain: layout; /* Reflow внутри не влияет на внешний layout */
}

.card {
  contain: strict; /* Максимальная изоляция: layout + style + paint + size */
}

contain: layout — мощный инструмент для компонентов, которые часто обновляются (списки, дашборды с real-time данными). Браузер знает, что изменения внутри .widget не могут повлиять на позиции элементов снаружи, и пропускает пересчёт внешнего layout.

Диагностика в Chrome DevTools

Вкладка Performance в DevTools показывает пайплайн рендеринга для каждого кадра:

  • Фиолетовые блоки — Layout (reflow)
  • Зелёные блоки — Paint
  • Жёлтые блоки — Scripting

Если в записи много фиолетовых блоков в цикле — это layout thrashing. Вкладка Rendering (включить через три точки → More tools) позволяет включить:

  • Paint flashing — зелёная подсветка областей, которые перерисовываются
  • Layout Shift Regions — синяя подсветка областей с layout shift

Эти инструменты позволяют визуально найти проблемные места без анализа кода вручную. На senior-собеседовании умение описать процесс диагностики производительности рендеринга — такой же важный навык, как знание теории.

Event Loop в браузере и requestAnimationFrame

Event Loop в браузере и requestAnimationFrame

Разработчик пишет setTimeout(updateUI, 0) в надежде, что UI обновится «немедленно». Но пользователь всё равно видит подвисание. Другой разработчик использует requestAnimationFrame для той же задачи — и анимация работает идеально. Разница не в скорости выполнения кода, а в том, когда именно браузер выполняет эти колбэки относительно цикла рендеринга.

Event Loop в браузере: расширенная модель

В статье о микро- и макрозадачах был разобран базовый алгоритм Event Loop. Браузерный Event Loop добавляет к нему критически важный элемент — цикл рендеринга. Полная последовательность одного «тика»:

  1. Выполнить одну макрозадачу (или синхронный скрипт при старте).
  2. Полностью опустошить очередь микрозадач.
  3. Проверить, нужна ли перерисовка (обычно каждые ~16.6 мс при 60 FPS).
  4. Если нужна: выполнить requestAnimationFrame колбэки.
  5. Выполнить style calculation и layout.
  6. Выполнить paint и composite.
  7. Если есть свободное время: выполнить requestIdleCallback колбэки.
  8. Вернуться к шагу 1.

Ключевое: рендеринг происходит не после каждой макрозадачи, а только когда браузер решает, что пора обновить экран. Это обычно ~60 раз в секунду, но может быть реже на фоновых вкладках или при высокой нагрузке.

requestAnimationFrame: синхронизация с рендером

requestAnimationFrame (rAF) — это не просто «быстрый setTimeout». Это механизм, который ставит колбэк в очередь для выполнения непосредственно перед следующей перерисовкой:

function animate(timestamp) {
  // timestamp — DOMHighResTimeStamp, время начала кадра
  const elapsed = timestamp - startTime;
  const progress = Math.min(elapsed / duration, 1);

  // Easing функция для плавности
  const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic

  element.style.transform = `translateX(${eased * 300}px)`;

  if (progress < 1) {
    requestAnimationFrame(animate);
  }
}

const startTime = performance.now();
requestAnimationFrame(animate);

timestamp — это время начала текущего кадра, одинаковое для всех rAF-колбэков в одном кадре. Это позволяет синхронизировать несколько независимых анимаций без дрейфа.

Почему setTimeout(fn, 0) плохо для анимаций

// ПЛОХО: анимация через setTimeout
function badAnimate() {
  element.style.left = (parseFloat(element.style.left) + 1) + 'px';
  setTimeout(badAnimate, 16); // ~60 FPS? Нет!
}

Проблемы:

  • setTimeout(fn, 16) не гарантирует выполнение ровно через 16 мс — таймер может сработать через 17, 18, 20 мс.
  • Если таймер срабатывает в середине кадра — изменение применится в следующем кадре, создавая визуальный разрыв.
  • На фоновых вкладках браузер замедляет таймеры до 1 раза в секунду, но rAF полностью останавливается — это правильное поведение.
  • setTimeout не знает о частоте обновления экрана (90 Hz, 120 Hz, 144 Hz).

rAF автоматически адаптируется к частоте обновления экрана. На 120 Hz мониторе он вызывается 120 раз в секунду, обеспечивая более плавную анимацию без изменений в коде.

Батчинг DOM-изменений с rAF

rAF — правильное место для применения накопленных изменений DOM:

let pendingUpdates = [];
let rafScheduled = false;

function scheduleUpdate(update) {
  pendingUpdates.push(update);

  if (!rafScheduled) {
    rafScheduled = true;
    requestAnimationFrame(() => {
      // Все обновления применяются в одном кадре
      pendingUpdates.forEach(update => update());
      pendingUpdates = [];
      rafScheduled = false;
    });
  }
}

// Множество вызовов — один rAF
scheduleUpdate(() => element1.classList.add('active'));
scheduleUpdate(() => element2.style.opacity = '0.5');
scheduleUpdate(() => element3.textContent = 'Updated');

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

requestIdleCallback: работа в свободное время

requestIdleCallback (rIC) — механизм для выполнения некритических задач в периоды, когда браузер не занят:

requestIdleCallback((deadline) => {
  // deadline.timeRemaining() — сколько мс осталось до следующего кадра
  // deadline.didTimeout — истёк ли таймаут (если указан)

  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    const task = tasks.shift();
    task(); // Выполняем задачу, пока есть время
  }

  if (tasks.length > 0) {
    requestIdleCallback(processQueue); // Продолжим в следующий idle период
  }
}, { timeout: 2000 }); // Выполнить не позже чем через 2 сек

timeRemaining() возвращает количество миллисекунд до конца idle-периода. Если задача занимает больше — её нужно разбить на части и продолжить в следующий idle-период.

Реальные кейсы для rIC:

  • Предзагрузка данных для следующего экрана
  • Аналитика и логирование
  • Индексирование контента для поиска
  • Сохранение черновиков в localStorage

Важно: rIC не подходит для обновлений UI — они должны происходить в rAF. rIC — только для фоновой работы.

Долгие задачи и прерывание Event Loop

Задача, выполняющаяся дольше 50 мс, считается долгой задачей (long task) и блокирует Event Loop. Пользователь ощущает это как «зависание» интерфейса.

Решение — разбить долгую задачу на части с помощью scheduler.yield() (новый API) или имитации через setTimeout:

// Обработка большого массива без блокировки UI
async function processLargeArray(items) {
  const CHUNK_SIZE = 100;

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    processChunk(chunk);

    // Отдать управление браузеру между чанками
    await new Promise(resolve => setTimeout(resolve, 0));
    // Или с scheduler API (Chrome 115+):
    // await scheduler.yield();
  }
}

scheduler.yield() — более правильный способ: он возвращает управление браузеру, но с более высоким приоритетом, чем setTimeout(0), что означает меньшую задержку.

Измерение производительности: PerformanceObserver

Для мониторинга долгих задач в продакшне:

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    if (entry.duration > 50) {
      console.warn(`Long task detected: ${entry.duration}ms`);
      // Отправить в систему мониторинга
      analytics.track('long_task', {
        duration: entry.duration,
        startTime: entry.startTime
      });
    }
  });
});

observer.observe({ entryTypes: ['longtask'] });

Это реальный паттерн для мониторинга производительности в продакшне. Долгие задачи напрямую влияют на метрику INP (Interaction to Next Paint) — один из Core Web Vitals с 2024 года.

Приоритеты в полной картине

Синхронный код
    ↓
Микрозадачи (Promise, queueMicrotask)
    ↓
requestAnimationFrame (перед рендером)
    ↓
Рендеринг (style → layout → paint → composite)
    ↓
requestIdleCallback (если есть свободное время)
    ↓
Следующая макрозадача (setTimeout, события)

Понимание этой последовательности объясняет, почему:

  • Изменения в rAF применяются в том же кадре, что и вызов.
  • Промисы, разрешённые в обработчике клика, выполняются до следующего рендера.
  • setTimeout(fn, 0) в обработчике события выполняется после рендера, вызванного этим событием.

На senior-собеседовании вопрос «почему анимация дёргается» — это вопрос про Event Loop, rAF и долгие задачи. Умение связать симптом (дёрганая анимация, зависающий UI) с конкретным механизмом (layout thrashing, долгая задача в Event Loop, неправильное использование setTimeout вместо rAF) — это то, что отличает senior от middle.

React Reconciliation и Fiber Architecture

React Reconciliation и Fiber Architecture

Почему React не обновляет весь DOM при каждом изменении стейта? Почему рендеринг тысячи элементов в React работает быстрее, чем наивное обновление DOM вручную? И почему в React 18 появился Concurrent Mode, если старый алгоритм работал? Ответы на все эти вопросы — в архитектуре Fiber и алгоритме reconciliation.

Проблема, которую решает Virtual DOM

Прямая работа с DOM дорогая: каждое изменение может вызвать reflow и repaint. Но главная проблема не в скорости DOM-операций — а в том, что разработчик не знает, что именно изменилось. Без этого знания приходится либо перерисовывать всё, либо вручную отслеживать каждое изменение.

Virtual DOM — это легковесное JavaScript-представление DOM-дерева. React хранит два дерева: текущее (то, что сейчас на экране) и новое (результат последнего рендера). Алгоритм reconciliation сравнивает их и вычисляет минимальный набор изменений для реального DOM.

Алгоритм Reconciliation: эвристики O(n)

Теоретически сравнение двух деревьев — задача сложности O(n3)O(n^3), где nn — количество узлов. Для дерева из 1000 элементов это миллиард операций. React использует эвристики, снижающие сложность до O(n)O(n):

Эвристика 1: Элементы разных типов создают разные деревья. Если <div> заменяется на <span> — React уничтожает всё поддерево и создаёт новое.

// До
<div>
  <Counter />
</div>

// После — Counter будет размонтирован и создан заново
<span>
  <Counter />
</span>

Эвристика 2: Элементы одного типа обновляются, а не пересоздаются. React обновляет только изменившиеся атрибуты:

// До: <div className="before" title="stuff" />
// После: <div className="after" title="stuff" />
// React обновит только className, title не тронет

Эвристика 3: Ключи (key) помогают идентифицировать элементы в списках. Без ключей React сравнивает элементы по позиции, что приводит к неэффективным обновлениям при вставке в начало списка.

// ПЛОХО: без ключей — при добавлении в начало все элементы обновятся
{items.map(item => <Item text={item.text} />)}

// ХОРОШО: с ключами — React знает, что изменилось
{items.map(item => <Item key={item.id} text={item.text} />)}

Почему нельзя использовать индекс массива как ключ? При изменении порядка элементов ключи «переезжают» вместе с позициями, и React не понимает, что элемент переместился — он думает, что изменился контент. Это приводит к неправильному обновлению стейта компонентов.

Stack Reconciler: проблема старого алгоритма

До React 16 использовался Stack Reconciler — рекурсивный алгоритм обхода дерева. Он работал синхронно: начав обход, нельзя было остановиться до завершения.

Для большого дерева (тысячи компонентов) это означало, что JavaScript-поток мог быть заблокирован на десятки миллисекунд. В это время браузер не мог обработать пользовательский ввод или обновить анимации. Пользователь ощущал «зависание».

Это и была главная мотивация для создания Fiber.

Fiber Architecture: прерываемый рендеринг

Fiber — это переписанный движок React, представленный в React 16. Ключевая идея: разбить работу по рендерингу на маленькие единицы (fiber units), которые можно прерывать, приостанавливать и возобновлять.

Fiber-узел — это JavaScript-объект, представляющий один элемент React-дерева. Он содержит:

{
  type: 'div',           // Тип элемента
  key: null,             // Ключ для reconciliation
  stateNode: domNode,    // Ссылка на реальный DOM-узел
  child: fiberNode,      // Первый дочерний fiber
  sibling: fiberNode,    // Следующий сиблинг
  return: fiberNode,     // Родительский fiber
  pendingProps: {},      // Новые пропсы
  memoizedProps: {},     // Текущие пропсы
  memoizedState: {},     // Текущий стейт
  effectTag: 'UPDATE',   // Что нужно сделать с DOM
  alternate: fiberNode,  // Ссылка на fiber из другого дерева
}

Fiber использует двойную буферизацию (double buffering): всегда существуют два дерева — current (текущее, отображаемое) и work-in-progress (строящееся). После завершения работы они меняются местами.

Work Loop: как Fiber обходит дерево

Вместо рекурсии Fiber использует итеративный обход с явным стеком через связанный список (child, sibling, return). Это позволяет сохранить состояние обхода и возобновить его позже.

Работа разделена на две фазы:

Render Phase (Reconciliation) — прерываемая:

  • Обход дерева, вызов функциональных компонентов
  • Сравнение старых и новых fiber-узлов
  • Пометка узлов эффектами (Placement, Update, Deletion)
  • Может быть прервана и возобновлена

Commit Phase — синхронная, непрерываемая:

  • Применение всех изменений к реальному DOM
  • Вызов useLayoutEffect (синхронно)
  • Вызов useEffect (асинхронно, после paint)
Render Phase:          Commit Phase:
[beginWork]            [commitMutationEffects]
  ↓                      ↓
[completeWork]         [commitLayoutEffects]
  ↓                      ↓
[effectList]           [commitPassiveEffects]

Effects: useEffect vs useLayoutEffect

Понимание фаз Fiber объясняет разницу между хуками:

useLayoutEffect выполняется синхронно после DOM-мутаций, но до того как браузер нарисует экран. Аналог componentDidMount/componentDidUpdate в классовых компонентах:

useLayoutEffect(() => {
  // DOM уже обновлён, браузер ещё не перерисовал
  const rect = element.current.getBoundingClientRect();
  setPosition(rect); // Обновление стейта здесь не вызовет мигания
}, []);

useEffect выполняется асинхронно после того как браузер нарисовал экран:

useEffect(() => {
  // Браузер уже перерисовал — безопасно для сайд-эффектов
  fetchData().then(setData);

  return () => cleanup(); // Cleanup при размонтировании
}, [dependency]);

Правило: используйте useLayoutEffect только когда нужно читать/изменять DOM до перерисовки (измерение размеров, предотвращение мигания). В остальных случаях — useEffect.

Приоритеты в Fiber Scheduler

Fiber имеет встроенный планировщик с приоритетами. В React 18 это реализовано через пакет scheduler:

Приоритет Примеры Таймаут
Immediate Синхронные обновления 0 мс
User Blocking Клики, ввод 250 мс
Normal Обычные обновления 5000 мс
Low Аналитика, prefetch 10000 мс
Idle Фоновая работа Нет таймаута

Обновления с высоким приоритетом (пользовательский ввод) могут прерывать обновления с низким приоритетом. Это основа Concurrent Mode.

Практический кейс: профилирование с React DevTools

React DevTools Profiler показывает, какие компоненты рендерились и почему. Типичный сценарий: компонент рендерится слишком часто.

// Компонент рендерится при каждом обновлении родителя
function ExpensiveChild({ data }) {
  // Тяжёлые вычисления
  const result = heavyComputation(data);
  return <div>{result}</div>;
}

// Решение: мемоизация
const ExpensiveChild = React.memo(({ data }) => {
  const result = useMemo(() => heavyComputation(data), [data]);
  return <div>{result}</div>;
});

React.memo — это оболочка, которая пропускает рендеринг, если пропсы не изменились (поверхностное сравнение). Fiber проверяет: если memoizedProps === pendingProps — пропускает beginWork для этого поддерева.

Понимание Fiber объясняет, почему React.memo работает именно так: Fiber сравнивает memoizedProps и pendingProps в начале beginWork. Если они равны и нет форсированного обновления — весь поддерево пропускается. Это не «магия», а конкретная проверка в коде reconciler.

Concurrent React: Transitions, Suspense и Scheduling

Concurrent React: Transitions, Suspense и Scheduling

React 18 — это не просто новые хуки. Это фундаментальное изменение модели рендеринга: от синхронного «всё или ничего» к конкурентному (concurrent) рендерингу, где React может работать над несколькими версиями UI одновременно, прерывать незавершённую работу и приоритизировать обновления. Понимание этого механизма — то, что отличает senior-разработчика, который «использует React 18», от того, кто «понимает React 18».

Что изменилось в модели рендеринга

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

В React 18 появился Concurrent Mode: React может начать рендеринг, приостановить его при появлении более приоритетного обновления, выполнить приоритетное обновление, а затем вернуться к прерванному. Это называется прерываемый рендеринг (interruptible rendering).

Важно: Concurrent Mode не включается автоматически при обновлении до React 18. Он активируется только при использовании createRoot вместо ReactDOM.render:

// React 17: синхронный рендеринг
ReactDOM.render(<App />, document.getElementById('root'));

// React 18: конкурентный рендеринг
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

Automatic Batching: бесплатная оптимизация

Первое изменение, которое получают все при переходе на React 18 — автоматический батчинг (automatic batching). В React 17 батчинг работал только внутри обработчиков событий React. В React 18 — везде:

// React 17: три отдельных рендера в setTimeout
setTimeout(() => {
  setCount(c => c + 1); // рендер
  setFlag(f => !f);     // рендер
  setData(d => [...d]); // рендер
}, 1000);

// React 18: один рендер
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  setData(d => [...d]);
  // Один рендер после всех обновлений
}, 1000);

Если нужно отключить батчинг (редкий случай):

import { flushSync } from 'react-dom';

flushSync(() => setCount(c => c + 1)); // Синхронный рендер
flushSync(() => setFlag(f => !f));     // Ещё один синхронный рендер

useTransition: разделение срочных и несрочных обновлений

useTransition — ключевой хук Concurrent React. Он позволяет пометить обновление как несрочное (non-urgent), давая React право прерывать его при появлении срочных обновлений (пользовательский ввод):

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e) {
    // Срочное обновление: поле ввода должно обновиться немедленно
    setQuery(e.target.value);

    // Несрочное обновление: результаты поиска можно отложить
    startTransition(() => {
      setResults(searchDatabase(e.target.value));
    });
  }

  return (
    <>
      <input value={query} onChange={handleSearch} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </>
  );
}

Что происходит под капотом: обновление внутри startTransition получает низкий приоритет в планировщике Fiber. Если пользователь продолжает вводить текст, React прерывает рендеринг результатов и обрабатывает новый ввод. Старые результаты остаются на экране до завершения нового рендеринга.

isPendingtrue пока transition не завершён. Используется для показа индикатора загрузки без скачков UI.

useDeferredValue: отложенное значение

useDeferredValue — альтернатива useTransition для случаев, когда нет доступа к функции обновления стейта (например, пропс приходит от родителя):

function SearchResults({ query }) {
  // deferredQuery отстаёт от query — обновляется с низким приоритетом
  const deferredQuery = useDeferredValue(query);

  // Тяжёлый компонент рендерится с отложенным значением
  return <ExpensiveList query={deferredQuery} />;
}

Разница между useTransition и useDeferredValue:

useTransition useDeferredValue
Управление Оборачивает вызов setState Оборачивает значение
Доступ к setState Нужен Не нужен
isPending Есть Нет
Применение Когда контролируете обновление Когда получаете значение как пропс

Suspense: декларативная загрузка

Suspense позволяет «приостановить» рендеринг компонента до готовности данных и показать fallback:

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile />  {/* Может "приостановиться" */}
    </Suspense>
  );
}

function UserProfile() {
  // use() — новый хук React 19, но концепция та же
  const user = use(fetchUser()); // Бросает Promise если данные не готовы
  return <div>{user.name}</div>;
}

Механизм: компонент бросает (throws) Promise. React перехватывает его, показывает fallback из ближайшего <Suspense>, и когда Promise разрешается — повторяет рендеринг компонента.

В React 18 Suspense работает с:

  • React.lazy для code splitting
  • Серверным рендерингом (Streaming SSR)
  • Библиотеками данных, поддерживающими Suspense (React Query, SWR, Relay)
// Code splitting с Suspense
const Dashboard = React.lazy(() => import('./Dashboard'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Dashboard />
    </Suspense>
  );
}

Streaming SSR: Suspense на сервере

В React 18 Suspense интегрирован с серверным рендерингом. Вместо ожидания всех данных перед отправкой HTML, сервер может стримить HTML по частям:

// Сервер
import { renderToPipeableStream } from 'react-dom/server';

const { pipe } = renderToPipeableStream(<App />, {
  onShellReady() {
    // Отправить начальный HTML немедленно
    response.setHeader('Content-Type', 'text/html');
    pipe(response);
  }
});

Компоненты внутри <Suspense> рендерятся на сервере асинхронно. Когда данные готовы — React стримит HTML для этого компонента и скрипт для «гидрации» (hydration). Пользователь видит контент постепенно, а не ждёт самого медленного запроса.

Selective Hydration: умная гидрация

В React 18 гидрация тоже стала конкурентной. Если пользователь кликает на компонент, который ещё не гидрирован — React приоритизирует его гидрацию:

Сервер отправил HTML:
[Header — гидрирован] [Sidebar — ещё нет] [Main — ещё нет]

Пользователь кликает на Sidebar:
React: "Срочно гидрировать Sidebar!"
→ Sidebar гидрируется первым
→ Клик обрабатывается
→ Main гидрируется в фоне

Это устраняет проблему «мёртвой страницы» — когда HTML уже виден, но JavaScript ещё не загрузился и клики не работают.

Практический кейс: тяжёлый список с фильтрацией

Реальная задача: список из 10 000 элементов с фильтрацией по вводу. Без оптимизации каждый символ вызывает тяжёлый рендеринг и поле ввода «лагает».

function HeavyList() {
  const [filter, setFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleFilter = (e) => {
    setFilter(e.target.value); // Срочно: поле обновляется мгновенно
    startTransition(() => {
      // Несрочно: фильтрация может подождать
      setFilteredItems(items.filter(item =>
        item.name.includes(e.target.value)
      ));
    });
  };

  return (
    <>
      <input onChange={handleFilter} />
      {isPending
        ? <div>Фильтрация...</div>
        : <VirtualList items={filteredItems} />
      }
    </>
  );
}

Результат: поле ввода реагирует мгновенно (срочное обновление), список обновляется с задержкой (несрочное), но без блокировки UI. Пользователь видит плавный интерфейс даже при быстром вводе.

Concurrent React — это не набор хуков, которые нужно выучить. Это новая ментальная модель: обновления имеют приоритеты, рендеринг прерываем, UI всегда отзывчив. Понимание этой модели позволяет принимать правильные архитектурные решения: когда использовать useTransition, когда useDeferredValue, когда Suspense, а когда достаточно React.memo.

Тонкости React: closures, batching и оптимизация

Тонкости React: closures, batching и оптимизация

Знание API React — необходимое условие для работы, но не достаточное для senior-уровня. Настоящие проблемы начинаются там, где официальная документация заканчивается: устаревшие замыкания в хуках, неочевидные правила зависимостей, неожиданное поведение батчинга, ловушки Strict Mode. Эти нюансы — то, что спрашивают на senior-собеседованиях и что встречается в реальных багах.

Stale Closures: главная ловушка хуков

Устаревшее замыкание (stale closure) — это когда функция внутри хука захватывает значение переменной из момента создания, а не из момента вызова. Это самый частый источник багов в функциональных компонентах.

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

  useEffect(() => {
    const interval = setInterval(() => {
      console.log(count); // Всегда 0! Stale closure
      setCount(count + 1); // Всегда устанавливает 1
    }, 1000);
    return () => clearInterval(interval);
  }, []); // Пустой массив зависимостей — эффект создан один раз

  return <div>{count}</div>;
}

Проблема: useEffect с пустым массивом зависимостей создаётся один раз при монтировании. Колбэк setInterval захватывает count = 0 из момента создания. Каждую секунду он читает count = 0 и устанавливает count = 1.

Решение 1 — функциональное обновление стейта:

setCount(prevCount => prevCount + 1); // Всегда актуальное значение

Решение 2 — useRef для хранения актуального значения:

const countRef = useRef(count);
countRef.current = count; // Обновляется при каждом рендере

useEffect(() => {
  const interval = setInterval(() => {
    console.log(countRef.current); // Всегда актуально
    setCount(countRef.current + 1);
  }, 1000);
  return () => clearInterval(interval);
}, []);

Решение 3 — добавить count в зависимости (но тогда интервал пересоздаётся при каждом изменении):

useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(interval);
}, [count]); // Интервал пересоздаётся при каждом изменении count

Правила массива зависимостей

Массив зависимостей в useEffect, useMemo, useCallback — это не «оптимизация», а контракт: «этот эффект/значение зависит от этих переменных». Нарушение контракта приводит к stale closures или бесконечным циклам.

Правило: в массив зависимостей должны входить все реактивные значения, используемые внутри хука. Реактивные значения — это пропсы, стейт и всё, что вычисляется из них.

// ПЛОХО: пропущена зависимость userId
useEffect(() => {
  fetchUser(userId).then(setUser);
}, []); // ESLint: react-hooks/exhaustive-deps предупредит

// ХОРОШО
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);

Частая ошибка — функции в зависимостях:

function Component({ onSuccess }) {
  useEffect(() => {
    fetchData().then(onSuccess);
  }, [onSuccess]); // onSuccess — новая функция при каждом рендере родителя!
  // Эффект будет запускаться при каждом рендере родителя
}

Решение — useCallback в родителе или useRef для стабилизации функции:

// В родителе:
const handleSuccess = useCallback((data) => {
  // ...
}, [/* стабильные зависимости */]);

// Или в дочернем через ref:
const onSuccessRef = useRef(onSuccess);
onSuccessRef.current = onSuccess;

useEffect(() => {
  fetchData().then(data => onSuccessRef.current(data));
}, []); // Стабильная зависимость

useMemo и useCallback: когда они нужны

Распространённое заблуждение: «мемоизировать нужно всё». На самом деле useMemo и useCallback имеют собственную стоимость — сравнение зависимостей при каждом рендере. Они оправданы только в конкретных случаях.

useMemo нужен когда:

  • Вычисление действительно дорогое (сортировка тысяч элементов, сложные трансформации данных)
  • Результат используется как зависимость другого хука
  • Результат передаётся в React.memo-компонент
// Оправдано: тяжёлое вычисление
const sortedItems = useMemo(
  () => items.sort((a, b) => b.score - a.score),
  [items]
);

// Не оправдано: простое вычисление
const doubled = useMemo(() => count * 2, [count]); // Лишняя сложность

useCallback нужен когда:

  • Функция передаётся в React.memo-компонент как пропс
  • Функция используется как зависимость useEffect
// Оправдано: стабилизация для React.memo
const handleClick = useCallback(() => {
  doSomething(id);
}, [id]);

// Не оправдано: функция не передаётся дальше
const handleLocalClick = useCallback(() => {
  setCount(c => c + 1);
}, []); // Лишняя обёртка

React.memo: поверхностное сравнение и его ловушки

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

const Child = React.memo(({ items, onSelect }) => {
  return items.map(item => (
    <div key={item.id} onClick={() => onSelect(item)}>{item.name}</div>
  ));
});

function Parent() {
  const [selected, setSelected] = useState(null);

  // ПЛОХО: новый массив при каждом рендере
  const items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];

  // ПЛОХО: новая функция при каждом рендере
  const handleSelect = (item) => setSelected(item);

  return <Child items={items} onSelect={handleSelect} />;
  // React.memo бесполезен — пропсы всегда "новые"
}

Правильно:

function Parent() {
  const [selected, setSelected] = useState(null);

  const items = useMemo(
    () => [{ id: 1, name: 'A' }, { id: 2, name: 'B' }],
    [] // Стабильный массив
  );

  const handleSelect = useCallback(
    (item) => setSelected(item),
    []
  );

  return <Child items={items} onSelect={handleSelect} />;
}

Batching в обработчиках событий и его нюансы

В React 18 батчинг автоматический (разобрано в предыдущей статье). Но есть нюанс с flushSync и синхронными обновлениями в useLayoutEffect:

function Component() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);

  useLayoutEffect(() => {
    // Оба обновления батчируются — один рендер
    setA(1);
    setB(2);
  });

  // Но если использовать flushSync:
  useLayoutEffect(() => {
    flushSync(() => setA(1)); // Синхронный рендер
    flushSync(() => setB(2)); // Ещё один синхронный рендер
    // Два рендера вместо одного
  });
}

Strict Mode: двойной рендер и его цель

В React 18 Strict Mode намеренно вызывает двойной рендер компонентов и двойное выполнение эффектов в режиме разработки:

// В Strict Mode этот код выполнится дважды:
useEffect(() => {
  console.log('Effect'); // Выведется дважды в dev mode
  const subscription = subscribe();
  return () => subscription.unsubscribe();
}, []);

Цель — выявить побочные эффекты, которые не очищаются корректно. Если ваш эффект ломается при двойном выполнении — это баг, который нужно исправить, а не отключать Strict Mode.

// ПЛОХО: не идемпотентный эффект
useEffect(() => {
  document.title = 'Page'; // OK при двойном вызове
  analytics.pageView(); // Дублирует событие аналитики!
}, []);

// ХОРОШО: с флагом
useEffect(() => {
  let sent = false;
  if (!sent) {
    analytics.pageView();
    sent = true;
  }
  return () => { sent = true; }; // Cleanup отменяет отправку
}, []);

Keys как инструмент сброса состояния

key — не только для списков. Изменение key компонента заставляет React размонтировать его и создать заново, сбрасывая всё состояние:

function UserProfile({ userId }) {
  const [editMode, setEditMode] = useState(false);
  const [formData, setFormData] = useState({});

  // При смене userId нужно сбросить состояние формы
  // Вместо useEffect с очисткой:
  return <ProfileForm key={userId} userId={userId} />;
  // При смене userId компонент пересоздаётся с чистым состоянием
}

Это элегантнее, чем useEffect с очисткой стейта, и гарантирует полный сброс без риска stale state.

React Compiler (React Forget)

React Compiler (ранее известный как React Forget) — экспериментальный инструмент, который автоматически добавляет мемоизацию на уровне компилятора. Вместо ручного useMemo/useCallback компилятор анализирует код и добавляет оптимизации автоматически.

// Вы пишете:
function Component({ items }) {
  const sorted = items.sort((a, b) => b.score - a.score);
  return <List items={sorted} />;
}

// Компилятор генерирует эквивалент:
function Component({ items }) {
  const sorted = useMemo(
    () => items.sort((a, b) => b.score - a.score),
    [items]
  );
  return <List items={sorted} />;
}

На момент написания React Compiler доступен в бета-версии и используется в продакшне в Meta. Его появление означает, что ручная мемоизация через useMemo/useCallback постепенно уйдёт в прошлое — но понимание того, почему мемоизация нужна, останется важным.

Все эти нюансы объединяет одна идея: React — это не магия, а предсказуемая система с чёткими правилами. Stale closures возникают из-за того, как работают замыкания в JavaScript. Батчинг — из-за планировщика Fiber. Двойной рендер в Strict Mode — намеренное решение для выявления багов. Понимание причин делает поведение предсказуемым, а отладку — быстрой.

Модульная архитектура, Atomic Design и FSD

Модульная архитектура, Atomic Design и FSD

Команда из десяти разработчиков работает над одним React-проектом. Через год кодовая база выросла до 200 компонентов, и никто не может быстро найти, где лежит нужный файл. Компоненты импортируют друг друга по кругу. Изменение одной кнопки ломает три несвязанных экрана. Это не проблема технологий — это проблема архитектуры. Выбор правильного подхода к организации кода определяет, насколько проект будет масштабируемым через год.

Почему архитектура фронтенда — это не про папки

Архитектура — это не «куда положить файл». Это система правил, которая определяет:

  • Как компоненты зависят друг от друга
  • Где живёт бизнес-логика
  • Как переиспользуется код
  • Как команда договаривается о структуре

Без явных правил каждый разработчик создаёт свою структуру, и через полгода проект превращается в «большой ком грязи» (big ball of mud) — антипаттерн, где всё зависит от всего.

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

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

Базовый принцип — высокая связность внутри модуля, низкая связность между модулями (high cohesion, low coupling):

src/
  modules/
    auth/
      components/
        LoginForm.tsx
        AuthGuard.tsx
      hooks/
        useAuth.ts
      api/
        authApi.ts
      store/
        authSlice.ts
      index.ts  ← Публичный интерфейс модуля

    products/
      components/
      hooks/
      api/
      index.ts

index.ts — это barrel file: он определяет, что модуль экспортирует наружу. Всё остальное — детали реализации, которые другие модули не должны импортировать напрямую.

// modules/auth/index.ts
export { LoginForm } from './components/LoginForm';
export { AuthGuard } from './components/AuthGuard';
export { useAuth } from './hooks/useAuth';
// authApi и authSlice — внутренние детали, не экспортируются

Проблема модульной архитектуры: она не даёт ответа на вопросы «как называть модули?», «как организовать компоненты внутри?», «как разрешать зависимости между модулями?». Для этого нужны более конкретные методологии.

Atomic Design: от атомов до страниц

Atomic Design — методология, предложенная Брэдом Фростом, которая организует UI-компоненты по уровням абстракции, аналогичным химической иерархии:

  • Atoms (атомы) — базовые элементы: Button, Input, Label, Icon. Не имеют собственной бизнес-логики, только визуальное представление.
  • Molecules (молекулы) — комбинации атомов: SearchField (Input + Button), FormField (Label + Input + ErrorMessage).
  • Organisms (организмы) — сложные секции UI: Header (Logo + Navigation + SearchField), ProductCard (Image + Title + Price + Button).
  • Templates (шаблоны) — структура страницы без реальных данных: ProductPageTemplate (Header + Sidebar + MainContent + Footer).
  • Pages (страницы) — конкретные экземпляры шаблонов с реальными данными.
src/
  components/
    atoms/
      Button/
        Button.tsx
        Button.stories.tsx
        Button.test.tsx
    molecules/
      SearchField/
    organisms/
      Header/
    templates/
      ProductPageTemplate/
    pages/
      ProductPage/

Сильные стороны Atomic Design:

  • Отличная интеграция со Storybook — каждый уровень легко документировать
  • Чёткая иерархия переиспользования
  • Хорошо работает для дизайн-систем и UI-библиотек

Слабые стороны:

  • Не отвечает на вопрос, где живёт бизнес-логика
  • Граница между molecule и organism субъективна — команды часто спорят
  • Плохо масштабируется для больших приложений с множеством фич
  • Не решает проблему зависимостей между фичами

На практике Atomic Design хорошо работает для дизайн-систем и UI-библиотек (компонентный слой), но недостаточен как архитектура всего приложения.

Feature-Sliced Design: архитектура для продуктов

Feature-Sliced Design (FSD) — методология, разработанная русскоязычным сообществом и ставшая стандартом де-факто для средних и крупных React-приложений. Её ключевое отличие: она решает не только вопрос «где лежат файлы», но и как разрешать зависимости.

FSD организует код по двум измерениям: слои (layers) и слайсы (slices).

Слои FSD (сверху вниз по абстракции)

src/
  app/          ← Инициализация приложения, провайдеры, роутер
  pages/        ← Страницы (композиция виджетов)
  widgets/      ← Самодостаточные блоки UI (Header, Sidebar, Feed)
  features/     ← Пользовательские сценарии (AddToCart, AuthByPhone)
  entities/     ← Бизнес-сущности (User, Product, Order)
  shared/       ← Переиспользуемый код без бизнес-логики (UI-kit, utils, API)

Главное правило FSD: слой может импортировать только из нижележащих слоёв. features может использовать entities и shared, но не widgets и не pages.

app → pages → widgets → features → entities → shared
↑ Каждый слой зависит только от нижних ↑

Это правило устраняет циклические зависимости и делает архитектуру предсказуемой.

Слайсы: организация внутри слоя

Каждый слой (кроме app и shared) делится на слайсы — единицы по предметной области:

features/
  auth/
    ui/
      LoginForm.tsx
    model/
      authStore.ts
      authSelectors.ts
    api/
      authApi.ts
    index.ts    ← Публичный API слайса

  cart/
    ui/
    model/
    api/
    index.ts

Сегменты: стандартная структура слайса

Внутри каждого слайса — сегменты с фиксированными именами:

  • ui/ — компоненты и стили
  • model/ — стейт, бизнес-логика, селекторы
  • api/ — запросы к серверу
  • lib/ — вспомогательные функции
  • config/ — конфигурация

Публичный API слайса

Каждый слайс экспортирует только то, что нужно другим через index.ts. Прямые импорты внутрь слайса запрещены:

// ПЛОХО: прямой импорт внутрь слайса
import { authStore } from '@/features/auth/model/authStore';

// ХОРОШО: через публичный API
import { authStore } from '@/features/auth';

Это правило позволяет рефакторить внутренности слайса без изменения кода в других местах.

Реальный кейс: внедрение FSD в продуктовой компании

На проекте с 150+ компонентами и командой из 8 разработчиков была проблема: новый разработчик не мог понять, куда добавить новую фичу. Компоненты были организованы по типу (components/, hooks/, utils/) — классическая «техническая» структура.

Переход на FSD занял 3 недели:

  1. Выделили shared/ui — перенесли все «тупые» компоненты без бизнес-логики.
  2. Выделили entitiesUser, Product, Order со своими типами и базовыми компонентами.
  3. Выделили features — каждый пользовательский сценарий стал слайсом.
  4. Собрали pages из виджетов и фич.

Результат: время онбординга нового разработчика сократилось с 2 недель до 3 дней. Вопрос «куда положить файл» перестал быть предметом споров — ответ всегда однозначен.

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

Критерий Atomic Design Модульная FSD
Масштабируемость Средняя Высокая Высокая
Правила зависимостей Нет Частично Строгие
Кривая обучения Низкая Средняя Средняя
Подходит для дизайн-систем Отлично Плохо Плохо
Подходит для продуктов Плохо Хорошо Отлично
Инструментальная поддержка Storybook ESLint ESLint plugin

Когда какую архитектуру выбирать

Atomic Design — для UI-библиотек, дизайн-систем, компонентных пакетов. Когда главная задача — документирование и переиспользование UI-компонентов.

Модульная архитектура — для небольших команд (2–4 человека) и проектов, где FSD избыточен. Хорошо работает как переходный этап перед FSD.

FSD — для продуктовых приложений с командой от 4 человек, где важна масштабируемость и онбординг новых разработчиков. Особенно эффективен при наличии ESLint-плагина eslint-plugin-boundaries или @feature-sliced/eslint-config, который автоматически проверяет правила зависимостей.

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

Архитектурные решения — это инвестиции. Atomic Design окупается быстро для дизайн-систем. FSD окупается через 3–6 месяцев на продуктовых проектах. Понимание компромиссов каждого подхода — это и есть то, что делает разработчика senior: не знание «правильного ответа», а умение выбрать правильный инструмент для конкретного контекста.