重新設計 React 組件庫
從宏觀的角度來講,其實只有良好的抽象才能真正提高一個團隊的開發效率,而囿於不同產品所面臨的不同業務需求,當我們抽絲剝繭般地將一個個前端工程抽象到最後一層,那麼剩下的其實就只有按鈕、輸入框、日曆、對話框、圖標等這些毫無業務含義的 UI 組件了。
選擇或開發一套適合自己團隊使用的 UI 組件庫應該是每一個前端團隊在底層架構達成共識後下一件就要去做的事情,那麼我們就以今天為始,分別從以下幾個方面來一起探討如何構建一套優秀的 UI 組件庫。
第一個問題:選擇開源 vs 自己造輪子
在 React 界,優秀且開源的 UI 組件庫有很多,國外的如 material-ui,國內的如 ant-design,都是經過眾多使用者檢驗,組件豐富且代碼質量過硬的組件庫。所以當你決定自己再造一套 UI 組件庫之前,不妨先嘗試下這些在 UI 組件庫界口碑良好的標品,再決定是否要親自進入這個看似簡單但實則困難重重的領域。
在這裡,我們並不會去比較任何組件庫之間的區別或優劣,但卻可以從產品層面給出幾個開發自有組件庫的判斷依據,僅供參考。
- 產品有獨立的設計規範,包括但不限於組件樣式、交互模式。
- 產品業務場景較為複雜,需要深度定製某些通用組件。
- 團隊需要同時支撐多個相似產品。
設計思想:規範 vs 自由
在選擇了自己造輪子這樣一條路之後,下一個擺在面前的艱難的選擇就是,要造一個規範的組件庫還是一個自由的組件庫?
規範的組件庫可以從根本上保證產品視覺、交互風格的一致性,也可以很大程度上降低業務開發的複雜度,從而提升團隊整體的開發效率。但在遇到一些看似相似實則不同的業務需求時,規範的組件庫往往會走入一個可怕的死循環,那就是 A 需求需要使用 A 組件,但是現有的 A 組件不能完全支持 A 需求。這時擺在工程師面前的就只有兩條路,從零開始把 A 需求開發一遍或者侵入 A 組件代碼去支持 A 需求。方法一費時費力,會極大地增加本次項目的開發成本,方法二會導致 A 組件代碼膨脹且邏輯複雜,極大地增加組件庫後期的維護成本。
在多次陷入上面所描述的這個困境之後,最近一次內部組件庫重構時,我們選擇了擁抱自由,這其中既有業務方面的考慮,也有 React 在組件自由組合方面的天然優勢,讓我們來看一個例子。
// traditional selectn<div className={dropdownClass}>n <divn className={`${baseClassName}-control ${disabledClass}`}n onMouseDown={this.handleMouseDown.bind(this)}n onTouchEnd={this.handleMouseDown.bind(this)}n >n {value}n <span className={`${baseClassName}-arrow`} />n </div>n {menu}n</div>n
這是一個非常傳統的 Select 組件,觸發 Select 的部分為 Select 的值及一個箭頭,我們來看下面的一個業務場景:
這裡的選擇器不再是 value 加一個箭頭,而是一個自定義元素,點擊後展開下拉列表。雖然它的交互和 Select 一模一樣,但這時候我們就不能再用當前的這個 Select 去實現它了。
// Customizeable Selectn<div {...filterProps} className={classes} onClick={::this.handleInnerClick}>n {n childrenn ||n <span>n <span className={`${prefixCls}-container`}>n {label ? <span className={`${prefixCls}-container-label`}>{label}</span> : null}n <span className={`${prefixCls}-container-value`} style={valueStyle}>n {currentValue !== ? currentValue : selectPlaceholder}n </span>n </span>n <Icon className={iconClasses} name="angle-down" />n </span>n }n {this.renderPopup()}n</div>n
在傳統的 value 和箭頭之外,更自由的 Select 添加了 label 及 children 支持,分別可以對應有名稱的 Select
或類似上面這種自定義的選擇器。
同樣的還有 Select 的孿生兄弟 Dropdown。
// Customizeable Dropdownn<div {...filterProps} className={classes}>n {n data.map((r, i) => {n return (n <ItemComponentn data={r} key={i} datas={data}n className={itemClasses}n onClick={onSelect.bind(null, r, i)}n onMouseOver={onMouseOver.bind(null, r, i)}n />n );n })n }n</div>nn// Using Dropdownnconst demoData = [{ text: Robb Stark, age: 36 }]nSelectItem(props) {n const { data, ...other } = props;n return (<div {...filterProps}>n <div>{data.text}</div>n <div>is {data.age} years old.</div>n </div>);n}n
這是一個常見的下拉列表的組件,是否允許用戶傳入 ItemComponent 其實就是一個規範與自由之間的博弈。而在選擇了擁抱自由之後,組件的使用者終於不用再被組件所定義好的 DOM 結構所束縛,可以自由地組織自定義下拉元素。
是的,相較於傳統的規範組件,自由的組件需要使用者在業務項目中多寫一些代碼,但如果我們往深處多看一層,這些特殊的下拉元素本就是屬於某個業務所獨有的,將其放在業務代碼層恰恰是一種更合適的分層方法。
而另一方面,我們在這裡所定義的自由,絕不僅僅是多暴露幾個渲染函數那麼簡單,這裡的自由,指的是組件 DOM 結構的自由,因為一旦某個組件定死了自己的 DOM 結構,外部除了重寫樣式去強行覆蓋外沒有任何其他可行的方式去改變它。
雖然我們上面提到了許多自由的好處,但很多時候我們還是會被一個問題所挑戰,那就是自由的組件在大部分時候真的很難用,因為調用起來很麻煩。
這個問題其實是有解的,那就是默認值。我們可以在組件內部內置許多常用的組成元素,當用戶不指定組成元素時,使用默認組成元素來渲染,這樣就可以在規範與自由之間達到一個良好的平衡。當然,這裡也有一個貼心小提示,那就是如果你真得希望在規範與自由之間達到一個良好的平衡,一定要提前做好組件庫工作量增加三分之一的準備。
或者你也可以選擇針對不同的使用場景,做兩套不同的解決方案,例如前端開源 UI 框架界的翹楚 antDesign,其底層依賴的 react-component 其實也是非常解耦的設計,幾乎看不到任何固定的 DOM 結構,而是使用自定義組件或 children prop 將 DOM 結構的決定權交給使用者。
// react-component/dropdownnnreturn (n <Triggern {...otherProps}n prefixCls={prefixCls}n ref="trigger"n popupClassName={overlayClassName}n popupStyle={overlayStyle}n builtinPlacements={placements}n action={trigger}n showAction={showAction}n hideAction={hideAction}n popupPlacement={placement}n popupAlign={align}n popupTransitionName={transitionName}n popupAnimation={animation}n popupVisible={this.state.visible}n afterPopupVisibleChange={this.afterVisibleChange}n popup={this.getMenuElement()}n onPopupVisibleChange={this.onVisibleChange}n getPopupContainer={getPopupContainer}n >n {children}n </Trigger>n);n
數據處理:耦合 vs 解耦
如果你問一個工程師在某個場景下,耦合好還是解耦好?我想他可能都不會問你是什麼場景,就脫口而出:當然解耦好,耦合的代碼根本沒法維護!
但事實上,在傳統的組件庫設計中,我們一直都默認組件是可以和數據源(一般組件都會有 data 這個 prop)相耦合的,這樣就導致了我們在給某個組件賦值之前,要先寫一個數據處理方法,將後端返回回來的數據處理成組件要求的數據結構,再傳給組件進行渲染。
這時,如果後端返回的或組件要求的數據結構再變態一些(如數組嵌套),這個數據處理方法有可能會寫得非常複雜,而且也會帶來許多的 edge case 導致組件在取某個特定的 attribute 時直接報錯。
那麼如何將組件與數據源解耦呢?答案就是不要在組件代碼(不論是視圖層還是控制層)中出現 http://data.xxx,而是在回調時將整個對象都拋給調用者供其按需使用。這樣我們的組件就可以無縫適配於各種各樣的後端介面,大大降低使用者或組件在數據處理過程中犯錯誤的可能。
承接前文,其實這樣的數據處理方式是和前面所提到的自由的設計思想一脈相承的,正是因為我們賦予了使用者自由定製 DOM 結構的能力,所以我們同時也可以賦予他們在數據處理上的自由。
講到這裡,支持規範組件的人可能已經有些崩潰了,因為聽起來自由組件既不強制 DOM 結構,也不處理數據,代碼都要我們在外面寫,那麼為什麼還要用這個組件呢?
我們以 Select(選擇器)組件為例來回答這個問題。
是的,自由的 Select 需要使用者自定義下拉元素,還需要在回調中自己處理使用 data 的哪個 attribute 來完成下一步的業務邏輯,但 Select 組件真的什麼都沒有做嗎?其實並不是,Select 組件規範了選擇這個交互方式,處理了什麼時候顯示或隱藏下拉列表,添加了下拉列表元素的 hover 和 click 事件,並控制了絕對定位的下拉列表的彈出位置。這些通用的交互邏輯,才是 Select 組件的核心,至於多變的渲染和數據處理邏輯,打包開放給用戶反而更利於他們在多變的業務場景中更加方便地使用 Select 組件。
講完了組件與數據源之間的解耦,我們再來講一下組件各個 props 之間解耦的必要性。
假設一個需求:按照中國、美國、英國、日本、加拿大的順序分別顯示 5 個當地時間,當地時間需由服務端獲取,且顯示格式不同。
這時我們可以設計一個時間組組件,可以接收五個國家的時間數據作為其 data prop,而展示一個當地時間至少需要英文唯一標識符(region)、中文顯示名(name)、當前時間(time)、顯示格式(format)四個屬性,由此我們可以設計時間組組件的 data 屬性為:
data: [n {n region: chinan name: 中國,n time: 1481718888,n format: MMMM Do YYYY, h:mm:ss a,n },n ...n]n
看起來很完美,但事實真的是這樣嗎?我相信如果你把這份數據結構拿給後端同事看時,他一定會立刻指出一個問題,那就是後端資料庫中是不會保存 name 及 format 欄位的,因為這是由具體產品定義的展示邏輯,而介面只負責告訴你這個地區是哪裡(region)以及這個地區的當前時間是多少(time)。事情到這裡也許還不算那麼糟糕,因為你可以在調用組件之前,把非同步獲取到的數據再重新格式化一遍,補上缺失的欄位。但這時一個更棘手的問題來了,那就是介面返回的數組數據一般是不保證順序的,你還需要按照產品的要求,在補充完缺失的欄位後,對這個數組進行一次重排,以保證每一次渲染出來的地區都在同樣的位置。
換一種方式,如果我們這樣去設計時間組組件的 props 呢?
{n data: {n china: {n time: 1481718888,n },n ...n },n timeList: [n {n region: china,n name: 中國,n format: MMMM Do YYYY, h:mm:ss a,n },n ...n ],n ...n}n
當我們將需要非同步獲取的 props 抽離之後,這個組件就變得非常 data & api friendly 了,我們通過配置 timeList 就可以完美地控制時間組的渲染規則及渲染順序並且再也不需要去對介面返回的數據進行補全或定製了。甚至我們還可以通過設置默認值的方式,讓組件先同步渲染出來,在非同步的數據請求完成後,重繪數值部分,給予用戶更好的視覺體驗。
除了分離非必須耦合的 props 之外,細心的朋友可能還會發現上面的 data prop 的數據結構從數組變為了對象,這又是為什麼呢?讓我們來看下一小節。
回調規範:數組 vs 對象
設計思想可以是自由的,數據處理也可以是自由的,但一個成熟的 UI 組件庫作為一個獨立的前端項目,在代碼層面必須要建立起自己的規範,拋開老生常談的 JavaScript 或 Sass 層面的代碼規範不表,我們從 CSS 類名、組件類別及回調規範三個方面來和大家分享一些最佳實踐。
在組件庫項目中,並不推薦使用 CSS Modules,一方面是因為其編譯出來的複雜類名不便於使用者在業務項目里進行簡單覆蓋,更重要的是我們其實可以很方便地將每一個組件看作是一個獨立的模塊,用添加 xui-componentName 類名前綴的方式來實現一套簡化版的 CSS Modules。另外,在 jsx 中我們可以參考 antDesign 的做法,為每一個組件添加一個名為 prefixCls 的 prop,並將其默認值也設置為 xui-componentName,這樣就在 jsx 層面也保證了代碼的統一性,方便團隊成員閱讀及維護。
在這次內部的組件庫重構項目中,我們將所有的組件分為純渲染與智能組件兩類,並規範其寫法為純函數與 ES6 class 兩種,徹底拋棄了 React.createClass 的寫法。這樣一方面可以進一步規範代碼,增強可讀性,另一方面也可以讓後續的維護者在一秒鐘內判斷出某個組件是純渲染組件還是智能組件。
而在回調方面,所有的組件內部函數都以 handleXXX(handleClick, handleHover, handleMouseover 等)為命名模板,所有對外暴露的回調函數都以 onXXX(onChange、onSelect 等)為命名模板,這樣在維護一些依賴層級較深的底層組件時,就可以在 render 方法中一眼看出某個回調是在處理內部狀態,還是會拋回到更高一層。
在設計回調數據的數據結構時,我們只使用了單一值(如 Input 組件的回調)和對象兩種數據結構,盡量避免了使用傳統組件庫中常用的數組。相較於對象,數組其實是一種含義更為豐富的數據結構,因為它是有向的(有順序的),比如在上面時間組的例子中,timeList 就被設計為數組,這樣它就可以在承載展示數據的同時表達出時間組展示的順序,極大地方便了組件使用。但在給使用者拋出回調數據時,並不是每一位使用者都能夠像組件設計者那樣清楚回調數據的順序,使用數組其實變相增加了使用者的記憶成本,而且筆者一直都不贊成在代碼中出現類似於
const value = data[0];n
這樣的表達式,因為沒有人能夠保證被取值的這個數組長度滿足需要且當前位上的元素就是要取的值。另一方面,對象因為鍵值對的存在,在具體到某一個元素的表意上要比數組更為豐富。例如選擇日曆區間後的回調需要同時返回開始日期及結束日期:
// arrayn[2016-11-11, 2016-12-12]nn// objectn{n firstDay: 2016-11-11,n lastDay: 2016-12-12,n}n
嚴格來講上述的兩種表達方式沒有對錯之分,只是對象的數據結構更能夠清晰地表達每個元素的含義並消除順序的影響,更利於不了解組件庫內部代碼的使用者快速上手。
小結
在本文中,我們從設計思想、數據處理、回調規範三個方面從總體上為各位剖析了在前端組件化已經成為了既定事實的今天,我們還能在組件化方面做出怎樣新的嘗試與突破。也許這些新的嘗試與突破並不會像一個新的框架那樣給你帶來全新的震撼,但我們相信這些實用的思考與經驗可以幫助你少走許多彎路或打開一些新的思路,並且跳脫出前端這個狹小的圈子,站在軟體工程的高度去看待自己手頭這些看似簡單實則複雜的工作。
在稍後的文章中,我們會從組件庫整體代碼架構、組件庫國際化方案及複雜組件架構設計等方面為大家帶來更多細節上的經驗與體會,也會穿插更多的具體的代碼片段來闡述我們的設計思想與理念,敬請期待。
文末彩蛋
組件庫是所有前端項目的基礎,在和大家分享經驗的同時,也希望能夠多和各位進行思想上的碰撞,我們會從所有留言的朋友中選出最有價值的一位,送上 pure render 專欄的最新力作《深入 React 技術棧》(@流形 老師簽名版)一本,歡迎各位多多留言,讓我們在交流與討論中一起成長!
《深入 React 技術棧》購買鏈接:
亞馬遜
china-pub
京東
噹噹
推薦閱讀:
※10min手寫(b三):b窮逼前端趕製的聖誕節禮物
※React Native 開源一周年回顧
※興趣部落的Git遷移實踐