Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

実践ViewComponent(2): コンポーネントを徹底的に強化する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。図はすべて元記事からの引用です。
また、"Algebraic effects"や"Effects"はコンピュータサイエンスの概念で、定訳がないため英ママとしています。

参考: Rubyでもalgebraic effectsがしたい! - lilyum ensemble

viewcomponent/view_component - GitHub

本記事は以下の記事の続編です。

実践ViewComponent(1): 現代的なRailsフロントエンド構築の心得(翻訳)

実践ViewComponent(2): コンポーネントを徹底的に強化する(翻訳)

はじめに

GitHubのViewComponentライブラリは、Ruby on Railsアプリケーションのビュー層を構築中に開発者たちの頭が爆発しないために使われてきました。ViewComponentの人気は着々と上昇中ですが、まだViewComponentの実力に見合うほどの勢いではありません。

本記事は、皆さんがViewComponentをぜひ試してみる必要がある理由について2部構成で解説いたします。Evil MartiansがこれまでViewComponentを用いるプロジェクトで培ってきたいくつかベストプラクティスや、さまざまなヒントや裏技を検証します。

  1. 実践ViewComponent(1): 現代的なRailsフロントエンド構築の心得(翻訳)
  2. 実践ViewComponent(2): コンポーネントを徹底的に強化する(翻訳) -- 本記事
  3. 実践ViewComponent(3)TailwindCSSのクラスとHTML属性(翻訳)

本シリーズ前回のパート1では、バックエンドのビュー層の構築に野性味あふれるコンポーネント方式を使うときの心得を解説し、コンポーネント方式を適切に使う方法についても学びましたが、実際の現場(もちろんproduction環境のことです)でどう使われているのかについてはまだ見ていません。このパート2では、いよいよそこを見ていくことにしましょう。

今回は、いよいよViewComponentのセットアップに関する部分をひととおり(そしてさらに深く)掘り下げていきます。そしてビューコンポーネントを「火星人流に」掌握する方法を学びます。前回と異なり、今回はコード例がたっぷり登場しますのでご期待ください。

🔗 コンポーネントを徹底的に強化する

ViewComponentはやるべきことをうまくやってくれますが、Railsほど手取り足取り懇切丁寧にやってくれるわけではありません。この方面の規約がまだ不足しているため、自分で考える以外に切り抜ける方法がない場面もちょくちょくあります。

しかし心配ご無用です。本章ではEvil Martiansがビューコンポーネント周りのコードを構築するときの方法を紹介し、すぐにでも皆さんが生産性を高めて貴重な時間を節約できるようにしたいと思います。

ViewComponentの基本的なあらましについては、公式のスタートガイドをお読みください。


注意: 本記事で紹介するテクニックには「非標準」のものも多くありますのでご了承ください。ビューコンポーネントのEvil Martians流クッキングレシピにつき、当然ながら賛否が大きく分かれることになるでしょう。しかしその中のいくつかについてはViewComponent本家にマージする計画がありますので、今後もご注目よろしくお願いします😉


🔗 view_component-contrib gem

palkan/view_component-contrib - GitHub

先に進む前に、view_component-contrib gemを紹介します(本記事ではこのgemの上で構築を進めます)。このgemは、私たちがさまざまなプロジェクトで作業するうちに有用であると感じられたViewComponent拡張およびパッチのコレクションです。ほとんどの低レベル処理を引き受けてくれるので、お肉の作られ方を考えずにお肉の味に集中できます。以下のコマンド一発でインストールできます。

rails app:template LOCATION="https://railsbytes.com/script/zJosO5"

すると設定ウィザードが起動するので、そこで好みの設定を行います(ウィザードの質問にどう答えたらいいかわからない場合は、本記事を読み進めれば答えが見つかるでしょう)。

ここからは、このview_component-contrib gemがインストールされている前提で進めます。

🔗 フォルダ構造

Rails(および類似のフレームワーク)の素晴らしい点は、どこにどんなファイルを置くかをほとんど考えずに済むことです。モデルはapp/models/フォルダに置き、コントローラはapp/controllers/フォルダに置くといった具合です。しかしビューコンポーネントの置き場所はどうにするのがよいでしょうか?関連ファイル(アセット、訳文、プレビューなど)も全部同じ場所に置くべきでしょうか?

ViewComponentのドキュメントではapp/components/フォルダに置くことを提案していますが、私はここに置くのは誤解の元になる可能性があると思います。

まずcomponentsという名前は一般的すぎますし、コンポーネントがビュー層に接続されることも示されていません。さらに言えば、フロントエンド関連のファイルは全部同じ場所にまとめておきたいと思いませんか?そういう規約があるプロジェクトでは、app/views/かapp/frontend/の下に置くのが普通でしょう。

そうした理由から、私はコンポーネントをapp/views/components/の下に置く方がずっと好ましいと思います1

これは完全に好みの問題であり、この置き場所を強く推奨しているわけではない点にご注意ください。ActionViewの規約をかき乱したくない場合は、この通りでなくてもまったく問題ありません。自分たちにとって使いやすければ、ビューコンポーネントをどこに置いても構いません。

しかしRailsのコントローラやメーラーのビューはデフォルトでapp/views/以下にも配置されることが期待されているので、このままではフォルダがたちまち散らかってしまうでしょう(下手をすると名前が衝突するかもしれません)。きちんと整理するために、以下のように対応するサブフォルダでビューを名前空間化しましょう。

views/
  components/
  layouts/
  controllers/
    my_controller/
      index.html.erb
  mailers/
    my_mailer/
      message.html.erb

そのために、まずApplicationControllerに以下の行を追加します。

append_view_path Rails.root.join("app", "views", "controllers")

ApplicationMailerにも以下の行を追加します。

append_view_path Rails.root.join("app", "views", "mailers")

それでは、app/views/components/フォルダの中を見てみましょう。

components/
  example/
    component.html.erb         # 私たちのテンプレート
    component.rb               # Example::Componentクラス
    preview.rb                 # Example::Previewクラス
    styles.css                 # CSSスタイル
    whatever.png               # その他のアセット

