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

Railsのコンポーネントを「gemなしで」シンプルに構築する(翻訳)

概要

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

日本語タイトルは内容に即したものにしました。

Railsのコンポーネントを「gemなしで」シンプルに構築する(翻訳)

私が手掛けている案件の中には、ViewComponentのようなサードパーティgemを使わない・使いたがらない・使えない事情のある顧客も多いため、そのような場合は、Railsのパーシャルを使うことになります。パーシャルで構築する場合、最初のうちは非常に大きな成果を出しやすいのですが、実装が進むに連れてメンテナンス性やクリーンなコードで壁に突き当たってしまいます(特に問題なのはビューのロジックが増えすぎてしまうことです)。

このトピックについて調べていくと、「XXヘルパーを使おう」といった記事をよく見かけますが、私にとって最も大きな不満の種は、ヘルパーのスコープがグローバルであることです。私は、アプリで本当に必要でない限り、ヘルパーを使わないようにしています。

本記事で紹介するさまざまなテクニックは、私自身が立ち上げたアプリを含むさまざまなアプリで、素のRailsパーシャルを超える機能を「gemに依存せずに」実現するために使われています。

本記事のコードは以下のリポジトリですべて参照できます。

rails-designer-repos/VanillaComponents - GitHub

また、以下のWebサイトで実際のコンポーネントが動く様子を見ることも可能です。

参考: Vanilla Rails Components (using Tailwind CSS)

🔗 コンポーネントヘルパー

このヘルパーこそが、「素のRailsコンポーネント」の中核を担います。ComponentHelperは、Railsアプリの再利用可能なUI要素をレンダリングするためのクリーンなAPIを提供します。

module ComponentHelper
  def component(name, locals = {}, &block)
    return render(layout: "components/#{name}", locals: locals, &block) if block_given?

    collection = locals.delete(:collection)
    return render(partial: "components/#{name}", collection: collection, as: locals.delete(:as) || name.to_sym, locals: locals) if collection

    render(partial: "components/#{name}", locals: locals)
  end
end

このコンポーネントヘルパーは、Railsの標準的なレンダリングメソッド(render)を、より簡潔なAPIでラップします。componentメソッドには、「コンポーネント名」「ローカル変数のハッシュ」「オプションのブロック」を渡せるようになっていて、渡された引数に応じて以下のように適切なレンダリング戦略を決定します。

  1. ブロックが渡されている場合は、コンポーネントをレイアウトとしてレンダリングする。ブロックの内容はコンポーネントのテンプレートに渡される。
  2. コレクションが渡された場合は、コレクション内の個別のアイテムごとに1回ずつコンポーネントをレンダリングする
  3. それ以外の場合は通常のシンプルなパーシャルとしてレンダリングする。

すべてのコンポーネントはapp/views/components/ディレクトリ以下に配置されていることが期待されます。また、Railsのパーシャル命名規則に準じて、コンポーネントのファイル名にもアンダースコアをプレフィックスすることが前提となります。

なお、私はファビコンのレンダリングのように十分シンプルな場合には引き続きパーシャルを使っています。

🔗 コンポーネントでlocalsを明示的に指定する

Railsの普通にパーシャルに、以下のようにlocalsアノテーションでインターフェイスを定義するだけで、最もシンプルなコンポーネントとして使えます。Rails 7.2で導入された明示的なlocalsは、そのコンポーネントでどの変数が必須(省略不可)かを明確に示してくれます。

<%# locals: (user:, css: user.avatar_css) %>
<%= tag.span user.avatar.present? ? image_tag(user.avatar, class: "size-full object-cover") : user.name.first.upcase, role: :img, class: css %>

この構文によって、userパラメータが省略不可であることと、cssパラメータにデフォルト値(渡されたuserオブジェクトから取り出される)が指定されていることが明確に示されます。なお、この「厳密なlocalsについては別記事でも取り上げています。

このコンポーネントの使い方は簡単です。

<%= component "avatar", user: User.first.decorate %>

通常のパーシャルと同様に、このコンポーネントにもコレクションを渡せます。

<%= component "avatar", collection: User.all.map(&:decorate), as: :user %>

decorateについては後述)

普通のパーシャルと同じような感覚でシンプルに使えます。皆さんもぜひ使ってみましょう。

🔗 コンポーネントにコンテンツブロックを渡す

コンポーネントによっては、コンテンツをラップする必要があります。コンポーネントヘルパーはこういうときに便利です。

以下のセクションコンポーネントはrootページでコンポーネントをプレビューするのに使われるメタなコンポーネントで、さまざまなコンテンツセクションに共通のレイアウトを提供します。

<%# locals: (name:) %>
<li>
  <%= tag.h2 name, class: "text-base font-bold text-gray-800" %>

  <div class="mt-1 p-4 border border-gray-200 rounded-md">
    <%= yield %>
  </div>
</li>

別の例として、別の実際のアプリで使われているパンくずリストコンポーネントも紹介します。

