Мотивация

Привет, меня зовут Сергей и я фронтендер в KTS.

Мы делаем проекты на React и на некоторых проектах важно использовать server side rendering (SSR). Не стану тут рассуждать, нужен ли SSR для всех проектов или только для тех, которые индексируются поисковиками, но, так или иначе, многие существующие проекты рендерят контент на сервере. Это часто усложняет разработку и накладывает ограничения на архитектуру приложения. Наиболее популярные сейчас React-ориентированные фреймворки для SSR и SCG (static content generation) - это NextJS и Gatsby. Фреймворки решают свою задачу хорошо и мы тоже их применяем. Но в то же время любой фреймворк диктует архитектуру приложения, а это приводит к проблемам в больших приложениях. Например, Next ориентируется на структуру файлов в проекте для определения страниц. Поэтому иногда мы делаем свою сборку с SSR, пишем сервер для рендеринга на node и тд и тп.

Тема эта обширная и интересная, потому что надо интегрировать между собой много инструментов, которые мы используем в стандартном SPA, заставить их работать в режиме серверного рендеринга и при этом не потерять гибкость и удобство разработки.

И, исходя из опыта, я решил сделать библиотеку для серверного рендеринга на React. Зачем? Было интересно сделать инструмент для других разработчиков, попутно разобравшись в принципах разработки подобных проектов. Основные шаги я описал в этой статье (не претендую на оптимальность решений). Код библиотеку доступен на гитхабе.

Постановка задачи

Что ж, для начала нужно определить, что, собственно, хочется получить. Во-первых, хочется, чтобы библиотеку было легко использовать прямо со старта. Ну, как в Next.js, скрипты по типу dev, build и тд, которые запускают дев-сервер или собирают проект для продакшна, не требуя сложного конфигурирования. Во-вторых, хотелось бы сделать так, чтобы это была скорее библиотека, а не фреймворк, то есть чтобы всяких контрактов в коде было по минимуму. Условно, хочется, чтобы любой разработчик мог “пересесть” на библиотеку не думая, что надо переписать архитектуру проекта и использовать другие инструменты для разработки. Забегая вперед, скажу, что все-таки контракты в коде неизбежно возникают.

Я попытался декомпозировать работу по этапам, которые нужно реализовать:

  1. Сборка. Чтобы вообще хоть что-то работало.

  2. Серверный рендеринг. Добавить в библиотеку сервер, который будет рендерить приложение.

  3. Код-сплиттинг. Чтобы минимизировать размеры бандлов.

  4. Dev server. Чтобы можно было удобно разрабатывать проект на базе библиотеки.

  5. Hot-reload. Добавить поддержку обновления модулей на лету в dev server.

  6. Роутинг и загрузка данных.

Сетап

Ну а начал я с сетапа, конечно же. Мне понадобится как минимум 2 проекта: сама библиотека и проект для тестирования. Библиотеку будем собирать в npm-пакет, а затем подключать в основной проект в качестве зависимости. Организовать это можно разными способами, например, использовать npm link или публиковать пакет в локальный npm-репозиторий. Но эти варианты неудобны, потому что хочется менять код библиотеки и тут же видеть изменения в тестовом проекте, а не публиковать еще куда-то предварительно. А npm link, к сожалению, не подходит вообще. По сути он всего лишь делает симлинк на библиотеку в node_modules тестового проекта. В итоге получится такая структура:

/test_app
|---node_modules/
|---my-super-lib
----|---node_modules/

Проблема в том, что библиотека должна как бы запускать тестовый проект (помните скрипт dev, как у Next.js?). А чтобы запустить, нужны зависимости библиотеки, причем нужны они в node_modules тестового проекта. А вот npm link транзитивно зависимости из поля dependencies в package.json не устанавливает, как это делает установка через npm install (чтобы node_modules весила больше, чем черная дыра).

Поэтому я решил использовать старые добрые yarn workspaces, которые устанавливают общие зависимости в одну папку node_modules и также делают симлинки на пакеты:

Кстати, если вам интересно, как организовать несколько проектов в монорепозиторий – читайте нашу Как мы сетапили монорепозиторий с SSR и SPA для Otus.ru.

