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

Розуміння каналів React: як маски бітів кодують пріоритет оновлень

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

9 хв читання2125 слів

Зрештою, не булі

У попередньому розділі ви бачили, як lanes та childLanes функціонують ніби прості флаги. Одне поле на волокні Counter змінювалося з порожнього на зайняте, те саме відбувалося на кожному батьківському волокні над ним, і обробка дерева ґрунтувалася на цих змінах, щоб визначити, які гілки варто переходити. Для цього пояснення не мало значення нічого іншого: поле було або порожнім, або ні.

Але цей опис не враховує те, що насправді містять ці поля.

{
  type: Counter,
  memoizedState: { count: 1 },
  lanes: 16,
}

lanes зберігає число, а не true чи false — справжнє ціле число, яке зберігається на волокні. Якби React потрібно було лише знати „чи щось у процесі виконання, так чи ні“, звичайний булі цілком б вистачив. Натомість React обрав щось інше.

То чому саме число є природним вибором для позначення „у цьому волокні є незавершена робота“?

Що таке маска бітів

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

SyncLane            = 1   = 00000001
InputContinuousLane = 4   = 00000100
DefaultLane         = 16  = 00010000
TransitionLane      = 64  = 01000000

Написані у двійковому форматі, кожна з цих констант містить рівно один 1, причому цей 1 знаходиться у окремій позиції для кожної константи. SyncLane займає першу позицію; DefaultLane — п’яту. Жодна з них ніколи не потрапляє у ту саму позицію. Така структура називається маскою бітів — це число, чия числова величина не має значення, але яке функціонує як рядок незалежних перемикачів „ввімкнено/вимкнено“, по одному на кожен біт, усі вони зберігаються разом у одному полі.

Коли властивість Counter «fiber» показує lanes: 16, це не означає, що «ця оновлення варта шістнадцять одиниць чогось». Це означає, що біт, який належитьDefaultLane, увімкнений, а всі інші біти залишаються вимкненими». Це число слугує контейнером для зберігання флагів, а не кількістю, яку потрібно виміряти.

Що відбувається, коли одному біту потрібно одночасно відстежувати більше ніж один з цих флагів?

Об’єднання двох каналів за допомогою однієї операції

Уявіть собі біт, який у той самий момент обробляє два різні типи завдань — звичайне оновлення та таке, яке навмисно було віднесене до нижчої пріоритетності. React об’єднує ці два маски бітів за допомогою операції OR: ця операція порівнює числа біт за бітом та ставить біт у результаті на значення 1, якщо в будь-якому з вихідних чисел він вже дорівнює 1.

00010000   (16, DefaultLane)
| 01000000   (64, TransitionLane)
-----------
  01010000   (80)

Оскільки DefaultLane та TransitionLane кожен мають свою позицію біта, якої не торкається жоден інший лан, їх об’єднання за допомогою операції OR просто додає обидва значення 1 у отримане число, при цьому жодне з них не стирає інше. Результат — 80 — є новим значенням lanes, і якщо перетворити його назад у двійковий формат, можна побачити, що обидва початкові біти збережені всередині нього, точно такими, якими вони були.

Якщо подивитися на число 80 окремо — що воно насправді розкриває?

Чому саме число 80 нічого не говорить

Сам по собі 80 нічого не розкриває. Таке значення не читають так, як звичайне число — його аналізують по одному біту за раз, використовуючи операцію бітового І, яка залишає 1 у певному полі лише тоді, коли в обох порівнюваних числах вже є 1.

