React под капотом, Архитектура и PWA

Углублённый курс по внутреннему устройству React, стратегиям управления состоянием и проектированию масштабируемых приложений с поддержкой оффлайн-режима. Синтез знаний об асинхронности и TypeScript для создания отказоустойчивых веб-систем.

Механика рендеринга: Virtual DOM, Reconciliation и Fiber

Механика рендеринга: Virtual DOM, Reconciliation и Fiber

Парадокс: изменение одного класса у <div> через нативный JavaScript занимает микросекунды. Зачем же тогда React вводит огромную прослойку абстракций, которая добавляет время на выполнение JS-кода? Ответ кроется в том, что мы разбирали в первом курсе: сам по себе JavaScript невероятно быстр, а вот вызовы браузерных процессов Layout и Paint — непозволительно дороги. React был создан не для того, чтобы быстрее выполнять код, а чтобы минимизировать общение с реальным DOM, группируя изменения.

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

Virtual DOM: Иллюзия скорости

Браузерный DOM (Document Object Model) — это тяжеловесная структура. Каждый узел содержит десятки свойств и методов, а изменение узла может запустить каскадный перерасчет геометрии страницы (Layout).

Virtual DOM (VDOM) — это легковесная копия реального DOM, хранящаяся в оперативной памяти (в Memory Heap движка V8). По сути, это обычный JavaScript-объект. Когда вы пишете JSX, он компилируется в вызовы функции React.createElement(), которая возвращает объекты такого вида:

const element = {
  type: 'div',
  props: {
    className: 'container',
    children: [
      { type: 'h1', props: { children: 'Hello' } }
    ]
  }
};

Создание и изменение тысяч таких объектов в памяти занимает доли миллисекунды и никак не затрагивает процессы рендеринга браузера. Когда состояние компонента меняется, React не бежит сразу обновлять страницу. Он создает новое дерево Virtual DOM и сравнивает его со старым.

Этот процесс поиска отличий называется Reconciliation (согласование).

Reconciliation: Искусство находить отличия

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

Правило 1: Элементы разных типов порождают разные деревья

Если в процессе обновления тег <a> сменился на <img>, или компонент <Header /> сменился на <Footer />, React даже не будет пытаться искать сходства внутри их дочерних элементов.

Он полностью уничтожит старый узел (вызвав размонтирование) и создаст новый с нуля. Это радикально отсекает лишние ветви для сравнения.

Правило 2: Идентификация через ключи (Keys)

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

Здесь на помощь приходит атрибут key. Он сообщает React: «Этот элемент — тот же самый, что был в прошлом рендере, просто он изменил позицию».

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

Проблема Stack Reconciler: Пробка в Main Thread

До 16-й версии React использовал архитектуру, которую сейчас называют Stack Reconciler. Процесс обхода дерева и сравнения Virtual DOM был рекурсивным.

Из курса по асинхронности мы помним, как работает Call Stack (стек вызовов). Рекурсивный обход дерева означает, что функции кладутся в Call Stack одна за другой. Пока рекурсия не дойдет до конца дерева и стек не опустеет, движок JavaScript не отдаст управление браузеру.

Если дерево большое, фаза Reconciliation могла занимать 50, 100 или 200 миллисекунд. Это классический Long Task. Поскольку Main Thread заблокирован, браузер пропускает кадры (теряет заветные 16.6 мс на кадр), анимации дергаются, а клики пользователя не обрабатываются. React не мог сказать: «Я сравнил половину дерева, давай отрисуем кадр, а остальное я доделаю потом». Стек вызовов V8 нельзя поставить на паузу.

React Fiber: Собственный Call Stack

Чтобы решить проблему блокировки потока, команде React пришлось переписать ядро с нуля. Если нативный Call Stack нельзя прервать, значит, React должен реализовать свой собственный виртуальный стек, которым он сможет управлять.

Этой архитектурой стал React Fiber.

Fiber (волокно) — это структура данных, представляющая единицу работы. По сути, это обычный JS-объект, содержащий информацию о компоненте, его состоянии и том, что нужно сделать.

Главное отличие Fiber от старой архитектуры — отказ от рекурсии в пользу связного списка (Linked List). Каждый узел Fiber имеет ссылки на:

  1. child (первого ребенка)
  2. sibling (соседнего элемента)
  3. return (родителя)

Такая структура позволяет обходить дерево в цикле while, а не через рекурсию. А цикл можно прервать в любой момент!

Теперь React работает так:

  1. Берет один Fiber-узел, выполняет для него работу (вызывает функцию компонента, сравнивает VDOM).
  2. Проверяет, не истекло ли время, выделенное браузером (обычно около 5 мс).
  3. Если время вышло, React сохраняет указатель на текущий узел и возвращает управление Main Thread. Браузер отрисовывает кадр, обрабатывает события.
  4. В следующем цикле Event Loop React возобновляет работу ровно с того места, где остановился.

Этот механизм дробления работы называется Time-slicing (нарезка времени).

Две фазы рендеринга

Внедрение Fiber позволило разделить процесс обновления экрана на две строгие фазы, понимание которых критически важно для отладки.

1. Render Phase (Фаза рендера)

Это этап, на котором React вызывает ваши компоненты, строит новое дерево Fiber и вычисляет изменения (Reconciliation). Свойство фазы: Она асинхронная и прерываемая. React может начать рендер, прервать его ради более приоритетной задачи (например, ввода текста), отбросить результаты и начать заново. Именно поэтому в теле функционального компонента не должно быть сайд-эффектов (например, мутаций глобальных переменных) — функция компонента может быть вызвана несколько раз до того, как результат попадет на экран.

2. Commit Phase (Фаза фиксации)

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

Итог

Virtual DOM и Reconciliation защищают нас от медленных операций браузера, минимизируя обращения к реальному DOM. Однако рост сложности приложений потребовал эволюции самого React. Переход от рекурсивного Stack Reconciler к связному списку Fiber позволил раздробить тяжелые вычисления и перестал блокировать Main Thread.

