5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ViewComponentReflex触って見たメモ

Posted at

これは何?

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 で指定する。

app/components/example/component.rb
module Example
  class Component < ViewComponent::Base
    def initialize(title:)
      @title = title
    end
  end
end

app/components/example/component.html.slim
h1 = @title

呼び出す時はView側で render Example::Component.new してあげる。
変数がない場合はnewだけでよい。

呼び出し側.slim
= render Example::Component.new(title: 'hogehoge')

これくらいをざっと書くと、コンポーネントが展開されたHTMLが出力される。

出力結果.html
<h1>hogehoge</h1>

コンテンツのエリアを指定したいとき

変数だけでなく、HTMLそのものをコンポーネントに渡したいケースなどは下記のようにする。

呼び出し時にブロック要素を渡す方法

ここは上述の通常時と変わらない。
特別な変数を用いなくとも、ブロック要素をcontentに自動で展開してくれるため。

app/components/example2/component.rb
module Example2
  class Component < ViewComponent::Base
    def initialize(title:)
      @title = title
    end
  end
end

ブロック要素を展開したい場所に content を配置する。

app/components/example2/component.html.slim
h1 = @title
div
  = content

呼び出し時にブロック要素でHTMLを渡してあげる。

呼び出し側.slim
= render Example2::Component.new(title: 'hogehoge') do |component|
  h2
    | 色んなHTMLをここに書けるよ

ブロック要素がcontentに自動で展開される。

出力結果.html
<h1>hogehoge</h1>
<div><h2>色んなHTMLをここに書けるよ</h2></div>

コンテンツそれぞれに名前をつける方法

content だけでなく、展開したい場所に応じて名前をつけることが出来る。
headerhooter などを分けて記載したい場合に便利。

with_content_areas でコンテンツエリアに名前をつける。

app/components/example3/component.rb
module Example3
  class Component < ViewComponent::Base
    with_content_areas :header, :footer

    def initialize(title:)
      @title = title
    end
  end
end

名前をつけたコンテンツエリアを配置。contentはいつでも使える。

app/components/example3/component.html.slim
div
  = header
div
  = content
div
  = footer

呼び出し時にcomponent.with(:コンテンツエリアの名前)のブロックを作ることで、コンテンツエリア内にその要素が展開される。

呼び出し側.slim
= render Example3::Component.new(title: 'hogehoge') do |component|
  = component.with(:header) do
    | ここはヘッダーだよ
  = component.with(:footer) do
    | ここはフッターだよ
  h2
    | ここはコンテンツとして展開されるよ

headerfooterが対応する場所に展開される。
また、component.withで指定しない部分はcontentとして展開されるため、h2の内容が真ん中に表示されている。

出力結果.html
<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の呼び出し方

三種類にわかれる。

移譲

シンプルな形だとこれ。

親コンポーネントを定義。

app/components/example4/component.rb
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

親コンポーネントの中から上記で定義したheaderpostsを呼び出すことが出来る。
そうすることで、子コンポーネントがそれぞれ呼び出されるようになる。

app/components/example4/component.html.slim
div
  / TODO: なぜかhtmlが文字列として展開されてしまうのでhtml_escapeをつけている
  = html_escape header
div
  - posts.each do |post|
    = html_escape post

子コンポーネントの中身はそれぞれこのような形。

app/components/example4/header_component.html.slim
div
  h1 = @title
  div
    = content
app/components/example_post/component.html.slim
h5 = @title
app/components/example_post/component.rb
module ExamplePost
  class Component < ViewComponent::Base
    def initialize(title:)
      @title = title
    end
  end
end

呼び出し側からは親コンポーネントを直接呼び出す。
また、renders_one, renders_many で設定したコンポーネントに呼び出し側から値を設定する。
もちろんここは @posts.each〜〜のような形で呼び出し側でループを回しても良い。

呼び出し側.slim
div
  = render Example4::Component.new do |c|
    = c.header(title: "hogehoge") do
      p ヘッダの中身だよ
    = c.post(title: "1つ目")
    = c.post(title: "2つ目")

親コンポーネントを呼び出すだけで、子コンポーネントも勝手に展開されているのがわかる。

出力結果.html
<div>
  <div>
    <h1>hogehoge</h1>
    <div>
      <p>ヘッダの中身だよ</p>
    </div>
  </div>    
</div>

<div>
  <h5>1つ目</h5>
  <h5>2つ目</h5>
</div>

なお、renders_one はひとつしか読み込めない。
下記のように複数した場合は後勝ち(つまりはhogehoge2が表示)となる。

複数のrenders_oneの例.slim
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つ目"}])

ラムダ式での呼び出し

移譲のケースは、下記のように書くことも出来る。
間に処理をはさみたいときなどはこちらのほうが便利。

app/components/example4/component.rb
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

直接呼び出し

app/components/example5/component.rb
module Example5
  class Component < ViewComponent::Base
    include ViewComponent::SlotableV2

    renders_one :header
    renders_many :posts
  end
end