01010000   (80)
& 00010000   (16, DefaultLane)
-----------
  00010000   (non-zero → yes, DefaultLane's bit is set)
01010000   (80)
& 00000001   (1, SyncLane)
-----------
  00000000   (zero → no, SyncLane's bit is not set)

Ви ніколи не перевіряєте значення окремої «смуги». Натомість ви ставите конкретне запитання щодо однієї певної смуги, і відповідь надходить або як нуль, або як щось ненульове. Саме це відбувалося насправді у логіці обробки дерева, про яку йшлося раніше: щоразу, коли вона перевіряла childLanes фібри, щоб вирішити, чи варто продовжувати розглядання певної гілки, вона виконувала цю саму перевірку І щодо тієї смуги, яку на той момент аналізувала.

Отже, повернімося до основного запитання: чому взагалі представляти це у вигляді числа, а не просто булевого флага?

Чому не використати просто булеве значення?

Булеве значення може відповісти лише на один тип запитання: чи є щось незавершене, так або ні. У нього немає можливості виразити ситуацію на кшталт „очікує звичайне оновлення, а окремо — ще одне з меншою пріоритетністю“, адже булеве значення містить лише один біт інформації. React насправді потребує водночас відстежувати кілька різних категорій незавершеної роботи на певному фібрі, і йому потрібна можливість перевіряти будь-яку з них, не впливаючи на стан інших.

Маска бітів дозволяє одночасно враховувати обидві вимоги. Одне числове поле може містити кілька флагів паралельно, оскільки кожен флаг займає власну позицію біта. А перевірка одного флага чи поєднання кількох флагів — це проста операція процесора типу AND або OR, а не щось, що вимагає обробки структури даних чи зберігання окремої булевої змінної для кожного можливого рівня пріоритету. Саме тому lanes зберігається як ціле число, а не як true чи false: воно не фіксує наявність роботи, а точно вказує, які саме види роботи існують, усі одночасно, майже без жодних витрат.

Що досі не пояснено, — це чому оновлення взагалі мають вважатися менш пріоритетними.

Чому оновлення взагалі потребують пріоритету

Уявіть собі поле пошуку, створене як керований компонент, де кожна натиснута клавіша знову фільтрує список із десяти тисяч елементів, причому сам фільтраційний процес відбувається безпосередньо всередині тіла функції компонента.

function SearchableList({ items }) {
  const [query, setQuery] = useState("");
  function handleChange(e) {
    setQuery(e.target.value);
  }
  const filteredItems = items.filter(item => item.includes(query));
  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>
    </div>
  );
}

Введіть один символ. Функція handleChange запускається, викликає setQuery, що призводить до повторної відрендеризації SearchableList. Коли React нарешті обробляє цю відрендеризацію, він викликає функцію SearchableList як один неперервний, синхронний виклик від початку до кінця. У момент цього виклику функція .filter() змушена пройтися по всіх десяти тисячах елементів, перш ніж зможе повернути свій новий JSX-код.

Виклик функції під час її виконання не може бути призупинений посеред її власного коду. Механізм переривання, який забезпечується фібрами, працює на межі між фібрами, а не під час лінійного виконання функції, яке все ще триває. Тож у React немає можливості оновити значення введеного даних, зберегти якісь зміни чи вивести пікселі на екран, доки цей виклик функції, включаючи процес фільтрації, не завершиться. Наступний натиск клавіші потрапляє у чергу після нього, не тому, що два окремі оновлення конкурують між собою, а тому, що обидві операції входять до складу результату цього єдиного виклику.

Рішенням є розділення цього процесу на два справді незалежні оновлення, кожне з яких може мати власний рівень пріоритету.

function SearchableList({ items }) {
  const [query, setQuery] = useState("");
  const [deferredQuery, setDeferredQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value);
    startTransition(() => {
      setDeferredQuery(value);
    });
  }

  const filteredItems = items.filter(item => item.includes(deferredQuery));

  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>
    </div>
  );
}

setQuery та setDeferredQuery тепер існують як два окремі виклики, кожен з яких запускає власне оновлення та має свій власний ідентифікатор. Оновлення, яке відбувається через setQuery, є дешевим, оскільки не передбачає фільтрації, тому воно швидко завершується, і поле введення залишається чутливим до набору тексту. Оновлення, яке відбувається через setDeferredQuery, — це те, що насправді виконує функцію .filter(), і воно містить елемент TransitionLane, оскільки знаходиться всередині функції startTransition, яка дозволяє використовувати цю модель відкладених оновлень. Якщо під час виконання цього повільного процесу фільтрації ви введете ще один символ, React перевіряє список lanes, знаходить оновлення вищої пріоритетності, яке чекає на виконання, та відкладає незавершену роботу нижчої пріоритетності, щоб спочатку обробити натискання клавіші. Це можливо лише тому, що тепер ці два оновлення відстежуються незалежно одне від одного.