Но самое главное: архитектура Fiber, научившая React ставить рендер на паузу, стала фундаментом для следующего большого шага — Concurrent Mode (конкурентного режима) и управления приоритетами задач, что мы подробно разберем в следующей главе.

Concurrent Mode и приоритеты: работа с Transition API и Suspense

Concurrent Mode и приоритеты: работа с Transition API и Suspense

Представьте интерфейс: пользователь быстро печатает текст в строке поиска, которая фильтрует список из 10 000 сложных компонентов. На каждое нажатие клавиши React запускает обновление состояния. Если рендеринг списка занимает 200 миллисекунд, главный поток (Main Thread) блокируется. Пользователь видит, как интерфейс «залипает», а буквы появляются с задержкой, потому что браузер пропускает кадры.

В основе решения этой проблемы лежит архитектура Fiber и разделение на прерываемую Render Phase и синхронную Commit Phase. Однако сам по себе Fiber — это лишь механизм (движок). Чтобы браузер не зависал, React должен понимать, какие именно задачи можно прерывать, а какие нужно выполнить немедленно. Для этого в React 18+ была внедрена концепция конкурентного рендеринга и система приоритетов.

Иллюзия многопоточности: что такое конкурентность в React

Конкурентный рендеринг (Concurrent Rendering) часто путают с параллельными вычислениями. Но JavaScript выполняется в одном потоке. React не может рендерить два компонента одновременно на разных ядрах процессора.

Вместо этого React использует кооперативную многозадачность. Он работает как планировщик операционной системы: начинает рендерить тяжелое дерево компонентов (Render Phase), периодически приостанавливает работу, чтобы вернуть управление браузеру (позволяя ему обработать пользовательский ввод или отрисовать кадр), а затем возобновляет рендеринг с того места, где остановился.

Если во время этой паузы поступает более важное обновление (например, пользователь нажал новую клавишу), React способен полностью отбросить текущую незавершенную работу, инвалидировать старый Render Phase и начать новый с актуальными данными.

Чтобы управлять этим процессом, React делит все обновления состояния на две категории:

  1. Urgent updates (Срочные обновления): Прямое взаимодействие (ввод текста, клики, ховеры). Интерфейс должен отреагировать немедленно.
  2. Transition updates (Переходы): Обновления UI, которые могут немного подождать (фильтрация списка, переключение вкладок с тяжелым контентом).

Математически логику планировщика можно описать через сравнение приоритетов: Purgent>PtransitionP_{urgent} > P_{transition} Где PP — это приоритет задачи в очереди Fiber. Если в очереди появляется задача с PurgentP_{urgent}, выполнение текущей задачи с PtransitionP_{transition} приостанавливается или отменяется.

Transition API: управление приоритетами вручную

По умолчанию React считает все вызовы setState срочными. Чтобы сказать движку: «это обновление может подождать, не блокируй из-за него интерфейс», используется Transition API.

startTransition и useTransition

Хук useTransition предоставляет флаг состояния isPending и функцию startTransition.

const [searchQuery, setSearchQuery] = useState('');
const [isPending, startTransition] = useTransition();

const handleInputChange = (event) => {
  const text = event.target.value;

  // 1. Срочное обновление: текст в инпуте меняется мгновенно
  setInputValue(text);

  // 2. Низкоприоритетное обновление: фильтрация списка
  startTransition(() => {
    setSearchQuery(text);
  });
};

Как это работает под капотом: Когда React видит вызов setSearchQuery внутри startTransition, он помечает созданный объект обновления (Update Object) специальным тегом (Lane) с низким приоритетом. Если пользователь вводит слово «React», быстро нажимая клавиши:

  1. Нажата «R»: запускается срочный рендер инпута. Затем начинается фоновый Render Phase для списка с фильтром «R».
  2. Нажата «e»: фоновый рендер для «R» еще не закончен (он прерывается). React видит срочное обновление инпута для «Re», выполняет его.
  3. React отбрасывает вычисления для «R» и начинает новый фоновый Render Phase для «Re».

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

useDeferredValue: когда мы не контролируем состояние

Transition API отлично работает, когда мы сами вызываем функцию обновления состояния. Но что делать, если мы получаем тяжелые данные через пропсы от родительского компонента или из сторонней библиотеки?

Для таких случаев существует useDeferredValue.

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

function SearchResults({ query }) {
  // query - это актуальное значение из инпута
  // deferredQuery - это "отстающее" значение для тяжелого рендера
  const deferredQuery = useDeferredValue(query);

  // Рендерим тяжелый список на основе deferredQuery
  return <HeavyList filter={deferredQuery} />;
}

Разница между ними архитектурная: useTransition оборачивает действие (функцию обновления), а useDeferredValue оборачивает результат (само значение).

Suspense: от CPU-bound к IO-bound задачах

Transition API решает проблемы, связанные с тяжелыми вычислениями и рендерингом (CPU-bound). Но во фронтенде есть другой класс проблем — ожидание данных по сети или загрузка чанков кода (IO-bound).

Исторически для обработки загрузки данных использовались флаги isLoading внутри компонента. Это приводило к водопадам (waterfalls) загрузок и сложной логике внутри JSX.

React Suspense меняет парадигму. Он позволяет компоненту «приостановить» свой рендеринг, пока необходимые ему данные не будут готовы, делегируя показ состояния загрузки ближайшему родительскому компоненту <Suspense>.

Механика Suspense: перехват Promise

Как React понимает, что компонент нужно приостановить? Здесь используется изящный хак, основанный на механизме перехвата ошибок, похожем на Error Boundaries, но работающем с асинхронностью.

Когда компонент пытается прочитать данные, которых еще нет, он выбрасывает (throw) Promise прямо во время Render Phase.

