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

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

Action Cable でサーバと通信し、Alpine.js で表示を更新する

Alpine.js です。 軽量で、宣言的で、リアクティブなふるまいを記述できるということで注目されている、ようです。

github.com

既存のフレームワークの Vue.js や React と比べてシンプルで軽量というところが注目点のようなのですが、わたしとしては HTML のタグにディレクティブを追加するだけというところに惹かれました。

Alpine.js であれば 既存の Ruby on Rails アプリケーションに簡単にリアクティブなフロントエンドを構築できるのではないか

と、いうわけで。 実際に Rails と Alpine.js をつないでみることにしました。

ここから先は、 INCDEC の二つのボタンを持ち、数字をカウントアップ/カウントダウンできる簡単な例を使って話を進めてゆきます。

また Rails のバージョンは 6.0 以降、Webpacker を利用している環境を想定しています。

f:id:E_Mattsan:20200714221753p:plain

Alpine.js で書く

count というデータを持ち、ボタンが押されたら +1 または -1 します。count の値は span タグの innerText として表示されます。 実質 HTML を書いただけでこのようなふるまいを実現できるのは、けっこう感動的です。

<!DOCTYPE html>
<html>
  <head>
    <script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js" defer></script>
  </head>
  <body>
    <div x-data="{count: 0}">
      <button @click="count -= 1">DEC</button>
      <button @click="count += 1">INC</button>
      <span x-text="count"></span>
    </div>
  </body>
</html>

Action Cable で書く

おなじ操作を Action Cable を使って実現します。 この例に Action Cable を使うのは大仰すぎますが、おのおののアプリケーションで実際に Action Cable を利用する状況を想像しながら読んでみてください。

まずボタンを配置したページを用意します。

次に Action Cable のチャネルを追加します。 チャネルには fetch, inc, dec のイベントを用意します。 fetch はクライアントとサーバの接続が確立したときに、クライアントがサーバ側にある初期値を取得するために利用します。

ページを追加する

ルーティングを追加します。

config/routes.rb

Rails.application.routes.draw do
  root to: 'home#index'
end

コントローラを追加します。

app/controllers/home_controller.rb

class HomeController < ApplicationController
end

ヴューを追加します。 チャネルのクライアントが操作するためにボタンと span タグに ID を割り当てておきます。

app/views/home/index.html.erb

<button id="dec">DEC</button>
<button id="inc">INC</button>
<span id="count"></span>

チャネルを追加する

Rails のコマンドを利用して新しいチャネルを生成します。 先述の通り三つのイベントを指定します。

$ bin/rails g channel Counter fetch inc dec

サーバ側です。

接続が確立したときにインスタンス変数 @count を初期化します。 今回は動作の確認ができればじゅうぶんという姿勢で stream に指定する文字列にはここでは適当な値を指定しています。

fetch を受信したら、現在の @count の値をブロードキャストします。

inc を受信したら、@count に 1 を加えてその値をブロードキャストします。

dec を受信したら、@count から 1 を引いてその値をブロードキャストします。

それぞれがやっていることがわかるように、あえて冗長に書いてみました。

app/channels/counter_channel.rb

class CounterChannel < ApplicationCable::Channel
  def subscribed
    @count = 0
    stream_from 'some_channel'
  end

  def unsubscribed
  end

  def fetch
    ActionCable.server.broadcast('some_channel', {count: @count})
  end

  def inc
    @count += 1
    ActionCable.server.broadcast('some_channel', {count: @count})
  end

  def dec
    @count -= 1
    ActionCable.server.broadcast('some_channel', {count: @count})
  end
end

クライアント側です。

生成されたコードのうち connectedreceived に手を加えます。

connected には、ボタンのイベントハンドラの設定と初期値の取得 (fetch) するコードを記述します。

received には、サーバから受け取った値を span タグの innerText に設定するコードを記述します。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    document.getElementById('inc').addEventListener('click', () => this.inc())
    document.getElementById('dec').addEventListener('click', () => this.dec())

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    document.getElementById('count').innerText = data.count
  },

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

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

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

Rails アプリケーションを起動しページを開くと、Action Cable を介して初期値が取得されページに表示されます。 ボタンを押すたびに inc または dec のイベントがサーバに送られ、サーバ側でカウントアップ/カウントダウンし、その結果がクライアントに送られます。

Action Cable + Alpine.js で書く

では本題の Action Cable + Alpine.js を連携するコードを書いていきます。

…が、その前に一つ。

Alpine.js が登場してからまもないということもあって、ベストプラクティスの蓄積はまだまだこれからだと思われます。 ここからはわたし自身が情報を集めて模索したもののを集めたものなります。

では。あらためて。

