xfyuan
xfyuan A Chinese software engineer living and working in Chengdu. I love creating the future in digital worlds, big and small.

狂野的ViewComponent(三): TailwindCSS类和HTML属性

 狂野的ViewComponent(三): TailwindCSS类和HTML属性

本文已获得原作者(Vladimir DementyevTravis Turner)和 Evil Martians 授权许可进行翻译。原文讲述了在单体式模块架构下,使用 ViewComponent 来构建组件化的现代 Rails 前端的故事。(本文是下篇)

【正文如下】

引言

Ruby on Rails 全栈开发正重回正轨:其方向就是优先考虑 HTML!为了向前迈进,我们需要更好的工具来管理视图层。GitHub 的 ViewComponent 库仍然是 Rails 开发人员为 HTML 带入理智的首选。

我们在 Evil Martians 使用这个库已经有一段时间了,我们准备展示自本系列开始以来所积累的新技巧和诀窍。在本文中,我们将讨论如何集成 TailwindCSS 样式以及在 view component 中对 HTML 属性的传播。出发吧!

本系列其他部分

  1. ViewComponent in the Wild I: building modern Rails frontends
  2. ViewComponent in the Wild II: supercharging your components

几年前,我们开始采用 ViewComponent 来帮助我们组织 HTML 模板和 partials。起初,我们主要将其用作一种软件设计模式,以帮助控制代码。然而,随着生态系统和我们经验的发展,ViewComponent 成为创建应用的设计系统的关键元素。

设计系统是用于制作 UI 的可重用元素和指南的集合。在代码中,设计系统通过 UI 工具包来呈现,UI 工具包是视图层的一部分,负责实现设计系统的 UI 元素。“可重复使用的元素”自然会产生组件。但是,如果没有 storybook,维护 UI 套件是不可能的。ViewComponent 与 Lookbook(我们在上一篇文章中讨论过)一起为你提供了所需的一切来开始由设计系统驱动的 UI 开发。

Rails 专注于生产力,而编写代码最有效率的方式就是根本不写代码。针对 Ruby 和 Rails 应用的现成 UI 套件仍然是稀有动物,其中大多数仍处于 alpha 阶段。因此,你可能必须为应用程序制作自定义的 UI 库。但不用担心!我们已经经历过几次,并准备分享有价值的技巧,以简化使用 ViewComponent 的 UI 工具包的开发。今天的“菜单”如下:

  1. Style variants(样式变体)
  2. HTML attributes propagation(HTML 属性传播)
  3. Browser tests for components(组件的浏览器测试)

Style variants

TailwindCSS 已经征服了 UI 开发的世界。当你能够使用 HTML 类来定义所有样式时,为什么还要为 CSS 规则、嵌套和命名(BEM、SMACSS 等)而烦恼呢?让 HTML 成为单一来源,与现代全栈 Rails 的“无构建”理念特别吻合。

使用 TailwindCSS,你可以开发一致的 UI,而无需接触 CSS 文件。你可以在 tailwind.config.js 文件中定义设计系统的基本要素(排版、网格等)和设计 token,并享受原子(和动态)CSS 类的魔力。然而,这是有代价的:HTML 中会有几十个类。让我们来看一个示例 Button 组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class UIKit::Button::Component < ApplicationComponent
  option :type, default: proc { "button" }

  erb_template <<~ERB
    <button type="<%= type %>"
      class="items-center justify-center rounded-md border border-slate-300
      bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm ring-blue-700
      hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2
      focus:ring-offset-blue-50 dark:border-slate-950 dark:bg-blue-700
      dark:text-blue-50 dark:ring-blue-950 dark:hover:bg-blue-800 dark:focus:ring-offset-blue-700">
      <%= content %>
    </button>
  ERB
end

上面的示例中,我们使用了 ViewComponent 的 inline 模板功能(在 v3 中加入)和通过 view_component-contrib 库生成的 ApplicationComponent 类(可在本系列的第二部分中了解更多信息)。我们可以按如下方式呈现它:

1
2
3
<%= render UIKit::Button::Component.new do %>
  Click Me
<% end %>

这会生成如下 UI:

img

但这仅仅是个开始,因为按钮组件从来都不是只有一种格式。每个 UI 工具包都包含多个按钮变体。让我们考虑添加按钮的 outline 版本。

这需要有条件地包含一些类,具体取决于所选的变体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class UIKit::Button::Component < ApplicationComponent
  option :type, default: proc { "button" }
  option :variant, default: proc { :default }

  STYLES = {
    default: "text-white bg-blue-600 ring-blue-700 \
      hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 \
      focus:ring-offset-blue-50 dark:border-slate-950 dark:bg-blue-700 \
      dark:text-blue-50 dark:ring-blue-950 dark:hover:bg-blue-800 dark:focus:ring-offset-blue-700",
    outline: "bg-slate-50 hover:bg-slate-100 focus:outline-none \
      focus:ring-2 focus:ring-slate-600 focus:ring-offset-2 \
      focus:ring-offset-blue-50 dark:border-slate-950 dark:bg-slate-700 \
      dark:ring-slate-950 dark:hover:bg-slate-800 dark:focus:ring-offset-slate-700"
  }.freeze

  erb_template <<~ERB
    <button type="<%= type %>"
      class="items-center justify-center rounded-md border border-slate-300
      px-4 py-2 text-sm font-medium shadow-sm <%= STYLES.fetch(variant) %>">
      <%= content %>
    </button>
  ERB
end

现在,我们可以在渲染按钮时指定一个可选变量:

1
1
2
3
4
5
6
7
8
<div class="flex flex-row space-x-4">
  <%= render UIKit::Button::Component.new do %>
    Click Me
  <% end %>
  <%= render UIKit::Button::Component.new(variant: :outline) do %>
    Click Me
  <% end %>
</div>

img

将类列表中的动态部分提取到一个常量中就可以了。但这只是针对单个变体维度和几个变体。在实践中,通常有更多的变体和变化。

例如,我们可能有不同的尺寸变体(小号、全号等)。有时,这些变化不是独立的,需要为某些组合添加额外的类。为了说明这一点,处理按钮组件的 disabled 状态可能就需要我们编写以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class UIKit::Button::Component < ApplicationComponent
  option :type, default: proc { "button" }
  option :variant, default: proc { :default }
  option :disabled, default: proc { false }

  STYLES = {
    # ...
  }.freeze

  erb_template <<~ERB
    <button type="<%= type %>"
      class="items-center justify-center rounded-md border border-slate-300
      px-4 py-2 text-sm font-medium shadow-sm <%= STYLES.fetch(variant) %>
      <%= disabled_classes if disabled %>"
      <%= "disabled" if disabled %>>
      <%= content %>
    </button>
  ERB

  def disabled_classes
    if variant == :outline
      "opacity-75 bg-slate-300 pointer-events-none"
    else
      "opacity-50 pointer-events-none"
    end
  end
end

