👨‍💻

ViewComponentで作るRailsのみのコンポーネント指向開発

2024/03/19に公開

TL;DR

ViewComponentは、Rails6.1からサポートされたHTML を出力する Ruby オブジェクトです。

ViewComponentを用いる事で、ReactVue.jsを用いる事なく簡易的にSPAライクなアプリケーションかつコンポーネント指向で開発を進めることができます。

今回はNETFLIXのトップページを模写する形で導入手順についてまとめます。

<nav class="w-full fixed z-40">
  <div class="px-4 md:px-16 py-6 flex flex-row items-center transition duration-500">
    <%= image_tag 'logo.png', class: "h-4 lg:h-7" %>
    <div class="flex-row ml-8 gap-7 hidden lg:flex">
      <%= component "navbar_item", text: "Home" %>
      <%= component "navbar_item", text: "Series" %>
      <%= component "navbar_item", text: "Films" %>
      <%= component "navbar_item", text: "New & Popular" %>
      <%= component "navbar_item", text: "Browse by languages" %>
    </div>
  </div>
</nav>

https://github.com/ViewComponent/view_component
https://viewcomponent.org/

環境構築

  • Ruby 3.2.2
  • Ruby on Rails 7.0.8

rails newの時点でTailwindCSSを導入しておきます。

rails new netflix_clone --css tailwind

ViewComponent用のGemも必要です。

Gemfile
gem 'view_component'

ViewComponentの使い方

基本的な使い方を追っていきます。

view_componentをインストールすることで、generateコマンドが使えるようになります。

rails g component コンポーネント名 オブジェクト生成時に渡す引数名
rails g component Example title 

を実行することでapp/componentsが自動生成され、追加で下記のファイルも作成されます。

app/components/example_component.rb
# frozen_string_literal: true

class ExampleComponent < ViewComponent::Base
  def initialize(title:)
    @title = title
  end
end
app/components/example_component.html.erb
<div>Add Example template here</div>

まずは実際にViewComponentを呼び出してみます。

rails g controller home top
config/routes.rb
Rails.application.routes.draw do
  root 'home#top'
end
app/views/home/top.html.erb
<%= render(ExampleComponent.new(title: "my title")) %>

処理の流れは、

  1. views/home/top.html.erb<%= render(ExampleComponent.new(title: "my title")) %>を実行。
  2. app/components/example_component.rbが呼び出され、ExampleComponentクラスのインスタンスを生成。
  3. コンポーネント名のスネークケース.html.erbが呼び出されブラウザに表示。
    となっています。

<%= render(ExampleComponent.new(title: "my title")) %>実行時にインスタンス変数に"my title"を代入しているので、erbでも利用することができます。

app/components/example_component.html.erb
<%= @title %>

可読性を高める

ViewComponentを最低限の使い方を追ってきましたが、コンポーネントを利用するたびに<%= render(ExampleComponent.new(title: "my title")) %>を書くのはあまり直感的ではありません。
その為、オブジェクト生成部分を関数に切り出すように修正していきます。

手順

  1. app/components配下にapplication_component.rbを作成し、ViewComponent::Baseを継承させる。
  2. component()を定義し、そこからViewComponent用のオブジェクトを生成。
  3. 各コンポーネントのrbファイルをApplicationComponentから継承するように修正
app/components/application_component.rb
class ApplicationComponent < ViewComponent::Base
  def component(name, **args, &block)
    component = (name.to_s.camelize + "Component").constantize
    render(component.new(**args), &block)
  end
end
app/components/example_component.rb
- class ExampleComponent < ViewComponent::Base
+ class ExampleComponent < ApplicationComponent
    def initialize(title:)
      @title = title
    end
  end

こうすることで呼び出しをスマートに書くことができます。

app/views/home/top.html.erb
- <%= render(ExampleComponent.new(title: "my title")) %>
+ <%= component "example", title: "my title" %>

NETFLIXのトップページを作る

ここから一気にトップページを作っていきます。
まずは必要なコンポーネントを生成しておきます。

# ナビゲーションバー用
rails g component navbar
# ナビゲーションバーの各アイテム用
rails g component navbar_item
# ビルボード(バックグラウンド画像)用
rails g component billboard

ナビゲーションバー

ロゴをapp/assets/imagesに配置します。

