Alpine.js です。
軽量で、宣言的で、リアクティブなふるまいを記述できるということで注目されている、ようです。
github.com
既存のフレームワークの Vue.js や React と比べてシンプルで軽量というところが注目点のようなのですが、わたしとしては HTML のタグにディレクティブを追加するだけというところに惹かれました。
Alpine.js であれば 既存の Ruby on Rails アプリケーションに簡単にリアクティブなフロントエンドを構築できるのではないか。
と、いうわけで。
実際に Rails と Alpine.js をつないでみることにしました。
ここから先は、 INC
と DEC
の二つのボタンを持ち、数字をカウントアップ/カウントダウンできる簡単な例を使って話を進めてゆきます。
また Rails のバージョンは 6.0 以降、Webpacker を利用している環境を想定しています。
Alpine.js で書く
count
というデータを持ち、ボタンが押されたら +1 または -1 します。count
の値は span タグの innerText として表示されます。
実質 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
クライアント側です。
生成されたコードのうち connected
と received
に手を加えます。
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.$data
は x-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
で定義したデータとおなじようにコンポーネントの他の部分にその変化が伝搬します。
<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 に更新時の処理を記述する必要がない
- パッケージを読み込む順序に注意する必要がある
いつか読むはずっと読まない:見えないうらがわのようすを知る