Fluxを修得するためにkriasoft/react-starter-kitをコードリーディングした

発端

実践的なFluxの勉強するためになにかいいサンプルコードないかと漁っていたら、KriaSoftがGithub上で公開しているreact-starter-kitが自前でReact.jsでのページネーションを実装してたりいろいろと参考になったのでコードリーディングしてみました。

Github

kriasoft/react-starter-kit · GitHub

DEMO

React.js Starter Kit

ちなみにGithubのdescriptionにも書かれていますが、このstarter-kitはBabel (ES6), JSX, Gulp, Webpack, BrowserSync, Jest, Flowとモダンなツールが全部入りなので、あらゆる方面から勉強になりました。

なお、これから貼るコードは抜粋なので、ソースコードを落としてエディタで見るかDevelopmentToolを実行しながら確認した方が良いかと思います。

src/templates/index.html

<!doctype html>
<html class="no-js" lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title><%- title %></title>
    <meta name="description" content="<%- description %>">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <link rel="apple-touch-icon" href="apple-touch-icon.png">
    <link rel="stylesheet" href="/css/bootstrap.css">
    <script src="/app.js"></script>
  </head>
  <body>
    <!--[if lt IE 8]>
      <p class="browserupgrade">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
    <![endif]-->
    <%= body %>
    <!-- Google Analytics: change UA-XXXXX-X to be your site's ID. -->
    <script>
      ...
    </script>
  </body>
</html>

まずはindex.htmlから。GoogleAnalyticsのコードなどを除けばこれだけ。 bodyは変数となっており、ここはserver側で定義しています。

src/server.js

// The top-level React component + HTML template for it
var App = React.createFactory(require('./components/App'));
var templateFile = path.join(__dirname, 'templates/index.html');
var template = _.template(fs.readFileSync(templateFile, 'utf8'));

server.get('*', function(req, res) {
  var data = {description: ''};
  var app = new App({
    path: req.path,
    onSetTitle: function(title) { data.title = title; },
    onSetMeta: function(name, content) { data[name] = content; },
    onPageNotFound: function() { res.status(404); }
  });
  data.body = React.renderToString(app);
  var html = template(data);
  res.send(html);
});

サーバ側はexpressで動作しています。ReactのComponentであるAppを定義し、renderToStringで描画しています。Isomorphic感が出ています。

src/components/App/App.js

  render() {
    var page = AppStore.getPage(this.props.path);
    invariant(page !== undefined, 'Failed to load page content.');
    this.props.onSetTitle(page.title);

    if (page.type === 'notfound') {
      this.props.onPageNotFound();
      return React.createElement(NotFoundPage, page);
    }

    return (
      <div className="App">
        <Navbar />
        {
          this.props.path === '/' ?
          <div className="jumbotron">
            <div className="container text-center">
              <h1>React</h1>
              <p>Complex web apps made easy</p>
            </div>
          </div> :
          <div className="container">
            <h2>{page.title}</h2>
          </div>
        }
        <ContentPage className="container" {...page} />
        <div className="navbar-footer">
          <div className="container">
            <p className="text-muted">
              <span>© Your Company</span>
              <span><a href="/">Home</a></span>
              <span><a href="/privacy">Privacy</a></span>
            </p>
          </div>
        </div>
      </div>
    );
  }

実際にbody部に描画されるのはApp.jsのrender内に定義されています。 また、ページネーションを実現しているhandleClickもこのApp.jsで定義されています。

  handleClick(event) {
    if (event.button === 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.defaultPrevented) {
      return;
    }

    // Ensure link
    var el = event.target;
    while (el && el.nodeName !== 'A') {
      el = el.parentNode;
    }
    if (!el || el.nodeName !== 'A') {
      return;
    }

    // Ignore if tag has
    // 1. "download" attribute
    // 2. rel="external" attribute
    if (el.getAttribute('download') || el.getAttribute('rel') === 'external') {
      return;
    }

    // Ensure non-hash for the same path
    var link = el.getAttribute('href');
    if (el.pathname === location.pathname && (el.hash || link === '#')) {
      return;
    }

    // Check for mailto: in the href
    if (link && link.indexOf('mailto:') > -1) {
      return;
    }

    // Check target
    if (el.target) {
      return;
    }

    // X-origin
    var origin = window.location.protocol + '//' + window.location.hostname +
      (window.location.port ? ':' + window.location.port : '');
    if (!(el.href && el.href.indexOf(origin) === 0)) {
      return;
    }

    // Rebuild path
    var path = el.pathname + el.search + (el.hash || '');

    event.preventDefault();
    AppActions.loadPage(path, () => {
      AppActions.navigateTo(path);
    });
  }

