Своя очередь обновлений и своя пачка
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья описала очередь обновлений словами и таблицей: число заменяет накопленное, обновитель преобразует. Пока очередь — картинка, ей приходится верить на слово. Соберём её целиком: меньше сорока строк, и «три set — плюс один», «три обновителя — плюс три» и «одна отрисовка на всё событие» перестанут быть правилами React, а станут следствиями вашего собственного кода.
React держит состояние компонента не в переменной, а в ячейке у себя: значение плюс список заявок, накопленных с прошлого разбора. Начнём с ячейки — вместе с разбором она умещается в пару десятков строк.
Главное решение принимается в сеттере, и оно неожиданно скучное: он не считает ничего. Берёт то, что дали, и кладёт в конец очереди. Вся арифметика откладывается до разбора — до flush, который проходит очередь слева направо.
Перед запуском ответьте: что напечатают две последние строки? Заявок в обеих очередях поровну, и подаются они одинаково — тремя одинаковыми вызовами.
snapshot + 1 посчиталось до вызова: в очередь трижды легло одно и то же число, и каждое следующее просто заменило предыдущее. «Три обновителя: 3» — потому что в очереди лежат функции, а не результаты, и каждая получает то, до чего дошёл разбор. Очередь одна и та же, разница только в том, что в неё кладут.Свёртку очереди вы уже писали руками — это была задача из прошлой статьи. Здесь у неё появился хозяин: ячейка, которая помнит и значение, и накопленные заявки.
Посмотрите на строку cell.queue.length = 0. Очередь опустошается ровно в момент разбора, и следующее событие копит заявки уже поверх нового значения: обновитель, поданный вторым нажатием, получит то, что накопило первое, а не то, с чего ячейка начинала. У ячейки, к которой никто не обращался, цикл не сделает ни шага, и значение останется прежним.
flush мы позвали руками. В React его руками не зовёт никто, и вопрос «когда именно» совсем не праздный: от ответа зависит и число отрисовок, и то, что успеет увидеть обработчик. Спросим у настоящего React — он ответит точно.
В обработчике две микрозадачи: одна поставлена до setCount, другая после. Куда среди них встанет строка из тела компонента? Прогноз — и запускайте.
Отрисовка встала между микрозадачами — ровно туда, куда встала бы микрозадача, поставленная в момент setCount. И обработчик успел доработать целиком: его собственная строка напечаталась раньше их всех. Никакой особой магии в объединении обновлений нет — есть отложенный на микрозадачу разбор.
Микрозадачи разбирались в статье «Микрозадачи и макрозадачи»: браузер выполняет их, когда текущий кусок кода дошёл до конца, — раньше любого setTimeout и раньше, чем нарисуется кадр.
Повторить это у себя — полтора десятка строк. Сеттер теперь не просто дописывает в очередь, а помечает ячейку и заказывает разбор; флажок следит, чтобы на всё событие пришёлся ровно один заказ.
Вторая строка вывода — самая говорящая: обработчик кончился, а в ячейке по-прежнему ноль. Ничего другого он увидеть и не мог — заявки лежат в очереди, разбор ещё не наступил. «Сеттер не присваивает, а планирует» из статьи про снимок теперь не утверждение, а видимое следствие трёх строк schedule.
Флажок scheduled стоит того, чтобы посмотреть на него отдельно. Без него каждый set заказывал бы свою микрозадачу: три вызова — три разбора и три отрисовки. С ним первый set события заказывает разбор, а остальные видят scheduled === true и молча дописывают в очередь.
«Сразу» — то есть разобрать очередь прямо внутри set — возвращает нас к ручному render() из части II: обработчик с тремя обновлениями даёт три отрисовки вместо одной — это первая задача в конце статьи, там видно, что получается. А если состояний два, между ними появляется отрисовка, где одно уже новое, а второе ещё старое, — сочетание, которого в вашей голове никогда не было.
setTimeout — другая крайность. Таймер — макрозадача: между концом обработчика и разбором браузер вправе нарисовать кадр, то есть показать пользователю страницу со старым состоянием, хотя новое уже посчитано. Микрозадача выполняется до отрисовки кадра, поэтому промежуточного состояния никто не увидит.
До сих пор отрисовки у нас не было — только onFlushed, пустышка, которую разбор зовёт последней строкой. Поставим туда компонент. Компонент в миниатюре — обычная функция без разметки: читает значение ячейки, печатает его и заново заводит обработчик.
И вот здесь целиком помещается статья про снимок. Строка const value = count.value выполняется в момент вызова функции, а вызывают эту функцию после разбора очереди. Обработчик, созданный внутри, замкнул это число — и другого не увидит, сколько бы событий ни прошло. Ровно так же, как замыкание из части I.
Пример нажимает две кнопки: сначала ту, где три set со значением, потом ту, где три обновителя. Вывод — три строки. Какие?
Ноль, единица, четвёрка. Три set(count, value + 1) положили в очередь три единицы, и разбор оставил последнюю; три обновителя сложились друг на друга поверх неё. Никто ничего не «замораживал» специально: у функции просто нет способа увидеть значение, появившееся после её вызова.
Второе нажатие отправлено через setTimeout нарочно: позови мы оба обработчика подряд, планировщик сложил бы их в одно событие — вышло бы одно нажатие с шестью заявками.
Заодно объяснилось и то, почему обработчик приходится заводить заново на каждой отрисовке. Он не «обновляется» — он новый, потому что новое у него замыкание. Старый никуда не делся, просто на кнопке теперь висит свежий; в React то же самое происходит с onClick в разметке.
Очередь у каждого состояния своя — это мы уже собрали. А планировщик один на всех, и отсюда берётся правило «одна отрисовка на событие»: сколько бы ячеек ни изменилось, разбор наступает один раз и onFlushed зовётся один раз.
Две ячейки, обработчик меняет обе, и вызывается он дважды: сразу и из таймера. Сколько строк «отрисовка» будет в выводе?
Пять строк на два нажатия — по одной отрисовке на каждое, хотя ячеек менялось по две. Шаг во втором нажатии уже двойка: обработчик, заведённый второй отрисовкой, замкнул новый снимок, поэтому счёт вырос сразу с единицы до тройки.
И главное: вторая пачка пришла из таймера, а собралась точно так же. Нашему планировщику всё равно, откуда позвали set, — он смотрит на границу микрозадачи, а не на обработчик. Именно этим React 18 отличается от семнадцатого: там объединение делала обёртка вокруг собственных обработчиков React, и за их пределами — в таймере, в .then, в подписке — каждое обновление давало свою отрисовку. Перенос заказа в микрозадачу сделал объединение автоматическим везде.
dirty — множество, поэтому две заявки в одну ячейку не заставят разобрать её дважды. React ведёт список «корней, у которых есть работа», примерно так же.
Теперь сравнение честное: тот же сценарий, что в третьем разделе, но на настоящем React.
Три строки — те же самые. Миниатюра не «похожа» на React и уж точно не устроена как он внутри; она повторяет его наблюдаемое поведение в той части, ради которой писалась. Дальше расхождения начинаются быстро.
| Что делает React | Есть ли это у нас | Где про это |
|---|---|---|
| Очередь у каждого состояния своя; число заменяет накопленное, обновитель преобразует | да | первый раздел |
| Заказ разбора в микрозадаче, один заказ на событие | да | второй раздел |
| Тело компонента получает значение, накопленное очередью | да | третий раздел |
| Пропуск отрисовки, если заявка не меняет значение | нет: мы рисуем после любого разбора | вторая задача и врезка ниже |
| Ячейки различаются не именем, а порядком вызова хуков | нет: у нас это обычные объекты с именами | «Правила хуков и почему они такие» |
| Срочные и несрочные обновления, прерываемый рендер | нет | глава про скорость |
| Коммит: сравнение описаний и правка DOM | нет: мы печатаем в консоль | дальше в этой главе |
Самая заметная строка — четвёртая. Наш планировщик перерисовывает после любого разбора. React бережливее и проверяет раньше: заявку, пришедшую в пустую очередь, он вычисляет прямо в сеттере и, если Object.is говорит «то же самое», отрисовку не заказывает вовсе — обработчик с setCount(count) в нетронутом компоненте не даёт новой строки в консоли. Как это выглядит изнутри и где у этой бережливости граница — во врезке ниже. Что именно сравнивает Object.is и почему это правило больно бьёт по объектам, разбирает следующая статья главы; починить миниатюру предлагает вторая задача.
Нет, и это стоит сказать прямо. У нас flush проходит по всем грязным ячейкам, и только потом зовётся компонент. React делает иначе: очередь конкретного состояния разбирается внутри вызова useState — то есть уже во время отрисовки, в тот момент, когда до этого хука дошло выполнение тела.
Увидеть это можно, если нарушить правило из прошлой статьи и дать обновителям печатать. В миниатюре оба напечатались бы в flush, раньше строки «тело началось». Где они окажутся у React?
Обновители разошлись по разным местам. Второй выполнился после «тело началось» — внутри useState, как и было сказано. А первый React вызвал прямо в сеттере, ещё до конца обработчика: очередь была пуста, и он заранее посчитал результат, чтобы сравнить его с текущим значением через Object.is и при совпадении не заказывать отрисовку вовсе. Это и есть пропуск из таблицы выше, и достаётся он только первой заявке в пустой очереди.
Отсюда граница бережливости. Если заявок несколько и в сумме они вернули прежнее — n => n + 1 и n => n - 1 в одном обработчике, — React компонент всё-таки вызовет, увидит то же значение и откажется только от остального: дочерние компоненты вызваны не будут. Так же ведёт себя и первый setCount(count) сразу после настоящего изменения: одна лишняя строка «отрисовка», и больше ничего. На значения всё это не влияет — к строке «отрисовка» очередь разобрана и у нас, и у React; разницу выдаёт только обновитель с побочным действием, ещё одна причина держать его чистым.
- Ячейка состояния — это значение плюс очередь заявок; сеттер ничего не вычисляет, он дописывает заявку в конец.
- Разбор идёт слева направо: число заменяет накопленное, обновитель получает накопленное и преобразует — отсюда «три set — плюс один» и «три обновителя — плюс три».
- Разбор заказывается в микрозадачу, а флажок оставляет один заказ на событие: обработчик успевает доработать целиком, отрисовка случается один раз.
- Значение, которое получает тело компонента, уже прошло через очередь — поэтому его локальные переменные и есть снимок, а обработчик этой отрисовки замкнул именно их.
- Очередь у каждого состояния своя, планировщик один: границу пачки задаёт микрозадача, а не обработчик, — потому объединение и работает в таймерах и промисах.
- Чего в миниатюре нет: проверки в сеттере, которая пропускает отрисовку, если заявка не меняет значение, ячеек по порядку вызова хуков, приоритетов и коммита в DOM.
1/4Почему в нашей миниатюре три подряд set(count, value + 1) дают плюс один?
2/4Зачем планировщику флажок scheduled?
3/4В обработчике стоят подряд: микрозадача, setCount, ещё микрозадача. Где среди них напечатается строка из тела компонента?
4/4Чем настоящий React отличается от нашей миниатюры, когда обработчик вызывает setCount(count)?
Разбор без отсрочки
Уберём микрозадачу: пусть set разбирает очередь и перерисовывает сразу — так вы и писали в части II, вручную зовя render() после каждого изменения. Предскажите вывод: сколько будет строк и какие в них числа. И только потом запускайте.
Четыре строки: 0, 1, 1, 1. Первая — начальная отрисовка, дальше по отрисовке на каждый set: три полных прохода вместо одного. Работа лишняя, а число всё равно единица — обработчик, который сейчас выполняется, родился в отрисовке со снимком 0 и все три раза кладёт в очередь 0 + 1. Немедленный разбор снимок не лечит: снимок берётся при вызове компонента, а этот вызов уже в прошлом.
Починка — вернуть заказ в микрозадачу и оставить один заказ на событие. В подложке этой задачи планировщика нет, поэтому он переезжает прямо в set:
Вывод становится двумя строками: 0 и 1.
Заодно видно, почему React не мог оставить поведение части II: там render() звали вы и сами следили, чтобы не позвать лишний раз. Здесь звать некому — значит, решение о числе отрисовок принимает планировщик.
Лишняя отрисовка
Обработчик кладёт в очередь ровно то число, которое в ячейке уже лежит, — и всё равно появляется вторая строка вывода. Сделайте так, чтобы её не было: flush должен вернуть false, если значение не изменилось. Планировщик в подложке уже спрашивает об этом, врёт ему сам flush.
Сравнивать нужно до присваивания — после него старого значения уже не достать:
Теперь строка одна. Object.is, а не ===, — по той же причине, по которой его выбрал React: у === два известных отклонения, NaN !== NaN и 0 === -0, и оба дали бы в состоянии неверный ответ.
Проверка у нас стоит после разбора, а у React — в сеттере, и только для первой заявки в пустой очереди. Поэтому наша отсеивает больше: обработчик с n => n + 1 и n => n - 1 тоже не даст второй строки, а React в таком случае компонент вызовет — это разбирает врезка в конце последнего раздела.
Именно эта проверка объясняет самую частую загадку следующей статьи: массив, изменённый через push, — та же самая ссылка, Object.is говорит «то же самое», и отрисовки не будет.
Предскажите три строки
Миниатюра целиком в подложке, начальное значение — десять, в обработчике смешанная очередь. Нажатий два, второе из таймера. Выпишите три строки вывода и проверьте себя запуском.
10, 16, 22.
Первое нажатие: снимок value равен десяти, в очередь ложатся n => n * 2, число 11 и n => n + 5. Разбор идёт от десятки: 20, потом число стирает накопленное и остаётся 11, потом 16.
Второе нажатие делает уже другой обработчик — тот, что завела отрисовка с шестнадцатью. Поэтому средняя заявка теперь 17, а не 11: 32, затем 17, затем 22.
Ловушка ровно одна и та же в обоих проходах: обновители берут накопленное очередью, а value + 1 — снимок. Смешивать их в одном обработчике можно, но читать такую очередь придётся по строчкам, как вы сейчас и сделали.