javascript

Почему React Server Components интегрируются с bundler

  • среда, 4 ноября 2026 г. в 00:00:09
https://habr.com/ru/companies/gnivc/articles/1103136/

Привет, я фронтенд-разработчик, и сегодня хочу разобраться с одной интересной особенностью React Server Components.

Если вы работали с RSC, то наверняка замечали, что вокруг них почти всегда появляется ещё один участник — bundler. В документации React прямо говорится, что директивы вроде 'use client' дают инструкции bundler'ам, совместимым с RSC, а при встрече клиентского компонента bundler должен построить для него клиентскую сборку.

Но возникает вполне логичный вопрос. Зачем React Server Components вообще нужен bundler?

Ведь JavaScript уже умеет работать с модулями. Браузер понимает import, умеет загружать ES-модули и поддерживает динамический import().

На первый взгляд можно сделать всё достаточно просто.

Допустим, сервер во время рендера встречает такой компонент:

'use client'

export function Counter({ initialCount }) {
    return (
        <button>
            {initialCount}
        </button>
    )
}

Почему бы серверу просто не сказать браузеру:

{
    "module": "/components/Counter.js",
    "export": "Counter"
}

А браузеру после этого не сделать:

const module = await import('/components/Counter.js')
const Counter = module.Counter

Технически такой подход возможен. Архитектура RSC не привязана к
конкретному bundler'у вроде Webpack или Vite. Проблема начинается в другом
месте.

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

И вот для этой задачи bundler становится практически естественным инструментом.

В этой статье предлагаю не начинать с теории RSC, а провести небольшой мысленный эксперимент.

Сначала полностью уберём bundler и попробуем представить, как RSC мог бы работать на обычных ES-модулях.Затем добавим зависимости и посмотрим, в какой момент одной только системы ESM становится недостаточно и зачем React приходится связывать RSC с системой сборки.

Убираем bundler

Начнём с самого простого случая. У нас есть клиентский компонент:

'use client'

export function Counter({ initialCount }) {
    return (
        <button>
            {initialCount}
        </button>
    )
}

Server Component использует <Counter initialCount={10} />. Сам компонент Counter RSC-серверу выполнять не нужно. В RSC-окружении вместо его кода используется Client Reference. При этом Client Components могут дополнительно выполняться на SSR-сервере во время формирования HTML. Поэтому вместо исходного JavaScript серверу концептуально достаточно передать клиенту информацию о том, какой компонент нужно использовать.

Условно это может выглядеть так:

{
    "module": "/components/Counter.js",
    "export": "Counter"
}

Браузер получает такую информацию и загружает модуль:

const module = await import('/components/Counter.js')
const Counter = module.Counter

После этого React уже может использовать полученный компонент. Изи сценарий, для которого никакого bundler не требуется. Браузер умеет работать с ES-модулями самостоятельно. В такой упрощённой реализации RSC может работать поверх обычных ESM-модулей без привязки к конкретному bundler'у.

Можно представить реализацию, которая использует обычные ESM-модули и самостоятельно поддерживает необходимую карту соответствий между серверными ссылками и клиентскими URL, но мы пока рассмотрели слишком простой случай.

Counter.js не имеет зависимостей. А ведь клиентский код никогда не состоит из одного файла.

Когда появляется граф модулей

Изменим Counter:

'use client'

import { Button } from './Button.js'

export function Counter({ initialCount }) {
    return (
        <Button>
            {initialCount}
        </Button>
    )
}

Теперь Counter.js импортирует Button.js. А Button.js, например, может импортировать иконку:

import { Icon } from './Icon.js'

А, в свою очередь, Icon.js может импортировать какие-нибудь общие утилиты: import { cx } from './utils.js'. В итоге у нас получается уже не один файл, а граф. И – никакой катастрофы. Браузер умеет спокойно работать с таким графом. Он загружает Counter.js, обнаруживает его import, затем получает Button.js, анализирует его импорты и продолжает загрузку.

Утверждение «без bundler браузер не умеет работать с графом зависимостей» – неверное: всё он умеет. Именно поэтому простой RSC-эксперимент на ESM вполне реалистичен.

Однако теперь перед нами появляется вопрос. А кто вообще должен выполнять эти модули? Все модули в клиентском поддереве этой границы должны быть доступны клиенту. При этом отдельный shared-модуль может одновременно использоваться и в серверном, и в клиентском графе.

