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



В предыдущей статье был рассмотрен простой проект универсального приложения на 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
    /#10669896

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

  2. apapacy
    /#10669912

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

    • alek0585
      /#10670042

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

      • apapacy
        /#10670076

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

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

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

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

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

        • alek0585
          /#10671954

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

          • apapacy
            /#10671974

            gnu not unix

            • apapacy
              /#10671980

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

  3. PaulMaly
    /#10669958

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

  4. eugef
    /#10671740

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

  5. apapacy
    /#10671788

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

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

    • eugef
      /#10671918

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

  6. apapacy
    /#10671938

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

  7. apapacy
    /#10671944

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

    • eugef
      /#10673158

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

      • apapacy
        /#10673638

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

        • eugef
          /#10673792

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

          • apapacy
            /#10673954

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