我們如何在飛輪的 Rails 中構建 UI 組件

已發表: 2019-11-16

在大型 Web 應用程序中保持視覺一致性是許多組織共同面臨的問題。 在飛輪,我們也不例外。 我們的主要 Web 應用程序是使用 Ruby on Rails 構建的,我們有大約 15 名 Rails 開發人員和 3 名前端開發人員在任何一天向它提交代碼。 我們也很重視設計(這是我們作為一家公司的核心價值觀之一),並且在我們的 Scrum 團隊中擁有三位與開發人員密切合作的設計師。

我們的一個主要目標是確保任何飛輪開發人員都可以構建一個沒有任何障礙的響應式頁面。 障礙通常包括不知道使用哪些現有組件來構建模型(這會導致代碼庫中包含非常相似的冗餘組件)以及不知道何時與設計人員討論可重用性。 這會導致不一致的客戶體驗、開發人員的挫敗感以及開發人員和設計人員之間的不同設計語言。

我們經歷了幾次迭代風格指南和構建/維護 UI 模式和組件的方法,每次迭代都有助於解決我們當時面臨的問題。 我們現在正在採用一種新的(對我們而言)方法,我相信這將在很長一段時間內為我們奠定基礎。 如果您在 Rails 應用程序中遇到類似問題,並且想從服務器端處理組件,希望本文能給您一些想法。

在本文中,我將深入探討:

  • 我們要解決的問題
  • 約束組件
  • 在服務器端渲染組件
  • 我們不能使用服務器端組件的地方


我們要解決的問題

我們希望完全限制我們的 UI 組件,並消除以多種方式創建相同 UI 的可能性。 雖然客戶可能無法分辨(起初),但對組件沒有約束會導致開發人員體驗混亂,使事情變得非常難以維護,並且難以進行全局設計更改。

我們處理組件的傳統方式是通過我們的樣式指南,其中列出了構建給定組件所需的全部標記。 例如,我們的 slat 組件的樣式指南頁面如下所示:

這工作得很好並且適合我們幾年,但是當我們添加變體、狀態或使用組件的替代方式時,問題開始蔓延。 對於復雜的 UI,參考樣式指南以了解要使用哪些類以及要避免哪些類以及輸出所需變化所需的標記順序變得很麻煩。 通常,設計師會對給定的組件進行少量添加或調整。 由於樣式指南並不完全支持這一點,因此使該調整正確顯示的替代黑客(例如不恰當地蠶食另一個組件的一部分)變得令人惱火。

無約束組件示例

為了說明不一致是如何隨著時間的推移而出現的,我將使用 Flywheel 應用程序中我們的一個組件的一個簡單(並且人為的)但非常常見的示例:卡片標題。

從設計模型開始,這就是卡片標題的樣子。 它非常簡單,有一個標題、一個按鈕和一個底部邊框。

.card__header
  .card__header-left
    %h2 Backups

  .card__header-right
    = link_to "#" do
      = icon("plus_small")

編碼後,想像一個設計師想要在標題左側添加一個圖標。 開箱即用,圖標和標題之間沒有任何邊距。

...
  .card__header-left
    = icon("arrow_backup", color: "gray25")
    %h2 Backups
...

理想情況下,我們會在卡片標題的 CSS 中解決這個問題,但是對於這個例子,假設另一個開發人員認為“哦,我知道! 我們有一些保證金助手。 我會在標題上加一個助手類。”

...
  .card__header-left
    = icon("arrow_backup", color: "gray25")
    %h2.--ml-10 Backups
...

好吧,從技術上講,這看起來就像模型一樣,對吧?! 當然可以,但是假設一個月後,另一個開發人員需要一個卡片標題,但沒有圖標。 他們找到最後一個示例,複製/粘貼它,然後簡單地刪除圖標。

再次看起來正確,對吧? 斷章取義,對於那些對設計沒有敏銳眼光的人來說,當然! 但看看它旁邊的原件。 標題上的左邊距仍然存在,因為他們沒有意識到需要刪除左邊距助手!

讓這個例子更進一步,假設另一個模型需要一個沒有底部邊框的卡片標題。 人們可能會在樣式指南中找到一種稱為“無邊界”的狀態並應用它。 完美的!

