Как стать автором
Обновить

Универсальные приложения React + Express

Время на прочтение 5 мин
Количество просмотров 16K
В прошлой статье рассматривалась библиотека Next.js, которая позволяет разрабатывать универсальные приложения «из коробки». В обсуждении статьи были озвучены существенные недостатки этой библиотеки. Судя по тому, что https://github.com/zeit/next.js/issues/88 бурно обсуждается с октября 2016 года, решения проблемы в ближайшее время не будет.

Поэтому, предлагаю ознакомится с современным состоянием «экосистемы» React.js, т.к. на сегодняшний день все, что делает Next.js, и даже больше, можно сделать при помощи сравнительно простых приемов. Есть, конечно, и готовые заготовки проектов. Например, мне очень нравится проект, который, к сожалению, базируется на неактульной версии роутера. И очень актуальный, хотя не такой «заслуженный» проект.

Использовать готовые проекты с массой плохо документированных возможностей немного страшно, т.к. не знаешь, где споткнешься, и самое главное — как развивать проект. Поэтому для тех, кто хочет разобраться в современном состоянии вопроса (и для себя), я сделал заготовку проекта с разъяснениями. В ней не будет какого-то моего личного эксклюзивного кода. Просто компиляция из примеров документации и большого количества статей.

В прошлой статье были перечисены задачи которые должно решать универсальное приложение.

1. Асинхронная предзагрузка данных на сервере (React.js как и большинство подобных бибиотек реализует только синхронный рендеринг)и формирование состояния компонента.
2. Серверный рендеринг компонента.
3. Передача состояния компонента на клиент.
4. Воссоздание cостояния компонента на клиенте из состояния, переданного сервером.
5. «Присоединение» компонента (hydrarte(...)) к полученной с сервера разметке (аналог render(...)).
6. Разбиение кода на оптимальное количество фрагментов (code splitting).

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

Начнем с роутинга. В документации React для реализации универсального роутинга предлагается формировать роуты на основании простого объекта. Например так:

// routes.js
module.exports = [
  {
    path: '/',
    exact: true,
    // component: Home,
    componentName: 'home'
  }, {
    path: '/users',
    exact: true,
    // component: UsersList,
    componentName: 'components/usersList',
  }, {
    path: '/users/:id',
    exact: true,
    // component: User,
    componentName: 'components/user',
  },
];

Такай форма описания роутов позволяет:

1) сформировать серверный и клиентский роутер на основании единого источника;
2) на сервере сделать предзагрузку данных до создания экземпляра компонента;
3) организовать разбиение кода на оптимальное количество фрагментов (code splitting).

Код серверного роутера очень простой:

import React from 'react';
import { Switch, Route } from 'react-router';
import routes from './routes';
import Layout from './components/layout'

export default (data) => (
  <Layout>
    <Switch>
      {
        routes.map(props => {
          props.component = require('./' + props.componentName);
          if (props.component.default) {
            props.component = props.component.default;
          }
          return <Route key={ props.path } {...props}/>
        })
      }
    </Switch>
  </Layout>
);

Отсутсвие возможности использовать полноценный общий <Layout/> в Next.js как раз и послужило отправной точкой для написания этой статьи.

Код клиентского роутера немного сложнее:

import React from 'react';
import { Router, Route, Switch} from 'react-router';
import routes from './routes';
import Loadable from 'react-loadable';
import Layout from './components/layout';

export default (data) => (
  <Layout>
    <Switch>
      {
        routes.map(props => {
          props.component = Loadable({
            loader: () => import('./' + props.componentName),
            loading: () => null,
            delay: () => 0,
            timeout: 10000,
          });
          return <Route key={ props.path } {...props}/>;
        })
      }
    </Switch>
  </Layout>
);

Самая интересная часть заключается в фрагменте кода () => import('./' + props.componentName). Функция import() дает команду webpack для реализации code splitting. Если бы на странице была обычная конструкция import или require(), то webpack включил бы код компонента в один результирующий файл. А так код будет загружаться при переходе на роут из отдельного фрагмента кода.

Рассмотрим основную точку входа клиентской части фронтенда:

'use strict'
import React from 'react';
import { hydrate } from 'react-dom';
import { Provider } from 'react-redux';
import {BrowserRouter} from 'react-router-dom';
import Layout from './react/components/layout';
import AppRouter from './react/clientRouter';
import routes from './react/routes';
import createStore from './redux/store';

const preloadedState = window.__PRELOADED_STATE__;
delete window.__PRELOADED_STATE__;
const store = createStore(preloadedState);

const component = hydrate(
  <Provider store={store}>
    <BrowserRouter>
        <AppRouter />
    </BrowserRouter>
  </Provider>,
  document.getElementById('app')
);

Все достаточно обычно и описано в документации React. Воссоздается состояние компонента с сервера и компонент «присоединяется» к готовой разметке. Обращаю внимание, что не все библиотеки позволяют сделать такую операцию в одной строчке кода, как это можно сделать в React.js.

И тот же компонент в серверном варианте:

import { matchPath } from 'react-router-dom';
import routes from './react/routes';
import AppRouter from './react/serverRouter';
import stats from '../dist/stats.generated';

...

app.use('/', async function(req, res, next) {
  const store = createStore();
  const promises = [];
  const componentNames = [];
  routes.forEach(route => {
    const match = matchPath(req.path, route);
    if (match) {
      let component = require('./react/' + route.componentName);
      if (component.default) {
        component = component.default;
      }
      componentNames.push(route.componentName);
      if (typeof component.getInitialProps == 'function') {
        promises.push(component.getInitialProps({req, res, next, match, store}));
      }
    }
    return match;
  })

  Promise.all(promises).then(data => {
    const context = {data};
    const html = ReactDOMServer.renderToString(
      <Provider store={store}>
        <StaticRouter location={req.url} context={context}>
            <AppRouter/>
        </StaticRouter>
      </Provider>
    );
    if (context.url) {
      res.writeHead(301, {
        Location: context.url
      })
      res.end()
    } else {
      res.write(`
        <!doctype html>
        <script>
         // WARNING: See the following for security issues around embedding JSON in HTML:
         // http://redux.js.org/docs/recipes/ServerRendering.html#security-considerations
         window.__PRELOADED_STATE__ = ${JSON.stringify(store.getState()).replace(/</g, '\\u003c')}
        </script>
        <div id="app">${html}</div>
        <script src='${assets(stats.common)}'></script>
        ${componentNames.map(componentName =>
          `<script src='${assets(stats[componentName])}'></script>`
        )}
      `)
      res.end()
    }
  })
});

Наиболее значимая часть — это определение по роуту необходимого компонента:

  routes.forEach(route => {
    const match = matchPath(req.path, route);
    if (match) {
      let component = require('./react/' + route.componentName);
      if (component.default) {
        component = component.default;
      }
      componentNames.push(route.componentName);
      if (typeof component.getInitialProps == 'function') {
        promises.push(component.getInitialProps({req, res, next, match, store}));
      }
    }
    return match;
  })

После того как мы находим компонент, мы вызываем его асинхронеый статический метод component.getInitialProps({req, res, next, match, store}). Статический — потому что экземпляр компонента на сервере еще не создан. Этот метод назван по аналогии с Next.js. Вот как этот метод может выглядеть в компоненте:

class Home extends React.PureComponent {
  static async getInitialProps({ req, match, store, dispatch }) {
   const userAgent = req ? req.headers['user-agent'] : navigator.userAgent
   const action = userActions.login({name: 'John', userAgent});
   if (req) {
     await store.dispatch(action);
   } else {
     dispatch(action);
   }
   return;
  }

Для хранения состояния объекта исползуется redux, что в данном случае существенно облегчает доступ к состоянию на сервере Без redux это было бы сделать не просто сложно а очень сложно.

Для удобства разработки нужно обеспечить компиляцию клиентского и серверного кода компонентов «на лету» и обновление браузера. Об этом а также о конфигурациях webpack для работы проекта я планирую рассказатьв следующей статье.
https://github.com/apapacy/uni-react

apapacy@gmail.com
14 февраля 2018 года
Теги:
Хабы:
+6
Комментарии 0
Комментарии Комментировать

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн