【JavaScript】明日から使えるRiot.js

前書き

相変わらず Web エンジニアみたいなお仕事をしているんですが、最近訳あって IE 8 からの呪縛から解き放たれました。

で、前々から使ってみたかったんだけど機会がなかったRiot.jsを使うことができるようになったので、ここ一ヶ月ほどもりもり書いていました。

ある程度の知見を得ることができたので、適当にメモしていきます。

Riot.js の目的

割とReactと比較されることが多い…と言うか、自分でReact の目指すところは悪くないんだけどあの文法はいくらなんでもクソすぎと言っている*1んですが、最終目標はDOM 操作における jQuery みたいな立ち位置を Web Components で目指すところっぽいです。Polymerの方が実質的なライバルでしょう。*2

また、ちょっとしたデータバインディングもできる*3ためか、Vue.jsからイチャモンをつけられているんですが、そもそもお互いが目指すゴールが違うので比較すること自体がなんだか妙な話です。*4

なお、あくまでライブラリであって、フレームワークではないです。「これ一本で Web アプリをバリバリつくるんじゃ~い!」と思うのであれば、AngularJSでも使ったほうがいいと思います。

つい最近Shadow DOM v1がとうとう Chrome に実装されたことでちょっとしたニュースになりましたが、あれもまだまだ気軽に使えるとは言いがたい*5ので、こう言うライブラリでもっと楽できればいいよね、みたいな気楽なスタンスです。

基本的な使い方

できる限り簡単に Web Components を実装したい!が最終目的なので、公式のガイドを 30 分~ 1 時間ぐらいかけて読めばほとんど全機能を使うことができます。

が、流石に実例がないとわかりづらいので、Bootstrap のサンプルを黙々と Riot.js に書き換えていく作業をやってみましょう。

Bootstrap も「CSS によるコンポーネント化を目指す」みたいなところがあるので、Web Components の概念と非常に相性がよくサンプルとして最適ですし、わかりやすく実用性があります。*6

また、読むだけだとやっぱりわかりづらいので、適当なリポジトリを作っておきました。Express で動くので、Node.js が入ってればすぐ遊べます。

元のソース

面倒なので<body>以下のところを一部引っこ抜いてきました。

<body>
  <nav class="navbar navbar-default navbar-fixed-top">
    <div class="container">
      <div class="navbar-header">
        <button
          type="button"
          class="navbar-toggle collapsed"
          data-toggle="collapse"
          data-target="#navbar"
          aria-expanded="false"
          aria-controls="navbar"
        >
          <span class="icon-bar"></span> <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="#">Project name</a>
      </div>
      <div id="navbar" class="navbar-collapse collapse">
        <ul class="nav navbar-nav">
          <li class="active"><a href="#">Home</a></li>
          <li><a href="#about">About</a></li>
          <li><a href="#contact">Contact</a></li>
        </ul>
      </div>
    </div>
  </nav>

  <div class="container">
    <div class="jumbotron">
      <h1>Navbar example</h1>
      <p>
        This example is a quick exercise to illustrate how the default, static
        and fixed to top navbar work. It includes the responsive CSS and HTML,
        so it also adapts to your viewport and device.
      </p>
      <p>
        To see the difference between static and fixed top navbars, just scroll.
      </p>
      <p>
        <a
          class="btn btn-lg btn-primary"
          href="../../components/#navbar"
          role="button"
          >View navbar docs &raquo;</a
        >
      </p>
    </div>
  </div>

  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  <script type="text/javascript" src="./javascripts/bootstrap.min.js"></script>
</body>

どっから手を付けて行こうかな、って感じですが、簡単そうな<div class="jumbotron">からいきますか。

カスタムタグと<yield/> [diff]

まずは適当な.tagファイルを作ります。jumbotron なんだから、jumbotron.tagとかでいいんじゃないですかね。(適当)

作り方はとても簡単です。

  1. ルート要素として新しく作りたいタグ(今回は<jumbotron>)を指定する。
  2. ルート要素の配下に実際に表示される内容を書く
