Tech memo

日々学んだ技術のびぼうろく

Riot.js 3.3 と Lumen 5.4 でつくる 初めてのSPA フロントエンド編

はじめに

最近 Riot が気になっていたので、
Laravel製軽量フレームワーク Lumen 5.4 と Riot 3.3 で簡単なブログを作ってみることにした。

今回はフロントエンド編。
DB との接続なしのモックアップを作っていく。
Material Design for Bootstrap も気になっていたのでついでに使ってみる。

ダミーテキストはWebtoolsのダミーテキストジェネレータで作成している。
これを使えば日本語・英語・数字混合のダミーテキストが作れる。

そのほかのエントリーはこちら。

www.yjhm214.com

www.yjhm214.com

スポンサーリンク

作るもの

いろいろ頑張って最終的にこんな感じのブログを作る。

  • 記事一覧の表示(カテゴリーの絞り込みあり)
  • 記事詳細の表示
  • 記事の新規作成・更新

記事一覧
f:id:yjhm214:20170325170558p:plain

記事詳細
f:id:yjhm214:20170325170608p:plain

記事編集
f:id:yjhm214:20170325170616p:plain

記事保存 f:id:yjhm214:20170325170621p:plain

前提

  • Lumen 5.4.5
  • Riot.js 3.3.1

手順

  1. Lumen ルーティングの作成
  2. 必要なCSS/JSファイルのダウンロードと設置
    1. Material Design for Bootstrapで使うCSS/JSファイルのダウンロードと設置
    2. Riot で使う JS ファイルの設定
  3. ベースとなる View の作成
  4. CSS の作成
  5. Riot タグの作成
    1. ナビゲーション
    2. 一覧画面
    3. 詳細画面
    4. 編集画面
  6. Riot ルーターの作成

1. Lumen ルーティングの作成

ベースとなる View ファイルを読み込むためのルーティングを routes/web.php に作成する。

$app->get('/', ['as' => 'index', function () use ($app) {
    return view('index');
}]);

$app->get('/categories', function ()  {
     return redirect()->route('index');
});
$app->group(['prefix' => 'categories'], function ($app) {
    $app->get('{any}', function ()  {
        return view('index');
    });
});

/categories/posts でルートを切っているのは、
これらを切らないと /categories/{category}/posts/{id} の画面でリロードすると ルートが存在しなくてエラーになってしまうため。

/ へのリダイレクトや index.blade.php を表示させることで、
いつでもベースとなる view を表示させ、
そのあと Riot のルーティングにより
URL で指定された view を表示させるようにする。

2. 必要なCSS/JSファイルのダウンロードと設置

2.1. Material Design for Bootstrapで使うCSS/JSファイルのダウンロードと設置

今回は Material Design for Bootstrap を使うため、
必要なファイルをダウンロードする。

ダウンロードしたらそれぞれ以下のようにファイルを設置する。

また、自分で記述する CSS の空ファイル(blog.css) もここで作成しておく。

root
├── public
│   ├── css
│   │   ├── bootstrap.css
│   │   ├── bootstrap-material-design.css
│   │   ├── ripples.css
│   │   └── blog.css
│   │
│   └── js
│       ├── jquery-3.1.1.js
│       ├── bootstrap.js
│       ├── material.js
│       └── ripples.js
│    
│    

2.2. Riot で使う JS ファイルの設定

Riot では以下の2つを使う。

  • Riot 本体 -riot+compiler.js の『ダウンロード』よりダウンロード
  • Riot router (ルーティングライブラリ)
    • route.min の 『Download by yourself』より ダウンロード

それぞれ public/jsに設置する。

また、Riot のルーターを定義するファイルとして
public/js/app.js の空ファイルを用意しておく。

3. ベースとなる View の作成

JS/CSS を配置したら、ベースとなる view resources/views/index.blade.php を作成する。
SPA のため、用意する view は これだけ。

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  <meta http-equiv="Cache-Control" content="no-cache">
  <meta http-equiv="Pragma" content="no-cache">
  <meta http-equiv="cache-control" content="no-cache">
  <meta http-equiv="expires" content="0">
  <meta name="description" content="">
  <meta property="og:type" content="website">
  <meta property="fb:app_id" content="">
  <meta property="og:title" content="">
  <meta property="og:description" content="">
  <meta property="og:image" content="">
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Blog</title>

  <!-- font -->
  <link rel="stylesheet" href="http://fonts.googleapis.com/css?family=Roboto:300,400,500,700" type="text/css">
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
  <!-- font -->

  <!-- Material Design for Bootstrap -->
  <link href="{{ url('/css/bootstrap.css') }}" rel="stylesheet">
  <link href="{{ url('/css/bootstrap-material-design.css') }}" rel="stylesheet">
  <link href="{{ url('/css/ripples.css') }}" rel="stylesheet">
  <!-- Material Design for Bootstrap -->

  <link href="{{ url('/css/blog.css') }}" rel="stylesheet">
</head>


<body>
  <header></header>
  <article></article>

  <!-- Material Design for Bootstrap -->
  <script src="{{ url('/js/jquery-3.1.1.js') }}"></script>
  <script src="{{ url('/js/bootstrap.js') }}"></script>
  <script src="{{ url('/js/material.js') }}"></script>
  <script src="{{ url('/js/ripples.js') }}"></script>
  <script>$.material.init();</script>
  <!-- Material Design for Bootstrap -->

  <!-- Riot -->
  <script src="{{ url('/js/riot+compiler.js') }}"></script>
  <script src="{{ url('/js/route.min.js') }}"></script>
  <script src="{{ url('/tags/raw.tag') }}" type="riot/tag"></script>
  <script src="{{ url('/tags/navbar.tag') }}" type="riot/tag"></script>
  <script src="{{ url('/tags/list.tag') }}" type="riot/tag"></script>
  <script src="{{ url('/tags/post.tag') }}" type="riot/tag"></script>
  <script src="{{ url('/tags/edit.tag') }}" type="riot/tag"></script>
  <script src="{{ url('/js/app.js') }}"></script>
  <!-- Riot -->

  <!-- Saved ダイアログ -->
  <div id="saved-dialog" class="modal fade" tabindex="-1" style="display: none;">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-body">
          <p>Saved.</p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-primary" data-dismiss="modal">OK<div class="ripple-container"><div class="ripple ripple-on ripple-out" style="left: 40.6562px; top: 20px; background-color: rgb(0, 150, 136); transform: scale(10.875);"></div></div></button>
        </div>
      </div>
    </div>
  </div>
  <!-- Saved ダイアログ -->

  <!-- Connection エラー ダイアログ -->
  <div id="connection-error-dialog" class="modal fade" tabindex="-1" style="display: none;">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-body">
          <p>Connection error. Try again.</p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-primary" data-dismiss="modal">OK<div class="ripple-container"><div class="ripple ripple-on ripple-out" style="left: 40.6562px; top: 20px; background-color: rgb(0, 150, 136); transform: scale(10.875);"></div></div></button>
        </div>
      </div>
    </div>
  </div>
  <!-- Connection エラー ダイアログ -->

</body>

</html>

ポイント

  • <header></header> に Riot のカスタムタグを挿入する。
  • <article></article> にも Riot のカスタムタグを挿入する。この部分が切り替わることで SPA を実現。

4. CSS の作成

全体で利用する共通の CSS を public/css/blog.css に書いていく。
こんな感じ。

body {
    line-height: 1.8;
    font-size: 16px;
}

article {
    width: 65%;
    min-width: 300px;
    margin: 0 auto;
}

a:hover {
    text-decoration: none;
}

#edit-btn {
    position: fixed;
    bottom: 40px;
    right: 2%;
}

.label-Tech {
  background-color: #ff5722;
}

.label-Book {
  background-color: #03a9f4;
}

.label-Hobby {
  background-color: #4caf50;
}

.label-Others {
  background-color: #9e9e9e;
}

.p15 {
    padding: 15px;
}

.p50 {
    padding: 50px;
}

.pt0 {
  padding-top: 0!important;
}

.mt10 {
    margin-top: 10px;
}

.mt20 {
    margin-top: 20px;
}
.mr5 {
    margin-right: 5px;
}

.mb0 {
    margin-bottom: 0;
}

.right-align {
  text-align: right;
}

.center {
    text-align: center;
}

5. Riot タグの作成

ようやく Riot タグを作っていく。
ここでは、 - navbar.tag – ヘッダーのナビゲーション - raw.tag – Riot による自動エスケープを避けるためのタグ - list.tag – ブログ記事一覧画面 - post.tag – ブログ記事詳細画面 - edit.tag – ブログ記事編集画面

を作成する。
それぞれ、public/tags 以下に作成。

5.1. ナビゲーション

navbar.tag

<navbar>
  <nav class="navbar navbar-default">
    <div class="container-fluid">
      <!-- Brand and toggle get grouped for better mobile display -->
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <h1>
          <a class="navbar-brand" href="/">Blog</a>
        </h1>
      </div>

      <!-- Collect the nav links, forms, and other content for toggling -->
      <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
        <ul class="nav navbar-nav navbar-right">
          <li each="{ categories }"><a href="/categories/{ name.toLowerCase() }">{ name }</a></li>
        </ul>
      </div><!-- /.navbar-collapse -->
    </div><!-- /.container-fluid -->
  </nav>

  <style>
    h1 { margin: 0; }
  </style>

  <script>
    this.categories = [
      {id: 1, name: 'Tech'},
      {id: 2, name: 'Book'},
      {id: 3, name: 'Hobby'},
      {id: 4, name: 'Others'},
    ]
    console.log(this.categories)
  </script>