Отлично, теперь-то уже можно писать код? Почти.

Базовая сборка

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

Писать библиотеку (как и вообще любые стандартные проекты) будем на typescript. Для сборки библиотеки нам достаточно только транспилировать ts-файлы: 

"lib:dev": "rm -rf build && tsc --project tsconfig.json --watch"

Watch-режим позволит нам изменять код библиотеки и тут же использовать обновленную версию в тестовом проекте.

Для получения бандла тестового проекта будем использовать webpack, как самый распространенный бандлер. Тем более, что у него есть простое api для использования из кода.

Базовая схема работы нашей утилиты довольно простая:

Отлично, а как саму утилиту-то получить? В package.json есть опция bin, которая как раз позволяет запускать исполняемые скрипты.

Нужно собрать нашу библиотечку (транспилировать ts, как писал выше) и в bin прописать нужный js-файл из сборки, который будет отвечать за cli. Структура файлов библиотеки изображена на картинке.

В package.json в поле bin указан скрипт ./bin/ssr-server.js, а он в свою очередь просто импортирует нужную функцию из сборки и выполняет ее.

#!/usr/bin/env node

const { cli } = require('../build/src/index');

cli();

Ну а теперь-то можно перейти к коду? ДА!

Нам нужно, чтобы функция run собирала конфиг и потом запускала webpack с этим конфигом. В самом конфиге будем использовать те опции, которые обычно используются у нас в компании. В конце концов цель – чтобы можно было легко подключить библиотеку и ничего не настраивать. Но вот как минимум ЧТО собирать и КУДА складывать – определить нужно. И тут начинается первый контракт в коде. Обычно в наших проектах есть папка src с файлом index.tsx (входная точка клиента) и App.tsx (рутовый компонент), а собираем все в папку dist. Поэтому будем ожидать, что в клиентском коде будет src с указанными файлами. В принципе, это можно было бы конфигурировать, но кажется, это не так принципиально. Значит, берем process.cwd(), чтобы определить контекст запуска и фиксируем пути src и dist.

// ssr-lib/src/parseOptions.ts
export type WebpackBuildConfigOptionsType = {
 srcPath: string;
 buildPath: string;
 rootPath: string;
 isProduction?: boolean;
 devServer?: boolean;
};

export const parseOptions = (): WebpackBuildConfigOptionsType => {
 const rootPath = process.cwd(); // Определяем контекст запуска

 const srcPath = path.join(rootPath, SRC_DIRNAME);
 const buildPath = path.join(rootPath, BUILD_DIRNAME);

 return {
   srcPath,
   buildPath,
   rootPath,
 };
};

На следующем этапе нужно сформировать webpack-конфиг. Сразу забегая вперед, вспомним, что надо не просто собирать проект, а еще сделать серверный рендеринг, то есть собирать не только клиентский бандл, но еще и серверный. Поэтому сборки на самом деле нужно две и для каждой свой конфиг. Благо они мало чем различаются. По сути различия сводятся к следующим:

  1. В серверном бандле не нужно создавать файлы со стилями / картинками и тд, они должны быть только в клиентском бандле.

  2. В серверный бандл не нужно собирать зависимости (у нас ведь будет node_modules на сервере, зависимости серверный код будет брать оттуда). Такие зависимости можно указать в поле externals в webpack-конфиге с помощью замечательных плагинов типа webpack-node-externals.

  3. Поле target будет отличаться. В серверном бандле – node, в клиентском – web.

  4. В серверном бандле нужно не забыть указать, чтобы __dirname и прочие встроенные переменные работали корректно.

Вот в принципе и все. Чтобы удобно работать с этими конфигами и не собирать их по 2 раза, я решил сделать 3 функции: buildCommonConfig, buildClientConfig и buildServerConfig. Они принимают собранные ранее опции (srcPath, buildPath, ...) и формируют части конфига. Затем, клиентская и серверная часть конфигов объединяется с common с помощью webpack-merge

