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

?v=1

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

Начиная гуглить, первое решение, к которому приведут скорее всего к React Helmet.

Одно из преимуществ, что библиотеку в некотором роде можно считать изоморфной и может прекрасно работать как на стороне клиента, так и на стороне сервера.

class Page extends Component {
   render() {
       return (
           
Turbo Todo {/* ... */}
); } }


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

app.get('/*', (req, res) => {
  const html = renderToString();
  const helmet = Helmet.renderStatic();
  res.send(`
     
     
     
       ${helmet.title.toString()}
       ${helmet.meta.toString()}
     
     
       
${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 = (
       
           
       
   );
   // ...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 = (
       
           
       
   );

   // 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(`
       
       
           ${helmet.title.toString()}​
           ${helmet.meta.toString()}
       
       
   `);

   const stream = ReactDOMServer.renderToNodeStream(app);
  
   stream.pipe(response, { end: false });
   stream.on('end', () => response.end(''));
});


С таким подходом становится под большим вопросом в принципе необходимость такой оптимизации и наверное вряд ли мы улучшим метрику TTFB, которой хотим добиться.

Тут конечно мы можем немного поиграть в оптимизацию и есть несколько вариантов

  • вместо renderToString использовать renderToStaticMarkup, что наверное в той или иной мере поможет выиграть какое-то время
  • вместо использования рендереров, предлагаемые реактом с коробки, придумать свою облегченную версию прохода по реактовскому дереву, например на базе библиотеки react-tree-walker
  • обдумать систему кеширования, которая могла бы иногда пропускать первый обход по реактовскому дереву


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

Мне кажется в этом случае, для тех кто знаком, как для 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(`​
     ​
     ​
         ${ReactDOMServer.renderToString(() => metaTags.map(tag => ) )}​​
     ​
     ​
 `);​

 const stream = ReactDOMServer.renderToNodeStream(app);​
 stream.pipe(response, { end: false });​
 stream.on('end', () => response.end(''));​
});


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

© Habrahabr.ru