При разработке больших клиентских веб-приложений с использованием React вы дойдете до того момента, когда не захотите загружать все приложение сразу. Либо потому, что приложение просто становится большим, и вы хотите сократить время загрузки, либо потому, что вы хотите загружать только те части приложения, к которым у пользователя есть доступ.

Есть отличное решение для загрузки частей приложения Javascript только тогда, когда они вам нужны: RequireJS. Использование RequireJS вместе с React для отложенной загрузки немного сложно, потому что загрузка модулей RequireJS асинхронна, а отрисовка компонентов React синхронна. Итак, давайте сначала начнем с того, как использовать React с RequireJS без отложенной загрузки, и покажем, в чем проблема.

Базу простого приложения, построенного с использованием RequireJS, можно записать так:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Example app</title>
  </head>
  <body>
    <div id="wrapper"></div>
    <script src="js/require.js"></script>
    <script>
      require.config({
        'baseUrl' : 'js/',
      });
      require(["application"]);
    </script>
  </body>
</html>

Файл js / application.js будет местом, где инициализируется приложение:

define(
  [],
  function() {
    // initialize application
  }
);

В случае приложения React js / application.js будет сгенерирован из этого JSX-файла.

define(
  ['react', 'components/application'],
  function(React, Application) {
    React.render(
      <Application />,
      document.getElementById('wrapper')
    );
  }
);

Во всех приведенных ниже примерах Javascript я просто напишу JSX и предполагаю, что JSX конвертируется на стороне сервера в Javascript с помощью Babel или других инструментов.

Файл js / components / application.js будет выглядеть так:

define(
  ['react'],
  function(React) {
    return React.createClass({
      displayName: 'Application',
      render: function() {
        return (
          <div className="application">
            <h1>Example app</h1>
          </div>
        );
      }
    });
  }
);

Как видите, в примере js / application.js компонент Application загружается с использованием управления зависимостями RequireJS. Это будет работать так же, когда компонент Приложение использует другие компоненты. Конечно, он будет использовать другие компоненты, потому что мы говорим о больших приложениях, где вам не удастся обрабатывать все в одном компоненте.

Однако, когда компонент загружается с использованием управления зависимостями RequireJS, зависимость будет загружена раньше зависимой. И не, основываясь на состоянии React зависимого, только тогда, когда это необходимо. В идеальном мире мы могли бы написать что-то вроде следующего. Точно так же, как работает загрузка модуля в Node.js.

define(
  ['react'],
  function(React) {
    return React.createClass({
      displayName: 'Application',
      render: function() {
        if (some condition) {
          var Dependency = require('components/dependency');
          return (
            <div className="application">
              <Dependency />
            </div>
          );
        } else {
          return (
            <div className="application">
              <h1>Example app</h1>
            </div>
          );
        }
      }
    });
  }
);

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

require(['components/dependency'], function(Dependency) {
  // Do something here with Dependency
});

Когда зависимость загружена, вы можете получить доступ к модулю следующим образом:

var Dependency = require('components/dependency');

Итак, нам нужен способ загрузить модуль перед, который мы собираемся его визуализировать, и позволить некоторому условию быть ложным, пока модуль действительно не загрузится. Это можно реализовать, создав метод componentDidMount в компоненте Application и загрузив туда требуемый модуль.

define(
  ['react', 'require'],
  function(React, require) {
    return React.createClass({
      displayName: 'Application',
      getInitialState: function() {
        return {
          dependency_loaded:
            require.defined('components/dependency')
        };
      },
      render: function() {
        if (this.state.dependency_loaded) {
          var Dependency = require('components/dependency');
          return (
            <div className="application">
              <Dependency />
            </div>
          );
        } else {
          return (
            <div className="application">
              <h1>Example app</h1>
            </div>
          );
        }
      },
      componentDidMount: function() {
        var self = this;
        if (!this.state.dependency_loaded) {
          require(['components/dependency'], function() {
            self.setState({dependency_loaded: true});
          })
        }
      }
    });
  }
);