まず Rails アプリケーションで Alpine.js を利用するために、yarn コマンドでパッケージを追加します。

$ yarn add alpinejs

追加した Alpine.js は必要となる js ファイルで import して利用します。

import "alpinejs"

これ以降はサーバ側のコードは変更がないので、クライアント側のコードのみ掲載することにします。

Alpine.js → Action Cable

Alpine.js の操作を Action Cable に伝える方法を考えます。

ボタンのクリックやテキストの入力といったページ上の操作を Action Cable そしてサーバに伝達するためのしくみです。

カスタムイベントを送る

Alpine.js でカスタムイベントを生成し Action Cable のチャネルで受け取ります。

developer.mozilla.org

Alpine.js には $dispatch というカスタムイベントを生成する マジックプロパティ があり、簡単にイベントを生成することができます。

イベントハンドラを割り当てる DOM を取得するために id="counter" を追加しておきます。

app/views/home/index.html.erb

<div id="counter" x-data="{}">
  <button @click="$dispatch('dec')">DEC</button>
  <button @click="$dispatch('inc')">INC</button>
  <span id="count"></span>
</div>

id="counter" を指定した DOM にイベントハンドラを割り当てます。 ここは一般的な id を使った DOM の取得とハンドラの割り当てです。 少し違う点は、任意に定義したカスタムイベントという点です。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"
import "alpinejs"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    const counter = document.getElementById('counter')

    counter.addEventListener('inc', () => this.inc())
    counter.addEventListener('dec', () => this.dec())

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    document.getElementById('count').innerText = data.count
  },

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

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

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

関数を呼び出す

Alpine.js の @click には任意の js のコードを記述できるので、Action Cable のチャネルの関数が見えていれば直接呼び出すことができます。

ただし Alpine.js のコンポーネントから見えるようにするにはグローバルスコープにチャネルの関数かオブジェクトを公開する必要があります。

For bundler users, note that Alpine.js accesses functions that are in the global scope (window), you'll need to explicitly assign your functions to window in order to use them with x-data for example window.dropdown = function () {} (this is because with Webpack, Rollup, Parcel etc. function's you define will default to the module's scope not window).

https://github.com/alpinejs/alpine#x-data

ここでは connected のタイミングでオブジェクトを公開します。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"
import "alpinejs"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    window.counterChannel = this

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    document.getElementById('count').innerText = data.count
  },

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

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

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

グローバルスコープに公開されたオブジェクトは自由にアクセスできるので、定義されている関数を直接呼び出すことができます。

app/views/home/index.html.erb

<div id="counter" x-data="{}">
  <button @click="counterChannel.dec()">DEC</button>
  <button @click="counterChannel.inc()">INC</button>
  <span id="count"></span>
</div>

Alpine.js ← Action Cable

次に Action Cable から Alpine.js への伝達を考えます。

サーバのデータの変化を Action Cable を介してページを更新するためのしくみです。

カスタムイベントを送る

Alpine.js は任意のイベントを受信することができるので、カスタムイベントを送ることで Alpine.js に情報を伝えることができます。

received でサーバから受信したデータを、updated と名付けた CustomEvent のオブジェクトに載せて dispatchEvent で送ります。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"
import "alpinejs"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    const counter = document.getElementById('counter')

    counter.addEventListener('inc', () => this.inc())
    counter.addEventListener('dec', () => this.dec())

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    const counter = document.getElementById('counter')
    const event = new CustomEvent('updated', {detail: {count: data.count}})
    counter.dispatchEvent(event)
  },

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

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

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

Alpine.js では用意したカスタムイベントのハンドラ @updated でイベントを受け取ります。 イベントの値はマジックプロパティ $event から取り出すことができます。

app/views/home/index.html.erb

<div id="counter" x-data="{count: 0}" @updated="count = $event.detail.count">
  <button @click="$dispatch('dec')">DEC</button>
  <button @click="$dispatch('inc')">INC</button>
  <span x-text="count"></span>
</div>

Alpine.js コンポーネントを操作する

Alpine.js コンポーネントのデータには DOM を介してアクセスすることができます。

取得した DOM には .__x というフィールドがあり、.__x.$datax-data で定義したデータの proxy になっています。

> document.getElementById('counter').__x.$data
Proxy {}

ですので、ここに直接値を書き込むと Alpine.js コンポーネントのデータを更新することができます。

> document.getElementById('counter').__x.$data.count = 10

ただ、これに関しては公式なドキュメントを見つけられませんでした。 ですので、いまのところは参考にとどめておいてください。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"
import "alpinejs"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    window.counterChannel = this

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    const counter = document.getElementById('counter')
    counter.__x.$data.count = data.count
  },

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

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

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

