Собираем свою библиотеку для SSR на React. Роутинг

Привет! Меня зовут Сергей, я занимаюсь фронтендом в KTS.

В прошлой статье мы создали библиотеку, которая позволяет запускать сервер для рендеринга React-приложения, работает в dev-режиме, а конфиги инкапсулированы внутри самой библиотеки, что делает ее простой в использовании.

Следующим шагом нужно доработать самое важное и интересное — механизм роутинга и получения данных на сервере и прокидывания их на фронт. Как и в первой части, будем ориентироваться на практики, применяемые в популярных фреймворках, но с некоторыми изменениями.

Принципы, которых будем придерживаться при разработке:

  1. Удобное использование библиотеки

  2. Максимальная приближенность используемых инструментов к привычным, используемым в стандартных React-приложениях с рендерингом на клиенте

  3. Минимальное количество «контрактов» в коде. Наша задача — сделать так, чтобы пользователи библиотеки могли с ходу понимать, что и как происходит, а не часами изучать документацию и требуемую архитектуру приложения

План статьи:

Поехали!

Контекст приложения

Пожалуй, первое, что нам понадобится — сделать контекст для всего приложения. Практически в любом приложении есть какие-то глобальные нужные для работы сторы. В NextJS есть возможность переопределить App — точку входа в приложение. Там вы можете сделать какие-то инициализации, сохранять и подмешивать данные в роуты при переходах. Хочется сделать нечто подобное.

В любом приложении на React всегда есть главный корневой компонент, который рендерит все приложение. Обычно он лежит в файле App и содержит провайдеры сторов, роутер и разные другие обертки, нужные в приложении. Чтобы не добавлять лишние сущности, можно переиспользовать такой файл, просто добавив нужный функционал и немного изменив интерфейс этого компонента.

Нам всего лишь нужно добавить некоторый глобальный контекст к компоненту App. Для этого, по аналогии с NextJS, добавим статический метод создания контекста к компоненту. Наш контекст должен быть сериализуемым, чтобы мы могли создать его на сервере, сериализовать, прокинуть на клиент и восстановить уже на клиенте. Выглядеть это будет так:

App.createContext = async (initialData) => {
	return someContext;
};

someContext — это объект, который должно быть можно сериализовать. Как правило, в такой глобальный контекст мы прокидываем стор, например, Redux. Обычно на своих проектах мы используем MobX в качестве стейт-менеджера, поэтому в своем тестовом проекте я также буду ориентироваться именно на него. Впрочем, это никак не влияет на функционал самой библиотеки — важно, что из функции createContext нужно вернуть некоторый сериализуемый объект.

Мой пример будет выглядеть так:

App.createContext = async (initialData) => {
 if (typeof window === "undefined") {
   enableStaticRendering(true); // На сервере для MobX нужно вызвать эту функцию
 }

 return new Store(initialData as StoreData);
};

В данном случае Store — это MobX-модель, которая должна уметь сериализовываться в JSON. Для этого можно добавить интерфейс 

export interface AppContextType {
 serialize(): Record;
}

и реализовать его в модели.

Отлично! Теперь остается только создать контекст на сервере и прокинуть на клиент.

Мы уже экспортировали App для нашего сервера (иначе как бы мы его отрендерили). Поэтому остается только вызвать App.createContext на сервере:

const { default: App } = serverExtractor.requireEntrypoint() as any;

const appUserContext = await App.createContext(); // Создаем контекст

const context = {
 appContextSerialized: appUserContext.serialize(), // Сериализуем его
};

const renderedHtml = ejs.render(templateHtml, {
 app: appString,
 scripts,
 styles,
 context: serializeJavascript(context), // Добавляем в рендер шаблона
});

В сам шаблон index.html.ejs добавим поле для рендера контекста:

<% if (typeof(context) !== 'undefined') { %>
   
<% } %>

Отлично! Теперь достаем контекст на клиенте перед тем, как вызвать hydrate.

const store = await App.createContext(
 (window as any).SERVER_CONTEXT.appContextSerialized
);

В функции createContext можно использовать initialData, которую мы получили с сервера.

Остается передать созданный стор в App и использовать его по своему усмотрению. Например, положить в контекст-провайдер:

// index

hydrate(
 
   
 ,
 root
);

// App

export type SSRAppRoot = React.ComponentType & {
 createContext: (
   initialData?: Record
 ) => Promise | AppContextType;
};

const App: SSRAppRoot = ({ appContext }: Props) => {
 return (
  

    // тут наше приложение

  
 );
};

И, конечно, не забываем то же самое сделать при серверном рендеринге App:

const view = ;

В качестве примера привожу код для глобального стора:

export class Store implements AppContextType {
 test = 1;

 constructor(initialData?: StoreData) {
   makeObservable(this, {
     test: observable,
     inc: action.bound,
   });

   if (initialData) {
     this.test = initialData.test;
   }
 }