Приведенный выше пример работает нормально, но в нем есть некоторые проблемы. Во-первых, он загружает зависимость независимо от состояния приложения. В реальном приложении у вас был бы некоторый элемент пользовательского интерфейса, который запускает изменение состояния и начинает загрузку только после того, как это изменение инициировано. Это, конечно, разрешимо в этом коде, но это создает некоторую спагетти. Более серьезные проблемы - это масштабируемость и разделение задач. Этот код отлично работает с одним компонентом, но будет расти с каждым добавленным вами компонентом. Что еще более важно: основная задача компонента Application - не загружать его зависимости. Конечно, в этом простом примере компонент Application мало что делает, но в более полезном приложении компонент Application обеспечит основу приложения. А загрузка зависимостей редко является основой приложения.

Итак, как нам решить эти проблемы, чтобы не получить гигантский клубок запутанного кода?

Поскольку React предоставляет только часть приложения view, вам понадобится некоторая структура, чтобы структурировать ваше приложение. В примерах ниже я использую Flux, но основные идеи применимы и к другим фреймворкам.

Основная идея Flux - это так называемый однонаправленный поток данных, что хорошо показано на диаграмме ниже.

Flux - это идея, а не каркас. Единственный код, который он предоставляет, - это Dispatcher (и некоторые примеры). Для остальных частей следует использовать другие проекты. В своих примерах я использую React для views и fbemitter, чтобы store отображали события. React очень важен для этой истории, но эмиттер событий - лишь один из многих вариантов. Любой другой хорошо написанный эмиттер событий выполнит эту работу, поэтому не стесняйтесь использовать свой любимый эмиттер. API может немного отличаться, но с этим проблем не возникнет.

В Flux все имеет свое определенное место, поэтому мы можем довольно легко создать общий макет, в котором должна располагаться часть загрузки компонентов. Вызов RequireJS для запроса компонента должен исходить от Action Creator и приводить к Action, которое отправляется в Dispatcher. Действие указывает, что компонент загружен и должен быть получен в Store, который сохраняет, какие компоненты фактически загружены. View запрашивает Store, чтобы выяснить, доступен ли компонент, и слушает события из Store, чтобы обновлять себя при изменении состояния компонента. .

Итак, давайте снова начнем с компонента Application (js / components / application.js):

define(
  ['react', 'components/plugin'],
  function(React) {
    return React.createClass({
      displayName: 'Application',
      render: function() {
        if (some condition) {
          return (
            <div className="application">
              <Plugin plugin="components/plugins/foo" />
            </div>
          );
        } else {
          return (
            <div className="application">
              <h1>Example app</h1>
            </div>
          );
        }
      }
    });
  }
);

Теперь компонент Application не заботится о том, загружен ли требуемый компонент, он просто вставляет компонент Plugin, который будет обрабатывать загрузку необходимого компонента. Я выбираю слово Plugin для загружаемого компонента, но вы можете называть его как хотите.

Flux предписывает работать с одним Диспетчером, и все Действия должны проходить через этот Диспетчер. Если вы уже работаете над приложением Flux, возможно, оно у вас уже есть, и вам следует использовать этот Dispatcher. Если это начало вашего приложения Flux, оно вам понадобится. Итак, давайте создадим файл js / dispatcher.js.

define(
  ['Flux'],
  function (Flux) {
    var dispatcher = new Flux.Dispatcher();
    return dispatcher;
  }
);

При разработке для Flux имеет смысл сначала создать четыре основных блока (Dispatcher, Store, View, Action Creator), а затем свяжите их вместе с помощью соединителей (Обратный вызов, Событие изменения, Запрос магазина, Взаимодействие с пользователем, Действие ). У нас уже есть Диспетчер, поэтому перейдем к Store.

Хранилище - это место, куда будут сохраняться загруженные плагины, и где представления могут получить эту информацию. Store - это, по сути, объект с простым API, который имеет две цели: подписка на события и получение и обработка данных. Начнем с самого простого: извлечения и обработки данных.

Магазин, в котором сохраняется загруженное состояние подключаемых модулей, будет называться PluginStore и находится в js / store / plugin_store. js. Нам нужна конечная точка, где мы можем узнать, загружен ли подключаемый модуль, и конечная точка, где мы можем сообщить Store, что подключаемый модуль был загружен.

