Server Side Rendering для React App на Express.js

    На написание этой статьи меня сподвигло отсутствие какого-либо более-менее полного мануала, как же сделать Server Side Rendering для React приложения.

    Когда я столкнулся с этой проблемой, у меня было 2 варианта это сделать, либо Next.js фреймворк, либо используя Express.js.

    На инвестигейт Next.js было потрачено около 100 часов, чтоб завести его для нашей готовой крупной OTT платформы, но проблем было настолько много, что мы от него отказались (по этому поводу напишу статью еще), остался выбор за малым, Express.js, про который я и хочу рассказать.

    Полный код демо-примера, рассматриваемого в статье, тут.

    Начнем с первоначальной задачи и то, что у нас было.

    Мы имели на тот момент:

    • Полностью готовое приложение, которое уже было в проде, со своей архитектурой и особенностями, указанными ниже.
    • Использовался Immutable.js для стора
    • Использовались Redux sagas
    • React.lazy и React.suspense для динамического импорта
    • React-router 4 для роутинга
    • Использовались кастомные шрифты для иконок

    Задачи:

    • Решение должно полностью отдавать отрендеренный HTML с данными
    • Это решение должно минимально затрагивать существующий код, т.е архитектура должна остаться с минимальными изменениями
    • Продолжать использовать библиотеки, которые уже есть в проекте
    • Мета данные для SEO должны приходить с сервера, вместе со страницей
    • Изображения, стили и шрифты должны загружаться на сервере и отдаваться на клиент, без последующей загрузки
    • Поддержка динамического импорта на стороне клиента
    • Избежать дублирования кода для предзагрузки на сервере и при навигации на клиентской стороне

    С задачами определились, давайте разбираться, как нам это сделать.

    Из документации реакта, мы можем узнать, что для SSR можно использовать renderToString() и hydrate() методы, но что с ними делать дальше?

    renderToString — используется для генерации HTML на сервере нашего приложения.
    hydrate — используется для универсального рендера на клиенте и на сервере.

    Загрузка данных


    Для загрузки данных на стороне сервера используем библиотеку redux-connect, которая позволяет загружать необходимые данные, до вызова первого рендера, то, что нам и надо. Для этого используется hoc asyncConnect. На серверной части он загружает данные, а при роутинге он работает как componentDidMount.

    @asyncConnect([
      {
        key: 'usersFromServer',
        promise: async ({ store: { dispatch } }) => {
          await dispatch(getUsersData());
    
          return Promise.resolve();
        },
      },
    ])
    

    Нам надо создать redux store на стороне сервера. Все как обычно, только создаем в файле server.js.

    Также на стороне сервера с помощью метода loadOnServer из redux-connect дожидаемся предзагрузки данных.

    С помощью renderToString мы получаем Html нашего приложения с данными.

    Данные, которые мы засетили в стор, можно достать с помощью getState() и добавить через тег <script/> в глобальный объект window. Из которого потом на клиенте мы достанем данные и засетим в стор.

    Все это выглядит так

    app.get('*', (req, res) => {
      const url = req.originalUrl || req.url;
      const history = createMemoryHistory({
        initialEntries: [url],
      });
      const store = configureStore(initialState, history);
      const location = parseUrl(url);
      const helpers = {};
      const indexFile = path.resolve('./build/main.html');
    
      store.runSaga(sagas).toPromise().then(() => {
        return loadOnServer({ store, location, routes, helpers })
          .then(() => {
            const context = {};
    
            if (context.url) {
              req.header('Location', context.url);
              return res.send(302)
            }
    
            const css = new Set(); // CSS for all rendered React components
            const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()));
    
            const dynamicRoutes = [...routes];
            dynamicRoutes[0].routes = [...dynamicRoutes[0].routes, ...StaticRoutesConfig];
    
            const appContent = ReactDOMServer.renderToString(
              <StyleContext.Provider value={{ insertCss }}>
                <Provider store={store} key="provider">
                  <StaticRouter location={location} context={context}>
                    <ReduxAsyncConnect routes={dynamicRoutes} helpers={helpers}/>
                  </StaticRouter>
                </Provider>
              </StyleContext.Provider>
            );
    
            const helmet = Helmet.renderStatic();
    
            fs.readFile(indexFile, 'utf8', (err, data) => {
              if (err) {
                console.log('Something went wrong:', err);
                return res.status(500).send('Oops, better luck next time!');
              }
              data = data.replace('__STYLES__', [...css].join(''));
              data = data.replace('__LOADER__', '');
              data = data.replace('<div id=app></div>', `<div id=app>${appContent}</div>`);
              data = data.replace('<div id="app"></div>', `<div id="app">${appContent}</div>`);
              data = data.replace('<title></title>', helmet.title.toString());
              data = data.replace('<meta name="description" content=""/>', helmet.meta.toString());
              data = data.replace('<script>__INITIAL_DATA__</script>', `<script>window.__INITIAL_DATA__ = ${JSON.stringify(store.getState())};</script>`);
    
              return res.send(data);
            });
          });
        store.close();
      });
    });
    

    В компонент ReduxAsyncConnect передаются 2 пропса:

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

    Для серверного роутинга надо использовать StaticRouter.

    Для добавления seo meta тэгов используется бибилиотека helmet. В компоненте каждой страницы есть описание с тегами.

    Для того, чтоб с сервера приходили сразу тэги тоже, используется

    const helmet = Helmet.renderStatic();
    helmet.title.toString()
    helmet.meta.toString()
    

    Роутинг надо было переписать на массив объектов, это выглядит так.

    export const StaticRoutesConfig = [
      {
        key: 'usersGender',
        component: UsersGender,
        exact: true,
        path: '/users-gender/:gender',
      },
      {
        key: 'USERS',
        component: Users,
        exact: true,
        path: '/users',
      },
      {
        key: 'main',
        component: Users,
        exact: true,
        path: '/',
      },
      {
        key: 'not-found',
        component: NotFound,
      },
    ];
    

    В зависимости от url, который приходит на сервер, react router отдавал нужную страницу с данными.

    Как выглядит клиент?

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

    browser/index.js

    import 'babel-polyfill';
    
    import { browserRender } from '../app/app';
    
    browserRender();
    

    Файл app.js

    const initialState = !process.env.IS_SERVER ? window.__INITIAL_DATA__ : {};
    
    const history = process.env.IS_SERVER
      ? createMemoryHistory({
        initialEntries: ['/'],
      })
      : createBrowserHistory();
    
    const store = configureStore(initialState, history);
    if (!process.env.IS_SERVER) {
      window.store = store;
    }
    
    const insertCss = (...styles) => {
      // eslint-disable-next-line no-underscore-dangle
      const removeCss = styles.map(style => style._insertCss());
      return () => removeCss.forEach(dispose => dispose());
    };
    
    export const browserRender = () => {
      const dynamicRoutes = [...routes];
      dynamicRoutes[0].routes = [...dynamicRoutes[0].routes, ...StaticRoutesConfig];
    
      hydrate(
        <StyleContext.Provider value={{ insertCss }}>
          <Provider key="provider" store={store} >
            <ConnectedRouter history={history}>
              <ReduxAsyncConnect helpers={{}} routes={dynamicRoutes} />
            </ConnectedRouter>
          </Provider>
        </StyleContext.Provider>,
        document.getElementById('app'),
      );
    };
    

    Для роутинга используется ConnectedRouter из connected-react-router/immutable.

    Для серверного рендеринга мы не можем использовать react-router-dom и сообстветственно описать наш роутинг через Switch:

    <Switch>
       <Route path="/about">
          <About />
        </Route>
        <Route path="/users">
           <Users />
        </Route>
        <Route path="/">
           <Home />
       </Route>
    </Switch>
    

    Вместо этого, как уже говорилось, у нас есть массив с описанными роутами, и для того, чтоб нам их добавить в приложение, нужно использовать react-router-config:

    App/index.js

    import React, { Component } from 'react';
    import PropTypes from 'prop-types';
    import { connect } from 'react-redux';
    import { Helmet } from 'react-helmet';
    import { renderRoutes } from 'react-router-config';
    
    import { getRouterLocation } from './selectors/router';
    
    
    @connect(state => ({
      location: getRouterLocation(state),
    }), null)
    export default class App extends Component {
      static propTypes = {
        location: PropTypes.shape().isRequired,
        route: PropTypes.shape().isRequired,
      };
    
      render() {
        const { route } = this.props;
    
        return (
          <div>
            {renderRoutes(route.routes)}
          </div>
        );
      }
    }
    

    Загрузка каритнок, стилей и шрифтов на сервере


    Для стилей использовался isomorphic-style-loader, так как обычный slyle-loader не работает в вебпаке с target: “node”;

    Он добавляет все стили в DOM, тем самым с сервера приходит уже готовая, красивая страница. Можно сохранять в отдельный файл стили, тем самым они смогут кешироваться браузером.

    Для отображения картинок и загрузки шрифтов на сервере был использован webpack loader base64-inline-loader.

    {
        test: /\.(jpe?g|png|ttf|eot|otf|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,
        use: 'base64-inline-loader?limit=1000&name=[name].[ext]',
      },
    

    Он был включен для изображений и всех типов шрифтов, которые мы использовали. В результате мы получали с сервера base64 код, который отображал страницу с шрифтами и изображениями без последующей загрузки.

    Для клиентского билда использовался обычный url-loader и file-loader.

    {
            test: /\.(eot|svg|otf|ttf|woff|woff2)$/,
            use: 'file-loader',
          },
          {
            test: /\.(mp4|webm|png|gif)$/,
            use: {
              loader: 'url-loader',
              options: {
                limit: 10000,
              },
            },
          },
    

    Да, это увеличило размер загружаемой страницы, но не сильно было заметно для пользователя по скорости загрузки.

    Динамический импорт на сервере


    В React.js для динамического импорта и отображения лоадера используются React.lazy и React.suspense, но они не работают для SSR.

    Мы использовали react-loadable, который выполняет то же самое.

    import Loadable from 'react-loadable';
    import Loader from './Loader';
    
    const LoadableComponent = Loadable({
      loader: () => import('./my-component'),
      loading: Loader,
    });
    
    export default class App extends React.Component {
      render() {
        return <LoadableComponent/>;
      }
    }
    
    

    На клиенте этот код отображает лоадер, на сервере, для того, чтоб модули загрузились, нужно добавить следующий код:

    Loadable.preloadAll().then(() => {
      app.listen(PORT, () => {
        console.log(` Server is listening on port ${PORT}`);
      });
    });
    

    Loadable.preloadAll() — возвращает промис, который говорит, что модули подгружены.

    Все основные моменты решены.

    Я сделал мини демо с использованием всего, что было описано в статье.

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

    Приходилось ли вам иметь дело с SSR?

    • 48%Да25
    • 51.9%Нет27
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

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

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

      +1

      Это уязвимость


      data = data.replace('<script>__INITIAL_DATA__</script>', `<script>window.__INITIAL_DATA__ = ${JSON.stringify(store.getState())};</script>`);

      Если в любой строке из state будет


      "</script><script>alert('hack')</script>"

      то это сработает, парсер html не смотрит, что там js строка. Просто сохраните в html файл такой код и посмотрите


      <script>
        const json = {"text":"</script><script>alert(1)</script>" };
        console.log(json);
      </script>
        0
        Спасибо, да, согласен.
        Но этот код же выполняется на стороне сервера, state формируется также на сервере, откуда там взяться XSS, если мы там контролируем сами все
          0

          У вас пользователи не добавляют контент (комментарии?), нет данных от сторонних апи и вы уверены в каждом сотруднике? XSS тут есть. То, что его не могут использовать посторонние люди другой вопрос.

            0
            С замечанием согласен, проблема возможна.
            Но в нашем случае все API надежное и если уже добавление контента будет, то оно будет обрабатываться при сохранении, а не при чтении.
        +1
        Хотелось бы поподробнее про проблемы с Next.js. По своему опыту могу сказать, что разработка на Next — просто сказка, когда разбираешься с его особенностями.
          0
          Если разрабатывать с 0 проект, то большинство проблем можно избежать. + в 9й версии Next.js тоже некоторые возникшие проблемы уже не актуальны.
          На данный момент могу только в виде видео показать www.youtube.com/watch?v=eenX8EGkTZM
            0
            Ну, девятой версии скоро будет полгода. О проблемах восьмой версии уже и правда немного неактуально говорить.
          0

          Можно выводы, стоит ли игра свеч? Насколько быстрее стало рендериться приложение?

            0
            Задача выполнялась больше для SEO, с перформансом проблем у нас не было.
            Для SEO задача выполнена на 100%, все показатели 90-98.
            Касаемо скорости загрузки всего контента, разница в 2 раза, SSR шустрее.

            SSR:


            Без SSR:
            0

            А что скажите по поводу Razzle?

              0
              Не использовал его, но подход очень похож на описанный мной выше.

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

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