BitArts Blog

ロードバイク通勤のRubyプログラマで伊豆ダイバー。の個人的なブログ。

Rails + ViewComponentによるコンポーネント指向UI開発

UI開発におけるコンポーネント指向とは

React、Vue.js、Angularなどフロントエンドの主要なViewフレームワークで採用されている手法

これをRailsに輸入したいというのが今回の話題

コンポーネントの例

検索ボックス(SearchBoxコンポーネント

コンポーネント指向UIのメリット

再利用性

コンポーネントを他の機能や他のシステムで再利用することが容易

保守性とテスト容易性

コンポーネントは独立して機能するため、修正・更新・テストが容易

可読性

小さな単位のコンポーネントで構成されるので、コードの可読性が向上

再利用可能なデザイン

デザインシステムやスタイルガイドを作成して、全体でUIデザインの一貫性を保つことができる

分業性

コンポーネントを独立して開発できるため、チーム内でのタスク分担が容易

コンポーネント指向UIのデメリット

最初は大変

大量のコンポーネントを作る必要がある

GitHubではPrimerというデザインシステムを公開しているので利用するのもあり (個人的にはTailwind CSSを使いたかったのでスクラッチで作ってます)

ページデザインとの相性が悪い

デザイナーがコーディングしたHTMLをベースに… というのはやりにくい

ページをデザインするのではなく、コンポーネントをデザインする必要がある

Railsコンポーネント指向UI開発するには

パーシャルテンプレートで実装する?

<%# _search_box.html.erb %>
<% content_for :head do %>
  <style>
   // CSS...
  </style>
<% end %>
<%
  ## Rubyのコード...
%>
<script>
  // JavaScriptのコード...
</script>
<form>
  <%= render 'text_input', name: 'q', placeholder: placeholder %>
  <%= render 'submit_button', label: '検索' %>
</form>

近いことはできそうだが、不完全

スコープ化が不完全性能が悪い単体テストが難しい

ViewComponentとは

Railsコンポーネント指向UIを実装でできる (GitHub謹製、github.comのために作られたライブラリ)

こんなふうにコンポーネントを実装できます

## app/components/search_box_component.rb
class SearchBoxComponent < ViewComponent::Base
  def initialize(placeholder:)
    @placeholder = placeholder
  end
end
<%# app/components/search_box_component.html.erb %>
<form>
  <%= render(TextInputComponent.new(name: 'q', placeholder: @placeholder)) %>
  <%= render(SubmitButtonComponent.new(label: '検索') %>
</form>

使いかた

<%= render(SearchBoxComponent.new(placeholder: 'キーワードを入力')) %>

Rails 6.1のrender拡張とViewComponent

パーシャルテンプレートを呼ぶ機能だったrenderだが、Rails 6.1でrender_inメソッドを実装したオブジェクトを渡せるようになった

<%= render ExampleComponent.new %>
class ExampleComponent
  def render_in(view_context)
    view_context.content_tag(:p, 'Hello, World!')
  end
end

Rails的にはこの機能を サードパーティー製のコンポーネントフレームワークのサポート」 と謳っている

これを利用したのがViewComponent

一時「ActionView::ViewComponent」という名前でRails本体にマージされたようだが、結果的にはこの形になったよう

基本的な使い方

# app/components/example_component.rb
class ExampleComponent < ViewComponent::Base
  def initialize(title:)
    @title = title
  end
end
<%# app/components/example_component.html.erb %>
<span title="<%= @title %>"><%= content %></span>

使い方

<%= render(ExampleComponent.new(title: 'my title')) do %>
  Hello, World!
<% end %>

結果

<span title="my title">Hello, World!</span>

応用機能

スロット

複数のコンテンツブロックを渡せる

## app/components/article_component.rb
class ArticleComponent < ViewComponent::Base
  renders_one :heading
  renders_one :body
end
<%# app/components/article_component.html.erb %>
<h1><%= heading %></h1>
<p><%= body %></p>

使い方

<%= render(ArticleComponent.new) do |component| %>
  <% component.with_heading do %>
    My Title
  <% end %>
  <% component.with_body do %>
    Hello, World!
  <% end %>
<% end %>

コレクション

配列を渡して繰り返し出力できる

Viewでループを書かなくてもリストが作れる

## app/components/product_component.rb
class ProductComponent < ViewComponent::Base
  def initialize(product:)
    @product = product
  end
end

使い方

<%= render(ProductComponent.with_collection(@products)) %>

※ デフォルトでは自動的にコンポーネント名に基づいた引数に渡される(ここではproduct)

条件付きレンダリング

コンポーネントレンダリングする条件を定義できる

Viewでif分岐する必要がなくなる

## app/components/confirm_email_component.rb
class ConfirmEmailComponent < ViewComponent::Base
  def initialize(user:)
    @user = user
  end

  def render?
    @user.requires_confirmation?
  end
end

View実装例

最終的にviewの中身はHTMLがなくなってコンポーネントがたくさん置かれるだけのものになる

edit.html.erbの例

<%= form_with model: [:admin, @record] do |f| %>
  <%= render(FormComponent.new(model: @record) do %>
    <%= render(FormFieldTextComponent.new(form: f, attribute: :email) %>
    <%= render(FormFieldTextComponent.new(form: f, attribute: :nick_name) %>
    <%= render(FormFieldPasswordComponent.new(form: f, attribute: :password, autocomplete: 'new-password') %>
    <%= render(FormFieldPasswordComponent.new(form: f, attribute: :password_confirmation) %>
  <% end %>
<% end %>

ちょっと改善

ヘルパーを作ると少し可読性が良くなるかも

module ApplicationHelper
  def component(name, *args, **kwargs, &)
    component = "#{name.to_s.camelize}Component".constantize
    render(component.new(*args, **kwargs), &)
  end
end
<%= form_with model: [:admin, @record] do |f| %>
  <%= component :form, model: @record do %>
    <%= component :form_field_text, form: f, attribute: :email %>
    <%= component :form_field_text, form: f, attribute: :nick_name %>
    <%= component :form_field_password, form: f, attribute: :password, autocomplete: 'new-password' %>
    <%= component :form_field_password, form: f, attribute: :password_confirmation %>
  <% end %>
<% end %>

Helper? Decorator? Partial? Component?

かくして、共通的にデータを整形して表示する主な手段は以下の4つとなる

Helper

<%= put_datetime user.created_at %>

Decorator (ViewModel)

<%= user.decorate.created_at %>

Partial

<%= render 'partial/datetime', user.created_at %>

ViewComponent

<%= render(DateTimeFormatComponent(datetime: user.created_at)) %>

GitHub的には全部Component推しのよう

JavaScriptコードのカプセル化

フロントエンドのコードもグローバルスコープに置かず、コンポーネントに閉じ込めたい

ViewComponentではそのための機能は特に用意されていないので、工夫が必要

相性のよさそうなフレームワークとして、StimulusとAlpine.jsを紹介します

Stimulus

Hotwireの構成要素。Rails 7の標準のシンプルなjsフレームワーク

Alpine.js

Vue.jsのようなHTMLをテンプレートとしたリアクティブデータバインディングが可能な軽量フレームワーク

JavaScriptコードのカプセル化(Stimulus編)

StimulusはHTML要素とJavaScriptコードを1:1でマッピングできる

<%# counter_component.html.erb %>
<div data-controller="counter">
  <h1 data-counter-target="display">0</h1>
  <button data-action="click->counter#increment">Count Up!</button>
</div>
// counter_component.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static targets = ['display'];
  connect() {
    this.count = 0;
  }
  increment() {
    this.count++;
    this.displayTarget.textContent = this.count;
  }
}

JavaScriptコードのカプセル化(Alpine.js編)

HTML要素とデータオブジェクトを 双方向バインディング

<%# counter_component.html.erb %>
<div x-data="counterComponent">
  <input type="text" x-model="count">
  <button @click="increment()">Count Up!</button>
</div>
// counter_component.js
export default () => ({
  count: 0,

  increment() {
    this.count++;
  }
});

双方向バインディングなので、テキストボックスに「10」と入力してからCount Up!を押すと「11」になる

シンプルな内容であればデータオブジェクトを用意しなくても実装できる

<%# counter_component.html.erb %>
<div x-data="{ count: 0 }">
  <input type="text" x-model="count">
  <button @click="count++">Count Up!</button>
</div>

Railsではロジックの大半はサーバサイドに行くので、フロントエンドはこれで事足りることが多いのでは

シンプルながらモダンなリアクティブ実装ができ、分かりやすく書けるので、個人的にはAlpine.js推し

CSSカプセル化

CSSコンポーネント単位で完結するようにしたいが、ViewComponentでは機能は用意されていない

classのスコーピングには色々テクニックはあるが、そもそもかなり難しい問題

  • 「BEM」など命名ルールで凌ぐ方法
  • CSS in JS」などJavaScriptでスタイルを定義する手法(React界隈)

決定的な方法がないです

いっそ、CSSを書くことを諦めるという手も…

CSSカプセル化(Tailwind CSS

Tailwind CSS 一押し!

class属性でスタイルを埋め込みで指定していく、ユーティリティーファーストのCSSフレームワーク

本来のCSSの思想を尊重したらあり得ない設計です 💦

<div class="flex justify-center items-center h-screen bg-gray-200">
  <div class="bg-white p-8 rounded shadow-lg text-center">
    <h2 class="text-2xl mb-4 text-blue-500">Hello, Tailwind CSS!</h2>
    <p class="text-gray-700">This is a simple example of using Tailwind CSS.</p>
    <button class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-400">
      Click me
    </button>
  </div>
</div>

しかしコンポーネントにデザインを閉じ込めたいというニーズには大変適している

コンポーネント指向を採用していない場合は保守性を損なうので使わないほうがいいと思いますが

コンポーネントのテスト

コンポーネントは独立したオブジェクトなので単体テストが書きやすい

従来のViewはほとんど単体テスト不可能でした

# search_box_component_spec.rb
RSpec.describe SearchBoxComponent, type: :component do
  let(:component) { described_class.new(placeholder: 'キーワードを入力') }

  it 'renders the message' do
    html = render_inline(component)
    expect(html.text).to include('キーワードを入力')
  end
end

プレビュー

コンポーネント単体でプレビューできる。フロントエンド界隈の Storybook と同等

コンポーネントをアプリケーションから切り離して単体で開発でき、カタログとして共有できる

ViewComponent標準でもプレビュー機能を持っているが、より高機能なサードパーティLookbookViewComponent::Storybook がおすすめ

プレビューの定義

サンプルのrenderを書いておくだけ

# search_box_component_preview.rb
class SearchBoxComponentPreview < ViewComponent::Preview
  def default
    render(SearchBoxComponent.new(placeholder: 'キーワードを入力'))
  end
end

プレビューをテストケースに使う

おすすめ

# search_box_component_spec.rb
RSpec.describe SearchBoxComponent, type: :component do
  it 'renders the message' do
    html = render_preview(:default)
    expect(html.text).to include('キーワードを入力')
  end
end

※ デフォルトでは対象のコンポーネントはテストのファイル名を元に自動的に推測される