聊聊前端 UI 元件:元件體系

發表於2023-09-19

本文是文章系列「聊聊前端 UI 元件」的第三篇。

在本系列的上篇文章《聊聊前端 UI 元件:元件特徵》中,透過從關注點分離的角度進行前端 UI 元件的構成分析,並以較為抽象的視角對 UI 元件分門別類,以及描述了讓元件間可以表現複用的繼承關係,從而建立出前端 UI 元件的特徵模型。

本文將以上篇文章中所得出的特徵模型為基礎,探討下如何設計並建立一個前端 UI 元件體系。

在做元件體系設計的時候,最重要的一點就是——要真真正正地想著把 UI 元件弄成可複用的,就像製造業生產時所用的物料一樣——構造可交換的 UI 元件。

由於 UI 元件構成元素的易變性對元件體系的設計有著很大的影響,為了方便檢視,將上篇文章中的易變性及其影響因素的表格搬過來:

構成 易變性影響因素
結構視覺結構不易變內容結構、佈局類樣式
內容結構較易變生成 HTML 的 JS 庫/框架的原始碼、平臺限定的檢視結構描述語言
表現主題風格很易變GUI 設計人員的審美和想法、非佈局類樣式、圖示與圖片
行為互動邏輯不易變互動設計人員的想法
業務邏輯很易變業務規則

元件架構

表格中列出的 UI 元件構成元素都可以作為單獨的元件存在。如果把 UI 元件看作是「最終產品」的話,那麼 UI 元件構成元素所對應的那些元件就是「中間產品」。

在軟體工程中,「元件(component)」一般是指軟體的可複用塊,好比製造業所使用的「構件」。這是一個比較寬泛的概念,它可以是軟體包,可以是 web 服務,也可以是模組等。

但在前端眼裡,「元件」通常是指頁面上的檢視單元,即「UI 元件」。可以說,「UI 元件」是「元件」的子集。

歐雷《聊聊前端 UI 元件:核心概念

鑑於上述原因,這裡需要特別說明下:上文所說的「作為單獨的元件存在」中的「元件」是指「軟體的可複用塊」,而不是「UI 元件」。

風格元件

在上篇文章中提到了「虛擬元件」的概念——

在繼續往下之前,先引入一個「虛擬元件」的概念。正如它的名字所示,是一個虛擬的,實際不存在的,只是概念上的元件。它是幾個主題風格屬性的集合。

歐雷《聊聊前端 UI 元件:元件特徵

與之相似,「風格元件」也是一些主題風格屬性的集合,大概包括:

風格元件構成

如果要用程式碼來體現的話,可以藉助 CSS 前處理器中的變數。這裡用 Sass 來舉例:

// 主題色
$sc--primary: #cce5ff !default;
$sc--secondary: #e2e3e5 !default;
$sc--info: #d1ecf1 !default;
$sc--success: #d4edda !default;
$sc--warning: #fff3cd !default;
$sc--danger: #f8d7da !default;

// 文字色
$sc--text-primary: #303133 !default;
$sc--text-secondary: #696c71 !default;
$sc--text-heading: #2c405a !default;
$sc--text-regular: #333 !default;
$sc--text-placeholder: #c0c4cc !default;

// 字型尺寸
$sc--font-size: 14px !default;
$sc--font-size-lg: 16px !default;
$sc--font-size-sm: 12px !default;

// 字型粗細
$sc--font-weight-light: 300 !default;
$sc--font-weight-normal: 400 !default;
$sc--font-weight-bold: 700 !default;

// 邊框粗細
$sc--border-width: 1px !default;

// 邊框顏色
$sc--border-color: #dcdfe6 !default;

// 邊框圓角
$sc--border-radius: 4px !default;
$sc--border-radius-lg: 6px !default;
$sc--border-radius-sm: 2px !default;

風格元件與表現複用的繼承密切相關——

輸入框元件、下拉選單元件等都屬於表單控制元件(form control),它們都繼承自「表單控制元件」這個虛擬元件,如果各自沒有指定顏色、字型、邊框等主題風格屬性的話,將會按照虛擬元件中所設定的來顯示。類似地,下拉選單元件、下拉選單元件等都有彈出層(pop-up),它們都繼承了「彈出層」這個虛擬元件。

想必你已經發現了,下拉選單元件同時繼承了「表單控制元件」和「彈出層」這兩個虛擬元件,這就是上面提到的「多重繼承」。

那些所謂的「虛擬元件」,它們也遵循著同樣的繼承規則——如果自身沒有指定特定的主題風格屬性,則會按照父級所設定的顯示。那麼,虛擬元件的「父級」是啥呢?——是基礎風格。

