ViewComponent で Stimulus を管理する方法 - esbuild版


この記事は、[ソニックガーデン プログラマ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/sonicgarden)の15日目の記事です。

## はじめに

Rails 環境で esbuild を使いバンドルしている場合、[ViewComponent](https://viewcomponent.org/) を活用して Stimulus を管理する際にひと手間が必要でした。本記事ではその手順をまとめます。

この構成は本番環境で約1年間運用しており、大きなトラブルもなく安定しています。また、各コンポーネントに関連するファイルを単一ディレクトリ内にまとめることで、より分かりやすく管理できるようにしています。

今回は Rails 8.0 と ViewComponent 3.20.0 の環境を用いて解説します。

## 目指す構成

次のようなディレクトリ構成を目指します。
```
app
└── components
    └── clipboard
        ├── component.html.haml
        ├── component.rb
        └── component_controller.js
```
- `components` 配下 に そのコンポーネントに閉じた形で `haml`, `rb`, `js` を配置 （この例では `Clipboard` コンポーネント）

## セットアップ

以下は、ViewComponent と esbuild の基本的なセットアップが済んでいる状態を前提とします。ここでは、ディレクトリごとに Stimulus を効率的に管理する方法を構築します。

### `stimulus:manifest:update` で Controller が登録されるようにする

`app/javascript/controllers/index.js` に `application.register` を手作業で追加するのは大変なので `stimulus:manifest:update` を実行することで自動登録されるようにします。

以下の Discussion で `stimulus-rails` で用意されている rakeタスクにモンキーパッチするアイデアが投稿されていたので、これを使わせてもらいます。
https://github.com/ViewComponent/view_component/discussions/1312

以下のコードを `lib/tasks/stimulus_tasks.rake` に保存します。

```ruby:lib/tasks/stimulus_tasks.rake
require 'stimulus/manifest'

namespace :stimulus do # rubocop:disable Metrics/BlockLength
  namespace :manifest do
    task update: :environment do
      components_manifest = Stimulus::Manifest.generate_from(Rails.root.join('app/components'))
      controllers_manifest = Stimulus::Manifest.generate_from(Rails.root.join('app/javascript/controllers'))

      components_manifest.each do |component|
        component.gsub!(/from "\./, 'from "./../../components')
      end

      Rails.root.join('app/javascript/controllers/index.js').open('w+') do |index|
        index.puts '// This file is auto-generated by ./bin/rails stimulus:manifest:update'
        index.puts '// Run that command whenever you add a new controller or create them with'
        index.puts '// ./bin/rails generate stimulus controllerName'
        index.puts
        index.puts %(import { application } from "./application")
        index.puts components_manifest
        index.puts controllers_manifest
      end
    end
  end
end
```

この Rake タスクを実行すると、`app/javascript/controllers/index.js` に自動的にコードが生成され、コンポーネントディレクトリ内の Stimulus Controller が登録されます。

以下は、モンキーパッチ前の `stimulus-rails` のtaskです。気になる方は差分を確認してみてください。
https://github.com/hotwired/stimulus-rails/blob/main/lib/tasks/stimulus_tasks.rake

## Clipboardコンポーネントを作成

今回はサンプルとして Stimulus Handbook の Clipboard コントローラーを参考にして作ってみます。
https://stimulus.hotwired.dev/handbook/building-something-real

### 1. app/components/clipboard 配下に haml, rb, js を作る

```haml:app/components/clipboard/component.html.haml
%div{ data: { controller: 'clipboard--component' } }  
  PIN:  
  %input{ data: { 'clipboard--component-target': 'source' }, type: 'text', value: @text }  
  %button{ data: { action: 'clipboard--component#copy' } } Copy to Clipboard
```

```ruby:app/components/clipboard/component.rb
class Clipboard::Component < ViewComponent::Base  
  def initialize(text:)  
    @text = text  
  end  
end
```

```javascript:app/components/clipboard/component_controller.js
import { Controller } from '@hotwired/stimulus'  
  
export default class extends Controller {  
  static targets = ['source']  
  
  copy() {  
    navigator.clipboard.writeText(this.sourceTarget.value)  
    alert(`${this.sourceTarget.value} をコピーしました`)  
  }  
}
```

同じディレクトリ `app/components/clipboard` で haml, rb, js を管理できるので分かりやすいですね。

### 2. manifestのupdate

```
bin/rails stimulus:manifest:update
```

このままでは `app/components/clipboard/component_controller.js` を読み込んでくれないので、rakeタスクを実行して manifestをupdate します。

`app/javascript/controllers/index.js` に以下のような差分ができます。

```diff_javascript
+import Clipboard__ComponentController from "./../../components/clipboard/component_controller"
+application.register("clipboard--component", Clipboard__ComponentController)
```

### 3. コンポーネントを利用する側の記述

```
<%= render(Clipboard::Component.new(text: 'Hello')) %> 
```
コンポーネントを使いたいところで、renderします。

![CleanShot 2024-12-14 at 12.10.22@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7035/5b25659f-b7d6-5804-422e-c1da28a34a46.png)

使えるようになりました 🎉

## haml内の記述を楽にする

このままでも良いのですが、コンポーネント毎に閉じ込める構成をとったために、haml内に `--component` の記述が出てきてちょっとノイズに感じてしまいます。
せっかく ViewComponent を使っているのですから、ruby側で工夫してみましょう。

### 1. 親クラスに便利メソッドを定義

各コンポーネントクラスの親クラスを定義します。

```ruby:app/components/application_comonent.rb
class ApplicationComponent < ViewComponent::Base  
  private  
  
  def controller_name  
    self.class.name.underscore.tr('_', '-').gsub('/', '--')  
  end  
  
  def target(value)  
    { "#{controller_name}_target" => value }  
  end  
  
  def action(value)  
    { action: "#{controller_name}##{value}" }  
  end  
end
```

### 2. component.rb の編集
先に作った `Clipboard::Component` で継承するように変更します。
```diff_ruby:app/components/clipboard/component.rb
-class Clipboard::Component < ViewComponent::Base
+class Clipboard::Component < ApplicationComponent
  def initialize(text:)  
    @text = text  
  end  
end
```

### 3. viewに適用

```haml:app/components/clipboard/component.html.erb
%div{ data: { controller: controller_name } }  
  PIN:  
  %input{ data: { **target('source') }, type: 'text', value: @text }  
  %button{ data: { **action('copy') } } Copy to Clipboard
```
すっきり書けました。

## まとめ

本記事では、ViewComponent を活用して Stimulus を効率的に管理する方法を紹介しました。この方法により、コードの可読性と保守性が向上し、新しいメンバーでもスムーズにプロジェクトに加わることができるのではないでしょうか。

## 補足

2024/12/15 時点で ViewComponent 公式には webpack を用いた方法しか記載されていませんでした。
https://viewcomponent.org/guide/javascript_and_css.html#stimulus 

また、Propshaft を用いた方法はプルリクエストが出ているので、そのうち公式ドキュメントで見られるようになると思います。
https://github.com/ViewComponent/view_component/pull/2160