Год назад команда реакта представила серверные компоненты (не путать с 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, но в итоге так и не сделал, показалось что вряд ли получится что-то полезное


А на этом всё, спасибо за внимание, надеюсь статья была кому-то интересная, а может даже полезная!

Комментарии (11)


  1. Aquahawk
    17.12.2021 12:35
    +4

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

    Ещё 15 лет назад ржали с однйо софтины в которой в в обработчики клика кнопки в базу ходили чтоб табличку нарисовать. Уже тогда все понимали что это плохо. Ксати, почему бы не отправить клиенту целиком docker образ с сервером и запустить его на JsLinux?


  1. garr1nch4
    17.12.2021 12:36

    Это очень похоже на Hotwire для RoR. На хабре про это есть статья.

    Для PHP, Python и других языков тоже видел нечто подобное.


  1. kahi4
    17.12.2021 13:42
    -4

    Интересно. Но команда Реакта так же работает над чем-то похожим, советую ознакомиться с server side components


    1. evnuh
      17.12.2021 14:49
      +6

      Действительно, интересно. Ведь первая строчка в статье именно о них.


      1. kahi4
        17.12.2021 14:51

        Ох, простите, просмотрел.


  1. george3
    17.12.2021 17:47
    +1

    Зачем сериализовать компонент, если можно сериализовать данные и отрисовать их компонентом, указав его тип. где логика? пример правильного подхода и для всех серверных языков - https://github.com/Claus1/unigui


    1. RealPeha Автор
      17.12.2021 18:00

      Ну вообще именно это происходит. По сути всё что надо для сериализации компонента это его пропсы (данные) и тип


      1. george3
        17.12.2021 20:57

        не. это жуть.


  1. Alexandroppolus
    17.12.2021 23:39
    +2

    Я не совсем понимаю, зачем это. Вот SSR понятно - для поисковиков. А тут что? Лишняя нагрузка для сервера.

    Параграф про полезности не слишком убедил. Доступ к серверному слою - это sql запросы прямо из компонента? А код для редактора можно сделать отдельным подгружаемым чанком и не заморачиваться. К тому же раз мы получаем json, то всё равно на клиенте потребуется js для рендеринга.


    1. dvenum
      18.12.2021 13:30
      -1

      Как по мне, полезно уже тем, что фронты могут какие-то вещи делать сами. Особенно в проектах, где SPA с кучей логики, а на бэке просто REST.

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


  1. MK1301
    18.12.2021 20:01

    Знаете похоже на что. Берём подарок заваорачиваем его ещё раз ещё и ещё и ещё, новый год всё таки.