Вот так собирается серверный конфиг
// ssr-lib/src/webpack/server.ts
export const buildServerConfig = (options: WebpackBuildConfigOptionsType) => {
 const { srcPath, buildPath } = options; // Опции из parseOptions

 return merge(buildCommonConfig(options), {
   name: 'server',
   target: 'node',
   devtool: 'source-map',
   entry: path.resolve(srcPath, './App.tsx'),
   output: {
     path: path.join(buildPath, 'server'),
     filename: 'server.js',
library: {
 type: 'commonjs', // Дальше мы будем импортировать серверный бандл в код сервера
},
   },
   externals: [
     nodeExternals(),
     '@loadable/component', // об этом будет дальше
     ...Object.keys(packageJson.peerDependencies),
   ],
 // https://webpack.js.org/configuration/node/
 node: {
     __dirname: false,
     __filename: false,
   },
 });
};

Остальные можно посмотреть в коде на гитхабе.

Так, конфиги собрали, теперь можно запускать сборку. Для этого достаточно создать экземпляр webpack.Compiler, передав ему конфиг, и вызвать .run.

// ssr-lib/src/build.ts
export const runCompiler = (
// MultiCompiler позволит собирать 2 сборки последовательно
 compiler: webpack.Compiler | webpack.MultiCompiler,
 onSuccess: (s: MultiStats) => void = () => {}
) =>
 new Promise((resolve, reject) => {
	// В колбек прилетит статистика ассетов собранного бандла, либо ошибка
   const cb = (err?: Error, stats?: MultiStats) => {
     if (stats) {
       onSuccess(compiler);

       compiler.close((closeErr) => {
         if (closeErr) {
           return reject(closeErr);
         }

         resolve(stats);
       });
     } else {
       reject(err);
     }
   };

   compiler.run(cb);
 });

При сборке будем собирать и клиентскую и серверную часть (кстати, можно это делать параллельно с помощью parallel-webpack), ну и не забываем почистить папку перед сборкой:

// ssr-lib/src/build.ts
export async function buildProd(options: WebpackBuildConfigOptionsType) {
 rimraf.sync(options.buildPath);

 options.isProduction = true;

 return runCompiler(
   webpack([buildClientConfig(options), buildServerConfig(options)]),
   () => {
     console.log(chalk.green('built'));
   }
 );
}

Серверный рендеринг

На самом деле мы немного забежали вперед. Ведь как писать сам сервер мы еще не разобрали. Теперь самое время это сделать.

Подробно останавливаться на принципах серверного рендеринга не буду, есть много статей на эту тему. В простом варианте серверная сборка содержит корневой компонент, рендерит его в строку или в поток и возвращает в ответ на запросы с клиента, а на клиенте происходит hydrate уже отрендеренной верстки. Проблемы возникают при организации роутинга, загрузке данных и тд, но это мы рассмотрим уже в следующей статье (если вам понравится эта). Пока что достаточно сделать простой сервер, как написано выше, собирать его (ssr-lib build) и запускать по скрипту ssr-lib prod.

Схематично выглядит так:

Добавим в скрипт запуска команду prod:

.command(
 'prod',
 'start prod server',
 (args) => {
   args.option('port', {
     describe: 'server port',
     default: 3000,
   });
   args.option('host', {
     describe: 'server host',
     default: '127.0.0.1',
   });
 },
 startProdServer
)

Она будет собирать опции host и port для запуска сервера.

Сама функция startProdServer предполагает, что сборка уже есть. Пусть она создает некоторое приложение на express, раздает статику из папки с клиентским бандлом (помните, мы собрали всю статику в папку dist/client/static) и запускает сервер.

// src/startServer.tsx
export const DEFAULT_SERVER_CONFIG: ServerConfig = {
 host: '127.0.0.1',
 port: 3000,
};

const createApp = express;

export function startProdServer(
 options: WebpackBuildConfigOptionsType,
 serverConfig: ServerConfig = DEFAULT_SERVER_CONFIG
) {
 const app = createApp();

 app.use(
   '/static',
   express.static(path.join(options.buildPath, 'client', 'static'))
 );

 runServer(app, options, serverConfig);
}

Остановимся подробнее на функции runServer. Что мы хотели от нашего сервера? На запрос некоторого урла (на самом деле пока что любого, кроме /static, который мы уже обработали), нужно отрендерить наше приложение. Звучит вроде просто, только вот откуда взять само приложение? Сам компонент, который рендерить, откуда его импортировать?