<jumbotron>
  <div class="jumbotron">
    <yield/>
  </div>
</jumbotron>

これで終わりです。<yield/>を指定することで、呼び出し元のカスタムタグの中に記述された HTML をそのまま読み込んでくれます。

次は実際にこのカスタムタグを先ほどの html で読み込んでみましょう。これもとても簡単です。

  1. riot+compiler.min.jsを読み込む
  2. script type="riot/tag"を指定し、.tagを読み込む
  3. riot.mountでマウントする
<div class="container">
  <jumbotron>
    <h1>Navbar example</h1>
    <p>
      This example is a quick exercise to illustrate how the default, static and
      fixed to top navbar work. It includes the responsive CSS and HTML, so it
      also adapts to your viewport and device.
    </p>
    <p>
      To see the difference between static and fixed top navbars, just scroll.
    </p>
    <p>
      <a
        class="btn btn-lg btn-primary"
        href="../../components/#navbar"
        role="button"
        >View navbar docs &raquo;</a
      >
    </p>
  </jumbotron>
</div>

<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script type="text/javascript" src="./javascripts/bootstrap.min.js"></script>
<script
  type="text/javascript"
  src="./javascripts/riot+compiler.min.js"
></script>
<script type="riot/tag" src="./components/jumbotron.tag"></script>
<script>
  riot.mount("jumbotron");
</script>

当然これだけだと何の旨味もありません。次は<nav>に書かれたグローバルナビゲーションもコンポーネント化してみましょう。

まずは何も考えず html をコピペして.tagを作ってしまいます。名前はglobal-nav.tagとかでいいでしょう。

次はカスタムタグのマウントです。マウントする要素が増えてきたらriot.mount('*')で一括指定してしまいましょう。

<body>
  <global-nav></global-nav>

  <div class="container">
    <jumbotron>
      <h1>Navbar example</h1>
      <p>
        This example is a quick exercise to illustrate how the default, static
        and fixed to top navbar work. It includes the responsive CSS and HTML,
        so it also adapts to your viewport and device.
      </p>
      <p>
        To see the difference between static and fixed top navbars, just scroll.
      </p>
      <p>
        <a
          class="btn btn-lg btn-primary"
          href="../../components/#navbar"
          role="button"
          >View navbar docs &raquo;</a
        >
      </p>
    </jumbotron>
  </div>

  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  <script type="text/javascript" src="./javascripts/bootstrap.min.js"></script>
  <script
    type="text/javascript"
    src="./javascripts/riot+compiler.min.js"
  ></script>
  <script type="riot/tag" src="./components/jumbotron.tag"></script>
  <script type="riot/tag" src="./components/global-nav.tag"></script>
  <script>
    riot.mount("*");
  </script>
</body>

随分すっきりしましたね。すっきりしすぎてむしろちょっと不安です。

ですが、これで<global-nav>を指定するだけでどんなページでもグローバルナビゲーションを呼び出すことが可能になりました。

ついでに<nav>の中に書かれていた色んな要素もコンポーネント化してしまいましょう。一つの.tagファイルに複数のカスタムタグを定義できるので、黙々と分解してしまいます。

<global-nav>
  <nav class="navbar navbar-default navbar-fixed-top">
    <div class="container">
      <nav-header></nav-header>
      <navbar></navbar>
    </div>
  </nav>
</global-nav>

<nav-header>
  <div class="navbar-header">
    <button
      type="button"
      class="navbar-toggle collapsed"
      data-toggle="collapse"
      data-target="#navbar"
      aria-expanded="false"
      aria-controls="navbar"
    >
      <span class="sr-only">Toggle navigation</span>
      <span class="icon-bar"></span> <span class="icon-bar"></span>
      <span class="icon-bar"></span>
    </button>
    <a class="navbar-brand" href="#">Project name</a>
  </div>
</nav-header>

<navbar>
  <div id="navbar" class="navbar-collapse collapse">
    <ul class="nav navbar-nav">
      <li class="active"><a href="#">Home</a></li>
      <li><a href="#about">About</a></li>
      <li><a href="#contact">Contact</a></li>
    </ul>
  </div>
