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

Проект из предыдущей статьи построен на описании роутов в виде простого объекта:

// 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',
  },
];

Этот объект задает также разбиение кода на фрагменты (code splitting). Во так это сконфигурировано для клиентского webpack:

const webpack = require('webpack'); //to access built-in
const HtmlWebpackPlugin = require('html-webpack-plugin'); //installed via npm
const path = require('path');
const CommonsChunkPlugin = webpack.optimize.CommonsChunkPlugin;
const nodeEnv = process.env.NODE_ENV || 'development';
const port = Number(process.env.PORT) || 3000;
const isDevelopment = nodeEnv === 'development';
const routes = require('../src/react/routes');
const hotMiddlewareScript = `webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000`;

const entry = {};
for (let i = 0; i < routes.length; i++ ) {
  entry[routes[i].componentName] = [
    '../src/client.js',
    '../src/react/' + routes[i].componentName + '.js',
  ];
  if (isDevelopment) {
    entry[routes[i].componentName].unshift(hotMiddlewareScript);
  }
}

module.exports = {
  name: 'client',
  target: 'web',
  cache: isDevelopment,
  devtool: isDevelopment ? 'cheap-module-source-map' : 'hidden-source-map',
  context: __dirname,
  entry,
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: isDevelopment ? '/static/' : '/static/',
    filename: isDevelopment ? '[name].bundle.js': '[name].[hash].bundle.js',
    chunkFilename: isDevelopment ? '[name].bundle.js': '[name].[hash].bundle.js',
  },
  module: {
    rules: [{
        test: /\.jsx?$/,
        exclude: /node_modules/,
        loader: "babel-loader",
        options: {
          cacheDirectory: isDevelopment,
          babelrc: false,
          presets: [
            'es2015',
            'es2017',
            'react',
            'stage-0',
            'stage-3'
          ],
          plugins: [
            "transform-runtime",
            "syntax-dynamic-import",
          ].concat(isDevelopment ? [
              ["react-transform", {
                "transforms": [{
                  "transform": "react-transform-hmr",
                  "imports": ["react"],
                  "locals": ["module"]
                }]
              }],
            ] : [
            ]
          ),
        }
      }
    ]
  },
  plugins: [
    new webpack.optimize.OccurrenceOrderPlugin(),
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin(),
    new webpack.NamedModulesPlugin(),
    //new webpack.optimize.UglifyJsPlugin(),
    function(compiler) {
  		this.plugin("done", function(stats) {
  		    require("fs").writeFileSync(path.join(__dirname, "../dist", "stats.generated.js"),
           'module.exports=' + JSON.stringify(stats.toJson().assetsByChunkName) + ';console.log(module.exports);\n');
      });
    }
  ].concat(isDevelopment ? [
        ] : [
      new CommonsChunkPlugin({
        name: "common",
        minChunks: 2
      }),
    ]
  ),
};

В каждый фрагмент результирующего кода включается общая точка входа client.js, основной компонент для соответсвующего имени роута, а для окружения development еще и webpack-hot-middleware/client.

Для рабочего билда дополнительно формируется модуль с общим для всех компонгентов кодом:

new CommonsChunkPlugin({
    name: "common",
    minChunks: 2
}),

Значение minChunks позволяет управлять рамером фрагментов. При значении 2 любой участок одинакового кода, который используется в двух фрагментах будет перемещен в файл с именем common.bundle.js. Увеличение значения позволяет уменьшить размер модуля common.bundle.js. И увеличивает размер других фрагментов.

Для билда серверного фронтенда используется другой файл с конфигурацией webpack:

const webpack = require('webpack');
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const externalFolder = new RegExp(`^${path.resolve(__dirname, '../src')}/(react|redux)/.*$`)
const nodeEnv = process.env.NODE_ENV || 'development';
const isDevelopment = nodeEnv === 'development';

