通过建立一个按钮来理解ViewComponent的概念

82 阅读3分钟

来自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

你也可以用rawhtml_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