</navbar>

カスタムタグの中にロジックを持たせる [diff]

困ったことに、このまま使っても<navbar>.activeが一切変わらず使い物になりません。

普通ならページごとに.activeを適用する場所だけを変えればいいんですが、せっかくコンポーネント化したのにそれはダサすぎです。自動でやってもらえるようにしましょう。

<navbar>
  <div id="navbar" class="navbar-collapse collapse">
    <ul class="nav navbar-nav">
      <li each="{ nav in navs }" class="{ active: isActive(nav.link) }">
        <a href="{ nav.link }">{ nav.title }</a>
      </li>
    </ul>
  </div>

  <script>
    this.navs = [
      {
        link: "index.html",
        title: "Home"
      },
      {
        link: "about.html",
        title: "About"
      },
      {
        link: "contact.html",
        title: "Contact"
      }
    ];

    this.isActive = function(path) {
      return path == location.pathname.replace(/^\//, "");
    };
  </script>
</navbar>

Riot.js ではカスタムタグ自身が持つ JavaScript のオブジェクトを自由にバインディング可能です。*7

また、ifeachを使うことで要素の表示/非表示やループを簡単に定義することができます。

カスタムタグ内にのみ適用されるスタイルを定義する [diff]

ある日、不意に「グローバルナビゲーションの.activeの背景色だけ変えたいな…」と思う日が来るかもしれません。*8ちゃんとコンポーネント化してあれば簡単にできます。カスタムタグ内で<style scoped>を定義しましょう。*9

<global-nav>
  <nav class="navbar navbar-default navbar-fixed-top">
    <div class="container">
      <nav-header></nav-header>
      <div id="navbar" class="navbar-collapse collapse">
        <navbar></navbar>
        <ul class="nav navbar-nav navbar-right">
          <li class="active"><a href="./">scope test</a></li>
        </ul>
      </div>
    </div>
  </nav>
</global-nav>

<navbar>
  <ul class="nav navbar-nav">
    <li each="{ nav in navs }" class="{ active: isActive(nav.link) }">
      <a href="{ nav.link }">{ nav.title }</a>
    </li>
  </ul>

  <style scoped>
    /* bootstrapの指定が複雑すぎるので!importantで強制的に上書き */
    .active > a {
      background-color: #bbdefb !important;
    }
  </style>

  <script>
    this.navs = [
      {
        link: "index.html",
        title: "Home"
      },
      {
        link: "about.html",
        title: "About"
      },
      {
        link: "contact.html",
        title: "Contact"
      }
    ];

    this.isActive = function(path) {
      return path == location.pathname.replace(/^\//, "");
    };
  </script>
</navbar>

ついでに本当にカスタムタグ内だけで完結しているかすぐわかるようscope testなるものも<global-nav>に追加しておきました。scopedを削除するとそっちも変な青色になるのがわかると思います。

また、「ある特定のルールだけは scoped にして、他は全体に適用できるようにしたい」と思ってしまった人のために、:scope擬似クラスなんてものも用意されています。

正直デフォルトで全部scopedにしておいてほしいような気もします*10が、まぁ妙な事故を起こさないよう適切に指定してあげてください。

ちょっと応用的な使い方

なんとなく使い方はわかってきたと思うので、もう少し応用的な使い方も説明していきましょう。

次はこっちのサンプルを Riot.js で書きなおしていきます。

呼び出し元からのパラメータを設定 [diff]

まずはブログの内容部分をコンポーネント化してみますか。

元々はこんな感じでした。

<div class="blog-post">
  <h2 class="blog-post-title">Sample blog post</h2>
  <p class="blog-post-meta">January 1, 2014 by <a href="#">Mark</a></p>

  <!-- 本文が書かれているだけなので省略 -->
</div>

カスタムタグにぶち込みます。ついでに CSS も持ってきてしまいましょう。ここ以外で使わなそうだし。

<blog-post>
  <div class="blog-post">
    <yield/>
  </div>

  <style scoped>
    .blog-post {
      margin-bottom: 60px;
    }
    .blog-post-title {
      margin-bottom: 5px;
      font-size: 40px;
    }
    .blog-post-meta {
      margin-bottom: 20px;
      color: #999;
    }

  </style>
</blog-post>

</blog-post>

せっかくコンポーネント化したんだから、タイトルや投稿日、投稿者なんかはできれば外部からパラメータとして欲しいですね。当然できます。

カスタムタグはこんな感じに定義します。

<blog-post>
  <div class="blog-post">
    <h2 class="blog-post-title">{ opts.title }</h2>
    <p class="blog-post-meta">{ opts.meta } by <a href="{ opts.link }">{ opts.author }</a></p>
    <yield/>
  </div>

  <style scoped>
    .blog-post {
      margin-bottom: 60px;
    }
    .blog-post-title {
      margin-bottom: 5px;
      font-size: 40px;
    }
    .blog-post-meta {
      margin-bottom: 20px;
      color: #999;
    }

  </style>
</blog-post>

呼び出し元はこんな感じです。

<blog-post
  title="Sample blog post"
  meta="January 1, 2014"
  author="Mark"
  link="#"
>
  <!-- 本文なので省略 -->
</blog-post>

見てもらえればわかる通り、HTML の属性として指定したものはカスタムタグ内でoptsから取得することができます。どこでoptsを使ってどこで<yield/>を使うかは設計者の腕の見せどころです。

また、optsに渡す情報はタグのマウント時に動的に指定することもできます。*11

<script>
  riot.mount("blog-post", {
    title: "Sample blog post",
    meta: "January 1, 2014",
    author: "Mark",
    link: "#"
  });
</script>

サーバからの情報と連携 [diff]

「ブログの内容は全部 JSON に書かれていて、それを読み込んで表示する」みたいなパターンをやってみましょう。

まずはデータの準備です。どこかの何かの API 叩くと、こんな感じの JSON 配列が来るとします。

[
  {
    "title": "Sample blog post",
    "meta": "January 1, 2014",
    "author": "Mark",
    "link": "#",
    "body": "<p>This blog post shows a few different types of content that's supported and styled with Bootstrap. Basic typography, images, and code are all supported.</p>\n<hr>\n<p>Cum sociis natoque penatibus et magnis <a href=\"#\">dis parturient montes</a>, nascetur ridiculus mus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Sed posuere consectetur est at lobortis. Cras mattis consectetur purus sit amet fermentum.</p>\n<blockquote>\n<p>Curabitur blandit tempus porttitor. <strong>Nullam quis risus eget urna mollis</strong> ornare vel eu leo. Nullam id dolor id nibh ultricies vehicula ut id elit.</p>\n</blockquote>\n<p>Etiam porta <em>sem malesuada magna</em> mollis euismod. Cras mattis consectetur purus sit amet fermentum. Aenean lacinia bibendum nulla sed consectetur.</p>\n<h2>Heading</h2>\n<p>Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>\n<h3>Sub-heading</h3>\n<p>Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.</p>\n<pre><code>Example code block</code></pre>\n<p>Aenean lacinia bibendum nulla sed consectetur. Etiam porta sem malesuada magna mollis euismod. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa.</p>\n<h3>Sub-heading</h3>\n<p>Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean lacinia bibendum nulla sed consectetur. Etiam porta sem malesuada magna mollis euismod. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.</p>\n<ul>\n<li>Praesent commodo cursus magna, vel scelerisque nisl consectetur et.</li>\n<li>Donec id elit non mi porta gravida at eget metus.</li>\n<li>Nulla vitae elit libero, a pharetra augue.</li>\n</ul>\n<p>Donec ullamcorper nulla non metus auctor fringilla. Nulla vitae elit libero, a pharetra augue.</p>\n<ol>\n<li>Vestibulum id ligula porta felis euismod semper.</li>\n<li>Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.</li>\n<li>Maecenas sed diam eget risus varius blandit sit amet non magna.</li>\n</ol>\n<p>Cras mattis consectetur purus sit amet fermentum. Sed posuere consectetur est at lobortis.</p>"
  }
  // 以下、同じようなものなので省略
]

この配列をeachでぐるぐる回し、先ほどの<blog-post>に表示させたいですね。こんな感じに。

<blog-main>
  <div class="col-sm-8 blog-main">
    <blog-post
      each="{ post in posts }"
      title="{ post.title }"
      meta="{ post.meta }"
      author="{ post.author }"
      link="{ post.link }"
      body="{ post.body }"
    >
    </blog-post>

    <nav>
      <ul class="pager">
        <li><a href="#">Previous</a></li>
        <li><a href="#">Next</a></li>
      </ul>
    </nav>
  </div>

  <style scoped>
    .blog-main {
      font-size: 18px;
      line-height: 1.5;
    }
  </style>

  <script>
    var self = this;
    this.posts = [];
    // API作るの面倒なのでJSON読みこむだけ
    $.get("/javascripts/blog.json").done(function(data) {
      self.posts = data;
      self.update();
    });
  </script>
</blog-main>

<blog-post>
  <div class="blog-post" name="body">
    <h2 class="blog-post-title">{ opts.title }</h2>
    <p class="blog-post-meta">
      { opts.meta } by <a href="{ opts.link }">{ opts.author }</a>
    </p>
  </div>

  <style scoped>
    .blog-post {
      margin-bottom: 60px;
    }
    .blog-post-title {
      margin-bottom: 5px;
      font-size: 40px;
    }
    .blog-post-meta {
      margin-bottom: 20px;
      color: #999;
    }
  </style>

  <script>
    // opts.bodyをそのまま表示するとエスケープされてしまうので、何らかの要素のinnerHTMLに追加しないといけない。
    // カスタムタグ内のnameが付いているDOMはthisからアクセスできるので、適当にdivにbodyなんてnameを指定している。
    $(this.body).append($(opts.body));
  </script>
</blog-post>

ここでのキモは JSONAjax で取得した後のself.update()です。確かに Riot.js はバインディング機構を持ってはいますが、厳密に Observable なわけではありません。カスタムタグ外のコンテクスト*12で何らかの値を更新した場合はちゃんと通知してあげる必要があります。*13

まとめ

本当はもっと落とし穴的な要素も紹介したかったんですが、あまりにも長くなってしまった*14ので一旦切ります。

続きを書けるのはいつになるんでしょうね。1 ヶ月後とかですかね。

参考

*1:私が Riot.js を気に入ったのはこれをはっきり言い切ったところが一番大きいです。

*2:今 Polymer ってどうなってるんでしょうね?React 以降全然話を聞かなくなってしまいましたが。

*3:と言うか、「コンポーネント」を謳っている以上、何らかの形で動的に表示するデータを差し替えられなきゃ使えたもんじゃない。

*4:実際、Riot.js 側の公式ガイドでは Vue.js の話は全くしていない。

*5:かなり頑張ってるとは思いますが…。

*6:私が最近やった仕事もひたすら「Bootstrap のせいでネストが深くなるところを Riot.js で書き直す」でした。

*7:script タグは省略可能ですが、個人的には唐突感があってあまり好きじゃないのでちゃんと書いてます。

*8:来ないと思う。

*9:なお、style scoped = Scoped CSS 自体は Riot.js 独自の機能ではなく、ちゃんとした HTML の仕様です。(実装が進んでいるとは言っていない) この記事で詳しく解説されているので読んでおきましょう。

*10:全体に適用するなら普通に.css ファイル書けばいいし…。

*11:ちなみに、マウント時に動的指定しつつ属性値で渡している場合は属性値で渡した値が優先される。

*12:めちゃめちゃ簡単に言うと、this がカスタムタグ以外を指している状態

*13:公式にObserverがあるように見えますが、これはどちらかと言うとカスタムタグ同士の Pub/Sub を実現するためのものです。

*14:サンプルがデカすぎるのが悪い。