データを直接更新しているので、Alpine.js 側ではデータを更新するコードを記述する必要がなくなります。

app/views/home/index.html.erb

<div id="counter" x-data="{count: 0}">
  <button @click="counterChannel.dec()">DEC</button>
  <button @click="counterChannel.inc()">INC</button>
  <span x-text="count"></span>
</div>

Spruce

Alpine.js の状態管理のために Spruce というパッケージが開発されています。 開発者は Alpine.js の contributor のお一人です。

Alpine.js は x-data の代わりに Spruce に格納されたデータを利用します。 Spruce の変化は Alpine.js コンポーネントx-subscribe を記述することで受け取ることができるようになります。

データへのアクセスは Spruce が定義する $store を介しておこないます。

冒頭の Alpine.js のコードを Spruce を使って書き換えてみます。

まずパッケージの読み込みですが、Spruce が Alpine.js よりも先に読み込まれている必要があります。 記述順に注意してください。 詳細はドキュメントを参照してください。

次に Spruce.store でデータを定義します。

Alpine.js コンポーネントx-data の値は利用しないと書きましたが、コンポーネントになるには x-data を持つ必要があるので空のオブジェクトを渡します。 またおなじタグに x-subscribe を追加します。

データは $store を利用することで Spruce.store で定義した値を参照と更新ができます。 更新すると x-data で定義したデータとおなじようにコンポーネントの他の部分にその変化が伝搬します。

<!DOCTYPE html>
<html>
  <head>
    <script src="https://cdn.jsdelivr.net/gh/ryangjchandler/spruce@0.x.x/dist/spruce.umd.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js"></script>

    <script>
      Spruce.store(
        'counter',
        {
          count: 0
        }
      )
    </script>
  </head>
  <body>
    <div x-data="{}" x-subscribe>
      <button @click="$store.counter.count -= 1">DEC</button>
      <button @click="$store.counter.count += 1">INC</button>
      <span x-text="$store.counter.count"></span>
    </div>
  </body>
</html>

Spruce を使うことの利点の一つは、Alpine.js コンポーネントの外の js コードからも Spruce.store で定義した値の参照と更新ができるという点です。 これを利用して Action Cable のチャネルのコードから Alpine.js コンポーネントを更新します。

Spruce を介する

Spruce を Action Cable で利用するために、これも yarn コマンドでインストールできます。 npm で Spruce を検索するといくつかヒットしますので、他のパッケージをインストールしないように注意してください。

$ yarn add @ryangjchandler/spruce

Action Cable のチャネルのコードで Spruce を import します。 この時も Alpine.js よりも先に import する必要があるので注意が必要です。

Alpine.js コンポーネント外から Spruce のデータにアクセスするには Spruce.store('counter').count という形で記述します。 コンポーネント内の $store.counter.count という記述に相当します。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"
import Spruce from "@ryangjchandler/spruce"

Spruce.store("counter", {
  count: 0
})

import "alpinejs"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    window.counterChannel = this

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    Spruce.store('counter').count = data.count
  },

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

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

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

Spruce のデータの更新が反映されるので Alpine.js コンポーネントのコードではデータの更新を記述する必要がなくなります。

app/views/home/index.html.erb

<div x-data="{}" x-subscribe>
  <button @click="counterChannel.dec()">DEC</button>
  <button @click="counterChannel.inc()">INC</button>
  <span x-text="$store.counter.count"></span>
</div>

まとめ

Action Cable と Alpine.js を繋ぐこころみは以上です。

最後に現在の理解のまとめです。

Alpine.js → Action Cable

  • カスタムイベントを使う
    • Apline.js と Action Cable のコードを分離できる
    • ハンドラの記述を間違えてイベントを受け取れなくてもエラーにならないので注意が必要かもしれない
  • 関数を呼び出す
    • 直接的、直感的
    • Action Cable の関数やオブジェクトをグローバルに公開してしまう(どこからも参照できる状態になる)

Alpine.js ← Action Cable

  • カスタムイベントを使う
    • Apline.js と Action Cable のコードを分離できる
    • Apline.js にイベントを受け取ったときの挙動を記述する必要がある
  • Alpine.js コンポーネントを操作する
    • 直接的、直感的、Alpine.js に更新時の処理を記述する必要がない
    • 公式の資料が見つからない
  • Spruce を介する
    • 直接的、直感的、Alpine.js に更新時の処理を記述する必要がない
    • パッケージを読み込む順序に注意する必要がある

いつか読むはずっと読まない:見えないうらがわのようすを知る

へんなものみっけ! (1) (ビッグコミックス)

へんなものみっけ! (1) (ビッグコミックス)

  • 作者:早良 朋
  • 発売日: 2017/07/12
  • メディア: コミック