来自GitHub的ViewComponent库正成为在服务器渲染的Rails应用程序中构建设计系统的一个流行答案。让我们通过创建一个花哨的组件按钮来了解其基本原理。
组件里有什么
在这篇文章中,组件是对负责渲染的视图模板的可重用部分的封装。组件在技术上可以是独特的,但核心思想是建立通用的可重用部分,远离一次性的组件。想想按钮、警报或图标。
在核心方面,ViewComponent系统中的组件只是一段Ruby代码。它是一个调用其call 方法来渲染内容的对象。
class LinkButtonComponent < ViewComponent::Base
# def initialize
# end
# def call
# end
end
我们可以用render 帮助器来渲染这样一个组件。
<!-- in a template -->
<%= render(LinkButtonComponent.new) ...
# in a controller
def show
render(LinkButtonComponent.new)
end
如果不提供call 方法,该组件将简单地获取并渲染你提供的块。
<!-- in a template -->
<%= render(LinkButtonComponent.new) do %>
<%= link_to "My button", page_path %>
<% end %>
但你总是可以通过覆盖call 来定义将要渲染的内容。
class LinkButtonComponent < ViewComponent::Base
def initialize
end
def call
link_to "My button", page_path
end
end
你也可以用raw 或html_safe 来代替Rails视图标签,直接提供HTML。
模板
由于组件应该保持灵活性,我将改变上面的组件,接受标签作为参数,同时保留里面的link_to 。
class LinkButtonComponent < ViewComponent::Base
def initialize(label:, url:)
@label = label
@url = url
end
def call
link_to @label, @url
end
end
<!-- later -->
<%= render(LinkButtonComponent.new("Click me!")) %>
然而,组件通常不仅仅是Ruby类。它们通常有一个附带的模板。
下面演示了call 方法如何默认渲染其对应的模板。
# link_button_component.rb
class LinkButtonComponent < ViewComponent::Base
def initialize(label:, url:)
@label = label
@url = url
end
end
<!-- link_button_component.html.erb -->
<%= link_to @label, @url %>
模板也可以使用组件的方法来保持事情的整洁或灵活。
# link_button_component.rb
class LinkButtonComponent < ViewComponent::Base
SIZES = [
:small,
:medium,
:large,
].freeze
def initialize(label:, url:, size: :medium)
@label = label
@url = url
@size = size
end
def choose_size
SIZES[@size] || :medium
end
# If not using the template
# def call
# link_to @label, @url, class: choose_size
# end
end
<!-- link_button_component.html.erb -->
<%= link_to @label, @url, class: choose_size %>
<!-- later -->
<%= render(LinkButtonComponent.new(label: "Click me!", url: @url)) %>
在通过初始化器传递我们的通用数据后,我们可以决定是重写call 方法还是提供一个模板更有意义。在这两种情况下,我们仍然可以调用组件中的方法。
内容
在构建组件时,实现的一个关键部分是要意识到,传递的块被保存在@content 变量中,如果没有模板,@content 是渲染的内容。上面的链接按钮不需要这些,所以让我们把这个组件变成一个button_tag 包装器,并把内部内容作为一个块传递。
# button_component.rb
class ButtonComponent < ViewComponent::Base
def initialize(data:, **attrs)
@data = data
@attrs = attrs
end
end
<!-- button_component.html.erb -->
<%= button_tag data: @data, **@attrs do %>
<%= content %>
<% end %>
<!-- later -->
<%= render(ButtonComponent.new(class: "button", data: {})) do %>
Click me!
<% end %>
在这里,"点击我!"是作为一个块传递的,在组件类中可作为@content ,在模板中可作为content 。由于data 是必须的,所以它被明确地传递,但任何其他的button_tag 参数都是通过利用双拼操作符来进行的。
如果我们有一小块内容,我们也可以在组件上调用with_content 。
<%= render(ButtonComponent.new(class: "button", data: {})).with_content("Click me!") %>
@content 也可以由我们提供或修改。一个例子可以是用 提供一个默认的。before_render
# button_component.rb
class ButtonComponent < ViewComponent::Base
def initialize(data:, **attrs)
@data = data
@attrs = attrs
end
def before_render
@content ||= "Click me!"
end
end
<!-- later -->
<%= render(ButtonComponent.new(class: "button", data: {})) %>
槽
槽是ViewComponent的黄油。它们扩展了一个组件的内容能力,以定义额外的命名内容区域。一个按钮可以有一个图标槽,一组按钮可以有许多按钮槽,或者一个警报可以有一个标题槽。
比方说,我们的按钮应该接受一个图标和一个标签槽。由于它们都是唯一的,我们用renders_one 。
# button_component.rb
class ButtonComponent < ViewComponent::Base
renders_one :icon
renders_one :label
def initialize(data:, **attrs)
@data = data
@attrs = attrs
end
end
一个基本槽可以渲染传递给它的东西(我们可以用? 方法找出它是否被传递)。
<!-- button_component.html.erb -->
<%= button_tag data: @data, **@attrs do %>
<% if icon? %>
<div class="button-icon">
<%= icon %>
</div>
<% end %>
<%= label %>
<% end %>
<!-- later -->
<%= render(ButtonComponent.new(class: "button", data: {})) do |c| %>
<%= c.with_icon do %>
<i class="bi bi-alt"></i>
<% end %>
<%= c.with_label do %>
Click me!
<% end %>
<% end %>
不过很多时候,我们会把槽委托给一个组件。
# button_component.rb
class ButtonComponent < ViewComponent::Base
renders_one :icon, "IconComponent"
class IconComponent < ViewComponent::Base
attr_reader :classes
def initialize(classes: "")
@classes = classes
end
def call
content_tag :i, "", { class: classes }
end
end
def initialize(data:, **attrs)
@data = data
@attrs = attrs
end
end
请注意,这个组件在里面。如果你做一个这样的组件,你需要用一个字符串而不是符号来引用它。模板看起来并没有什么不同(图标组件仍然可以接受一个块)。
<%= render(ButtonComponent.new(class: "button", data: {})) do |c| %>
<%= c.with_icon(classes: "bi bi-alt") %>
<%= c.with_label do %>
Click me!
<% end %>
<% end %>
最后,让我们创建一个按钮组,将任何数量的这些按钮分组。
# button_group_component.rb
class ButtonGroupComponent < ViewComponent::Base
renders_many :buttons, ButtonComponent
end
这里我们用renders_many 来定义槽。不要忘了现在的名字是复数。
在组件模板中,尽管如此,其用法实际上与单个槽的用法相同。
<!-- button_group_component.html.erb -->
<div class="buttons">
<% buttons.each do |button| %>
<%= button %>
<% end %>
</div>
对于普通模板也是如此。
<%= render(ButtonGroupComponent.new) do |c| %>
<%= c.with_button(data: {}) do |b| %>
<%= b.with_label do %>
Click me!
<% end %>
<% end %>
<%= c.with_button(data: {}) do |b| %>
<%= b.with_label do %>
Click me too!
<% end %>
<% end %>
<% end %>
还有其他很好的方法来处理集合,所以请确保阅读链接的文档。
条件式
作为这个介绍的最后一个概念,我想提到条件渲染。现在我们有了一个按钮组,如果有必要,我们可以将它扩展为只为管理员渲染。渲染是由render? 来控制的。
# button_group_component.rb
class ButtonGroupComponent < ViewComponent::Base
renders_many :buttons, ButtonComponent
def initialize(user:)
@user = user
end
def render?
@user.admin?
end
end
如果render? 被评估为false ,那么什么都不会被渲染。视图中不再有条件反射。
<%= render(ButtonGroupComponent.new(user: Current.user)) do |c| %>
...
<% end %>
测试
ViewComponent有一个很好的测试指南。其核心思想是,我们在线渲染组件,然后通过语义元素或类来断言。
require "test_helper"
class ButtonComponentTest < ActiveSupport::TestCase
include ViewComponent::TestHelpers
test "renders a basic component" do
render_inline(ButtonComponent.new(data: {}))
assert_selector("button", text: "Click me!")
end
...
或者,在槽的情况下。
require "test_helper"
class ButtonGroupComponentTest < ActiveSupport::TestCase
include ViewComponent::TestHelpers
test "renders buttons" do
render_inline(ButtonGroupComponent.new(user: User.new) do |component|
component.with_button(data: { action: "click->action" }) { "Ok" }
...
end
assert_selector("button[data-action=\"click->action\"]", text: "Ok")
...
end