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

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

2017.03.1
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>

このコンポーネントでは以下のことが行われています。

  1. <form>submitされたらイベントハンドラであるcreateTaskが叩かれる
  2. <input>を参照し、そのvalue(=入力された値)をthis.tasksに追加
  3. this.tasksに紐付いている<li>eachが更新され、表示上も新たなタスクが追加される
  4. <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>

このコンポーネントでは以下のことが行われます。

  1. <input>が変更されたらその値をthis.nameに反映
  2. this.nameが空でなければ<button>disabledを解除
  3. <form>submitされたらcreateが叩かれる
  4. 属性on-createから渡された親コンポーネントのcreateTaskopts.onCreateで参照できるので、this.nameを引数にして叩く
  5. 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から渡された親コンポーネントのremoveTaskopts.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を定義しました。

このコンポーネントでは以下のことが行われます。

  1. <input><select>が変更されたら、それぞれに紐付けてある状態this.namethis.priority)を更新
  2. this.nameが空でなければ<button>disabledを解除
  3. <form>submitされたらcreateが叩かれる
  4. 属性on-createから渡された親コンポーネントのcreateTaskopts.onCreateで参照できるので、 各状態this.namethis.priority)を格納したオブジェクトを引数にして叩く
  5. this.namethis.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>にクラスを付与する

新しい話として出てきたのはifclassです。

iffalseならその要素をDOMごと非表示にします(条件属性)。

classtrueならその文字列がクラスに付与されます。カンマ区切りで複数指定できます(クラス省略記法)。

そして、最後に完了したタスクを一括削除するボタンを追加します。

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からisCheckedfalseのものだけ抽出して、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.isAppearedtrueにして、transitionを指定しておいたappearedというクラスを付与する

this.on('mount', function(){})のところで、このコンポーネントがマウントされた時に実行される処理を記述します。このようなライフサイクルイベントは他にupdateupdatedbefore-mountbefore-unmountunmountがあります(イベント)。

ただ、マウント時に即実行してしまうと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の印象です。

 

編集部オススメ

  • このエントリーをはてなブックマークに追加

あわせて読みたい記事

この記事を書いたメンバー

ほそだ

Designer

虚弱体質 of 虚弱体質。 毎朝のアサイージュースでなんとか人並みの血圧を維持している。

ドワンゴデザイナー メンバー募集 ! !
Better site: ProxyBot webproxy https://proxybot.cc/b?q=sFukn6F8a1kiU68s68PTSt350N8dnoY