在 react + redux 已經成爲大部分前端項目底層架構的今天,讓咱們再回到軟件工程界一個永恆的問題上來,那就是如何提高一個開發團隊的開發效率?
從宏觀的角度來說,只有對具體業務的良好抽象才能真正提升一個團隊的開發效率,又囿於不一樣產品所面臨的不一樣業務需求,當咱們抽絲剝繭般地將一個個前端項目抽象到最後一層,那麼剩下的就只有按鈕、輸入框、對話框、圖標等這些毫無業務意義的純 UI 組件了。前端
選擇或開發一套適合本身團隊使用的 UI 組件庫應該是每個前端團隊在底層架構達成共識後下一件就要去作的事情,那麼咱們就以今天爲始,分別從如下幾個方面來探討如何構建一套優秀的 UI 組件庫。react
在 React 界,優秀且開源的 UI 組件庫有不少,國外的如 Material-UI,國內的如 Ant Design,都是通過衆多使用者檢驗,組件豐富且代碼質量過硬的組件庫。因此當咱們決定再造一套 UI 組件庫以前,不妨先嚐試下這些在 UI 組件庫界口碑良好的標品,再決定是否要進入這個看似簡單實則困難重重的領域。git
在這裏,咱們並不會去比較任何組件庫之間的區別或優劣,但卻能夠從產品層面給出幾個開發自有組
件庫的判斷依據,以供參考。github
在選擇了本身造輪子這樣一條路以後,下一個擺在面前的艱難選擇就是,要造一個規範的組件庫仍是一個自由的組件庫?數據庫
規範的組件庫能夠從源碼層面保證產品視覺、交互風格的一致性,也能夠很大程度上下降業務開發的複雜度,從而提高團隊總體的開發效率。但在遇到一些看似類似實則不一樣的業務需求時,規範的組件庫每每會走入一個難以免的死循環,那就是實現 A 需求須要使用 a 組件,可是現有的 a 組件又不能徹底支持 A 需求。redux
這時擺在工程師面前的就只有兩條路:後端
方法一費時費力,會極大地增長本次項目的開發成本,而方法二又會致使 a 組件代碼膨脹速度過快且邏輯複雜,極大地增長組件庫後期的維護成本。api
在屢次陷入上面所描述的這個困境以後,在最近的一次內部組件庫重構時,咱們選擇了擁抱自由,這其中既有業務方面的考慮,也有 React 在組件自由組合方面的自然優點,讓咱們來看一個例子。數組
// 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 組件,觸發下拉菜單的區域爲一段文字加一個箭頭。咱們來看下面的一個業務場景:bash
這裏觸發下拉菜單的區域再也不是傳統的一段文字加一個箭頭,而是一個自定義元素,點擊後展開下拉列表。雖然它的交互模式和 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 的孿生兄弟 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>
);複製代碼
若是你問一個工程師在某個場景下,兩個模塊是耦合好仍是解耦好?我想他甚至可能都不會問你是什麼場景就脫口而出:「固然解耦好,耦合的代碼根本沒辦法維護!」
但事實上,在傳統的組件庫設計中,咱們一直都默認組件是能夠和數據源(通常的組件都會有 data 這個 prop)相耦合的,這樣就致使了咱們在給某個組件賦值以前,要先寫一個數據處理方法,將後端返回回來的數據處理成組件要求的數據結構,再傳給組件進行渲染。
這時,若是後端返回的或組件要求的數據結構再變態一些(如數組嵌套),這個數據處理方法就頗有可能會寫得很是複雜,甚至還會致使許多的 edge case 使得組件在獲取某個特定的 attribute 時直接報錯。
如何將組件與數據源解耦呢?答案就是不要在組件代碼(不管是視圖層仍是控制層)中出現 data.xxx
,而是在回調時將整個對象都拋給調用者供其按需使用。這樣組件就能夠無縫適配於各類各樣的後端接口,大大下降使用者在數據處理時犯錯誤的可能。
承接前文,其實這樣的數據處理方式和前面提到的自由的設計思想是一脈相承的,正是由於咱們賦予了使用者自由定製 DOM 結構的能力,因此咱們同時也能夠賦予他們在數據處理上的自由。
看到這裏,支持規範組件的朋友可能已經有些崩潰了,由於聽起來自由組件既不定義 DOM 結構,也不處理數據,那麼我爲何還要用這個組件呢?
讓咱們以 Select 組件爲例來回答這個問題。
是的,自由的 Select 組件須要使用者自定義下拉元素,還須要在回調中本身處理使用 data 的哪一個 attribute 來完成下一步的業務邏輯,但 Select 組件真的什麼都沒有作嗎?其實並非,Select 組件規範了「選擇」這個交互方式,處理了何時顯示或隱藏下拉列表,響應了下拉列表元素的 hover
和 click
事件,並控制了絕對定位的下拉列表的彈出位置。這些通用的交互邏輯,纔是 Select 組件的核心,至於多變的渲染和數據處理邏輯,打包開放出來反而更利於使用者在多變的業務場景下方便地使用 Select 組件。
講完了組件與數據源之間的解耦,咱們再來談一下組件各個 props 之間解耦的必要性。
假設一個需求:按照中國、美國、英國、日本、加拿大的順序顯示當地時間,當地時間需從服務端獲取且顯示格式不一樣。
咱們能夠設計一個組件,接收不一樣國家的時間數據做爲其 data prop,展現一個當地時間至少須要英文惟一標識符 region
,中文顯示名 name
,當前時間 time
,顯示格式 format
等四個屬性,由此咱們能夠設計組件的 data 屬性爲:
data: [{
region: 'china'
name: '中國',
time: 1481718888,
format: 'MMMM Do YYYY, h:mm:ss a',
}, {
...
}]複製代碼
看起來不錯,但事實真的是這樣嗎?我相信若是你把這份數據結構拿給後端同事看時,他必定會馬上指出一個問題,那就是後端數據庫中是不會保存 name
及 format
字段的,由於這是由具體產品定義的展現邏輯,而接口只負責告訴你這個地區是哪裏 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 的數據結構從數組變爲了對象,這又是爲何呢?
設計思想能夠是自由的,數據處理也能夠是自由的,但一個成熟的 UI 組件庫做爲一個獨立的前端項目,在代碼層面必需要創建起本身的規範。拋開老生常談的 JavaScript 及 Sass/Less 層面的代碼規範不表,讓咱們從 CSS 類名、組件類別及回調規範三個方面分享一些最佳實踐。
在組件庫項目中,並不推薦使用 CSS Modules,一方面是由於其編譯出來的複雜類名不便於使用者在業務項目裏進行簡單覆蓋,更重要的是咱們能夠將每個組件都看做是一個獨立的模塊,用添加 xui-componentName
類名前綴的方式來實現一套簡化版的 CSS Modules。另外,在 jsx 中咱們能夠參考 antd 的作法,爲每個組件添加一個名爲 prefixCls
的 prop,並將其默認值也設置爲 xui-componentName
,這樣就在 jsx 層面也保證了代碼的統一性,方便團隊成員閱讀及維護。
在此次內部組件庫重構項目中,咱們將全部的組件分爲了純渲染組件與智能組件兩類,並規範其寫法爲純函數與 ES6 class 兩種,完全拋棄了 React.createClass
的寫法。這樣一方面能夠進一步規範代碼,加強可讀性,另外一方面也可讓後續的維護者在一秒鐘內判斷出某個組件是純渲染組件仍是智能組件。
在回調函數方面,全部的組件內部函數都以 handleXXX
(handleClick
,handleHover
,handleMouseover
等)爲命名模板,全部對外暴露的回調函數都以 onXXX
(onChange
,onSelect
等)爲命名模板。這樣在維護一些依賴層級較深的底層組件時,就能夠在 render 方法中一眼看出某個回調是在處理內部狀態,仍是將回調至更高一層。
在設計回調數據的數據結構時,咱們只使用了單一值(如 Input 組件的回調)和對象兩種數據結構,儘可能避免了使用傳統組件庫中經常使用的數組。相較於對象,數組實際上是一種含義更爲豐富的數據結構,由於它是有向的(包含順序的),好比在上面的例子中,timeList prop 就被設計爲數組,這樣它就能夠在承載數據的同時包含數據展現的順序,極大地方便了組件的使用。但在給使用者拋出回調數據時,並非每一位使用者都可以像組件設計者那樣清楚回調數據的順序,使用數組實際上變相增長了使用者的記憶成本,並且筆者一直都不同意在代碼中出現相似於 const value = data[0];
這樣的表達式。由於沒有人可以保證數組的長度知足須要且當前位上的元素就是要取的值。另外一方面,對象由於鍵值對的存在,在具體到某一個元素的表意上要比數組更爲豐富。例如選擇日曆區間後的回調須要同時返回開始日期及結束日期:
// array
['2016-11-11', '2016-12-12']
// object
{
firstDay: '2016-11-11',
lastDay: '2016-12-12',
}複製代碼
嚴格來說上述的兩種方式並無對錯之分,只是對象的數據結構更可以清晰地表達每一個元素的含義並消除順序的影響,更利於不瞭解組件庫內部代碼的使用者快速上手。
在本文中,咱們從設計思想、數據處理、回調規範三個方面爲各位剖析了在前端組件化已經成爲既定事實的今天,咱們還能在組件庫設計方面作出怎樣新的嘗試與突破。也許這些新的嘗試與突破並不會像一個新的框架那樣給你帶來全新的震撼,但咱們相信這些實用的思考與經驗可讓你少走許多彎路並打開一些新的思路,而且跳出前端這個「狹小」的圈子,站在軟件工程的高度去看待這些看似簡單實則複雜的工做。
在之後的文章中,咱們還會從組件庫總體代碼架構、組件庫國際化方案及複雜組件架構設計等方面爲你們帶來更多細節上的經驗與體會,也會穿插更多的具體的代碼片斷來闡述咱們的設計思想與理念,敬請期待。