Главная / Статьи

Спланировщик React и цикл событий: кто на самом деле решает, когда выполняться задачи

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

Издатель: REACTAPP.TOP13 мин чтения3017 слов

Источники: How Does React’s Scheduler Work With the JavaScript Event Loop?

React определяет не только то, что должно измениться на экране, но и когда это произойдет, используя приоритеты, потоки обработки и возможность прерывания процесса отрисовки. Однако React — это всего лишь JavaScript, у которого уже есть цикл событий, отвечающий за определение момента выполнения кода. Так почему же React предоставляет собственный планировщик, и кто на самом деле контролирует процесс? В этой статье разбирается эта взаимосвязь, показывается место конкурентных функций, таких как переходы и useDeferredValue, а также объясняется фундаментальный лимит, который никакой умный планировщик не может преодолеть, как в веб-приложениях, так и в React Native.

Начните с стека вызовов, а не с React

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

console.log("A");

function calculate() {
  console.log("B");
}
calculate();
console.log("C");

Результаты выполнения кода соответствуют его порядку точно:

A
↓
B
↓
C

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

console.log("A");
setTimeout(() => {
  console.log("B");
}, 0);
console.log("C");

Порядок меняется:

A
C
B

Это удивляет людей, потому что задержка буквально равна нулю:

0

Объяснение заключается в том, что setTimeout() никогда не означает «выполнить это сейчас». Он означает, что функция-колбэк становится готова к выполнению после задержки, но ей всё равно придётся ждать, пока не завершится текущий код и система выполнения не дойдёт до неё. Решение о том, когда это произойдёт, лежит в компетенции цикла событий.

Кратко о цикле событий

Для анализа React достаточно упрощённой картины:

              ┌─────────────┐
              │ Call Stack  │
              └──────┬──────┘
                     │
                     ↓
              JavaScript Runs
                     │
                     ↓
              Stack becomes free
                     │
                     ↓
                Event Loop
                     │
              ┌──────┴──────┐
              ↓             ↓
         Microtasks       Tasks

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

Намеренно замороженное приложение

Чтобы понять, что означает это правило для React, рассмотрим компонент, в котором обработчик нажатия выполняет цикл с высокой загрузкой в течение примерно пяти секунд перед обновлением состояния. В примере используется компонент Button из React Native, но поведение аналогично в React DOM:

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

function handlePress() {
    const start = Date.now();
    while (Date.now() - start < 5000) {
      // intentionally blocking JavaScript
    }
    setCount(c => c + 1);
  }
  return (
    <Button
      title={`Count: ${count}`}
      onPress={handlePress}
    />
  );
}

В течение этих пяти секунд поток JavaScript:

JavaScript Thread

выглядит так:

██████████████████████████████       BLOCKED FOR 5 SEC

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

Кто контролирует?

Хочется представить, что React находится над всем и управляет JavaScript:

React Scheduler
      ↓
Controls JavaScript

Но на самом деле всё происходит наоборот: среда выполнения предоставляет время для работы, а оптимизатор React решает, как им воспользоваться:

JavaScript Runtime
      ↓
Provides execution opportunity
      ↓
React Scheduler
      ↓
Coordinates React work

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

Зачем вообще нужен распорядитель?

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

App
├── Header
├── Search
├── Filters
├── ProductList
│   ├── Product
│   ├── Product
│   ├── Product
│   ├── ...
│   └── Product × 5000
└── Footer

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

Start Render
     ↓
████████████████████████████
     ↓
Finish Entire Render

Тем временем пользователь продолжает взаимодействовать с интерфейсом:

Type
Tap
Scroll

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

Совместное планирование

Внутренний планировщик React — именно такая кооперативная система. Вместо старого подхода:

Render everything NOW

он постоянно задает ряд вопросов о незавершённой работе:

There is React work pending.What should we work on first?How urgent is it?Should we continue?Should we yield?Did something more important arrive?

С точки зрения потока выполнения цикл выглядит так:

Pending React Work
        ↓
     Scheduler
        ↓
Check Priority
        ↓
Perform Some Work
        ↓
Should We Yield?
   ↙             ↘
 YES             NO
  ↓               ↓
Give Host       Continue
a Chance        Rendering

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

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

Два уровня, два вопроса

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

  • Цикл событий задаётся вопросом: когда JavaScript может выполнить запланированную работу?
  • Планировщик React задаётся вопросом: теперь, когда у React есть поток, какая из задач React должна быть выполнена в первую очередь?

Если объединить все слои, они будут выглядеть так:

Browser / Native Runtime
          ↓
    JavaScript Runtime
          ↓
       Event Loop
          ↓
 JavaScript gets to run
          ↓
   React Scheduler
          ↓
 Choose React Work
          ↓
       Fiber
          ↓
   Render / Reconcile
          ↓
       Commit

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

