これは何?
ViewComponentReflexという「ajaxで値を書き換える処理をRails側だけで実装できてしまうようなもの」を試してみたくなったので、色々試して見たメモ。
モチベーション
- JavaScriptがあまり好きではないので、画面内のデータの更新だけであればサーバー側だけで書きたかった
- JavaScript + APIで色々やりたい人とは相容れない考え方かもしれない
事前知識
ViewComponentReflexはViewComponentの拡張である。
そのため、まずは最低限のViewComponentの知識が必要。
また、ajax的な感じで通信する部分(雑)は stimulus_reflex をベースに書かれているが、ざっくりの処理を行うだけだと意識しないで良い形となっている。
ここからは、まずはざっくりViewComponentについての記述を行った後、ViewComponentReflexをみていく。
ViewComponentってそもそも何?
概略
- Railsで吐き出されるHTMLを、React等々のようにコンポーネント化出来るようにするためのもの
- コンポーネント化すると、Viewのテストも便利だし、データも追いやすい
partialsと何が違うの?
RailsにはHTMLの一部をコンポーネント化するためのpartialsというものがすでにある。
// 呼び出す側
<div>
  <%= render :partial => "(template_name))" %>
</div>
// 呼び出される側
<p>こいつが表示される</p>
// 結果
<div>
  <p>こいつが表示される</p>
</div>
ざっくりいえば下記が違う。
- ViewComponentの方が読み込みが速い
- 10倍くらい早いと書かれているが、状況次第
 
- Viewのテストがしやすい
- Viewのテストコードを書きやすくなる
 
素直に考えると、partialの進化版みたいなイメージ。
(個人的には...)
GoogleAnalyticsのタグを埋め込むなど、静的HTMLであればpartialの方がシンプル。
一覧等で変数を展開していくのであれば、ViewComponentの方が速いし良い、、位の切り分け。
CSSなどを分けてコンポーネント化しておきたい場合は、変数展開なしでもViewComponentの方が良さそう。
ここからはガイドに従ってサンプルを実装した時のメモ。
slimで実装しているので適宜読み替えてほしい。
Installation
Rails6.1だとすんなり入る。
Rails5.0+の場合は モンキーパッチが必要。
先にRails6.1に上げてしまったほうが楽かもしれない。
gem "view_component", require: "view_component/engine"
基本的な動き
ComponentをRubyで定義すると、同じディレクトリにあるHTMLが自動で読み込まれる。
Componentに変数を指定する場合は、initialize で指定する。
module Example
  class Component < ViewComponent::Base
    def initialize(title:)
      @title = title
    end
  end
end
h1 = @title
呼び出す時はView側で render Example::Component.new してあげる。
変数がない場合はnewだけでよい。
= render Example::Component.new(title: 'hogehoge')
これくらいをざっと書くと、コンポーネントが展開されたHTMLが出力される。
<h1>hogehoge</h1>
コンテンツのエリアを指定したいとき
変数だけでなく、HTMLそのものをコンポーネントに渡したいケースなどは下記のようにする。
呼び出し時にブロック要素を渡す方法
ここは上述の通常時と変わらない。
特別な変数を用いなくとも、ブロック要素をcontentに自動で展開してくれるため。
module Example2
  class Component < ViewComponent::Base
    def initialize(title:)
      @title = title
    end
  end
end
ブロック要素を展開したい場所に content を配置する。
h1 = @title
div
  = content
呼び出し時にブロック要素でHTMLを渡してあげる。
= render Example2::Component.new(title: 'hogehoge') do |component|
  h2
    | 色んなHTMLをここに書けるよ
ブロック要素がcontentに自動で展開される。
<h1>hogehoge</h1>
<div><h2>色んなHTMLをここに書けるよ</h2></div>
コンテンツそれぞれに名前をつける方法
content だけでなく、展開したい場所に応じて名前をつけることが出来る。
headerやhooter などを分けて記載したい場合に便利。
with_content_areas でコンテンツエリアに名前をつける。
module Example3
  class Component < ViewComponent::Base
    with_content_areas :header, :footer
    def initialize(title:)
      @title = title
    end
  end
end
名前をつけたコンテンツエリアを配置。contentはいつでも使える。
div
  = header
div
  = content
div
  = footer
呼び出し時にcomponent.with(:コンテンツエリアの名前)のブロックを作ることで、コンテンツエリア内にその要素が展開される。
= render Example3::Component.new(title: 'hogehoge') do |component|
  = component.with(:header) do
    | ここはヘッダーだよ
  = component.with(:footer) do
    | ここはフッターだよ
  h2
    | ここはコンテンツとして展開されるよ
headerやfooterが対応する場所に展開される。
また、component.withで指定しない部分はcontentとして展開されるため、h2の内容が真ん中に表示されている。
<div>ここはヘッダーだよ</div>
<div>
  <h2>ここはコンテンツとして展開されるよ</h2>
</div>
<div>ここはフッターだよ</div>
Slots
ViewComponentにはSlotsという謎な概念がある。
Slotsを使うと、複数のブロックから成るコンテンツを、ひとつのViewComponentから呼び出すことができる。
ざっくりいえば、呼び出し側は一行だが、ネストしたコンポーネントを扱えるようになる。
Slotsの種類
一回しか読み込まないか、複数回読み込むかで種類が変わる。
renders_one
ヘッダなど、一回しか読み込まれないコンポーネントをslotとして設定するときに使う。
例:renders_one :header
renders_many
記事へのコメントなど、ひとつの親コンポーネントから複数回呼び出されるコンポーネントに設定する。
例:renders_many :comments
Slotsの呼び出し方
三種類にわかれる。
移譲
シンプルな形だとこれ。
親コンポーネントを定義。
module Example4
  class Component < ViewComponent::Base
    include ViewComponent::SlotableV2
    
    # 同じクラス内の場合は文字列でコンポーネントを指定
    renders_one :header, 'HeaderComponent'
    # 別ファイルから呼び出す場合はクラスを直接指定
    # postのコンポーネントを複数回呼び出すため、renders_manyを指定している
    renders_many :posts, ExamplePost::Component
    class HeaderComponent < ViewComponent::Base
      attr_reader :title
      def initialize(title:)
        @title = title
      end
    end
  end
end
親コンポーネントの中から上記で定義したheaderやpostsを呼び出すことが出来る。
そうすることで、子コンポーネントがそれぞれ呼び出されるようになる。
div
  / TODO: なぜかhtmlが文字列として展開されてしまうのでhtml_escapeをつけている
  = html_escape header
div
  - posts.each do |post|
    = html_escape post
子コンポーネントの中身はそれぞれこのような形。
div
  h1 = @title
  div
    = content
h5 = @title
module ExamplePost
  class Component < ViewComponent::Base
    def initialize(title:)
      @title = title
    end
  end
end
呼び出し側からは親コンポーネントを直接呼び出す。
また、renders_one, renders_many で設定したコンポーネントに呼び出し側から値を設定する。
もちろんここは @posts.each〜〜のような形で呼び出し側でループを回しても良い。
div
  = render Example4::Component.new do |c|
    = c.header(title: "hogehoge") do
      p ヘッダの中身だよ
    = c.post(title: "1つ目")
    = c.post(title: "2つ目")
親コンポーネントを呼び出すだけで、子コンポーネントも勝手に展開されているのがわかる。
<div>
  <div>
    <h1>hogehoge</h1>
    <div>
      <p>ヘッダの中身だよ</p>
    </div>
  </div>    
</div>
<div>
  <h5>1つ目</h5>
  <h5>2つ目</h5>
</div>
なお、renders_one はひとつしか読み込めない。
下記のように複数した場合は後勝ち(つまりはhogehoge2が表示)となる。
div
  = render Example4::Component.new do |c|
    = c.header(title: "hogehoge1") do
    = c.header(title: "hogehoge2")
また、renders_manyで複数回呼び出す場合は、postsのような形で配列を渡すことも出来る。
div
  = render Example4::Component.new do |c|
    = c.posts([{title: "1つ目"}, {title: "2つ目"}])
ラムダ式での呼び出し
移譲のケースは、下記のように書くことも出来る。
間に処理をはさみたいときなどはこちらのほうが便利。
module Example4
  class Component < ViewComponent::Base
    include ViewComponent::SlotableV2
    renders_one :header, -> (title:) do
      HeaderComponent.new(title: title)
    end
    renders_many :posts, -> (title:) do
      # titleをちょっと変更したいときや、変数を渡して表示内容を制御したいときなどは便利
      ExamplePost::Component.new(title: title + '_hogehoge')
    end
    class HeaderComponent < ViewComponent::Base
      attr_reader :title
      def initialize(title:)
        @title = title
      end
    end
  end
