SmartHR Tech Blog

SmartHR 開発者ブログ

Stimulus使ってみた(RubyKaigiノベルティ登録アプリで初めて体験してみた感想)

こんにちは、エンジニャーのkinoppydです。

SmartHRでは今年もRubyKaigiに協賛しており、今回は新作として「マスクに貼れる! アイコンステッカー」を作成しました。マスクで顔を隠す時代、とかいう以前にそもそもインターネットピーポーは会っても顔がわからないので、マスクに自分のアイコンを貼って私は何者ですとアピールすることによってRubyistのオフラインでのコミュニケーションをお手伝いできればと思い作成しました。

tech.smarthr.jp

すでに受け付けは終了していますが、このノベルティは事前登録が必要でした。アイコンの画像を収集し、実際に印刷所に持ち込んでシールとして作成してもらうためです。その事前登録用のアプリで、Rails7 + Stimulusを初体験したので、所感を記録しておきます。

Stimulusとは

Rails7からデフォルトに採用されたJavaScriptのフレームワークです。

stimulus.hotwired.dev

HTMLのdata属性を介してJavaScriptのコードとデータをやり取りし、DOMを操作します。data属性を使うので、HTMLの構造が不自然に崩れることなく、読みやすいViewを作ることができるのが特徴です。同じくRails7から入ったTurboと合わせてHotwireと呼ばれるフレームワークの一部です。

Stimulusを説明するときに少しだけややこしいなと思うのが、Controllerという概念です。Railsを使う人、あるいは一般的にMVCモデルに則ったフレームワークを使う人のイメージするControllerは、RouterやDispatcherから呼び出されModelを操作しViewをレンダリングする役割のパーツだと思います。ですが、Stimulusにも似た目的で動くControllerという名前のパーツがあり、それは既存のRailsのControllerという言葉とは全く異なった存在として存在しています(コードを置く場所も違います)。StimulusのControllerは、HTML中に書かれたdata属性を監視して自分が制御するものを発見し、データをバインドし、なにかイベントを受け取るとController内の関数を発火させる役割を持っています。役割は一般的にControllerと呼ばれるものそのものなんですが、Rails自身のRubyで書かれたControllerと名前が全く同じなので、初見の人は少しまばたきが増えると思います。

実際にどのようなコードを書くのかは、Stimulusのページに書かれているサンプルコードが一番わかり易いと思います。

<!--HTML from anywhere-->
<div data-controller="hello">
  <input data-hello-target="name" type="text">

  <button data-action="click->hello#greet">
    Greet
  </button>

  <span data-hello-target="output">
  </span>
</div>

上のようなHTMLに対して、次のようなJavaScriptで書かれたControllerがデータとイベントを監視します。

// hello_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "name", "output" ]

  greet() {
    this.outputTarget.textContent =
      `Hello, ${this.nameTarget.value}!`
  }
}

data-controller 属性に書かれた名前のControllerが、その要素と子要素を監視し、data-コントローラ名-target 属性が書かれた要素の値を取得することが出来ます。buttonタグやinputタグのイベントであるClickやChangeを data-action 属性内に click=>hello#greet のように、イベント名=>コントローラ名#関数名 の形で記述し、イベントに関数を対応させてトリガーします。そして呼び出された関数の中では、 this.nameTargetthis.outputTarget のように、 xxxTarget の形式でDOMデータにアクセスしています。これらは、 data-hello-target="name" のように data-コントローラ名-target="名前" の形でHTMLの中で記述すると、StimulusのControllerの中から操作することができるようになります。最終的に、このサンプルでは Greetボタンを押すと、inputフォーム(name という名前をdata属性で与えられている)に入力された文字列を取得し、それにHello の文字列を頭に加えたものを、 span要素(output という名前のdata属性がついている)のtextContentに挿入してDOMを書き換えています。

他にもいろいろな機能がありますが、基本的にはこんな感じでイベントトリガーで関数を呼び出す手順をdata属性を介して接続するのがStimulusです。

実際にどういう目的でStimulusを使ったのか