 inc() {
   this.test += 1;
 }

 serialize() {
   return {
     test: this.test,
   };
 }
}

В итоге в ответе сервера видим нужные данные:

image-loader.svg

Целиком схему можно представить так:

image-loader.svg

Вспомогательная библиотека

Можно все оставить так. Но в будущем мы будем добавлять новые возможности в App, поэтому логично все вспомогательные утилиты сразу вынести в отдельную библиотеку. 

Для этого создадим еще один пакет ssr-utils и настроим сборку. Эти пункты я опущу, они довольно стандартны. Весь код доступен на github.

В ssr-utils вынесем типы и функцию для базового рендера приложения, а также компонент-обертку с контекстом:

// Обертка с контекстом
const SSRAppWrapper: React.FC = ({ appContext, children }: Props) => {
 return (
   {children}
 );
};

// функция для рендера
export const renderApp = (App: SSRAppRoot, prepare?: () => void) => {
 loadableReady(async () => {
   const root = document.getElementById('app');

   const store = await App.createContext(
     (window as any).SERVER_CONTEXT.appContextSerialized
   );

   prepare?.(); // на всякий случай, чтобы можно было сделать дополнительную логику перед hydrate

   hydrate(
     
       
     ,
     root
   );
 });
};

Используем эту библиотеку в тестовом приложении:

// index
import { renderApp } from @kts/ssr-utils";

import App from "./App";

renderApp(App);

 // App
const App: SSRAppRoot = ({ appContext }: Props) => {
 return (
   
     // Наше приложение
   
 );
};

Роутинг

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

В версиях react-router со 2-й по 5-ю существует библиотека react-router-config, которая используется как раз для схемы с серверным рендерингом в документации react-router. Обратите внимание, что в недавно вышедшей новой версии роутера необходимость в этой библиотеке отпадает. Но я буду рассматривать 5-ую версию: во-первых, она все еще актуальна для большинства проектов, а во-вторых, принципы, описанные далее, не зависят от версии пакета.

На правах рекламы скажу, что вместе с 6-ой версией react-router его создатели заопенсорсили SSR-фреймворк Remix, про который мы сделали перевод небольшого туториала в нашем блоге.

Суть: мы задаем конфиг роутов, описанных объектами, в которых перечисляем путь, компонент для рендера и вложенные роуты в таком же формате. По такому конфигу мы сможем матчить запрошенный пользователем URL и рендерить нужный компонент.

Для примера я буду использовать такую структуру страничек:

Пример конфига роутов:

export const routes: RouteConfig[] = [
 {
   path: "/about",
   component: AboutPage as any,
   routes: [
     {
       path: "/about/:id",
       component: AboutIdPage as any,
     },
   ],
 },
 {
   path: "/",
   exact: true,
   component: MainPage,
 },
];

Теперь на сервере остается матчить URL из запроса с этим конфигом и понимать, на какую страницу перешел пользователь. Для рендера нужной страницы мы просто используем StaticRouter. Он будет отрисовывать нужную страницу один раз с предположением, что URL не может меняться. На то он и называется Static:

// Генерация view для рендера на сервере
const view = (
 
   
 
);

В сам App нужно добавить отрисовку роутов, как в обычном приложении. Это удовлетворяет нашему принципу «максимальной приближенности используемых инструментов» из начала статьи.

const App: SSRAppRoot = ({ appContext }: Props) => {
 return (
   
     
       {routes.map((route, i) => (
         
       ))}
     
   
 );
};

Роутинг и данные

Теперь надо подгрузить нужные для каждого роута данные, отрендерить страницы вместе с ними и прокинуть их на фронт.

В NextJS этого можно достичь с помощью методов getInitialProps и getServerSideProps, которые должны вернуть объект, который затем будет прокинут в качестве пропсов в компонент страницы. Довольно удобный и понятный механизм, попробуем его реализовать. В NextJS есть много оптимизаций и методов под разные кейсы, например, для пререндера. Нам достаточно будет одного метода loadData. В нем будем получать сам объект матча роута, чтобы фетчить нужные данные (например, по id), глобальный контекст AppContext (вдруг нам нужно что-то из глобального стора?), ну и предыдущую версию данных этой страницы (если мы ее уже загружали, может мы захотим просто отдавать закешированные данные).

Ниже сигнатура метода loadData для компонента AboutPage:

AboutPage.loadData = async (match, ctx, pageData) => {
// в match - данные роутера
// ctx - глобальный контекст
// pageData - данные страницы

if (pageData["/about"]) {
 return pageData["/about"];
}

 return { about: "data from loadData" };
};