然後另一個開發人員可能會嘗試重用該代碼,但在這種情況下,他們實際上需要一個邊框。 假設他們忽略了樣式指南中記錄的正確用法,並且沒有意識到刪除無邊框類會給他們帶來邊框。 相反,他們添加了一條水平線。 最終在標題和邊框之間有一些額外的填充,所以他們對 hr 應用了一個輔助類,瞧!

通過對原始卡片標題的所有這些修改,我們現在在代碼中手頭一團糟。

.card__header.--borderless
  .card__header-left
    %h2.--ml-10 Backups

  .card__header-right
    = link_to "#" do
      = icon("plus_small")

  %hr.--mt-0.--mb-0

請記住,上面的示例只是為了說明不受約束的組件如何隨著時間的推移變得混亂。 如果我們團隊中的任何人試圖發布卡片標題的變體,則應該通過設計審查或代碼審查來發現它。 但是這樣的事情有時會從裂縫中溜走,因此我們需要防彈的東西!


約束組件

您可能認為上面列出的問題已經用組件清楚地解決了。 這是一個正確的假設! 像 React 和 Vue 這樣的前端框架非常受歡迎。 它們是封裝 UI 的絕佳工具。 但是,我們並不總是喜歡它們的一個小問題——它們要求您的 UI 由 JavaScript 呈現。

Flywheel 應用程序的後端非常繁重,主要使用服務器呈現的 HTML——但對我們來說幸運的是,組件可以有多種形式。 歸根結底,UI 組件是樣式和設計規則的封裝,可將標記輸出到瀏覽器。 通過這種實現,我們可以對組件採用相同的方法,但沒有 JavaScript 框架的開銷。

我們將在下面討論如何構建受約束的組件,但以下是我們發現使用它們的一些好處:

  • 將組件放在一起從來沒有真正錯誤的方法。
  • 該組件為您完成所有設計思考。 (您只需傳遞選項!)
  • 創建組件的語法非常一致且易於推理。
  • 如果需要對組件進行設計更改,我們可以在組件中更改一次,並確信它會隨處更新。

在服務器端渲染組件

那麼我們通過約束組件在談論什麼? 讓我們深入挖掘!

如前所述,我們希望在 Flywheel 應用程序中工作的任何開發人員都能夠查看頁面的設計模型並能夠立即構建該頁面而不會遇到任何障礙。 這意味著創建 UI 的方法必須是 A) 很好地記錄和 B) 非常具有聲明性並且沒有猜測。

部分救援(或者我們認為)

我們過去嘗試過的第一個嘗試是使用 Rails 部分。 Partials 是 Rails 為您提供模板可重用性的唯一工具。 自然,它們是每個人最先接觸到的東西。 但是依賴它們有很大的缺點,因為如果您需要將邏輯與可重用模板結合起來,您有兩個選擇:在使用部分的每個控制器之間複製邏輯或將邏輯嵌入到部分本身中。

Partials 確實可以防止複制/粘貼重複錯誤,並且在您需要重用某些內容的前幾次它們可以正常工作。 但是根據我們的經驗,partials 很快就會因為對越來越多的功能和邏輯的支持而變得混亂。 但是邏輯不應該存在於模板中!

細胞簡介

幸運的是,有一個更好的替代部分,它允許我們重用代碼並將邏輯保持在視圖之外。 它被稱為 Cells,是 Trailblazer 開發的 Ruby gem。 Cells 早在 React 和 Vue 等前端框架流行起來之前就已經存在,它們允許您編寫封裝的視圖模型來處理邏輯模板。 它們提供了一個視圖模型抽象,Rails 並沒有真正開箱即用。 實際上,我們已經在 Flywheel 應用程序中使用 Cells 有一段時間了,只是還沒有在全球範圍內實現超級可重用的規模。

在最簡單的層面上,Cells 允許我們像這樣抽像一大塊標記(我們使用 Haml 作為我們的模板語言):

%div
  %h1 Hello, world!

變成一個可重用的視圖模型(此時與局部視圖非常相似),並將其變成這樣:

= cell("hello_world")

