Fluxを修得するためにkriasoft/react-starter-kitをコードリーディングした
発端
react-starter-kitのコード読んでて、Server側でStoreのコード呼んでServerSide Renderingしててこれがisomorphicか...! ってなってる
— ぺら@Civ中毒リハビリ中 (@Peranikov) 2015, 4月 2
実践的なFluxの勉強するためになにかいいサンプルコードないかと漁っていたら、KriaSoftがGithub上で公開しているreact-starter-kitが自前でReact.jsでのページネーションを実装してたりいろいろと参考になったのでコードリーディングしてみました。
kriasoft/react-starter-kit · GitHub
DEMO
ちなみに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についておさらいしておきます。
よくある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}); }