Серверный рендеринг и потоковая передача UI с нуля в React с использованием Suspense
В этом посте мы с нуля реализуем server-side рендеринг (SSR) и потоковую передачу пользовательского интерфейса (UI) в React, используя express.js в качестве серверной платформы.
Одной из самых интересных функций Next.js является потоковая передача UI, которая позволяет нам отображать частичный контент наряду с мгновенной загрузкой тех частей, для которых все еще требуется получение данных непосредственно с сервера. Несмотря на простоту использования потоковой передачи в Next.js, все же неплохо бы попробовать реализовать нашу собственную версию потоковой передачи. Итак, давайте начнем.
Начальная настройка
$ mkdir react-streaming
$ cd react-streaming
$ mkdir src
$ npm init -y
$ touch src/server.js
Теперь давайте установим наши зависимости:
$ npm i --save express dotenv chalk@4.1.2
$ npm i -D @babel/core @babel/node @babel/preset-env @babel/preset-react
Конфигурация babel .babelrc
:
{
"ignore": ["node_modules"],
"presets": [
["@babel/preset-env", {
"targets": {
"node": "current"
}
}], "@babel/preset-react"
]
}
Инициализация нашего-сервера Express:
import chalk from 'chalk';
import express from 'express';
import { config as env } from 'dotenv';
// * initialization
env();
const app = express();
const port = process.env.PORT ?? 3000;
app.use(express.static('public'));
app.get('/', (_req, res) => {
res.status(200).json({ message: "Success" })
});
app.listen(port, () => {
console.log(chalk.blueBright(`Server running at http://localhost:${port}`));
});
Чтобы выполнить рендеринг нашего приложения React на стороне сервера, нам нужна функция renderToPipeableStream
из react-dom/server
. Итак, давайте установим его вместе с react
и создадим некоторые необходимые компоненты:
$ npm i react react-dom
$ mkdir src/client
$ touch src/client/App.jsx
$ touch src/client/Html.jsx
import * as React from 'react';
const App = () => {
return (<div>This is our React application</div>);
};
export default App;
import * as React from 'react';
// * initial HTML boilerplate
const Html = ({ children }) => {
return (
<html>
<head>
<meta charSet='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Document</title>
</head>
<body>
<div id='app'>{children}</div>
</body>
</html>
);
};
export default Html;
Далее мы добавим логику рендеринга на стороне сервера на наш сервер Express:
import * as React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
// * components
import Html from './client/Html';
import App from './client/App';
app.get('/', (_req, res) => {
const stream = renderToPipeableStream(
<Html>
<App />
</Html>,
{
onShellReady() {
stream.pipe(res);
},
}
);
});
Добавьте скрипт NPM для запуска сервера:
{
"scripts": {
"start:server": "npx babel-node src/server.js"
}
}
$ npm run start:server
Откройте http//:localhost:3000 в своем браузере и просмотрите исходный код страницы. Вы увидите, что ваше приложение React успешно отображается на сервере.
Теперь у нас есть приложение React, визуализируемое на стороне сервера, но у нас есть две основные проблемы:
- В нашем приложении React нет никакой интерактивности.
- Мы не осуществляем потоковую передачу компонентов, хотя используем
renderToPipeableStream
.
Давайте сначала рассмотрим проблему интерактивности.
Интерактивность
Создание приложения React, отображаемого на стороне сервера, состоит из двух шагов:
- Отобразите приложение на сервере и обслуживайте его.
- Пусть React гидратирует серверный HTML и добавит интерактивности.
Чтобы позволить React гидратировать приложение, мы используем функцию hydarteRoot
, предоставляемую response-dom/client
, чтобы React взял на себя управление после того, как сервер завершит обработку HTML.
Для достижения этой цели мы используем функцию hydrateRoot
, предоставляемую react-dom/client
, позволяющую React взять на себя управление после того, как сервер завершит обработку HTML — исходного HTML в случае потоковой передачи.
Итак, давайте создадим index.jsx
:
$ touch src/client/index.jsx
import * as React from 'react';
import { hydrateRoot } from 'react-dom/client';
// * components
import App from './App';
hydrateRoot(document.getElementById('app'), <App />);
Теперь нам нужен способ транспилировать и связывать index.jsx
, чтобы его можно было включить в HTML-код, отправляемый с сервера. Для этого используем webpack и babel-loader:
$ npm i -D webpack webpack-cli babel-loader
const path = require('path');
module.exports = {
entry: './src/client/index.jsx',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'public'),
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
],
},
resolve: {
extensions: ['.js', '.jsx'],
},
};
{
"scripts": {
"build:client": "npx webpack --mode production",
"start:server": "npx babel-node src/server.js",
"start": "npm run build:client && npm run start:server"
}
}
Затем запустите $ npm run build:client
, чтобы веб-пакет сгенерировал bundle.js
внутри public
папки.
Последний шаг по добавлению интерактивности включает в себя включение bundle.js
в HTML, сгенерированный на сервере, что довольно просто благодаря React. Просто включите файл сценария в поле bootstrapScripts
параметров функции renderToPipeableStream
.
app.get('/', (_req, res) => {
const stream = renderToPipeableStream(
<Html>
<App />
</Html>,
{
bootstrapScripts: ['/bundle.js'],
onShellReady() {
stream.pipe(res);
},
}
);
});
Проверим интерактивность:
$ touch src/client/Counter.jsx
import * as React from 'react';
const Counter = () => {
const [count, setCount] = React.useState(0);
const decrement = () => setCount(prev => prev - 1);
const increment = () => setCount(prev => prev + 1);
return (
<div className='counter' style={{ display: 'flex', gap: '0.5rem' }}>
<button type='button' onClick={decrement}>
decrement
</button>
{count}
<button type='button' onClick={increment}>
increment
</button>
</div>
);
};
export default Counter;
import * as React from 'react';
// * components
import List from './List';
import Counter from './Counter';
const App = () => {
return (
<main>
<p style={{ marginBottom: '1rem' }}>this is the app component</p>
<Counter />
<React.Suspense
fallback={
<div style={{ marginTop: '1rem' }}>Loading the List...</div>
}
>
<List />
</React.Suspense>
</main>
);
};
export default App;
Теперь запустите проект с помощью $ npm run start
и проверьте счетчик. Вы увидите, что он полностью интерактивный.
Потоковая передача
Последний шаг этого руководства — обеспечить потоковую передачу компонентов, которым необходимы данные, и отображение состояний загрузки в ожидании получения данных.
При такой настройке мы можем использовать границы React Suspense для отображения состояний загрузки с помощью резервного свойства, но проблема в том, что дочерние элементы React не могут быть промисами, а значением ожидающего запроса на выборку является Promise
. Таким образом, мы получим ошибку. Чтобы решить эту проблему, мы используем экспериментальный хук use
React для обработки обещания, который доступен только в канале выпуска React canary.
Чтобы использовать этот хук, нам сначала нужно обновить react
и react-dom
на версию canaray:
$ npm install react@canary react-dom@canary
Далее нам нужен компонент для потоковой передачи:
$ touch src/client/List.jsx
import * as React from 'react';
// * data
const list = [
{
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio',
body: 'quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto',
},
{
userId: 1,
id: 2,
title: 'qui est esse',
body: 'est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla',
},
{
userId: 1,
id: 3,
title: 'ea molestias quasi exercitationem repellat qui ipsa sit aut',
body: 'et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut',
},
];
const fetchList = () => {
return new Promise(resolve => setTimeout(() => resolve(list), 3000));
};
const List = () => {
const [items] = React.useState(React.use(fetchList()));
return (
<div
className='list'
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
{items.map(item => (
<div
key={item.id}
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
>
<h3>{item.title}</h3>
<p>{item.body}</p>
</div>
))}
</div>
);
};
export default List;
Теперь нам нужно обернуть компонент List
границей Suspense
:
import * as React from 'react';
// * components
import List from './List';
const App = () => {
return (
<main>
this is the app component
<React.Suspense fallback={<div>Loading the List...</div>}>
<List />
</React.Suspense>
</main>
);
};
export default App;
$ npm run start
Откройте http://localhost:3000, подождите 3 секунды и та-да!
Теперь у вас есть работающее приложение React, отображаемое на стороне сервера, которое поддерживает потоковую передачу пользовательского интерфейса и мгновенные состояния загрузки.