javascript

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

  • четверг, 15 февраля 2018 г. в 03:15:16
https://habrahabr.ru/post/349064/
  • Разработка веб-сайтов
  • Веб-дизайн
  • ReactJS
  • Node.JS
  • JavaScript


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

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

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

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

1. Асинхронная предзагрузка данных на сервере (React.js как и большинство подобных бибиотек реализует только синхронный рендеринг)и формирование состояния компонента.
2. Серверный рендеринг компонента.
3. Передача состояния компонента на клиент.
4. Воссоздание компонента на клиенте с состоянием, переданным с сервера.
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 для работы проекта я планирую рассказатьв следующей статье.

apapacy@gmail.com
14 февраля 2018 года