Собираем свою библиотеку для 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<string, any>; }

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

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

Мы уже экспортировали 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') { %>    <script id="context">      window.SERVER_CONTEXT = <%- context %>;      window.INITIAL_LOAD = true;    </script> <% } %>

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

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

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

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

// index  hydrate(  <BrowserRouter>    <App appContext={store} />  </BrowserRouter>,  root );  // App  export type SSRAppRoot<T> = React.ComponentType<T> & {  createContext: (    initialData?: Record<string, any>  ) => Promise<AppContextType> | AppContextType; };  const App: SSRAppRoot<Props> = ({ appContext }: Props) => {  return (   <AppContext.Provider value={appContext}>      // тут наше приложение    </AppContext.Provider>  ); };

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

const view = <App appContext={appUserContext} />;

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

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,    };  } }

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

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

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

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

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

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

// Обертка с контекстом const SSRAppWrapper: React.FC<Props> = ({ appContext, children }: Props) => {  return (    <AppContext.Provider value={appContext}>{children}</AppContext.Provider>  ); };  // функция для рендера export const renderApp = (App: SSRAppRoot<any>, prepare?: () => void) => {  loadableReady(async () => {    const root = document.getElementById('app');     const store = await App.createContext(      (window as any).SERVER_CONTEXT.appContextSerialized    );     prepare?.(); // на всякий случай, чтобы можно было сделать дополнительную логику перед hydrate     hydrate(      <BrowserRouter>        <App appContext={store} />      </BrowserRouter>,      root    );  }); };

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

// index import { renderApp } from "@kts/ssr-utils";  import App from "./App";  renderApp(App);   // App const App: SSRAppRoot<Props> = ({ appContext }: Props) => {  return (    <SSRAppWrapper      appContext={appContext}    >      // Наше приложение    </SSRAppWrapper>  ); };

Роутинг

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

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

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

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

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

  • Main — главная

  • About — страница «о проекте»

  • About/id — страница с параметром

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

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 = (  <StaticRouter location={req.url} context={routerContext}>    <App appContext={appUserContext} />  </StaticRouter> );

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

const App: SSRAppRoot<Props> = ({ appContext }: Props) => {  return (    <SSRAppWrapper      appContext={appContext}    >      <Switch>        {routes.map((route, i) => (          <Route            path={route.path as string}            component={route.component}            key={route.key || i}          />        ))}      </Switch>    </SSRAppWrapper>  ); };

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

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

В 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 = (  <StaticRouter location={req.url} context={routerContext}>    <App serverContext={context} appContext={appUserContext} />  </StaticRouter> );

Обратите внимание, что данные мы передали в новый пропс у 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<string, any>) => void; };  export const [  PageDataContext,  usePageDataContext, ] = createContext<PageDataContextType>();

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

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

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

type Props = { route: RouteConfig; path: string };  const SSRRoute: React.FC<Props> = ({ route, path }: Props) => {   // Забираем данные из контекста  const pageData = usePageDataContext().pageData[route.path as string];   const Component = route.component as any;     return (    <Route      path={path}      exact={route.exact}      strict={route.strict} // Подмешиваем данные в пропсы компонента      render={(p) => <Component route={route} pageData={pageData} {...p} />}    />  ); };

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

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

const App: SSRAppRoot<Props> = ({ serverContext, appContext }: Props) => {  return (    <SSRAppWrapper      routes={routes as RouteConfig[]}      serverContext={serverContext}      appContext={appContext}    >      <Switch>        {routes.map((route, i) => (          <SSRRoute            path={route.path as string}            route={route}            key={route.key || i}          />        ))}      </Switch>    </SSRAppWrapper>  ); };

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

type Props = { pageData: any };  const About: SSRPage<Props> = (props: Props) => {  const { id } = useParams<{ id: string }>();   return (    <div>      <p>        About with param {id} {props.pageData.login}      </p>      <Link to="/about">About</Link>    </div>  ); };  About.loadData = async () => {  const { data } = await axios.get("https://api.github.com/users/NapalmDeath");  // мб какие-то манипуляции с данными   return data; };

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

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

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

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

Принцип довольно прост и описан в том же 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 | null>(    location  );  const [prevLocation, setPrevLocation] = useState<Location | null>(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<Props> = ({  routes,  serverContext,  appContext,  children, }: Props) => {  const [routerValue, pageDataValue, isLoading] = usePageLoader(    routes,    appContext,    serverContext  );   return (    <__RouterContext.Provider value={routerValue}>      <PageDataContext.Provider value={pageDataValue}>        <AppContext.Provider value={appContext}>          {isLoading && <TopBarProgress />}          {children}        </AppContext.Provider>      </PageDataContext.Provider>    </__RouterContext.Provider>  ); };

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

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

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

Схема:

Заключение

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

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

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


ссылка на оригинал статьи https://habr.com/ru/company/kts/blog/598571/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *