Riot.js 3.3 と Lumen 5.4 でつくる 初めてのSPA フロントエンド編
はじめに
最近 Riot が気になっていたので、
Laravel製軽量フレームワーク Lumen 5.4 と Riot 3.3 で簡単なブログを作ってみることにした。
今回はフロントエンド編。
DB との接続なしのモックアップを作っていく。
Material Design for Bootstrap も気になっていたのでついでに使ってみる。
ダミーテキストはWebtoolsのダミーテキストジェネレータで作成している。
これを使えば日本語・英語・数字混合のダミーテキストが作れる。
そのほかのエントリーはこちら。
スポンサーリンク
作るもの
いろいろ頑張って最終的にこんな感じのブログを作る。
- 記事一覧の表示(カテゴリーの絞り込みあり)
- 記事詳細の表示
- 記事の新規作成・更新
記事一覧
記事詳細
記事編集
記事保存
前提
- Lumen 5.4.5
- Riot.js 3.3.1
手順
- Lumen ルーティングの作成
- 必要なCSS/JSファイルのダウンロードと設置
- Material Design for Bootstrapで使うCSS/JSファイルのダウンロードと設置
- Riot で使う JS ファイルの設定
- ベースとなる View の作成
- CSS の作成
- Riot タグの作成
- ナビゲーション
- 一覧画面
- 詳細画面
- 編集画面
- 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 書いてないのにめちゃマテリアルデザインになる。
関連記事
スポンサーリンク