// Упрощенная демонстрация того, как работает кэш для Suspense
function fetchProfileData() {
  let status = 'pending';
  let result;
  // Создаем промис, который поменяет статус при разрешении
  let suspender = fetch('/api/profile').then(
    (res) => {
      status = 'success';
      result = res;
    },
    (err) => {
      status = 'error';
      result = err;
    }
  );

  return {
    read() {
      if (status === 'pending') {
        throw suspender; // Выбрасываем Promise!
      } else if (status === 'error') {
        throw result; // Выбрасываем ошибку (поймает ErrorBoundary)
      } else if (status === 'success') {
        return result; // Возвращаем данные
      }
    }
  };
}

Алгоритм работы React при встрече с Suspense:

  1. React вызывает функцию компонента (Render Phase).
  2. Компонент вызывает метод read().
  3. Так как данные еще грузятся, функция делает throw Promise.
  4. React ловит этот Promise. Он останавливает рендеринг этого поддерева.
  5. React поднимается вверх по дереву компонентов, находит ближайший <Suspense fallback={<Spinner />}> и рендерит fallback в синхронной Commit Phase.
  6. React берет пойманный Promise и навешивает на него .then().
  7. Когда Promise переходит в состояние Settled (Fulfilled), React инвалидирует кэш и запускает Render Phase заново. В этот раз read() вернет готовые данные.

Синтез: Suspense и Transitions

Настоящая мощь конкурентного режима раскрывается при объединении этих инструментов.

Если пользователь уже смотрит на список элементов и нажимает кнопку «Следующая страница», мы не хотим, чтобы старый список исчез и заменился на спиннер из <Suspense>. Это ухудшает пользовательский опыт (мерцание интерфейса).

Если обернуть смену страницы в startTransition, React изменит поведение Suspense:

  1. React начнет рендерить новую страницу в памяти (в фоновом Fiber-дереве).
  2. Наткнувшись на отсутствие данных, компонент выбросит Promise.
  3. Ключевое отличие: так как это Transition-обновление, React не будет показывать fallback спиннер. Он сохранит на экране старый, полностью интерактивный UI.
  4. Флаг isPending из useTransition станет true, что позволит нам показать аккуратный индикатор загрузки поверх старого списка.
  5. Когда данные загрузятся, React завершит фоновый рендер и за один кадр обновит DOM.

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

Управление состоянием: выбор между Context, Redux и MobX

Управление состоянием: выбор между Context, Redux и MobX

В предыдущих главах мы разобрали, как React оптимизирует процесс рендеринга с помощью архитектуры Fiber и как Concurrent Mode позволяет ставить отрисовку «на паузу». Но даже самый совершенный алгоритм Reconciliation не спасет приложение, если рендеринг запускается слишком часто и не там, где нужно. Любое обновление интерфейса начинается с изменения состояния. И то, как именно мы храним и передаем эти данные, определяет, будет ли React точечно обновлять один элемент или впустую пересчитывать половину дерева компонентов.

Когда приложение разрастается, передача пропсов сверху вниз через десятки промежуточных компонентов (Prop drilling) становится невыносимой. Возникает потребность в глобальном хранилище. Экосистема React предлагает три принципиально разных подхода: встроенный Context API, функционально-иммутабельный Redux и реактивно-мутабельный MobX.

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

Context API: внедрение зависимостей, а не стейт-менеджер

Самое частое заблуждение — считать встроенный в React Context полноценной заменой Redux или MobX. Context API создавался для решения одной конкретной задачи: доставки данных в любую точку дерева Fiber без явной передачи через пропсы (Dependency Injection).

Посмотрим, как это работает на уровне движка. Когда значение в <Context.Provider value={data}> меняется, React обходит поддерево Fiber и ищет все компоненты, которые используют этот контекст через хук useContext. Найдя такой компонент, React принудительно помечает его как требующий обновления (Render Phase).

В этом кроется главная архитектурная проблема Context API при работе с часто меняющимися данными.

Представьте, что мы храним в контексте объект с настройками приложения и данными пользователя: { theme: 'dark', user: { name: 'Alice', status: 'online' } }. Если статус пользователя изменится на 'offline', мы передадим в Provider новый объект. React увидит, что ссылка на объект изменилась, и заставит перерендериться абсолютно все компоненты, подписанные на этот контекст. Даже компонент кнопки, которому нужна только строка theme, будет вызван заново, потому что Context не умеет подписываться на отдельные поля объекта.

Dependency Injection (Внедрение зависимостей)

Паттерн проектирования, при котором объект получает объекты, от которых зависит (зависимости), извне, а не создает их сам. В React Context выступает именно таким механизмом доставки конфигурации или сервисов до компонентов.

Когда использовать: Context идеален для редко меняющихся глобальных данных: текущая локаль (язык), цветовая тема, токен авторизации или инстанс API-клиента. Использовать его для хранения динамического состояния (например, списка сообщений в чате или координат курсора) — прямой путь к деградации производительности.

Redux: иммутабельность и внешнее хранилище

Redux решает проблему лишних рендеров радикально: он выносит состояние за пределы дерева React. Хранилище (Store) в Redux — это обычный JavaScript-объект, существующий независимо от компонентов.

Чтобы связать внешнее хранилище с React, используются селекторы (хук useSelector). Именно здесь кроется магия оптимизации. Когда в Redux происходит действие (Action) и редьюсер возвращает новое состояние, Redux уведомляет всех подписчиков. Но компонент не рендерится сразу.

Сначала хук вычисляет результат функции-селектора. Если компонент подписан только на имя пользователя: useSelector(state => state.user.name), хук берет старое значение и новое значение, и сравнивает их.

Redux требует строгой иммутабельности (неизменяемости) данных. Мы не мутируем объекты, а создаем новые. Благодаря этому движку не нужно глубоко обходить вложенные свойства, чтобы понять, изменились ли данные. Достаточно проверить равенство ссылок: aba \neq b. Это операция со сложностью O(1)O(1), которая выполняется мгновенно. Если ссылка на user.name осталась прежней, хук молча игнорирует обновление хранилища, и Render Phase для этого компонента не запускается.

