Hotwire の関連ライブラリ?として、hotwired/stimulus があります。
Hotwireのサンプル(hotwired/hotwire-rails-demo-chat)でも使われています。
こちらは、前回の記事 Hotwire を試す では触りませんでした。
なぜかというと、使わなくても作りたいものは実現できたからです。
そうだったのですが、実は最近 stimulus について言及している記事やツイートを見かけていて、興味があったので試すことにしました。
参考
stimulus を眺める
Stimulus の説明に、翻訳をかませると次のようになっています。
Stimulusは、控えめな野心を持つJavaScriptフレームワークです。
よくわかりません。
実際、HTMLのレンダリングにはまったく関係ありません。代わりに、HTMLを輝かせるのに十分な動作でHTMLを拡張するように設計されています。
先にサンプルアプリなどを見ると、ReactやVueのようなレンダリングに関与する訳ではなく、動作の拡張にだけ寄与していました。
Hotwireのサンプルで、フロントエンドの機能拡張に使われている辺り、jQueryの代替も意識されていそうです。
あくまで、「機能拡張で抑える」というのが控えめな野心のようです。
Hotwire に組み合わせる
Hotwire を試す で作ったアプリを書き換えて stimulusを導入します。
今回の実装では、送信側は Tarbo Frames でフォームごと差し替えているのでフォームがリセットされます。
参考にした記載を見るとフォームのリセットのために、stimulus.js
を使っているものがあります。
やってみます。
github - hotwired/stimulus-railsを参考に導入します。
Gemfileを書き換えて、bundle install
します。
続けて以下コマンドを実行します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| bundle exec rails stimulus:install
|
ディレクトリとファイルがいくつか作成・書き換えされます。
フォームリセット用の stimulus
のコントローラーを作成します。
app/assets/javascripts/controllers/memos_controller.js1 2 3 4 5 6 7 8 9 10 11
| import { Controller } from "stimulus"
export default class extends Controller { connect() { console.log("Initialize") } sendAfter(){ console.log("Form Reset!") this.element.reset() } }
|
作った stimulus
のコントローラーを使用するようにビューを書き換えます。
app/views/memos/index.html.erb1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <%= Time.now %>
<div data-controller="memosbb"> <%= turbo_stream_from "memo-list" %> <turbo-frame id="memos"> <%= form_with model: Memo, url: memos_path, method: :Post , data: {controller: "memos" } do |f| %> <%= f.text_field :scribble %> <%= f.submit "追加", data: {action: "turbo:submit-end->memos#sendAfter"} %> <% end %> <ul id="memo-list-el"> <% if @memos.present? %> <% @memos.each do |memo|%> <%= render partial: "scribble", :locals => { memo: memo } %> <% end %> <% end %> </ul> </turbo-frame> </div>
|
Railsのコントローラーを書き換えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| class MemosController < ApplicationController def index @memos = Memo.all.order(id: :desc) end
def create @memo = Memo.new(memo_params) @memo.save
respond_to do |format| format.turbo_stream do render turbo_stream: turbo_stream.prepend("memo-list-el", partial: "memos/dummy") end end end
def destroy memo = Memo.find(params[:id]) memo.destroy
respond_to do |format| format.html { redirect_to action: :index } end end
private def memo_params params.require(:memo).permit(:scribble) end end
|
format.turbo_stream
で返すレスポンスは、明示的にダミー用のパーシャルを設定するようにしました。
返すダミー用のパーシャルは以下の通りです。
app/views/memos/_dummy.html.erb1
| <% # Return handled by cable %>
|
ここで動作確認すると、フォーム送信後にフォームが初期化されるようになりました。
form への data: { controller: "memos" ,action: "turbo:submit-end->memos#sendAfter" }
の設定がポイントでした。
注意すべきなのはturbo:submit-end
でしょうか、submit後にこのイベントが発火していました。
ここでclick
を設定すると送信前に、フォームが初期化されてしまって空の文字列で送信されてしまうことになります。
stimulus を細かく眺めてみる
hotwired/stimulus-starter で始める
stimulus 単体で試すため、hotwired/stimulus-starter を使っていきます。
1 2 3 4
| git clone https://github.com/hotwired/stimulus-starter.git cd .\stimulus-starter\ yarn install yarn start
|
こちらを実行して localhost:9000
にアクセスすると、「IT works!」と表示されます。
中身を見てみる
src/index.js で、個別のコントローラーを読み込んでいました。
src/index.js1 2 3 4 5 6
| import { Application } from "stimulus" import { definitionsFromContext } from "stimulus/webpack-helpers"
const application = Application.start() const context = require.context("./controllers", true, /\.js$/) application.load(definitionsFromContext(context))
|
src/controllers/example_controller.js が実装本体でした。
読み込みされたとき、connect()
が実行されていました。
src/controllers/example_controller.js1 2 3 4 5 6 7
| import { Controller } from "stimulus"
export default class extends Controller { connect() { this.element.textContent = "It works!" } }
|
これらをマウントしている public/index.html は以下のようになっています。
public/index.html1 2 3 4 5 6 7 8 9 10 11
| <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <link rel="stylesheet" href="main.css" /> <script src="bundle.js" async></script> </head> <body> <h1 data-controller="example"></h1> </body> </html>
|
いろいろ試してみる
STIMULUS を見ながら、試します。
値の書き換え
2つのフォーム間で値を渡してみます。
public/index.html1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <link rel="stylesheet" href="main.css" /> <script src="bundle.js" async></script> </head> <body> <h1 data-controller="example"></h1>
<div data-controller="test"> <input type="text" data-test-target="input"> <button data-action="click->test#reflect">Reflect</button> <input type="text" data-test-target="output" readonly> </div>
</body> </html>
|
src/controllers/test_controller.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { Controller } from "stimulus"
export default class extends Controller { static targets = ["input", "output"]
connect() { console.log("Initialize!") }
reflect() { const inputElement = this.inputTarget const outputElement = this.outputTarget outputElement.value = inputElement.value } }
|
2つのinput要素間で、値の受け渡しができました。
Class設定
要素へのclassの追加/削除をしてみます。
public/index.html1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <link rel="stylesheet" href="main.css" /> <script src="bundle.js" async></script> </head> <body> <h1 data-controller="example"></h1>
<div data-controller="test2"> <span data-test2-target="text">TEXT</span> <button data-action="click->test2#reflect">Reflect</button> </div>
</body> </html>
|
src/controllers/test2_controller.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { Controller } from "stimulus"
const CLASSNAME = 'red'
export default class extends Controller { static targets = ["text"]
connect() { console.log("Initialize!") }
reflect() { const textElement = this.textTarget
if(textElement.classList.contains(CLASSNAME)){ textElement.classList.remove(CLASSNAME); return } textElement.classList.add(CLASSNAME); } }
|
ボタンを押す度にclass のつけ外しが行われ、文字色の変更が実行できるようになりました。
配列で管理されるターゲット
stimulus のコントローラー内で、target は配列としても管理されます。
public/index.html1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <link rel="stylesheet" href="main.css" /> <script src="bundle.js" async></script> </head> <body> <h1 data-controller="example"></h1>
<div data-controller="test3"> <div data-test3-target="list">AA</div> <div data-test3-target="list">BB</div> <div data-test3-target="list">CC</div> <div data-test3-target="list">DD</div>
<button data-action="click->test3#update">Update</button> </div>
</body> </html>
|
src/controllers/test3_controller.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import { Controller } from "stimulus";
export default class extends Controller { static targets = ["list"];
index = 0;
initialize() { console.log("Initialize!") this.reflect(); }
update() { this.index++; if( this.listTargets.length <= this.index){ this.index = 0; } this.reflect(); }
reflect() { this.listTargets.forEach((element, index) => { element.hidden = index != this.index; }); } }
|
data-hgehoge-target
に、同じtargets
に指定した文字列を与えることで、this.hogeTarget
がthis.hogeTargets
になりました。
「Update」を押すと、AA->BB->CC->DD->EE
というように、内容が書き変わります。
stimulus のコントローラーの外からパラメータを与える
自動的に設定した間隔で更新してくれる機能があったので試します。
public/index.html1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <link rel="stylesheet" href="main.css" /> <script src="bundle.js" async></script> </head> <body> <h1 data-controller="example"></h1>
<div data-controller="test4" data-test4-refresh-interval-value="1000" data-test4-prefix-value="現在の秒は" > <span data-test4-target="time"></span> </div>
</body> </html>
|
src/controllers/test4_controller.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import { Controller } from "stimulus";
export default class extends Controller { static targets = ["time"]; static values = { refreshInterval: Number, prefix: String}
connect() { if(this.refreshIntervalValue){ this.startRefreshing() } }
disconnect() { if (!this.interval) return clearInterval(this.interval) }
startRefreshing() { this.interval = setInterval(() => { this.reflect() }, this.refreshIntervalValue) }
reflect() { this.timeTarget.textContent = `${this.prefixValue}${(new Date).getSeconds()}` } }
|
static values = { hoge: huga}
を使うことで、コントローラー外からパラメーターを渡すことができました。
パラメータをもとに内容の更新が行われています。
stimulus を使い、フォームのリセットや内部に状態を持ったボタンやリストなどを実装してみました。
「レンダリングに影響しない」、「HTMLを輝かせる」という設計の内容からすると、複雑なことをするのは目的外とも感じます。
操作に当たっては、生のDOM操作の知識が生きる形でした。
あくまで、HTML+CSSと機能拡張という体裁になるものでした。
Hotwire が主流になって、JavaScriptでのレンダリングをしない技術選択をするのであれば輝きそうです。
少なくとも、コントローラーのインスタンス単位?で this が閉じているので、同じコントローラーを並べた時に競合を考えなくていいのはGoodでした。
ではでは。