组件的代码不仅变得越来越纠缠不清,而且它的工作方式也不像我们预期的那样了。问题在于,对于处于禁用状态的按钮的 outline 版本,我们现在有两个冲突的类: bg-slate-50 (来自 STYLES[:outline] )和 bg-slate-300 (来自 #disabled_classes )。而前者胜出生效。

我们如何恢复组件样式的清晰度、可维护性和正确性?这正是介绍样式变体的时候。

Style Variants 是 view_component-contrib 包中包含的一个插件,其灵感来自 Tailwind VariantsCVA 项目。它允许你以声明方式定义样式规则,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class UIKit::Button::Component < ApplicationComponent
  option :type, default: proc { "button" }
  option :variant, default: proc { :default }
  option :disabled, default: proc { false }

  style do
    base {
      %w[
        items-center justify-center px-4 py-2
        text-sm font-medium
        border border-slate-300 shadow-sm rounded-md
        focus:outline-none focus:ring-offset-2
      ]
    }
    variants {
      variant {
        primary {
          %w[
            text-white bg-blue-600 ring-blue-700
            hover:bg-blue-700
            focus:ring-offset-blue-50
            dark:border-slate-950 dark:bg-blue-700 dark:text-blue-50 dark:ring-blue-950
            dark:hover:bg-blue-800
            dark:focus:ring-offset-blue-700
          ]
        }
        outline {
          %w[
            bg-slate-50
            hover:bg-slate-100
            focus:ring-slate-600 focus:ring-offset-blue-50
            dark:border-slate-950 dark:bg-slate-700 dark:ring-slate-950
            dark:hover:bg-slate-800
            dark:focus:ring-offset-slate-700
          ]
        }
      }
      disabled {
        yes { %w[opacity-50 pointer-events-none] }
      }
    }
    defaults { {variant: :primary, disabled: false} }
    # The "compound" directive allows us to declare additional classes to add
    # when the provided combination is used
    compound(variant: :outline, disabled: true) { %w[opacity-75 bg-slate-300] }
  end

  erb_template <<~ERB
    <button type="<%= type %>" class="<%= style(variant:, disabled:) %>"<%= " disabled" if disabled %>>
      <%= content %>
    </button>
  ERB
end

所有样式逻辑都在 style do ... end 块中描述。在 HTML 模板中,我们只使用 #style(**) 帮助方法并将变体值传递给它。因此,我们不再有分散在组件代码中的类。

我们可能会强制执行自定义约定以用于样式变体定义。例如,你可能会注意到,我们已按修饰符对类进行分组(如 focus:*dark:* 类放在单独的行上)。可以编写一个自定义的 RuboCop 规则来强制执行此约定(甚至可以自动排列类)。因此,从文本片段切换到 Ruby 代码也开辟了新的 DX 可能性。

compound 指令取代了我们以前 #disabled_classes 的方法。它是如何解决类冲突问题的?默认情况下,Style Variants 插件对类的性质没有任何假设(即,它不是特定于 TailwindCSS 的)。为了让它更聪明一点,并教它如何解决 CSS 冲突,我们可以将它与 tailwind_merge gem 集成:

1
2
3
4
5
6
7
class ApplicationComponent < ViewComponentContrib::Base
  include ViewComponentContrib::StyleVariants

  style_config.postprocess_with do |classes|
    TailwindMerge::Merger.new.merge(classes.join(" "))
  end
end

样式变体显著改进了使用 TailwindCSS 支持的 UI 组件的 DX。HTML 不再被冗长的样式定义所污染。CSS 类组织良好且可静态分析。通过这种方法,我们设法简化了 HTML 属性的定义 class 。其他方面呢?好,我们来谈谈 HTML 属性传播。

HTML attribute propagation

对于基本的(原子)UI 元素,如按钮和表单 input,必须支持所有可能的函数式 HTML 属性(例如, requireddisabledautocomplete 等)。假设一个通用 input 组件定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class UIKit::Input::Component < ApplicationComponent
  option :name

  option :id, default: proc { nil }
  option :type, default: proc { "text" }
  option :value, default: proc { nil }
  option :autocomplete, default: proc { "off" }
  option :placeholder, default: proc { nil }
  option :required, default: proc { false }
  option :disabled, default: proc { false }

  erb_template <<~ERB
    <span class="relative">
      <input type="<%= type %>"
        <% if id %> id="<%= id %>"<% end %>
        <% if value %> value="<%= value %>"<% end %>
        <% if name %> name="<%= name %>"<% end %>
        autocomplete="<%= autocomplete %>"
        <% if placeholder %> placeholder="<%= placeholder %>"<% end %>
        <% if required %> required<% end %>
        <% if disabled %> disabled<% end %>
      >
    </span>
  ERB
end

在这里,我们有一个用于注入这些属性的巨可怕的模板(仅当它们被定义时)。当我们有 content_tag (甚至 text_field_tag )时,为什么要处理原始字符串?原因各不相同(从“避免将帮助方法与纯 HTML 混合使用” 到 “为了性能”)。无论如何,这里重要的是可以找到这样的代码,并非凭空捏造。

我们也可以声明我们想要公开的所有 HTML 属性(通过 dry-initializer 的 .option DSL)。但事实证明,这远非最佳。首先,它们可能有很多,而且大多数必须按原样注入 HTML 中。其次,经常有来自外部的属性,我们不希望我们的隔离性组件对它们负责(例如,Stimulus 的 data 属性,或 test_id 用于浏览器测试)。

作为明确的第一步,我们提出了属性 Bag 的想法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class UIKit::Input::Component < ApplicationComponent
  option :name

  option :html_attrs, default: proc { {} }
  option :input_attrs, default: proc { {} }, type: -> { {autocomplete: "off", required: false}.merge(_1) }

  erb_template <<~ERB
    <span class="relative" <%= tag.attributes(**html_attrs) %>>
      <input <%= tag.attributes(**input_attrs) %>>
    </span>
  ERB

  def before_render
    input_attrs.merge({name:})
  end
end

我们没有枚举所有可能的选项,而只添加了两个选项: html_attrs (对于容器元素)和 input_attrs 。为了将哈希转换为 HTML 属性字符串,我们使用 Rails 的内置 tag.attributes 帮助方法(从 Rails 7 开始可用)。下面是用于组件的新接口:

1
2
3
<%= render UIKit::Input::Component.new(
  name: "name",
  input_attrs: {placeholder: "Enter your name", autocomplete: "on", autofocus: true}) %>

请注意,我们仍然接受输入字段的名称作为单独的选项,以强调其基本作用(以及它是必需的)。

上面的声明 DSL 看起来仍然不够理想(尤其是 type: ... 部分)。因此,我们可以更进一步,像这样对 API 语法糖化:

1
2
3
4
5
6
7
8
9
10
11
12
class UIKit::Input::Component < ApplicationComponent
  option :name

  html_option :html_attrs
  html_option :input_attrs, default: {autocomplete: "off", required: false}

  erb_template <<~ERB
    <span class="relative" <%= dots(html_attrs) %>>
      <input <%= dots(input_attrs) %>>
    </span>
  ERB
end

请注意, #dots 别名是对 JS 对象扩展运算符 ( ... ) 的引用。

Browser tests for components

为了完成我们的年度“火星上的 ViewComponent”报告,我想分享我们最近发布的另一个小扩展,旨在改善开发人员使用视图组件时的体验。

在本系列的第一部分中,我们提到可测试性是切换到组件的主要优势之一。但是,除了单元测试之外,你还可以使用 Rails 的系统测试来测试交互式组件:

1
2
3
4
5
6
7
8
# spec/system/components/my_component_spec.rb

it "does some dynamic stuff" do
  visit("/rails/view_components/my_component/default")
  click_on("JavaScript-infused button")

  expect(page).to have_content("dynamic stuff")
end

此类测试依赖于预览功能,该功能会扰乱 storybook,并在测试环境和开发环境之间引入耦合。为了解决这个问题,我们为测试构建了 inline 模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it "does some dynamic stuff" do
  visit_template <<~ERB
    <form id="myForm" onsubmit="event.preventDefault(); this.innerHTML = '';">
      <h2>Self-destructing form</h2>
      <%= render Button::Component.new(type: :submit, kind: :info) do %>
        Destroy me!
      <% end %>
    </form>
  ERB

  expect(page).to have_text "Self-destructing form"

  click_on("Destroy me!")

  expect(page).to have_no_text "Self-destructing form"
end

现在,被测试的 HTML 可以直接在测试本身中定义了,而此功能可通过 rails-intest-views gem 获得。试一试吧!

A last note

自从本系列的第一篇文章在撰写本文大约一年半前发表以来,我们构建 Rails 全栈应用的方式已经发生了变化。

TailwindCSS 赢得了 CSS 的战争(我们曾经尝试过 PostCSS Modules),Webpacker 退役了,UI 组件库终于来到了 Rails 的世界。

尽管如此,ViewComponent 仍然是我们的核心 UI 技术,可以轻松地让我们跟上不断变化的软件开发趋势。这正是我们继续投资于它的原因!

Rating:

comments powered by Disqus