Архитектурные особенности Redux:

  • Единый источник истины: Все состояние лежит в одном большом дереве.
  • Предсказуемость: Состояние меняется только через отправку сериализуемых Action. Это позволяет легко реализовать Time-travel debugging (отладку с путешествием во времени), записывая и воспроизводя цепочку событий.
  • Разделение логики: Бизнес-логика (саги, thunk-и) и правила изменения данных (редьюсеры) полностью отделены от UI.

Когда использовать: Redux блестяще справляется со сложными бизнес-приложениями (CRM, финтех-дашборды), где множество независимых компонентов должны реагировать на одни и те же события. Он незаменим, когда важна строгая предсказуемость потока данных и возможность легко покрыть логику unit-тестами.

MobX: прозрачная реактивность через Proxy

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

В основе MobX лежит паттерн «Наблюдатель» (Observer), реализованный с помощью встроенного в движок JavaScript объекта Proxy. Когда мы объявляем класс состояния и помечаем его поля как observable, MobX оборачивает этот объект в Proxy.

Proxy позволяет перехватывать любые обращения к свойствам объекта (геттеры) и их изменения (сеттеры).

Как это работает в связке с React:

  1. Компонент оборачивается в функцию observer.
  2. Во время Render Phase компонент обращается к данным: store.user.name.
  3. Proxy-геттер MobX замечает: «Ага, компонент Profile прочитал свойство name». MobX динамически строит граф зависимостей.
  4. Позже где-то в коде происходит мутация: store.user.name = 'Bob'.
  5. Proxy-сеттер перехватывает изменение. MobX смотрит в свой граф, находит ровно те компоненты, которые читали name, и точечно инициирует их рендер.

Observable (Наблюдаемый объект)

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

Вам не нужно писать селекторы. Вы просто используете данные, и MobX сам знает, когда нужно обновить интерфейс. При этом, если вы измените store.user.status, компонент, читающий только store.user.name, не перерендерится. Реактивность получается максимально гранулярной (мелкомодульной) прямо из коробки.

Когда использовать: MobX идеально подходит для приложений со сложными, глубоко вложенными структурами данных (например, визуальные редакторы, таблицы с множеством связей). Там, где в Redux пришлось бы писать сложные нормализаторы (например, normalizr) для плоского хранения графа объектов, в MobX вы просто работаете с обычными ссылками между классами. Также MobX значительно ускоряет прототипирование за счет минимального количества шаблонного кода (boilerplate).

Синтез: как сделать выбор

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

Характеристика Context API Redux MobX
Парадигма Dependency Injection Функциональное программирование Объектно-ориентированное программирование
Мутабельность Зависит от реализации Строгая иммутабельность Мутабельность (через Proxy)
Оптимизация рендера Ручная (через разделение контекстов) Ручная (через селекторы) Автоматическая (гранулярная)
Кривая обучения Низкая Высокая (концепции Action, Reducer, Middleware) Средняя (понимание реактивности и Proxy)
Объем шаблонного кода Минимальный Высокий (даже с Redux Toolkit) Минимальный

Главный вывод: Не пытайтесь использовать один инструмент для всего. Современная архитектура часто подразумевает их комбинацию. Например, вы можете использовать Context для внедрения темы оформления, React Query (или RTK Query) для кэширования серверных данных, а локальное состояние сложных интерактивных виджетов доверить MobX.

Когда мы определились с тем, как управлять состоянием, возникает следующий вопрос: где именно в структуре проекта должны лежать эти хранилища, компоненты и бизнес-логика? Если каждый разработчик будет складывать файлы по-своему, даже идеальный стейт-менеджер не спасет проект от хаоса. В следующей главе мы разберем архитектурные паттерны организации кода: от базового Atomic Design до продвинутого Feature-Sliced Design (FSD).

Архитектурные паттерны: от Atomic Design до Feature-Sliced Design (FSD)

Архитектурные паттерны: от Atomic Design до Feature-Sliced Design (FSD)

Вы выбрали идеальный стейт-менеджер, настроили гранулярную реактивность через MobX или оптимизировали селекторы в Redux, и ваш интерфейс работает со скоростью 60 FPS. Но спустя полгода разработки добавление новой кнопки в профиль пользователя ломает корзину покупок, а папка src/components превратилась в свалку из 150 файлов. Проблема больше не в том, как данные текут по приложению, а в том, где живет код и кто от кого зависит.

Управление состоянием решает задачу потока данных, но не решает задачу организации кодовой базы. Для этого нужны архитектурные паттерны.

Atomic Design: химия пользовательского интерфейса

Исторически первой попыткой навести порядок в React-приложениях стала методология Atomic Design (Атомарный дизайн), предложенная Брэдом Фростом. Она предлагает смотреть на интерфейс не как на набор страниц, а как на систему компонентов, собираемую от простого к сложному, подобно химическим элементам.

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

Brad Frost, "Atomic Design"

Структура атомарного подхода выглядит так:

  1. Атомы (Atoms): Базовые строительные блоки, которые не могут быть разбиты на меньшие части без потери смысла. Пример: <Button />, <Input />, <Label />.
  2. Молекулы (Molecules): Простые комбинации атомов, выполняющие одну конкретную задачу. Пример: <SearchForm />, состоящая из <Label>, <Input> и <Button>.
  3. Организмы (Organisms): Сложные, самостоятельные блоки интерфейса, состоящие из молекул и/или атомов. Они формируют законченные смысловые секции. Пример: <Header />, включающий логотип (атом), навигацию (молекула) и форму поиска (молекула).
  4. Шаблоны (Templates): Каркасы страниц, определяющие расположение организмов, но не содержащие реальных данных.
  5. Страницы (Pages): Конкретные экземпляры шаблонов, куда проброшены реальные данные (стейт, ответ от API).

Проблема атомарного дизайна в сложных приложениях

Atomic Design идеально решает задачу создания UI-китов (библиотек компонентов) и Storybook. Но когда дело доходит до реального React-приложения со сложной бизнес-логикой, возникает концептуальный тупик: где должен жить стейт?