根幹は最後にあるAppActions.loadPageで、その前のif文は対象としないリンクを弾くためのガード節です。

休憩

Actionが出てきたので、Fluxについておさらいしておきます。

f:id:Peranikov:20150420154018p:plain

よくあるFluxのフローをまとめた図です。今見てきたところはViewの部分で、これからViewからのイベントハンドリングを管理するAction、各Storeにメッセージを発信するDispatcher、データを管理するStoreへと流れていきます。コードリーディングする際にも、一連の流れに沿っているFluxの考え方の恩恵が受けられますね。

src/actions/AppActions.js

  loadPage(path, cb) {
    Dispatcher.handleViewAction({
      actionType: ActionTypes.LOAD_PAGE,
      path
    });

    http.get('/api/page' + path)
      .accept('application/json')
      .end((err, res) => {
        Dispatcher.handleServerAction({
          actionType: ActionTypes.LOAD_PAGE,
          path,
          err,
          page: res.body
        });
        if (cb) {
          cb();
        }
      });
  }

App.jsから呼んでいたloadPageの抜粋です。Dispatcher.handleViewActionを呼んだ後に、サーバへGETでリクエストし遷移先のbodyを受け取り、Dispatcher.handleServerActionを呼んでいます。サーバ側より先にシンプルなDispatcherのコードを見てみます。

src/core/Dispatcher.js

let Dispatcher = assign(new Flux.Dispatcher(), {

  /**
   * @param {object} action The details of the action, including the action's
   * type and additional data coming from the server.
   */
  handleServerAction(action) {
    var payload = {
      source: PayloadSources.SERVER_ACTION,
      action: action
    };
    this.dispatch(payload);
  },

  /**
   * @param {object} action The details of the action, including the action's
   * type and additional data coming from the view.
   */
  handleViewAction(action) {
    var payload = {
      source: PayloadSources.VIEW_ACTION,
      action: action
    };
    this.dispatch(payload);
  }

});

Dispatcherの仕事は単純で、各StoreにActionからの通知を横流しします。ここでは、Serverからのデータか、Viewからのデータかでメソッドを使い分けています。Dispatcherはpayloadと呼ばれるオブジェクトでactionオブジェクトをラップし、データ元などの情報を付加してdispatchメソッドでStoreへ通知します。

Storeで通知を受ける前に、Actionから呼んでいたサーバ側の処理を覗いてみます。

src/server.js

//
// Page API
// -----------------------------------------------------------------------------
server.get('/api/page/*', function(req, res) {
  var urlPath = req.path.substr(9);
  var page = AppStore.getPage(urlPath);
  res.send(page);
});

ページのbodyを返すサーバのAPIでは、なんとStoreのコードを呼んでいました。

src/stores/AppStore.js(Serverからの呼び出し)

var pages = {};

...

if (__SERVER__) {
  pages['/'] = {title: 'Home Page'};
  pages['/privacy'] = {title: 'Privacy Policy'};
}