define(
  [], 
  function () {
    var API = {
      loaded: function(name) {},
      setLoaded: function(name) {},
    };
    return API;
  }
);

Чтобы сохранить эту информацию, достаточно простого Array, так что давайте просто воспользуемся им.

var plugins = [];

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

plugins.push(name);

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

plugins.indexOf(name) !== -1

Давайте соберем все вместе, чтобы завершить API извлечения данных и управления ими.

define(
  [], 
  function () {
    var _plugins = [];
    var API = {
      loaded: function(name) {
        return (_plugins.indexOf(name) !== -1);
      },
      setLoaded: function(name) {
        _plugins.push(name);
      },
    };
    return API;
  }
);

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

Во-вторых, Store должен генерировать события, чтобы View мог их прослушивать. Итак, должна быть конечная точка для добавления прослушивателя событий и конечная точка для удаления прослушивателя событий.

define(
  [], 
  function () {
    var _plugins = [];
    var API = {
      addLoadListener: function(callback) {},
      removeLoadListener: function(callback) {},
      loaded: function(name) {
        return (_plugins.indexOf(name) !== -1);
      },
      setLoaded: function(name) {
        _plugins.push(name);
      },
    };
    return API;
  }
);

В качестве эмиттера событий мы будем использовать fbemitter. Эмиттер событий должен быть инициализирован следующим образом:

var events = new fbemitter.EventEmitter();

Добавление и удаление прослушивателя событий упрощается с помощью fbemitter:

events.addListener(event_name, callback);
events.removeListener(event_name, callback);

Чтобы отправлять значимые события в Просмотры, Store должен запускать fbemitter.

events.emit(event_name);

Склеивая эти части вместе, создается (почти) готовый PluginStore.

define(
  ['fbemitter'], 
  function (fbemitter) {
    var _plugins = [];
    var events = new fbemitter.EventEmitter();
    var API = {
      addLoadListener: function(callback) {
        events.addListener('load', callback);
      },
      removeLoadListener: function(callback) {
        events.removeListener('load', callback);
      },
      loaded: function(name) {
        return (_plugins.indexOf(name) !== -1);
      },
      setLoaded: function(name) {
        _plugins.push(name);
        events.emit('load');
      },
    };
    return API;
  }
);

Мы еще не реализовали то, как связать Store с Dispatcher. Но сначала мы напишем View и Action Creator.

View - это компонент Plugin, который использовался компонентом Application следующим образом:

<Plugin plugin="components/plugins/foo" />

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

define(
  ['react'],
  function(React) {
    return React.createClass({
      displayName: 'Plugin',
      propTypes: {
        plugin: React.PropTypes.string.isRequired
      },
      render: function() {
        return (
          <div className="plugin">
          </div>
        );
      }
    });
  }
);

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

this.props.plugin

Когда плагин уже загружен, мы можем его отобразить. В противном случае нам нужно показать сообщение о загрузке. Независимо от того, загружен ли плагин, следует сохранить в состоянии. Итак, давайте перепишем для этого метод render. Помните, что мы можем использовать синхронную форму require, если компонент загружен.

render: function() {
  if (this.state.loaded) {
    return (
      <div className="plugin">
        {React.createElement(require(this.props.plugin), null)}
      </div>
    );
  } else {
    return (
      <div className="plugin-loading">
        Loading...
      </div>
    );
  }
}

Конечно, значение loaded должно быть переведено в состояние, загрузив его из PluginStore. Поскольку общедоступный API PluginStore уже готов, мы собираемся связать View с Store. Когда компонент Plugin смонтирован, будет вызван метод getInitialState. В этом методе мы получаем значение loaded из PluginStore.

getInitialState: function() {
  return {
    loaded: PluginStore.loaded(this.props.plugin)
  }
}

Когда подключаемый модуль уже смонтирован и получает новое значение свойства подключаемого модуля из приложения, будет вызван метод componentWillReceiveProps. В нашем простом примере этого не происходит, но в реальном приложении это очень вероятно. В этом методе нам нужно сбросить значение loaded в состоянии, потому что новый плагин не обязательно имеет такое же загруженное состояние.