Когда ввод текста конкурирует с обработкой большого объёма данных

Представьте, что React находится на середине отрисовки большого списка товаров:

Rendering Products...
██████████████░░░░░░░░

Затем пользователь начинает вводить запрос к поиску, по одной клавише за раз:

R
Re
Rea
React

Теперь у React есть два вида задач, конкурирующих за один и тот же поток:

Large List Rendering
        +
User Input Update

Для пользователя гораздо важнее процесс ввода: каждая буква должна появляться мгновенно, даже если отрисовка списка немного задерживается. Приоритизация ввода и прерывание отрисовки списка позволяют этого добиться.

Как Fiber обеспечивает точки разделения

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

App Fiber
   ↓
Header Fiber
   ↓
Search Fiber
   ↓
List Fiber
   ↓
Product Fiber
   ↓
Product Fiber
   ↓
...

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

Start Rendering
      ↓
Perform Some Fiber Work
      ↓
Pause
      ↓
Handle More Urgent Work
      ↓
Continue / Restart

В документации React одновременную отрисовку описывают как прерываемую именно в этом смысле: процесс отрисовки может быть запущен, приостановлен, возобновлен позже или прерван, если он устарел, и все это до того, как что-либо будет сохранено. Это совершенно иное утверждение, чем то, что React выполняет JavaScript параллельно, чего он не делает.

Одновременность — это не параллельность

Фраза:

Concurrent React

Часто возникает образ отдельных потоков для каждой задачи:

Thread 1 → InputThread 2 → Product ListThread 3 → React Rendering

Эта модель неверна. Механизм параллелизма в React основан на переключении действий в рамках одного потока, более похожего на это:

Work A
██████░░░░░░        ↓ pauseWork B
████        ↓Work A
██████

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

Срочные задачи, поступающие во время перехода

Предположим, React отрисовывает ресурсоемкое обновление, которое было помещено в рамки перехода:

startTransition(() => {
  setResults(expensiveResults);
});

Пока происходит эта отрисовка, пользователь вводит еще один символ. Последовательность действий тогда выглядит примерно так:

Transition Render
██████████░░░░░░░
          ↓      User Types          ↓Urgent Update
████████          ↓React can reconsider
lower-priority work

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

Отложение значения вместо обновления

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

const deferredQuery = useDeferredValue(query);

Входной элемент продолжает использовать реальное, актуальное состояние:

<TextInput
  value={query}
  onChangeText={setQuery}
/>

в то время как дорогостоящий компонент получает отложенную копию:

<ExpensiveList query={deferredQuery} />

В результате поле уже может отображать:

React

в то время как список ещё некоторое время отражает предыдущий запрос:

Reac

Тем временем React готовит новый список в отдельности, и каждая новая нажатие клавиши просто заставляет его отказаться от этой попытки и начать заново с последнего запроса. Это не делает дорогостоящий компонент быстрее; цель заключается исключительно в том, чтобы менее важная обработка не мешала взаимодействию, на котором сосредоточен пользователь. Выбор между этими двумя инструментами обычно прост: используйте startTransition, когда вы контролируете обновление состояния, а useDeferredValue — когда вы получаете значение только, например, в качестве свойства.

Ограничение: планировщик не может прервать ваш код

Всё вышесказанное зависит от того, что React регулярно получает обратно поток выполнения. Теперь рассмотрим функцию, выполняющую огромные синхронные вычисления:

function calculateEverything() {
  for (let i = 0; i < 10_000_000_000; i++) {
    // huge synchronous calculation
  }
}

Во время её выполнения JavaScript-поток:

JS THREAD

полностью занят:

████████████████████████████Scheduler waiting...React waiting...Input waiting...

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

Почему отрисовка и сохранение изменений остаются разными процессами

Эта же модель объясняет, почему React настаивает на разделении обновления на две фазы:

Render

и:

Commit

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

Render

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

Fiber A ✓
Fiber B ✓
Fiber C ...
      ↓
Interrupted
      ↓
Continue / Restart
      ↓
Entire Tree Ready
      ↓
COMMIT
      ↓
Visible UI

Именно поэтому прерванная обработка никогда не приводит к появлению на экране элементов с смешанными состояниями:

Half old UI
+
Half new UI

Фиксация изменений всегда применяет единообразный, полностью завершённый результат.

Почему функция отрисовки должна быть чистой

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

function Product() {
  analytics.track("Product Rendered");
return <View />;
}

При одновременной отрисовке React может проходить через такую последовательность действий:

Starts Render
    ↓
Pauses
    ↓
Abandons Work
    ↓
Renders Again

Каждая из этих попыток запускает функцию компонента, поэтому этот вызов:

analytics.track(...)

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

Render
Pause
Retry
Abandon
Restart