, кожен з них має власний позначник пріоритету, замість того щоб бути об’єднаними в один виклик функції, який би одночасно обробляв обидва завдання.

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

Пріоритет переважно автоматичний

У більшості випадків — ні. React зазвичай обирає шлях обробки на основі того, що спричинило оновлення, а не тому, що хтось навмисно обрав певний варіант. Це рішення приймається в системі подій React, яка знає, який тип браузерної події обробляється саме у момент виконання setState.

Приблизний порядок від найнагальнішого до менш нагального:

Discrete events    (clicks, key presses, form submissions)
Continuous events  (scroll, drag, pointer-move)
Default            (timeouts, resolved promises, no special context)
Transition         (explicitly deprioritized via startTransition)
Idle               (only when nothing else is pending)

Виклик setState зсередини обробника кліку автоматично піднімає його майже на вершину цього рейтингу. Виклик з обробника прокрутки ставить його на рівень нижче, оскільки вважання кожної зміни, спричиненої прокруткою, максимально високим пріоритетом — потенційно десятків разів на секунду — саме по собі може спричинити затримки. Оновлення, викликане виконанням обіцянки чи таймаутом, без наявності контексту для аналізу, за замовчуванням потрапляє до DefaultLane. Єдиний механізм ручного керування, до якого справді має доступ розробник, — це startTransition, і його сфера застосування обмежена: або оновлення відбувається звичайним чином, і React визначає його пріоритет на основі контексту, як і все інше, або воно обгортається, що змушує його потрапити до TransitionLane, незалежно від того, що насправді його спричинило. useDeferredValue по суті є легким обгортником, створеним на основі того самого механізму. Ніхто не встановлює fiber.lanes = 64 безпосередньо, і немає жодної причини це робити.

Є одна застереження, яку варто згадати перед завершенням: фактичний кодовий базис React визначає близько тридцяти окремих констант типу lane, а не лише чотири. Багато з них існують виключно для внутрішнього обліку, а не для позначення справді нового рівня пріоритету, корисного для окремого відстеження кількох переходів, замість того щоб об’єднувати їх у один спільний, недиференційований контейнер. П’ять рівнів, описаних вище, — це ті, які варто запам’ятати як уявний модель. Усе інше є деталями реалізації, які знаходяться під структурою, яку ви вже розумієте.

Чотири елементи, один механізм

Якщо узагальнити все, ось що насправді відбувається під час повторної обробки, від початку до кінця. Стан зберігається у фібрі, тому що саме фібра є об’єктом, який існує довше, ніж один процес обробки, на відміну від функції компонента, яка забуває все між викликами, або елемента, який щоразу видаляється та створюється заново. Фібра існує у двох пов’язаних версіях — поточній та у процесі створення, які з’єднані за допомогою вказівника alternate, адже для будь-якого порівняння необхідні справжні знімки «до» та «після», розташовані поруч один з одним. Виклик функції setState ніколи не впливає безпосередньо на жодну з фібр. Натомість вона залишає запис у черзі та відкладає фактичну роботу, тоді як позначена група шляхів дає можливість пізнішому обходу дерева зрозуміти, які гілки справді варто переходити. І цей маркер ніколи не був простим

По-перше, це флаг – насправді маска бітів, яка містить достатньо кодованої інформації, щоб один натиск клавіші дозволив обійти всю роботу, яка ще залишається у черзі для обробки.

Чотири різні ідеї, але вони об’єднуються в один механізм. Кожна з них стає повністю зрозумілою лише після того, як ви вже зрозуміли попередню. Якщо розглядати їх окремо, кожен з цих елементів перетворюється на факт, який можна запам’ятати, без розуміння справжніх підстав для нього.