Если файл содержит 'use client', это не означает, что только сам этот файл должен попасть в браузер. React указывает, что такой модуль становится клиентской границей, а его транзитивные зависимости также относятся к клиентскому коду.

То есть после появления

'use client'

import { Button } from './Button.js'

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

'use client' — это не просто пометка компонента

Похоже, это самая важная часть всей истории. И кажется, что 'use client'– это просто обычный флаг того, что этот компонент будет выполняться в браузере, но для системы сборки смысл глубже:'use client' обозначает границу клиентского модуля.

Вот: Server Component -> Counter.js ('use client'), а дальше, на следующем уровне,– Button.js, Icon.js, utils.js. Для серверного графа Counter.js нельзя обрабатывать как обычный серверный модуль. Если сервер начнёт импортировать его реальное содержимое, он фактически попытается загрузить клиентский код.

Поэтому в реализации RSC происходит другая вещь. На серверной стороне вместо реального Counter создаётся ссылка на клиентский модуль.

Упрощённо её можно представить так:

const Counter = {
    $$typeof: Symbol.for('react.client.reference'),
    $$id: './components/Counter.js#Counter'
}

Это не код самого компонента. Это описание: здесь находится клиентский компонент Counter; его настоящая реализация находится в клиентской части приложения.

Конкретная внутренняя структура такой ссылки и её сериализация являются деталями реализации RSC runtime и конкретного bundler'а, но сама идея Client Reference заключается в том, что сервер работает не с исполняемым клиентским модулем, а с его идентификатором.

Сам import() при этом никуда не исчезает. Он по-прежнему может использоваться для загрузки клиентского модуля. Но сначала runtime должен понять, какой именно модуль и какой export соответствуют полученному Client Reference. А после сборки дополнительно нужно знать, в каком клиентском артефакте находится этот модуль и какие chunks необходимы для его загрузки.

Что должен знать сервер

Представим исходный файл ./components/Counter.js. В проекте всё просто, но после сборки клиентской части этот файл может исчезнуть как отдельный ресурс. Bundler может превратить приложение примерно в:

/assets/client.js
/assets/chunk-847.js
/assets/chunk-912.js

И настоящий код Counter уже окажется внутри одного из этих файлов. Поэтому сервер не может просто отправить браузеру:

{
    "module": "./components/Counter.js",
    "export": "Counter"
}

Браузер не обязан уметь по такому идентификатору найти исходный файл. Серверу нужна дополнительная информация, например о том, какому клиентскому модулю и каким chunks соответствует данный Client Reference.

Реальный формат manifest зависит от конкретной интеграции. Например, существующая интеграция React с Webpack использует отдельные механизмы и manifest для сопоставления client references с результатами сборки. Сам React также предоставляет bundler-специфичные пакеты вроде react-server-dom-webpack. Но принцип один: исходный модуль нужно связать с тем, что получилось после клиентской сборки. И вот эту связь bundler как раз умеет создавать.

Что на самом деле делает bundler

То, что описано выше, – лишь одна из задач bundler и далеко не главная для понимания RSC. В контексте Server Components bundler нужен прежде всего потому, что он умеет анализировать граф модулей и отдельно обрабатывать серверную и клиентскую части этого графа. Условно у нас есть исходный граф:

Для серверной среды это не должен оставаться обычным графом. Она может выглядеть так:

А клиентская сборка начинает строиться от Counter как от клиентской границы:

В итоге bundler не только складывает файлы в chunks, но и анализирует граф модулей и помогает отдельно обработать серверную и клиентскую части этого графа. React прямо описывает 'use client' как инструкцию для совместимых bundler'ов: встретив такую границу, bundler понимает, что соответствующий модуль и его зависимости должны рассматриваться как клиентский код.

Серверу нужен не сам Counter, а информация о Counter

Теперь можно вернуться к нашему Counter. Допустим, после сборки его реальный код оказался в /assets/chunk-847.js. Сервер при рендеринге видит <Counter initialCount={10} />, но сам Counter на сервере является Client Reference.

При сериализации RSC React должен передать клиенту достаточно информации, чтобы тот смог восстановить настоящий компонент. Примерно можно представить это так:

Client Reference -> module id -> export name -> chunks -> client module

То есть сервер не говорит: «Вот JavaScript-код Counter», а говорит: «В React-дереве находится клиентский компонент с таким-то идентификатором, вот информация, которая позволит runtime найти его».

RSC payload не обязан содержать исходный JavaScript клиентского компонента. Он содержит данные, необходимые для восстановления React-дерева, в том числе ссылки на Client Components.

