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

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

1. 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(), на практике почти не используется.
  • Каждый контекст содержит три ключевых компонента:

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

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

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

  • Движок создаёт Global Execution Context и помещает его в стек.
  • Вызывается main() — создаётся новый контекст, помещается поверх глобального.
  • Внутри main вызывается greet('Alice') — ещё один контекст ложится на стек.
  • greet возвращает строку — её контекст снимается со стека.
  • console.log выполняется в контексте main.
  • main завершается — её контекст снимается.
  • Стек содержит только глобальный контекст.
  • Именно поэтому в стектрейсе ошибки вы видите цепочку вызовов снизу вверх — это буквально снимок call stack в момент исключения.

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

    !Схема Call Stack и Execution Context

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

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

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

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

    После завершения 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 в глобальной области видимости — антипаттерн: вы загрязняете глобальный объект.

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

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

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

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

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

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

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

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

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

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

    10. 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: главный антипаттерн

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

    !Пайплайн рендеринга: Layout → Paint → Composite

    Слои и GPU Acceleration

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

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

  • Элементов с position: fixed или position: sticky
  • Элементов с transform или opacity в анимации
  • Элементов с will-change
  • <video>, <canvas>, <iframe>
  • Элементов с overflow: scroll
  • Свойство will-change — явная подсказка браузеру создать слой заранее:

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

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

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

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

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

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

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

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

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

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

    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-собеседовании умение описать процесс диагностики производительности рендеринга — такой же важный навык, как знание теории.

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

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

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

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

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

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

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

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

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

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

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

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

    12. 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)

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

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

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

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

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

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

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

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

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

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

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

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

    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)
  • !Архитектура Fiber: двойная буферизация и фазы рендеринга

    Effects: useEffect vs useLayoutEffect

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

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

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

    Правило: используйте 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 показывает, какие компоненты рендерились и почему. Типичный сценарий: компонент рендерится слишком часто.

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

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

    13. 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:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • React.lazy для code splitting
  • Серверным рендерингом (Streaming SSR)
  • Библиотеками данных, поддерживающими Suspense (React Query, SWR, Relay)
  • Streaming SSR: Suspense на сервере

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Вычисление действительно дорогое (сортировка тысяч элементов, сложные трансформации данных)
  • Результат используется как зависимость другого хука
  • Результат передаётся в React.memo-компонент
  • useCallback нужен когда:

  • Функция передаётся в React.memo-компонент как пропс
  • Функция используется как зависимость useEffect
  • React.memo: поверхностное сравнение и его ловушки

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

    Правильно:

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

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

    Strict Mode: двойной рендер и его цель

    В React 18 Strict Mode намеренно вызывает двойной рендер компонентов и двойное выполнение эффектов в режиме разработки:

    Цель — выявить побочные эффекты, которые не очищаются корректно. Если ваш эффект ломается при двойном выполнении — это баг, который нужно исправить, а не отключать Strict Mode.

    Keys как инструмент сброса состояния

    key — не только для списков. Изменение key компонента заставляет React размонтировать его и создать заново, сбрасывая всё состояние:

    Это элегантнее, чем useEffect с очисткой стейта, и гарантирует полный сброс без риска stale state.

    React Compiler (React Forget)

    React Compiler (ранее известный как React Forget) — экспериментальный инструмент, который автоматически добавляет мемоизацию на уровне компилятора. Вместо ручного useMemo/useCallback компилятор анализирует код и добавляет оптимизации автоматически.

    На момент написания React Compiler доступен в бета-версии и используется в продакшне в Meta. Его появление означает, что ручная мемоизация через useMemo/useCallback постепенно уйдёт в прошлое — но понимание того, почему мемоизация нужна, останется важным.

    Все эти нюансы объединяет одна идея: React — это не магия, а предсказуемая система с чёткими правилами. Stale closures возникают из-за того, как работают замыкания в JavaScript. Батчинг — из-за планировщика Fiber. Двойной рендер в Strict Mode — намеренное решение для выявления багов. Понимание причин делает поведение предсказуемым, а отладку — быстрой.

    15. Модульная архитектура, Atomic Design и FSD

    Модульная архитектура, Atomic Design и FSD

    Команда из десяти разработчиков работает над одним React-проектом. Через год кодовая база выросла до 200 компонентов, и никто не может быстро найти, где лежит нужный файл. Компоненты импортируют друг друга по кругу. Изменение одной кнопки ломает три несвязанных экрана. Это не проблема технологий — это проблема архитектуры. Выбор правильного подхода к организации кода определяет, насколько проект будет масштабируемым через год.

    Почему архитектура фронтенда — это не про папки

    Архитектура — это не «куда положить файл». Это система правил, которая определяет:

  • Как компоненты зависят друг от друга
  • Где живёт бизнес-логика
  • Как переиспользуется код
  • Как команда договаривается о структуре
  • Без явных правил каждый разработчик создаёт свою структуру, и через полгода проект превращается в «большой ком грязи» (big ball of mud) — антипаттерн, где всё зависит от всего.

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

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

    Базовый принцип — высокая связность внутри модуля, низкая связность между модулями (high cohesion, low coupling):

    index.ts — это barrel file: он определяет, что модуль экспортирует наружу. Всё остальное — детали реализации, которые другие модули не должны импортировать напрямую.

    Проблема модульной архитектуры: она не даёт ответа на вопросы «как называть модули?», «как организовать компоненты внутри?», «как разрешать зависимости между модулями?». Для этого нужны более конкретные методологии.

    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 (страницы) — конкретные экземпляры шаблонов с реальными данными.
  • Сильные стороны Atomic Design:

  • Отличная интеграция со Storybook — каждый уровень легко документировать
  • Чёткая иерархия переиспользования
  • Хорошо работает для дизайн-систем и UI-библиотек
  • Слабые стороны:

  • Не отвечает на вопрос, где живёт бизнес-логика
  • Граница между molecule и organism субъективна — команды часто спорят
  • Плохо масштабируется для больших приложений с множеством фич
  • Не решает проблему зависимостей между фичами
  • На практике Atomic Design хорошо работает для дизайн-систем и UI-библиотек (компонентный слой), но недостаточен как архитектура всего приложения.

    !Сравнение трёх архитектурных подходов: Atomic Design, модульная архитектура и FSD

    Feature-Sliced Design: архитектура для продуктов

    Feature-Sliced Design (FSD) — методология, разработанная русскоязычным сообществом и ставшая стандартом де-факто для средних и крупных React-приложений. Её ключевое отличие: она решает не только вопрос «где лежат файлы», но и как разрешать зависимости.

    FSD организует код по двум измерениям: слои (layers) и слайсы (slices).

    Слои FSD (сверху вниз по абстракции)

    Главное правило FSD: слой может импортировать только из нижележащих слоёв. features может использовать entities и shared, но не widgets и не pages.

    Это правило устраняет циклические зависимости и делает архитектуру предсказуемой.

    Слайсы: организация внутри слоя

    Каждый слой (кроме app и shared) делится на слайсы — единицы по предметной области:

    Сегменты: стандартная структура слайса

    Внутри каждого слайса — сегменты с фиксированными именами:

  • ui/ — компоненты и стили
  • model/ — стейт, бизнес-логика, селекторы
  • api/ — запросы к серверу
  • lib/ — вспомогательные функции
  • config/ — конфигурация
  • Публичный API слайса

    Каждый слайс экспортирует только то, что нужно другим через index.ts. Прямые импорты внутрь слайса запрещены:

    Это правило позволяет рефакторить внутренности слайса без изменения кода в других местах.

    Реальный кейс: внедрение FSD в продуктовой компании

    На проекте с 150+ компонентами и командой из 8 разработчиков была проблема: новый разработчик не мог понять, куда добавить новую фичу. Компоненты были организованы по типу (components/, hooks/, utils/) — классическая «техническая» структура.

    Переход на FSD занял 3 недели:

  • Выделили shared/ui — перенесли все «тупые» компоненты без бизнес-логики.
  • Выделили entitiesUser, Product, Order со своими типами и базовыми компонентами.
  • Выделили features — каждый пользовательский сценарий стал слайсом.
  • Собрали 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: не знание «правильного ответа», а умение выбрать правильный инструмент для конкретного контекста.

    2. 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: полный подъём

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

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

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

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

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

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

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

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

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

    !Временная мёртвая зона (TDZ) для let и const

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Pending — начальное состояние, операция ещё выполняется.
  • Fulfilled — операция завершилась успешно, есть результат.
  • Rejected — операция завершилась с ошибкой.
  • Переход из pending в fulfilled или rejected необратим. Промис нельзя «сбросить» обратно в pending.

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

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

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

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

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

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

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

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

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

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

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

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

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

    !Схема работы промисов и async/await

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

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

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

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

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

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

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

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

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

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

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

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

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

    4. Микро- и макрозадачи в 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 создаёт минимум одну микрозадачу)
  • Критическое правило: очередь микрозадач полностью опустошается перед каждой следующей макрозадачей. Если микрозадача добавляет новую микрозадачу — она тоже выполнится до следующей макрозадачи.

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

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

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

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

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

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

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

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

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

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

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

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

  • Минимальная задержка в браузерах — 4 мс (для вложенных таймеров 5 уровней).
  • Перед выполнением колбэка выполнятся все текущие микрозадачи.
  • Браузер может перерисовать экран между макрозадачами.
  • Реальный кейс: разработчик хочет обновить DOM и сразу прочитать размеры элемента:

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

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

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

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

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

  • Выполнить одну макрозадачу из очереди (или синхронный скрипт при старте).
  • Полностью опустошить очередь микрозадач (включая новые, добавленные в процессе).
  • Если нужна перерисовка — выполнить requestAnimationFrame колбэки, затем перерисовать.
  • Если есть время — выполнить requestIdleCallback колбэки.
  • Вернуться к шагу 1.
  • Это объясняет, почему requestAnimationFrame идеален для анимаций: он выполняется синхронно с циклом перерисовки браузера, гарантируя 60 кадров в секунду при отсутствии тяжёлых вычислений.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • new — наивысший приоритет
  • Явная привязка (call, apply, bind)
  • Неявная привязка (метод объекта)
  • Привязка по умолчанию
  • this в классах

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

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

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

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

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

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

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

    Getter и setter:

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

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

  • Вызывается ли функция с new? → this = новый объект.
  • Вызывается ли с call/apply/bind? → this = первый аргумент.
  • Вызывается ли как метод объекта (obj.fn())? → this = объект слева от точки.
  • Ни одно из выше? → this = undefined (strict) или window (non-strict).
  • Стрелочные функции — отдельный случай: они не участвуют в этом алгоритме, потому что у них нет собственного this. Смотрите на лексическое окружение, где стрелочная функция определена, и применяйте алгоритм к нему.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Классы всегда работают в строгом режиме.
  • Классы не поднимаются (TDZ, как let/const).
  • Методы класса не перечисляемы (enumerable: false), в отличие от методов, добавленных на прототип вручную.
  • Вызов класса без new бросает TypeError.
  • Проверка принадлежности: instanceof и isPrototypeOf

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Не копирует функции (бросает DataCloneError)
  • Не копирует Symbol-ключи
  • Не копирует прототипы (результат — plain object)
  • Не копирует геттеры/сеттеры
  • JSON.parse(JSON.stringify(...)): старый трюк с ограничениями

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

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

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

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

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

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

    !Сравнение поверхностного и глубокого копирования объектов

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

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

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

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

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

    Object.freeze и Object.seal

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

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

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

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

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

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

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

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

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

    8. Парсинг страницы и 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() и изменить структуру документа.

    Шаг 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), и они должны быть актуальными.

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

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

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

  • Элементы с display: none не включаются в Render Tree (в отличие от visibility: hidden — они включаются, просто невидимы).
  • Псевдоэлементы (::before, ::after) включаются в Render Tree, хотя их нет в DOM.
  • Элементы <head>, <script>, <meta> не включаются.
  • Шаг 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 от HTML до пикселей

    Что такое «критический» в 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 загрузить асинхронно.

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

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

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

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

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

  • Разделить CSS на критический (встроить) и некритический (async).
  • Добавить defer к скриптам.
  • Включить HTTP/2 для параллельной загрузки ресурсов.
  • Результат: FCP снизился до 0.8 секунды. Это реальные числа с реального проекта — и все они объясняются пониманием Critical Rendering Path.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    rel="prefetch" и rel="preconnect"

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

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

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

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

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

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

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

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

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