Ruby on Railsを開発したDHHの会社のBasecampがStimulusというJSライブラリを発表したという話があり、気になっていたので試してみました。
Stimulusとは?
Stimulusはいわゆるフロントエンド系のJSライブラリなのですが、JSでゴリゴリにHTMLを操作する系ではなく、あくまでHTMLを拡張するためのものという位置づけのようです。なんといってもTurbolinksとの親和性を重視したものになっているので、Railsエンジニアでフロント系のコーディングをJS側に寄せすぎることに違和感を感じていた人たちにとってはとてもいいものなんじゃないか?という予感がします。
stimulusjs.org
2018-03-05現在、Rails5.1.5にて新たにRailsアプリケーションを作成した場合には、rails new
の段階でwebpackで利用するJSフレームワークとしてstimulusを指定することができます。
rails new project_name --webpack=stimulus --skip-action-cable --skip-coffee
webpackを使うため、ES2015で書けるから--skip-coffee
を指定しています。
これで自動的にインストールされます。
layout.html.slimを修正
デフォルトのlayout.html.slimの状態だと、stimulusで定義したcontrollerを自動で読み込まないので、headに= javascript_pack_tag 'application'
を追記します。私は普段からテンプレートエンジンはslimを使っているので、HTMLに関してはslim表記にしています。
doctype html
html
head
title
| ProjectName
= csrf_meta_tags
= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
= javascript_include_tag 'application', 'data-turbolinks-track': 'reload'
= javascript_pack_tag 'application'
body
= yield
これで、使えるようになりました。
Stimulus Handbookをやってみた
既存のJSライブラリと何がどう違うのか、とりあえず触ってみてから考えます。ちなみに私はReactはやったことありません。仕事ではRails + knockout.jsばっかりやってます。あとは趣味でAngularDartのチュートリアルを少々やったくらい。
stimulusjs.org
hello_controller.jsを読み解く
まず接続確認
HTMLとStimulusのControllerを紐付けるには、data-controller属性を使います。
div data-controller="hello"
input type="text"
button type="button"
| Greet
とりあえず、これで紐付いています。
hello_controller.jsの中身は以下のような感じで、ファイルだけでどのControllerかを判断するみたいです。
import { Controller } from "stimulus"
export default class extends Controller {
connect() {
console.log("Hello, Stimulus!", this.element)
}
}
ブラウザをリロードすると、connectメソッドが呼ばれてコンソールにログが表示されます。ちなみにconnectメソッドはStimulusのコールバック関数で、該当controllerとHTMLが接続したら自動的に呼ばれます。
DOMに紐づくイベントをcontrollerに定義する
次はボタンにイベントをdata-action
で定義します。このボタンをクリックしたらgreetメソッドが呼ばれる、というのがわかりやすいです。
div data-controller="hello"
input type="text"
button type="button" data-action="click->hello#greet"
| Greet
JS側は、greetメソッドを定義します。
import { Controller } from "stimulus"
export default class extends Controller {
greet() {
console.log("Hello, Stimulus!", this.element)
}
}
Greetボタンを押したら、コンソールにログが出るようになりました。
次に、input要素にJSの変数をマッピングします。
div data-controller="hello"
input type="text" data-target="hello.name"
button type="button" data-action="click->hello#greet"
| Greet
JS側は、targets
という配列の変数に、html側で使われる変数名を定義していきます。targetsにname
を定義すると、nameTarget
というプロパティが自動的に作られるようです。nameTargetには、紐付いたDOM要素が入ってきます。以下では、greetメソッドを改変して、nameに入れた値を取得してコンソールに出力しています。
import { Controller } from "stimulus"
export default class extends Controller {
static targets = [ "name" ]
greet() {
const element = this.nameTarget
const name = element.value
console.log(`Hello, ${name}!`)
}
}
プロパティがDOM要素になるというのが、なんか意外でした。
最後に、ES2015のgetterを使って、リファクタリングしていました。
import { Controller } from "stimulus"
export default class extends Controller {
static targets = [ "name" ]
greet() {
console.log(`Hello, ${this.name}!`)
}
get name() {
return this.nameTarget.value
}
}
シンプルになりましたね。これでStimulusのHello Worldは終わりでした。
クリップボードにコピーするサンプルを読み解く
次は、テキストボックスにある文字列をクリップボードにコピーするサンプルでした。
まずは最初の状態。
div data-controller="clipboard"
| PIN:
input(type="text" value="1234" readonly)
button type="button"
| Copy to Clipboard
stimulusのControllerも。copyメソッドがあるだけ。
import { Controller } from "stimulus"
export default class extends Controller {
copy() {
}
}
DOMに変数とイベントをマッピング
sourceという変数にマッピングさせます。
div data-controller="clipboard"
| PIN:
input(data-target="clipboard.source" type="text" value="1234" readonly)
button type="button"
| Copy to Clipboard
Controller側
import { Controller } from "stimulus"
export default class extends Controller {
static targets = [ "source" ]
}
次に、ボタンを押したらcopyメソッドを呼ぶようにします。
div data-controller="clipboard"
| PIN:
input(data-target="clipboard.source" type="text" value="1234" readonly)
button type="button" data-action="clipboard#copy"
| Copy to Clipboard
hello_controllerのときには、data-action
でclick->hello#greet
としていたのに、今回はclick->
がありません!なんとdata-action
には、デフォルトのアクションが要素タイプ毎に定義されているようです。button要素なので、clickがデフォルトのアクションのため、省略できるということでした。
Controller側
import { Controller } from "stimulus"
export default class extends Controller {
static targets = [ "source" ]
copy() {
this.sourceTarget.select()
document.execCommand("copy")
}
}
copyメソッドで、sourceに紐付いた要素の内容を選択してクリップボードにコピーしています。
copy機能をサポートしてないブラウザ対策
古いブラウザだと、JSだけではクリップボードにコピーできない場合があるため、その対策をしていました。その対策を通じて、stimulusでどういうふうに見た目をコントロールするかが理解できます。
まず、ボタンに.clipboard-button
をつけます。
button.clipboard-button type="button" data-action="clipboard#copy"
| Copy to Clipboard
そして、cssを定義します。クリップボードへのコピーをサポートしている場合は表示されるけれど、そうでない場合は見えないようにするというのがクラス名からわかりますね。
.clipboard-button {
display: none;
}
.clipboard--supported .clipboard-button {
display: initial;
}
そして、JS側。connectメソッドで、クリップボードへのコピーをサポートしているかチェックしてます。していたら、.clipboard--supported
をdata-controller="clipboard"と書いたdivに追加しています。結果的に、クリップボードにコピーボタンが表示されますね。
connect() {
if (document.queryCommandSupported("copy")) {
this.element.classList.add("clipboard--supported")
}
}
見た目のコントロールはcssのクラス名の付与・削除で行い、JSで直に表示・非表示を切り替えるというアプローチではなさそうです。
状態管理のサンプルを読み解く
次はスライドショーのサンプルでした。スライドショーと言っても、🐵🙈🙉🙊というお猿の絵文字をポチポチと切り替えていくというものでした。
まずはHTML。
div data-controller="slideshow"
button data-action="slideshow#previous" ←
button data-action="slideshow#next" →
div data-target="slideshow.slide" class="slide" 🐵
div data-target="slideshow.slide" class="slide" 🙈
div data-target="slideshow.slide" class="slide" 🙉
div data-target="slideshow.slide" class="slide" 🙊
次にcss。現在のスライド以外は非表示にしてますね。
.slide {
display: none;
}
.slide.slide--current {
display: block;
}
次にslideshow_controller.jsです。
import { Controller } from "stimulus"
export default class extends Controller {
static targets = [ "slide" ]
initialize() {
this.showSlide(0)
}
next() {
this.showSlide(this.index + 1)
}
previous() {
this.showSlide(this.index - 1)
}
showSlide(index) {
this.index = index
this.slideTargets.forEach((el, i) => {
el.classList.toggle("slide--current", index == i)
})
}
}
HTML側で、data-target="slideshow.slide"
が複数書かれていました。これはどうなるのか?というと、stimulusの中では、this.slideTargets
というふうに複数系になります!最初コードを見たときはtarget被っとるじゃないか!と思ったのですが、問題ありませんでした。
ここで、また新たにinitializeメソッドが出てきました。initializeメソッドも、Stimulusのコールバック関数です。コールバック関数は、
- initialize(1回のみ呼ばれる)
- connect(ViewとControllerが接続される度に呼ばれる)
- disconnect(ViewとControllerが切断される度に呼ばれる)
の3種類があるそうです。Turbolinksで画面が切り替わるとdisconnectとなりますが、戻ってくるとconnectが呼ばれる、という感じなのでしょう(試してない)。このコールバック関数を使って、connectで例えばsetIntervalとかのタイマーを使う処理を呼んだ場合、disconnectでそういうタイマー処理を破棄する、というふうに使うそうです。
ここまで見たら、「もうこのコントローラー完成してるじゃないか。どこをいじるんだ?」となるんですが、ここからStimulusのData APIを触っていくことになります。
Stimulus Data API
スライドショーだったら、ページの途中からリンクされることもあるでしょう、そうでしょう。ということで、初期ページ設定をしていきます。
div data-controller="slideshow" data-slideshow-index="0"
ここで定義した初期ページを、initializeメソッドで設定します。
initialize() {
const index = parseInt(this.element.getAttribute("data-slideshow-index"))
this.showSlide(index)
}
とまぁ、これが通常の属性の取得方法なのですが、StimulusのData APIではそこが簡単になっています。
initialize() {
const index = parseInt(this.data.get("index"))
this.showSlide(index)
}
Data APIはget, set, hasメソッドがありますから、あまり困ることはなさそうです。
そして、状態の管理ですが、このData APIを使います。どういうことかというと、DOMの属性に状態を持たせておいて、JSの変数では持たないということです。
最終的なslideshow_contoller.jsは以下のようになります。
import { Controller } from "stimulus"
export default class extends Controller {
static targets = [ "slide" ]
initialize() {
this.showCurrentSlide()
}
next() {
this.index++
}
previous() {
this.index--
}
showCurrentSlide() {
this.slideTargets.forEach((el, i) => {
el.classList.toggle("slide--current", this.index == i)
})
}
get index() {
return parseInt(this.data.get("index"))
}
set index(value) {
this.data.set("index", value)
this.showCurrentSlide()
}
}
indexのgetter, setterで、Data APIを使って取得、代入をしています。HTMLの属性側で状態を管理しています。そのため、turbolinksで画面が切り替わってから戻ってきても、問題ないということでしょう。 状態の管理をJSではなくHTML側に持たせるというアプローチは、jQueryで書いてた頃には多少やってたことがありましたが、それは別にturbolinks云々というよりはデータの置き場がそこしかないからという気持ちでやっていました。しかしStimulusでのアプローチだと、そのほうがいいですね。getter,setterを活かしていくのがよさそうです。
感想
以上を読んだ感じでは、JSで小難しいことをせずにRailsの恩恵を活用しようというという感じですね。当初はループどうすんねんと思ってたのですが、this.slideTargets
のように、data-target="slideshow.slide"
を何度も出せばいいわけなので、RailsのView側でのループでこれを使えばいい訳ですし。knockout.jsでもturbolinksとの相性問題があって、現在でも自分の担当プロダクトではturbolinks化できてなかったりします。Rails的なアプローチでは、高速化はturbolinksに乗っかるのが開発コスト的に少なくて済むので、Stimulusは結構魅力的です。
最後のサンプルについてここに書いていませんが、一応読みました。外部リソースを使う話で、fetchでHTMLを取得してcontrollerが紐付いたdivに入れるというものでした。まぁこれまでのサンプルのコールバックの実践的な使い方という感じでした。興味のある人は読んでみましょう。