Рендеринг на стороне сервера (SSR) означает, что содержимое вашего сайта генерируется на сервере, а затем отправляется в браузер – в наши дни это достигается различными способами.

Обратите внимание

Обычно, если рендеринг происходит во время выполнения – это называется SSR. Если рендеринг происходит во время сборки – это обычно называется генерацией на стороне сервера (SSG), что, по сути, является подмножеством SSR.

Эта разница не важна для данного руководства, всё сказанное применимо как к SSR, так и к SSG.

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

Неизоморфный SSR

Вам не нужно делать ничего особенного для поддержки неизоморфного SSR/SSG.

В этом случае начальный HTML обычно генерируется отдельно с использованием какого-либо шаблонизатора, который часто работает на другом языке программирования (не JS). Клиентский код в этом случае работает только в браузере клиента и не используется никаким образом для генерации ответа сервера.

Этот подход работает для effector, как и для любого другого JavaScript-кода. Любое SPA-приложение, по сути, является крайним случаем этого, так как его HTML-шаблон не содержит никакого контента, кроме ссылки <script src="my-app.js" />.

Примечание

Если у вас неизоморфный SSR – просто используйте effector так же, как и для SPA-приложения.

Изоморфный SSR

Когда у вас изоморфное SSR-приложение, большая часть клиентского кода используется совместно с серверным кодом и используется для генерации HTML-ответа.

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

Отсюда и название – несмотря на то, что код собирается и выполняется в разных средах, его вывод остается (в основном) одинаковым при одинаковых входных данных.

Существует множество различных фреймворков, построенных на этом подходе – например, Next.js, Remix.run, Razzle.js, Nuxt.js, Astro и т.д.

Next.js

Next.js выполняет SSR/SSG особым образом, что требует некоторой кастомной обработки на стороне effector.

Это делается с помощью специального пакета @effector/next – используйте его, если хотите использовать effector с Next.js.

В этом руководстве мы не будем фокусироваться на каком-либо конкретном фреймворке или реализации сервера – эти детали будут абстрагированы.

Sid (Стабильные Идентификаторы)

Для обработки изоморфного SSR с effector нам нужен надежный способ сериализации состояния, чтобы передать его по сети. Для этого нам нужно иметь стабильные идентификаторы (сиды) для каждого стора в нашем приложении.

Обратите внимание

Подробное объяснение о sid можно найти здесь.

Чтобы добавить sid’ы – просто используйте один из плагинов effector.

Общий код приложения

Основная особенность изоморфного SSR – один и тот же код используется как для серверного, так и для клиентского приложения.

Для примера мы будем использовать очень простое React-приложение счетчик – весь код будет содержаться в одном модуле:

// app.tsx
import React from "react";
import { createEvent, createStore, createEffect, sample, combine } from "effector";
import { useUnit } from "effector-react";

// модель
export const appStarted = createEvent();
export const $pathname = createStore<string | null>(null);

const $counter = createStore<number | null>(null);

const fetchUserCounterFx = createEffect(async () => {
  await sleep(100); // в реальной жизни это был бы какой-то API-запрос

  return Math.floor(Math.random() * 100);
});

const buttonClicked = createEvent();
const saveUserCounterFx = createEffect(async (count: number) => {
  await sleep(100); // в реальной жизни это был бы какой-то API-запрос
});

sample({
  clock: appStarted,
  source: $counter,
  filter: (count) => count === null, // если счетчик уже загружен – не загружать его снова
  target: fetchUserCounterFx,
});

sample({
  clock: fetchUserCounterFx.doneData,
  target: $counter,
});

sample({
  clock: buttonClicked,
  source: $counter,
  fn: (count) => count + 1,
  target: [$counter, saveUserCounterFx],
});

const $countUpdatePending = combine(
  [fetchUserCounterFx.pending, saveUserCounterFx.pending],
  (updates) => updates.some((upd) => upd === true),
);

const $isClient = createStore(typeof document !== "undefined", {
  /**
   * Здесь мы явно указываем effector, что это стор, которое зависит от окружения,
   * никогда не должно включаться в сериализацию,
   * так как оно должно всегда вычисляться на основе текущего окружения.
   *
   * Это не обязательно, так как в сериализацию включается только разница изменений состояния,
   * и этот стор не будет изменяться.
   *
   * Но всё же хорошо добавить эту настройку – чтобы подчеркнуть намерение.
   */
  serialize: "ignore",
});

const notifyFx = createEffect((message: string) => {
  alert(message);
});

sample({
  clock: [
    saveUserCounterFx.done.map(() => "Обновление счетчика успешно сохранено"),
    saveUserCounterFx.fail.map(() => "Не удалось сохранить обновление счетчика :("),
  ],
  // Совершенно нормально иметь некоторые ветвления в логике приложения в зависимости от текущего окружения.
  //
  // Здесь мы хотим вызвать уведомление только на клиенте.
  filter: $isClient,
  target: notifyFx,
});

// UI
export function App() {
  const clickButton = useUnit(buttonClicked);
  const { count, updatePending } = useUnit({
    count: $counter,
    updatePending: $countUpdatePending,
  });

  return (
    <div>
      <h1>Приложение-счетчик</h1>
      <h2>
        {updatePending ? "Счетчик обновляется" : `Текущее значение: ${count ?? "неизвестно"}`}
      </h2>
      <button onClick={() => clickButton()}>Обновить счетчик</button>
    </div>
  );
}

Это код нашего приложения, который будет использоваться как для рендеринга на стороне сервера, так и для обработки нужд клиента.

Примечание

Обратите внимание, что важно, чтобы все юниты effector – сторы, события и т.д. – были “привязаны” к React-компоненту через хук useUnit.