Если бы писали не библиотеку, а просто сервер рядом с клиентской частью, то мы спокойно могли импортировать рутовый компонент из клиентского кода. Но тут у нас такой возможности нет, ведь у нашего сервера есть только собранная версия какого-то клиентского приложения (которое собственно собиралось нашей утилитой).

На самом деле все не так плохо. При вызове вебпака (в функции runCompiler) в коллбеке после успешного выполнения мы получаем объект stats. Там содержатся все нужные чанки, ассеты и тд. Но даже это нам не понадобится, ведь на помощь приходит замечательная библиотека @loadable/component. На самом деле она в первую очередь используется для код-сплиттинга. Библиотека позволяет динамически импортировать компоненты так, что эти компоненты собираются в отдельные чанки. Таким образом мы разбиваем код и минимизируем количество загружаемых на клиент данных. Но еще важной особенностью является то, что у библиотеки есть реализация для серверного рендеринга. Она нужна как раз для получения статистики по всем используемым в процессе рендера чанкам и ассетам. Для получения этой статистики в момент сборки используется специальный webpack-плагин. Он создает json-файлик со всеми нужными данными, включая entrypoint. Вы уже могли обратить внимание на него на скрине выше, где был показан результат сборки. Что ж, добавим плагин в наш конфиг вебпака:

new LoadablePlugin({
 filename: 'loadable-stats.json',
 writeToDisk: true,
}),

И при сборке получим файлик loadable-stats.json со следующей структурой:

Теперь остается только получить entrypoint в момент обработки запроса на сервере, отрендерить компонент и вернуть верстку.

// ssr-lib/src/startServer.tsx
import { ChunkExtractor } from '@loadable/server';

const runServer = (
 app: Express,
 { buildPath }: WebpackBuildConfigOptionsType,
 { host, port }: ServerConfig = DEFAULT_SERVER_CONFIG
) => {
 app.use(async (req: Request, res: Response) => {
   const serverExtractor = new ChunkExtractor({
     statsFile: path.resolve(buildPath, 'server', 'loadable-stats.json'),
   }); // загрузили сгенерированный в момент сборки файлик с путями чанков
   
	 const { default: App } = serverExtractor.requireEntrypoint() as any; // получили entrypoint (App.tsx)

   const appString = renderToString(<App />);

   return res.status(200).send(appString);
 });

 app.listen(port, host, () => {
   console.log(chalk.green(`Running on http://${host}:${port}/`));
 });
};

// ssr-lib-check/src/App.tsx
export default () => <div>hello world</div>;

В этом примере мы отрендерили не HTML целиком, а только 1 div, но главное, что принцип работает, компонент отрисовался на сервере и верстка вернулась в ответе.

Теперь остается возвращать все-таки HTML и добавить туда нужные ассеты. Для создания html-файла в сборке используется html-webpack-plugin, но можно и просто в App рендерить html структуру. Я решил использовать html-шаблон, потому что на проектах в KTS мы обычно делаем так, хоть и конкретно в реализации фреймворка, мне кажется, удобнее было бы сделать какой-нибудь специальный компонент (по типу _document.tsx в Next.js).

Шаблон с версткой
// ssr-lib/src/assets/index.html.ejs
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <title>App</title>
   <% if (typeof (styles) !== "undefined") { %><%- styles; %>
   <% } %>
</head>
<body>
<div id="app"><% if (typeof (app) !== "undefined") { %><%- app; %><% } %></div>
<% if (typeof (scripts) !== "undefined") { %><%- scripts; %>
<% } %>
</body>
</html>

Можно использовать любой шаблонизатор, я взял ejs. Важно, что в шаблоне заданы 3 “поля”, в которые нужно будет вставить стили, отрендеренную верстку и скрипты. Остается только рендерить этот шаблон и отдавать его клиенту в ответ на запрос. А нужные скрипты и стили для конкретного чанка получим с помощью @loadable/server:

// ssr-lib/src/startServer.tsx, функция runServer
// Получаем файл шаблона, пользователь может переопределить его в своем src.
let indexHtmlPath = path.join(srcPath, 'index.html.ejs');

