Webデザイナーがコンポーネント指向な実装をはじめるなら、まずはRiotから入ってみるといいかもしれない

こんにちは。ほそだです。
以前、このブログでReactについて書いてから早一年あまり。まわりを見渡せば、ReactはもはやかつてのjQueryのように当たり前の存在になっていて、時の流れの早さを感じます。
とはいえ、僕はエンジニアではなくデザイナーなので、従来のようなデザイナーだけで完結する規模感のWeb実装も相変わらず行います。その際、せっかくReactを通して培ったコンポーネント指向な実装をもうちょっとカジュアルにやれないかなと思い、この半年ほどRiotを使ってみました。
ということで、今回は主にデザイナー向けにRiotについて解説します。
Riotとは
コンポーネント指向でビューを作っていく、だいぶReactっぽいライブラリです。
Riot.js — Simple and elegant component-based UI library
国内だと2016年春くらいに若干ブレイクの兆しがありましたが、その後あまり伸びることなく(汗)今日に至っています。先日発表されたJavaScript ベスト・オブ・ザ・イヤー 2016のフロントエンドフレームワーク部門では、10位に甘んじてしまいました(しかしVueの勢いがすごい…)。
Riotの主な特長は以下になります。
軽い
Riot紹介記事でまず挙げられるのがこれですね。
- polymer.min.js – 49.38 KB
- react.min.js – 45.06 KB
- riot.min.js – 10.18 KB
この程度であれば、仮にjQueryのような重めなライブラリを併用せざるを得なかったとしても許容範囲なのかなと思います。
ユニークなシンタックス
コンポーネントを実装するにあたって、ReactがJSXという独自のシンタックスを用いてJavaScriptの中にHTMLを記述するのに対して、Riotは逆にHTMLの中にJavaScript(およびCSS)を書きます。
<my-component> <h1>{ opts.title }</h1> <p>{ opts.description }</p> <script> // js </script> <style> /* css */ </style> </my-component>
でもこれってよく見ると昔から見慣れてる光景ですよね。見慣れてるから怖くないです。
公式ドキュメントに日本語版がある
開発者に日本人の方がいるということもあってか、公式サイトに日本語のドキュメントが用意されています。
英語以外では日本語の他に、スペイン語、フランス語、ロシア語、中国語が用意されていますが、v3に対応しているのは2017/3/1現在、英語と日本語のみです。
当然ながらまずはじめに英語版が更新されるので、その他の言語は後追いになりますが、敷居の低さを重要視するのであれば母国語のドキュメントがあるのは心強いです。
何か作ってみる
とりあえず何か作ってみましょう。公式で紹介されるようなTODOリスト的なものを、最初は単純なものから徐々に複雑にしていってみます。
STEP1 : 下準備
まずはベースとなるhtmlを用意します。
index.html
<!DOCTYPE html> <html> <head> <title>Riot Sample</title> </head> <body> <script src="./riot+compiler.min.js"></script> </body> </html>
今回は何ができるのかを体感するのが主目的なので、手っ取り早く動かすためにコンパイラを内包したRiotのJSファイルを昔ながらの<script>
タグで予め読み込んでおく方式で進めます。
http://riotjs.com/ja/download/
2017/3/1現在、最新バージョンは3.3.1です。いくつか種類がありますが、riot+compiler.min.jsを選んでください。CDNでもOKです。
riot+compiler.min.jsはコンパイラを内包したファイルなのでサイズはその分大きくなります。
コンパイラなし版を使うにはプリコンパイルが必要です(プリコンパイル)。ES2015以降を使いたい場合も同様です。
もちろん各種フロントエンドのツールにも対応しています。
gulp | gulp-riot |
---|---|
browserify | riotify |
webpack | tag-loader |
rollup | rollup-plugin-riot |
STEP2 : カスタムタグでコンポーネントを作ってみる
ではとりあえず手始めに、入力したテキストをただただ一覧に追加していくだけのコンポーネントを作ってみることにします。
todo.tag
<todo> <h1>TODO List</h1> <form onsubmit={ createTask }> <input type="text" ref="taskName" placeholder="新規タスクを入力"> <button>追加</button> </form> <ul> <li each={ task, i in tasks }> #{ i }: { task } </li> </ul> <script> this.tasks = []; createTask(e) { e.preventDefault(); var taskName = this.refs.taskName; this.tasks.push(taskName.value); taskName.value = ''; } </script> <style> :scope { display : block; background-color : #fff; font-size : 1.6rem; } h1 { font-size : 1.8rem; color : #666; padding : 10px 10px 0; } /* 略 */ </style> </todo>
このコンポーネントでは以下のことが行われています。
<form>
がsubmitされたらイベントハンドラであるcreateTask
が叩かれる<input>
を参照し、そのvalue
(=入力された値)をthis.tasks
に追加this.tasks
に紐付いている<li>
のeach
が更新され、表示上も新たなタスクが追加される<input>
のvalue
を空に戻す
JSで定義しているthis.tasks
はこのコンポーネントが持っている状態(Reactでいうところのstate)です。これを更新することで、表示上のタスク一覧も更新されます。
RiotでDOMを参照する時は、HTML側でref="hoge"
のように名前をつけておいて、JS側でthis.refs.hoge
のようにします(名前付き要素)。
CSSは<style>
タグの中で指定します。指定したスタイルはこのコンポーネントの下位要素セレクタとして扱われます。:scope
で指定しているのはこのコンポーネントで最も親となる要素です(タグのスタイリング)。CSSをコンポーネントに含めるかどうかは判断が分かれるところだと思いますが(個人的には含めない派です)、ひとまずこの記事では含めるものとします。
each
についてはいくつか書き方があるのですが(ループ)、この記事ではin
を使った書き方で統一します。
メソッドはES2015の記法なのに、なんで変数はvar
なんだと言われてしまいそうですが、Riotのメソッドはbabel等のトランスパイラを使わなくてもこのように書けます(逆にトランスパイラを使うと使えなくなるようです…)
これをさきほどのindex.htmlから読み込みます。
index.html
<todo /> <script data-src="./todo.tag" type="riot/tag"></script> <script src="./riot+compiler.min.js"></script> <script> riot.mount('todo'); </script>
コンポーネントのファイルの読み込みにも<script>
タグを使います。type
属性はriot/tagです。
riot.mount('todo')
で<todo />
というタグに、先ほどのコンポーネントをマウントします。riot.mount
は、引数でマウント先やパラメータを指定できます(タグのマウント)。
ファイルの参照先は通常のsrc
でも読み込めますが、data-src
としておくことでChromeがコンソールに出すwarning(invalid type/language attributes)を回避できます(In-browser compilation)。
表示してみる:序
入力したテキストをただただ一覧に追加していくだけのコンポーネントができました。
CodePenに貼っているHTMLのコードでは、都合上コンポーネントを外部.tagファイルにはせずインラインで記述しています(インブラウザ・コンパイル)。
それと、Riotのコンポーネント内では<script>
タグを省略しても良いことになっています。個人的にはあまり省略しないほうが良いと思ってますが、CodePen上では省略しないと動かなかったのでそうしています。
STEP3 : 子コンポーネントに切り出す
このままだと役に立ちそうもないので、もうちょっと機能を追加してみます。
- 追加したタスクを削除できる
- タスク名が未入力であれば追加ボタンを押せなくする
ただ、さきほどのtodo.tagを複雑にしたくないので、入力フォーム部分と各タスク部分を別のコンポーネントに切り出してみましょう。
todo.tag
<todo> <h1>TODO List</h1> <entry on-create={ createTask } /> <ul> <li each={ task, i in tasks }> <task index={ i } name={ task } on-remove={ parent.removeTask } /> </li> </ul> <script> this.tasks = []; createTask(value) { this.tasks.push(value); this.update(); } removeTask(index) { this.tasks.splice(index, 1); this.update(); } </script> <style> /* 略 */ </style> </todo>
入力フォーム部分をentry、各タスク部分をtaskというコンポーネントにします。
子コンポーネントにタグの属性でデータを渡します。
- entryには
createTask
メソッドをon-create
という属性で渡します - taskには
this.tasks
の各インデックスをindex
という属性で渡しますthis.tasks
の各値をname
という属性で渡しますremoveTask
メソッドをon-remove
という属性で渡します
イベントハンドラ以外で、任意のタイミングでテンプレートを更新したい場合は、this.update()
を使います(タグのライフサイクル)。
removeTask
を渡す時にparent
がついてますが、これがおそらくRiotで最もハマりやすいところで、each
で回すとそれぞれの要素はコンテキストが子扱いになります。なのでそれらからremoveTask
を参照する際は親を示すparent
が必要です(コンテキスト)。
ここではわかりやすさを優先して<li>
をループさせていますが、カスタムタグ自体をループさせることもできます(カスタムタグのループ)。
では切り出した子コンポーネントを見ていきます。まずは入力フォーム部分です。
entry.tag
<entry> <form onsubmit={ create }> <input type="text" value={ name } placeholder="新規タスクを入力" onkeyup={ changeName }> <button disabled={ name === '' }>追加</button> </form> <script> this.name = ''; changeName(e) { this.name = e.target.value; } create(e){ e.preventDefault(); opts.onCreate(this.name); this.name = ''; } </script> <style> /* 略 */ </style> </entry>
このコンポーネントでは以下のことが行われます。
<input>
が変更されたらその値をthis.name
に反映this.name
が空でなければ<button>
のdisabled
を解除<form>
がsubmitされたらcreate
が叩かれる- 属性
on-create
から渡された親コンポーネントのcreateTask
がopts.onCreate
で参照できるので、this.name
を引数にして叩く this.name
を空に戻す
STEP2ではテキストフィールドの入力内容をコンポーネントの状態としては持たせず、submit時に直接入力欄のDOMを参照してvalue
を取得していました。
今回はコンポーネントが状態(this.name
)を持っており、テキストフィールドの値の変更と紐付けてあるので、入力内容に応じてリアルタイムにテンプレートを更新することができます。ここではthis.name
が空になったら、<button>
にdisabled
属性が追加されるようにしています(真偽値属性)。
Riotでは、親からタグの属性でhoge={ data }
のように渡したデータをopts.hoge
の形で受け取ります。その際注意することは、属性名は小文字とハイフンのみ可なので単語を区切りたい時にケバブケース(on-create
)にするわけですが、opts
のプロパティでそれを受け取る時はプロパティ名がキャメルケース(onCreate
)に変換されています。
次に各タスク部分です。
task.tag
<task> <p>#{ opts.index } { opts.name }</p> <p><button onclick={ remove }>削除</button></p> <script> remove() { opts.onRemove(opts.index); } </script> <style> /* 略 */ </style> </task>
このコンポーネントでは以下のことが行われます。
- 属性
on-remove
から渡された親コンポーネントのremoveTask
がopts.onRemove
で参照できるので、削除ボタンをクリックした時にタスクのインデックスを引数にして叩く
最後にこれら子コンポーネントのファイルもindex.htmlから読み込みます。
index.html
<todo /> <script data-src="./todo.tag" type="riot/tag"></script> <script data-src="./entry.tag" type="riot/tag"></script> <script data-src="./task.tag" type="riot/tag"></script> <script src="./riot+compiler.min.js"></script> <script> riot.mount('todo'); </script>
子コンポーネントそれぞれに対してriot.mount
はしません。親コンポーネントがマウントされれば子コンポーネントもマウントされます。
ではこの状態で改めて表示してみましょう。
表示してみる:破
追加したタスクを削除できるようになりました。それとタスク名が未入力状態だと追加できないようになりました。
STEP4 : 扱うデータ項目を増やしてみる
タスクの削除ができるようになりましたが、まだTODOリストというには寂しいですよね。もうちょっと1つのタスクが持つデータ項目を増やしてみましょう。ということで次のように変更します。
- タスクの重要度を設定できる
- タスク完了即削除ではなく、タスクの完了はチェックボックスにする
- 完了したタスクをあとから一括削除できるようにする
今度は先に入力フォーム部分から見ていきましょう。
entry.tag
<entry> <form onsubmit={ create }> <input type="text" value={ name } placeholder="新規タスクを入力" onkeyup={ changeName }> <select onchange={ changePriority }> <option value="low" selected={ priority === 'low' }>優先度:低</option> <option value="mid" selected={ priority === 'mid' }>優先度:中</option> <option value="high" selected={ priority === 'high' }>優先度:高</option> </select> <button disabled={ name === '' }>追加</button> </form> <script> var DEFAULT_NAME = ''; var DEFAULT_PRIORITY = 'mid'; this.name = DEFAULT_NAME; this.priority = DEFAULT_PRIORITY; changeName(e) { this.name = e.target.value; } changePriority(e) { this.priority = e.target.value; } create(e){ e.preventDefault(); opts.onCreate({ name : this.name, priority : this.priority, isChecked : false }); this.name = DEFAULT_NAME; this.priority = DEFAULT_PRIORITY; } </script> <style> /* 略 */ </style> </entry>
優先度(priority)を選択する<select>
を追加したので、コンポーネントの状態としてthis.priority
を定義しました。
このコンポーネントでは以下のことが行われます。
<input>
、<select>
が変更されたら、それぞれに紐付けてある状態(this.name
、this.priority
)を更新this.name
が空でなければ<button>
のdisabled
を解除<form>
がsubmitされたらcreate
が叩かれる- 属性
on-create
から渡された親コンポーネントのcreateTask
がopts.onCreate
で参照できるので、 各状態(this.name
、this.priority
)を格納したオブジェクトを引数にして叩く this.name
、this.priority
を初期値に戻す
今回は複数のデータ項目があるのでopts.onCreate
に渡すデータをオブジェクトにします。isChecked
は完了状態を示すデータですが、タスク作成時は常に未完了なのでfalse
にしておきます。
続いて、各タスク部分です。
task.tag
<task> <label class={ done: opts.props.isChecked }> <p><input type="checkbox" checked={ opts.props.isChecked } onchange={ toggleCheckbox }></p> <p> <span if={ opts.props.priority === 'low' } class="low">低</span> <span if={ opts.props.priority === 'mid' } class="mid">中</span> <span if={ opts.props.priority === 'high' } class="high">高</span> </p> <p>{ opts.props.name }</p> </label> <script> toggleCheckbox(e) { opts.onUpdate(opts.index, e.target.checked); } </script> <style> /* 略 */ </style> </task>
STEP3との違いは以下の点です。
- 各タスクが持つデータ項目が複数になったので、それを
opts.props
という形で受け取る - 削除ボタンの代わりに完了状態を操作するチェックボックスを追加
- そのため
opts.onUpdate
で親コンポーネントにチェックボックスの値(真偽値)も渡すように変更 opts.props.priority
の値に応じて、優先順位表示を出し分けるopts.props.isChecked
の値に応じて、<label>
にクラスを付与する
新しい話として出てきたのはif
とclass
です。
if
はfalse
ならその要素をDOMごと非表示にします(条件属性)。
class
はtrue
ならその文字列がクラスに付与されます。カンマ区切りで複数指定できます(クラス省略記法)。
そして、最後に完了したタスクを一括削除するボタンを追加します。
todo.tag
<todo> <h1>TODO List</h1> <entry on-create={ createTask } /> <ul> <li each={ task, i in tasks }> <task index={ i } props={ task } on-update={ parent.updateTask } /> </li> </ul> <p> <button disabled={ !hasCheckedTask } onclick={ removeCheckedTasks }>完了したタスクを削除</button> </p> <script> this.tasks = []; this.hasCheckedTask = false; createTask(data) { this.tasks.push(data); this.update(); } updateTask(index, value) { this.tasks[index].isChecked = value; this.hasCheckedTask = this.tasks.some(function(task){ return task.isChecked; }); this.update(); } removeCheckedTasks() { this.tasks = this.tasks.filter(function(task){ return !task.isChecked; }); this.hasCheckedTask = false; this.update(); } </script> <style> /* 略 */ </style> </todo>
追加されたのは以下の点です。
- 一括削除ボタンを押すと、
this.tasks
からisChecked
がfalse
のものだけ抽出して、this.tasks
自体を上書く this.hasCheckedTask
という状態を定義して、一括削除ボタンのdisabled
を切り替える
ではこれでまた表示してみます。
表示してみる:Q
ようやくそれっぽくなってきましたね。あとは仕上げとして細かい調整を入れていこうと思います。
STEP5 : 仕上げ
以下の点を調整します。
- タスク追加時にちょっとしたアニメーションをつける
- タスクがない時は「タスクがありません」という文言を表示する
- タスクが増えてスクロールするようになったら、追加時にスクロール位置を調整して追加したタスクが見えるようにする
ではまず、アニメーションをつけてみます。
task.tag
<task> <label class={ done: opts.props.isChecked, appeared: isAppeared }> <!-- 略 --> </label> <script> this.isAppeared = false; this.on('mount', function(){ // 1ms遅延させる setTimeout(function() { this.isAppeared = true; this.update(); }.bind(this), 1); }); // 略 </script> <style> label { transform : scale(.9); } label.appeared { transform : scale(1); transition : transform .5s cubic-bezier(0.175, 0.885, 0.320, 1.275); } /* 略 */ </style> </task>
追加されたのは以下の点です。
- コンポーネントがマウントされた時に
this.isAppeared
をtrue
にして、transition
を指定しておいたappeared
というクラスを付与する
this.on('mount', function(){})
のところで、このコンポーネントがマウントされた時に実行される処理を記述します。このようなライフサイクルイベントは他にupdate、updated、before-mount、before-unmount、unmountがあります(イベント)。
ただ、マウント時に即実行してしまうとtransition
が反応しないので、setTimeout
でちょっとだけ遅延させています。
残り2つはいっぺんにいきましょう。
todo.tag
<todo> <!-- 略 --> <ul ref="taskContainer"> <li each={ task, i in tasks }> <task index={ i } props={ task } on-update={ parent.updateTask } /> </li> <li if={ tasks.length === 0 } class="empty">タスクはありません</li> </ul> <!-- 略 --> <script> this.tasks = []; this.hasCheckedTask = false; createTask(data) { this.tasks.push(data); this.update(); // スクロール位置を一番下に this.refs.taskContainer.scrollTop = this.refs.taskContainer.scrollHeight; } // 略 </script> <style> /* 略 */ </style> </todo>
追加されたのは以下の点です。
this.tasks
が1つもなければ「タスクはありません」という文言を表示- タスクの追加時に、
<ul>
内のスクロール位置を一番下に移動
これで完成です!
表示してみる:||
実際のWebサービスではこれに加えて、初期表示時やthis.tasks
が更新されたタイミングでAPIを叩くなりの処理が入ることになります。
メリット
とっつきやすい
デザイナーの場合、長らくjQueryベースのJSしか扱ったことがなかったりするので、Classなんかをがっつり使った最近のネイティブな実装はけっこう敷居が高かったりするかもしれません。
Riotはそのへんがうまく隠蔽されていて、実装の簡易さを優先して作られている気がします(逆に言うとエンジニアにとってはそのあたりが不安を感じてしまう要因だと思いますが…)。
この手のライブラリの作法は一通り踏まえられている
- コンポーネントのマウント・アンマウント
- 各コンポーネントが自身の状態を持つこと
- 属性を用いた子コンポーネントへのデータの受け渡し
- ライフサイクルイベント
これらを理解できていると、ReactやVueといった他のコンポーネント指向系ライブラリを使ったプロジェクトに関わった時にも、わりとすんなり入れるんじゃないかなと思います。
Atomic Design
今回の例だと、まず全体像を作ってそこからコンポーネントを切り出していきましたが、Atomic Design的に先に基礎的なコンポーネントから作っていって、それらの組み合わせでデザインを構築していくというアプローチにも向いています。
まとめ
Riot、いかがでしたでしょうか。
個人的には、コストパフォーマンスという点で良くも悪くも立ち位置のはっきりしたライブラリだと思いました。Reactっぽくはあるけど、そもそもファイルサイズが全然違うので、その分やってることは簡易的だったりはします。一定以上複雑なアプリケーションを作るには、堅牢さという点でやや心許ない感じは否めません。そういうところが、昨年ブレイクしきれなかった理由なのかもしれないです。
とはいえ、やはりこのコストの安さは魅力的です。特にデザイナーが自前で実装する規模感のものには、機能面でも学習コストの面でも最適なんじゃないかなと思います。少なくとも、コンポーネント指向なWeb実装をこれからはじめようと思っている方には、いきなりReactやVueよりはRiotあたりから入ってみることをオススメします。
MCには心許ないけど、ひな壇では常に結果を出す芸人のような存在…それがRiotの印象です。