Представьте организм <UserCard />. С точки зрения Atomic Design, это просто визуальный блок. Но что если внутри него есть кнопка «Добавить в друзья», которая должна делать dispatch экшена в Redux? Если мы подключим Redux прямо к организму, он перестанет быть переиспользуемым (его нельзя будет отрендерить без Redux-провайдера). Если мы вынесем логику на уровень Страницы и будем передавать коллбеки через пропсы вниз, мы вернемся к проблеме Prop drilling, от которой уходили в предыдущей главе.

Atomic Design группирует код по визуальному масштабу, полностью игнорируя бизнес-домены.

Feature-Sliced Design (FSD): архитектура по бизнес-смыслу

Чтобы решить проблему смешивания UI и бизнес-логики, фронтенд-сообщество пришло к архитектуре, ориентированной на домены (Domain-Driven Design для фронтенда). Самым популярным воплощением этого подхода стал Feature-Sliced Design (FSD).

Feature-Sliced Design — архитектурная методология для фронтенда, которая разделяет проект по бизнес-доменам (фичам) и строит строгий однонаправленный граф зависимостей между слоями.

FSD отказывается от абстрактных «организмов» и вводит четкие слои (Layers), каждый из которых имеет свою зону ответственности. Слои располагаются в строгой иерархии — от самых переиспользуемых (внизу) до самых специфичных (наверху).

Слои FSD (снизу вверх)

  1. Shared (Общее): Инфраструктурный код, отвязанный от специфики бизнеса. Здесь живут UI-кит (те самые атомы и молекулы!), хелперы, настройки API-клиента.
  2. Entities (Сущности): Бизнес-сущности приложения. Например, User, Article, Product. Здесь лежат типы данных, базовые (глупые) UI-компоненты для отображения сущности и схемы стейта (например, Redux-слайс для хранения загруженного пользователя).
  3. Features (Фичи): Пользовательские сценарии, несущие бизнес-ценность. Это действия, которые пользователь совершает над сущностями. Например: LikeArticle, SendComment, AddToCart. Именно здесь происходит основная работа со стейт-менеджерами и API.
  4. Widgets (Виджеты): Самостоятельные блоки интерфейса, объединяющие сущности и фичи. Например, ArticleFeed (список карточек статей с кнопками лайков) или Header (содержащий профиль пользователя и авторизацию).
  5. Pages (Страницы): Компоненты маршрутизации (роуты). Их задача — просто собрать нужные виджеты вместе.
  6. App (Приложение): Инициализация приложения. Здесь настраиваются глобальные провайдеры (Redux Store, Router, Context, глобальные стили).

Главное правило FSD: Однонаправленность зависимостей

Архитектура работает только тогда, когда в ней есть строгие правила импортов. В FSD правило одно: модуль может импортировать только те модули, которые находятся на слоях строго ниже него.

Это значит, что:

  • Features могут импортировать Entities и Shared.
  • Entities могут импортировать только Shared.
  • Shared не может импортировать ничего из других слоев.
  • Никто не может импортировать Widgets кроме Pages.

Если фиче LikeArticle нужно обновить счетчик лайков в сущности Article, она делает это, импортируя экшен из слоя Entities. Но сущность Article ничего не знает о том, что ее кто-то лайкает — она остается изолированной и переиспользуемой.

Анатомия фичи: как это выглядит на практике

Давайте посмотрим, как распределится код для задачи «Добавление статьи в избранное» в FSD:

  • shared/ui/Button: Глупая кнопка (Атом). Ничего не знает о статьях.
  • entities/article:
    • Тип Article { id, title, isFavorite }.
    • Компонент <ArticleCard /> (принимает данные статьи через пропсы, не ходит в сеть).
  • features/addToFavorite:
    • Компонент <FavoriteButton />. Внутри себя использует shared/ui/Button, берет ID статьи из пропсов, делает dispatch(addToFavoriteThunk(id)) в Redux.
  • widgets/article-list:
    • Итерируется по массиву статей, рендерит <ArticleCard /> (из Entities), передавая внутрь <FavoriteButton /> (из Features) в качестве слота (React children или render-prop).

В результате изменения в логике лайков (Features) никогда не сломают отображение самой карточки (Entities), а карточку можно легко переиспользовать в другом месте без привязки к логике лайков.

Сравнение подходов: что выбрать?

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

Критерий Atomic Design Feature-Sliced Design (FSD)
Главный фокус Визуальная композиция и размеры UI-элементов. Бизнес-логика, домены и потоки данных.
Управление состоянием Не регламентировано (часто приводит к хаосу). Четко локализовано (в слоях Entities и Features).
Идеальный юзкейс Разработка UI-библиотек (Material UI, Ant Design), Storybook. Разработка продуктовых приложений (SPA, PWA) со сложной логикой.
Масштабируемость Низкая (Организмы быстро обрастают пропсами и связями). Высокая (Изоляция фич предотвращает сайд-эффекты).

Атомарный дизайн отлично описывает то, что в FSD называется слоем Shared. Вы можете смело использовать терминологию «атомы и молекулы» внутри папки shared/ui, а для всего остального приложения применять строгие слои и бизнес-домены FSD.

Архитектура — это искусство проведения границ. Мы разобрались, как изолировать компоненты друг от друга на уровне файловой системы и зависимостей JavaScript. Но когда эти изолированные виджеты и фичи попадают в DOM, браузеру нужно отрисовать их на экране, не сломав верстку. О том, как современный CSS решает проблему позиционирования сложных независимых блоков, мы поговорим в следующей главе, разобрав механику CSS Grid.

CSS Grid: современная раскладка и решение проблем Layout

CSS Grid: современная раскладка и решение проблем Layout

