stimulus を触ってみる

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します。

Gemfile
1
gem 'stimulus-rails'

続けて以下コマンドを実行します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bundle exec rails stimulus:install
# =>Copying Stimulus JavaScript
# create app/assets/javascripts
# create app/assets/javascripts/controllers/hello_controller.js
# create app/assets/javascripts/importmap.json.erb
# create app/assets/javascripts/libraries
# create app/assets/javascripts/libraries/.keep
# Add app/assets/javascripts to asset pipeline manifest
# append app/assets/config/manifest.js
# Add Stimulus include tags in application layout
# insert app/views/layouts/application.html.erb
# Turn off development debug mode
# gsub config/environments/development.rb
# Turn off rack-mini-profiler
# gsub Gemfile
# run bin/bundle from "."

ディレクトリとファイルがいくつか作成・書き換えされます。

フォームリセット用の stimulus のコントローラーを作成します。

app/assets/javascripts/controllers/memos_controller.js
1
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.erb
1
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
# format.html { redirect_to action: :index } <= コメントアウト
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.erb
1
<% # 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.js
1
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.js
1
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.html
1
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.html
1
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.js
1
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.html
1
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>
public/main.css
1
2
3
4
/* .red の定義を追記 */
.red{
color: red;
}
src/controllers/test2_controller.js
1
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.html
1
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.js
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
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.hogeTargetthis.hogeTargetsになりました。

「Update」を押すと、AA->BB->CC->DD->EEというように、内容が書き変わります。

stimulus のコントローラーの外からパラメータを与える

自動的に設定した間隔で更新してくれる機能があったので試します。

public/index.html
1
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.js
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
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でした。

ではでは。