歐雷《聊聊前端 UI 元件:元件特徵

上面示例中所定義的 Sass 變數就是「基礎風格」。

以「表單控制元件」為例,一個繼承了基礎風格的虛擬元件用程式碼表示為:

$sc--form-control-font-size: $sc--font-size !default;
$sc--form-control-font-size-lg: $sc--font-size-lg !default;
$sc--form-control-font-size-sm: $sc--font-size-sm !default;

$sc--form-control-height: 36px !default;
$sc--form-control-height-lg: 40px !default;
$sc--form-control-height-sm: 32px !default;

$sc--form-control-color: $sc--text-regular !default;
$sc--form-control-placeholder-color: $sc--text-placeholder !default;
$sc--form-control-bg: #fff !default;
$sc--form-control-box-shadow: none !default;

$sc--form-control-border-width: $sc--border-width !default;
$sc--form-control-border-color: $sc--border-color !default;
$sc--form-control-border-radius: $sc--border-radius !default;
$sc--form-control-border-radius-lg: $sc--border-radius-lg !default;
$sc--form-control-border-radius-sm: $sc--border-radius-sm !default;

相應地,輸入框元件的風格元件部分大致為:

$sc--input-font-size: $sc--form-control-font-size !default;
$sc--input-font-size-lg: $sc--form-control-font-size-lg !default;
$sc--input-font-size-sm: $sc--form-control-font-size-sm !default;

$sc--input-height: $sc--form-control-height !default;
$sc--input-height-lg: $sc--form-control-height-lg !default;
$sc--input-height-sm: $sc--form-control-height-sm !default;

$sc--input-color: $sc--form-control-color !default;
$sc--input-placeholder-color: $sc--form-control-placeholder-color !default;
$sc--input-bg: $sc--form-control-bg !default;
$sc--input-box-shadow: $sc--form-control-box-shadow !default;

$sc--input-border-width: $sc--form-control-border-width !default;
$sc--input-border-color: $sc--form-control-border-color !default;
$sc--input-border-radius: $sc--form-control-border-radius !default;
$sc--input-border-radius-lg: $sc--form-control-border-radius-lg !default;
$sc--input-border-radius-sm: $sc--form-control-border-radius-sm !default;

視覺元件

雖然 UI 元件的最終呈現需要內容結構作為骨架去支撐,但若僅僅是為了勾勒出 UI 元件視覺結構的輪廓,只用 CSS 就可以了。一系列模組化、可複用、可組合的 CSS 規則構成了「視覺元件」,也可叫做「CSS 元件」。

在視覺元件中,得用 BEM 之類的命名法為 CSS 類選擇器命名。推薦使用由 BEM 衍生出來的這種:

/* 元件 */
.ComponentName {}

/* 元件後代 */
.ComponentName-descendentName {}

/* 元件修飾符 */
.ComponentName--modifierName {}

/* 元件狀態 */
.ComponentName.is-stateOfComponent {}

一個完整的視覺元件中已經包含了風格元件。拿按鈕元件來舉例的話,它的視覺元件大體是這樣:

$sc--button-font-size: $sc--form-control-font-size !default;
$sc--button-font-size-lg: $sc--form-control-font-size-lg !default;
$sc--button-font-size-sm: $sc--form-control-font-size-sm !default;

$sc--button-padding-y: 10px !default;
$sc--button-padding-y-lg: 12px !default;
$sc--button-padding-y-sm: 9px !default;

$sc--button-padding-x: 20px !default;
$sc--button-padding-x-lg: 20px !default;
$sc--button-padding-x-sm: 15px !default;

$sc--button-color: $sc--form-control-color !default;
$sc--button-bg: $sc--form-control-bg !default;
$sc--button-box-shadow: $sc--form-control-box-shadow !default;

$sc--button-border-width: $sc--form-control-border-width !default;
$sc--button-border-color: $sc--form-control-border-color !default;
$sc--button-border-radius: $sc--form-control-border-radius !default;
$sc--button-border-radius-lg: $sc--form-control-border-radius-lg !default;
$sc--button-border-radius-sm: $sc--form-control-border-radius-sm !default;

$sc--button-disabled-color: $sc--text-placeholder !default;
$sc--button-disabled-bg: #eee !default;

/* ----- 以上為風格元件部分 ----- */