view_component-contrib gemを使う場合は上のような感じになります(使わない場合はあまりきれいになりません)。

component.rbファイルとcomponent.html.erbファイル(もちろんerb以外のテンプレートエンジンも使えます)が必須であることはすぐわかりますが、それ以外に必須のものはなく、いずれもオプショナルです。コンポーネントが動作するのに必要なものすべてが、単一のフォルダにきれいに収まる点にご注目ください。完璧主義者の皆さん、ここは泣いて喜ぶところです!

もちろん、必要であればコンポーネントをサブフォルダに配置して名前空間化するのも自由です。

components/
  way_down/
    we_go/
      example/
        component.rb    # WayDown::WeGo::Example::Componentクラス
        preview.rb      # WayDown::WeGo::Example::Previewクラス

🔗 ヘルパー

コンポーネントは、以下のように書くだけでレンダリングできます。

<%= render(Example::Component.new(title: "Hello World!")) %>

これでも悪くありませんが、たちまち繰り返しだらけになるでしょう。そんなときはApplicationHelperにちょっぴり甘みを加えましょう。

def component(name, *args, **kwargs, &block)
  component = name.to_s.camelize.constantize::Component
  render(component.new(*args, **kwargs), &block)
end

これで以下のようにスッキリ書けます。

<%= component "example", title: "Hello World!" %>

名前空間を使っている場合は以下のように書けます。

<%= component "way_down/we_go/example", title: "Hello World!" %>

🔗 基底クラス

エンティティ種別ごとに抽象クラスを作成する手法は、モンキーパッチに頼らずにフレームワークを手軽に拡張するときの常套手段です(ApplicationControllerApplicationMailerなど)。

これをコンポーネントで使わない手はありません。

# app/views/components/application_view_component.rb

class ApplicationViewComponent < ViewComponentContrib::Base
  extend Dry::Initializer

  include ApplicationHelper
end

dry-initializer gemを追加すれば、命令的(imparative)な#initializeメソッドを宣言的(declarative)なコードに移行して、今後多くの定型文を削減できるようになります

dry-rb/dry-initializer - GitHub

 include ApplicationHelperは、上述のコンポーネントテンプレートやプレビューに定義されているcomponentヘルパーを再利用するのに必要となります。

プレビューの基底クラスは以下のような感じになります。

# app/views/components/application_view_component_preview.rb

class ApplicationViewComponentPreview < ViewComponentContrib::Preview::Base
  # このクラスをプレビューのindexから隠蔽する
  self.abstract_class = true

  # レイアウトは継承される(ただしオーバーライドされる可能性あり)
  layout "component_preview"
end

🔗 "Effects"

本記事のパート1では、グローバルステートをコンテキストとして渡さなければならないことと、dry-effectsを使えば可能になることを学びました。

dry-rb/dry-effects - GitHub

それでは、current_userを実際にグローバルに利用可能にして、実現方法を見ていきましょう。

必要な作業は、ApplicationControllerに以下を追加することだけです。

include Dry::Effects::Handler.Reader(:current_user)

around_action :set_current_user

private

def set_current_user
  # `#current_user`が定義済みであることが前提
  with_current_user(current_user) { yield }
end

ApplicationViewComponentにも以下を追加します。

include Dry::Effects.Reader(:current_user, default: nil)

これで、カレントユーザーが必要になったときに、どのコンポーネントのどの場所でも#current_userメソッドを呼ぶだけで済むようになります。

ただし、このようなコンテキストの提供が必要な場所はproductionコードだけではありません。記事パート1ではコンポーネントを分離してテストする方法を学びましたが、記憶力のよい方なら、そこでまさに#with_current_userヘルパーを使ったことを覚えていることでしょう。もちろん、これも別途設定が必要です。

RSpec設定は以下のような感じになるでしょう。

# spec/support/view_component.rb

require "view_component/test_helpers"
require "capybara/rspec"

RSpec.configure do |config|
  config.include ViewComponent::TestHelpers, type: :view_component
  config.include Capybara::RSpecMatchers, type: :view_component
  config.include Dry::Effects::Handler.Reader(:current_user), type: :view_component

  config.define_derived_metadata(file_path: %r{/spec/views/components}) do |metadata|
    metadata[:type] = :view_component
  end
end

🔗 ネスト

コンポーネントを名前空間化すると、app/views/components/フォルダの肥大化を防ぐのに有効であることは既に説明しました。

同じ目的に使えるもうひとつの手法が、コンポーネントのネストです(子の場合、子コンポーネントは親コンポーネントフォルダ内に配置します)。要するに、あるコンポーネントがその親コンポーネント以外の場所で使われる可能性がまったくないことがわかっていれば、そのコンポーネントをルートフォルダに配置する理由はありません。

さて、my_childコンポーネントを別のmy_parentコンポーネントの下にネストし、my_parent/my_childのように完全な名前を指定すれば、問題なくレンダリングできます。しかしそこからもう少し踏み込んで、親コンポーネント内で相対名を使えるようにもできます。

ApplicationViewComponentに以下のコードを追加してみましょう。

class << self
  def component_name
    @component_name ||= name.sub(/::Component$/, "").underscore
  end
end

def component(name, ...)
  return super unless name.starts_with?(".")

  full_name = self.class.component_name + name.sub('.', '/')

  super(full_name, ...)
end

これで以下のように書けます。

<%= component ".my-nested-component" %>

ただしネストを深くすると痛い目にあいますので、ご利用は控えめに。フォルダ構造をフラットにしておく方がよい場合もあります。

🔗 国際化(I18n)

ViewComponentには、すぐ利用できるI18nサポートがあり、これによってコンポーネントごとに個別のローカライズファイルを持たせることができます。しかし訳文を1箇所に保存したい場合は、view_component-contrib gemが提供する名前空間化機能を別途利用できます。どちらの場合も相対パスを利用できます。

config/locales/en.ymlファイルに以下の訳文があるとします。

en:
  view_components:
    way_down:
      we_go:
        example:
          title: "Hello World!"

これをway_down/we_go/exampleコンポーネントで参照するには以下のようにします。

<!-- app/views/components/way_down/we_go/example/component.html.erb -->

<h1><%= t(".title") %></h1>

🔗 CSS

私たちのセットアップでは、関連するアセットをすべてcomponents/フォルダの下に保存していますが、Rubyアプリはアセットがそのフォルダにあることを実際には認識しません。アセットを適切にバンドルするのはアセットパイプラインの仕事です。これは完全に別のトピックではありますが、コンポーネントのテンプレート内ではCSSクラスを利用するので、議論しておく価値があります。

CSSは本質的にグローバルなので、設計上分離されているコンポーネントでCSSを利用するのは少し面倒です。私たちはCSSクラスをコンポーネントに対してスコープ化して、あらゆる名前衝突を防ぎたいので、コンポーネント内にあるすべてのstyles.cssファイルを単純に1個の巨大CSSファイルと紐付けるわけにはいきません。一般に、この問題を解決する方法は2とおりあります。

方法のひとつは、BEMなどの規約を使うかCSSクラスの命名を工夫することで名前衝突の可能性を排除するというものです。たとえばすべてのCSSクラス名にc--component-name--ccomponentの略)をプレフィックスする方法が考えられます。しかしこの方法は開発者に余分な認知の負荷を強いますし、時間とともにこの命名が大量に拡散してしまいます。

既にCSSモジュールに馴染んでいる方もいるでしょう。CSSモジュールは、バンドル処理でCSSクラス名を一意の識別子に変換することで分離を達成し、開発者がコードを書くときにそのことを一切意識する必要が生じないようにする手法です。残念ながら、この方法はJavaScriptではうまくいきますが、RubyではRubyソースコードをバンドル処理しないので、(少なくとも現時点では)Rubyで手軽に行う方法はありません。

css-modules/css-modules - GitHub

ではどうしたらいいでしょうか?適当に思いついた識別子を場当たり的にCSSクラス名で使うわけにはいきませんが、だからといって毎回c--component-name--のような名前を手書きするという最終手段に訴える必要があるわけではありません。そういう作業はバンドル処理にやらせればよいのです。具体的な方法はアセットパイプラインの設定によって異なりますが、ポイントは自分たちの命名規則に沿ってCSSクラス名を自動生成することです。

例として、私たちがCSSファイルをPostCSSでバンドルしているとします。この場合、postcss-modulesパッケージを利用できます。このパッケージをインストールし(Yarnの場合はyarn add postcss-modules)、postcss.config.jsファイルに以下のコードを追加します。

module.exports = {
  plugins: {
    'postcss-modules': {
      generateScopedName: (name, filename, _css) => {
        const matches = filename.match(/\/app\/views\/components\/?(.*)\/index.css$/)

        // components/フォルダの外にあるCSSファイルは変換しない
        if (!matches) return name

        // "way_down/we_go/example" を "way-down--we-go--example"に変換
        const identifier = matches[1].replaceAll('_', '-').replaceAll('/', '--')

        return `c--${identifier}--${name}`
      },
      // *.css.jsonファイルは生成しない(私たちの場合は不要)
      getJSON: () => {}
    }
  }
}

もちろん、コンポーネントのテンプレートでも同じ命名規約に従う必要があります。これを手軽に行うには、ApplicationViewComponentに以下のヘルパーを追加します。

class << self
  def identifier
    @identifier ||= component_name.gsub("_", "-").gsub("/", "--")
  end
end

def class_for(name)
  "c--#{self.class.identifier}--#{name}"
end

これで、以下のCSSクラスを

/* app/views/components/example/styles.css */

.container {
  padding: 10px;
}

以下のようにコンポーネントのテンプレートで参照できます。

<!-- app/views/components/example/component.html.erb -->

<div class="<%= class_for("container") %>">
  Hello World!
</div>

これで、どんなCSSクラス名も衝突の心配なしに安全に使えるようになり、所属するコンポーネント内で自動的にスコープ化されます。

追伸: Tailwind(または同様のCSSフレームワーク)を使う場合は、フレームワークに組み込まれているクラスですべてまかなえる可能性が高いので、上で説明した作業がすべて必要とは限りません。

🔗 JavaScript

人生には、決して変わらないものがあります。太陽がいつも東から昇り、税金から一生逃れられないように、インタラクティブなインターフェイスを構築するにはJavaScriptが必要です。しかしその気になれば、必ずしもJavaScriptを自分で書かずに済むこともあります。本記事パート1でも簡単に述べたように、Hotwireスタック(特にTurbo)を使えば、当分の間JavaScriptをまったく書かなくても、生き生きと動作するレスポンシブWebアプリケーションが手に入ります。

しかし、やがて自分のUIにJavaScriptを振りかけたくなる日が来るでしょう。Stimulusは、そんなときのツールとして最適です。Stimulusは、カスタムdata-controller属性を経由してHTML要素に動的な振る舞いを手軽にアタッチできます(この振る舞いはStimulusコントローラクラスで定義します)。

Stimulusドキュメントのコード例を見て、アプリケーションのコンポーネントに変えてみましょう。

最初に、Stimulusのコントローラクラスを作成します(通常はコンポーネントごとにcontroller.jsが1つあれば十分です)。

// app/views/components/hello/controller.js

import { Controller } from "@hotwired/stimulus"

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

  greet() {
    const element = this.nameTarget
    const name = element.value
    console.log(`Hello, ${name}!`)
  }
}

続いて、このコントローラをdata-*属性でHTMLテンプレートに接続します。

<!-- app/views/components/hello/component.html.erb -->

<div data-controller="hello">
  <input data-hello-target="name" type="text">
  <button data-action="click->hello#greet">Greet</button>
</div>

最後に、これらをアプリケーションのエンドポイントのどこか(これはアセットパイプラインの設定に強く依存します)に接着します。

// app/assets/javascripts/application.js

import { Application } from "@hotwired/stimulus"
import HelloController from "../../views/components/hello/controller"

window.Stimulus = Application.start()
Stimulus.register("hello", HelloController)

訳注

Rails 7でimportmap-railsを使う場合とjsbundling-railsを使う場合の違いについては以下の記事もどうぞ。

Rails 7: importmap-railsとjsbundling-railsでのStimulusの扱いの違い