Обращу внимание, что при использовании NextJS у меня часто возникала проблема именно с получением глобального контекста. Существуют случаи, когда он нужен, но Next прокидывает в вышеупомянутые функции свой контекст, который содержит в основном данные роутера: URL и т.д. В моем опыте добавление своих данных в этот контекст доставляло много хлопот.

Вернемся к нашему методу. Нам нужно вызвать его на сервере только в случае, если URL запроса совпадает с URL компонента AboutPage из нашего конфига. Значит, алгоритм будет такой: пробегаем по конфигу, ищем объект роута с совпадающим URL, берем у него компонент и вызываем метод loadData, если он есть.

На практике наша ssr-lib ничего не знает о файлах внутри проекта, где она используется. Поэтому мы используем «контракт в коде» и экспортируем конфиг роутов, чтобы забрать его на сервере — например из компонента App:

const { default: App, routes } = serverExtractor.requireEntrypoint() as any;

А функцию поиска нужного роута и загрузки данных можно вынести во вспомогательную библиотеку, она еще понадобится нам на клиенте:

export const loadRoutesData = async (
 routes: RouteConfig[],
 path: string,
 appContext: AppContextType,
 pageData: PageDataType = {}
) => {
// matchRoutes предоставляет библиотека react-router-config
 const promises: any[] = matchRoutes(routes, path).map(({ route, match }) => ({
   path: route.path,
   url: match.url,
   promise: (route?.component as any)
     ?.load() // загружаем компонент, у нас LazyLoad
     .then(({ default: { type, loadData } }: any) => {
       const load = loadData || type?.loadData;
       return load ? load(match, appContext, pageData) : Promise.resolve(null); // Если есть loadData, вызываем.
     }),
 }));

 const data = await Promise.all(promises.map((p) => p.promise));

 return promises.reduce(
   (acc, next, i) => ({
     ...acc,
     [next.path]: data[i],
   }),
   {}
 ); // Возвращаем карту путь -> данные
};

Использовать функцию на сервере совсем просто:

const appUserContext = await App.createContext();

const pageData = await loadRoutesData(routes, req.path, appUserContext);

А полученные данные pageData с картой «путь» → данные будем передавать в контекст по аналогии с тем, как мы делали с глобальным контекстом:

const context = {
 pageData,
 appContextSerialized: appUserContext.serialize(),
};

const view = (
 
   
 
);

Обратите внимание, что данные мы передали в новый пропс у App — serverContext.

Для получения серверного контекста можно добавить функцию, которая будет возвращать пустой объект pageData, если контекст не передан:

export const getServerContext = (
 serverContext?: ServerContextType
): ServerContextType =>
 typeof window === 'undefined'
   ? serverContext || {
       pageData: {},
     }
   : window.SERVER_CONTEXT;

После загрузки данных мы можем переложить их в state, чтобы потом использовать в компонентах и изменять при переходах между страницами. Для этого отлично подойдет наш SSRAppWrapper. Добавим хранение данных страниц в него:

const loadedContext = getServerContext(serverContext);

const [data, setData] = useState(loadedContext.pageData);

Обратите внимание, что в pageData хранятся данные всех роутов, сматченных в процессе парсинга URL. Например, для /about/123 будет сохранены данные страниц /about и /about/123 в объекте pageData с соответствующими ключами. Поэтому pageData мы будем хранить на самом верхнем уровне в обертке SSRAppWrapper.

Для использования данных страниц по аналогии с глобальным контекстом создадим контекст с pageData:

export type PageDataContextType = {
 pageData: PageDataType;
 setPageData: (d: Record) => void;
};

export const [
 PageDataContext,
 usePageDataContext,
] = createContext();

И используем этот контекст в SSRAppWrapper.

В NextJS мы могли бы получить данные в пропсах в нужном компоненте-странице. Чтобы добиться такого поведения, мы можем сделать обертку над компонентами страниц. Она будет подтягивать нужные данные из контекста и передавать в пропсы. Или можно ее не использовать и напрямую брать данные из контекста в нужных компонентах.

Такую обертку можно использовать вместо компонента Route из react-router. Сделаем свой SSRRoute:

type Props = { route: RouteConfig; path: string };

const SSRRoute: React.FC = ({ route, path }: Props) => {
  // Забираем данные из контекста
 const pageData = usePageDataContext().pageData[route.path as string];

 const Component = route.component as any;
  
 return (
    }
   />
 );
};

Аналогичную логику можно было сделать и в HOC«е и в хуке, не принципиально.

Теперь наш App будет выглядеть так:

const App: SSRAppRoot = ({ serverContext, appContext }: Props) => {
 return (
   
     
       {routes.map((route, i) => (
         
       ))}
     
   
 );
};

На этом этапе мы уже можем протестировать приложение. В компонент AboutId, который отвечает за роут /about/: id, добавим функцию, которая будет фетчить данные, например из гитхаба:

type Props = { pageData: any };

