Свой Suspense: приостановка — это брошенный промис

свои хуки≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Статья «use() и Suspense» показала две странности и назвала их причины словами: тело компонента выполнилось трижды, а промис, созданный прямо в теле, дал вечную «Загрузка…». Обе держатся на одном механизме, и он умещается в тридцать строк. Соберём читалку, которая при неготовом значении бросает промис, и границу, которая его ловит, показывает заглушку и по готовности рисует поддерево заново.

Разделы темы:·

Компонент обязан вернуть разметку. Данных нет — возвращать нечего, и null тут не годится: он означал бы «пусто», а у нас «ещё не знаю». Функция, которая не может доделать работу, поступает в JavaScript известным образом — бросает. Приём из части I, только бросают не ошибку: наверх летит промис, и в нём написано, когда пробовать снова.

Значит, кто-то должен отличать готовое от неготового. Это кеш: словарь, где у каждого ключа одно из двух состояний — «ждём» с промисом внутри и «готово» со значением. Прогноз перед запуском: сколько раз напечатается «запрос за user»?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Один раз. Запись в кеше завелась при первом же чтении, и второе — через 500 миллисекунд — никуда не пошло: read нашёл готовое значение и просто его вернул. А первое чтение не вернуло ничего: вместо строки из функции вылетел промис, и catch его поймал.

У данных три состояния — «не начинали», «ждём», «готово», — а выходов из читалки ровно два: значение или промис. Третьего смысла у неё нет, и в этом вся хитрость: тот, кто её зовёт, пишется так, будто ожидания не бывает.

Бросают именно промис, а не ошибку, и это не мелочь: по типу брошенного и различают «я ещё не готов» и «всё сломалось». Первое ловит Suspense, второе — граница ошибок из следующей статьи.

Ловить брошенное сам компонент не должен — иначе теряется весь смысл: он снова станет местом, где написано слово «загрузка». Ловит тот, кто стоит выше и знает, что показать вместо поддерева. Это и есть граница: try вокруг вызова, заглушка в catch и подписка на пойманный промис.

Читалка едет с нами из прошлого примера без единой правки — в тексте от неё осталась одна строка declare. Прогноз: сколько раз напечатается «тело Profile»?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Дважды. Первая попытка не дала ничего: read бросил промис на середине выражения, и всё, что Profile успел насчитать, пропало вместе с вызовом. Граница показала заглушку и подписалась на промис. Когда тот разрешился, она позвала Profile заново — с самого начала, потому что продолжить прерванный вызов нечем. На этот раз read нашёл значение в кеше, и строка досчиталась до конца.

Строка «не промис — бросаем дальше» — это дверь. Всё, что летит наверх и промисом не является, граница пропускает мимо себя: чужие ошибки ловить не её работа.

Здесь же лежит ответ на вопрос, откуда взялось требование держать тело компонента чистым.

Прерванный вызов не возобновляют: обычную функцию в JavaScript нельзя поставить на паузу и продолжить с того же места. Значит, попытку выбрасывают и делают заново, а всё, что тело успело натворить по дороге, остаётся сделанным.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две метрики на один показанный профиль. И это ещё скромно: настоящий React зовёт тело чаще нашей границы — сколько именно, разбираем в следующем разделе. Отсюда правило: в теле только вычисления, всё остальное — в эффект или в обработчик.

В прошлой статье настоящий React выполнил тело трижды, наша граница — дважды. Разница в одном: у нашей границы был единственный ребёнок, и его приостановка означала конец работы. У React детей несколько, и, наткнувшись на ожидание, он делает ещё один проход — чтобы дойти до соседей приостановленного компонента и начать их ожидание тоже, а не после того, как дождётся первого.

Добавим второго ребёнка и второй проход. Прогноз: сколько раз напечатается «тело Profile», сколько «тело News» — и в каком проходе уйдёт запрос за новостями?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«тело Profile» — три раза, «тело News» — два. Первый проход умер на профиле: до новостей очередь не дошла, и запрос за ними не начинался вовсе. Второй проход обходит детей поодиночке и ловит каждого отдельно — только поэтому «запрос за news» и случился, а не встал в очередь за именем. Третий раз оба тела зовутся после ответа и оба досчитывают до конца.

Прошлая статья сказала это словами: число проходов вам не принадлежит. Теперь видно, из чего оно складывается, — из того, сколько у границы детей и кто из них когда ответил, а не из вашего кода. Полагаться на него нельзя, а понимать, откуда оно берётся, полезно: в выводе вы будете видеть его постоянно.

Заглушка печатается сразу после первого прохода, ещё до второго: соседей граница прогревает, когда fallback уже на экране. Так с 19-й версии делает и React — сперва коммитит заглушку, потом дорисовывает соседей. А «граница показывает всех разом или никого» — не отдельное правило, а следствие того, что результат прохода собирается в одну строку.

У нашей читалки есть ключ — строка "user". У use() ключа нет: ему дают промис, и больше ничего. Значит, ключом кеша служит сам объект промиса, а метку о готовности приходится держать прямо на нём.