これで動くようになりますが、まだ改善可能な点がいろいろあります。

まず、コントローラ名を推論して、そのコントローラ名に対応するコントローラクラスに自動的に登録するようにしたいと思います。これもアセットパイプラインの設定に強く依存しますが、Viteを使う場合は以下のようになります。

// app/assets/javascripts/application.js

import { Application } from '@hotwired/stimulus'

const application = Application.start()

window.Stimulus = application

const controllers = import.meta.globEager(
  "./../../app/views/components/**/controller.js"
)

for (let path in controllers) {
  let module = controllers[path]
  let name = path
    .match(/app\/views\/components\/(.+)\/controller\.js$/)[1]
    .replaceAll("_", "-")
    .replaceAll("/", "--")

  application.register(name, module.default)
}

ここでは、すべてのコンポーネントにあるcontroller.jsファイルをすべて集めて、コンポーネントのフォルダパスから推論したStimulusコントローラ名に紐付けます。これはすべてバンドル処理中に行われます。

Stimulusを他のフロントエンドツールで構成する方法に関心がある方は、#14の議論をご覧ください。

注意深く見てみると、ここでコントローラ名を推論する方法が、前のセクションで定義した::identifierメソッドでの方法と非常に似ていることに気づくと思いますが、これは偶然ではありません。CSSの場合と同様に、バンドル処理とRubyアプリの間には直接のつながりがないので、命名規約に頼るしかありません。

ApplicationViewComponentに以下のヘルパーを追加してみましょう。

def controller_name
  self.class.identifier
end

テンプレート内のdata-*属性にコントローラ名を直接書くと以後もコントローラ名を同期する必要がありますが、上のヘルパーがあれば以下のように書けます。

<!-- app/views/components/hello/component.html.erb -->

<div data-controller="<%= controller_name %>">
  <input data-<%= controller_name %>-target="name" type="text">
  <button data-action="click-><%= controller_name %>#greet">Greet</button>
</div>

🔗 ジェネレータ

ここまでの大半を、定型文を減らして楽になれるさまざまなコツの紹介に費やしてきましたが、定型文を完全に消し去れるわけではありません(Go開発者に聞いてみましょう😉)。

たとえばビューコンポーネントのコンテキストでは、ビューコンポーネントを追加したくなるたびにいくつものファイルを作成しなければなりません(プレビュー、spec、コンポーネントクラス自身など)。手作業では面倒ですが、ViewComponentにはすぐ使えるジェネレータが用意されているので、以下を実行するだけで生成できます。

bin/rails g component Example

しかしジェネレータが役に立つのはプロジェクトのニーズに合うときだけです。そういうわけで、view_component-contrib gemはインストール時にカスタムジェネレータを生成してリポジトリにチェックインしておき、必要に応じてカスタマイズできるようにしています。プロジェクトに合わせたジェネレータを作成しておくと、ワークフローをさらに制御できるようになります。

🔗 ランタイムlinter

最後に重要なものとして、パート1で述べたいくつかのベストプラクティスを強制する方法を見ていくことにしましょう。具体的には、ビューコンポーネントでデータベースクエリを回避することを推奨します。

ベストプラクティスによってはビルド時のlinter(RuboCopのカスタムルールなど)で強制する方がよいものもありますが、それ以外のベストプラクティス(関心のあるもの)についてはランタイムlinterにするのが合理的です。ありがたいことに、これはViewComponentが提供するActiveSupport instrumentationを利用して実現できます。

最初にinstrumentationを有効にします。

# config/application.rb

config.view_component.instrumentation_enabled = true

次に、config/environments/のdevelopment.rbとtest.rbに以下のカスタム設定オプションを追加します。これにより、開発しているビューコンポーネントの動作不良をテスト実行中にキャッチできるようになります。

config.view_component.raise_on_db_queries = true

ただし、問答無用でポリシーを強制するのはちょっと乱暴なので、必要に応じてコンポーネント側でオプトアウトできるようにしましょう。これを行うには、ApplicationViewComponentに以下を追加します。

class << self
  # これをクラス定義に置くとDBクエリを容認するようになる
  # self.allow_db_queries = true
  attr_accessor :allow_db_queries
  alias_method :allow_db_queries?, :allow_db_queries
end

後はlinter自身を実装します。

# config/initializers/view_component.rb

if Rails.application.config.view_component.raise_on_db_queries
  ActiveSupport::Notifications.subscribe "sql.active_record" do |*args|
    event = ActiveSupport::Notifications::Event.new(*args)

    Thread.current[:last_sql_query] = event
  end

  ActiveSupport::Notifications.subscribe("!render.view_component") do |*args|
    event = ActiveSupport::Notifications::Event.new(*args)
    last_sql_query = Thread.current[:last_sql_query]
    next unless last_sql_query

    if (event.time..event.end).cover?(last_sql_query.time)
      component = event.payload[:name].constantize
      next if component.allow_db_queries?

      raise <<~ERROR.squish
        `#{component.component_name}` component is not allowed to make database queries.
        Attempting to make the following query: #{last_sql_query.payload[:sql]}.
      ERROR
    end
  end
end

もちろん、このテクニックは他のものを強制するときにも使えます。後はあなたの想像力次第です。

🔗 ボーナス: Storybookをセットアップする

ここまで読み進めれば、プロジェクトで自信を持ってViewComponentを使い始めるのに必要な知識は十分身に付いているはずです。

ただし、プレビューについてはこれまであえて触れていませんでした(プレビューはオプション機能です)。それでも、プレビューがオプションだからといって以下を読み飛ばさないでください。プレビューはコンポーネントの中でもトップクラスに便利なツールだからです。

🔗 プレビューには多くのメリットがある

人類は「大きな作業を具体的な作業に小分けしてじっくり取り組めるようにするにはどうすればよいか?」と何度悩んできたことでしょう。私個人も数え切れないほど悩んできました。ありがたいことに、ビューコードの作業ではコンポーネントのプレビュー機能によってコンポーネントの作成やテストを分離できるので、この問題で悩むことはめったにありません。必要な作業は、いくつかのデータをモックアップして、新しいコンポーネントがブラウザでどう見えるかを確認することだけです。