這最終幫助我們將組件限制在不修改單元格本身的情況下無法添加輔助類或不正確的子組件的位置。

構建細胞

我們將所有 UI 單元放在 app/cells/ui 目錄中。 每個單元格必須只包含一個 Ruby 文件,後綴為 _cell.rb。 從技術上講,您可以使用 content_tag 幫助器直接在 Ruby 中編寫模板,但我們的大多數 Cell 還包含相應的 Haml 模板,該模板位於組件命名的文件夾中。

一個沒有邏輯的超級基本單元看起來像這樣:

// cells/ui/slat_cell.rb

module UI
  class SlatCell < ViewModel
    def show
    end
  end
end

show 方法是實例化單元格時呈現的內容,它會自動在文件夾中查找與單元格同名的相應 show.haml 文件。 在這種情況下,它是 app/cells/ui/slat (我們將所有 UI 單元都限定為 UI 模塊)。

在模板中,您可以訪問傳遞給單元格的選項。 例如,如果單元格在像 = cell(“ui/slat”, title: “Title”, subtitle: “Subtitle”, label: “Label”) 這樣的視圖中實例化,我們可以通過 options 對象訪問這些選項。

// cells/ui/slat/show.haml
.slat
  .slat__inner
    .slat__content
      %h4= options[:title]
      %p= options[:subtitle]
      = icon(options[:icon], color: "blue")

很多時候,我們會將簡單元素及其值移動到單元格中的方法中,以防止在不存在選項時呈現空元素。

// cells/ui/slat_cell.rb
def title
  return unless options[:title]
  content_tag :h4, options[:title]
end

def subtitle
  return unless options[:subtitle]
  content_tag :p, options[:subtitle]
end
// cells/ui/slat/show.haml
.slat
  .slat__inner
    .slat__content
      = title
      = subtitle

使用 UI 實用程序包裝單元格

在證明這可以大規模工作的概念之後,我想解決調用單元格所需的無關標記。 它只是不太正確,很難記住。 所以我們為它做了一個小幫手! 現在我們可以調用 = ui “name_of_component” 並內聯傳入選項。

= ui "slat", title: "Title", subtitle: "Subtitle", label: "Label"

將選項作為塊而不是內聯傳遞

將 UI 實用程序再深入一點,很快就會發現,一個包含一堆選項的單元格都在一行上會非常難以理解,而且非常難看。 這是一個包含許多內聯定義選項的單元格示例:

= ui “slat", title: “Title”, subtitle: “Subtitle”, label: “Label”, link: “#”, tertiary_title: “Tertiary”, disabled: true, checklist: [“Item 1”, “Item 2”, “Item 3”]

它非常繁瑣,導致我們創建了一個名為 OptionProxy 的類,它攔截 Cells 的 setter 方法並將它們轉換為哈希值,然後將其合併到選項中。 如果這聽起來很複雜,別擔心——這對我來說也很複雜。 這是我們的高級軟件工程師之一 Adam 編寫的 OptionProxy 類的要點。

這是在我們的單元格中使用 OptionProxy 類的示例:

module UI
  class SlatCell < ViewModel
    def show
      OptionProxy.new(self).yield!(options, &block)
      super()
    end
  end
end

現在有了這些,我們可以將繁瑣的內聯選項變成一個更令人愉快的塊!

= ui "slat" do |slat|
  - slat.title = "Title"
  - slat.subtitle = "Subtitle"
  - slat.label = "Label"
  - slat.link = "#"
  - slat.tertiary_title = "Tertiary"
  - slat.disabled = true
  - slat.checklist = ["Item 1", "Item 2", "Item 3"]

引入邏輯

到目前為止,這些示例還沒有包含任何關於視圖顯示內容的邏輯。 這是 Cells 提供的最好的東西之一,所以讓我們來談談吧!

堅持使用我們的 slat 組件,我們有時需要將整個事物呈現為鏈接,有時將其呈現為 div,具體取決於是否存在鏈接選項。 我相信這是我們唯一可以呈現為 div 或鏈接的組件,但它是 Cells 強大功能的一個非常簡潔的示例。

下面的方法調用 link_to 或 content_tag 幫助器,具體取決於選項[:link]的存在。

