皆さんこんにちは、最近は ToraLab.fm のパーソナリティの おっくん(おくたに)です。
Ruby on Rails の作者 DHH が 2020 年 12 月 23 日に「Hotwire」に言及した後、 2020 年末から 2021 年始にかけて Rails 界隈の話題の一翼を担っていたと思います。 LT 会でも Hotwire について言及している場面を見かけることがありました。
今回は、Hotwire gem を利用し、 デモでも紹介されるような簡単なチャットと、検索画面そして更新通知を実装してみたいと思います。
最終的には、こんなものを作成します。
Hotwire って?
HOTWIRE HTML OVER THE WIRE に紹介されている紹介文には、以下のように記されています。
Hotwire is an alternative approach to building modern web applications without using much JavaScript by sending HTML instead of JSON over the wire.
Hotwire は、JavaScript を多用せずに JSON の代わりに HTML をネットワークで送信し、モダンなアプリケーションを構築するための代替のアプローチです。
伝統的なシングルページアプリケーション開発と比較し 4 つの利点を紹介されていました。
- 最初のロードが高速になる
- サーバーでのレンダリングを維持できる
- シンプルにできる
- あらゆる言語でのより生産的な開発体験
これらについては、「Hotwire 振り返り」で使ってみた感想を踏まえて別途触れていきます。
Hotwire は、Turbo と Stimulus から構成されています。 (あとStradaというものがあるんですが、こちらは現在「COMING SOON!」となっており、2021年内には公開されるようです。)
さらに、Turbo は以下の3つの機能に分けることができます。
- Turbo Drive
従来のTurbo links のことだそうです。 こちらについて DHH が言及しているやり取りがあります。
What used to be Turbolinks is now Turbo Drive, one of several techniques included in Turbo. https://t.co/uRdzfqPsJI
— DHH (@dhh) 2020年12月22日
Turbo Frames
指定した部分だけをレスポンスのHTMLから抽出し、ページの任意の部分をHTMLの書き換える機能。Turbo Streams
WebSocket、SSE、その他の転送方法で、ページの任意の部分を書き換える機能。
Stimulus は、「控えめな」JavaScriptフレームワークです。 フレームワークとして、ドキュメントの構造を変えるなどのHTMLのレンダリングは関与せず、HTMLを魅力的にするために設計されています。
ここからは Hotwire(Turbo Frames, Turbo Streams, Stimulus) を使ったアプリケーションをつくっていきます。
実行環境
- macOS 10.15.17
- redis 5.0.7
後述する Turbo Streams にて Action Cable を使用するにあたり必要です。 - ruby 3.0.0
実装
この実装では、下敷きとして hotwire が公開しているデモアプリを参考にします。
今回作成するアプリの概要は、以下の通りです。
- 簡単な 1 部屋 1 画面だけのチャットアプリ
- 書き込みを検索できる機能
- 書き込みの検索結果を表示している間に新しい投稿があった時は、新着通知を表示する機能
アプリの環境設定
任意のディレクトリで以下を進めます。
bundle init bundle set --local path vendor/bundle # Gemfile の gem "rails" のコメントアウトを外す bundle install bundle exec rails new . --skip-javascript
Gemfile に、以下の編集を行います。
[Gemfile]
# redis がコメントアウトされているのでコメントアウトを外す gem 'redis', '~> 4.0' # hotwire-rails と stimulus-railsを書き足す gem 'hotwire-rails' gem 'stimulus-rails' # お好みでCSSのライブラリなど gem "bulma-rails", "~> 0.9.1"
hotwire-rails と stimulus-railsをインストールします。
bundle exec rails turbo:install bundle exec rails stimulus:install
action-cable の設定を行う、config/cable.yml
を設定します。
[config/cable.yml]
development: adapter: redis url: redis://localhost:6379/cable
bundle exec rails rails s
でいつもの Rails の初期画面を確認します。
実装
続けて、モデル、コントローラー、view テンプレートを実装します。
モデル - テーブル
今回作成するのはシンプルな掲示板だけなので、テーブルは以下の一つだけとします。
カラム名 | データ型 |
---|---|
id | BIGINT |
name | VARCHAR |
coment | VARCHAR |
created_at | DATETIME |
created_at | DATETIME |
モデルは以下の通りです。
[app/models/post.rb]
class Post < ApplicationRecord after_create_commit do broadcast_prepend_to "post-list", target: "post-list-el", partial: "posts/post" broadcast_replace_to "post-info", target: "post-info-el", partial: "posts/info" end end
新たに Post が作成されたとき、Turbo Streams の機能で、2 種類の内容をブロードキャストします。
検索をしていない画面への、投稿の追加表示用
アクション:prepend
(子要素の先頭に追加) 対象チャンネル:post-list
書き換えターゲット:post-list-el
使用テンプレート:posts/post
検索をしている画面への、新着通知の表示用
アクション:replace
(要素を差し替える) 対象チャンネル:post-info
書き換えターゲット:post-info-el
使用テンプレート:posts/info
前者の場合、post-list
をリッスンしているクライアント向けに、posts/_post.html.erb
を使用しレンダリングされたHTMLを配信し、id="post-list"
のHTMLエレメントの先頭に追記するという設定になります。
アクションには、他にappend
update
remove
があります。
コントローラー
コントローラーは以下の通りです。
[app/controllers/posts_controller.rb]
class PostsController < ApplicationController def index @search_keyword = search_params[:keyword] @posts = if @search_keyword.nil? Post.all.order(created_at: :desc) else Post.where(['comment LIKE ?', "%#{@search_keyword}%"]).order(created_at: :desc) end end def create @post = Post.new(post_params) @post.save respond_to do |format| format.turbo_stream do render turbo_stream: turbo_stream.prepend("post", partial: "posts/dummy") end end end private def search_params params.permit(:keyword) end def post_params params.require(:post).permit(:name,:comment) end end
ポイントになるのは、以下のレスポンスです。
respond_to do |format| format.turbo_stream do render turbo_stream: turbo_stream.prepend("post", partial: "posts/dummy") end end
Turbo Stream でのレスポンス返却を行っています。
ただし、実際には先に示したモデルの実装の通り、after_create_commit
でTurbo Streamの機能で変更内容を反映させるので、ここではダミーとなるパーシャルを返して適当なレスポンスを返すだけにとどめています。
ルーティング
ルーティングは、非常にシンプルです。 Hotwire を使うからということを特別意識する必要はありません。
[ config/routes.rb]
Rails.application.routes.draw do resources :posts, only: [:index, :create] end
view テンプレート
今回 view テンプレートは、以下の4種類を用意します。
- app/views/posts/index.html.erb
ページ本体を構成するテンプレート - app/views/posts/_post.html.erb
1つの投稿を構成するパーシャルテンプレート - app/views/posts/_info.html.erb
新着通知を構成するパーシャルテンプレート - app/views/posts/_dummy.html.erb
PostsController#create
のレスポンスとなるダミーのレスポンスを返すためのパーシャルテンプレート
それぞれの詳細は以下の通りです。
[app/views/posts/index.html.erb]
<section class="section"> <h1 class="title">サンプルアプリ</h1> <p>現在時刻<%= DateTime.now %></p> <%= form_with url: posts_path, method: :get do |f| %> <div class="field has-addons"> <div class="control"> <%= f.text_field :keyword, value: @search_keyword, class: "input", placeholder: "検索キーワード"%> </div> <div class="control"> <%= f.submit "検索", class: "button is-info" %> </div> <div class="control"> <%= link_to "リセット", posts_path, class: "button is-link" %> </div> </div> <% end %> </section> <!-- Turbo Frame の機能を使い部分的な書き換えをする場合には、<turbo-frame>タグで囲ってあげる --> <turbo-frame id="posts"> <section class="section"> <!-- Turbo Stream の機能を使うには、turbo_stream_from でリッスンするチャンネルを指定する --> <% if @search_keyword.present? %> <%= turbo_stream_from "post-info" %> <% else %> <%= turbo_stream_from "post-list" %> <% end %> <% if !@search_keyword.present? %> <%= form_with model: Post, url: posts_path, method: :post, data: { controller: "posts", action: "turbo:submit-end->posts#reset" } do |f| %> <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label">ハンドルネーム</label> </div> <div class="field-body"> <div class="field"> <div class="control"> <%= f.text_field :name, class: "input" %> </div> </div> </div> </div> <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label">コメント</label> </div> <div class="field-body"> <div class="field"> <div class="control"> <%= f.text_field :comment, class: "input", data: { "posts-target": "comment"} %> </div> </div> </div> </div> <div class="field is-horizontal"> <div class="field-label"> <!-- Left empty for spacing --> </div> <div class="field-body"> <div class="field"> <div class="control"> <%= f.submit "書き込み", class: "button is-primary" %> </div> </div> </div> </div> <% end %> <% end %> </section> <section class="section"> <div id="post-info-el"> </div> <% if @posts.present? %> <% if @search_keyword.present? %> 「<%= @search_keyword %>」の検索結果 <% end %> <div id="post-list-el"> <% @posts.each do |post|%> <%= render partial: "post", :locals => { post: post } %> <% end %> </div> <% else %> <% if @search_keyword.present? %> 「<%= @search_keyword %>」を含む投稿はありません。 <% else %> 投稿はありません。 <% end %> <% end %> </section> </turbo-frame>
後述するStimulusによる、機能拡張が行われています。
[app/views/posts/_post.html.erb]
<div> <div class="card"> <header class="card-header"> <p><%= post.name %> さんの投降 - <%= post.created_at.strftime("%H:%M %S") %> </p> </header> <div class="card-content"> <div class="content"> <p><%= post.comment %></p> </div> </div> </div> <hr/> </div>
[app/views/posts/_info.html.erb]
<div id="post-info-el" class="notification is-primary"> <%= link_to "新着投稿があります(#{post.created_at.strftime("%H:%M:%S")})", posts_path %> </div>
[app/views/posts/_dummy.html.erb]
<% # Return handled by cable %>
CSSライブラリとして、Bulmaを使用しています。 こちらについては公式のドキュメントを参照ください。
Stimulus コントローラー
今回のアプリケーションでは、データ作成をPOSTしたときにフォーム部分は、要素の書き換えが行われないようになっています。こちらを、Stimulusを用いて拡張し送信した時にフォームの中でコメント部分のみリセットするようにします。
[app/assets/javascripts/controllers/posts_controller.js]
import { Controller } from "stimulus" export default class extends Controller { static targets = ["comment"] reset(){ this.commentTarget.value = "" } }
Stimulus
のコントローラーは、app/views/posts/index.html.erb
で呼び出しが行われています。
以下の部分です。
<%= form_with model: Post, url: posts_path, method: :post, data: { controller: "posts", action: "turbo:submit-end->posts#reset" } do |f| %>
変換された HTML は次のようなります。
<form data-controller="posts" data-action="turbo:submit-end->posts#reset" action="/posts" accept-charset="UTF-8" method="post">
data-controller="hoge"
で使用するコントローラを、data-action="fuga"
でトリガーと実行するアクションそのものを指定できます。
Stimulus ハンドブックには、様々な使い方が記載されています。
Hotwireと関連させずとも使うことができるものなので、興味があればぜひ試してみてください。
動作確認
作成したアプリケーションを二つのタブで開き、操作した様子が冒頭でも示した以下の動画です。
検索をしていない場合には、書き込みのためのフォームが表示され、投稿すると別のタブで内容が追加されます。
検索画面では、他のタブで書き込みがあると、「新着投稿があります(HH:MM:ss)」の形式で通知が現れます。
変更内容を画面の状態に従い、整理して表示することができています。
また、書き込みが行われた時フォームの最上部に出した現在時刻が書き換わらずページ全体での再読み込みがされていないこともお判りいただけるかと思います。
Hotwire 振り返り
冒頭の Hotwire の紹介で以下の 4 つの特徴を取り上げました。
- 最初のロードが高速になる
- サーバーでのレンダリングを維持できる
- シンプルにできる
- あらゆる言語でのより生産的な開発体験
この中で、「サーバーでのレンダリングを維持できる」と「シンプルにできる」ついてはアプリケーションを作る中で体験できるものかと思います。 特に、シングルページアプリケーションをバックエンドのAPIまで作った経験があると、体感しやすいかと思います。
続いて「最初のロードが高速になる」は、厳密な比較は難しいので断定的な言及は控えますが、アプリの規模が大きくなるほどシングルページアプリケーションの方が遅くなるだろうことはある程度予想できそうです。 昨今だと、フレームワーク側でSSRしてくれるものも多いので、一概にこの主張が正しいとは思いません。 また、バンドルするファイルを分割するなどでも対応することができるかと思います。
「あらゆる言語でのより生産的な開発体験」の「生産的な開発体験」ついては、通信に必要なWebSocket関連の実装などはすべてHotwireが用意してくれています。
新たに覚える必要がある事柄も、あまり多くは無く学習コストはかなり低いように思います。
先に示したように、動的なフロントエンドを持つアプリの開発を行う中で、要求されるJavaScriptの実装はほぼなく、
stimulus でのフォームの拡張程度で済みました。
Ruby と JavaScript でのコンテキストスイッチがほとんどないという点では、「生産的な開発体験」といえるかもしれません。(これは好みであったり、Ruby と JavaScript の切り替えをコンテキストスイッチと思うかなど意見がありそうです。)
「あらゆる言語でのより生産的な開発体験」の「あらゆる言語」の部分については、今回体験できる内容ではありません。
お気づきの方もいらっしゃるかと思いますが、「HTMLページをレンダリングしレスポンスする」と「パーシャルをレンダリングして転送する」仕組みがあれば Hotwire (特にTurbo Frame と Turbo Strems) の構成を取ることが考えられているようです。
既に様々な言語でのHotwireの実装の試みている方がいらっしゃるので、いくつか紹介します。
Python
JavaScript
Go
PHP
まとめ
Hotwire を 使った簡単なチャットと、検索画面と通知を作成しました。
Hotwire で示されたアプリケーションの構造がメインストリームとなるかはこれからにかかっているのだと思います。
特に Ruby のトレンドに閉じてしまわないことが、必要なのことなのだと思います。
代表的といえるような導入事例がいくつか挙がってくると、風向きが変わってくるかもしれませんね。
P.S.
3/19(金)19:30~ 【オンライン】3/19 とらのあなエンジニア&マーケター採用説明会【地方勤務可能!!】 yumenosora.connpass.com
採用情報
募集職種
yumenosora.co.jp
カジュアル面談も随時開催中です
お申し込みはこちら!
yumenosora.connpass.com
ToraLab.fmスタートしました!
メンバーによるPodcastを配信中!是非スキマ時間に聞いて頂けると嬉しいです。
Twitterもフォローしてくださいね!
ツイッターでも随時情報発信をしています
twitter.com