Вы можете использовать официальный eslint-плагин effector для проверки этого и следования другим лучшим практикам – посетите сайт eslint.effector.dev.

Точка входа сервера

Путь <App /> к браузерам клиентов начинается на сервере. Для этого нам нужно создать отдельную точку входа для специфического серверного кода, который также будет обрабатывать рендеринг на стороне сервера.

В этом примере мы не будем углубляться в различные возможные реализации серверов – вместо этого мы сосредоточимся на самом обработчике запросов.

Обратите внимание

Помимо базовых нужд SSR, таких как вычисление конечного состояния приложения и его сериализация, effector также обрабатывает изоляцию данных пользователей между запросами.

Это очень важная функция, так как серверы на Node.js обычно обрабатывают более одного пользовательского запроса одновременно.

Поскольку платформы на основе JS, включая Node.js, обычно имеют один “главный” поток – все логические вычисления происходят в одном контексте, с одной и той же доступной памятью. Таким образом, если состояние не изолировано должным образом, один пользователь может получить данные, подготовленные для другого пользователя, что крайне нежелательно.

effector автоматически обрабатывает эту проблему внутри функции fork. Подробнее читайте в соответствующей документации.

Это код для обработчика запросов сервера, который содержит всё специфичное для сервера, что нужно сделать. Обратите внимание, что для значимых частей нашего приложения мы всё ещё используем “общий” код app.tsx.

// server.tsx
import { renderToString } from "react-dom/server";
import { Provider } from "effector-react";
import { fork, allSettled, serialize } from "effector";

import { appStarted, App, $pathname } from "./app";

export async function handleRequest(req) {
  // 1. Создаем отдельный экземпляр состояния effector – специальный объект `Scope`.
  const scope = fork({
    values: [
      // некоторые части состояния приложения могут быть сразу установлены в нужные значения,
      // до начала любых вычислений.
      [$pathname, req.pathname],
    ],
  });

  // 2. Запускаем логику приложения – все вычисления будут выполнены в соответствии с логикой модели,
  // а также любые необходимые эффекты.
  await allSettled(appStarted, {
    scope,
  });

  // 3. Сериализуем вычисленное состояние, чтобы его можно было передать по сети.
  const storesValues = serialize(scope);

  // 4. Рендерим приложение – также в сериализуемую версию.
  const app = renderToString(
    // Используя Provider с scope, мы указываем <App />, какое состояние сторов использовать.
    <Provider value={scope}>
      <App />
    </Provider>,
  );

  // 5. Подготавливаем сериализованный HTML-ответ.
  //
  // Это граница сериализации (или сети).
  // Точка, в которой всё состояние преобразуется в строку для отправки по сети.
  //
  // Состояние effector сохраняется в виде `<script>`, который установит состояние в глобальный объект.
  // Состояние `react` сохраняется как часть DOM-дерева.
  return `
    <html>
      <head>
        <script>
          self._SERVER_STATE_ = ${JSON.stringify(storesValues)}
        </script>
        <link rel="stylesheet" href="styles.css" />
        <script defer src="app.js" />
      </head>
      <body>
        <div id="app">
          ${app}
        </div>
      </body>
    </html>
  `;
}

☝️ В этом коде мы создали HTML-строку, которую пользователь получит по сети и которая содержит сериализованное состояние всего приложения.

Точка входа клиента

Когда сгенерированная HTML-строка достигает браузера клиента, обрабатывается парсером и все необходимые ресурсы загружены – наш код приложения начинает работать на клиенте.

На этом этапе <App /> должен восстановить своё предыдущее состояние (которое было вычислено на сервере), чтобы не начинать с нуля, а продолжить с того же места, где работа остановилась на сервере.

Процесс восстановления состояния сервера на клиенте обычно называется гидрацией, и это то, что должна делать точка входа клиента:

// client.tsx
import React from "react";
import { hydrateRoot } from "react-dom/client";
import { fork, allSettled } from "effector";
import { Provider } from "effector-react";

import { App, appStarted } from "./app";

/**
 * 1. Находим, где сохранено состояние сервера, и извлекаем его.
 *
 * Смотрите код обработчика сервера, чтобы узнать, где оно было сохранено в HTML.
 */
const effectorState = globalThis._SERVER_STATE_;
const reactRoot = document.querySelector("#app");

/**
 * 2. Инициализируем клиентский scope effector с вычисленными на сервере значениями.
 */
const clientScope = fork({
  values: effectorState,
});

/**
 * 3. "Гидрируем" состояние React в DOM-дереве.
 */
hydrateRoot(
  reactRoot,
  <Provider value={clientScope}>
    <App />
  </Provider>,
);

/**
 * 4. Вызываем то же стартовое событие на клиенте.
 *
 * Это необязательно и зависит от того, как организована логика вашего приложения.
 */
allSettled(appStarted, { scope: clientScope });

☝️ На этом этапе приложение готово к использованию!

Итог

  1. Вам не нужно делать ничего особенного для неизоморфного SSR, все шаблоны, как в SPA, будут работать.
  2. Изоморфный SSR требует небольшой специальной подготовки – вам понадобятся sid для сторов.
  3. Общий код изоморфного SSR-приложения обрабатывает все значимые части – как должен выглядеть UI, как должно вычисляться состояние, когда и какие эффекты должны выполняться.
  4. Серверный код вычисляет и сериализует всё состояние приложения в HTML-строку.
  5. Клиентский код извлекает это состояние и использует его для “гидрации” приложения на клиенте.
Перевод поддерживается сообществом

Документация на английском языке - самая актуальная, поскольку её пишет и обновляет команда effector. Перевод документации на другие языки осуществляется сообществом по мере наличия сил и желания.

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

Соавторы