Собираем свою библиотеку для SSR на React. Роутинг
Привет! Меня зовут Сергей, я занимаюсь фронтендом в KTS.
В прошлой статье мы создали библиотеку, которая позволяет запускать сервер для рендеринга React-приложения, работает в dev-режиме, а конфиги инкапсулированы внутри самой библиотеки, что делает ее простой в использовании.
Следующим шагом нужно доработать самое важное и интересное — механизм роутинга и получения данных на сервере и прокидывания их на фронт. Как и в первой части, будем ориентироваться на практики, применяемые в популярных фреймворках, но с некоторыми изменениями.
Принципы, которых будем придерживаться при разработке:
Удобное использование библиотеки
Максимальная приближенность используемых инструментов к привычным, используемым в стандартных React-приложениях с рендерингом на клиенте
Минимальное количество «контрактов» в коде. Наша задача — сделать так, чтобы пользователи библиотеки могли с ходу понимать, что и как происходит, а не часами изучать документацию и требуемую архитектуру приложения
План статьи:
Поехали!
Контекст приложения
Пожалуй, первое, что нам понадобится — сделать контекст для всего приложения. Практически в любом приложении есть какие-то глобальные нужные для работы сторы. В 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,
};
}
}
В итоге в ответе сервера видим нужные данные:
Целиком схему можно представить так:
Вспомогательная библиотека
Можно все оставить так. Но в будущем мы будем добавлять новые возможности в 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;
};
При серверном рендеринге данные успешно рендерятся на странице:
Схема этого этапа:
Навигация между страницами
Теперь, когда при первом рендеринге страницы мы видим данные, полученные на сервере, нужно добавить ту же загрузку при переходе между страницами уже на клиенте.
Принцип довольно прост и описан в том же 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 — это компонент, который будет подсвечивать загрузку страницы.
Отлично, теперь при смене страницы мы будем дожидаться загрузки данных и только потом рендерить новую страницу:
Схема:
Заключение
В этой статье мы добавили функционал роутинга и загрузки данных для библиотеки серверного рендеринга на React, которую писали в прошлой статье.
В процессе разработки мы постарались использовать только привычные инструменты, чтобы минимизировать число контрактов в коде и специфики «фреймворка». Конечно, этот пример не production-ready. На нем мы рассмотрели принципы серверного рендеринга, роутинга на сервере и клиенте, и загрузки данных, а также попробовали завернуть все это во «фреймворк» так, чтобы разработчики могли запускать сервер одной командой.
Надеюсь, было интересно и полезно. Весь код доступен на github.