Rails: content_forをハックしてdata-*属性をDOMにプッシュする(翻訳)
(これは今日思いついたネタで、たぶんひどい方法だと思うのですが、うまく動くようです。この方法が完全に馬鹿げているという心当たりがおありの方は、ぜひメールでお知らせください!)
RailsでHotwiredを使っていれば、おそらくdata-*
属性をたくさん書くことになるでしょう。これは(HTMXと同様に)DOMを主要な情報源にできるなど素晴らしい点がいろいろありますが、その代わり、Stimulusコンポーネントに厄介な制約を1つ課すことがあります。
- Stimulusの値は、必ずコントローラで指定したのと同じ要素上に設定しなければなりません(つまり、
data-controller="cheese"
というタグを持つ要素は、いかなる場合であってもdata-cheese-smell-value="stinky"
のような値属性をすべて含んでいる必要があります)。
これは、ERBテンプレートの処理が終わった後にならないと手軽にアクセスできない場合には問題になります(単純に子孫要素に設定することはできません)。 -
Stiumulusコントローラのターゲット(
data-cheese-target="swiss"
)は、そのコントローラの子孫でなければなりません。
DOM上で表示されるターゲットの領域が大きく様変わりし、無関係なテンプレートによってレンダリングされる場合、設計が難しくなる可能性があります。 -
アクションは、そのコントローラがイベントをトリガーしたノードの祖先である場合にのみ、コントローラに到達します(つまり、
data-action="click->cheese#sniff"
は、data-controller="cheese"
を持つ要素の子孫に配置されている場合にのみ機能します)。
私が書くStimulusコンポーネントの多くは、上の3つ条件に該当しなければもっと簡単に実装できるはずでした。そういうわけで、「個別のビューやパーシャルで作ったレイアウトのDOMツリーの上部近くにdata-*
属性を配置して、関連するすべての要素を共通祖先として特定のコントローラを共有できればいいのに」と思うことがよくありました。
代替案としては、「使い捨てのdata-*
属性を値として保存する方法(Stimulusの便利なValues API)のメリットが帳消しになってしまう)」「アクションをグローバルイベント(@window
)にバインドする方法」「間接的なStimulusコントローラ間通信」がありますが、どれもさらに劣っています。
🔗 問題の例
私の場合、画面レイアウトにiOSナビゲーション的なUIを少しばかり使っています。このナビゲーションバーには特定のビューの検索フィールドを表示し、その検索バーではフィルタ可能な項目が多数あります。
このDOMツリーは以下のような感じになります。
- トップレベル: レイアウトテンプレート
- ナビゲーションバー用パーシャル(
yield :navbar
でカスタマイズ可能) - ビューコンテナ(ビューごとにレイアウトのプライマリ
yield
を含んでいる)
各ビューは以下を行う:- 必要なコンテンツをすべてレンダリングする
- [Optional]で、そのページ用の検索フィールドをナビゲーションバーに表示するかどうかを設定できる(
content_for :navbar
を利用) - [Optional]には、フィルタ可能な要素のリストを表示する
- ナビゲーションバー用パーシャル(
たとえば、このフィルタリングの振る舞いをStimulusのFilterable
コントローラで制御するとします。このとき、data-controller="filterable"
属性はDOMのどこに配置するのがよいのでしょうか?
この場合、ナビゲーションバーに配置するわけにはいきません(フィルタ対象となる要素がFilterable
コントローラの下に来なくなるため)。
かといってビューに配置するわけにもいきません(検索バーのイベントがFilterable
コントローラのアクションをトリガーしなくなるため)。
もちろん、レイアウトの<body>
に配置することも「一応」可能ではありますが、フィルタ機能を使うページがほんの少ししかない場合には果たして適切でしょうか。利用する可能性があるStimulusコントローラをあらゆるページの<body>
に配置するのは、明らかに間違った答えです。
私の編み出した解決方法は、Action Viewのcontent_for
とyield
をがっつりハックすることです(このあたりがよくわからない方は、Railsガイドのyield
を理解するを参照してください)。
🔗 ハックによるソリューション
この方法は要するに、ビューやパーシャルで使いたいdata-*
属性を、content_for
呼び出しでJSONとしてエンコードし、次にそれらをyield
で<body>
(または類似の上位要素)の属性としてレイアウトの上部でレンダリングするというものです。
この非常にシンプルなケースでは、DOMを共有祖先要素にプッシュする必要があったのはdata-controller="filterable"
属性だけでした。これについては、フィルタ可能なアイテムを含むビューに、以下のマジックをちょっぴり振りまくだけでできました。
<% json_content_for :global_data_attrs, {controller: "filterable"} %>
次にレイアウトを以下のように更新しました。
<%= content_tag :body, data: json_content_from(yield(:global_data_attrs)) do %>
<!-- すべてをここに配置する -->
<% end %>
これで、そのページのbody
タグにdata-controller="filterable"
属性が含まれるようになります。これにより、以下が可能になります。
- そのビュー内のアイテム(個別のアイテムに
data-filterable-target="item"
属性が設定される)が、有効なターゲットとなる - 検索バーのアクション(
data-action="input->filterable#update"
属性が設定される)が、<body>
のFilterable
コントローラに到達可能になる
🔗 しくみ
これを行うためのjson_content_for
ヘルパーメソッドとjson_content_from
ヘルパーメソッドの実装方法を以下に示します。
# app/helpers/content_for_abuse_helper.rb
module ContentForAbuseHelper
STUPID_SEPARATOR = "|::|::|"
def json_content_for(name, json)
content_for name, json.to_json.html_safe + STUPID_SEPARATOR
end
def json_content_from(yielded_content)
yielded_content.split(STUPID_SEPARATOR).reduce({}) { |memo, json|
memo.merge(JSON.parse(json)) { |key, val_1, val_2|
token_list(val_1, val_2)
}
}
end
end
特に、上のtoken_list
呼び出しにご注目ください。このメソッドは、Hash#merge
に渡されるブロック内で呼び出されるので、重複するdata-*
属性名のコンテンツ同士はホワイトスペースで結合されます。
これで、ビューやパーシャルに以下の呼び出しを書けるようになります。
<% json_content_for :global_data_attrs, {controller: "cheese"} %>
レイアウトに以下も書けるようになります。
<% json_content_for :global_data_attrs, {controller: "meats veggies"} %>
レイアウトがこのようになっていれば、指定のコントローラが以下のように配置されるようになります。
<body data-controller="cheese meats veggies">
クールですね。
🔗 これはあくまでハックです
content_for
メソッドやyield
メソッドが発明された頃は、開発者がHTMLをHTMLファイルに配置し、CSSをCSSファイルに配置し、JavaScriptをJavaScriptファイルに配置するという良識が守られていた、シンプルな時代でした。
しかしTailwindとStimulusが登場したことで、スタイルや振る舞いをHTMLの属性に記述することが増えたため、この2つのメソッドをねじ曲げる形で私のやりたいことを実現しました。
この種のアプローチは慎重に行うことをおすすめします。ハックする対象が高度になるに連れて混乱のもとになりますし、キャッシュも効かなくなり、無関係な場所で競合が発生する可能性があります。
よくわかりませんが、動いてはいるようです。🤷♂️
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
なお、特定のページでのみ
<body>
にdata-controller="filterable"
属性を設定したい場合は、コントローラかアクションで、それが必要なページでのみ臨時にレイアウトを切り替える方がシンプルなのかなと思いました↓が、著者に問い合わせると「レイアウトがかなり複雑なので、属性以外同じレイアウトが重複するリスクは避けたい」とのことでした。参考: 2.2.14.1 コントローラ用のレイアウトを指定する: レイアウトとレンダリング - Railsガイド