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

    На этом можно было бы закончить, но возможно вы пойдете дальше в попытка выжать максимально производительность и улучшить некоторые SEO метрики. Например, улучшить можно метрику Time To First Byte (TTFB)- когда сервер может отправлять разметку страницы чанками по мере их вычисления, а не дожидаясь, пока она будет вычислена полностью. Для этого вы начнете смотреть в сторону использования 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, или отказаться от полного рендеринга дерева и смотреть только на первый уровень дерева, не обращая внимание на вложенные компоненты, так сказать shallow рендиринг
    • обдумать систему кеширования, которая могла бы иногда пропускать первый обход по реактовскому дереву

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

    Мне кажется в этом случае, для тех кто знаком, как для SSR извлекать данные для рендеринга (а если кто не знает — то вот тут отличная статья на эту тему), мы поможет пойти по такому же пути извлечении метатегов для страницы.

    Общая концепция такова — у нас есть конфигурационный файл роутеров — это обычная JS структура, которая представляет собой массив объектов, каждый из которых содержит несколько полей типо component, path. На базе url реквеста мы по конфигурационному файлу находим нужный нам роутер и компонент ассоциированный с ним. Для этих компонентов определяем набор статичных методов такие как loadData и, например, для наших метатегов еще и createMetatags.

    Таким образом сам компонент страницы у нас станет таким:

    class ProductPage extends React.Component {
       static createMetatags(store, request){
           const item = selectItem(store, request.params.product_id);
           return []
               .concat({property: 'og:description', content: item.desc})
               .concat({property: 'og:title', content: item.title})
       }
       static loadData(store, request){
           // extract external data for SSR and return Promise
       }
       // the rest of component
    }

    Мы тут определили статичный метод 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>'));​
    });

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

    Подробнее
    Реклама

    Комментарии 4

      0

      А чем пользуется facebook? Как то странно, что react достаточно давно в строю, а библиотек от производителя нет

        0
        Добрый день, ставил цель если кто-то по каким-то причинам стал сам писать с нуля SSR для себя, а так можно попытаться пользоваться любым фреймворком, например NextJS и похожее.
        По поводу фейсбука, вероятно они используют GraphQL как источник получения данных и там есть целая платформа в виде Apollo
        Apollo имеет свой механизм извлечения данных и заодно облеченный механизм рендеринга дерева для извлечения метатегов (но тут надо обратить внимание, что рендерит он только первый уровень (слой) дерева и никие другие вложенные компоненты не будут включены). Вот как раз код для этого случая приведен тут:
        // ....
        import { getDataFromTree } from 'react-apollo';
        
        const helmetContext = {};
        
        const app = (
          <HelmetProvider context={helmetContext}>
            <App/>
          </HelmetProvider>
        );
        
        await getDataFromTree(app);
        
        const [header, footer] = template({
          helmet: helmetContext.helmet,
        });
        
        res.status(200);
        res.write(header);
        renderToNodeStream(app)
          .pipe(
            through(
              function write(data) {
                this.queue(data);
              },
              function end() {
                this.queue(footer);
                this.queue(null);
              }
            )
          )
          .pipe(res);

        Т.е. внутри этого метода getDataFromTree происходит и частичный рендеринг дерева ну и плюс заодно можно поизвлекать теги их Helmet.
          0
          Спасибо
            +1

            Только Facebook скорее всего использует Relay, а не Apollo.

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

        Самое читаемое