プレビューを活用することで、ビューコンポーネントの作業を分離できるようになります。


しかしそれだけではありません。プレビュー機能はコンポーネント関連のあらゆるシナリオ、あらゆるエッジケースのテストで活用できるのです。同じことをアプリケーションの全コンポーネントで行えば、実際に動かせる「ライブドキュメント」が基本的に無料で手に入ることになります。ライブドキュメントは開発者のみならず、チーム全体にとっても有用であることがすぐにわかるでしょう。

ところで、単体テストでプレビューをテストケースとして利用する方法がViewComponentガイドに書かれていることをご存知ですか。素晴らしいですね!

🔗 Lookbook

ViewComponentは、コンポーネントのプレビューをブラウザで見る方法を既に提供しています(/rails/view_componentsで表示できます)が、これはほんの序の口です。私たちのStorybookでも検索・カテゴリ・動的パラメータなどのさまざまな機能が使えたらさらに素晴らしいと思いませんか?フロントエンド界隈にはそのためのStorybook.jsというライブラリがあり、これらの機能すべてに加えて他にも多くの機能を利用できます。これと同じようなものがRubyにもあるでしょうか?

もちろんありますとも。それがLookbookです。最近Evil MatiansのあるプロジェクトでもLookbookを採用して大成功を収めたので、私たちがそこで培ったLookbookの活用法を喜んでここに公開したいと思います。

allmarkedup/lookbook - GitHub

ところでLookbookは最近めでたくv1.0になりました 🎉

以下はLookbookでできることを示すささやかなサンプルです。

🔗 基本的なセットアップ

最初にlookbook gemをGemfileに追加します。

gem "lookbook", require: false

ファイルウォッチャーがproductionで実行されないようにするため、デフォルトではlookbookをrequireしていません。development環境やstaging環境で有効にするには、LOOKBOOK_ENABLED環境変数などが使えます。

LookbookエンジンはRailsコンフィグがイニシャライザを登録した直後に読み込まれる必要がありますが、惜しいことにRailsにはそれ用のフックがありません。条件付きrequireを行うには以下の方法を使うしかありません。

# config/application.rb

config.lookbook_enabled = ENV["LOOKBOOK_ENABLED"] == "true" || Rails.env.development?
require "lookbook" if config.lookbook_enabled

このルーティングをroutes.rbに追加しましょう(なお、production環境とdevelopment環境でルーティングを分けたい場合はconfig/routes/development.rbに追加できます)。

if Rails.application.config.lookbook_enabled
  mount Lookbook::Engine, at: "/dev/lookbook"
end

これで作業はほぼ終わりなので、残る作業はプレビューの"effects"もセットアップしておくことです。

ApplicationControllercurrent_userに値を注入してコンポーネント内で解決させたことを覚えていますか?プレビューのレンダリングはApplicationControllerと無関係な別のコントローラで行われるので、通常とは異なる方法が必要です。

細かな部分は省略しますが、セットアップ全体は以下のような感じになります。

# app/views/components/application_view_component_preview.rb

class ApplicationViewComponentPreview < ViewComponentContrib::Preview::Base
  # https://github.com/lsegal/yard/issues/546 を参照
  send :include, Dry::Effects.State(:current_user)

  def with_current_user(user)
    self.current_user = user

    block_given? ? yield : nil
  end
end
# config/initializers/view_component.rb

ActiveSupport.on_load(:view_component) do
  ViewComponent::Preview.extend ViewComponentContrib::Preview::Sidecarable
  ViewComponent::Preview.extend ViewComponentContrib::Preview::Abstract

  if Rails.application.config.lookbook_enabled
    Rails.application.config.to_prepare do
      Lookbook::PreviewsController.class_eval do
        include Dry::Effects::Handler.State(:current_user)

        around_action :nullify_current_user

        private

        def nullify_current_user
          with_current_user(nil) { yield }
        end
      end
    end
  end
end

これで、プレビューで便利に使える#with_current_userが手に入りました(バンザイ!)。

class Example::Preview < ApplicationViewComponentPreview
  def default
    with_current_user(User.new(name: "Handsome"))
  end
end

🔗 "Evil Martians流"プレビュー

コンポーネントをプレビューでレンダリングする方法はいろいろあります。

  • 方法1: view_component-contrib gemが提供するデフォルトのプレビューテンプレートを用いる
  • 方法2: ::Previewクラスのインスタンスメソッド(ちなみにexamplesと呼ばれています)の中でコンポーネントを手動でレンダリングする
  • 方法3: exampleごとに個別の.html.{erb, slim, etc}テンプレートを作成し、アプリケーションの他の場所で行うときとまったく同じようにコンポーネントをレンダリングする

直近のプロジェクトでは方法3を採用しましたが、まったく後悔していません。

私たちのセットアップでは、preview.html.erbをコンポーネントごとに用意し、これがコンポーネントのすべてのexampleでデフォルトのプレビューテンプレートになります。example固有のプレビューテンプレートをpreviews/のサブフォルダ内に多数置くことも可能です。

それでは、前述の動画で紹介したCollapsibleコンポーネントのプレビューを書く方法を見ていきましょう。

# app/views/components/collapsible/preview.rb

class Collapsible::Preview < ApplicationViewComponentPreview
  # @param title text
  def default(title: "What is the goal of this product?")
    render_with(title:)
  end

  # @param title text
  def open(title: "Why is it open already?")
    render_with(title:)
  end
end

上の@paramタグは、Lookbookをブラウザで見るときにリアルタイムで変更できる動的なパラメータとして扱うことをLookbookに通知します。他にもさまざまなタグが利用できるので、詳しくはLookbookのドキュメントをご覧ください。

このとき、プレビューテンプレートは以下のような感じになるでしょう。

<!-- app/viewc/components/collapsible/preview.html.erb -->

<%= component "collapsible", title: do %>
  Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid.
<% end %>
<!-- app/viewc/components/collapsible/previews/open.html.erb -->

<%= component "collapsible", title:, open: true do %>
  Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid.
<% end %>