RubyKaigiのノベルティ事前登録用アプリでは、確実にアイコン画像を収集することが重要でした。そのため、入力されたアイコンのURLをfetchAPIで取得し、blobが取れなかったらエラー扱いにしてフロントに通知する、という動作を実現するためにStimulusを使ってみようと思いました。

それとは別に、SmartHR UIというUIコンポーネントをStimulus経由で使えるかどうかというのも試してみたくて、試しにやってみました。

アイコンのバリデーション

入力されたURLでアイコンが取得可能かどうかを判定する前に、まずURLを入力するフォームを作ります。

<div class="flex flex-col md:flex-row" data-controller="imagebox">
  <div class="flex flex-col md:w-1/2">
    <%= form_with model: rubykaigi2022 do |f| %>

/** 中略 **/

      <div class="form-container" data-controller="form" data-action="form:fetchedImage->imagebox#flash">
        <div class="mb-2">
          <div class="form-label">
            <%= f.label :icon, "アイコン画像のURL" %>
          </div>
        </div>
        <div>
          <%= f.text_field :icon, required: true, maxlength: 1000, class: "form-input", "data-form-target" => "input", "data-action" => "form#iconValidate" %>
        </div>
      </div>

/** 後略 **/

formタグを data-controller="form" のdata属性がついたdivで囲み、Stimulusのformコントローラと接続します。同じdivには、 data-action="form:fetchedImage->imagebox#flash" という属性もついていますが、これはformコントローラの中からfetchedImageというアクションを発行すると、imageboxコントローラのflash関数が動くという書き方で、複数のコントローラをイベント経由で発火させることも出来ますが、色々と制約があったりもします(この実装でimageboxコントローラは、form_withメソッドを囲むdivにかかれていて、なんだか変な感じがしますね)。

formコントローラの実装はこちらです。

import {Controller} from "@hotwired/stimulus"

// Connects to data-controller="form"
export default class extends Controller {
  static override targets = ["input"]

  iconValidate() {
    const url = this.inputTarget.value
    fetch(url)
      .then((res) => {
        if (!res.ok) {
          throw new Error("Not success")
        }
        return res.blob()
      })
      .then((res) => {
        this.inputTarget.setCustomValidity("")
        this.dispatch("fetchedImage", {detail: {imageURL: url}})
      })
      .catch((_) => {
        this.inputTarget.setCustomValidity("画像が取得できません")
      })
  }
}

f.text_field :icon によって作成されたInputタグには、 data-action="form#iconValidate" のdata属性がついており、formタグのデフォルトアクションであるonChangeをトリガーにしてiconValidate関数を呼び出します。内部では、 this.inputTarget.value を使いフォームの入力値を取り、fetchAPIで OK で返されること、blob() に応答することという2つのチェックを行っています。どちらかのチェックで弾かれると、inputフォームに対してCustomValidityが設定され、ブラウザの制御としてsubmitボタンを押せなくなるようにしています。

developer.mozilla.org

画像URLの正当性のチェックは、これだけです。fetchAPIによるチェックと、CustomValidityによるフォーム制御で不正な値の送信を防いでいます。簡易的なチェックなのでその気になれば余裕で突破できるとは思いますが、どちらかというと不正対策ではなく画像の回収ミスを防ぎたいほうが主眼なので、これくらいで大丈夫です。

このように、比較的小さなタスクを小さな範囲でこなすJavaScriptを使うとき、data属性とControllerのバインディングで記述できるのは本当に取り回しが良いなと思いました。ライフサイクルもわかりやすいし、影響範囲もきちんと絞れるので、悩むことなくサクサク書ける感覚がありとても使いやすかったです。

Reactコンポーネントを置く

StimulusではJavaScriptによる小さな制御を簡単に入れる事ができるので、Reactで書いた小さいコンポーネントをサッとHTMLに挿入する、いわゆるWebpacker時代のreact-rails的な使い方も出来ないかなと思い、試してみました。

