元件庫設計實戰系列:重新設計 React 元件庫

誠身發表於2017-10-23

在 react + redux 已經成為大部分前端專案底層架構的今天,讓我們再回到軟體工程界一個永恆的問題上來,那就是如何提升一個開發團隊的開發效率?
從巨集觀的角度來講,只有對具體業務的良好抽象才能真正提高一個團隊的開發效率,又囿於不同產品所面臨的不同業務需求,當我們抽絲剝繭般地將一個個前端專案抽象到最後一層,那麼剩下的就只有按鈕、輸入框、對話方塊、圖示等這些毫無業務意義的純 UI 元件了。

選擇或開發一套適合自己團隊使用的 UI 元件庫應該是每一個前端團隊在底層架構達成共識後下一件就要去做的事情,那麼我們就以今天為始,分別從以下幾個方面來探討如何構建一套優秀的 UI 元件庫。

第一個問題:選擇開源 vs 自己造輪子

在 React 界,優秀且開源的 UI 元件庫有很多,國外的如 Material-UI,國內的如 Ant Design,都是經過眾多使用者檢驗,元件豐富且程式碼質量過硬的元件庫。所以當我們決定再造一套 UI 元件庫之前,不妨先嚐試下這些在 UI 元件庫界口碑良好的標品,再決定是否要進入這個看似簡單實則困難重重的領域。

在這裡,我們並不會去比較任何元件庫之間的區別或優劣,但卻可以從產品層面給出幾個開發自有組
件庫的判斷依據,以供參考。

  • 產品有獨立的設計規範,包括但不限於元件樣式、互動模式。
  • 產品業務場景較為複雜,需要深度定製某些常用元件。
  • 前端團隊需要同時支撐多條業務線。

設計思想:規範 vs. 自由

在選擇了自己造輪子這樣一條路之後,下一個擺在面前的艱難選擇就是,要造一個規範的元件庫還是一個自由的元件庫?

規範的元件庫可以從原始碼層面保證產品視覺、互動風格的一致性,也可以很大程度上降低業務開發的複雜度,從而提升團隊整體的開發效率。但在遇到一些看似相似實則不同的業務需求時,規範的元件庫往往會走入一個難以避免的死迴圈,那就是實現 A 需求需要使用 a 元件,但是現有的 a 元件又不能完全支援 A 需求。

這時擺在工程師面前的就只有兩條路:

  • 重新開發一個完美支援 A 需求的 a+ 元件
  • 修改 a 元件原始碼使其支援 A 需求

方法一費時費力,會極大地增加本次專案的開發成本,而方法二又會導致 a 元件程式碼膨脹速度過快且邏輯複雜,極大地增加元件庫後期的維護成本。

在多次陷入上面所描述的這個困境之後,在最近的一次內部元件庫重構時,我們選擇了擁抱自由,這其中既有業務方面的考慮,也有 React 在元件自由組合方面的天然優勢,讓我們來看一個例子。

Select

// traditional select
<div className={dropdownClass}>
  <div
    className={`${baseClassName}-control ${disabledClass}`}
    onMouseDown={this.handleMouseDown.bind(this)}
    onTouchEnd={this.handleMouseDown.bind(this)}
  >
    {value}
    <span className={`${baseClassName}-arrow`} />
  </div>
  {menu}
</div>複製程式碼

這是一個非常傳統的 Select 元件,觸發下拉選單的區域為一段文字加一個箭頭。我們來看下面的一個業務場景:

select

這裡觸發下拉選單的區域不再是傳統的一段文字加一個箭頭,而是一個自定義元素,點選後展開下拉選單。雖然它的互動模式和 Select 一模一樣,但因為二者在 DOM 結構上的巨大差別,導致我們無法複用上面的這個 Select 來實現它。

// Customizeable Select
<div {...filterProps} className={classes} onClick={::this.handleInnerClick}>
  {
    children
    ||
      <span>
        <span className={`${prefixCls}-container`}>
          {label ? <span className={`${prefixCls}-container-label`}>{label}</span> : null}
          <span className={`${prefixCls}-container-value`} style={valueStyle}>
            {currentValue !== '' ? currentValue : selectPlaceholder}
          </span>
        </span>
        <Icon className={iconClasses} name="angle-down" />
      </span>
  }
  {this.renderPopup()}
