Seiichi Yonezawa

新しいActionCableの書き方

· nzwsch

プログレスバーの実装に見る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"を持つタグがあると接続が重複してしまう。

そこで発想を変えてProgressChannelreceivedを呼び出すときに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}%`
  }
}

これにより不具合が解消できたうえにconnectdisconnectの記述も不要になった。

これまでSSEやReactのuseEffectsetTimeoutの組み合わせ、WebWorkerなどを用いてプログレスバーを実装してみたが、個人的には今回のActionCableとStimulusの組み合わせはとても直感的だと認識できた。

実際に書いたコードはもう少し複雑ではあるが、この記述を使いこなせばReactとはまた違ったアプローチでインタラクティブなページを実装できそうに感じた。

基本的な画面処理はTurbo、部分更新にはActionCableやStimulusといった棲み分けがうまく使いこなせるようになりたい。