Год назад команда реакта представила серверные компоненты (не путать с SSR). Если вкратце, то суть в том, что компонент создается на сервере, сериализуется в хитрый json, отправляется клиенту по http, а клиент десериализует и рендерит его как обычный реакт компонент (тут-то и самое заметное отличие от SSR, который клиенту передает уже готовый html код). Вообще штука прикольная, но как мне кажется не получила особого внимания со стороны сообщества, может отчасти из-за сырого состояние (на то это и демка), а может из-за сложности в реализации и внедрения в проект (ИМХО)
Но как бы там ни было я заинтересовался и подумал, если можно передавать по HTTP, значит можно и по WebSocket. Действительно, почему бы нет, да и работать будет намного быстрее. Попытка переписать их демку на веб-сокеты потерпела поражения, уж очень много там странного и непонятного для меня кода, да и не охота было сильно углубляться в то обилие зависимостей, которые требуются, чтобы все это дело заработало.
Поэтому я принялся писать свою личную упрощенную версию серверных компонентов, которые могли бы в реал-тайме передаваться по веб-сокет соединению. Почему бы и нет.
К слову, писал я её еще 10 месяцев назад и как-то забыл об этом. Но на днях я наткнулся на репозиторий и подумал, что все таки вышло прикольно и решил написать об этом статью.
Основная идея
В первую очередь клиент устанавливает веб-сокет соединение с сервером. Дальше, неким магическим импортом, получает сериализованный компонент, который на самом деле хранится и создается на сервере, десериализует его, и может работать с ним как с обычным реактовским, в том числе рендерить его в любом месте, передавать ему сериализуемые пропсы и children
. Так как компонент существует только на сервере, то при каждом изменении пропсов его нужно перезапросить и снова отрисовать
Сериализация компонентов
Первая задача, которую нужно решить — это сериализация компонентов. Она происходит исключительно на сервере, от чего серверные компоненты сильно ограничены, по сути они должны включать в себя только JSX и не могут использовать состояние, собственно, как и любые другие хуки.
Задача сериализации компонента сводится к сериализации непосредственно JSX. Я не пишу классовые компоненты от слова совсем, поэтому я даже не рассматривал процесс их сериализации, но не думаю, что он может сильно отличаться. Но все же скажу, что все следующие примеры будут справедливы только для функциональных компонентов.
Для начала посмотрим как вообще выглядят компоненты если их вывести в консоль (я удалил лишние свойства, для компактности)
Пустой html элемент
console.log(<div />)
{
$$typeof: Symbol(react.element),
props: {},
type: "div"
}
html элемент со строкой внутри
console.log(<div>habr<div>)
{
$$typeof: Symbol(react.element),
props: { children: 'habr' },
type: "div"
}
html элемент с массивом данных внутри
console.log(<div>{['habr', 42]}</div>)
{
$$typeof: Symbol(react.element),
props: {
children: ['habra', 42]
},
type: "div"
}
html элемент с другим html элементом внутри
console.log(<div><div /></div>)
{
$$typeof: Symbol(react.element)
props: {
children: {
$$typeof: Symbol(react.element),
props: {},
type: "div"
}
},
type: "div"
}
Функциональный компонент
const Component = () => <div />
console.log(<Component />)
{
$$typeof: Symbol(react.element),
props: {},
type: () => react_1.default.createElement("div", null)
}
Функциональный компонент обернутый в React.memo
const Component = React.memo(() => <div />)
console.log(<Component />)
{
$$typeof: Symbol(react.element),
props: {},
type: {
$$typeof: Symbol(react.memo),
compare: null,
type: () => react_1.default.createElement("div", null)
}
}
Фрагмент
console.log(<></>)
{
$$typeof: Symbol(react.element),
props: {},
type: Symbol(react.fragment)
}
Так, из этого можно сделать вывод, что все что нужно для сериализации это type
и props
. Но вот что нужно учитывать:
children
- строка/число, когда дочерний элемент строка/число
- массив строк/чисел, когда есть несколько дочерних элементов строк/чисел
- объект, когда дочерний элемент компонент/html элемент
- массив объектов, когда есть несколько дочерних компонент/html элементов
type
- строка для html элементов
- функция для компонентов
- объект для компонентов обернутых в
memo
Symbol
для фрагмента
На основе этих данных у меня получилась следующая функция сериализации (я удалил некоторые доп. проверки, чтобы акцентировать внимание только на главном, полную версию можно посмотреть в гитхабе):
const decomposeFunctionElement = (Component, props) => {
// call functional component as function :3
const decomposed = Component(props)
return serializeReactElement(decomposed)
}
const serializeChildren = (children) => {
if (children === null || typeof children === 'undefined') {
return null
}
if (!Array.isArray(children)) {
return serializeReactElement(children)
}
return children.map(serializeReactElement)
}
const serializeReactElement = (element) => {
// строки и числа оставляем как есть
if (typeof element === 'string' || typeof element === 'number') {
return element
}
const {
type,
props: { children, ...props } = {},
} = element
// Memo
if (typeof type === 'object') {
return decomposeFunctionElement(type.type, element.props)
}
// Function
if (typeof type === 'function') {
return decomposeFunctionElement(type, element.props)
}
const serializedChildren = serializeChildren(children)
if (serializedChildren) {
props.children = serializedChildren
}
// HTML tag
return { type, props }
}
Основная функция это serializeReactElement
. По сути принцип её работы в том, что я оставляю из компонента только свойства type
и props
, но для случая, когда type
это функция или объект, то это немного отличается. Самое интересное происходит в decomposeFunctionElement
, где я вызываю реакт компонент как обычную функцию, тем самым "разбираю" его и получаю чистый JXS, который снова сериализую, и так далее по рекурсии. А с memo
почти всё также, он имеет внутри себя еще один type
, который уже является функцией и представляет собой непосредственно обернутый компонент
На выходе получаю такой результат:
const Component = ({ title, children }) => (
<div>
<span>{title}</span>
{children}
</div>
)
const jsx = (
<Component title="Title">
<p>Content</p>
</Component>
)
const serialized = serializeReactElement(jsx)
console.log(serialized)
{
type: 'div',
props: {
children: [
{
type: 'span',
props: {
children: 'Title',
},
},
{
type: 'p',
props: {
children: 'Content',
},
},
],
},
}
Сервер
Теперь, когда я умею сериализовать компоненты, можно писать сервер. Его реализация получилась прям максимально простой и компактной
// wss - любой веб сокет сервер
wss.on('connection', socket => {
socket.on('message', message => {
const { path, props } = JSON.parse(message)
const Component = require(resolve(__dirname, 'example', path)).default
const element = React.createElement(Component, props, Tags.CHILDREN)
socket.send(JSON.stringify({
path,
element: serializeReactElement(element)
}))
})
})
Клиент запрашивает у сервера компонент, передавая ему path
— путь к нему и props
— пропсы, с которыми нужно создать компонент. Я получаю компонент и через встроенную в реакт функцию createElement
создаю его.
children в серверных компонентах
Но тут есть интересный момент. У компонента обычно есть еще children
, по сути это пропс, но в createElement
он выделен под отдельный аргумент. А так как children
доступны только на клиенте, то я при создании компонента указываю вместо них специальный тег Tags.CHILDREN
, тем самым как бы говоря клиенту "вот в это место, при десериализации компонента, ты должен вставить свои children
"
Что это за тег? А это самый обычный объект
const CHILDREN = {
type: 'CHILDREN'
}
Теперь, помимо всех остальных типов, у меня появился дополнительный CHILDREN
, который я создал, и его нужно учесть при сериализации.
С таким тегом результат сериализации будет таким:
const Component = ({ children }) => (
<div>{children}</div>
)
const cmp = React.createElement(Component, {}, Tags.CHILDREN)
const serialized = serializeReactElement(cmp)
{
type: 'div',
props: {
children: {
type: 'CHILDREN',
}
},
}
Клиентские компоненты в серверных
Аналогичным образом необходимо поддержать возможность использовать клиентские компоненты в серверных. Опять же, клиентские компоненты доступны только из клиентского кода, поэтому для серверных компонентов, в местах где нужно будет вставить клиентский, я через еще один специальный тег
говорю клиенту "вот тут вот, при десериализации компонента, тебе нужно будет вставить клиентский компонент с таким-то именем".
Вот как выглядит этот тег
const CLIENT_COMPONENT = (path, props) => {
return {
type: 'CLIENT_COMPONENT',
props,
name,
}
}
const importClientComponent = (name) => (props) => {
return CLIENT_COMPONENT(name, props)
}
Теперь в серверных компонентах, если нужно вставить клиентский, вместо привычных import/require
нужно пользоваться функцией importClientComponent
, которая возвращает новый компонент, а он в свою очередь динамически создает тег с нужной информацией для клиента.
Пример
const ClientComponent = importClientComponent('Counter')
const ServerCompnent = () => {
return (
<div>
<ClientComponent initValue={0} step={2}>
</div>
)
}
А при сериализации получится такой результат:
{
type: 'div',
props: {
children: {
type: 'CLIENT_COMPONENT',
props: {
initValue: 0,
step: 2
},
name: 'Counter'
}
},
}
Клиент
С сервером разобрался, осталось написать клиент и дело в шляпе. Начну с десериализации, она почти не сложная
const clientComponentsMap = {
Counter: require('./Counter.js').default
}
const deserializeChildren = (children, clientChildren) => {
if (!children || (Array.isArray(children) && !children.length)) {
return null
}
if (!Array.isArray(children)) {
return deserializeReactElement(children, clientChildren)
}
return children.map(child => deserializeReactElement(child, clientChildren))
}
const deserializeReactElement = (element, clientChildren = null) => {
if (typeof element === 'string' || typeof element === 'number') {
return element
}
const { type, name, props = {} } = element
if (type === 'CHILDREN') {
return clientChildren
}
if (type === 'CLIENT_COMPONENT' && name) {
const Component = clientComponentsMap[name]
return createElement(Component, props, deserializeChildren(props.children, clientChildren))
}
return createElement(type, props, deserializeChildren(props.children, clientChildren))
}
Учитываю теги, которые оставляет нам сервер: для CHILDREN
— возвращаю непосредственно children
, для CLIENT_COMPONENT
— получаю клиентский компонент по имени и создаю его, а для всего остального просто создаю компонент. Я использовал parcel
в качестве сборщика и он, как я понял, не поддерживает динамические импорты, поэтому клиентские компоненты пришлось заранее импортировать и сохранить в объект clientComponentsMap
, чтобы иметь доступ к ним по их имени.
Импорт серверных компонентов в клиентских
Теперь самое главное, как отрендерить серверный компонент в клиентском. Во-первых, нужно создать веб-сокет подключение и всё приложение обернуть в WebSocketProvider
и WaitWebSocketOpen
.
const ws = new WebSocket('ws://localhost:3000')
// ...
const ServerComponent = importServerComponent('./ServerComponent.js')
<WebSocketProvider value={ws}>
<WaitWebSocketOpen fallback={<h1>Loading...</h1>}>
<ServerComponent value="some_value">
Some child
</ServerComponent>
</WaitWebSocketOpen>
</WebSocketProvider>
Первый это просто контекст, чтобы иметь доступ к веб-сокет соединению из любого компонента, а второй — ожидание открытие соединения и отображения лоадера. Пока ничего необычного. А сам магический импорт
серверных компонентов делается с помощью функции importServerComponent
. Это функция высшего порядка и вот её реализация:
const importServerComponent = (path) => ({ children: clientChildren, ...props } = {}) => {
const [element, setElement] = useState(null)
const ws = useWebSocket()
useEffect(() => {
ws.onComponent(path, (data) => setElement(data.element))
}, [])
useEffect(() => {
ws.send(JSON.stringify({ path, props }))
}, [JSON.stringify(props)])
if (!element) {
return null
}
return element.type // type is undefined for Fragment and maybe some another
? deserializeReactElement(element, clientChildren)
: deserializeChildren(element.props.children, clientChildren)
}
Она принимает путь к компоненту относительно сервера и возвращает компонент, который отправляет запрос на получение компонента от сервера, а когда компонент приходит, то десериализует и рендерит его
Единственный момент, который остался, это использование странной функции ws.onComponent
. Она необязательна и вообще можно реализовать как угодно. Но суть её в том, что я делаю подписку на onmessage
единожды. На onComponent
я регистрирую коллбеки на компоненты, которые присылает мне сервера, а при событии onmessage
вызываю нужный коллбек в зависимости от path
ws.componentsMap = new Map()
ws.onComponent = (path, callback) => {
ws.componentsMap.set(path, callback)
}
ws.onmessage = message => {
const data = JSON.parse(message.data)
const callback = ws.componentsMap.get(data.path)
if (callback) callback(data)
}
И всё, получилась вот такая очень компактная реализация серверных компонентов. Конечно, она далека от идеала, не супер функциональная и, возможно, я не продумал некоторые моменты, но в целом было интересно и получилось как мне кажется тоже неплохо
Как это может быть полезно?
Идея в том, что компонент рендерится на сервере, а значит он имеет доступ ко всему серверному слою. Это значит, что можно делать запросы к бд прям в нем, я не включил это в статью, но в гитхабе можно увидеть как легко сделать серверные компоненты асинхронными
Еще один плюс это то, что тяжелые библиотеки, которые обычно требуются на клиенте, можно перенести на сервер, из-за чего они не будут включены в бандл. Ярким примером (других я и не могу придумать) могут быть marked
и sanitize-html
. К примеру бандл моей демки визуального markdown редактора без использования серверных компонентов весит примерно 380 Кб
, а с — 130 Кб
А вот и ссылка на полную версию кода: react-websocket-components
Изначально я хотел сделать из этого библиотеку и загрузить в npm
, но в итоге так и не сделал, показалось что вряд ли получится что-то полезное
А на этом всё, спасибо за внимание, надеюсь статья была кому-то интересная, а может даже полезная!