</div>複製程式碼

在支援傳統的文字加箭頭之外,更自由的 Select 新增了對 label 及 children 支援,分別可以對應有名稱的 Select

select-label

及類似前面提到的自定義元素。

Dropdown

類似的還有 Select 的孿生兄弟 Dropdown。

// Customizeable Dropdown
<div {...filterProps} className={classes}>
  {data.map((value, idx) => {
    return (
      <ItemComponent
        data={value} key={idx} datas={data}
        className={itemClasses}
        onClick={onSelect.bind(null, value, idx)}
        onMouseOver={onMouseOver.bind(null, value, idx)}
      />
    );
  })}
</div>

// Using Dropdown
const demoData = [{ text: 'Robb Stark', age: 36 }]
const DropdownItem = (props) => (
  <div {...props}>
    <div>{props.data.text}</div>
    <div>is {props.data.age} years old.</div>
  </div>
);複製程式碼

這是一個常見的下拉選單元件,是否允許使用者傳入 ItemComponent 其實就是一個規範與自由之間的取捨。在選擇了擁抱自由之後,元件的使用者終於不會再被元件內部的 DOM 結構所束縛,轉而可以自由地定製子元素的 DOM 結構。

相較於傳統的規範的元件,自由的元件需要使用者在業務專案中多寫一些程式碼,但如果我們往深處再看一層,這些特殊的下拉元素本就是屬於某個業務所特有的,將其放在業務程式碼層恰恰是一種更合適的分層方法。

另一方面,我們在這裡所定義的自由,絕不僅僅是多暴露幾個渲染函式那麼簡單,這裡的自由指的是元件內部 DOM 結構的自由。因為一旦某個元件定死了自己的 DOM 結構,外部使用時除了重寫樣式去強行覆蓋外沒有任何其他可行的方式去改變它。

雖然我們上面提到了許多自由的好處,但很多時候我們還是會被一個問題所挑戰,那就是自由的元件在大部分時候不如規範的元件來得好用,因為呼叫起來很麻煩。

這個問題其實是有解的,那就是預設值。我們可以在元件庫中內建許多常用的子元素,當使用者不指定子元素時,使用預設的子元素來完成渲染,這樣就可以在規範與自由之間達成一個良好的平衡,但這裡需要注意的是,新增常用子元素的工作量也非常巨大,團隊內部也需要對“常用”這個詞有一個統一的認識。

或者你也可以選擇針對不同的使用場景,做兩套不同的解決方案。例如前端開源 UI 框架界的翹楚 antd,其底層依賴的 react-component 也是非常解耦的設計,幾乎看不到任何固定的 DOM 結構,而是使用自定義元件或 children prop 將 DOM 結構的決定權交給使用者。

// react-component/dropdown
return (
  <Trigger
    {...otherProps}
    prefixCls={prefixCls}
    ref="trigger"
    popupClassName={overlayClassName}
    popupStyle={overlayStyle}
    builtinPlacements={placements}
    action={trigger}
    showAction={showAction}
    hideAction={hideAction}
    popupPlacement={placement}
    popupAlign={align}
    popupTransitionName={transitionName}
    popupAnimation={animation}
    popupVisible={this.state.visible}
    afterPopupVisibleChange={this.afterVisibleChange}
    popup={this.getMenuElement()}
    onPopupVisibleChange={this.onVisibleChange}
    getPopupContainer={getPopupContainer}
  >
    {children}
  </Trigger>
);複製程式碼

資料處理:耦合 vs. 解耦

如果你問一個工程師在某個場景下,兩個模組是耦合好還是解耦好?我想他甚至可能都不會問你是什麼場景就脫口而出:“當然解耦好,耦合的程式碼根本沒辦法維護!”

但事實上,在傳統的元件庫設計中,我們一直都預設元件是可以和資料來源(一般的元件都會有 data 這個 prop)相耦合的,這樣就導致了我們在給某個元件賦值之前,要先寫一個資料處理方法,將後端返回回來的資料處理成元件要求的資料結構,再傳給元件進行渲染。

