patorashのブログ

方向性はまだない

Stimulus Handbookを大体やったので所感を書く

Ruby on Railsを開発したDHHの会社のBasecampがStimulusというJSライブラリを発表したという話があり、気になっていたので試してみました。

Stimulusとは?

Stimulusはいわゆるフロントエンド系のJSライブラリなのですが、JSでゴリゴリにHTMLを操作する系ではなく、あくまでHTMLを拡張するためのものという位置づけのようです。なんといってもTurbolinksとの親和性を重視したものになっているので、Railsエンジニアでフロント系のコーディングをJS側に寄せすぎることに違和感を感じていた人たちにとってはとてもいいものなんじゃないか?という予感がします。

stimulusjs.org

Railsで使うには?

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ボタンを押したら、コンソールにログが出るようになりました。

DOMに変数をマッピングする

次に、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)

最後に、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-actionclick->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に入れるというものでした。まぁこれまでのサンプルのコールバックの実践的な使い方という感じでした。興味のある人は読んでみましょう。