if (!fs.existsSync(indexHtmlPath)) {
    // А если его нет, возьмем наш вариант
 indexHtmlPath = path.join(__dirname, 'assets', 'index.html.ejs');
}

const templateHtml = fs.readFileSync(indexHtmlPath, 'utf8');

app.use(async (req: Request, res: Response) => {
 const serverExtractor = new ChunkExtractor({
   statsFile: path.resolve(buildPath, 'server', 'loadable-stats.json'),
 });
 const { default: App } = serverExtractor.requireEntrypoint() as any;

 const clientExtractor = new ChunkExtractor({
   statsFile: path.resolve(buildPath, 'client', 'loadable-stats.json'),
 });

 const jsx = clientExtractor.collectChunks(<App />);

 const appString = renderToString(jsx);

 // Извлечем все клиентские ассеты
 const scripts = clientExtractor.getScriptTags();
 const styles = clientExtractor.getStyleTags();

    // Вставляем ассеты на нужные позиции
 const renderedHtml = ejs.render(templateHtml, {
   app: appString,
   scripts,
   styles,
 });

 return res.status(200).send(renderedHtml);
});

Важное замечание: в клиентском бандле нужно сделать hydrate того же компонента, что был отрендерен на сервере, чтобы навесить необходимые обработчики событий на уже отрендеренные компоненты, а еще не забыть loadableReady, чтобы дождаться загрузки всех нужных чанков на клиенте.

// index.ts на клиенте:
loadableReady(() => {
  const root = document.getElementById('app');
  hydrate(
     <App />,
	   root
  );
})

Теперь точно все! Запускаем и получаем полностью функционирующую страничку с версткой:

Весь процесс, который мы для этого проделали, схематично:

Теперь наша библиотека умеет собирать клиентский код и запускать сервер для рендеринга. Но для удобной разработки еще хочется сделать dev server.

DevServer

Пусть, по аналогии с Next.js у нас будет команда ssr-lib dev, которая будет запускать дев сервер.

Код команды dev в cli
const { argv } = yargs
 .command(
   'dev [port]',
   'start dev server',
   (args) => {
     args.positional('port', {
       describe: 'dev server port',
       default: 3000,
     });
   },
   startDevServer
 )

От дев режима нам нужна в первую очередь пересборка проекта при изменениях кода. То есть для серверного бандла нужно запускать вебпак не 1 раз, а в watch-режиме, чтобы он следил за изменениями и пересобирал наш сервер. Немного изменим функцию runCompiler, чтобы она поддерживала watch-режим.

Обновленный код для сборки
// ssr-lib/src/build.ts
export const runCompiler = (
 compiler: webpack.Compiler | webpack.MultiCompiler,
 onSuccess: (s: MultiStats) => void = () => {},
 watch = false
) =>
 new Promise((resolve, reject) => {
   const cb = (err?: Error, stats?: MultiStats) => {
     ...
   };

	// Все просто, вместо .run вызываем .watch
   if (watch) {
     return compiler.watch({}, cb);
   }

   compiler.run(cb);
 });

export function buildServer(
 options: WebpackBuildConfigOptionsType
) {
 const compiler = webpack(buildServerConfig(options));

 return runCompiler(
   compiler,
   () => {
     console.log(chalk.green('server built'));
   },
   !options.isProduction 
 );
}

export async function buildDev(options: WebpackBuildConfigOptionsType) {
 rimraf.sync(options.buildPath);

 options.isProduction = false;

 await buildServer(options);
}

Теперь при изменениях кода проекта серверный бандл будет пересобираться. Остается разобраться с клиентской частью.

В проектах обычно используется webpack-dev-server, он поднимает сервер, собирает бандл проекта в памяти и пересобирает при изменениях. Этот сервер раздает ассеты на клиент. Сам сервер у нас уже есть, поэтому нужно только подмешать webpack-dev-middleware, который будет отслеживать запросы на статические файлы и отдавать их:

// ssr-lib/src/index.tsx, функция cli
// Команда запуска dev-сервера
startDevServer: async ({ port }) => {
 await buildDev(options); < Собираем сервер

 await startDevServer(options, webpack(buildClientConfig(options)), { port, host: '127.0.0.1' });
},