注意:這是由 Adam Lassek 啟發和創建的,他在幫助我們使用 Cells 構建整個 UI 開發方法方面具有巨大的影響力。

def container(&block)
  tag =
    if options[:link]
      [:link_to, options[:link]]
    else
      [:content_tag, :div]
    end

  send(*tag, class: “slat__inner”, &block)
end

這允許我們用容器塊替換模板中的 .slat__inner 元素:

.slat
  = container do
  ...

Cells 中我們經常使用的另一個邏輯示例是條件輸出類。 假設我們向單元格添加了一個禁用的選項。 除了您現在可以傳遞一個 disabled: true 選項並觀察整個事情變成禁用狀態(以不可點擊的鏈接變灰)之外,在調用單元格時沒有其他任何變化。

= ui "slat" do |slat|
  ...
  - slat.disabled = true

當 disabled 選項為 true 時,我們可以在模板中的元素上設置類,以獲得所需的禁用外觀。

.slat{ class: possible_classes("--disabled": options[:disabled]) }
  .slat__inner
    .slat__content
      %h4{ class: possible_classes("--alt": options[:disabled]) }= options[:title]
      %p{ class: possible_classes("--alt": options[:disabled]) }=
      options[:subtitle]
      = icon(options[:icon], color: "gray")

傳統上,我們必須記住(或參考樣式指南)哪些單個元素需要額外的類才能使整個事物在禁用狀態下正常工作。 單元格允許我們聲明一個選項,然後為我們完成繁重的工作。

注意: possible_classes 是我們創建的一種方法,它允許以一種很好的方式有條件地在 Haml 中應用類。


我們不能使用服務器端組件的地方

雖然單元方法對我們的特定應用程序和我們的工作方式非常有幫助,但我會說它解決了我們 100% 的問題。 我們仍然編寫 JavaScript(很多),並在整個應用程序中構建了相當多的 Vue 體驗。 75% 的時間,我們的 Vue 模板仍然存在於 Haml 中,我們將 Vue 實例綁定到包含元素,這使我們仍然可以利用單元格方法。

但是,在將組件完全約束為單文件 Vue 實例更有意義的地方,我們不能使用 Cells。 例如,我們的選擇列表都是 Vue。 但我覺得沒關係! 我們還沒有真正遇到過需要在 Cells 和 Vue 組件中擁有重複版本的組件,所以有些組件是 100% 使用 Vue 構建的,有些是使用 Cells 構建的。 如果一個組件是用 Vue 構建的,這意味著需要 JavaScript 在 DOM 中構建它,我們利用 Vue 框架來做到這一點。 但是,對於我們的大多數其他組件,它們不需要 JavaScript,如果需要,它們需要已經構建了 DOM,我們只需掛接並添加事件偵聽器。

隨著我們不斷推進 cell 方法,我們肯定會嘗試將 cell 組件和 Vue 組件結合起來,這樣我們就只有一種創建和使用組件的方法。 我還不知道那是什麼樣子,所以當我們到達那裡時,我們會過那座橋!


我們的結論

到目前為止,我們已經將大約 30 個最常用的視覺組件轉換為 Cells。 它給我們帶來了巨大的生產力爆發,並讓開發人員有一種驗證感,即他們正在構建的體驗是正確的,而不是被破解在一起的。

我們的設計團隊比以往任何時候都更有信心,我們應用程序中的組件和體驗與他們在 Adob​​e XD 中設計的內容是 1:1 的。 現在,組件的更改或添加僅通過與設計師和前端開發人員的交互來處理,這使團隊的其他成員保持專注,無需擔心知道如何調整組件以匹配設計模型。

我們一直在迭代約束 UI 組件的方法,但我希望本文中介紹的技術能讓您了解哪些方法對我們有效!


來飛輪工作吧!

在飛輪,每個部門都對我們的客戶和利潤產生有意義的影響。 無論是客戶支持、軟件開發、營銷還是介於兩者之間的任何事情,我們都在共同努力實現我們的使命,即建立一個人們可以真正愛上的託管公司。

準備好加入我們的團隊了嗎? 我們正在招聘! 在這裡申請。