これは何?
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郎')])
あとはこれらを応用していくだけ。
雑感
多少癖はあるし、まだまだ足りない部分は多いが、軽めのサービスだったらトライしてみて良さそう。
掲示板等のコメント投稿後のリロード等々だと使い勝手は良さそう。
データの更新等々を誰がやるべきかは難しい。ベストプラクティスはわからない。