app/assets/stylesheets/application.tailwind.css
@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  @apply bg-zinc-900 h-full overflow-x-hidden;
}
app/views/home/top.html.erb
<%= component "navbar" %>
app/components/navbar_component.html.erb
<nav class="w-full fixed z-40">
  <div class="px-4 md:px-16 py-6 flex flex-row items-center transition duration-500">
    <%= image_tag 'logo.png', class: "h-4 lg:h-7" %>
  </div>
</nav>

次にナビゲーションバーの各リンクを表示させます。
navbar_component.html.erb内で<%= component "navbar_item", text: "リンク名" %>を生成するようにします。

app/components/navbar_component.html.erb
<nav class="w-full fixed z-40">
  <div class="px-4 md:px-16 py-6 flex flex-row items-center transition duration-500">
    <%= image_tag 'logo.png', class: "h-4 lg:h-7" %>
    <div class="flex-row ml-8 gap-7 hidden lg:flex">
      <%= component "navbar_item", text: "Home" %>
      <%= component "navbar_item", text: "Series" %>
      <%= component "navbar_item", text: "Films" %>
      <%= component "navbar_item", text: "New & Popular" %>
      <%= component "navbar_item", text: "Browse by languages" %>
    </div>
  </div>
</nav>
app/components/navbar_item_component.rb
class NavbarItemComponent < ApplicationComponent
  def initialize(text:)
    @text = text
  end
end
app/components/navbar_item_component.html.erb
<div class="text-white cursor-pointer hover:text-gray-300">
  <%= @text %>
</div>

ビルボード

最後にバックグラウンドで動画を再生させるようにします。

app/views/home/top.html.erb
<%= component "navbar" %>
<%= component "billboard" %>
app/components/billboard_component.rb
require 'json'

class BillboardComponent < ApplicationComponent
  def initialize()
    @billboard_item = fetch_billboard_item  
  end

  private
  
  def fetch_billboard_item
    file = File.read('billboard.json')
    data = JSON.parse(file) 
    data.first
  end
end
billboard.json
[
   {
      "title":"Big Buck Bunny",
      "description":"Three rodents amuse themselves by harassing creatures of the forest. However, when they mess with a bunny, he decides to teach them a lesson.",
      "videoUrl":"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
      "thumbnailUrl":"https://upload.wikimedia.org/wikipedia/commons/7/70/Big.Buck.Bunny.-.Opening.Screen.png",
      "genre":"Comedy",
      "duration":"10 minutes"
   }
]
app/components/billboard_component.html.erb
<div class="text-white relative h-56.25vw">
  <video poster="<%= @billboard_item["thumbnailUrl"] %>"
    class="w-full h-[56.25vw] object-cover brightness-[60%] transition duration-500" autoPlay muted loop
    src="<%= @billboard_item["videoUrl"] %>"></video>
  <div class="absolute top-[30%] md:top-[40%] ml-4 md:ml-16">
    <p class="text-white text-1xl md:text-5xl h-full w-[50%] lg:text-6xl font-bold drop-shadow-xl">
      <%= @billboard_item["title"] %>
    </p>
    <p class="text-white text-[8px] md:text-lg mt-3 mb-5 md:mt-8 w-[90%] md:w-[80%] lg:w-[50%] drop-shadow-xl">
      <%= @billboard_item["description"] %>
    </p>
    <div className="mt-3 md:mt-4">
      <button class="
            bg-white
            text-white
              bg-opacity-30
              rounded-md
              py-1 md:py-2
              px-2 md:px-4
              w-auto
              text-xs lg:text-lg
              font-semibold
              flex
              flex-row
              items-center
              hover:bg-opacity-20
              transition
            ">More Info
      </button>
    </div>
  </div>
</div>

本来であれば、ビルボード内のボタン類もコンポーネントに分けるべきですが、今回はそこまでは考慮しないようにしました。

また、ローカルにJSONファイルを配置して読み込むようにしましたが、この部分も実際にAPIを叩くことで勉強になると思います。

まとめ

ViewComponentと合わせてHotwireStimulusを組み合わせれば、2割の労力で8割の利益を取りに行くことも実現できるので、今後も追っていきたいです。

最後まで読んでいただきありがとうございました。

参考記事

https://zenn.dev/cobachie/articles/tried-view-component
https://zenn.dev/bitarts/articles/a811df6039b9bd
https://techracho.bpsinc.jp/hachi8833/2022_11_25/123684

Discussion