新しいActionCableの書き方
プログレスバーの実装に見るDOM操作の課題
Rails 7時代はTurboの登場で以前ほどJavaScriptやActionCableを書く機会が減りつつある。
それでもTurboだけですべての挙動をカバーするのはまだ難しい。
私の場合はプログレスバーがTurboのときはDOMごと書き換えてしまうのでどことなくぎこちないが、style.width
を書き換えることでなめらかなアニメーションを表現できる。
例えばProgressChannel
を作成したときこのようなコードが生成される。
import consumer from "channels/consumer"
consumer.subscriptions.create("ProgressChannel", {
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
}
});
ActionCableの実装例はあまり見かけないのだが、Full-Stack Examplesの記述例ではDOMを直接更新しているように見える。
実装例を見てもreceived
を始め、このクラスにDOM操作を記述していくとなると大変そうだ。jQueryの時代ならまだしも、これならまだReactで記述したほうがマシに思える。
せっかくStimulusが使えるようになったので、jQueryでもReactでもないなにかよい書き方はないだろうか。
ActionCableの実装例とその課題
ひとまずProgressController
を作成してみる。ProgressChannel
に対して接続を試みるタイミングをすべてのページからではなく、特定のDOM(例えば<progress>
)がある時に限定してconsumer.subscriptions.create()
を実行するようにしてはどうだろうか。
import { Controller } from "@hotwired/stimulus"
import consumer from "channels/consumer"
// Connects to data-controller="progress"
export default class extends Controller {
connect() {
this.subscription = consumer.subscriptions.create("ProgressChannel", {
received: this.handleReceived.bind(this)
})
}
handleReceived(data) {
this.element.style.width = `${data.progress}%`
}
}
おそらくはこのようなコードにたどりつくと思う。仮にif文で制御をしたとしても、複数のdata-controller="progress"
を持つタグがあると接続が重複してしまう。
そこで発想を変えてProgressChannel
がreceived
を呼び出すときにStimulusが反応するようなコードを作ればよいのではないだろうか。
幸いCustomEventと呼ばれるコンストラクターが存在しているので、これで任意のイベントを作成してStimulus側に反応させるのはどうだろうか。
import consumer from "channels/consumer"
consumer.subscriptions.create("ProgressChannel", {
received(data) {
const event = new CustomEvent("progress", {
detail: {
progress: data["progress"]
}
})
window.dispatchEvent(event)
}
});
ProgressChannel
からdata
を受け取ったときにprogress
という任意のイベントを配信できるようになった。少なくともこれでchannel
側のコードにはこれ以上記述するものはないだろう。
Stimulusを活用したスマートな実装方法
続いてStimulus側のコードを見てみよう:
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="progress"
export default class extends Controller {
connect() {
window.addEventListener("progress", this.onProgress)
}
disconnect() {
window.removeEventListener("progress", this.onProgress)
}
onProgress(event) {
this.element.style.width = `${event.detail.progress}%`
}
}
最初に比べるとすっきりした。ただしこのコードは実行してもらうとわかるが、関数をbind
していないのでthis.element
にアクセスすることはできない。
この状態でbind
するとしたらあらたにthis.onProgress.bind(this)
を変数に格納する必要もありそうだ。
そこでCommunicating between Stimulus controllers using custom eventsというページを参考にした。
<div data-action="progress@window->progress#onProgress">
カスタムイベント@window->コントローラー#onProgress
のように解釈できる。
直感的なプログレスバーの実装と可能性
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="progress"
export default class extends Controller {
onProgress(event) {
this.element.style.width = `${event.detail.progress}%`
}
}
これにより不具合が解消できたうえにconnect
とdisconnect
の記述も不要になった。
これまでSSEやReactのuseEffect
とsetTimeout
の組み合わせ、WebWorkerなどを用いてプログレスバーを実装してみたが、個人的には今回のActionCableとStimulusの組み合わせはとても直感的だと認識できた。
実際に書いたコードはもう少し複雑ではあるが、この記述を使いこなせばReactとはまた違ったアプローチでインタラクティブなページを実装できそうに感じた。
基本的な画面処理はTurbo、部分更新にはActionCableやStimulusといった棲み分けがうまく使いこなせるようになりたい。