Представьте, что вы собираете страницу приложения по методологии Feature-Sliced Design (FSD). У вас есть независимые виджеты: шапка, боковая панель, лента новостей и подвал. Если ваш единственный инструмент — это Flexbox, вам придется создавать искусственные HTML-обертки. Вы обернете боковую панель и ленту в дополнительный div, чтобы расположить их в ряд, а затем поместите этот блок между шапкой и подвалом. Каждая такая обертка углубляет дерево DOM. Как мы разбирали в самом начале курса, глубокое дерево DOM усложняет этап вычисления геометрии (Layout или Reflow), заставляя браузер тратить драгоценные миллисекунды бюджета кадра на расчет вложенных контейнеров.

Здесь на сцену выходит CSS Grid. Это не просто еще один способ выравнивания кнопок, это архитектурный инструмент, который позволяет отвязать визуальную раскладку от HTML-структуры.

Смена парадигмы: от одномерности к двумерности

Главная ошибка при изучении современных спецификаций CSS — попытка противопоставить Flexbox и Grid, выбирая «что-то одно». На самом деле они решают принципиально разные задачи, которые вытекают из их размерности.

Flexbox работает от контента (Content-out), а Grid — от контейнера (Layout-in).

Характеристика Flexbox (1D) CSS Grid (2D)
Оси управления Одна ось за раз (строка или колонка). Две оси одновременно (строки и колонки).
Поведение элементов Элементы сами решают, сколько места занять, расталкивая соседей. Контейнер диктует элементам, в какие ячейки им встать.
Идеальный юзкейс Навигационное меню, группа кнопок, карточка товара. Каркас страницы, галерея со сложной сеткой, дашборд.

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

Анатомия сетки и гибкая единица измерения

Чтобы Grid мог управлять пространством, ему нужна система координат. Она состоит из треков (Grid Tracks — это ряды и колонки) и линий (Grid Lines — границы между треками).

Определяя размеры треков, мы сталкиваемся с проблемой адаптивности. Использовать пиксели жестко, а проценты — неудобно, так как при добавлении отступов (gap) сумма процентов превысит 100%100\%, и верстка сломается. Для решения этой математической проблемы спецификация Grid ввела единицу измерения fr (fractional unit).

Единица fr означает «доля свободного пространства». Алгоритм браузера работает так: сначала он вычитает из ширины контейнера все фиксированные размеры (пиксели, em, gap), а оставшееся место делит пропорционально между элементами с fr.

Например, если мы зададим колонки как grid-template-columns: 1fr 2fr 1fr, браузер разделит свободное пространство на 4 части (1+2+1=41 + 2 + 1 = 4). Если доступная ширина контейнера составляет 1000 пикселей, то центральная колонка получит 500500 пикселей, а боковые — по 250250 пикселей. Это избавляет разработчика от необходимости писать сложные функции calc() для учета отступов.

Декларативная архитектура с grid-template-areas

Самая мощная возможность CSS Grid, идеально ложащаяся на компонентный подход React — это именованные области. Свойство grid-template-areas позволяет буквально «нарисовать» макет страницы прямо в CSS-коде с помощью строк.

Вернемся к нашей FSD-странице. В React-компоненте мы можем отрендерить виджеты плоским списком, без единой обертки:

<main className="page-layout">
  <HeaderWidget className="header" />
  <SidebarWidget className="sidebar" />
  <FeedWidget className="content" />
  <FooterWidget className="footer" />
</main>

Вся магия позиционирования переносится в CSS. Мы присваиваем каждому компоненту имя области через grid-area, а в контейнере визуализируем их расположение:

.header { grid-area: h; }
.sidebar { grid-area: s; }
.content { grid-area: c; }
.footer { grid-area: f; }

.page-layout {
  display: grid;
  grid-template-columns: 250px 1fr;
  grid-template-rows: 60px 1fr 80px;
  grid-template-areas:
    "h h"
    "s c"
    "f f";
}

Этот синтаксис решает сразу две глобальные задачи:

  1. Читаемость кода. Любой разработчик, открыв CSS, моментально понимает структуру страницы. Буква h занимает две колонки сверху, s и c делят центральную часть, f занимает низ.
  2. Изоляция изменений. Если на мобильных устройствах боковую панель нужно убрать вниз, под контент, мы не трогаем React-дерево. Мы меняем только grid-template-areas внутри медиа-запроса:
@media (max-width: 768px) {
  .page-layout {
    grid-template-columns: 1fr;
    grid-template-areas:
      "h"
      "c"
      "s"
      "f";
  }
}

Влияние на производительность рендеринга

Использование Grid-раскладки напрямую влияет на производительность, возвращая нас к концепциям из начала курса.

Когда мы избавляемся от div-оберток, необходимых для Flexbox, мы делаем дерево DOM более плоским (Flat DOM). Меньшее количество узлов в DOM означает, что:

  • Движок браузера быстрее сопоставляет стили (CSSOM) с элементами.
  • Построение Render Tree занимает меньше времени.
  • Самая ресурсоемкая фаза — Layout (Reflow) — выполняется быстрее, так как браузеру нужно рассчитать геометрию меньшего количества вложенных прямоугольников.

Таким образом, архитектурное решение (использование FSD-виджетов на одном уровне) и технологическое решение (CSS Grid для их позиционирования) работают в синергии. Мы получаем чистый React-код, декларативный CSS и оптимизированный Critical Rendering Path.

Теперь, когда наше приложение имеет строгую, масштабируемую архитектуру, оптимизированный рендеринг и надежное управление состоянием, остается последний барьер. Все эти технологии бесполезны, если у пользователя пропадает интернет. В следующей главе мы разберем, как Service Workers позволяют приложению пережить потерю сети и превращают обычный сайт в полноценное Progressive Web App (PWA).

Service Workers: жизненный цикл и стратегии кэширования

Service Workers: жизненный цикл и стратегии кэширования

Мы выстроили архитектуру приложения, оптимизировали рендеринг React и настроили идеальную сетку на CSS Grid. Но всё это теряет смысл в ту секунду, когда пользователь заходит в туннель метро и видит страницу с динозавром. Браузеру нечего рендерить, если он не смог скачать HTML и JavaScript.

