Почему React Server Components интегрируются с bundler
- среда, 4 ноября 2026 г. в 00:00:09
Привет, я фронтенд-разработчик, и сегодня хочу разобраться с одной интересной особенностью 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 с системой сборки.
Начнём с самого простого случая. У нас есть клиентский компонент:
'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' обозначает границу клиентского модуля.
Вот: 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 и далеко не главная для понимания RSC. В контексте Server Components bundler нужен прежде всего потому, что он умеет анализировать граф модулей и отдельно обрабатывать серверную и клиентскую части этого графа. Условно у нас есть исходный граф:

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

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

В итоге bundler не только складывает файлы в chunks, но и анализирует граф модулей и помогает отдельно обработать серверную и клиентскую части этого графа. React прямо описывает 'use client' как инструкцию для совместимых bundler'ов: встретив такую границу, bundler понимает, что соответствующий модуль и его зависимости должны рассматриваться как клиентский код.
Теперь можно вернуться к нашему 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 файла». Это логическая ссылка на клиентский модуль, которую можно разрешить через информацию клиентской сборки.
Берём тот же самый <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.
Теперь снова вернёмся к вопросу из начала статьи. Допустим, мы вообще не используем 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, ассетов и других задач современной клиентской сборки. На маленьком проекте такой подход вполне реален, но в настоящем это будет ад.
Проблема RSC не в том, что браузер не умеет загружать граф ES-модулей. Умеет. И проблема не сводится к тому, что без bundler обязательно получится ужасный waterfall из HTTP-запросов. Современный браузер умеет анализировать module graph, использовать кеширование, HTTP/2, HTTP/3, modulepreload и другие механизмы оптимизации.
Bundler действительно может уменьшить количество отдельных модулей и дать более удобную стратегию code splitting, но это не является фундаментальной причиной его интеграции с RSC. Фундаментальная проблема как мы уже говорили это вопросы:
Какие модули являются серверными?
Какие модули являются клиентскими?
Где проходит граница между ними?
Как серверу представить клиентский модуль, не выполняя его?
Как после сборки сопоставить эту ссылку с настоящим клиентским кодом?
Какие chunks нужно загрузить, чтобы этот модуль можно было выполнить?
Вот именно для этого и нужен механизм интеграции между RSC и системой сборки.
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 оказываются настолько тесно связаны.