這時,如果後端返回的或元件要求的資料結構再變態一些(如陣列巢狀),這個資料處理方法就很有可能會寫得非常複雜,甚至還會導致許多的 edge case 使得元件在獲取某個特定的 attribute 時直接報錯。

如何將元件與資料來源解耦呢?答案就是不要在元件程式碼(不論是檢視層還是控制層)中出現 data.xxx,而是在回撥時將整個物件都拋給呼叫者供其按需使用。這樣元件就可以無縫適配於各種各樣的後端介面,大大降低使用者在資料處理時犯錯誤的可能。

承接前文,其實這樣的資料處理方式和前面提到的自由的設計思想是一脈相承的,正是因為我們賦予了使用者自由定製 DOM 結構的能力,所以我們同時也可以賦予他們在資料處理上的自由。

看到這裡,支援規範元件的朋友可能已經有些崩潰了,因為聽起來自由元件既不定義 DOM 結構,也不處理資料,那麼我為什麼還要用這個元件呢?

讓我們以 Select 元件為例來回答這個問題。

是的,自由的 Select 元件需要使用者自定義下拉元素,還需要在回撥中自己處理使用 data 的哪個 attribute 來完成下一步的業務邏輯,但 Select 元件真的什麼都沒有做嗎?其實並不是,Select 元件規範了“選擇”這個互動方式,處理了什麼時候顯示或隱藏下拉選單,響應了下拉選單元素的 hoverclick事件,並控制了絕對定位的下拉選單的彈出位置。這些通用的互動邏輯,才是 Select 元件的核心,至於多變的渲染和資料處理邏輯,打包開放出來反而更利於使用者在多變的業務場景下方便地使用 Select 元件。

講完了元件與資料來源之間的解耦,我們再來談一下元件各個 props 之間解耦的必要性。

假設一個需求:按照中國、美國、英國、日本、加拿大的順序顯示當地時間,當地時間需從服務端獲取且顯示格式不同。

我們可以設計一個元件,接收不同國家的時間資料作為其 data prop,展示一個當地時間至少需要英文唯一識別符號 region,中文顯示名 name,當前時間 time,顯示格式 format 等四個屬性,由此我們可以設計元件的 data 屬性為:

data: [{
  region: 'china'
  name: '中國',
  time: 1481718888,
  format: 'MMMM Do YYYY, h:mm:ss a',
}, {
  ...
}]複製程式碼

看起來不錯,但事實真的是這樣嗎?我相信如果你把這份資料結構拿給後端同事看時,他一定會立刻指出一個問題,那就是後端資料庫中是不會儲存 nameformat 欄位的,因為這是由具體產品定義的展示邏輯,而介面只負責告訴你這個地區是哪裡 region 以及這個地區的當前時間是多少 time。事情到這裡也許還不算那麼糟糕,因為我們可以在呼叫元件前,將非同步獲取到的資料再重新格式化一遍,補上缺失的欄位。但這時一個更棘手的問題來了,那就是介面返回的陣列資料一般是不保證順序的,你還需要按照產品的要求,在補充完缺失的欄位後,對整個陣列進行一次重排以保證每一次渲染出來的地區都保持同樣的順序。

換一種方式,如果我們這樣去設計元件的 props 呢?

{
  data: {
    china: {
       time: 1481718888,
    },
    ...
  },
  timeList: [{
    region: 'china',
    name: '中國',
    format: 'MMMM Do YYYY, h:mm:ss a',
  }, {
    ...
  }],
  ...
}複製程式碼

當我們將需要非同步獲取的 props 抽離後,這個元件就變得非常 data & api friendly 了,僅通過配置 timeList prop 就可以完美地控制元件的渲染規則及渲染順序並且再也不需要對介面返回的資料進行補全或定製了。甚至我們還可以通過設定預設值的方式,先將元件同步渲染出來,在非同步資料請求完成後再重繪數值部分,給予使用者更好的視覺體驗。