試してみてまず気づいたのですが、Rails7ではデフォルトでJavaScriptのバンドルツールが設定されておらず、依存解決にはimportmapが使用されています。特に何も考えずに rails new してみて困ったのが、ビルドツールが無いとReact(というかJSX)がJavaScriptのコードに変換されず、全く動かないという点でした。なので、試してみたい場合はesbuildかwebpackをrails new するときに指定するか、追加でインストールする必要があります。JSXを使わないハードコアモードは、簡単に入れたいという趣旨から外れてしまうので除外します。

実際のコードを見る前に結論を話すと、SmartHR UIのようなある程度完成されたコンポーネント集をStimulus経由で呼び出すのはあまり良い選択肢ではないなと思いました。SmartHR UIには膨大な数のコンポーネントがあるので、それらに対応するコントローラをいちいち作るわけにもいかず、かといって一つのコントローラ内でなんのコンポーネントをレンダリングするかを分岐させるのもやや冗長で、せっかくのReactの強みを殺しているような状態になってしまったからです。

SmartHR UIをレンダリングするコントローラはこんな風になっています。

import {Controller} from '@hotwired/stimulus'
import {render_logo, destroy, render_status_label} from './SmartHRUIComponent'

export default class extends Controller {
  static override targets = ["container"]
  component?: string = undefined

  override initialize() {
    this.component = this.containerTarget.dataset.component
  }

  override connect() {
    switch (this.component) {
      case 'logo':
        render_logo(this.containerTarget, {width: "95", height: "16", fill: "brand"})
        break
      case 'statusLabel':
        const label = this.containerTarget.dataset.statusLabelName
        const props = JSON.parse(this.containerTarget.dataset.props)
        render_status_label(this.containerTarget, label, props)
      default:
        break
    }
  }

  override disconnect() {
    destroy(this.containerTarget)
  }
}

data属性でコンポーネント名を渡し、それをswitchで切り替えてレンダリングしています。お世辞にも良いコードとは思いません。コンポーネントが増えれば増えるほどswitchとimportが伸び、結局React内部の制御は何もしていない状態です。

また、もう一つ困った点として、ReactのライフサイクルとStimulusのライフサイクルの相性があまり良くないというか、どう折り合いをつければ良いのかよくわからなかった点があります。React自身も、Stimulus自身もそれぞれイベントハンドリングや状態の保持を行うため、やるべきことが重複してしまい、どこで制御すべきなのかが宙ぶらりんになってしまいました。また、FCなどの状態を持たないコンポーネントで、例えばselect タグなどを扱う場合、selected 属性がついているかどうかを仮想DOM側でReactが制御する一方で、Stimulus側は実際のDOM構造を見てしまうので、どうにもいかなくなります。自分で作ったコンポーネントであればよいのかなとも思いますが、SmartHR UIのように既にロジックがついたUIパーツを使ってしまうと、このような状態の整合性の不一致により身動きが取れなくなってしまいます。

Stimulus を本当に軽くだけ触った所感

URLの正当性チェックのような、本当に軽いタスクをたくさん用意して小さく制御していくという使い方で、Stimulusは実に強力な働きをすると思いました。同じくHotwireを構成するTurboなどと組み合わせると、かなり強力なSPA(っぽいもの)がRailsの機能の範囲内だけで完結してしまう、とても野心的なツールだと思いました。

一方で、既存の資産を流用しようとするのはかなり骨の折れる行動のように思えました。もしかしたらカッチリはまるケースもあるかもしれませんが、どちらかというと既存のコードとStimulusのコードとの間で八方塞がりになりそうな雰囲気を感じました。

Railsは本当に初速に強いフレームワークだと思うので、まず新規はStimulus+Turboでそれっぽく動くものを作って、必要に応じて更に強力なフロント用のフレームワークに置き換えていく、というこれまで言われていたセオリーを更に加速させるような力をStimulusには感じました。

求人

SmartHRでは、RubyKaigiを盛り上げたい仲間も求めています。RubyKaigi2022の会場にブースもあるので、是非お話をさせてください!

hello-world.smarthr.co.jp