ReactJS, Server Side rendering и некоторые тонкости обработки метатегов страницы

от автора

Одной из проблем, которую придется решать при написании Server Side rendering приложения — это работа с метатегами, которые должны быть у каждой страницы, которые помогают при индексации их поисковыми системами.
Начиная гуглить, первое решение, к которому приведут скорее всего к React Helmet
Одно из преимуществ, что библиотеку в некотором роде можно считать изоморфной и может прекрасно работать как на стороне клиента, так и на стороне сервера.

class Page extends Component {    render() {        return (            <div>                <Helmet>                    <title>Turbo Todo</title>                    <meta name="theme-color" content="#008f68" />                </Helmet>                {/* ... */}            </div>        );    } } 

На сервере роутер тогда будет выглядеть так:

app.get('/*', (req, res) => {   const html = renderToString(<App />);   const helmet = Helmet.renderStatic();   res.send(`      <!doctype html>      <html ${helmet.htmlAttributes.toString()}>      <head>        ${helmet.title.toString()}        ${helmet.meta.toString()}      </head>      <body ${helmet.bodyAttributes.toString()}>        <div id="app">${html}</div>      </body>      </html>   `); });

Оба приведенных сниппета полностью корректны и работоспособны, но есть одно но, приведенный выше код для сервера полностью синхронен и поэтому полностью безопасен, но стоит ему стать асинхронным, как он сразу становится не верным:

app.get('/*', async (req, res) => {    // ....    await anyAsyncAction();    //....    const helmet = Helmet.renderStatic();    // ... }); 

Проблема тут в первую очередь в самой библиотеке React Helmet и в частно в том, что она собирает все теги внутри React Tree и складывает его фактически в глобальную переменную, а так как код стал асинхронным, код может миксовать одновременно обрабатываемые реквесты пользователей.
Хорошая новость тут, что на базе этой библиотеки был сделан форк и сейчас лучше отдать предпочтение react-helmet-async библиотеке. Основая парадигма в ней, что в данном случае контекст react-helmet  будет изолирован в рамках одного реквеста за счет оборачивании React Tree приложения в HelmetProvider:

 import { Helmet, HelmetProvider } from 'react-helmet-async';  app.get('/*', async (req, res) => {​    // ... code may content any async actions    const helmetContext = {};    const app = (        <HelmetProvider context={helmetContext}>            <App/>        </HelmetProvider>    );    // ...code may content any async actions    const html = renderToString(app);    const { helmet } = helmetContext;    // ...code may content any async actions }); 

На этом можно было бы заокнчить, но возможно вы пойдете дальше в попытка выжать максимально производительности и улучшить некоторые метрики. Например, улучшить можно метрику Time To First Byte — когда сервер может отправлять разметку страницу чанками по мере их вычисления, а не дожидаясь, пока вся разметки страницы будет вычислена. Для этого вы начнете смотреть в сторону использования renderToNodeStream вместо renderToString.
Тут мы снова столкнулись с небольшой проблемой. Чтобы получить все метатеги, которые необходимо странице, мы обязательно должны пройтись по всему дереву реакт приложения, но проблема в том, метатеги должны быть отправлены раньше момента, когда мы начинаем уже стримить контент с использованием renderToNodeStream. Фактически нам нужно тогда вычислять React Tree дважды и выглядит примерно это так:

app.get('/*', async (req, res) => {​    const helmetContext = {};    let app = (        <HelmetProvider context={helmetContext}>            <App/>        </HelmetProvider>    );     // do a first pass render so that react-helmet-async    // can see what meta tags to render    ReactDOMServer.renderToString(app);    const { helmet } = helmetContext;     response.write(`        <html>        <head>            ${helmet.title.toString()}​            ${helmet.meta.toString()}        </head>        <body>    `);     const stream = ReactDOMServer.renderToNodeStream(app);       stream.pipe(response, { end: false });    stream.on('end', () => response.end('</body></html>')); }); 

С таким подходом становится под большим вопросом в принципе необходимость такой оптимизации и наверное вряд ли мы улучшим метрику TTFB, которой хотим добиться.
Тут конечно мы можем немного поиграть в оптимизацию и есть несколько вариантов
— вместо renderToString использовать renderToStaticMarkup, что наверное в той или иной мере поможет выиграть какое-то время
— вместо использования рендереров, предлагаемые реактом с коробки, придумать свою облегченную версию прохода по реактовскому дереву, например на базе библиотеки react-tree-walker
— обдумать систему кеширования, которая могла бы иногда пропускать первый обход по реактовскому дереву
Но в любом случае, все описанное звучит чересчур мудреным и ставится в принципе под сомнение это гонка за эффективностью, когда за пару миллисекунд выстраивается какая-то ненормально сложная архитектура.
Мне кажется в этом случае, для тех кто знаком, как для SSR извлекать данные для рендеринга (а если кто не знает — то вот тут мне кажется отличная статья на эту тему), мы поможет пойти по такому же пути извлечении метатегов для страницы.
Общая концепция такова — у нас есть конфигурационный файл роутеров — это обычный JS структура представляет собой массив объектов, каждый из которых содержит несколько полей типо component, path. На базе url реквеста мы по конфигурационному файлу находим нужный нам ройтер и компонент ассоциированный с ним. Для этих компонентов определить набор статичных методов таким как loadData и например для наших метатегов еще createMetatags. Тогда наш код будет вот таким:

app.get('/*', async (req, res) => {​​    const store = createStore();    const matchedRoutes = matchRoutes(routes, request.path);     // load app state    await Promise.all(        matchedRoutes.reduce((promises, { route }) => {            return route.component.loadData ? promises.concat(route.component.loadData(store, req)) : promises;        }, [])    );       // to get  metatags    const metaTags = matchedRoutes.reduce((tags, {route}) => {        return route.component.createMetatags ? tags.concat(route.component.createMetatags(store, req)): tags    });     res.write(`​      <html>​      <head>​          ${ReactDOMServer.renderToString(() => metaTags.map(tag => <meta {...tag}/>) )}​​      </head>​      <body>​  `);​   const stream = ReactDOMServer.renderToNodeStream(app);​  stream.pipe(response, { end: false });​  stream.on('end', () => response.end('</body></html>'));​ });

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

ссылка на оригинал статьи https://habr.com/ru/post/484456/


Комментарии

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

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