end
直接呼び出し
module Example5
  class Component < ViewComponent::Base
    include ViewComponent::SlotableV2
    renders_one :header
    renders_many :posts
  end
end
上記のようにラフに定義だけして呼び出すことも出来る。
コンポーネントの設計が綺麗にできているのであれば、この形がシンプルではある。
div
  = render Example5::Component.new do |c|
    = c.header(title: "hogehoge")
    = c.posts([{title: "1つ目"}, {title: "2つ目"}])
インラインコンポーネント
テンプレートを書かずにrbのみでコンポーネントを作ることも出来る。
module Example6
  class Component < ViewComponent::Base
    # デフォルトでcallという名前が設定されている 
    def call
      link_to 'link to root', root_path
    end
    # call_xxxという名前にした場合、publicなメソッドとして呼び出すことが出来る
    def call_other
      link_to 'link to other', root_path
    end
  end
end
div
  = render Example6::Component.new
  br
  / with_variant(:other)を指定することで、 call_otherを呼び出している
  = render Example6::Component.new.with_variant(:other)
条件によってレンダリングするか分けたいとき
render? を使うことで制御できる。
これによって、表示制御のロジックをViewから排除することが出来る。
module Example7
  class Component < ViewComponent::Base
    def initialize(is_show:)
      @is_show = is_show
    end
    def render?
      @is_show == true
    end
  end
end
div
  = render Example7::Component.new(is_show: true)
  = render Example7::Component.new(is_show: false)
<div><p>1件だけ表示される</p></div>
レンダリングの前処理
レンダリングする前に前処理を行うことが出来る。
initializeで行えば良い気もするが、複数回ループさせるときなどはこっちのほうが速そう。
module Example8
  class Component < ViewComponent::Base
    def before_render
      @title = '事前にレンダリングされたタイトル'
    end
    def initialize; end
  end
end
コレクションをパラメーターとして渡したいとき
module Example
  class Component < ViewComponent::Base
    # コレクションの場合に渡されるパラメーター名はデフォルトでは hoge_component.rb のhogeの部分
    # これを変更する場合はwith_collection_parameterとして名前を設定する必要がある
    with_collection_parameter :title
    def initialize(title:)
      @title = title
    end
  end
end
div
  - titles = ["hoge", "fuga"]
  = render Example::Component.with_collection(titles)
コレクションのカウントを取りたいとき
module Example9
  class Component < ViewComponent::Base
    with_collection_parameter :title
    # _counterという名前の場合、コレクションをループした回数のカウントが設定される
    def initialize(title:, title_counter:)
      @title = title
      @counter = title_counter
    end
  end
end
div
  - titles = ["hoge", "fuga"]
  = render Example9::Component.with_collection(titles)
ディレクトリ構造
app/componentsディレクトリの配下に色々置く。
コンポーネントとしてわかりやすくする観点だと、app/components/example 用に、コンポーネント単位でディレクトリを作り、その中に必要なファイルを置く形が良いと考えている。
もちろん、app/components直下に色々置く形でも問題ない。
app/components
├── ...
├── example
|   ├── component.rb
|   ├── component.css
|   ├── component.html.slim
|   └── component.js
├── ...
// 呼び出す時はフォルダ名をネームスペースとして考慮してくれるので、下記の形となる
<%= render(Example::Component.new(title: "my title")) do %>
  Hello, World!
<% end %>
ViewComponentのテスト
以下はrspecの場合。
設定
render_inline などの便利メソッドを生やすための設定が必要。
require "view_component/test_helpers"
RSpec.configure do |config|
  config.include ViewComponent::TestHelpers, type: :component
end
テストの例
Viewのテストが書きやすくなって便利かなと思う。
require 'rails_helper'
RSpec.describe Example::Component, type: :component do
  it 'renders something useful' do
    render_inline(described_class.new(title: 'hoge'))
    assert_includes rendered_component, 'hoge'
   # capybaraが入っている時はcapybaraのマッチャも使える
    assert_text('hoge')
  end