上記のようにラフに定義だけして呼び出すことも出来る。
コンポーネントの設計が綺麗にできているのであれば、この形がシンプルではある。

呼び出し側.slim
div
  = render Example5::Component.new do |c|
    = c.header(title: "hogehoge")
    = c.posts([{title: "1つ目"}, {title: "2つ目"}])

インラインコンポーネント

テンプレートを書かずにrbのみでコンポーネントを作ることも出来る。

app/components/example6/component.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

呼び出し側.slim
div
  = render Example6::Component.new
  br
  / with_variant(:other)を指定することで、 call_otherを呼び出している
  = render Example6::Component.new.with_variant(:other)

条件によってレンダリングするか分けたいとき

render? を使うことで制御できる。
これによって、表示制御のロジックをViewから排除することが出来る。

app/components/example7/component.rb
module Example7
  class Component < ViewComponent::Base
    def initialize(is_show:)
      @is_show = is_show
    end

    def render?
      @is_show == true
    end
  end
end

呼び出し側.slim
div
  = render Example7::Component.new(is_show: true)
  = render Example7::Component.new(is_show: false)
出力結果.html
<div><p>1件だけ表示される</p></div>

レンダリングの前処理

レンダリングする前に前処理を行うことが出来る。
initializeで行えば良い気もするが、複数回ループさせるときなどはこっちのほうが速そう。

app/components/example8/component.rb
module Example8
  class Component < ViewComponent::Base
    def before_render
      @title = '事前にレンダリングされたタイトル'
    end

    def initialize; end
  end
end

コレクションをパラメーターとして渡したいとき

app/components/example/component.rb
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

呼び出し側.slim
div
  - titles = ["hoge", "fuga"]
  = render Example::Component.with_collection(titles)

コレクションのカウントを取りたいとき

app/components/example9/component.rb
module Example9
  class Component < ViewComponent::Base
    with_collection_parameter :title

    # _counterという名前の場合、コレクションをループした回数のカウントが設定される
    def initialize(title:, title_counter:)
      @title = title
      @counter = title_counter
    end
  end
end
呼び出し側.slim
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 などの便利メソッドを生やすための設定が必要。

spec/rails_helper.rb
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/application.rb
config.view_component.preview_paths << "#{Rails.root}/spec/components/previews"

プレビューを見るための設定は下記。
プレビュー用のクラスとメソッドを作成し、そこから呼び出してあげるだけ。

spec/components/previews/example_preview.rb
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の諸々の設定が終わっていれば、他は特に不要。

使い方

app/components/example10/component.rb
module Example10
  class Component < ViewComponentReflex::Component
    def initialize
      @count = 0
    end

    def increment
      @count += 1
    end
  end
end

refrex_tagを書いて、コンポーネント側で定義したメソッドを指定するだけで終わり。

app/components/example10/component.html.slim
= component_controller do
  p
    = @count
  = reflex_tag :increment, :button, "Click"
呼び出し側.slim
div
  = render Example10::Component.new

これだけしか書いてないけどサーバー側と通信して数字がインクリメントされている。

view_component_reflex.gif

クリックイベントではなく、マウスエンター等に変更する場合は下記の感じで書く。

app/components/example10/component.html.slim
= component_controller do
  p
    = @count
  = reflex_tag "mouseenter->increment", :button, "Click"

マウスカーソルを合わせるだけで数字が増えるようになる。
mouseenter.gif

他の要素を動かしたいとき

component_controllerを利用すると、変数keyが自動で生成される。
こののkeyを利用すると、他のコンポーネントの処理を動かすことが出来るようになる。

parent.slim
= component_controller do
  div#loader
    - if @loading
      p ロード中
  = reflex_tag :do_action, :button, "Click"
  / component_controllerによってkeyが自動生成されている
  = render Child::Component.new(parent_key: key)
parent.rb
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
child.rb
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

コレクションに要素を追加したいとき

要素のコレクション自体を保持する親要素と、要素のコレクションを表示する子要素に分けて実装する。

parent.rb

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
child.rb
module Child
  class Component < ViewComponentReflex::Component
    # これを定義し忘れるとうまく引数を読み込まない¥
    with_collection_parameter :user

    def initialize(user:)
      @user = user
    end

    # こいつを設定しておかないと怒られる
    def collection_key
      @user.id
    end
  end
end
parent.slim
= component_controller do
  / コレクションを表示するコンポーネント(子)を呼び出し
  = render Child::Component.with_collection(@users)
  = reflex_tag :add_user, :button, "太郎を増やす"
child.slim
/ 表示しているだけ
= component_controller do
  = @user.id
  = @user.name
呼び出し元.slim
div
  = render Parent::Component.new(users: [MyUserModel.new(id:1, name:'1郎')])

あとはこれらを応用していくだけ。

雑感

多少癖はあるし、まだまだ足りない部分は多いが、軽めのサービスだったらトライしてみて良さそう。
掲示板等のコメント投稿後のリロード等々だと使い勝手は良さそう。
データの更新等々を誰がやるべきかは難しい。ベストプラクティスはわからない。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?