var AppStore = assign({}, EventEmitter.prototype, {

...

  /**
   * Gets page data by the given URL path.
   *
   * @param {String} path URL path.
   * @returns {*} Page data.
   */
  getPage(path) {
    return path in pages ? pages[path] : {
      title: 'Page Not Found',
      type: 'notfound'
    };
  },

...

AppStoreのgetPageでは、与えられたパスに対するオブジェクトを返しています。存在しないパスに対するハンドリングも行っており、ここでルーティングの役目を担っています。

サーバ側はこれで終わりなので、StoreがDispatcherからの通知を受けたところから再開します。

src/stores/AppStore.js(Dispatcherからの通知)

AppStore.dispatcherToken = Dispatcher.register((payload) => {
  var action = payload.action;

  switch (action.actionType) {

    case ActionTypes.LOAD_PAGE:
      if (action.source === PayloadSources.VIEW_ACTION) {
        loading = true;
      } else {
        loading = false;
        if (!action.err) {
          pages[action.path] = action.page;
        }
      }
      AppStore.emitChange();
      break;

    default:
      // Do nothing

  }

});

AppStoreの下部の方でDispatcher.registerを実行しており、これによりDispatcherからのdispatchメソッドによって通知が受け取れるようになっています。

通知を受けた後に処理をしているのはActionTypesがLOAD_PAGE、つまりAppActionのloadPageメソッドからの通知を受けた時で、かつPayloadSourcesがVIEW_ACTION以外、つまりSERVER_ACTIONの時ですね。(VIEW_ACTIONの時にもloading = trueと代入していますが、この変数を実際に使っている箇所はありませんでした。)

AppSotreのpagesオブジェクトに、パスと対応するbodyの情報をセットし、emitChangeを呼んでStoreの仕事は終わりです。

  /**
   * Emits change event to all registered event listeners.
   *
   * @returns {Boolean} Indication if we've emitted an event.
   */
  emitChange() {
    return this.emit(CHANGE_EVENT);
  },

emitChange内部ではただEventEmitterのemitを呼んでいるだけですね。 で、後はViewがStoreからの通知を受け取ってレンダーするだけ…かと思っていたのですが、このサンプルではView側でStoreの通知を監視していませんでした。そこで、まだ解説していなかったコードを見ていきます。

src/components/App.js(AppActions.loadPage後)

handleClick(event) {
...
event.preventDefault();
    AppActions.loadPage(path, () => {
      AppActions.navigateTo(path);
    });

handleClick内でAppActions.loadPageを実行後、コールバックでAppActions.navigateToを実行しています。

src/actions/AppAction.js

navigateTo(path, options) {
    if (ExecutionEnvironment.canUseDOM) {
      if (options && options.replace) {
        window.history.replaceState({}, document.title, path);
      } else {
        window.history.pushState({}, document.title, path);
      }
    }

    Dispatcher.handleViewAction({
      actionType: ActionTypes.CHANGE_LOCATION,
      path
    });
  },

AppActionsのnavigateToでは、pushState/replaceStateで履歴を操作していました。その後はactionTypeをActionTypes.CHANGE_LOCATIONとしてDispatcher.handleViewActionを実行しています。そして、CHANGE_LOCATIONをどこで監視しているのか調べていくと、

src/app.js

function run() {
  // Render the top-level React component
  let props = {
    path: path,
    onSetTitle: (title) => document.title = title,
    onSetMeta: setMetaTag,
    onPageNotFound: emptyFunction
  };
  let element = React.createElement(App, props);
  React.render(element, document.body);

  // Update `Application.path` prop when `window.location` is changed
  Dispatcher.register((payload) => {
    if (payload.action.actionType === ActionTypes.CHANGE_LOCATION) {
      element = React.cloneElement(element, {path: payload.action.path});
      React.render(element, document.body);
    }
  });
}

と、app.jsのrunメソッドの中でDispatcher.registerを呼んでいました。 この後はCloneされたAppのrenderが呼ばれ、ここで描画が完了します。

最後のここの部分に関しては、Fluxに従うのであればStoreから通知を受けたViewで処理するのではないのかと思いましたが、アプリケーション全体に関わる部分だからapp.jsで処理をしているとかでしょうか。

ちなみに、App.js内でpopstateをaddEventListenerすることでブラウザバックにも対応していました。

...
componentDidMount() {
    window.addEventListener('popstate', this.handlePopState);
    window.addEventListener('click', this.handleClick);
  }
...

handlePopState(event) {
    AppActions.navigateTo(window.location.pathname, {replace: !!event.state});
  }