end
コンポーネントのプレビュー
ActionMailer::Previewのように、コンポーネントのプレビューを見ることが出来る。
個別でCSS書く場合なんかは便利に使えそう。
初期設定として下記が必要。
config.view_component.preview_paths << "#{Rails.root}/spec/components/previews"
プレビューを見るための設定は下記。
プレビュー用のクラスとメソッドを作成し、そこから呼び出してあげるだけ。
class TestComponentPreview < ViewComponent::Preview
  # コンポーネントということを考えると、layoutは設定しない方が良さそう
  # 好みによって設定かな
  layout false
  def default_title
    render(Example::Component.new(title: 'Test component default'))
  end
end
上記のファイルの場合はこちらからアクセスできる。
http://127.0.0.1:3000/rails/view_components
ここからやっとViewComponentReflexの話に入る。
ViewComponentReflexってそもそも何?
自分もよくわかっていないからこうやって試している。
雑にいえば、ajaxで値を変えたりする何かを、全てサーバー側で記載してしまおうというもの。
準備
ActionCableでRedisが必要になるため、何かしらの形でRedisを準備する。
ViewComponentの諸々の設定が終わっていれば、他は特に不要。
使い方
module Example10
  class Component < ViewComponentReflex::Component
    def initialize
      @count = 0
    end
    def increment
      @count += 1
    end
  end
end
refrex_tagを書いて、コンポーネント側で定義したメソッドを指定するだけで終わり。
= component_controller do
  p
    = @count
  = reflex_tag :increment, :button, "Click"
div
  = render Example10::Component.new
これだけしか書いてないけどサーバー側と通信して数字がインクリメントされている。
クリックイベントではなく、マウスエンター等に変更する場合は下記の感じで書く。
= component_controller do
  p
    = @count
  = reflex_tag "mouseenter->increment", :button, "Click"
他の要素を動かしたいとき
component_controllerを利用すると、変数keyが自動で生成される。
こののkeyを利用すると、他のコンポーネントの処理を動かすことが出来るようになる。
= component_controller do
  div#loader
    - if @loading
      p ロード中
  = reflex_tag :do_action, :button, "Click"
  / component_controllerによってkeyが自動生成されている
  = render Child::Component.new(parent_key: key)
module Parent
  class Component < ViewComponentReflex::Component
    def initialize
      @loading = false
    end
    # 子から呼ばれる処理
    def update_loading
      @loading = !@loading
      
      # コンポーネント内の特定のセレクタを更新する処理
      # prevent_refresh!という、更新を受け付けない処理もある
      # refresh_all!すると、body要素全てを更新する
      refresh! '#loader'
    end
  end
end
module Child
  class Component < ViewComponentReflex::Component
    def initialize(parent_key:)
      @parent_key = parent_key
    end
    def stimulate_parent
      # 親の処理をstimulateで叩く
      # parent_keyでコンポーネントの紐付けを行っている
      stimulate("Parent::Component#update_loading", { key: @parent_key })
    end
  end
end
コレクションに要素を追加したいとき
要素のコレクション自体を保持する親要素と、要素のコレクションを表示する子要素に分けて実装する。
class MyUserModel
  attr_accessor :id, :name
  def initialize(id:, name:)
    @id = id
    @name = name
  end
end
module Parent
  class Component < ViewComponentReflex::Component
    def initialize(users:)
      @users = users
    end
    def add_user
      # 普通は外部からデータを渡すと思うが、サンプルなので。
      # ViewComponentの中でどこまでやるかは悩ましい。データ保存からの再表示等々もやることもあるとは。
      @users.append(MyUserModel.new(id: @users.length + 1, name: "#{(@users.length + 1).to_s}郎"))
    end
  end
end
module Child
  class Component < ViewComponentReflex::Component
    # これを定義し忘れるとうまく引数を読み込まない¥
    with_collection_parameter :user
    def initialize(user:)
      @user = user
    end
    # こいつを設定しておかないと怒られる
    def collection_key
      @user.id
    end
  end
end
= component_controller do
  / コレクションを表示するコンポーネント(子)を呼び出し
  = render Child::Component.with_collection(@users)
  = reflex_tag :add_user, :button, "太郎を増やす"
/ 表示しているだけ
= component_controller do
  = @user.id
  = @user.name
div
  = render Parent::Component.new(users: [MyUserModel.new(id:1, name:'1郎')])
あとはこれらを応用していくだけ。
雑感
多少癖はあるし、まだまだ足りない部分は多いが、軽めのサービスだったらトライしてみて良さそう。
掲示板等のコメント投稿後のリロード等々だと使い勝手は良さそう。
データの更新等々を誰がやるべきかは難しい。ベストプラクティスはわからない。