В реальности React сериализует Client References в специальный формат Flight/RSC, а клиентский runtime использует полученные метаданные для загрузки соответствующего модуля. В Webpack-интеграции эти метаданные включают идентификатор модуля, имя export и связанные chunks. Поэтому выражение «module reference» здесь гораздо точнее, чем просто «URL файла». Это логическая ссылка на клиентский модуль, которую можно разрешить через информацию клиентской сборки.

А что с props?

Берём тот же самый <Counter initialCount={10} /> , понимая, что Counter — это Client Reference, а:

{
    initialCount: 10
}

это данные React-дерева, т.е серверу не нужно превращать компонент вместе с его props в какой-то самостоятельный JavaScript-файл. Он сериализует React-модель, в которой присутствует ссылка на клиентский компонент и его props.

Упрощённо:

Client Reference:
    Counter

Props:
    initialCount = 10

На клиенте RSC runtime использует reference, чтобы получить настоящий Counter, а затем восстанавливает соответствующий React element с переданными props. module reference решает только одну часть задачи – как найти код клиентского компонента, а наличие props — это уже часть сериализованной модели React.

Почему нельзя просто передать исходный ESM URL?

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

Counter.js → /components/Counter.js
Button.js  → /components/Button.js
Icon.js    → /components/Icon.js

И тогда manifest можно написать самостоятельно:

{
    "./components/Counter.js#Counter": {
        "url": "/components/Counter.js",
        "export": "Counter"
    }
}

Браузер получает URL и делает await import('/components/Counter.js'). Всё работает.

Значит, bundler действительно можно убрать. Но тогда мы сами становимся системой сборки. Нам придётся самостоятельно: определять 'use client', строить клиентский граф зависимостей, следить за тем, какие модули принадлежат клиентской части, генерировать уникальные идентификаторы клиентских модулей, создавать manifest, сопоставлять исходные модули с URL, определять, какие chunks необходимы для конкретного Client Reference и обеспечивать корректную загрузку модулей на клиенте.

И это ещё без минификации, tree shaking, обработки CSS, ассетов и других задач современной клиентской сборки. На маленьком проекте такой подход вполне реален, но в настоящем это будет ад.

Но ведь браузер и без bundler умеет загружать зависимости

Проблема RSC не в том, что браузер не умеет загружать граф ES-модулей. Умеет. И проблема не сводится к тому, что без bundler обязательно получится ужасный waterfall из HTTP-запросов. Современный браузер умеет анализировать module graph, использовать кеширование, HTTP/2, HTTP/3, modulepreload и другие механизмы оптимизации.

Bundler действительно может уменьшить количество отдельных модулей и дать более удобную стратегию code splitting, но это не является фундаментальной причиной его интеграции с RSC. Фундаментальная проблема как мы уже говорили это вопросы:

  • Какие модули являются серверными?

  • Какие модули являются клиентскими?

  • Где проходит граница между ними?

  • Как серверу представить клиентский модуль, не выполняя его?

  • Как после сборки сопоставить эту ссылку с настоящим клиентским кодом?

  • Какие chunks нужно загрузить, чтобы этот модуль можно было выполнить?

Вот именно для этого и нужен механизм интеграции между RSC и системой сборки.

Почему React не может сам сделать всё это?

React не является Webpack. Webpack знает устройство своего module graph, свои module IDs, свои chunks и правила code splitting. Другой bundler устроен по-другому. Поэтому невозможно сделать так, чтобы React просто сказал: «Вот тебе chunk ID 847, загрузи его». Что такое 847 и как по нему получить JavaScript — зависит уже от конкретной системы сборки.

Именно поэтому существуют bundler-специфичные интеграции RSC.

Например:

react-server-dom-webpack
react-server-dom-turbopack
react-server-dom-parcel

У каждой такой интеграции задача примерно одна и та же – дать React возможность работать с системой сборки конкретного bundler'а.

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

Итог

RSC интегрируется с bundler, потому что ему нужно превратить границу server/client в работающий механизм сборки и разрешения Client References.

Webpack, Vite, Turbopack или другой bundler уже умеет решать большую часть этой задачи на уровне графа модулей и результатов сборки.

Поэтому bundler не является концептуально обязательной частью самого RSC.

Но если убрать его из реального приложения, нам всё равно придётся самостоятельно решить те же задачи. Вот и получается, что RSC и bundler оказываются настолько тесно связаны.