</navbar>

ポイント

  • <navbar></navbar> で囲むことで、 viwe 側で このタグが使えるようになる。
  • html を記述し、そのタグに適用したいスタイルを <style> に記述し、そのタグに適用したい JS を <script> に記述。
  • データは this.categories のように、this(Riot タグインスタンス) のプロパティとして持たせることでタグ内で使えるようになる。
  • 持たせたデータは { } で囲むことで展開される。
  • {} 内ではjavascript が書ける!
  • ぐるぐる回したい場合は、<li each="{ categories }"><a href="/category/{ name.toLowerCase() }">{ name }</a></li> のように each="{ categories }" とかく。

ちなみに、Riot 3 からは Scoped CSSがデフォルトに なったため、
<style> 要素に scoped 属性を追加する必要がなくなった!

あと、<script>タグはあってもなくてもOK

5.2. Riot による自動エスケープを避けるためのタグ

Riot はテンプレート変数を自動でエスケープするため、改行などが反映されない。
エスケープしないでHTMLを表示する を参考にエスケープしないカスタムタグを定義する。

raw.tag

<raw>
  <span></span>

  <script>
    this.root.innerHTML = opts.content
  </script>
</raw>

ポイント

  • this.root : 作成した Riot タグ自身

5.3. 一覧画面

list.tag

<list>
  <ul>
    <li each="{list}">
      <div class="card p15">
        <div class="card-block">
          <h2 class="card-title mb0"><raw content="{ title }"></raw></h2>

          <a each="{ category in categories }" href="/categories/{ category.toLowerCase() }">
            <span  class="label label-{ category } mr5">{ category }</span>
          </a>

          <p class="card-text mt20"><raw content="{ text }"></raw></p>
          <div class="right-align">
            <a href="/posts/{ id }" class="btn btn-primary btn-raised">Contiune</a>
          </div>
        </div>
      </div>
    </li>
  </ul>
  <a id="edit-btn" href="/posts/new/edit" class="btn btn-danger btn-fab">
    <i class="material-icons">mode_edit</i>
  </a>


  <style>
    ul {
        padding-left: 0;
    }

    ul li {
        list-style: none;
        margin-bottom: 20px;
    }
  </style>


  <script>
    this.list = [
      {
        id: 1,
        title: 'Lorem ipsum dolor si',
        text: 'これはダミーテキストです。フォントやレイアウトを確認するために入れています。Lorem ipsum dolor sit amet, consectetur.これはダミーテキストです。フォントやレイアウトを確認するために入れています。Lorem ipsum dolor sit amet, consec',
        categories: ['Tech', 'Book'],
      },
      {
        id: 2,
        title: 'Lorem ipsum dolor si',
        text: 'これはダミーテキストです。フォントやレイアウトを確認するために入れています。Lorem ipsum dolor sit amet, consectetur.これはダミーテキストです。フォントやレイアウトを確認するために入れています。Lorem ipsum dolor sit amet, consec',
        categories: ['Book', 'Hobby', 'Others']
      },
      {
        id: 3,
        title: 'Lorem ipsum dolor si',
        text: 'これはダミーテキストです。フォントやレイアウトを確認するために入れています。Lorem ipsum dolor sit amet, consectetur.これはダミーテキストです。フォントやレイアウトを確認するために入れています。Lorem ipsum dolor sit amet, consec',
        categories: ['Hobby', 'Others']
      },
      {
        id: 4,
        title: 'Lorem ipsum dolor si',
        text: 'これはダミーテキストです。フォントやレイアウトを確認するために入れています。Lorem ipsum dolor sit amet, consectetur.これはダミーテキストです。フォントやレイアウトを確認するために入れています。Lorem ipsum dolor sit amet, consec',
        categories: ['Others']
      },
    ];
    console.log(this.list)
  </script>
</list>

ポイント

  • ぐるぐる回す eachは、<a each="{ category in categories }" href="/category/{ category.toLowerCase() }"> のように、each={ category in categories } としても使える。

5.4. 詳細画面

post.tag