// ssr-lib/src/startServer.tsx
// Функция запуска dev-сервера
export async function startDevServer(
 options: WebpackBuildConfigOptionsType,
 compiler: webpack.Compiler,
 serverConfig: ServerConfig = DEFAULT_SERVER_CONFIG
) {
 const app = createApp();

// Подмешиваем клиентскую сборку в devMiddleware. Статика будет раздаваться оттуда.
 const devServer = devMiddleware(compiler, {
   serverSideRender: true,
 });

 app.use(devServer);

// Дожидаемся сборки и стартуем сервер
 devServer.waitUntilValid(() => {
   runServer(app, options, serverConfig);
 });
}

Отлично, dev-middleware отслеживает клиентские ассеты и раздает их нашим же сервером. waitUntilValid гарантирует, что клиентский бандл уже собран.

В итоге в нашей папке dist при сборке в dev-режиме будет следующее:

Видно, что папка клиента пустая (ведь клиентская сборка в памяти и раздается с помощью dev-middleware), а в серверной сборке есть чанки разных страниц тестового проекта и вендорные чанки используемых библиотек.

Схема этого этапа:

Hot-reload

У нас уже почти все готово, кроме маленькой изюминки. Хочется добавить hot-reload, чтобы не надо было перезагружать страничку после изменения кода проекта. Раньше для этого использовался react-hot-loader, но не так давно появился Fast Refresh, который мы и возьмем. Для сетапа можно взять react-refresh-webpack-plugin. Нам понадобится добавить его в наш babel-loader и плагины HotModuleReplacement и ReactRefreshWebpackPlugin в клиентскую сборку.

По умолчанию этот плагин использует webpack-dev-server для интеграции HotModuleReplacement, но у нас используется свой кастомный сервер для запуска, поэтому дефолтные настройки не работают. Нужно переопределить sockIntegration в плагине. Мне показалось удобным использовать webpack-hot-middleware для этих целей.

Добавим hot-middleware в наш сервер и в клиентский бандл:

// ssr-lib/src/webpack/client.ts, функция buildClientConfig
entry: [
 indexPath,
 !options.isProduction && 'webpack-hot-middleware/client', // Добавляем клиент hot-middleware в dev-режим
].filter(Boolean),

// ssr-lib/src/startServer.tsx, функция startDevServer
const app = createApp();

const devServer = devMiddleware(compiler, {
 serverSideRender: true,
});

app.use(devServer);

app.use(hotMiddleware(compiler)); // Добавляем hot-middleware в наш сервер

devServer.waitUntilValid(() => {
 runServer(app, options, serverConfig);
});

hot-middleware добавляет на наш сервер дополнительный роут (по умолчанию это /__webpack_hmr), к которому подключается клиент и затем получает события о пересборках проекта: {"name":"client","action":"built","time":1009,"hash":"e85accb203b6092faec5",...}, позволяя перезагрузить обновленные чанки.

Теперь все. Перезапустим проект, поменяем код и проверим, что все работает:

// Запуск
[HMR] connected
// Изменяем код
[HMR] bundle rebuilding
[HMR] bundle 'client' rebuilt in 981ms
[HMR] Checking for updates on the server...
[HMR] Updated modules:
[HMR]  - ./src/MainPage.tsx
[HMR] App is up to date.

Все это позволяет изменять код и тут же видеть изменения в браузере. Ну а серверный бандл пересобирается в watch-режиме, поэтому при перезагрузке страницы у всегда будет актуальная версия.

Заключение

Думаю, на этом для первой части статьи стоит остановиться.

Мы собрали cli, который позволяет без сложных конфигураций собирать и запускать серверный рендеринг нашего тестового проекта, а также предоставляет дев-сервер с hot-reload’ом для удобной разработки.

Весь код доступен на гитхабе.

Ну а если вам понравилась статья, то в следующей части рассмотрим самое интересное – как работать с роутингом и данными, сделаем дополнительную библиотеку со вспомогательными функциями и компонентами, чтобы получился полноценный фреймворк.