Чтобы разорвать жесткую зависимость веб-приложений от стабильного интернета, был создан механизм, превращающий обычный сайт в Progressive Web App (PWA). В основе этого механизма лежит Service Worker — программируемый сетевой прокси-сервер, работающий прямо в браузере пользователя.

Service Worker как фоновый процесс

Ранее мы разбирали, что движок JavaScript работает в единственном потоке (Main Thread), но может делегировать задачи фоновым Web APIs. Service Worker (SW) — это именно такой фоновый процесс.

Он выполняется в отдельном от основной страницы потоке. У него нет доступа к DOM (он не может менять элементы на странице), и он не блокирует UI. Его главная суперспособность — перехват абсолютно всех сетевых запросов, исходящих от приложения.

Service Worker стоит между вашим приложением и интернетом. Когда React-код делает fetch('/api/users') или браузер запрашивает <img src="logo.png">, этот запрос сначала попадает в Service Worker. И именно SW решает: отправить запрос дальше в сеть, отдать сохраненную копию из кэша или сгенерировать ответ на лету.

Так как SW живет вне контекста страницы, он может работать даже тогда, когда вкладка с приложением закрыта. Это открывает путь к фоновой синхронизации и Push-уведомлениям.

Жизненный цикл: от установки до перехвата

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

1. Регистрация (Main Thread)

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

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => console.log('SW зарегистрирован'))
    .catch(error => console.error('Ошибка:', error));
}

2. Установка (Install)

Как только браузер скачивает sw.js и видит, что это новый воркер, внутри самого SW срабатывает событие install.

Это идеальный момент для прекэширования (pre-caching) — сохранения критически важных файлов (App Shell), без которых приложение не запустится. App Shell обычно включает базовый HTML, CSS-файлы и JS-бандлы.

// Внутри sw.js
self.addEventListener('install', event => {
  // event.waitUntil удерживает SW в фазе установки,
  // пока промис не разрешится (пока кэш не скачается)
  event.waitUntil(
    caches.open('app-shell-v1').then(cache => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles.css',
        '/bundle.js'
      ]);
    })
  );
});

3. Активация (Activate)

После успешной установки SW переходит в фазу активации. Однако, если у пользователя прямо сейчас открыта вкладка со старой версией приложения, новый SW будет находиться в состоянии ожидания (waiting). Он активируется только когда все вкладки со старой версией будут закрыты. Это гарантирует, что приложение не сломается из-за подмены файлов на лету.

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

4. Перехват запросов (Fetch)

Как только SW активирован, он начинает контролировать страницу. С этого момента каждый сетевой запрос вызывает событие fetch. Здесь мы применяем стратегии кэширования.

Стратегии кэширования: баланс между скоростью и актуальностью

Когда запрос перехвачен, у нас есть выбор. В зависимости от типа запрашиваемых данных (статика, API, картинки), мы применяем разные паттерны ответа. Для работы с сохраненными ответами используется встроенный Cache API.

Стратегия 1: Cache First (Кэш, затем сеть)

Применение: Статические ресурсы (шрифты, изображения, JS/CSS бандлы с хэшами в именах).

Логика: Мы ищем ответ в кэше. Если он там есть — мгновенно отдаем его. Если нет — идем в сеть, скачиваем, сохраняем в кэш на будущее и отдаем приложению.

В стандартной ситуации время ответа складывается из времени сети и обработки сервером: Tresponse=Tnetwork+TserverT_{response} = T_{network} + T_{server}. При использовании стратегии Cache First для закэшированных файлов Tresponse0T_{response} \approx 0, так как данные отдаются с локального диска за миллисекунды, минуя сеть.

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cachedResponse => {
      // Вернуть из кэша, если найдено
      if (cachedResponse) return cachedResponse;

      // Иначе сходить в сеть
      return fetch(event.request).then(networkResponse => {
        return caches.open('dynamic-cache-v1').then(cache => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
      });
    })
  );
});

Стратегия 2: Network First (Сеть, затем кэш)

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

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

Стратегия 3: Stale-While-Revalidate (Устаревшее, пока ревалидируется)

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

Логика: Это гибридный подход. Мы мгновенно отдаем данные из кэша (stale), чтобы UI отрисовался без задержек. Одновременно с этим (в фоне) мы делаем запрос в сеть (revalidate), скачиваем свежую версию и тихо обновляем кэш.

При паттерне Stale-While-Revalidate пользователь всегда видит данные мгновенно, но это данные от его предыдущего визита. Свежие данные, скачанные в фоне, он увидит при следующем посещении страницы.

Синтез: архитектура и кэширование

Выбор стратегии не бывает случайным, он строго привязан к архитектуре приложения. Вспомним структуру Feature-Sliced Design из предыдущих глав.

Слой Shared содержит UI-кит и иконки — они меняются редко, для них идеален Cache First. Слой Entities (например, профиль пользователя) требует актуальности, поэтому запросы за сущностями обрабатываются по стратегии Network First.

Таким образом, Service Worker становится не просто «костылем для оффлайна», а полноценным архитектурным слоем управления данными. Он берет на себя сетевую нестабильность, позволяя React-компонентам и стейт-менеджерам (Redux/MobX) работать с предсказуемым потоком данных, не заботясь о том, есть ли у пользователя интернет прямо сейчас.

Однако Cache API, с которым работает Service Worker, предназначен для хранения целых HTTP-ответов (файлов, JSON-строк). Если нам нужно локально хранить и изменять сложные структурированные данные (например, черновики сообщений, которые пользователь написал в оффлайне, чтобы отправить их позже), простого кэша будет недостаточно. Для таких задач потребуется полноценная браузерная база данных.

PWA в действии: Offline, IndexedDB и Push-уведомления

PWA в действии: Offline, IndexedDB и Push-уведомления