при этом ни одна из этих попыток не влияет на внешний мир. Побочные эффекты должны находиться в функциях effects или обработчиках событий, которые выполняются только для уже сохраненных операций. Чтобы узнать практические рекомендации по выбору места размещения логики, ознакомьтесь с выбором подходящего инструмента React: derive, handle, fetch, defer или effect.

Аспект React Native

В приложении React Native путь от нажатия клавиши до отображения на экране проходит через те же слои, с нативным рендерером в конце:

User Types
    ↓
JS Event Handler
    ↓
setState()
    ↓
React Scheduler
    ↓
Fiber Work
    ↓
React Native Renderer
    ↓
Native UI Update

Если заблокировать поток JavaScript дорогостоящей синхронной работой, цепочка выполнения остановится на втором шаге:

User Types
    ↓
JS Thread Busy
████████████████
    ↓
React can't process JS work yet

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

Is React rendering too much?ORIs JavaScript itself blocked?ORIs expensive native work happening?ORIs the UI thread overloaded?

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

Полная цепочка обработки

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

USER INTERACTION
       ↓
JavaScript Runtime
       ↓
Event Handler
       ↓
setState()
       ↓
Update Created
       ↓
Lane / Priority
       ↓
React Scheduler
       ↓
Choose Work
       ↓
Fiber Render
       ↓
Can Yield / Restart
       ↓
Reconciliation
       ↓
Completed Work
       ↓
Commit
       ↓
UI Updated

И все это происходит в пределах среды выполнения JavaScript:

┌─────────────────────────────┐
│     JavaScript Runtime      │
│                             │
│ Event Loop gives JS a chance│
│            ↓                │
│     React coordinates       │
│        its own work         │
└─────────────────────────────┘

Цикл событий определяет порядок выполнения кода JavaScript; в рамках каждого такого цикла React решает, как его использовать.

Вопросы, на которые стоит уметь отвечать

Является ли планировщик React тем же, что и цикл событий?

Нет. Цикл событий принадлежит среде выполнения хоста. Планировщик React координирует работу React в течение времени, выделенного этой средой выполнения.

Работает ли React параллельно с компонентами на нескольких потоках?

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

Может ли React прерывать процесс отрисовки?

Да, в режиме одновременной отрисовки. React может отдать приоритет срочным обновлениям и отбросить устаревшие данные отрисовки до их сохранения.

Может ли планировщик спасти заблокированный поток?

Нет. Долгозанимающие синхронные операции JavaScript заставляют React ждать, как и всё остальное, пока поток не освободится.

Почему компоненты должны оставаться чистыми?

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

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

Большинство разработчиков начинают с представления, что изменение состояния приводит непосредственно к повторной отрисовке:

setState()
   ↓
React renders
   ↓
Screen updates

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

setState()
   ↓
Update
   ↓
Lane
   ↓
Scheduler
   ↓
Render
   ↓
Commit

Последний уровень — это сама среда выполнения, находящаяся под всеми действиями React:

JavaScript Runtime
       ↓
Event Loop
       ↓
React Scheduler
       ↓
Fiber
       ↓
Render
       ↓
Commit

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

Куда движется этот процесс дальше

В этой статье был прослежен путь от обновления состояния до цикла событий:

State Update
    ↓
Update Queue
    ↓
Fiber
    ↓
Lanes
    ↓
Scheduler
    ↓
JavaScript Event Loop

В React Native этот путь продолжается дальше — от коммитов в React к реальным интерфейсам платформы. Это довольно простая структура:

<View>
  <Text>Hello React Native</Text>
</View>

Он всё равно проходит через инструменты сборки кода, движок Hermes, механизм JSI и рендерер Fabric прежде, чем превратиться в нативный интерфейс Android или iOS. Каждый из этих слоев имеет собственные потоки и проблемы, связанные с планированием задач; их стоит рассмотреть отдельно после того, как будет понятна сторона JavaScript.

Основные выводы

  • Цикл событий определяет моменты выполнения JavaScript; планировщик React решает, какие задачи React необходимо выполнить в эти моменты.
  • Планирование в React носит кооперативный характер: он передаёт управление между единицами работы Fiber, чтобы можно было обрабатывать ввод и выполнять отрисовку.
  • Конкурентность в React означает переключение задач на одном потоке, а не их параллельную обработку.
  • startTransition отмечает обновления как незначимые; useDeferredValue позволяет компоненту отрисовываться на основе устаревших данных.
  • Ни один планировщик не может прервать собственный длинный синхронный код, поэтому тяжелые вычисления всё равно требуют оптимизации или переноса.
  • Коммиты применяются только к полным результатам, а процессы отрисовки должны быть чистыми, поскольку их можно повторить или отклонить.
  • Обновление с высоким приоритетом не может отрисоваться, пока пять секунд занимают синхронные вычисления; оно ждёт, пока этот код завершится.