.Button {
  padding: $sc--button-padding-y $sc--button-padding-x;
  font-size: $sc--button-font-size;
  color: $sc--button-color;
  background-color: $sc--button-bg;
  border: $sc--button-border-width solid $sc--button-border-color;
  border-radius: $sc--button-border-radius;
  box-shadow: $sc--button-box-shadow;

  &-icon,
  &-text {
    display: inline-block;
    vertical-align: middle;
  }

  &-icon + &-text {
    margin-left: 5px;
  }

  // 大按鈕
  &--large {
    padding: $sc--button-padding-y-lg $sc--button-padding-x-lg;
    font-size: $sc--button-font-size-lg;
    border-radius: $sc--button-border-radius-lg;
  }

  // 小按鈕
  &--small {
    padding: $sc--button-padding-y-sm $sc--button-padding-x-sm;
    font-size: $sc--button-font-size-sm;
    border-radius: $sc--button-border-radius-sm;
  }

  // 按鈕失效/禁用狀態
  &.is-disabled {
    color: $sc--button-disabled-color;
    background-color: $sc--button-disabled-bg;
  }
}

從上面的程式碼示例中可以看出,按鈕元件中包含了「圖示」和「文字」這兩個橫向排列且垂直居中的「後代」,並有「常規」、「大」和「小」三種「規格」,以及有「正常」和「失效/禁用」兩種「狀態」——透過 CSS 描繪出了 UI 元件視覺上的基本結構與特性。

無頭元件

「無頭」這個詞譯自「headless」,在計算機領域中代表硬體或軟體在使用或執行時不需要依賴 GUI 相關的裝置或庫。在這裡,「無頭元件」是指 UI 元件的互動邏輯,以及與之相融合的業務邏輯。

無頭元件的職責是負責監聽並接收事件系統的通知,提供處理 UI 元件自身狀態、資料轉換邏輯的函式或方法,它不應該關注和處理除了互動邏輯之外的事情。

在無頭元件中,所監聽並接收的並非是執行環境提供的真實事件,而是自定義的「代理事件」,它是真實事件的佔位符。這麼做的主要原因是,同一個行為雖然可能是由不同的真實事件觸發的,但對 UI 元件而言其語義是相同的——透過代理事件來表達對 UI 元件有意義的真實語義。

就拿下拉選單元件來說,它的彈出層的顯示可以透過其所包含的按鈕的 clickmouseover 這兩個真實事件來觸發,但對 UI 元件的真實語義是「彈出」而非「點選」或「懸停」,因而使用代理事件 pop-up 來替代。

上文說到無頭元件是「UI 元件的互動邏輯及與之相融合的業務邏輯」,又說「不應該關注和處理除了互動邏輯之外的事情」,這兩點乍看之下相互矛盾,然而並不——

業務邏輯對於一個網站、應用來說是十分必要且重要的,但對 UI 元件來說,它就沒那麼必要了,更談不上重要。在前端的 GUI 層面,業務邏輯理應是互動邏輯的延伸。

歐雷《聊聊前端 UI 元件:元件特徵

在 UI 元件中,業務邏輯與事件是息息相關的,不僅僅是 UI 事件,如點選按鈕後發出 HTTP 請求;還有資料事件,如業務資料變化後更新顯示的文字。因此,業務邏輯是互動邏輯的延伸。這就需要無頭元件在處理互動邏輯時提供擴充套件點,比如「事件對映」,以使業務邏輯作為無頭元件的擴充套件存在,而不是整合進去。

無頭元件會根據代理事件去呼叫事件處理函式。在未指定的情況下,代理事件會預設指向一個真實事件,事件處理函式會執行一段預設的處理邏輯;事件對映的作用就是更改代理事件所指向的真實事件以及事件處理函式的邏輯。

無頭元件的介面定義大概長這樣:

// 代理事件
type EventBroker = string;
// 真實事件
type EventName = string;
// 事件處理函式
type EventHandler = (params: any) => void;
// 事件物件
type EventObject = { name: EventName; handler: EventHandler };
// 事件對映
type EventMap = { [key: string]: EventName | EventHandler | EventObject };

interface IHeadlessComponent {
  // 事件對映
  setEventMap(map: EventMap): void;
  // 獲取真實事件
  getEventName(broker: EventBroker): EventName;
  // 獲取事件處理函式
  getEventHandler(broker: EventBroker): EventHandler;
}

結構元件

顧名思義,「結構元件」是用來生成 UI 元件內容結構的,但它的作用不僅如此,還會負責對接視覺元件與無頭元件。

