Понимание каналов React: как маски битов кодируют приоритет обновлений
Узнайте, как React кодирует несколько приоритетов обновлений в одно целое число с помощью масок битов, и почему для планирования используются операции над битами вместо простых логических флагов.
В конце концов, это не логические значения
В предыдущей части вы видели, как поля 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 указано lanes: 16, это не означает, что «это обновление стоит шестнадцать единиц чего-либо». Это означает, что бит, относящийся кDefaultLane, включен, а все остальные биты остаются выключенными. Это число служит контейнером для хранения флагов, а не величиной, подлежащей измерению.
Что происходит, когда одному биту необходимо одновременно отслеживать более одного из этих флагов?
Сочетание двух каналов с помощью одной операции
Представьте бит, который в один и тот же момент обрабатывает два разных вида задач — обычное обновление и обновление, намеренно отнесенное к более низкому приоритету. React объединяет два маски битов с помощью операции побитового ИЛИ: при этой операции числа сравниваются бит за битом, и в результате бит становится 1, если он установлен в любом из исходных чисел.
00010000 (16, DefaultLane)
| 01000000 (64, TransitionLane)
-----------
01010000 (80)
Поскольку DefaultLane и TransitionLane каждый имеют свою позицию бита, к которой не примыкают другие лейны, их объединение с помощью операции И просто добавляет оба значения 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 на самом деле должен одновременно отслеживать несколько разных категорий незавершённых задач на определённом потоке и иметь возможность проверять любую из них без влияния на состояние остальных.
Маска битов позволяет одновременно удовлетворять обеим требованиям. Одно числовое поле может хранить несколько флагов параллельно, поскольку каждый флаг занимает свой собственный бит. Проверка одного флага или комбинирование нескольких флагов представляет собой простую операцию процессора — ИЛИ или И, а не действие, требующее просмотра структуры данных или хранения отдельной логической переменной для каждого возможного уровня приоритета. Именно по этой причине 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 проверяет статус маркеров обновлений, находит обновление с более высоким приоритетом и откладывает незавершённую работу с низким приоритетом, чтобы сначала обработать новый ввод. Это возможно только благодаря тому, что теперь эти два типа обновлений отслеживаются независимо друг от друга
В этот момент возникает разумный вопрос: ожидается ли от разработчиков вручную присваивать эти значения приоритета по мере обновлений?
Приоритет в основном автоматический
В большинстве случаев — нет. 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 никогда не влияет напрямую ни на одну из фибр. Вместо этого он оставляет запись в очереди и откладывает фактическую обработку, в то время как специально отмеченные каналы позволяют при последующем просмотре дерева определить, какие ветви действительно стоит исследовать. И этот маркер никогда не был простым...
По сути, это маска битов, содержащая достаточно закодированной информации, чтобы один нажатие клавиши позволило пропустить всю работу по рендерингу, которая ещё остаётся в очереди.
Четыре разных идеи, но все они сводятся к одному механизму. Каждая из них становится полностью понятной только после того, как вы уже разобрались в предыдущей. Рассматриваемые по отдельности, любые из этих элементов превращаются в факт, который можно запомнить, без понимания логики, которая его обосновывает.