Одной из проблем, которую придется решать при написании Server Side rendering приложения — это работа с метатегами, которые должны быть у каждой страницы, которые помогают при индексации их поисковыми системами.
Начиная гуглить, первое решение, к которому приведут Вас, скорее всего React Helmet.
Одно из преимуществ библиотеки, что ее в некотором роде можно считать изоморфной и может прекрасно использована как на стороне клиента, так и на стороне сервера.
На сервере роутер тогда будет выглядеть так:
Оба приведенных сниппета полностью корректны и работоспособны, но есть одно НО, приведенный выше код для сервера полностью синхронен и поэтому полностью безопасен, но стоит ему стать асинхронным, как он станет в себе скрывать сложно отлаживаемые баги:
Проблема тут в первую очередь в самой библиотеке React Helmet и в частности в том, что она собирает все теги внутри React Tree и складывает его фактически в глобальную переменную, а так как код стал асинхронным, код может миксовать одновременно обрабатываемые реквесты от разных пользователей.
Хорошая новость тут в том, что на базе этой библиотеки был сделан форк и сейчас лучше отдать предпочтение react-helmet-async библиотеке. Основная парадигма в ней в том, что в данном случае контекст react-helmet будет изолирован в рамках одного реквеста за счет инкапсуляции React Tree приложения в HelmetProvider:
На этом можно было бы закончить, но возможно вы пойдете дальше в попытка выжать максимально производительность и улучшить некоторые SEO метрики. Например, улучшить можно метрику Time To First Byte (TTFB)- когда сервер может отправлять разметку страницы чанками по мере их вычисления, а не дожидаясь, пока она будет вычислена полностью. Для этого вы начнете смотреть в сторону использования renderToNodeStream вместо renderToString.
Тут мы снова столкнулись с небольшой проблемой. Чтобы получить все метатеги, которые необходимо странице, мы обязательно должны пройтись по всему дереву реакт приложения, но проблема в том, метатеги должны быть отправлены раньше момента, когда мы начинаем уже стримить контент с использованием renderToNodeStream. Фактически нам нужно тогда вычислять React Tree дважды и выглядит это примерно так:
С таким подходом становится под большим вопросом в принципе необходимость такой оптимизации и вряд ли мы улучшим метрику TTFB, которой хотим добиться.
Тут мы можем немного поиграть в оптимизацию и есть несколько вариантов
Но в любом случае, все описанное звучит чересчур мудреным и ставит в принципе под сомнение эта гонка за эффективностью, когда за пару миллисекунд выстраивается какая-то ненормально сложная архитектура.
Мне кажется в этом случае, для тех кто знаком, как для SSR извлекать данные для рендеринга (а если кто не знает — то вот тут отличная статья на эту тему), мы поможет пойти по такому же пути извлечении метатегов для страницы.
Общая концепция такова — у нас есть конфигурационный файл роутеров — это обычная JS структура, которая представляет собой массив объектов, каждый из которых содержит несколько полей типо component, path. На базе url реквеста мы по конфигурационному файлу находим нужный нам роутер и компонент ассоциированный с ним. Для этих компонентов определяем набор статичных методов такие как loadData и, например, для наших метатегов еще и createMetatags.
Таким образом сам компонент страницы у нас станет таким:
Мы тут определили статичный метод createMetatags, который создает требуемый набор метатегов. С учетом этого, код на сервере станет таким:
Т.е. теперь нам нет необходимости дважды рендерить реактовское дерево — мы сразу же по аналогии с извлечение данных для роута можем извлекать из изоморфного приложения все что нам нужно для работы.
Начиная гуглить, первое решение, к которому приведут Вас, скорее всего 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>')); });
Т.е. теперь нам нет необходимости дважды рендерить реактовское дерево — мы сразу же по аналогии с извлечение данных для роута можем извлекать из изоморфного приложения все что нам нужно для работы.
