エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

Railsでサーバサイドで動的にHTMLを更新したいための覚書

動機

  • Rails で動的に要素を更新するページを作りたい
  • JavaScript 勢としては、JavaScript のコードをできるだけ書かずにすませたい

今回の解決策のメリット

  • サーバサイドでレンダリングするので、View のテンプレートを利用できる

今回の解決策のデメリット

  • データの転送量が多い

よいですよね、 Phoenix LiveView 。 同じようなことを Rails でもやってみたいと思い立ち、試行錯誤してみました。

app を用意する

サンプルとして、サーバサイドで時刻を更新しブラウザ上で刻々と表示を更新する clock app を作ります。

Rails のバージョンとしては 6.0 、標準で WebPacker を利用していることを前提にしています。

$ rails new clock
$ cd clock

コントローラを追加する

ページを表示するためにコントローラを用意します。 更新は動的に行うのでコントローラのアクションは show のみ用意しています。

$ bin/rails g controlle clocks show

チャネルを追加する

更新する要素を輸送するチャネルを用意します。 クライアントからの update の要求を受け取れるようにイベントハンドラを用意しています。

$ bin/rails g channel clock update

JavaScript を編集する

本音としてはいろいろあっても、やはり JavaScript のコードをまったく書かずにすますわけにはいきませんでした。

と、いうわけで。 自動生成された JavaScript のコードを編集します。

app/javascript/packs/application.js

ページごとに .js ファイルの読み込みを制御したいので、チャネルの .js ファイルを一括で読み込んでいるコードをコメントアウトします。

require("@rails/ujs").start()
require("turbolinks").start()
// require("channels") コメントアウトする

app/javascript/channels/clock_channel.js

consumer.subscriptions.create の行の先頭に export default を追加してチャネルのオブジェクトを export します。

このファイルで定義されている関数を編集してカスタマイズするのが行儀のよい方法なのだと思います。 今回は、サーバから受信したデータをハンドリングする関数をこのオブジェクトの received にあとから代入するという方法をとります。

import consumer from "./consumer"

export default consumer.subscriptions.create("ClockChannel", {
  connected() {
    // Called when the subscription is ready for use on the server
  },

  disconnected() {
    // Called when the subscription has been terminated by the server
  },

  received(data) {
    // Called when there's incoming data on the websocket for this channel
  },

  update: function() {
    return this.perform('update');
  }
});

app/javascript/packs/clock.js

export されたチャネルのオブジェクトを import し、各イベントをハンドリングする .js ファイルを用意します。

import clockChannel from '../channels/clock_channel'

window.addEventListener('DOMContentLoaded', () => {
  // ブラウザで update がクリックされたら、チャネルの update を呼び出す
  const buttonUpdate = document.getElementById('update')
  buttonUpdate.addEventListener('click', () => { clockChannel.update() })

  // チャネルからデータを受け取ったら container に格納Gする
  const container = document.getElementById('container')
  clockChannel.received = (data) => { container.innerHTML = data.html }
})

view を編集する

app/views/clocks/show.html.erb

update のイベントを発するボタン、動的に生成された要素を格納するコンテナを配置します。 また先に用意したチャネルの .js ファイルを読み込むタグを追加します。

<button id="update">UPDATE</button>
<div id="container"></div>

<%= javascript_pack_tag 'clock' %>

channel を編集する

app/channels/clock_channel.rb

update メソッドの内容に tramsmit メソッドでデータを送信するコードを追加します。 ここでは現在時刻を文字列に変換し div で囲ったものを送っています。

class ClockChannel < ApplicationCable::Channel
  periodically :update, every: 1.second

  def subscribed
    # stream_from "some_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def update
    transmit(html: "<div>#{Time.current}</div>")
  end
end

表示を確認する

Rails サーバを起動して、追加したページをブラウザで開きます。

ページを開くとボタンが一つ表示されています。 それをクリックすると、現在時刻が表示されるはずです。

更新する要素をテンプレートで書く

更新する要素を update メソッドに直書きするのではサーバサイドで更新するメリットがありません。 view と同じようにテンプレートを使ってレンダリングするようにしてみます。

テンプレートを用意する

app/views/clocks/clock.html.erb

まず一般の view と同じようにテンプレートを用意します。

<div><%= current_time %></div>

app/views/layouts/plain.html.erb

テンプレートのレンダリングではレイアウトが利用されます。 標準で用意されているレイアウトは HTML のヘッダなどが含まれています。 ページ内の要素をレンダリングするにはそれらは不要ですので、何も装飾しないレイアウトを用意します。

<%= yield %>

channel を編集する

app/channels/clock_channel.rb

ApplicationController.render を使って用意したテンプレートでレンダリングします。

update メソッドを次のように変更します。

  def update
    html = ApplicationController.render('clocks/clock.html', layout: 'plain.html', locals: {current_time: Time.current.to_s})
    transmit(html: html)
  end

表示を確認する

再び Rails サーバを起動してページをブラウザで開きます。 編集前と変わらない表示ができています。

view と同じテンプレートを利用できるので SlimHaml を利用することもできます。

定期的に表示を更新する

Action Cable には Periodically というメソッドが用意されています。 これを利用するとクライアントから update のイベントを送ることなく、サーバが自ら定期的に更新を実行できるようになります。

app/channels/clock_channel.rb

ClockChannel クラスに periodically :update, every: 1.second の一行を追加します。

class ClockChannel < ApplicationCable::Channel
  periodically :update, every: 1.second

  # 以下略

表示を確認する

三度 Rails サーバを起動してページをブラウザで開きます。 今度は update ボタンを押さなくても秒ごとに表示が更新されることがわかります。

periodically を設定すると常に update メソッドが呼ばれていないかと気になりますが、チャネルは接続の状態( subscribed / unsubscribed )を把握しているので接続中のみメソッドを呼ぶようになっています。

いつか読むはずっと読まない:Life on Earth

博士は間違いなくわたしの科学好きに影響を与えたお一人です。