Свой Suspense: приостановка — это брошенный промис
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Статья «use() и Suspense» показала две странности и назвала их причины словами: тело компонента выполнилось трижды, а промис, созданный прямо в теле, дал вечную «Загрузка…». Обе держатся на одном механизме, и он умещается в тридцать строк. Соберём читалку, которая при неготовом значении бросает промис, и границу, которая его ловит, показывает заглушку и по готовности рисует поддерево заново.
Компонент обязан вернуть разметку. Данных нет — возвращать нечего, и null тут не годится: он означал бы «пусто», а у нас «ещё не знаю». Функция, которая не может доделать работу, поступает в JavaScript известным образом — бросает. Приём из части I, только бросают не ошибку: наверх летит промис, и в нём написано, когда пробовать снова.
Значит, кто-то должен отличать готовое от неготового. Это кеш: словарь, где у каждого ключа одно из двух состояний — «ждём» с промисом внутри и «готово» со значением. Прогноз перед запуском: сколько раз напечатается «запрос за user»?
Один раз. Запись в кеше завелась при первом же чтении, и второе — через 500 миллисекунд — никуда не пошло: read нашёл готовое значение и просто его вернул. А первое чтение не вернуло ничего: вместо строки из функции вылетел промис, и catch его поймал.
У данных три состояния — «не начинали», «ждём», «готово», — а выходов из читалки ровно два: значение или промис. Третьего смысла у неё нет, и в этом вся хитрость: тот, кто её зовёт, пишется так, будто ожидания не бывает.
Бросают именно промис, а не ошибку, и это не мелочь: по типу брошенного и различают «я ещё не готов» и «всё сломалось». Первое ловит Suspense, второе — граница ошибок из следующей статьи.
Ловить брошенное сам компонент не должен — иначе теряется весь смысл: он снова станет местом, где написано слово «загрузка». Ловит тот, кто стоит выше и знает, что показать вместо поддерева. Это и есть граница: try вокруг вызова, заглушка в catch и подписка на пойманный промис.
Читалка едет с нами из прошлого примера без единой правки — в тексте от неё осталась одна строка declare. Прогноз: сколько раз напечатается «тело Profile»?
Дважды. Первая попытка не дала ничего: read бросил промис на середине выражения, и всё, что Profile успел насчитать, пропало вместе с вызовом. Граница показала заглушку и подписалась на промис. Когда тот разрешился, она позвала Profile заново — с самого начала, потому что продолжить прерванный вызов нечем. На этот раз read нашёл значение в кеше, и строка досчиталась до конца.
Строка «не промис — бросаем дальше» — это дверь. Всё, что летит наверх и промисом не является, граница пропускает мимо себя: чужие ошибки ловить не её работа.
Здесь же лежит ответ на вопрос, откуда взялось требование держать тело компонента чистым.
Прерванный вызов не возобновляют: обычную функцию в JavaScript нельзя поставить на паузу и продолжить с того же места. Значит, попытку выбрасывают и делают заново, а всё, что тело успело натворить по дороге, остаётся сделанным.
Две метрики на один показанный профиль. И это ещё скромно: настоящий React зовёт тело чаще нашей границы — сколько именно, разбираем в следующем разделе. Отсюда правило: в теле только вычисления, всё остальное — в эффект или в обработчик.
В прошлой статье настоящий React выполнил тело трижды, наша граница — дважды. Разница в одном: у нашей границы был единственный ребёнок, и его приостановка означала конец работы. У React детей несколько, и, наткнувшись на ожидание, он делает ещё один проход — чтобы дойти до соседей приостановленного компонента и начать их ожидание тоже, а не после того, как дождётся первого.
Добавим второго ребёнка и второй проход. Прогноз: сколько раз напечатается «тело Profile», сколько «тело News» — и в каком проходе уйдёт запрос за новостями?
Прошлая статья сказала это словами: число проходов вам не принадлежит. Теперь видно, из чего оно складывается, — из того, сколько у границы детей и кто из них когда ответил, а не из вашего кода. Полагаться на него нельзя, а понимать, откуда оно берётся, полезно: в выводе вы будете видеть его постоянно.
Заглушка печатается сразу после первого прохода, ещё до второго: соседей граница прогревает, когда fallback уже на экране. Так с 19-й версии делает и React — сперва коммитит заглушку, потом дорисовывает соседей. А «граница показывает всех разом или никого» — не отдельное правило, а следствие того, что результат прохода собирается в одну строку.
У нашей читалки есть ключ — строка "user". У use() ключа нет: ему дают промис, и больше ничего. Значит, ключом кеша служит сам объект промиса, а метку о готовности приходится держать прямо на нём.
Соберём такую читалку — она короче прежней, словарь ей не нужен. Границу возьмём попроще, ту самую с одним ребёнком: считать попытки на ней нагляднее. И повторим фокус из прошлой статьи — пусть промис рождается в теле компонента. Прогноз: сколько запросов уйдёт за одним и тем же именем?
Три — и ушло бы сколько угодно, не оборви мы цикл сами на четвёртой попытке. Каждый вызов load даёт новый объект, а на новом объекте метки нет: use видит чистый промис, помечает его и бросает. Пришёл ответ — граница зовёт тело заново, тело создаёт ещё один промис, и всё повторяется. Метки честно проставились на трёх объектах, до которых уже никому нет дела.
Лечится тем, что промис перестаёт рождаться в теле.
Две попытки вместо бесконечности, один запрос — и на промисе действительно появились дописанные поля. В прошлой статье вы уже видели ту же пару у настоящего React: Object.keys на промисе, отданном в use(), показывал status и value — и рядом с ними своё служебное поле отладочной сборки. Теперь понятно, чья это работа и зачем она нужна.
Отсюда же и требование «промис должен пережить отрисовку»: пережить — значит остаться тем же объектом. Модульный уровень, кеш по ключу, пропс сверху — три способа это обеспечить, и все три про одно и то же.
Проверим, что мы собрали не похожую вещь, а ту же самую. Читалка из первого раздела про React ничего не знает — она просто бросает промис. Отдадим её настоящему компоненту и накроем настоящим <Suspense>. Никакого use() в примере нет.
Прогноз: React отругается на брошенный промис, покажет заглушку или погасит дерево?
Три «тело Profile», заглушка на сотой миллисекунде, данные к 1200-й — то же, что прошлая статья видела у use(), только теперь в выводе есть и строка запроса. Настоящая граница поймала наш промис так же, как ловила свой: приостановка — это не устройство внутри use, а соглашение между тем, кто читает данные, и тем, кто их ждёт.
В своём коде так писать не надо: для чтения промиса есть use(). React понимает брошенный промис потому, что этим протоколом с 2018 года пользуются библиотеки данных, — а use() поверх него просто узаконили и типизировали.
Тот же опыт с двумя детьми. Чтобы разглядеть, что происходит между ответами, второй ребёнок читает меню — оно отвечает через 900 миллисекунд, втрое дольше профиля, — а в теле печатает, что о меню знает кеш. Прогноз: сколько раз напечатается «тело Profile»?
Пять — а наша граница с теми же детьми насчитала бы три. Первые пять строк повторяют миниатюру, только без наших «на экране» — их работу делает DOM. Это попытка, оборванная на профиле, и второй проход ради соседа. Дальше расходимся. На трёхсотой миллисекунде пришёл профиль, и React не стал ждать меню: попробовал сразу — профиль досчитался, меню бросило свой промис ещё раз («ждём»), и к этой попытке, как в самом начале, добавился второй проход. До экрана дошла только пара после ответа меню. Наша граница ждёт всех через Promise.all, а React пробует снова после каждого ответа.
Одинаковые сроки не спасают. У user и news ответ назначен на одну и ту же 300-ю миллисекунду, но запросы ушли в разных проходах, и ответы приходят двумя отдельными задачами таймера — React обычно успевает вклиниться между ними. Замените в Menu ключ "menu" на "news", в обеих строках, — пар после ответа снова будет три. Сколько раз позовут тело, решает время ответов, а не ваш код.
| Наша миниатюра | Настоящий React | |
|---|---|---|
| Что летит наверх | сам промис | use() бросает свой сигнал, но и просто брошенный промис принимает |
| Кто ловит | try вокруг вызова детей | ближайшая вверх по дереву граница <Suspense> |
| Где помнится готовность | запись в кеше или метка на промисе | поля status и value на самом промисе |
| Когда повторная попытка | когда ответили все ожидания | после каждого ответа отдельно |
| Что вместо детей | строка в console.log | поддерево fallback в DOM |
| Что с остальной страницей | рисуем всё заново | за пределами границы ничего не трогается, состояние живо |
| Если вместо ожидания ошибка | пропускаем дальше вверх | то же самое — и это следующая статья |
Главная разница — не про промисы: React отменяет не вычисление строки, а работу над куском дерева, — потому приостановка ничего и не портит на экране. Всё остальное вы только что написали руками.
- Приостановка — это
throw: компонент, которому нечего вернуть, бросает наверх промис — не ошибку и не заглушку. - Ловит его ближайшая граница: показывает
fallback, подписывается на промис и по готовности зовёт поддерево заново, с самого начала. - Поэтому тело и выполняется по нескольку раз: первая попытка выбрасывается целиком, второй проход нужен ради соседей, третий приходит с ответом.
- Готовность помнит не React, а кеш: у читалки с ключом — запись в словаре, у
use()— метка на самом объекте промиса. - Промис, созданный в теле, — новый объект на каждой попытке, то есть новый ключ кеша: метка ставится заново, и загрузка не кончается никогда.
- Настоящий
<Suspense>ловит точно такой же брошенный промис: протокол один и тот же, аuse()— узаконенная дверь к нему.
1/4Компонент читает данные, которых ещё нет. Что уходит вверх по дереву?
2/4Почему тело приостановленного компонента выполняется трижды, а не один раз?
3/4В теле компонента написано use(load(id)), промис создаётся тут же. Почему загрузка не кончается?
4/4Граница поймала промис. Что она делает дальше?
Граница, которая не пробует снова
Читалка из статьи на месте, ответ приходит через 300 миллисекунд — а на экране навсегда остаётся «Загрузка…». Последняя строка примера доказывает, что значение в кеше есть. Почините границу.
Граница поймала промис и показала заглушку, но ничего не сделала с самим промисом — а он и есть единственный сигнал «пробуй снова». Не хватает подписки.
Теперь вывод такой:
Обратите внимание, что boundary зовётся рекурсивно: повторная попытка — это не «продолжение», а такой же полный проход, просто теперь чтение находит значение в кеше и ничего не бросает.
Второй запрос уходит слишком поздно
Карточка читает подряд две вещи: имя (ответ через 300 мс) и меню (через 900 мс). Запустите и найдите в выводе строку «запрос за menu» — она появляется позже, чем могла бы. Сделайте так, чтобы оба запроса уходили сразу, не трогая тело Card.
Первое чтение бросает промис и обрывает вызов — до второго read дело не доходит, и запрос за меню начинается только после того, как пришло имя. Ожидания складываются: 300 миллисекунд, потом ещё 900. Это та самая лесенка запросов, что и в главе про эффекты, только устроенная приостановкой.
Лечится тем, что запись в кеше заводится заранее — до отрисовки. Промис при этом никому не нужен, важно, что запрос пошёл:
Теперь «запрос за user» и «запрос за menu» стоят в выводе двумя первыми строками, и общее ожидание — 900 миллисекунд вместо 1200. Тело Card осталось прежним: где начинать запросы — решение вызывающей стороны, а не читателя данных. Ровно этим и занимается второй проход границы для соседей, только здесь оба чтения живут в одном компоненте, и помочь ему некому.
Быстрый блок не должен ждать медленный
Профиль отвечает через 300 миллисекунд, меню — через 900, и до девятисотой на экране одна общая заглушка. Сделайте так, чтобы профиль появлялся сразу, как будет готов, а своя заглушка осталась только у меню. Компоненты трогать нельзя.
Граница у нас — обычная функция, поэтому «поставить вторую границу» значит просто позвать её ещё раз, со своим списком детей и своей заглушкой:
Вывод:
Профиль показался на трёхсотой миллисекунде, меню — на девятисотой. В настоящем React это те же две границы <Suspense> в разметке вместо одной: расстановка границ решает, какой кусок экрана ждёт вместе, а не логика внутри компонентов.