Головна / Статті

Планувальник React та цикл подій: хто насправді вирішує, коли буде виконана робота.

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

Видавець: REACTAPP.TOP13 хв читання3017 слів

Джерела: How Does React’s Scheduler Work With the JavaScript Event Loop?

React вирішує не лише те, що має змінитися на екрані, а й коли це відбудеться, використовуючи пріоритети, канали та можливість переривання процесу відображення. Проте React — це просто JavaScript, а у 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

Кожен шар працює всередині того, що знаходиться над ним.

Коли введення тексту конкурує з обробкою великого об’єму даних

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

щоб жодна з цих спроб не впливала на зовнішній світ. Побічні ефекти мають знаходитися у функціях ефектів або обробниках подій, які виконуються лише для збережених даних. Щоб дізнатися, де саме слід розміщувати логіку, перегляньте вибір правильного інструменту 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 дозволяє компоненту відображати інформацію на основі затриманої значення.
  • Жоден планувальник не може перервати ваш власний довгий синхронний код, тому складні обчислення все одно потребують оптимізації або переміщення.
  • Комітти застосовуються лише до повних результатів, а відображення має бути чистим, оскільки його можна спробувати знову або відхилити.
  • Оновлення високої пріоритетності не може відображатися, поки п’ятисекундне синхронне обчислення займає потік; воно чекає, поки цей код завершиться.