habrahabr

Прогрессивный JSON

  • суббота, 7 июня 2025 г. в 00:00:16
https://habr.com/ru/articles/915274/

Вы знаете, что такое прогрессивный JPEG? Можете почитать хорошее объяснение. Идея заключается в том, что вместо загрузки изображения сверху вниз оно сначала грузится размытым, а потом постепенно становится чётче.

Что, если мы применим тот же принцип к передаче JSON?

Допустим, у нас есть дерево JSON с какими-то данными:

{
  header: 'Welcome to my blog',
  post: {
    content: 'This is my article',
    comments: [
      'First comment',
      'Second comment',
      // ...
    ]
  },
  footer: 'Hope you like it'
}

А теперь представьте, что нам нужно передать его по сети. Так как это формат JSON, у нас не будет валидного дерева, пока не загрузится последний байт. Нам придётся ждать, пока загрузится весь файл, затем вызвать JSON.parse, и уже потом обрабатывать его.

Клиент не сможет ничего сделать с JSON, пока сервер не отправит последний байт. Если часть JSON сервер генерирует медленно (например, для загрузки всех comment потребуется долгое обращение к базе данных), то клиент не сможет начать никакую работу, прежде чем сервер не закончит всю работу.

Разве это хорошая архитектура? Тем не менее, таков статус-кво — именно так 99,9999%* приложений отправляет и обрабатывает JSON. Осмелимся ли мы улучшить ситуацию?

* Статистику я выдумал


Потоковая передача JSON

Можно попробовать оптимизировать работу, реализовав потоковый парсер JSON. Потоковый парсер JSON будет способен создать дерево на основании неполных данных:

{
  header: 'Welcome to my blog',
  post: {
    content: 'This is my article',
    comments: [
      'First comment',
      'Second comment'

Если мы запросим результат на этом этапе, то потоковый парсер передаст нам следующее:

{
  header: 'Welcome to my blog',
  post: {
    content: 'This is my article',
    comments: [
      'First comment',
      'Second comment'
      // (Остальная часть комментариев отсутствует)
    ]
  }
  // (Отсутствует свойство footer)
}

Однако это тоже не так уж здорово.

Один из недостатков такого подхода заключается в том, что объекты формируются неверно. Например, объект верхнего уровня должен был иметь три свойства (headerpost и footer), но footer отсутствует, потому что пока не встречался в потоке. Свойство post должно было содержать три comment, но мы не можем определить, поступят ли новые comment или этот был последним.

В каком-то смысле, это неотъемлемое свойство потоковой передачи — разве мы не стремились получить неполные данные? — но это сильно усложняет использование данных в клиенте. Ни один из типов не «согласуется» из-за отсутствующих полей. Мы не знаем, что загрузилось полностью, а что нет. Именно поэтому потоковая передача JSON популярна только в нишевых ситуациях. Слишком сложно воспользоваться преимуществами такого подхода в логике приложения, которая обычно предполагает, что типы корректны, «готово» означает «завершено» и так далее.

В аналогии с JPEG такой наивный подход к потоковой передаче соответствует механизму загрузки сверху вниз. Вы видите чёткое изображение, но только только одну десятую его часть сверху. Поэтому, несмотря на высокое качество, вы не видите, что же находится на изображении.

Любопытно, что по умолчанию именно так работает сам потоковый HTML. При загрузке HTML-страницы по медленному каналу она будет потоково передаваться в порядке документа:

<html>
  <body>
    <header>Welcome to my blog</header>
    <article>
      <p>This is my article</p>
        <ul class="comments">
          <li>First comment</li>
          <li>Second comment</li>

Такой подход обладает некоторыми плюсами (браузер может частично отобразить страницу), но и теми же самыми недостатками. Точка отсечки выбирается произвольно и может быть визуально неприятной или даже полностью исказить структуру страницы. Непонятно, будет ли загружаться последующий контент. То, что находится внизу, например сноски, отсекаются, даже если они готовы на сервере и могли бы передаваться раньше. Когда мы потоково передаём данные по порядку, одна медленная часть откладывает загрузку всего остального.

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


Прогрессивный JSON

Потоковую передачу можно реализовать иначе.

Ранее мы передавали элементы с сортировкой по глубине. Мы начинали со свойств объекта верхнего уровня, затем заходили в свойство post этого объекта, затем в свойство comments уже этого объекта и так далее. Если что-то оказывается медленным, то приостанавливается всё остальное.

Однако мы можем передавать данные и с сортировкой по ширине.

Допустим, мы отправляем объект верхнего уровня следующим образом:

{
  header: "$1",
  post: "$2",
  footer: "$3"
}

Здесь "$1""$2""$3" обозначают элементы информации, которые ещё не были переданы. Это подстановочные символы, которые можно прогрессивно заменять позже по потоку.

Например, предположим, что сервер отправляет в поток ещё несколько строк данных:

{
  header: "$1",
  post: "$2",
  footer: "$3"
}
/* $1 */
"Welcome to my blog"
/* $3 */
"Hope you like it"

Обратите внимание, что мы не обязаны отправлять строки в каком-то конкретном порядке. В показанном выше примере мы передали лишь строки $1 и $3, но строка $2 по-прежнему ожидается!

Если бы клиент попробовал воссоздать дерево на этом этапе, оно могло бы выглядеть так:

{
  header: "Welcome to my blog",
  post: new Promise(/* ... not yet resolved ... */),
  footer: "Hope you like it"
}

Представим ещё не загруженные части в виде Promise.

Теперь представим, что сервер смог передать ещё несколько строк:

{
  header: "$1",
  post: "$2",
  footer: "$3"
}
/* $1 */
"Welcome to my blog"
/* $3 */
"Hope you like it"
/* $2 */
{
  content: "$4",
  comments: "$5"
}
/* $4 */
"This is my article"

С точки зрения клиента они заполнят недостающие элементы:

{
  header: "Welcome to my blog",
  post: {
    content: "This is my article",
    comments: new Promise(/* ... not yet resolved ... */),
  },
  footer: "Hope you like it"
}

Promise для post теперь ресолвит объект. Однако мы всё ещё не знаем, что находится внутри comments, поэтому теперь уже они представлены в виде Promise.

Далее загружаются и комментарии:

{
  header: "$1",
  post: "$2",
  footer: "$3"
}
/* $1 */
"Welcome to my blog"
/* $3 */
"Hope you like it"
/* $2 */
{
  content: "$4",
  comments: "$5"
}
/* $4 */
"This is my article"
/* $5 */
["$6", "$7", "$8"]
/* $6 */
"This is the first comment"
/* $7 */
"This is the second comment"
/* $8 */
"This is the third comment"

Теперь с точки зрения клиента готово всё дерево:

{
  header: "Welcome to my blog",
  post: {
    content: "This is my article",
    comments: [
      "This is the first comment",
      "This is the second comment",
      "This is the third comment"
    ]
  },
  footer: "Hope you like it"
}

Благодаря отправке данных блоками с сортировкой по ширине мы получили возможность прогрессивно обрабатывать их в клиенте. Если клиент может справляться с тем, что некоторые части ещё «не готовы» (представлены в виде Promise), и обрабатывать остальные, то мы таким образом улучшим ситуацию!


Встраивание

Теперь, когда у нас есть базовый механизм, можно оптимизировать его для более эффективного вывода. Давайте ещё раз взглянем на всю последовательность потоковой передачи из предыдущего примера:

{
  header: "$1",
  post: "$2",
  footer: "$3"
}
/* $1 */
"Welcome to my blog"
/* $3 */
"Hope you like it"
/* $2 */
{
  content: "$4",
  comments: "$5"
}
/* $4 */
"This is my article"
/* $5 */
["$6", "$7", "$8"]
/* $6 */
"This is the first comment"
/* $7 */
"This is the second comment"
/* $8 */
"This is the third comment"

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

Допустим, у нас есть две медленные операции: загрузка поста и загрузка комментариев к нему. В этом случае логично будет отправлять три блока.

Сначала мы отправим внешнюю оболочку:

{
  header: "Welcome to my blog",
  post: "$1",
  footer: "Hope you like it"
}

В клиенте она сразу же примет следующий вид:

{
  header: "Welcome to my blog",
  post: new Promise(/* ... not yet resolved ... */),
  footer: "Hope you like it"
}

Затем мы отправим данные post (но без comments):

{
  header: "Welcome to my blog",
  post: "$1",
  footer: "Hope you like it"
}
/* $1 */
{
  content: "This is my article",
  comments: "$2"
}

С точки зрения клиента:

{
  header: "Welcome to my blog",
  post: {
    content: "This is my article",
    comments: new Promise(/* ... not yet resolved ... */),
  },
  footer: "Hope you like it"
}

Наконец, мы одним блоком отправляем комментарии:

{
  header: "Welcome to my blog",
  post: "$1",
  footer: "Hope you like it"
}
/* $1 */
{
  content: "This is my article",
  comments: "$2"
}
/* $2 */
[
  "This is the first comment",
  "This is the second comment",
  "This is the third comment"
]

Таким образом мы создадим в клиенте полное дерево:

{
  header: "Welcome to my blog",
  post: {
    content: "This is my article",
    comments: [
      "This is the first comment",
      "This is the second comment",
      "This is the third comment"
    ]
  },
  footer: "Hope you like it"
}

Это более компактное решение, реализующее ту же цель.

В целом, этот формат даёт нам свободу решать, когда отправлять элементы в одном или в нескольких блоках. Если у клиента не возникает проблем с получением блоков не по порядку, сервер может выбирать различные эвристики разбиения на группы и блоки.


Вынесение

Любопытное следствие из применения этой методики заключается в том, что мы естественным образом можем снизить повторяемость в выходном потоке. Если мы сериализуем объект, который уже встречали ранее, то можем просто вынести его в отдельную строку и использовать повторно.

Например, пусть у нас есть такое дерево объекта:

const userInfo = { name: 'Dan' };
 
[
  { type: 'header', user: userInfo },
  { type: 'sidebar', user: userInfo },
  { type: 'footer', user: userInfo }
]

Если бы мы сериализовали обычный JSON, то нам бы пришлось повторять { name: 'Dan' }:

[
  { type: 'header', user: { name: 'Dan' } },
  { type: 'sidebar', user: { name: 'Dan' } },
  { type: 'footer', user: { name: 'Dan' } }
]

Однако если мы передаём JSON прогрессивно, то можем решить вынести его:

[
  { type: 'header', user: "$1" },
  { type: 'sidebar', user: "$1" },
  { type: 'footer', user: "$1" }
]
/* $1 */
{ name: "Dan" }

Также можно попробовать реализовать более сбалансированную стратегию — например, чтобы по умолчанию объекты встраивались (ради компактности), пока мы не встретим повторное многократное использование какого-то объекта. В этом случае мы отправим его отдельно и устраним его дублирование в остальной части потока.

Это означает ещё и то, что в отличие от обычного JSON, мы можем обеспечить поддержку сериализации цикличных объектов. Цикличный объект просто будет иметь свойство, указывающее на его собственную «строку» в потоке.


Потоковая передача данных и потоковая передача UI

По сути, по описанной выше схеме работают React Server Components.

Допустим, мы пишем страницу с React Server Components:

function Page() {
  return (
    <html>
      <body>
        <header>Welcome to my blog</header>
        <Post />
        <footer>Hope you like it</footer>
      </body>
    </html>
  );
}
 
async function Post() {
  const post = await loadPost();
  return (
    <article>
      <p>{post.text}</p>
      <Comments />
    </article>
  );
}
 
async function Comments() {
  const comments = await loadComments();
  return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}

React будет передавать вывод Page в виде потока прогрессивного JSON. В клиенте он будет воссоздаваться в виде прогрессивно загружаемого дерева React.

Изначально дерево React в клиенте будет выглядеть так:

<html>
  <body>
    <header>Welcome to my blog</header>
    {new Promise(/* ... not resolved yet */)}
    <footer>Hope you like it</footer>
  </body>
</html>

Затем, когда на сервере произойдёт ресолвинг loadPost, выполнится дополнительная потоковая передача:

<html>
  <body>
    <header>Welcome to my blog</header>
    <article>
      <p>This is my post</p>
      {new Promise(/* ... not resolved yet */)}
    </article>
    <footer>Hope you like it</footer>
  </body>
</html>

Далее, когда на сервере произойдёт ресолвинг loadComments, клиент получит остальное:

<html>
  <body>
    <header>Welcome to my blog</header>
    <article>
      <p>This is my post</p>
      <ul>
        <li key="1">This is the first comment</li>
        <li key="2">This is the second comment</li>
        <li key="3">This is the third comment</li>
      </ul>
    </article>
    <footer>Hope you like it</footer>
  </body>
</html>

Однако здесь есть одна тонкость.

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

Именно поэтому React не отображает «дыры» вместо ожидаемых Promise. Вместо этого он отображает наиболее близкое декларативное состояние загрузки, обозначенное как <Suspense>.

В показанном выше примере в дереве нет границ <Suspense>. Это означает, что хотя React будет получать данные в виде потока, он не будет отображать пользователю «скачущую» страницу. Он дождётся полной загрузки страницы.

Однако можно выбрать прогрессивно отображаемое загружаемое состояние, обернув часть дерева UI в <Suspense>. Это не изменит способ передачи данных (они всё равно будут передаваться максимально «потоково»), но изменит время отображения их React пользователю.

Пример:

import { Suspense } from 'react';
 
function Page() {
  return (
    <html>
      <body>
        <header>Welcome to my blog</header>
        <Post />
        <footer>Hope you like it</footer>
      </body>
    </html>
  );
}
 
async function Post() {
  const post = await loadPost();
  return (
    <article>
      <p>{post.text}</p>
      <Suspense fallback={<CommentsGlimmer />}>
        <Comments />
      </Suspense>
    </article>
  );
}
 
async function Comments() {
  const comments = await loadComments();
  return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}

Теперь с точки зрения пользователя последовательность загрузки будет состоять из двух этапов:

  • Сначала покажется пост с заголовком, сносками и заглушкой вместо комментариев. Заголовок и сноски никогда не отображаются сами по себе.

  • Затем отдельно покажутся комментарии.

Иными словами, этапы отображения UI отвязаны от процесса поступления данных. Данные потоково передаются тогда, когда становятся доступными, но отображаются элементы пользователю только согласно явно выбранным состояниям загрузки.

В каком-то смысле эти Promise в дереве React работают почти как throw, а <Suspense> — почти как catch. Данные поступают с максимально возможной для них скоростью и в том порядке, в котором сервер готов их отправлять, но React сам занимается правильным представлением последовательности загрузки, позволяя разработчику управлять визуальным отображением.

Всё описанное выше никак не связано с SSR и HTML. Я описал общий механизм потоковой передачи дерева UI, представленного в виде JSON. Можно превратить это дерево JSON в прогрессивно отображаемый HTML (и React способен на это), но этот принцип шире, чем HTML и применим также к навигации в духе SPA.


В заключение

В этом посте я описал одну из основных инноваций RSC. Вместо того, чтобы передавать данные как один большой блок, они отправляют свойства дерева компонентов снаружи вовнутрь. В результате этого в случае наличия запланированного состояния загрузки для отображения React может реализовать его, пока остальные данные страницы продолжают потоково загружаться.

Я бы хотел призвать разработчиков других инструментов реализовывать прогрессивную потоковую передачу данных. Если у вас возникла ситуация, когда вы не можете начать что-то делать в клиенте, пока сервер не закончит что-то делать, то это как раз пример того, в чём вам может помочь потоковая передача. Если что-то одно может замедлить всё после него, то это ещё один предупреждающий признак.

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