<post>
  <div class="card p15">
    <div class="card-block">
      <h2 class="card-title mb0"><raw content="{ title }"></raw></h2>

      <a each="{ category in categories }" href="/categories/{ category.toLowerCase() }">
        <span  class="label label-{ category } mr5">{ category }</span>
      </a>

      <p class="card-text mt10"><raw content="{ text }"></raw></p>
    </div>
  </div> 
  <a id="edit-btn" href="/posts/{ id }/edit" class="btn btn-danger btn-fab">
    <i class="material-icons">mode_edit</i>
  </a>

  <style></style>

  <script>
    this.id         = 1
    this.title      = 'Lorem ipsum dolor si'
    this.text       = 'これはダミーテキストです。フォントやレイアウトを確認するために入れています。Lorem ipsum dolor sit amet, consectetur.これはダミーテキストです。フォントやレイアウトを確認するために入れています。Lorem ipsum dolor sit amet, consec'
    this.categories = ['Tech', 'Book']
  </script>
</post>

一覧画面と詳細画面で使った文法とほぼ同じ。

5.5. 編集画面

submit などのファンクションはまだ実装しない。

edit.tag

<edit>

  <form id="edit-form" class="form-horizontal card p50">
    <fieldset>
      <legend>Edit</legend>
      <div class="form-group">
        <label for="inputTitle" class="col-md-1 control-label">Title</label>
        <div class="col-md-11">
          <input type="text" name="title" class="form-control" id="inputTitle" placeholder="Title..." value="{ post.title }">
          <span class="help-block">100 文字以内</span>
        </div>
      </div>


      <div class="form-group">
        <label for="" class="col-md-1 control-label pt0">Category</label>
        <div class="checkbox col-md-2" each="{ categories }">
          <label>
            <input type="checkbox" name="categories[]" value="{ id }" checked="{ (post.categories)&&(post.categories.indexOf(name) >= 0) }"><span class="checkbox-material"><span class="check"></span></span> { name }
          </label>
        </div>
      </div>


      <div class="form-group">
        <label for="inputText" class="col-md-1 control-label">Text</label>
        <div class="col-md-11">
          <textarea name="text" class="form-control" rows="15" id="inputText" value="{ post.text }"></textarea>
          <span class="help-block">5000 文字以内</span>
        </div>
      </div>

      <div class="form-group center">
        <div class="col-md-11">
          <button type="button" class="btn btn-default" onClick="{ cancel }">Cancel</button>
          <button type="submit" class="btn btn-primary" onClick="{ submit }">Submit</button>
        </div>
      </div>

    </fieldset>
  </form>


  <style>
  </style>


  <script>
    this.categories = [
      {id: 1, name: 'Tech'},
      {id: 2, name: 'Book'},
      {id: 3, name: 'Hobby'},
      {id: 4, name: 'Others'},
    ]
    console.log(this.categories)

    this.post = {}

    cancel() {
      history.back()
    }

    submit(e) {
      e.preventDefault()
    }
  </script>

</edit>

ポイント

  • テキストエリアやテキストボックスの value="{ post.title }" は更新時のデフォルト値
  • Riot のchecked属性selected属性は、checked="{ 変数 }" と書け、変数=false/undefined の場合は無視される。
  • クリックイベントは onClick="{ submit }"と書ける。

6. Riot ルーターの作成

最後にルータを作る。

app.js

route.base('/')
riot.mount('header', 'navbar')

route(function(collection, id, action){
  console.log('collection: ' + collection)
  console.log('id: ' + id)
  console.log('action: ' + action)
  riot.mount('article', collection || 'list', {id: id, action: action})
})

route('/categories/*', function(categoryName) {
  console.log('categoryName ' + categoryName)
  riot.mount('article', 'list', {categoryName: categoryName})
})

route('/posts/*', function(id, action) {
  console.log('id ' + id)
  riot.mount('article', 'post', {id: id})
})

route('/posts/*/edit', function(id) {
  console.log('id ' + id)
  riot.mount('article', 'edit', {id: id})
})

route.start(true)

ポイント

  • route.base('/') でペースパスをデフォルトの#から/に変更。(ルータをカスタマイズする)
  • riot.mount('header', 'navbar')riot.mount(selector, tagName, [opts]) の書き方で、view上のselectorにカスタムタグtagNameを設置する、という意味。
  • route(function(collection, id, action){}) でルーティングの設定を行う。 (ルーティングの設定)
  • route.start(true) でURL変更の検知を開始する。(URL変更の検知)

まとめ

Riot やばい。
Angular とか React とか Vue とかと比べると一番簡単。
学習コストがほぼ0。

Material Design for Bootstrap やばい。
ほとんど CSS 書いてないのにめちゃマテリアルデザインになる。

関連記事

www.yjhm214.com

www.yjhm214.com

スポンサーリンク