上の書き方は、通常のビューや別のコンポーネントのテンプレートでコンポーネントをレンダリングするときとまったく同じである点にご注目ください(念のため申し添えると、titleなどのローカル変数は#render_with由来です)。

一見すると、コンポーネントごとに個別のプレビューテンプレートを作成するあたりが少々「定型文の繰り返し」っぽく感じられるかもしれませんが、それと引き換えに、各コンポーネントの表現方法を完全に自由に選択できるようになり、しかも他のコンポーネントを壊さずにいつでも自由にいじれるようになります。

とはいえ、おそらくもっと重要な点は、コンポーネントのレンダリング方法があらゆるコードベースで完全に同じになることでしょう。プレビューでもproductionコードでも同じ書き方ができるのです(経験から申し上げると、プロジェクトのフロントエンド開発者たちが大喜びします)。

🔗 メーラーでプレビューを使う

コンポーネントで既にプレビューを使えるようになったのですから、他でも使いたいですよね。実際、プレビューがあると嬉しい機能はたくさんあります。

たとえば以下のメーラーがあるとしましょう。

# app/mailers/test_mailer.rb

class TestMailer < ApplicationMailer
  def test(email, title)
    @title = title

    mail(to: email, subject: "This is a test email!")
  end
end
<!-- app/views/mailers/test_mailer/test.html.erb -->

<% content_for :content do %>
  <h1><%= @title %></h1>
<% end %>

そしてアプリケーションではすべてのメーラーが以下のレイアウトを共有しているとします。

<!-- app/views/layouts/mailer.html.erb -->

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <%= stylesheet_link_tag "email" %>
  </head>
  <body>
    <div class="container">
      <%= yield :content %>
    </div>
  </body>
</html>

ここまでは何の変哲もない、いつもどおりのAction Mailerコードです。

それでは、メーラー向けの基底プレビュークラスを追加してスパイスを効かせてみましょう(クラスに@hiddenタグを指定するとLookbookで表示されなくなる点にご注目ください)。

# app/views/components/mailer_preview/preview.rb

# このプレビューはメーラープレビューのレンダリングに用いる
#
# @hidden
class MailerPreview::Preview < ApplicationViewComponentPreview
  layout "mailer_preview"

  def render_email(kind, *args, **kwargs)
    email = mailer.public_send(kind, *args, **kwargs)

    {
      locals: {email:},
      template: "mailer_preview/preview",
      source: email_source_path(kind)
    }
  end

  private

  def mailer
    mailer_class = self.class.name.sub(/::Preview$/, "").constantize
    mailer_params ? mailer_class.with(**mailer_params) : mailer_class
  end

  def email_source_path(kind)
    Rails.root.join("app", "views", "mailers", mailer.to_s.underscore, "#{kind}.html.erb")
  end

  def mailer_params = nil
end

考え方は次のとおりです。上のクラスを継承するときに自動的にメーラークラスを推論します。続いて指定のパラメータでメールをレンダリングし、出力をメール用のカスタムプレビューテンプレート(mailer_preview/preview)に注入します。必要な場合は、このクラスの子孫クラスでmailer_paramsが実装されることが想定されています。

また、render_emailが返すsourceキーにもご注目ください。このカスタムキーはViewComponentからもLookbookからも(まだ)認識されていません。このsourceキーにはメールテンプレートへの完全パスが含まれており、後ほどこれを利用してLookbookのsourceタブを調整する予定です。ご期待ください。

さて、以下がプレビューテンプレートです。

<!-- app/views/components/mailer_preview/preview.html.erb -->

<header>
  <dl>
    <% if email.respond_to?(:smtp_envelope_from) && Array(email.from) != Array(email.smtp_envelope_from) %>
      <dt>SMTP-From:</dt>
      <dd id="smtp_from"><%= email.smtp_envelope_from %></dd>
    <% end %>

    <% if email.respond_to?(:smtp_envelope_to) && email.to != email.smtp_envelope_to %>
      <dt>SMTP-To:</dt>
      <dd id="smtp_to"><%= email.smtp_envelope_to %></dd>
    <% end %>

    <dt>From:</dt>
    <dd id="from"><%= email.header['from'] %></dd>

    <% if email.reply_to %>
      <dt>Reply-To:</dt>
      <dd id="reply_to"><%= email.header['reply-to'] %></dd>
    <% end %>

    <dt>To:</dt>
    <dd id="to"><%= email.header['to'] %></dd>

    <% if email.cc %>
      <dt>CC:</dt>
      <dd id="cc"><%= email.header['cc'] %></dd>
    <% end %>

    <dt>Date:</dt>
    <dd id="date"><%= Time.current.rfc2822 %></dd>

    <dt>Subject:</dt>
    <dd><strong id="subject"><%= email.subject %></strong></dd>

    <% unless email.attachments.nil? || email.attachments.empty? %>
      <dt>Attachments:</dt>
      <dd>
        <% email.attachments.each do |a| %>
          <% filename = a.respond_to?(:original_filename) ? a.original_filename : a.filename %>
          <%= link_to filename, "data:application/octet-stream;charset=utf-8;base64,#{Base64.encode64(a.body.to_s)}", download: filename %>
        <% end %>
      </dd>
    <% end %>
  </dl>
</header>

<div name="messageBody">
  <%== email.decoded %>
</div>

おっと、基本レイアウトの作業もお忘れなく。

<!-- app/views/layouts/mailer_preview.html.erb -->

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width" />
    <style type="text/css">
      html, body, iframe {
        height: 100%;
      }

      body {
        margin: 0;
      }

      header {
        width: 100%;
        padding: 10px 0 0 0;
        margin: 0;
        background: white;
        font: 12px "Lucida Grande", sans-serif;
        border-bottom: 1px solid #dedede;
        overflow: hidden;
      }

      dl {
        margin: 0 0 10px 0;
        padding: 0;
      }

      dt {
        width: 80px;
        padding: 1px;
        float: left;
        clear: left;
        text-align: right;
        color: #7f7f7f;
      }

      dd {
        margin-left: 90px; /* 80px + 10px */
        padding: 1px;
      }

      dd:empty:before {
        content: "\00a0"; //   
      }

      iframe {
        border: 0;
        width: 100%;
      }
    </style>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

残る作業は、Railsにメールのプレビューを探索するよう指示して、Lookbookでプレビューを拾い上げられるようにすることです(私たちはプレビューをメールテンプレートと同じ場所に保存しています)。

# config/application.rb

config.view_component.preview_paths << Rails.root.join("app", "views", "mailers")

ふぅ〜!いろいろ盛りだくさんでしたが、少なくともMailerPreview::Previewクラスを継承すれば以下のようにコード1行だけでメールをレンダリングできるようになりました。

# app/views/mailers/test_mailer/preview.rb

class TestMailer::Preview < MailerPreview::Preview
  # @param body text
  def default(body: "Hello World!")
    render_email(:test, "john.doe@example.com", body)
  end
end

ご覧ください、メーラーのプレビューができました!

A preview of the mailer shows an email that says 'Hello World!'

Hello, test email!

🔗 フロントエンドのコンポーネントをプレビューする

最近、我がEvil Martiansのあるプロジェクトでは、従来の「SPAかMPAか」を検討するパスと縁を切ることを決定し、代わりにハイブリッドなソリューションに落ち着きました。

そのアプリではほとんどのインターフェイスをViewComponentで書いていましたが、一部(特にフロントエンド)はReactアプリの形で書かれていました。これによって(フロントエンドがボトルネックにならない形で)チームメンバーに作業を等分に割り当てることが可能になったので、当時としては極めて慎重に考え抜かれたソリューションでした。そのおかげで複雑さを抑えられるようになりました(フルSPA + バックエンドGraphQL APIでやる場合と大違いです)。

すべて順調でしたが、1つ問題がありました。
プロジェクト内でStorybookを2つに分けたくなかったので、フロントエンドのReactコンポーネントでもLookbookを採用することにしました。これについて詳しく紹介する前に、以下のフォルダ構造をしばしご覧ください。

app/
  views/
    components/       # ViewComponentのコンポーネント
frontend/
  components/         # Reactのコンポーネント
    Example/
      previews/       # example固有のプレビュー
        something.tsx
      index.tsx       # Reactのコンポーネント
      preview.rb      # Frontent::Example::Preview
      preview.tsx     # デフォルトのプレビュー

見ての通り、フロントエンドのコンポーネントフォルダはバックエンドで用いるコンポーネントのフォルダととても良く似ていますが、プレビューファイルの拡張子がhtml.erbではなく.tsxである点だけが異なります。考え方としては、フロントエンドのプレビューをReactコンポーネントとして書くことで、個別にバンドルしてLookbookに動的に注入可能にするというものです。

以下はそのpreview.tsxです。

// frontend/components/Example/preview.tsx

import * as React from 'react'

import Example from './index'

interface Params {
  title: string
}

export default ({ title }: Params): JSX.Element => {
  return (
    <Example title={title} />
  )
}

そして以下はpreview.rbです

# frontend/components/Example/preview.rb

class Frontend::Example < ReactPreview::Preview
  # @param title text
  def default(title: "Hello World!")
    render_with(title:)
  end

  def something
  end
end

もちろん、プレビューの探索場所をRailsに指示しておく必要もあります。

# config/application.rb

config.view_component.preview_paths << Rails.root.join("frontend", "components")

これで期待通り動きました。

The params tab in Lookbook has 'Hello World!' as the 'title' parameter, and this is correctly reflected in the preview tab

注入が成功した様子

これでよさそうですよね?しかし、これを動かすには大量のグルーコードが必要です。ご用心。

まずはReactコンポーネントのプレビューに用いる基底クラスを見てみましょう。

# app/views/components/react_preview/preview.rb

require "json"

# Reactコンポーネントのプレビュー用に名前空間を定義する
module Frontend; end

# このプレビューはReactコンポーネントのプレビューのレンダリングに用いる
#
# @hidden
class ReactPreview::Preview < ApplicationViewComponentPreview
  layout "react_preview"

  class << self
    def render_args(example, ...)
      super.tap do |result|
        result[:template] = "react_preview/preview"
        result[:source] = preview_source_path(example)
        result[:locals] = {
          component_name: react_component_name,
          component_props: result[:locals].to_json,
          component_preview: example
        }
      end
    end

    private

    def react_component_name
      name.sub(/^Frontend::/, "")
    end

    def preview_source_path(example)
      base_path = Rails.root.join("frontend", "components", react_component_name)

      if example == "default"
        base_path.join("preview.tsx")
      else
        base_path.join("previews", "#{example}.tsx")
      end
    end
  end
end

以下はreact_preview/previewテンプレートです。

<!-- app/views/components/react_preview/preview.html.erb -->

<script>
  window.componentName = '<%= component_name %>'
  window.componentPreview = '<%= component_preview %>'
  window.componentProps = <%= raw(component_props) %>
</script>

ここではViewComponent内部のrender_argsメソッドをオーバーライドしていますが、これはまさに次のことを実現するのが目的です: これをブラウザのグローバル変数に渡すことで、フロントエンドが特定のプレビューをレンダリングするのに必要なすべてのデータをフロントエンドのバンドルに提供します。

コードからわかるように、Reactのコンポーネント名はRubyのプレビュークラス名から推論しています(Frontend::Example → Example)。また、render_withで渡されるすべての変数を単一のJSONに集約してコンポーネントのプロパティとして配信します。前のセクションで説明したカスタム:sourceプロパティがここでも登場していますが、ここにはそのプレビューの.tsxファイルへの完全パスが含まれます(これは後で必要になります)。

これでできあがりです!コンポーネントのレンダリングに必要なものがすべて揃ったので、今度は実際にレンダリングしましょう。例によってこの部分もアセットパイプライン設定によって大きく変わりますが、Viteを使う場合は以下のようになるでしょう。

<!-- app/views/layouts/react_preview.html.erb -->

<!DOCTYPE html>
<html lang="en" class="h-full">
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">

    <%= vite_client_tag %>
    <%= vite_react_refresh_tag %>
    <%= vite_javascript_tag "preview" %>
  </head>

  <body>
    <div id="preview-container">
      <%= yield %>
    </div>
  </body>
</html>

以下が実際のグルーコードです。

// frontend/entrypoints/preview.js

import { createRoot } from 'react-dom/client'
import { createElement } from 'react'

const defaultPreviews = import.meta.globEager('../components/*/preview.tsx')

const namedPreviews = import.meta.globEager('../components/*/previews/*.tsx')

function previewModule(componentName, previewName) {
  if (previewName === 'default') {
    return defaultPreviews[`../components/${componentName}/preview.tsx`].default
  } else {
    return namedPreviews[
      `../components/${componentName}/previews/${previewName}.tsx`
    ].default
  }
}

const container = document.getElementById('preview-container')

const root = createRoot(container)
const element = createElement(
  previewModule(window.componentName, window.componentPreview),
  window.componentProps
)

root.render(element)

当然ですが、このコードはproductionコードへの干渉を避けるために別のバンドルに置くことが重要です、念のため。

これでほぼ準備が整いました...というのは冗談で、これでおしまいです!それにしても半端ないコード量ですよね。ここまでする価値があるかどうかについては皆さんの判断におまかせします。ただ、経験から申し上げると、私たちのハイブリッドアプリケーションでは、表示される要素を単一のStorybookに集約することで多くのことがずっとシンプルになりました。

🔗 Source を修正する

LookbookのSourceタブは、現在のプレビューでソースコードをハイライト表示します(これは基本的にそのコンポーネントのライブドキュメントとして機能します)。しかし、これはRubyのビューコンポーネントでは完璧に動きますが、Reactのコンポーネントやメーラーでは必ずしもそうとは言い切れません。この問題を修正しましょう。

Lookbookにさまざまなものを詰め込もうとしたときに追加したカスタム:sourceプロパティを覚えていますか?今こそこのプロパティの出番です。必要なのは以下のモンキーパッチだけです。

# config/initializers/view_component.rb

ActiveSupport.on_load(:view_component) do
  if Rails.application.config.lookbook_enabled
    Rails.application.config.to_prepare do
      class << Lookbook::Lang
        LANGUAGES += [
          {
            name: "tsx",
            ext: ".tsx",
            label: "TypeScript",
            comment: "// %s"
          }
        ]
      end

      Lookbook::PreviewExample.prepend(Module.new do
        def full_template_path(template_path)
          @preview.render_args(@name)[:source] || super
        end
      end)
    end
  end
end

Lookbookを見てみましょう。

The 'Source' tab in Lookbook now correctly displays our source code thanks to the monkey patch.

Sourceタブが動くようになりました!

はいはい、モンキーパッチが信用ならないことは重々承知ですが、手軽なのも確かです。このモンキーパッチを使ってよいのは、本家側の誰かが同じような機能の実装を決定する日までです。


本章では、Lookbook gemを用いてアプリケーションにStorybookをセットアップする方法を学び、さらにそれを本来の設計以外のものに対しても適用しました。これはかなりうまくいっていると思います。そのおかげで、ViewComponentコンポーネントだけではなく、他のあらゆるものについても作業を分離できるようになりました。

ここで私は、「プレビューがあらゆる場面でこんなに便利に使えるのなら、もっと手軽にセットアップできるようにしてもいいのでは?」と思わずにいられません。何らかの汎用的なプレビューフォーマットをRails本体に統合してはどうでしょう。こう書いただけではあまりに漠然としていますが、このことは真剣に考えてみる価値があります。そうすれば、いつかそのうち本当に実現するかもしれません!😉

🔗 まとめ

ここまで来れば、私たちのViewComponentセットアップはギンギンに強化されたと言ってもよいでしょう。本記事では多くのトピックを取り上げました(大半は純粋に技術的なものです)が、ここで一歩下がって自分の胸に手を当てながら聞いてみましょう。「自分たちは実際に何を学んだだろうか?」「本当に欲しかったものがこれで達成できたか?」最後に「もう一度やってみたい気持ちになれるか?」

間違いなく、私自身の気持ちは「イエス」しかありません。ViewComponentの経験を私の言葉でまとめるとしたら、こんなふうになるでしょう。

  • ✅ ビューのコード全般を理解しやすくなった
  • ✅ ビューのコードを安心して更新できるようになり、テストカバレッジを当てにできるようになった
  • ✅ フロントエンドチームとバックエンドチームの連携が改善された
  • ✅ 比較的シンプルなUIを持つアプリでフロントエンドがボトルネックにならなくなった

もちろん、Trailblazer cellsnice_partialsといった他の方法もありますが、得られるメリットは同じだと信じています。

この方法に欠点があるとしたら何でしょうか?おそらく、引き続きフロントエンド開発者たちにRubyを多少なりとも教える必要があることでしょう。チームによってはこれがつまずきになるかもしれませんが、私たちの場合はまったく問題になりませんでした(私たちの"母国語"がRubyであることを差し引いておく必要はありますが)。

私から皆さんに申し上げたいことは以上です!フロントエンドの世界で革命が起きたのですから、バックエンドでもその時期が到来したと私は思っています。ご乗車はお早めに!🚂


お世話になった以下の皆さまに特に感謝を申し上げます。

  • Joel Hawksley: ViewComponentの作者であり、貴重な時間を割いて本記事をレビューいただきました
  • Vladimir Dementyev: 本記事のアイデアの多くを提供してくれた弊社の主席バックエンジニアです
  • そして素晴らしいview_component-contrib  gemにも!

お知らせ: プロジェクトで何か困ったことがおありでしたら、SPA、MPA、ハイブリッドアプリケーション、その他どんな問題でもEvil Martiansのメンバーがお助けに参上いたします!こちらのフォームまでご相談をお寄せください。

関連記事

実践ViewComponent(1): 現代的なRailsフロントエンド構築の心得(翻訳)

実践ViewComponent(3)TailwindCSSのクラスとHTML属性(翻訳)

Railsの「HTTPキャッシングとFaraday」の世界を冒険しながら学ぶ(翻訳)


  1. 訳注: 現時点のview_component-contrib gemのウィザードは、デフォルトでapp/frontend/components/フォルダを作成します。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。