module.exports = {
  name: 'server',
  devtool: isDevelopment ? 'eval' : false,
  entry: './src/render.js',
  target: 'node',
  bail: !isDevelopment,
  externals: [
    nodeExternals(),
    function(context, request, callback) {
      if (request == module.exports.entry
        || externalFolder.test(path.resolve(context, request))){
        return callback();
      }
      return callback(null, 'commonjs2 ' + request);
     }
  ],
  output: {
    path: path.resolve(__dirname, '../src'),
    filename: 'render.bundle.js',
    libraryTarget: 'commonjs2',
  },
  module: {
    rules: [{
      test: /\.jsx?$/,
      exclude: [/node_modules/],
      use: "babel-loader?retainLines=true"
    }]
  }
};

Он значительно проще т.к. нам не нужно разбивать серверный код на фрагменты, а также обеспечивать поддержку старых версий браузеров (которые не поддерживают ES2017).

Опция devtool: 'eval' для режима разработчика показывает в сообщении об ошибке реальный файл и номер строки исходного кода.

Функция определяющая каталоги не воходящие в билд:

const externalFolder = new RegExp(`^${path.resolve(__dirname, '../src')}/(react|redux)/.*$`);
...
   function(context, request, callback) {
      if (request == module.exports.entry
        || externalFolder.test(path.resolve(context, request))){
        return callback();
      }
      return callback(null, 'commonjs2 ' + request);
     }

Предполагается что все модули кроме react и redux будут написаны с учетом возможностей node.js и не будут преобразовываться в legacy JavaScript.

Теперь рассмотрим код сервера, который может работать в режиме разработчика с hot reload, и в режиме продакшна:

'use strict';
const path = require('path');
const createServer = require('http').createServer;
const express = require('express');
const port = Number(process.env.PORT) || 3000;
const api = require('./src/api/routes');
const app = express();
const serverPath = path.resolve(__dirname, './src/render.bundle.js');
let render = require(serverPath);
let serverCompiler

const nodeEnv = process.env.NODE_ENV || 'development';
const isDevelopment = nodeEnv === 'development';
app.set('env', nodeEnv);

if (isDevelopment) {
  const webpack = require('webpack');
  serverCompiler = webpack([require('./webpack/config.server')]);
  const webpackClientConfig = require('./webpack/config.client');
  const webpackClientDevMiddleware = require('webpack-dev-middleware');
  const webpackClientHotMiddleware = require('webpack-hot-middleware');
  const clientCompiler = webpack(webpackClientConfig);
  app.use(webpackClientDevMiddleware(clientCompiler, {
    publicPath: webpackClientConfig.output.publicPath,
    headers: {'Access-Control-Allow-Origin': '*'},
    stats: {colors: true},
    historyApiFallback: true,
  }));
  app.use(webpackClientHotMiddleware(clientCompiler, {
    log: console.log,
    path: '/__webpack_hmr',
    heartbeat: 10 * 1000
  }));
  app.use('/static', express.static('dist'));
  app.use('/api', api);
  app.use('/', (req, res, next) => render(req, res, next));
} else {
  app.use('/static', express.static('dist'));
  app.use('/api', api);
  app.use('/', render);
}

app.listen(port, () => {
  console.log(`Listening at ${port}`);
});

if (isDevelopment) {
  const clearCache = () => {
    const cacheIds = Object.keys(require.cache);
    for (let id of cacheIds) {
      if (id === serverPath) {
        delete require.cache[id];
        return;
      }
    }
  }
  const watch = () => {
    const compilerOptions = {
      aggregateTimeout: 300,
      poll: 150,
    };
    serverCompiler.watch(compilerOptions, onServerChange);
    function onServerChange(err, stats) {
      if (err || stats.compilation && stats.compilation.errors && stats.compilation.errors.length) {
        console.log('Server bundling error:', err || stats.compilation.errors);
      }
      clearCache();
      try {
        render = require(serverPath);
      } catch (ex) {
        console.log('Error detecded', ex)
      }
      return;
    }
  }
  watch();
}

