Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Stimulus.jsコントローラ間の癒着を防ぎながらイベントを中継する方法(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Stimulus.jsコントローラ間の癒着を防ぎながらイベントを中継する方法(翻訳)

Stimulusできらめきを放つ魅力の1つは、ブラウザに長時間居座り続けるステートフルなアプリケーションを構築「しなくても」、DOMにリッチな動的振る舞いを追加できることです。

Stimulusコントローラの1つ1つは、孤島のように独立していて互いに干渉せず、1個のコントローラが1つの振る舞いを追加し(例: クリップボードにコピーするためのコントローラや、アップロード状況を表示するコントローラ、ドラッグアンドドロップで並べ替えを行うためのコントローラなど)、そしてStimulusコントローラはすべてdata-*属性で設定可能という特徴があります。

この仕組みは、Stimulusコントローラで実装すべき振る舞いがユーザーの振る舞いによってトリガーされていれば「実にうまく機能します」。

しかし逆に、Stimulusコントローラで実装すべき振る舞いを、別のStimulusコントローラからトリガーしようとすると、この仕組みはなかなかうまく動いてくれません

🔗 何が問題か

この点については、Stimulusがメジャーバージョンアップされるたびに、Outletsのような機能や、名前空間化されたイベントでコントローラ同士が通信する便利なdispatchメソッドが追加されるなどの形で改善が進んでいます。

Stimulusの組み込みAPIは、Stimulusコントローラの振る舞いを間接的にトリガーする方法の大半をカバーしてはいるものの、本来お互いを知る必要がないはずのStimulusコントローラ同士を不必要に癒着させることを開発者に迫る可能性があります。

イベント経由での通信といえば、最近以下の記事で「DOM の競合」を解決する方法について解説しました。この競合が発生する可能性があるのは、イベントの送信者が「受信者の子孫である」か「受信者から派生した要素への参照を保持している」か「受信者と同じノードにインストールされている」のいずれかであることが必要なためです。

Rails: content_forをハックしてdata-*属性をDOMにプッシュする(翻訳)

しかしStimulusのようにJavaScriptの振る舞いをDOMで宣言的に編成するという側面は扱いにくい場合があります。Stimulusコントローラ同士を連携可能にしようとしてdata-controllerをDOMの上位要素に配置すると、Stimulusが普段行っているような自動管理を、開発者が手書きしたカスタムコードで置き換えるはめになりがちです。

  • Stimulusコントローラの要素が従来のように、置き換え可能なTurbo FrameやTurbo Streamで完全に囲まれていれば、Stimulusコントローラのセットアップやティアダウン(終了処理)は簡単です。
    しかし、StimulusコントローラをDOMの上位に移動してその子孫要素のみが置き換えられる形になると、同じセットアップやティアダウンのためにMutationObserverを手動で追加しなければならなくなる可能性があります。

  • 1つのページにStimulusコントローラのインスタンスが複数含まれていても(なお、個別のコントローラで指定の操作のために含まれるターゲットやアクションや値は、それぞれ最大1個までです)、チュートリアルで見られるようにシンプルでわかりやすいコードを作成できます。
    しかし、Stimulusコントローラを上位要素に移動して、1個のStimulusコントローラインスタンスが(1個のターゲットではなく)ターゲットの配列を処理しなければならなくなると、フレームワークが管理するプリミティブの代わりに、配列やオブジェクトの値を自力でトラッキングするロジックも必要になります。

  • StimulusコントローラをDOMのかなり上位に移動して、その下に論理的に存在するはずのパーシャルやコンポーネントから抜け出す必要がある場合、コードを十分カプセル化するには、上述の記事で書いたようなバカバカしいうえにキャッシュもろくに効かないソリューションを用いるはめになるかもしれません。

これらのつらみが回避不可能ということは決してありません。既に述べたように、StimulusのOutletは、任意のCSSセレクタを用いる形で階層的なイベントディスパッチを越えて拡張を行う方法の1つです。

もちろん、この方法を使えばStiumulusコントローラ同士の癒着が強まることは明らかですが、どっちみち癒着することに変わりはありません。「Stimulusは、コントローラが他のコントローラのことを一切気にせずに独立して動作するところに価値がある」とお考えの方にとっては、どちらの方法も残念なものに思えるでしょう。

(真面目な話、私はかれこれ20年以上もJavaScriptでブラウザアプリを書いてきましたが、ほとんど手を加えずにそのままオープンソースとして十分公開してもよいくらい極めて汎用性の高い機能を実装できたのは、Stimulusが初めてでした!実に爽快な気分です)

🔗 根本原因

アプリケーションで2つの無関係なタスクをうまく連携させなければならなくなると、一方を変更するために両方とも変更せざるをえなくなるという「単一責任原則への違反」になるのがオチです。たとえば以下のようなものが複雑に絡み合います。

  • StimulusコントローラAはパンを焼くのが責務だが、StimulusコントローラBが受信可能な形でイベントをトリガーしてやる必要もある。
  • StimulusコントローラBは焼き上がったパンをスライスするのが責務だが、パンが焼き上がったという通知をStimulusコントローラAから受け取るには、自分自身をDOMのどこに配置するかを調整しなければならなくなる。

StimulusコントローラAをStimulusコントローラBのノードの祖先(上位)に配置するかどうかを計画しなければならなくなった場合や、「これらのコントローラがページの中で互いに遠く離れた位置に置かれているのは問題だ」と考えていることに気付いた場合、それはStimulusコントローラAとStimulusコントローラBが本当は「独立していない」という明確な兆候なのです。

これは、私が「分離の密結合(tightly decoupled)」と呼んでいるアンチパターンの例です。2つのStimulusコントローラ同士を癒着させる「明示的な」参照は見当たらないかもしれませんが、「暗黙で」癒着しているのです。こうなると、StimulusコントローラがDOM上で適切な位置に置かれなくなったときに問題が発生します。

🔗 解決方法

この種の設計問題を解決するには、何らかの形で、他のもの同士の調整に専念する第3の「何か」を導入することがよくあります。

  • StimulusコントローラAは、パンを焼き、焼き上がったらイベントを発行する
  • StimulusコントローラBは、パンが焼き上がったらスライスする
  • StimulusコントローラCは、あるStimulusコントローラから別のStimulusコントローラにイベントを中継する

(なお、このような場合の80%は、第3のコントローラを導入する必要はありません。StimulusコントローラAとStimulusコントローラBは多くの場合互いに自然な関係を保っているので、何も考えなくても「問題なく動く™」ものです。これが問題になるのは、DOM上でコントローラ同士がイベントベースの通信を行いやすい場所に配置されていない場合だけです。)

そこで、data-controller属性をDOM上であれこれ動かさずにこの問題を解決するために、私が最近使い始めているパターンをここで紹介します。

私が書いたのは、RelayControllerというささやかなStimulusコントローラです。これはStimulusコントローラがDOM内のどの位置にあっても、送信コントローラーがイベントを他の場所に再ブロードキャストし、受信コントローラーがそれらのイベントをサブスクライブ可能にします。

さらに、このRelayControllerは、関連するコントローラの中で最も近い位置にある直近の祖先のみを対象にできます。これにより、グローバルなイベントバスが果てしなく混乱する(無関係なターゲットが発する大量のイベントをリスナー側がフィルタしなければならなくなる)ことを防ぎます。

以下はこの問題が起きているコード例です。DOM上に2つのStimulusコントローラが兄弟として横並びに配置されているとします。

<div data-controller="list-appender">
  <form onsubmit="return false;">
    <label for="name">Name</label>
    <input type="text" id="name" data-list-appender-target="nameInput" data-action="keydown.enter->list-appender#append">
    <button type="button" data-action="list-appender#append">Add</button>
  </form>
  <ul data-list-appender-target="list">
  </ul>
</div>
<!-- 上と下の間にDOMノードが大量にあると想像せよ -->
<div data-controller="commentator"
  data-action="list-appender:listWasAppended->commentator#comment">
</div>

上のコードを眺めてみると、まずListAppenderControllerにあるappendアクションは、ユーザーがEnterキーを押すかAddボタンをクリックすると発火して、listターゲットの末尾に<li>を追加するということは見当がつくでしょう。

その下のCommentatorControllerのアクションを見ると、listの末尾に何か追加されたらコメントを追加しようとしていることが示されています。

しかし、どちらのStimulusコントローラも相手の祖先にバインドされていないので、このままでは「動きません」。つまり、list-appender:listWasAppendedイベントがDOM内をどこまで駆け上がろうと、CommentatorControllerにバインドされた要素にたどり着けるはずがないのです。

この問題に対する素人くさい解決方法は、「2つのStimulusコントローラの一方または両方を、DOMの両方のブランチの共通祖先に配置する」というものです(これで問題ないことも多々ありますが、場合によっては「問題ありまくり」です)。

実際にやるとしたら、以下のどちらかになるでしょう。

  • commentatorをDOMの上位に移動して、そこでlistWasAppendedイベントをリッスンさせ、list-appenderをDOMの上位に移動して、commentatorにターゲットを追加する(イベントをディスパッチ可能にするため)
  • 2つのStimulusコントローラを「両方とも」同じ親要素に移動する

既に申し上げたように、これが適切な場合もあるにはありますが、多くの場合、冒頭のOutsetのところで示したような癒着の問題を引き起こすのがオチです。

それでは、遠い親戚同士であるStimulusコントローラ間の通信のみを担当する、第3のStimulusコントローラを導入したバージョンを以下に示します。

<div data-controller="relay">
  <div data-controller="list-appender"
    data-action="list-appender:listWasAppended->relay#forward">
    <form onsubmit="return false;">
      <label for="name">Name</label>
      <input type="text" id="name" data-list-appender-target="nameInput" data-action="keydown.enter->list-appender#append">
      <button type="button" data-action="list-appender#append">Add</button>
    </form>
    <ul data-list-appender-target="list">
    </ul>
  </div>

  <div data-controller="commentator"
    data-action="list-appender:listWasAppended->commentator#comment"
    data-relay-events="list-appender:listWasAppended">
  </div>
</div>

上によって追加されるのは、以下の3つだけです。

  • data-controller=relay
    これは最初の2つのStimulusコントローラの共通祖先で共有される
  • リストを末尾に追加するlist-appenderの要素に対するアクション
    これはlist-appender:listWasAppendedイベントから relay#forwardアクションにマッピングされます
  • data-relay-eventsという名前のdata-*属性
    これは"list-appender:listWasAppended"イベントを受信することをオプトインします。

このRelayControllerは、たとえば以下のように実装できます。

import { Controller } from '@hotwired/stimulus'

export default class RelayController extends Controller {
  forward (e) {
    const subscribers = this.element.
      querySelectorAll(`[data-relay-events*='${e.type}']`)

    subscribers.forEach(el => {
      el.dispatchEvent(new CustomEvent(e.type, {
        detail: e.detail,
        params: e.params
      }))
    })
  }
}

メソッドは1個しかありませんが、強力です。

  1. RelayControllerインスタンスの下にある任意の要素の中に、指定のイベント種別をdata-relay-events属性に持っているものがあるかどうかを探索します。

  2. 1個の属性で複数のイベントをスペース区切りで指定可能にするため、*=演算子でlazyな方法で実装しています(その分バグが起きやすい)。

  3. 同じ種類の新しいカスタムイベントを、それらのサブスクライバにディスパッチします(他の人にとってどうかは知りませんが、私にとって必要なのはオブジェクトのdetailparamsだけです)。

もっと詳しく知りたい方は、実際に動かせるサンプルを以下のリポジトリに置いてあるのでご覧ください。

searls/relay-stimulus-controller-example - GitHub

🔗「ちょっと待った、これをパッケージ化してくれないの?」

私が断続的にStimulusと取り組んできたおかげで、自分の書いたJavaScriptコントローラの多くが単体で汎用性の高い有用なものになったと先ほど書きましたが、実際にオープンソース化したことはありません(私が覚えている限りでは)。

私としては一部始終を皆さんと共有しなければという気持ちで本記事を書きました。stimulus-relayを新しいパッケージとしてプッシュしてREADMEに一通りのことを書いておき、解決方法をググった人がこれを見つけて深く考えなくても使えるようにしておけば、記事を書く時間の半分で済んだにもかかわらず、です。

「じゃぁどうしてオープンソースにしないの?」

たったの6行ぽっち」だからです。
実際、あるときふと興味を抱いて、私がStimulusコントローラをサードパーティ依存関係として取り込むたびに調べてみたところ、やはり「たったの6行ぽっち」でした。アプリのメインJavaScriptフレームワークに強い依存関係を持ち込むにもかかわらず、1回こっきりしか使わないようなライブラリを今後も追いかけ続ける手間ひまを考えれば、メリットよりもデメリットの方がずっと大きくなります。いいからこのコードをコピペして、必要な部分だけを書き換えてとっとと先に進んでください。

そしておそらく、これこそが私からStimulusに贈る最大級の賛辞です。Stimulusコントローラのオープンソースパッケージの「どれがいい」といった話題や「このパッケージがおすすめ」といった宣伝をあまり見かけない理由は、Stimulusコントローラがあまりに小さいので自分で実装する方が早いからです。

いつものことですが、本当に難しいのは、本来のニーズの背後に隠れている概念を理解することです。それを解き明かせれば、コードはたいてい自然に書けるものです。🪁

関連記事

Better Stimulusガイド:アーキテクチャ1: アプリケーションコントローラ(翻訳)

Better Stimulusガイド 07: SOLID原則(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。