除了分離非必須耦合的 props 之外,細心的朋友可能還會發現上面的 data prop 的資料結構從陣列變為了物件,這又是為什麼呢?

回撥規範:陣列 vs. 物件

設計思想可以是自由的,資料處理也可以是自由的,但一個成熟的 UI 元件庫作為一個獨立的前端專案,在程式碼層面必須要建立起自己的規範。拋開老生常談的 JavaScript 及 Sass/Less 層面的程式碼規範不表,讓我們從 CSS 類名、元件類別及回撥規範三個方面分享一些最佳實踐。

在元件庫專案中,並不推薦使用 CSS Modules,一方面是因為其編譯出來的複雜類名不便於使用者在業務專案裡進行簡單覆蓋,更重要的是我們可以將每一個元件都看作是一個獨立的模組,用新增 xui-componentName 類名字首的方式來實現一套簡化版的 CSS Modules。另外,在 jsx 中我們可以參考 antd 的做法,為每一個元件新增一個名為 prefixCls 的 prop,並將其預設值也設定為 xui-componentName,這樣就在 jsx 層面也保證了程式碼的統一性,方便團隊成員閱讀及維護。

在這次內部元件庫重構專案中,我們將所有的元件分為了純渲染元件與智慧元件兩類,並規範其寫法為純函式與 ES6 class 兩種,徹底拋棄了 React.createClass 的寫法。這樣一方面可以進一步規範程式碼,增強可讀性,另一方面也可以讓後續的維護者在一秒鐘內判斷出某個元件是純渲染元件還是智慧元件。

在回撥函式方面,所有的元件內部函式都以 handleXXXhandleClickhandleHoverhandleMouseover 等)為命名模板,所有對外暴露的回撥函式都以 onXXXonChangeonSelect 等)為命名模板。這樣在維護一些依賴層級較深的底層元件時,就可以在 render 方法中一眼看出某個回撥是在處理內部狀態,還是將回撥至更高一層。

在設計回撥資料的資料結構時,我們只使用了單一值(如 Input 元件的回撥)和物件兩種資料結構,儘量避免了使用傳統元件庫中常用的陣列。相較於物件,陣列其實是一種含義更為豐富的資料結構,因為它是有向的(包含順序的),比如在上面的例子中,timeList prop 就被設計為陣列,這樣它就可以在承載資料的同時包含資料展示的順序,極大地方便了元件的使用。但在給使用者丟擲回撥資料時,並不是每一位使用者都能夠像元件設計者那樣清楚回撥資料的順序,使用陣列實際上變相增加了使用者的記憶成本,而且筆者一直都不贊成在程式碼中出現類似於 const value = data[0]; 這樣的表示式。因為沒有人能夠保證陣列的長度滿足需要且當前位上的元素就是要取的值。另一方面,物件因為鍵值對的存在,在具體到某一個元素的表意上要比陣列更為豐富。例如選擇日曆區間後的回撥需要同時返回開始日期及結束日期:

// array
['2016-11-11', '2016-12-12']

// object
{
  firstDay: '2016-11-11',
  lastDay: '2016-12-12',
}複製程式碼

嚴格來講上述的兩種方式並沒有對錯之分,只是物件的資料結構更能夠清晰地表達每個元素的含義並消除順序的影響,更利於不瞭解元件庫內部程式碼的使用者快速上手。

小結

在本文中,我們從設計思想、資料處理、回撥規範三個方面為各位剖析了在前端元件化已經成為既定事實的今天,我們還能在元件庫設計方面做出怎樣新的嘗試與突破。也許這些新的嘗試與突破並不會像一個新的框架那樣給你帶來全新的震撼,但我們相信這些實用的思考與經驗可以讓你少走許多彎路並開啟一些新的思路,並且跳出前端這個“狹小”的圈子,站在軟體工程的高度去看待這些看似簡單實則複雜的工作。

在以後的文章中,我們還會從元件庫整體程式碼架構、元件庫國際化方案及複雜元件架構設計等方面為大家帶來更多細節上的經驗與體會,也會穿插更多的具體的程式碼片段來闡述我們的設計思想與理念,敬請期待。


相關文章