Если со слушателями изменения клиенской части фронтенда все понятно и хорошо описано в документации, то с серверной частью рендеринга я нашел решение в статье и немного упростил его. Суть такая, что в режиме разработчика функция рендеринга оборачивается другой функцией, которая вызывает всегда самый актуальный вариант функции рендеринга. При этом, после того как компилятор обнаруживает изменения в исходных файлах, происходит очстка кэша require и повторная загрузка скомпилирванного модуля:

    clearCache();
    try {
        render = require(serverPath);
    } catch (ex) {
        console.log('Error detecded', ex)
    }

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

Как это часто бывает, проделанная работа уперлась в непредвиденный момент. Code splitting это хорошо. Но как же ведет себя асинрхронно загружаемый компонент в реальной жизни? Увы, весь код роутинга и рендеринга React.js синхронный, и на время первой загрузки компонента отображается прелоадер (его можно сделать кастомным). Но для этого ли я все начинал? Все же решение нашлось. На основании стандартного компонента Link можно создать асинхронный компонента AsyncLink:

import React from "react";
import PropTypes from "prop-types";
import invariant from "invariant";
import { Link, matchPath } from 'react-router-dom';
import routes from './routes';

const isModifiedEvent = event =>
  !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);

class AsyncLink extends Link {
  handleClick = (event) => {
    if (this.props.onClick) this.props.onClick(event);
    if (
      !event.defaultPrevented && // onClick prevented default
      event.button === 0 && // ignore everything but left clicks
      !this.props.target && // let browser handle "target=_blank" etc.
      !isModifiedEvent(event) // ignore clicks with modifier keys
    ) {
      event.preventDefault();
      const { history } = this.context.router;
      const { replace, to } = this.props;
      function locate() {
        if (replace) {
          history.replace(to);
        } else {
          history.push(to);
        }
      }
      if (this.context.router.history.location.pathname) {
        const route = routes.find((route) => matchPath(this.props.to, route) ? route : null);
        if (route) {
          import(`${String('./' + route.componentName)}`).then(function() {locate();})
        } else {
          locate();
        }
      } else {
        locate();
      }
    }
  };
}
export default AsyncLink;

Вобщем все достаточно гладко после этого начало работать.
https://github.com/apapacy/uni-react

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

