Riot.js Advent Calendar 2016 の19日目です。
今まで書いた人と似通ってたりしますが、よろしくお願いします。
<- 前日 @clown0082 さん: karma + mocha + Riot.jsでのテスト はじめの第一歩
-> 翌日 @potato4d さん: Riot.jsを通じて気軽に翻訳とOSSへの貢献を体験してみよう
#はじめに
今回は、僕的なriotのプロジェクト構成についてディレクトリ構成を晒しながらざっくり説明します。
SPAしつつ、クローラー対策としてSSR(サーバーサイドレンダリング)してます。
ふんわり話す程度なのでリファレンスっていうより読み物と思っていっただけたらと。
-
今回説明すること
- ディレクトリ構成
- それぞれのディレクトリが何してるかの解説
- 使ってるライブラリ(ちょっとだけ)
-
今回説明しないこと
- ソースコード解説
#さっそく
さっそくではありますがプロジェクトのディレクトリツリーです。
/src
┣━ common
┃ ┣━ action
┃ ┃ ┣━ UserAction.js
┃ ┃ ┗━ ...
┃ ┣━ Core.js
┃ ┣━ Dispatcher.js
┃ ┣━ store
┃ ┃ ┣━ User.js
┃ ┃ ┗━ ...
┃ ┣━ tags
┃ ┃ ┣━ atoms
┃ ┃ ┃ ┣━ text-input.tag
┃ ┃ ┃ ┗━ ...
┃ ┃ ┣━ molecules
┃ ┃ ┣━ organisms
┃ ┃ ┗━ page
┃ ┃ ┣━ page-user.tag
┃ ┃ ┗━ ...
┃ ┣━ util
┃ ┃ ┣━ index.js
┃ ┃ ┗━ ...
┃ ┗━ ...
┃
┣━ frontend
┃ ┣━ main.js
┃ ┗━ route
┃ ┣━ user.js
┃ ┗━ ...
┃
┗━ backend
┣━ index.js
┣━ page
┃ ┣━ UserPage.js
┃ ┗━ ...
┣━ route
┃ ┣━ user.js
┃ ┗━ ...
┗━ view
┣━ main.jade
┣━ isomorphic.jade
┗━ ...
今回触れるものに絞っておりますが、このほかutilやlogなど必要に応じていろいろ追加して使っています。
(なんか長くてみづらいのであとで分けるかもです。)
基本構成
commonがプロジェクトの根幹になっています。基本的には普通にflux
を用いてSPAを組んでいます。frontendはクライアントサイドのルーティングを主に担っており、backendはwebサーバでルーティングとSSRをします。それぞれ詳しい話は後述します。
共有ファイルとSSR
frontendで使うもの, backendで使うもの、そしてfrontend,backend共有で使うcommonに分けてます。分岐が必要なタイミングでそれぞれnodeとbrowserの判定をいれても良かったんですが、よく忘れるのとif文が多くなって汚くみえちゃったので分けました。common以下にはブラウザでもnodeでも使えるmethodのみを用いて記述します。基本的には呼び出しもすべてcommon以下で完結するようにしますが、どうしてもfrontendのみ、serverのみで動くコードを使いたいときは、common以下にラッパーをつくりフォールバックを書いてます。
ブラウザアクセスの際のエントリーポイントはfrontend/main.js
でクライアントサイドでルーティングをして、frontend/page
をrenderingをしています。
クローラからアクセスされたときはpathごとにbackend/page
でエントリーを分けています。
Core.js
riot.jsでは、ブラウザではriot.mount
, サーバではriot.render
を用いてDOMのレンダリングするため、同一のコードでは動かないので、wrapperを作ってます。それがcommonにあるCore.js
で、globalなmixinやmount前後で共通で行いたい処理など書いてます。
export default class Core {
...
mount(selector, tagName, opts) {
// 共通処理など
...
if (util.isBrowser()) {
riot.mount(selector, tagName, opts)
} else {
riot.render(tagName, opts)
}
}
...
}
タグの中でも動的にprogramaticalyにmountしたかったりfluxでstoreをタグに渡したかったりがあるので、僕の場合はCoreにdispacherおよびstoreを管理させた上で、GlobalMixinですべてのタグに渡してます。
export default class Core {
constructor(opts) {
this.dispatcher = new Dispatcher();
// global mixin
riot.mixin({core: this});
...
}
/**
* storeを追加
* @param store
*/
addStore(store) {
this.dispatcher.addStore(store);
}
/**
* storeを取得
* @return store
*/
getStore(key) {
return this.dispatcher.getStore(key);
}
/**
* カスタムtagのmountのラッパー
*/
mount(selector, tagName, opts) {
// 共通処理など
...
if (util.isBrowser()) {
riot.mount(selector, tagName, opts)
} else {
riot.render(tagName, opts)
}
}
}
API Client
API ClientはSuperagent
を使ってます。NodeでもBrowserでも動いてくれるので、特に気にせず使えて便利です。一点だけ、rollupを使ってbundleするときにバグるっていう罠があります(解決策求ム)。
backend
backendでは前述の通り、要はwebサーバです。expressを使っています。各routeでクローラー判定を行って、ブラウザからの場合はmain.jade
を、クローラーからの場合は/page
をrenderingし、それを用いてhtmlを作って返してます。また、riotのサーバサイドレンダリングではcssを返してくれないのと、クローラー対策でしか使用しないため、frontが作るdomとは若干異なっており、ここではrenderingされたtagを並べてるだけのhtmlがcallbackのdom
に入ります。
import UserPage from '../page/UserPage'
export default class User {
constructor() {
super();
this.uri = '/user';
}
use(router) {
router.route(this.uri)
.get(this.get)
;
}
get(req, res, next) {
if (!res.locals.bot) {
// Browser-side rendering
res.render("main", res.locals);
return;
}
res.render("main", res.locals);
let page = new UserPage(res.locals);
page.render((err, data) => {
res.render('isomorphic', {
dom: data.dom,
local: res.locals
});
});
}
}
PageではCoreの初期化、Storeの準備を行い必要なデータをAPI叩いて取ってきてレンダリングしてます。
class UserPage extends Page {
constructor(locals) {
super();
this.core = new Core();
this.core.addStore(new UserStore());
}
render(callback) {
let self = this;
Promise.all([
new Promise((resolve, reject) => {
// user get
self.core.getStore('user').on('GET_USER', (state, store) => {
resolve();
})
let action = new UserAction()
action.get('imakei')
},
]).then((result) => {
let raws = [];
// 適当に必要なものをrendering
// header
raws.push(self.core.mount(null, 'header-tag');
...
// page本体
raws.push(self.core.mount(null, 'page-user');
callback(null, {
dom: raws.join(''),
});
})
}
frontendとルーティング
フロントエンドのルーティングはriot-routeを用いてます。backendと構成を似させるためにfrontend/route
を作っていますがやっていることはルートに合わせてapp.tagのcontainerにmountするtagを決定しているだけです。
下記の例では(actionに隠蔽化されちゃってますが)、http://hoge.com/user
にアクセスしたときにpage-user
をmountするってことをします。
export default class UserRoute extends Route {
constructor() {
super();
this.uri = '/user'
}
use(route, action) {
route(this.uri, (...args) => {
action.pageChange('page-user', args);
});
}
}
Component
componentですが、最近はAtomicDesignを採用することが多いです。AtomicDesignなんだよって方は AdventCalendarの7日目を書かれている79さんのこの記事とか読んだらわかると思います。AtomicDesignを採用する理由はriotにあってるっていうのとUIComponentのライブラリを使いやすいってところです。
MaterialDesignLiteを例に上げると
<text-input class="mdl-textfield mdl-js-textfield">
<input class="mdl-textfield__input" name="{ opts.name }" type="text" value="{ opts.value }">
<label class="mdl-textfield__label" for="{ opts.name }">{ opts.label }</label>
<style scoped>
</style>
<script>
this.on('mount', () => {
componentHandler.upgradeElement(this.root);
});
</script>
</text-input>
こんな感じでatomとして簡単に使えるのでとてもいい感じです。
終わりに
駆け足になってしまいましたが、最近はこんな構成でriotで開発しております。あまり細かく説明してないですが、何かの参考になれば幸いです。(時間なさすぎたので後で加筆しよ。。。)
また、もし要望があればサンプルのリポジトリとかつくろうかなぁって思ってます。
"ここ詳しく知りたい"とかありましたら気軽にコメントとかメッセとかください。
最後に、遅刻さーせん。