如果單純從最終的 HTML 結構上來看,它也算是不易變的,但在現代前端開發中,HTML 的結構基本是動態生成的,並且強依賴於 React、Vue 這類沒有統一標準的 JS 庫/框架。另外,還存在著像 WXML、AXML 這類平臺限定的檢視結構描述語言。由於寫法不一致,這就使頁面內容結構變得不那麼穩定。

歐雷《聊聊前端 UI 元件:元件特徵

如上所述,UI 元件的內容結構依賴於檢視結構描述語法,檢視結構描述語法又取決於平臺或執行環境,這就導致了結構元件無法像風格元件、視覺元件和無頭元件一樣將變化隔離在外部,因而結構元件是構成 UI 元件的幾個元件中易變性最強的,最容易被替換的。

用 Vue 2.x 版本的類元件寫法來舉例,下拉選單元件的結構元件大致為:

<template>
  <div :class="$style.Dropdown">
    <button :class="$style['Dropdown-trigger']" @[popUpEventName]="handlePopUp">顯示彈出層</button>
    <div :class="[$style['Dropdown-popup'], { [$style['is-shown']]: isPopUpShown }]">我是彈出層</div>
  </div>
</template>

<!-- 視覺元件 -->
<style lang="scss" src="./style.scss" module></style>

<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator';

// 無頭元件
import DropdownHeadlessComponent from './headless';

@Component
export default class DropdownComponent extends Vue {
  // 事件對映配置外接
  @Prop({ type: Object, default: () => ({}) })
  private readonly eventMap!: { [key: string]: any };

  private popUpEventName: string = '';

  private isPopUpShown: boolean = false;

  private handlePopUp(): void {
    this.isPopUpShown = true;
  }

  // 初始化無頭元件例項及與其相關的
  private initHeadless(): void {
    const hc = new DropdownHeadlessComponent();

    hc.setEventMap(this.eventMap);

    this.popUpEventName = hc.getEventName('pop-up');
  }

  public created(): void {
    this.initHeadless();
  }
}
</script>

倘若要支援多技術棧、多平臺,當前流行的主要有兩種策略:在各技術棧、各平臺下分別實現結構元件;利用 Tarouni-app 這類工具進行轉譯。

可定製性

上文闡述的元件架構將一個原本很容易實現得魚龍混雜的 UI 元件根據關注點拆分成了風格元件、視覺元件、無頭元件和結構元件,這種架構會使各部分的可複用性得到很大的提升。除了易變性較強的結構元件之外,其他元件在達到一定成熟度之後就基本不會變動。假如需要更換技術棧或新支援一個平臺,只需實現一遍結構元件即可,其他元件可以拿來就用。

不單是可複用性有所改善,可定製性也有所加強。根據定製程式碼/配置與程式結合時所處的程式生命週期階段,將可定製性整理為下表:

可定製點編輯時/編譯時執行時
主題風格
視覺結構
觸發事件
業務邏輯
內容結構

如果風格元件的程式碼是像示例程式碼中寫的那樣,是不支援執行時定製的,得稍微改造一下:

// 未經改造
$sc--font-size: 14px !default;

// 利用 CSS 自定義屬性改造後
$sc--font-size: var(--sc-font-size, 14px) !default;

元件規範

每個 UI 元件都應當被視作是獨立的軟體包、模組,所以它的各方面應該是完備的——除了實現 UI 元件的程式碼,還應有詳盡的使用說明文件、可互動的線上 demo、完善的測試程式碼以及用來做一些自動化處理的後設資料等。

還是以下拉選單元件為例,上述材料相關檔案的目錄結構大體如下:

dropdown
   ├── demo
   │   └── ...
   ├── test
   │   └── ...
   ├── changelog.md
   ├── headless.ts
   ├── index.ts
   ├── metadata.yml
   ├── package.json
   ├── readme.md
   ├── structure.vue
   └── style.scss

程式碼編寫方面可以參考我總結並整理的程式碼風格指南:https://ntks.ourai.ws/guides/coding-style/

另外,在結構元件中對接視覺元件時,要用 CSS Modules,以避免外部的樣式程式碼所引起的非預期效果。

總結

本文基於本系列的上篇文章中得出的特徵模型提出了一個以「構造可交換的 UI 元件」為目標的元件架構,主要由風格元件、視覺元件、無頭元件和結構元件所構成,除了結構元件之外的元件可複用性都很高。

當一個 UI 元件是可交換的時,就可以圍繞它做一些比較有趣且有價值的事情了。

最後的最後,文中的示例程式碼是為了幫助理解而寫,僅供參考。;-)


本文其他閱讀地址:個人網站微信公眾號

相關文章