componentWillReceiveProps: function(props) {
  this.setState({
    loaded: PluginStore.loaded(props.plugin)
  });
}

Теперь нам нужно связать View с эмиттером событий PluginStore. Плагин может не загружаться при монтировании компонента Plugin, но идея, конечно, в том, что он будет загружен в какой-то момент.

componentDidMount: function() {
  PluginStore.addLoadListener(this._onLoad);
},
componentWillUnmount: function() {
  PluginStore.removeLoadListener(this._onLoad);
},
_onLoad: function() {
  this.setState({
    loaded: PluginStore.loaded(this.props.plugin)
  });
}

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

Просмотр почти готов, так что давайте соединим части:

define(
  ['react', 'stores/plugin_store', 'require'],
  function(React, PluginStore, require) {
    return React.createClass({
      displayName: 'Plugin',
      propTypes: {
        plugin: React.PropTypes.string.isRequired
      },
      getInitialState: function() {
        return {
          loaded: PluginStore.loaded(this.props.plugin)
        }
      },
      render: function() {
        if (this.state.loaded) {
          return (
            <div className="plugin">
              {React.createElement(
                require(this.props.plugin),
                null
              )}
            </div>
          );
        } else {
          return (
            <div className="plugin-loading">
              Loading...
            </div>
          );
        }
      },
      componentWillReceiveProps: function(props) {
        this.setState({
          loaded: PluginStore.loaded(props.plugin)
        });
      },
      componentDidMount: function() {
        PluginStore.addLoadListener(this._onLoad);
      },
      componentWillUnmount: function() {
        PluginStore.removeLoadListener(this._onLoad);
      },
      _onLoad: function() {
        this.setState({
          loaded: PluginStore.loaded(this.props.plugin)
        });
      }
    });
  }
);

Все, что нам нужно для завершения просмотра, - это каким-то образом запустить процесс загрузки плагина. Потому что сейчас мы ждем завершения процесса, но процесс никогда не запускается. Этим процессом должен заниматься Action Creator, так что это то, что нам сейчас нужно.

У Action Creator должен быть API, позволяющий запускать загрузку модуля. Когда этот процесс запускается, необходимо использовать асинхронную форму require для загрузки модуля, а когда он будет завершен, необходимо отправить Action , поэтому факт загрузки модуля может быть обработан PluginStore. Если модуль уже загружен, процесс загрузки не должен начинаться, но Действие должно быть отправлено немедленно. Мы можем проверить, загружен ли уже модуль, вызвав метод require.defined.

require.defined(name)

Действие - это объект, у которого есть свойство type и некоторые свойства, зависящие от типа. В нашем случае нам нужно включить имя загруженного плагина в Action.

{
  type: 'plugin-loaded',
  plugin: plugin_name
}

Действие отправляется в Диспетчер следующим образом:

Dispatcher.dispatch(action);

Итак, сложив эти части вместе, мы получим Action Creator, который необходимо сохранить в js / actions / plugin_action_creators.js.

define(
  ['require', 'dispatcher'],
  function(require, Dispatcher) {
    return {
      loadPlugin: function(name) {
        if (require.defined(name)) {
          Dispatcher.dispatch({
            type: 'plugin-loaded',
            plugin: name
          });
        } else {
          require([name], function() {
            Dispatcher.dispatch({
              type: 'plugin-loaded',
              plugin: name
            });
          });
        }
      },
    };
  }
);

Action Creator - это первый из основных блоков, работа над которым завершена. Он создает Action и отправляет его Dispatcher. Теперь нам нужно создать обратные вызовы, которые связывают Store с Dispatcher.

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

Поэтому нам нужно изменить PluginStore, чтобы зарегистрировать Обратный вызов в Диспетчере.

Dispatcher.register(function(action) {
  switch(action.type) {
    case 'plugin-loaded':
      API.setLoaded(action.plugin);
      break;
  }
});

В полном коде PluginStore это выглядит так:

define(
  ['fbemitter', 'dispatcher'], 
  function (fbemitter, Dispatcher) {
    var _plugins = [];
    var events = new fbemitter.EventEmitter();
    var API = {
      addLoadListener: function(callback) {
        events.addListener('load', callback);
      },
      removeLoadListener: function(callback) {
        events.removeListener('load', callback);
      },
      loaded: function(name) {
        return (_plugins.indexOf(name) !== -1);
      },
      setLoaded: function(name) {
        _plugins.push(name);
        events.emit('load');
      },
    };
    API.dispatchToken = Dispatcher.register(function(action) {
      switch(action.type) {
        case 'plugin-loaded':
          API.setLoaded(action.plugin);
          break;
      }
    });
    return API;
  }
);

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

Теперь, когда запускается loadPlugin Action Creator, модуль загружается, создается и отправляется Action. PluginStore получит Action и соответствующим образом обновит внутреннее хранилище и выдаст событие Change. Теперь не хватает только фактического запуска Action Creator. Это должно быть сделано в View.

Нам нужно добавить этот триггер в методы componentDidMount и componentDidUpdate. Нам нужно только загрузить плагин, если он еще не загружен, поэтому мы пишем триггер следующим образом:

if (!this.state.loaded) {
  Actions.loadPlugin(this.props.plugin);
}

Теперь у нас есть все части последнего компонента Plugin, так что давайте объединим их.

define(
  [
    'react', 
    'stores/plugin_store',
    'require', 
    'actions/plugin_action_creators'
  ],
  function(React, PluginStore, require, Actions) {
    return React.createClass({
      displayName: 'Plugin',
      propTypes: {
        plugin: React.PropTypes.string.isRequired
      },
      getInitialState: function() {
        return {
          loaded: PluginStore.loaded(this.props.plugin)
        }
      },
      render: function() {
        if (this.state.loaded) {
          return (
            <div className="plugin">
              {React.createElement(
                require(this.props.plugin),
                null
              )}
            </div>
          );
        } else {
          return (
            <div className="plugin-loading">
              Loading...
            </div>
          );
        }
      },
      componentWillReceiveProps: function(props) {
        this.setState({
          loaded: PluginStore.loaded(props.plugin)
        });
      },
      componentDidMount: function() {
        PluginStore.addLoadListener(this._onLoad);
        if (!this.state.loaded) {
          Actions.loadPlugin(this.props.plugin);
        }
      },
      componentDidUpdate: function() {
        if (!this.state.loaded) {
          Actions.loadPlugin(this.props.plugin);
        }
      },
      componentWillUnmount: function() {
        PluginStore.removeLoadListener(this._onLoad);
      },
      _onLoad: function() {
        this.setState({
          loaded: PluginStore.loaded(this.props.plugin)
        });
      }
    });
  }
);

Итак, мы завершили диаграмму потоков. Мы создали четыре основных блока и соединительные элементы, что означает, что мы закончили.

В этом методе загрузки подключаемого модуля все волшебство происходит за пределами компонента Application. Это дает много практических преимуществ, потому что приложению вряд ли нужно знать о том, что плагин загружается лениво. Если вы предпочитаете это, вы также можете изменить это и позволить компоненту Application вызывать Action Creator после некоторого взаимодействия с пользователем и включать плагин только после того, как он был загружен.

Чтобы сделать это немного более практичным, я создал образец приложения, которое находится в свободном доступе на Github и реализует обе стратегии загрузки. Стратегия загрузки, представленная в этой статье, называется стратегией Direct, а стратегия, которая делает компонент Application более осведомленным о загрузке, называется Async. . Кроме того, в нем показано, как заставить эту загрузку плагина работать, когда у вас есть более одного плагина, потому что при практическом применении этого у вас, вероятно, будет несколько плагинов. Наконец, он показывает, как склеить все эти части вместе с помощью Gulp, собрать необходимые библиотеки с помощью NPM и как использовать оптимизатор RequireJS, чтобы объединить все эти маленькие файлы в файлы большего размера, чтобы уменьшить количество HTTP-запросов.

Рольф ван де Крол (Rolf van de Krol) - инженер-программист в компании Hoppinger, где он создает сложные приложения для национальных и международных клиентов. Если вам нужен кто-то, кто поможет вам выделиться в Интернете, вам следует связаться с Hoppinger.