Комментарии (17)


  1. token
    14.02.2018 22:45

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


  1. apapacy Автор
    14.02.2018 22:54

    Вобщем то это один из самых простых конфигов webpack. Да это так, что сейчас без компоновщиков на JavaScript разработка не ведется. Хуже когда компоновка не только сложная но еще и нигде и никем не документированная. И когда компоновку нужно развивать то неясно что делать. Разработка универсального приложения — это тоже сложно. Например Airbnb где работет главный идеолог универсаьных приложений затратил на это 4 года см. habrahabr.ru/post/346960


    1. alek0585
      15.02.2018 00:43

      То-то у них сайт работает через одно место!


      1. apapacy Автор
        15.02.2018 01:15

        Ну спорный вопрос. Они по сведениям англоязычной Вики занимают 23 место среди топовых интернет компаний. en.wikipedia.org/wiki/List_of_largest_Internet_companies.

        Сайт высоконагруженный и просто сложный. Это же бизнес, деньги. Не лендинг же.

        А самое главное достигнута цель. Я например беру произвольную фразу с объявления на сайте

        Осталось всего несколько мест. Присоединяйтесь к еще 8 гостям на мероприятии хозяина Pimlada в назначенный день (пятница)

        И google мне находит то самое объявление откуда я взял эту фразу. А чистые SPA приложения могут месяцами не попадать в индексы поисковиков.


        1. alek0585
          15.02.2018 19:27

          Что значит спорный? Хоть раз им пользовались?))
          Карта лагает, цены прыгают, отзыв не поставить, техподдержки нет. Сайт уровня авито.
          Мне как пользователю абсолютно всё равно на чистоту SPA.
          Кстати, термин SPA означает одностраничное приложение и фраза «SPA приложение» звучит немного странно.


          1. apapacy Автор
            15.02.2018 19:45

            gnu not unix


            1. apapacy Автор
              15.02.2018 19:48

              SPA приложение это не потому что я такой тупой и не знаю как расшировывается SPA. Просто хочу разделить SPA — приложения от SPA — процедур. Тут же хочется добавить процедур не языкыв программирования фитнес-клубов.


  1. PaulMaly
    14.02.2018 23:22

    Это прекрасно! Вы даже не представляете как во-время появились ваши статьи! Жду продолжения!


  1. eugef
    15.02.2018 18:11

    Спасибо за статьи, скажите, какие преимущества дает сборка серверного когда с помощью вебпака? Кроме возможности хот-релоада.
    Зачем его вообще собирать, когда достаточно просто прогнать через бабел если надо.


  1. apapacy Автор
    15.02.2018 18:24

    Не думаю что есть болшая разница собран или не собран серверный код фронтенда в единый файл с точки зрения произвоительности или других причин. В данном случае я как раз пытаюсь не пропускать через babel все кроме фронтенда (папки react и redux). Поэтому все файлы бэкэнда остаеются на своих местах и относительные пути не ломаются. Если фронтенд пропустить через babel в каталог например build то все относительные пути будут недоступны и тогда уже нужно все будет пропустить через babel. Что мне не хотелось бы т.к. последние версии Node.js большинство фич ES2017 реализуюют нативно и это на порядок более произвоительно.

    Но тут конечно все обсуждаемо. Можно просто копировать в другую папку. не знаю. Если у Вас есть доводы что модно сделать более обоснованно то посоветуйте как это сделать.


    1. eugef
      15.02.2018 19:08

      Я использую es6 modules на сервере, поэтому и прогоняю через бабел. А вот зачем нужен вебпак на сервере — мне как-то не понятно. Вы используете его только чтобы следить за изменениями в файлах и делать хот-релоад?


  1. apapacy Автор
    15.02.2018 19:20

    В папках react и redux находятся компоненты единые для сервера и для кдиента поэтому там используются модули es6 и синтаксис jsx выглядит примерно так function render(){return <Component/>}. Что касается идеи прогонять все абсолютно через babel, то в какой-то мере она мне нравится т.к. можно использовать модули es6 и аннотации. Но есть и другая сторона. Последние версии нода и реализуют уже даже и модули, которые несовместимы с модулями babel. И тут надо выбирать или вечно все пропускать через препроцессор или же переходить на нативные модули.


  1. apapacy Автор
    15.02.2018 19:24

    Но главное даже не это. Нативные фичи стали производительные. Например от версии нода await в конструкции try/catch прибавил скорости раз в 10. Не будет ли генерировать babel код который снизит производительность?


    1. eugef
      16.02.2018 12:55

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


      1. apapacy Автор
        16.02.2018 15:32

        расскажите какой, дайте ссылку на описание. Я пронимаю что такое что-то должно быть. Но хотелось бы конкретики. Я например нашел github.com/christophehurpeau/babel-preset-latest-node. Там один контрибьютор 3 звезды как то не сильно похоже на то на что можно рассчитывать в проекте.


        1. eugef
          16.02.2018 16:39

          Для 6й ноды я использую babel-preset-es2015-node6


          1. apapacy Автор
            16.02.2018 17:47

            Посмотрел код. В версии 8 нод существенно прибавил в скорости (по некоторым фичам на порядок как я уже говорил try/catch/await. У авторов плагина очень хорошая идея это использовать проверку есть ли функция в окружении. Но это скорее всего не будти влиять на конструкции с async/await и они будут преобразованы.