const About: SSRPage = (props: Props) => {
 const { id } = useParams<{ id: string }>();

 return (
   
     

       About with param {id} {props.pageData.login}      

     About    
 ); }; About.loadData = async () => {  const { data } = await axios.get("https://api.github.com/users/NapalmDeath");  // мб какие-то манипуляции с данными return data; };

При серверном рендеринге данные успешно рендерятся на странице:

image-loader.svg

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

image-loader.svg

Навигация между страницами

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

Принцип довольно прост и описан в том же react-router-config. Мы будем отлавливать изменения состояния роутера и загружать данные. А пока они загружаются, показывать предыдущую страницу, то есть насильно устанавливать прошлое значение роутера. Как только данные загрузятся, можно «разблокировать» роутер и установить новую страницу.

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

export const usePageLoader = (
 routes: RouteConfig[],
 appContext: AppContextType,
 serverContext?: ServerContextType
): [RouteComponentProps, PageDataContextType, boolean] => {
 const location = useLocation(); // Получаем текущий location
 const context = useContext(__RouterContext); // получаем контекст роутера
 const loadedContext = getServerContext(serverContext);
 const [data, setData] = useState(loadedContext.pageData); // данные страницы

// Текущий и прошлый location будем хранить в стейте
 const [currentLocation, setCurrentLocation] = useState(
   location
 );
 const [prevLocation, setPrevLocation] = useState(location);
 const [isLoading, setIsLoading] = useState(false); // идет ли загрузка данных

 useEffect(() => {
	 // Если локейшн поменялся
   if (window.INITIAL_LOAD) {
		// Если это первая загрузка (мы возвращаем INITIAL_LOAD = true прямо с сервера в html), то ничего не делать, данные уже есть.
     window.INITIAL_LOAD = false;
     return;
   }

   // Сохраняем прошлый локейшн, устанавливаем текущий и флаг загрузки
   setPrevLocation(currentLocation);
   setCurrentLocation(location);
   setIsLoading(true);
   
		// Загрузка данных из ssr-utils. Ее мы уже использовали на сервере
   loadRoutesData(routes, location.pathname, appContext, data).then(
     (loadedData) => {
				// Обновляем данные страниц
       setData((s) => ({
         ...s,
         ...loadedData,
       }));

				// Сбрасываем прошлый локейшн
       setIsLoading(false);
       setPrevLocation(null);
     }
   );
 }, [location]);

	// Реальный локейшн будем брать из предыдущего либо текущего. Когда данные грузятся – будет взять prevLocation, затем, когда загрузятся – current. Смотри код выше
 const routeLocation = prevLocation || currentLocation;

 const routerValue = useMemo(
   () => ({
     ...context,
     location: routeLocation || context.location,
   }),
   [routeLocation]
 );

 const pageDataValue = useMemo(
   () => ({
     pageData: data,
     setPageData: setData,
   }),

   [routeLocation]
 );

// Возвращаем значения для роутера и данных
 return [routerValue, pageDataValue, isLoading];
};

Использовать такой хук будем в SSRAppWrapper:

const SSRAppWrapper: React.FC = ({
 routes,
 serverContext,
 appContext,
 children,
}: Props) => {
 const [routerValue, pageDataValue, isLoading] = usePageLoader(
   routes,
   appContext,
   serverContext
 );

 return (
   <__RouterContext.Provider value={routerValue}>
     
       
         {isLoading && }
         {children}
       
     
   
 );
};

Обратите внимание на __RouterContext. Это нужно, чтобы переопределить локейшн для всего роутера. На самом деле переопределить локейшн можно у компонентов Switch, но react-router позволяет рендерить роуты на любом уровне вложенности, и «добраться» до них из нашей библиотеки будет сложно: ведь мы ничего не знаем о пользовательском коде, который использует библиотеку. Максимум, что мы можем — добавлять контракты и интерфейсы взаимодействия. Поэтому очень удобным способом в данном случае будет глобальное переопределение значения в контексте роутера, которое, в свою очередь, будут использовать все Switch/Route и другие компоненты роутера на любом уровне вложенности в пользовательском коде.

TopBarProgress — это компонент, который будет подсвечивать загрузку страницы.

Отлично, теперь при смене страницы мы будем дожидаться загрузки данных и только потом рендерить новую страницу:

image-loader.svg

Схема:

image-loader.svg

Заключение

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

В процессе разработки мы постарались использовать только привычные инструменты, чтобы минимизировать число контрактов в коде и специфики «фреймворка». Конечно, этот пример не production-ready. На нем мы рассмотрели принципы серверного рендеринга, роутинга на сервере и клиенте, и загрузки данных, а также попробовали завернуть все это во «фреймворк» так, чтобы разработчики могли запускать сервер одной командой.

Надеюсь, было интересно и полезно. Весь код доступен на github.

© Habrahabr.ru