実践ViewComponent(2): コンポーネントを徹底的に強化する(翻訳)
はじめに
GitHubのViewComponentライブラリは、Ruby on Railsアプリケーションのビュー層を構築中に開発者たちの頭が爆発しないために使われてきました。ViewComponentの人気は着々と上昇中ですが、まだViewComponentの実力に見合うほどの勢いではありません。
本記事は、皆さんがViewComponentをぜひ試してみる必要がある理由について2部構成で解説いたします。Evil MartiansがこれまでViewComponentを用いるプロジェクトで培ってきたいくつかベストプラクティスや、さまざまなヒントや裏技を検証します。
- 実践ViewComponent(1): 現代的なRailsフロントエンド構築の心得(翻訳)
- 実践ViewComponent(2): コンポーネントを徹底的に強化する(翻訳) — 本記事
本シリーズ前回のパート1では、バックエンドのビュー層の構築に野性味あふれるコンポーネント方式を使うときの心得を解説し、コンポーネント方式を適切に使う方法についても学びましたが、実際の現場(もちろんproduction環境のことです)でどう使われているのかについてはまだ見ていません。このパート2では、いよいよそこを見ていくことにしましょう。
今回は、いよいよViewComponentのセットアップに関する部分をひととおり(そしてさらに深く)掘り下げていきます。そしてビューコンポーネントを「火星人流に」掌握する方法を学びます。前回と異なり、今回はコード例がたっぷり登場しますのでご期待ください。
コンポーネントを徹底的に強化する
ViewComponentはやるべきことをうまくやってくれますが、Railsほど手取り足取り懇切丁寧にやってくれるわけではありません。この方面の規約がまだ不足しているため、自分で考える以外に切り抜ける方法がない場面もちょくちょくあります。
しかし心配ご無用です。本章ではEvil Martiansがビューコンポーネント周りのコードを構築するときの方法を紹介し、すぐにでも皆さんが生産性を高めて貴重な時間を節約できるようにしたいと思います。
ViewComponentの基本的なあらましについては、公式のスタートガイドをお読みください。
注意: 本記事で紹介するテクニックには「非標準」のものも多くありますのでご了承ください。ビューコンポーネントのEvil Martians流クッキングレシピにつき、当然ながら賛否が大きく分かれることになるでしょう。しかしその中のいくつかについてはViewComponent本家にマージする計画がありますので、今後もご注目よろしくお願いします
view_component-contrib gem
先に進む前に、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!" %>
基底クラス
エンティティ種別ごとに抽象クラスを作成する手法は、モンキーパッチに頼らずにフレームワークを手軽に拡張するときの常套手段です(ApplicationController
やApplicationMailer
など)。
これをコンポーネントで使わない手はありません。
# app/views/components/application_view_component.rb
class ApplicationViewComponent < ViewComponentContrib::Base
extend Dry::Initializer
include ApplicationHelper
end
dry-initializer gemを追加すれば、命令的(imparative)な#initialize
メソッドを宣言的(declarative)なコードに移行して、今後多くの定型文を削減できるようになります。
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を使えば可能になることを学びました。
それでは、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--
(c
はcomponent
の略)をプレフィックスする方法が考えられます。しかしこの方法は開発者に余分な認知の負荷を強いますし、時間とともにこの命名が大量に拡散してしまいます。
既にCSSモジュールに馴染んでいる方もいるでしょう。CSSモジュールは、バンドル処理でCSSクラス名を一意の識別子に変換することで分離を達成し、開発者がコードを書くときにそのことを一切意識する必要が生じないようにする手法です。残念ながら、この方法はJavaScriptではうまくいきますが、RubyではRubyソースコードをバンドル処理しないので、(少なくとも現時点では)Rubyで手軽に行う方法はありません。
ではどうしたらいいでしょうか?適当に思いついた識別子を場当たり的に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を使う場合の違いについては以下の記事もどうぞ。
これで動くようになりますが、まだ改善可能な点がいろいろあります。
まず、コントローラ名を推論して、そのコントローラ名に対応するコントローラクラスに自動的に登録するようにしたいと思います。これもアセットパイプラインの設定に強く依存しますが、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.jsというライブラリがあり、これらの機能すべてに加えて他にも多くの機能を利用できます。これと同じようなものがRubyにもあるでしょうか?
もちろんありますとも。それがLookbookです。最近Evil MatiansのあるプロジェクトでもLookbookを採用して大成功を収めたので、私たちがそこで培ったLookbookの活用法を喜んでここに公開したいと思います。
ところで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”もセットアップしておくことです。
ApplicationController
のcurrent_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
ご覧ください、メーラーのプレビューができました!
フロントエンドのコンポーネントをプレビューする
最近、我がEvil Martiansのあるプロジェクトでは、従来の「SPAかMPAか」を検討するパスと縁を切ることを決定し、代わりにハイブリッドなソリューションに落ち着きました。
そのアプリではほとんどのインターフェイスをViewComponentで書いていましたが、一部(特にフロントエンド)はReactアプリの形で書かれていました。これによって(フロントエンドがボトルネックにならない形で)チームメンバーに作業を等分に割り当てることが可能になったので、当時としては極めて慎重に考え抜かれたソリューションでした。そのおかげで複雑さを抑えられるようになりました(フルSPA + バックエンドGraphQL APIでやる場合と大違いです)。
すべて順調でしたが、1つ問題がありました。
プロジェクト内でストーリーブックを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")
これで期待通り動きました。
これでよさそうですよね?しかし、これを動かすには大量のグルーコードが必要です。ご用心。
まずは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コードへの干渉を避けるために別のバンドルに置くことが重要です、念のため。
これでほぼ準備が整いました…というのは冗談で、これでおしまいです!それにしても半端ないコード量ですよね。ここまでする価値があるかどうかについては皆さんの判断におまかせします。ただ、経験から申し上げると、私たちのハイブリッドアプリケーションでは、表示される要素を単一のストーリーブックに集約することで多くのことがずっとシンプルになりました。
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を見てみましょう。
はいはい、モンキーパッチが信用ならないことは重々承知ですが、手軽なのも確かです。このモンキーパッチを使ってよいのは、本家側の誰かが同じような機能の実装を決定する日までです。
本章では、Lookbook gemを用いてアプリケーションにストーリーブックをセットアップする方法を学び、さらにそれを本来の設計以外のものに対しても適用しました。これはかなりうまくいっていると思います。そのおかげで、ViewComponentコンポーネントだけではなく、他のあらゆるものについても作業を分離できるようになりました。
ここで私は、「プレビューがあらゆる場面でこんなに便利に使えるのなら、もっと手軽にセットアップできるようにしてもいいのでは?」と思わずにいられません。何らかの汎用的なプレビューフォーマットをRails本体に統合してはどうでしょう。こう書いただけではあまりに漠然としていますが、このことは真剣に考えてみる価値があります。そうすれば、いつかそのうち本当に実現するかもしれません!
まとめ
ここまで来れば、私たちのViewComponentセットアップはギンギンに強化されたと言ってもよいでしょう。本記事では多くのトピックを取り上げました(大半は純粋に技術的なものです)が、ここで一歩下がって自分の胸に手を当てながら聞いてみましょう。「自分たちは実際に何を学んだだろうか?」「本当に欲しかったものがこれで達成できたか?」最後に「もう一度やってみたい気持ちになれるか?」
間違いなく、私自身の気持ちは「イエス」しかありません。ViewComponentの経験を私の言葉でまとめるとしたら、こんなふうになるでしょう。
- ビューのコード全般を理解しやすくなった
- ビューのコードを安心して更新できるようになり、テストカバレッジを当てにできるようになった
- フロントエンドチームとバックエンドチームの連携が改善された
- 比較的シンプルなUIを持つアプリでフロントエンドがボトルネックにならなくなった
もちろん、Trailblazer cellsやnice_partialsといった他の方法もありますが、得られるメリットは同じだと信じています。
この方法に欠点があるとしたら何でしょうか?おそらく、引き続きフロントエンド開発者たちにRubyを多少なりとも教える必要があることでしょう。チームによってはこれがつまずきになるかもしれませんが、私たちの場合はまったく問題になりませんでした(私たちの”母国語”がRubyであることを差し引いておく必要はありますが)。
私から皆さんに申し上げたいことは以上です!フロントエンドの世界で革命が起きたのですから、バックエンドでもその時期が到来したと私は思っています。ご乗車はお早めに!
お世話になった以下の皆さまに特に感謝を申し上げます。
- Joel Hawksley: ViewComponentの作者であり、貴重な時間を割いて本記事をレビューいただきました
- Vladimir Dementyev: 本記事のアイデアの多くを提供してくれた弊社の主席バックエンジニアです
- そして素晴らしいview_component-contrib gemにも!
お知らせ: プロジェクトで何か困ったことがおありでしたら、SPA、MPA、ハイブリッドアプリケーション、その他どんな問題でもEvil Martiansのメンバーがお助けに参上いたします!こちらのフォームまでご相談をお寄せください。
関連記事
- 訳注: 現時点のview_component-contrib gemのウィザードは、デフォルトでapp/frontend/components/フォルダを作成します。 ↩
The post 実践ViewComponent(2): コンポーネントを徹底的に強化する(翻訳) first appeared on TechRacho.
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。図はすべて元記事からの引用です。
また、”Algebraic effects”や”Effects”はコンピュータサイエンスの概念で、定訳がないため英ママとしています。
参考: Rubyでもalgebraic effectsがしたい! – lilyum ensemble