Соберём такую читалку — она короче прежней, словарь ей не нужен. Границу возьмём попроще, ту самую с одним ребёнком: считать попытки на ней нагляднее. И повторим фокус из прошлой статьи — пусть промис рождается в теле компонента. Прогноз: сколько запросов уйдёт за одним и тем же именем?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три — и ушло бы сколько угодно, не оборви мы цикл сами на четвёртой попытке. Каждый вызов load даёт новый объект, а на новом объекте метки нет: use видит чистый промис, помечает его и бросает. Пришёл ответ — граница зовёт тело заново, тело создаёт ещё один промис, и всё повторяется. Метки честно проставились на трёх объектах, до которых уже никому нет дела.

Лечится тем, что промис перестаёт рождаться в теле.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две попытки вместо бесконечности, один запрос — и на промисе действительно появились дописанные поля. В прошлой статье вы уже видели ту же пару у настоящего React: Object.keys на промисе, отданном в use(), показывал status и value — и рядом с ними своё служебное поле отладочной сборки. Теперь понятно, чья это работа и зачем она нужна.

Отсюда же и требование «промис должен пережить отрисовку»: пережить — значит остаться тем же объектом. Модульный уровень, кеш по ключу, пропс сверху — три способа это обеспечить, и все три про одно и то же.

Проверим, что мы собрали не похожую вещь, а ту же самую. Читалка из первого раздела про React ничего не знает — она просто бросает промис. Отдадим её настоящему компоненту и накроем настоящим <Suspense>. Никакого use() в примере нет.

Прогноз: React отругается на брошенный промис, покажет заглушку или погасит дерево?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три «тело Profile», заглушка на сотой миллисекунде, данные к 1200-й — то же, что прошлая статья видела у use(), только теперь в выводе есть и строка запроса. Настоящая граница поймала наш промис так же, как ловила свой: приостановка — это не устройство внутри use, а соглашение между тем, кто читает данные, и тем, кто их ждёт.

В своём коде так писать не надо: для чтения промиса есть use(). React понимает брошенный промис потому, что этим протоколом с 2018 года пользуются библиотеки данных, — а use() поверх него просто узаконили и типизировали.

Тот же опыт с двумя детьми. Чтобы разглядеть, что происходит между ответами, второй ребёнок читает меню — оно отвечает через 900 миллисекунд, втрое дольше профиля, — а в теле печатает, что о меню знает кеш. Прогноз: сколько раз напечатается «тело Profile»?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пять — а наша граница с теми же детьми насчитала бы три. Первые пять строк повторяют миниатюру, только без наших «на экране» — их работу делает 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 миллисекунд — а на экране навсегда остаётся «Загрузка…». Последняя строка примера доказывает, что значение в кеше есть. Почините границу.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Граница поймала промис и показала заглушку, но ничего не сделала с самим промисом — а он и есть единственный сигнал «пробуй снова». Не хватает подписки.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь вывод такой:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание, что boundary зовётся рекурсивно: повторная попытка — это не «продолжение», а такой же полный проход, просто теперь чтение находит значение в кеше и ничего не бросает.

Второй запрос уходит слишком поздно

Карточка читает подряд две вещи: имя (ответ через 300 мс) и меню (через 900 мс). Запустите и найдите в выводе строку «запрос за menu» — она появляется позже, чем могла бы. Сделайте так, чтобы оба запроса уходили сразу, не трогая тело Card.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первое чтение бросает промис и обрывает вызов — до второго read дело не доходит, и запрос за меню начинается только после того, как пришло имя. Ожидания складываются: 300 миллисекунд, потом ещё 900. Это та самая лесенка запросов, что и в главе про эффекты, только устроенная приостановкой.

Лечится тем, что запись в кеше заводится заранее — до отрисовки. Промис при этом никому не нужен, важно, что запрос пошёл:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь «запрос за user» и «запрос за menu» стоят в выводе двумя первыми строками, и общее ожидание — 900 миллисекунд вместо 1200. Тело Card осталось прежним: где начинать запросы — решение вызывающей стороны, а не читателя данных. Ровно этим и занимается второй проход границы для соседей, только здесь оба чтения живут в одном компоненте, и помочь ему некому.

Быстрый блок не должен ждать медленный

Профиль отвечает через 300 миллисекунд, меню — через 900, и до девятисотой на экране одна общая заглушка. Сделайте так, чтобы профиль появлялся сразу, как будет готов, а своя заглушка осталась только у меню. Компоненты трогать нельзя.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Граница у нас — обычная функция, поэтому «поставить вторую границу» значит просто позвать её ещё раз, со своим списком детей и своей заглушкой:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Профиль показался на трёхсотой миллисекунде, меню — на девятисотой. В настоящем React это те же две границы <Suspense> в разметке вместо одной: расстановка границ решает, какой кусок экрана ждёт вместе, а не логика внутри компонентов.