Пользователь заполняет длинную форму оформления заказа, нажимает кнопку «Оплатить», и в этот самый момент его поезд въезжает в тоннель. Сеть пропадает. Если приложение опирается только на стандартные сетевые запросы, браузер выдаст ошибку соединения, а введенные данные безвозвратно исчезнут. Чтобы превратить этот фрустрирующий опыт в бесшовный, нам недостаточно просто закэшировать статику — нам нужно научить приложение сохранять пользовательские намерения локально, отправлять их в фоне при появлении сети и уведомлять об успехе, даже если вкладка уже закрыта.

За пределами Cache API: зачем нужна IndexedDB

Ранее мы разобрали Cache API — отличный инструмент для хранения статических ресурсов (App Shell) и ответов на GET-запросы. Однако Cache API оперирует парами «Запрос-Ответ». Он совершенно не подходит для сохранения структурированных пользовательских данных, таких как черновики сообщений, товары в корзине или аналитические события.

Для этих задач в браузере существует другой механизм.

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

В отличие от localStorage, который синхронен и блокирует Main Thread (что критично при 16.6 мс на кадр), IndexedDB работает асинхронно. Все операции чтения и записи делегируются Web APIs, а результаты возвращаются в Main Thread через очередь микрозадач (Microtask Queue) в виде промисов. Это позволяет сохранять мегабайты данных, не вызывая просадок FPS в React-приложении.

Данные в IndexedDB организованы в Object Stores (хранилища объектов, аналог таблиц), где каждый объект имеет уникальный ключ. Транзакции гарантируют целостность: если при сохранении сложного заказа из нескольких позиций произойдет сбой, вся операция откатится.

Background Sync: отложенная отправка намерений

Итак, поезд в тоннеле, пользователь нажал «Отправить», и мы сохранили его заказ в IndexedDB. Но как отправить эти данные на сервер, когда связь восстановится? Пользователь может к тому времени переключиться на другое приложение или вообще закрыть вкладку браузера.

Здесь на сцену выходит Service Worker и Background Sync API.

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

  1. Регистрация намерения: React-приложение понимает, что сети нет (например, перехватив ошибку fetch), сохраняет данные в IndexedDB и регистрирует событие синхронизации с определенным тегом.
  2. Ожидание: Браузер берет на себя ответственность за отслеживание статуса сети.
  3. Пробуждение: Как только устройство ловит стабильный интернет, браузер запускает Service Worker и генерирует в нем событие sync.
  4. Отправка: Service Worker извлекает сохраненные данные из IndexedDB и отправляет их на сервер.
// Внутри React-компонента или слоя Features (FSD)
async function saveOrderOffline(orderData) {
  // 1. Сохраняем в IndexedDB
  await db.put('orders', orderData);

  // 2. Регистрируем фоновую синхронизацию
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('sync-new-orders');
}

В самом Service Worker мы слушаем это событие:

// Внутри Service Worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-new-orders') {
    // event.waitUntil гарантирует, что Service Worker не уснет,
    // пока промис не разрешится
    event.waitUntil(sendOrdersToServer());
  }
});

async function sendOrdersToServer() {
  const orders = await idb.getAll('orders');
  for (const order of orders) {
    await fetch('/api/orders', { method: 'POST', body: JSON.stringify(order) });
    await idb.delete('orders', order.id); // Очищаем локальную базу после успеха
  }
}

Push-уведомления: инициатива от сервера

Заказ успешно отправлен в фоне. Теперь серверу нужно сообщить пользователю: «Ваш заказ принят в обработку». Но вкладка все еще закрыта. Нам нужен механизм, позволяющий серверу инициировать контакт с клиентом.

Частая ошибка на собеседованиях — путать Push API и Notifications API. Это две разные технологии, которые работают в тандеме:

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

Жизненный цикл Push-уведомления:

  1. Приложение запрашивает у пользователя разрешение на отправку уведомлений.
  2. Браузер связывается со своим пуш-сервисом и получает уникальный endpoint (URL) для этого конкретного устройства.
  3. Приложение отправляет этот endpoint на ваш бэкенд.
  4. Когда нужно отправить уведомление, ваш бэкенд делает HTTP-запрос на этот endpoint.
  5. Пуш-сервис браузера находит устройство пользователя и будит Service Worker событием push.
  6. Service Worker вызывает Notifications API для показа UI.
// Внутри Service Worker
self.addEventListener('push', (event) => {
  const data = event.data.json(); // Данные, пришедшие от сервера

  const options = {
    body: data.message,
    icon: '/icon-192x192.png',
    vibrate: [100, 50, 100],
    data: { url: data.redirectUrl } // Сохраняем URL для клика
  };

  // Показываем системное уведомление
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Обработка клика по уведомлению
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  );
});

Синтез: Архитектура оффлайн-фичи

Давайте сведем все изученные концепции воедино и посмотрим, как оффлайн-заказ ложится на архитектуру Feature-Sliced Design (FSD) и механизмы React.

Слой / Технология Роль в процессе Описание взаимодействия
Feature (React) UI и координация Компонент кнопки «Оплатить». Использует startTransition (Concurrent Mode), чтобы UI не блокировался при формировании тяжелого объекта заказа. При ошибке сети вызывает хелпер из слоя Shared.
Shared (API) Абстракция хранилища Утилита для работы с IndexedDB. Инкапсулирует логику открытия транзакций и записи, возвращая в Feature чистый Promise.
App (Service Worker) Фоновая координация Регистрирует sync. При появлении сети извлекает данные из IndexedDB (Shared) и выполняет реальный сетевой запрос.
App (Service Worker) Реакция на сервер Слушает событие push. Когда сервер сообщает об успешной обработке отложенного заказа, показывает системное уведомление.

В этой схеме React отвечает только за декларативный UI и передачу намерений. Движок V8 в Main Thread остается свободным для рендеринга и анимаций. Грязная работа с сетью, локальной базой данных и ожиданием связи полностью вынесена в фоновые потоки браузера (Web APIs и Service Worker).

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