<%# locals: (items: []) %>
<nav aria-label="Breadcrumb">
  <ol class="flex items-center gap-x-1 text-sm font-semibold" itemscope itemtype="https://schema.org/BreadcrumbList">
    <% items.each.with_index do |item, index| %>
      <li class="flex items-center" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
        <%= link_to item[:label], item[:href], class: "text-gray-800 hover:underline", itemprop: "item" %>

        <%= tag.meta itemprop: "name", content: item[:label] %>
        <%= tag.meta itemprop: "position", content: index + 1 %>

        <span class="inline-block ml-1 font-medium text-gray-500" aria-hidden="true">/</span>
      </li>
    <% end %>

    <li class="text-gray-600" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem" aria-current="page">
      <span itemprop="name"><%= yield %></span>

      <%= tag.meta itemprop: "position", content: items.length + 1 %>
    </li>
  </ol>
</nav>

このコンポーネントのitems引数には、labelキーとhrefキーを含むハッシュを渡します。blockはコンポーネントに渡したブロック({ "ここ" })で、パンくずリストの最後の項目を表します。

<%= component("breadcrumbs", items: [{label: "Rails Designer", href: "https::/railsdesigner.com"}, {label: "Articles", href: "https://railsdesigner.com/articles/"}]) { "ここ" } %>

🔗 コンポーネントでDecoratorを使う

冒頭のサンプルに登場した"avatar"コンポーネントは、Decoratorを用いて、ビュー固有のメソッドをUserモデル向けに提供しています。

class User::Decorator < SimpleDelegator
  include ActionView::Helpers::TagHelper

  def avatar_css(size = :md)
    class_names(
      "flex items-center justify-center font-semibold text-gray-600 border border-gray-300/50 overflow-clip rounded-full",
      {
        "size-5 text-sm": size == :sm,
        "size-6 text-base": size == :md,
        "size-8 text-ll": size == :lg,
        "size-10 text-xl": size == :xl
      }
    )
  end
end

このDecoratorは、decorateメソッドを用いてUserオブジェクトに適用されます。このパターンによって、ビュー固有のロジックをモデルから切り離すと同時に、ビューから手軽にアクセスできるようになります。Decoratorについて詳しくは、過去記事をご覧ください。

🔗 クラスベースのコンポーネント

コンポーネントが複雑になってきたら、ロジックを専用のRubyクラスに移動すると便利です。以下のバッジコンポーネントでその方法を示します。

class BadgeComponent
  include ActionView::Helpers::TagHelper

  def initialize(name)
    @name = name

    raise "Incorrect badge name" if NAMES.exclude? name
  end

  def name
    NAMES[@name]
  end

  def css
    class_names(
      "inline-block px-3 py-0.5",
      "text-xs font-medium",
      "ring ring-offset-0 border border-white/24 rounded-full",
      COLORS[@name]
    )
  end

  private

  NAMES = {
    webmaster: "Webmaster Supreme",
    pwru: "Power User 9000",
    # ...
  }

  COLORS = {
    webmaster: "bg-blue-100 text-blue-800 ring-blue-200",
    pwru: "bg-purple-100 text-purple-800 ring-purple-200",
    # ...
  }
end

上に対応するビューテンプレートはシンプルです。

<%# locals: (name:, badge: BadgeComponent.new(name)) %>
<%= tag.span badge.name, class: badge.css %>

このコンポーネントの使い方は以下のとおりです。

<%= component("badge", name: :webmaster) %>

上ではnameを渡しているだけで、コンポーネント自身はBadgeComponentのインスタンスを使っていることにご注目ください。

Rubyクラスを使うことで、コンポーネントの複雑なロジックがカプセル化され、テストとメンテナンスが容易になります。ビューテンプレートは、コンポーネントのHTMLレンダリングだけを行っています。

クラスを活用することで、ViewComponentの機能にかなり近くなります!

🔗 サンプル

rails-designer-repos/VanillaComponents - GitHub

VanillaComponentsリポジトリには、以下のようなさまざまなコンポーネント設計手法を示すサンプルコンポーネントがすべて含まれています。

単独のアバター:

<%= component "avatar", user: User.first.decorate %>

アバターのリスト

<%= component "avatar", collection: User.all.map(&:decorate), as: :user %>

パンくずリスト:

<%= component("breadcrumbs", items: [{label: "Rails Designer", href: "https::/railsdesigner.com"}, {label: "Articles", href: "https://railsdesigner.com/articles/"}]) { "Here" } %>

表示/非表示の切り替え:

<%= component("input_toggle", value: "my secret") %>

バッジの表示:

<%= component("badge", name: :webmaster) %>

これらのサンプルは、ComponentHelperを使って、シンプルなアバターから複雑なインタラクティブコンポーネントに至るさまざまなUI要素を作る方法を示しています。

私はこうしたパターンに沿って開発することで、メンテナンス可能なコンポーネントをgemに依存しない形で作成して、さまざまなクライアント向けに提供しています。

参考: Rails UI Consultancy | Rails Designer

関連記事


CONTACT

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