通天塔之石——企業級前端組件庫方案
組件庫是前端大規模開發中提升效率的重要一環,同時也是可視化頁面搭建、自動化測試等上層建築的基石。因此設計時要考慮的問題涵蓋面非常廣。要設計好非常難,但是設計好之后從上層建築帶來的回報會超過你的想象。
這篇文章中我們先一起來關注和探討組件庫要解決的問題,最后會推導出一套足夠靈活——適用於大團隊或社區使用,又足夠強大——能支撐起上層建築的組件庫方案。也請讀者注意,結論其實很簡單,文中思考過程才是重點。知道結論並能讓你一躍成為架構師,但知道了如何從系統角度設計局部卻讓你有機會可以。共勉。
1. 問題域
要理清問題域,我們先要了解組件庫在架構層面處於哪個位置,它都與哪些其他部分有關系,一圖蔽之:
可以看出問題域大體可分為三部分:
一,產生於應用框架等上層建築。例如應用框架可能希望能控制組件的所有狀態,監聽所有事件,以便能提供完整的回滾等功能給用戶。精確到組件數據的測試框架的示例:
二,產生於工程工具。希望得到更多組件內部的信息。對屬性的自動讀取示例:
三,產生於組件的需求本身。這里涉及到理想的應用框架中提到的兩個問題:有需求希望組件的邏輯不變,展示稍微變一下怎么辦?或者前后兩者反過來怎么辦?
接下來再細化每個部分的問題:
1.1 上層建築
任何底層方案設計時首先要關注的就是上層建築。上層建築是回報的來源,能承載的上層建築越多,回報越大。但是同時,高樓帶給底層的壓力和挑戰也是巨大的。例如從我們上面所舉的例子——測試框架通過對組件所有屬性變化的監聽來實現數據對比或者回滾——現在並沒有哪一個組件引擎天然很好地支持了這樣的能力。即使是類似 react 的調試工具中顯示的狀態也是利用了引擎的特殊支持。
如果引擎不能提供,或者要 hack 才能實現,那建立上層建築的壓力和風險就太大。對這種需求,很多人可能想到這里就放棄了。為什么一定要提供組件級別的狀態回滾這樣的功能呢,以前也沒有人這樣干過啊?我們還是先蓋個平房吧。
這種想法很可悲,一是認識不到上層建築的價值,二是不能正確剖析問題。其實很多時候只要再邁一步,想想它的本質,解法就躍然紙上了。
對上層的應用框架、調試工具、測試工具來說,他們功能的本身,就是對組件的控制或信息展示,所以它們要求完全控制組件的構成成分的是合理的。就像木偶身上的線越多,能控制的動作就越精細。要提供構成成分的控制權,我們先理清楚組件的構成成分有哪些:
- 用於驅動視圖的數據
- 改變數據的方法(事件函數)
- 視圖(通常就是 render 函數或模板)
- 組件內部使用的幫助函數和緩存數據等
最后一個外部不需要,可以忽略。視圖的外部控制會在之后提到,暫時擱置。那么這里我們要考慮就只有數據和事件函數了。如果組件的數據能直接暴露給外部,甚至由外部控制,那么實現調試時數據的查看、狀態的回滾等功能就會很簡單。我們在寫組件是也經常發現一個現象:
組件內部的 state,通常都要提供一個同名的 prop 允許外部來控制。
因為用戶越多,需求也就越多,今天有人問屬性 a 能不能配置,明天有人問 b,最后一定會發展到幾乎所有能影響視圖的數據都可以由外部配置。
事件也是一樣,在寫組件的過程中也常會收到這樣的需求:能不能在組件XXX事件之后提供一個回調?能不能在之前提供一個回調?能不能提供參數阻止掉默認事件?問題的本質仍然一樣,場景越豐富,外部要求的控制也就越強,最后一定會發展到每一次視圖變化都得對外提供回調、都提供能阻止默認行為的情況。回想一下,這樣的情況是不是有點似曾相識?原生組件基本上就都是這樣的!想想 input 組件有多少事件就知道了。
對這兩個場景,可供選擇的解決方案有很多。小的方案可以是提供一些工具類,在聲明數據或者事件的時候使用工具類包裝一下。例如:
// 偽碼 class Com extends React.Component { constructor(props) { super() // takePropsAsDefault 負責檢查 props 上有沒有要外部傳入的覆寫的數據 this.state = takePropsAsDefault(props, {/*state 的定義*/}) // wrapWithCallback 負責在事件函數前后觸發回調 Object.assign(this, wrapWithCallback(props, {/*listener 的定義*/})) } }
大的方案可以是不直接創建組件,而只是將數據和事件聲明出來,由上層建築根據自己的需要使用統一的方法來創建組件。例如:
// 定義 const ComDef = { state: {/* state 的定義*/} onChange() {/* 事件函數代碼 */} } // 在外部使用時,使用統一的封裝函數封裝成組件 const Com = wrap(ComDel)
無論哪種方式,看起來都是簡化定義,但同時又能夠支持組件常見的行為。例如上例我們定義了 onChange。那么用戶在使用時應該能自動用類似以下這下方式傳入回調或阻止默認事件:
// 普通觸發 <Com onChange={() => {}} /> // 阻止默認 onChange 觸發,用數組表示有選項要傳入。當然也可以別的表示法 <Com onChange={[() => {}, true]} /> // 在默認 onChange 前觸發。這里用個數組第三個參數表示。 <Com onChange={[() => {}, false, true]} />
綜上,對外提供控制權的基本思路都是組件先只定義,然后統一經過二次包裝再變成組件。想想如果組件庫不統一這樣設計,而是每個組件、並且每個數據和事件函數都單獨支持這樣的能力,得多花費多少時間!
這里先記住這個結論,至於創建組件是在組件層還是外部,先不做決定,留下空間,因為還要考慮其他幾個層次的問題。
1.2 工程工具
工程工具通常指的文檔、示例、版本發布工具等。有的人會把測試也划入到工程工具中,我們前面已經提到,所以這里不再贅述。
工程工具遇到最主要的問題就是更新不同步,例如組件今天加了個新屬性,文檔忘了寫。這種情況還算好,如果是屬性刪了,文檔忘了更新那就會收到一大批 issue 了。所以稍微大點的工程,稍微有點追求的工程師,都會想做自動化。可能會使用 jsDoc 之類的工具,將注釋自動變成文檔等。
工程工具的核心也正是自動化。
示例,自動讀取的文檔:
示例,提交代碼時的文檔自動檢測:
那么自動化的前提是什么呢,或者說對組件層的要求是什么?如果我刪了一個屬性,工具要自動幫我刪掉相應的文檔,前提是不是工具必須知道我刪掉的“是一個屬性”,而不是任何其他無關的數據?怎么知道?簡單,創建組件時,屬性通常會以某種方式聲明出來。例如 React 中聲明的 propTypes。同理,如果今天刪掉的是一個回調呢?如果組件也以某種方式聲明函數式一個回調,那么當然就也能識別,就也能自動化。除了代碼中的聲明,用注解的方式也可以實現。總之就是要告訴外部,什么東西是干什么用的,並且告訴得越多越好。這里就引出了我們設計組件庫時最重要的一個概念:
組件元素的語義化。工程自動化的前提就是組件提供足夠多的語意。
我們繼續看實現中的問題。首先會注意到,現代的組件框架中,語意是不夠的,例如用戶聲明在組件上的一個方法,你怎么知道它是個工具方法?還是用來改變數據並且會引起重新渲染的?同樣,用戶傳入的函數,你是用來做某種判斷呢?還是用來做回調?這些語意不明確下來,工程工具就無法實現它的功能。
組件框架不設計這樣的區別是可以理解的,因為從它的角度來說,並不需要這樣的語意。需要這些語意的是更上層的建築。所以,我們的方案中需要有個組件的原始定義來保存住足夠多的語意。因此第一步的方案中,組件只做聲明,由外部來包裝這個方案更好。
雖然有了結論,但是到這里思考還沒有結束。語意的聲明是對每個數據、函數都再加個描述字段嗎?那這樣寫起來和 jsDoc 的注解沒有本質區別。這種方式和文檔的風險一樣,也會忘記寫,而且無感知。最好的開發體驗應該是一旦沒寫,就調試、運行不了,但同時又沒有增加開發者的負擔。滿足這個條件只有一種情況,就是聲明本身是組件的一部分。我們注意到組件中的屬性,通常都會有默認值。聲明默認值的過程,不就是聲明屬性的過程嗎?同樣,聲明事件函數的時候,如果不是直接把函數粗暴的暴露出來,而是放在一個指定的字段下,那么就也能輕松地辨識。所以,把組件定義寫成一個語意明確的鍵值對,不就解決了嗎:
const Com = { defaultState: {}, // 事件函數 defaultListeners: {}, // 攔截器 defaultIntercepters: {}, // ... }
再回頭想想第一個問題,上層框架要精確控制組件層,語意也是必不可少!要精確控制數據和事件函數,本身就需要先知道哪些函數是事件函數。
1.3 組件擴展
維護過組件庫的讀者會發現,有一類比例很大的需求很累人,就是增加配置項。例如,把 Table 的翻頁放在 Table 上面的,還有要求上下都要有的。還有要求給某個組件增加某些攔截器功能,在攔截器成功時就執行默認事件,否則不執行。組件的功能越多,用的場景越多,這樣的需求也就越多。並且最后的結果只有兩種,一是支持,加上了各種選項,組件配置越來越冗雜。二是不支持,請提需求的人自己改改源碼以滿足需求。
第二種情況下,站在改組件的人角度來看,又會發現新問題。有時源碼是用 ts 或者其他變種寫的,改起來很不習慣。通常組件庫內還有大量的內部約定或者公用代碼,要改動的話還得全盤熟悉。又或是打包發布時發現要改寫只能重發布一套組件庫,單獨發布組件還要大改發布的代碼。這種種限制,讓覆寫步步維艱。
其實增加配置項這類需求的本質就是覆寫,無論是改一點點樣式還是改一點點行為,都是覆寫。如果不想無休止地支持配置項,那么我們就該讓覆寫變得簡單一點。在前面的結論下,你會發現這個問題已經天然地被解決了。因為我的組件在開發階段只是定義,都還沒有被真正封裝成組件,你直接拿來覆蓋掉其中的一部分定義即可。並且無論組件原本元什么語言寫的,在你拿到的時候,仍然只是個標准的 js 對象,這樣就也不再存在工程問題。
import Com from './Com' export const Com2 = { ...Com, listeners: { ...Com.listeners, onChange() {/* 覆寫 onChange */} } }
那么到這里,方案看起來已經可以確定了?
等等,還有一個問題。就是視圖內部的覆寫。這個問題討論得比較少。
這個覆寫包括樣式的覆寫、內容的覆寫和功能的覆寫三種。目前業界樣式的覆寫基本上都是通過覆寫 css 實現的。雖然對 css 獨立還是 css-in-js 多有爭論,但實施上兩者並沒有很明顯的優劣,這里先不討論。
內容的覆寫指的是:“組件內的文案寫的太差,能不能動態換掉”?”icon 更不能換個更好看的“?”某一塊區域能不能高亮“?如果這些細節都要寫成配置由外部傳入,那組件開發將沒完沒了,毫無樂趣。但如果讓用戶像復寫邏輯一樣完全復寫 render,又太重,復雜的組件實施難度大。有沒有可能在框架層面天生提供這樣的能力?
當然可以。拿個場景來思考——我們想要替換掉某一部分的文案——先不論用什么方式,是不是必須先知道哪一塊展示的是文案?怎樣知道?法寶,語義化!是的,又是語義化。如果我能以種方式告訴外界視圖的某一部分是文案,再提供外界覆蓋的能力,那么就實現了。以 React 為例:
// 定義 const ComDef = { render({ wrappers }) { const { Text, Root } = wrappers return ( <Root> <Text>some text</Text> </Root> ) } }
// 使用 const Com = wrap(ComDef) const Root = ({children}) => <div style={{background: "red"}}>{children}</div> const Text = ({children}) => <div style={{color: "black"}}>{children}</div> ReactDom.render(( <Com Root={Root} Text={Text}/> ), node)
這個例子里面,我們可以通過外部配置得到無數種樣式的 Com 組件實例,但 Com 在定義時完全無感知!
一個 Card 組件,動態覆寫的效果示例:
有了這個方案,視圖覆寫的世界已經為你打開了一扇巨大的門。樣式的覆寫變得更簡單,我不再需要了解組件本身的實現方式,原組件到底是 css 還是 css-in-js 我都不管,我只需要關注我想要的就好,至於我怎么實現樣式也與原組件沒有沖突。再舉個例子,國際化,再次基礎上我們就有了更好的方案。過去的國際化通常都需要組件了解國際化工具的存在,並且形成約定,例如 react-intl。而現在通過框架統一的覆寫,組件與國際化工具完全解耦了。
再發揮一下想象力,我們剛剛還提到了功能的覆寫。這里有個典型場景:“可視化編輯中的組件拖拽功能”。拖拽對於普通的組件還好,容器類的組件是個麻煩。例如 Tabs。我要將子組件拖到 Tabs 中,那 Tabs 必須要實現 onDrop 事件我才能收到消息。而誰會在開發 Tabs 的時候就考慮拖拽的問題呢?所以很多可視化的工具的解決方案是:為這一類組件再單獨開發了一個長得一樣的替身,專門用於編輯時的拖拽。這種方法簡單,但是卻讓維護成本翻倍。一旦原組件改了,替身很可能也要修改。而如果用剛剛的方案,只要組件明確了標簽的語意,並且接受從外部傳入覆蓋,那么我們只要在傳入的組件中實現 onDrop 事件就行了。原組件不需要任何特殊支持。
提供視圖覆寫能力的意義在於,開發者不需要知道外部需求的細節,始終只維護一份組件源碼,就能自動支持海量的視圖需求!
2 方案
綜上,回顧問題域的三個部分,我們有了以下結論:
- 只聲明,不封裝,封裝交由外部處理。這樣上層能獲得最大的控制權。
- 聲明組件時保證足夠的語意,讓工程工具能夠更好理解組件。
一份完整的組件聲明如下圖所示:
export const defaultStateTypes = {/* state 的類型聲明 */} export const getDefaultState = () => ({/* 默認的 state */}) export function initialize() { // 返回一個對象,改對象將作為 instance 參數注入到所有函數中。可將 instance 作為數據緩存 return {} } export const defaultIntercepters = {/* 聲明外部傳入的函數類型的屬性 */} export const defaultListeners = { // 第一參數為外部框架注入。后面的參數即調用 listener 時傳入的參數。 onClick({ state, instance }, ...args) { // const changedStateValues = ... // changedStateValues 只包含變化了的 state 字段 return changedStateValues } } export defaultWrappers = { // 可由外部傳入的語義化的子組件 Text: 'span' } export const identifiers = { // 例如 Tabs 下的 TabPane。Input 的 Prefix 這種占位符式的組件需要在這里聲明 } export function render({state, children, instance, listeners, wrappers, intercepters}) { return <div></div> }
本質上,無論什么組件框架都能使用這套方案。甚至可以實現同一個組件聲明,由不同的引擎渲染。我們的團隊目前已經在多個項目中實踐這套組件規范,並提供了 React 版的工具倉庫,可以將組件定義封裝成單獨可用的組件。上面的 Card 覆蓋效果就是其中一個 React 實現的例子。這里可以在看一個組件只聲明 onChange,自動加上回調以及組織默認事件的功能。
然而,除去規范本身,我們更希望讀者關注到的是它為構建上層建築所提供的架構基礎,以及我們是如何從系統角度去考慮問題的呃。在做底層基礎設施建設時,一定不能只關注本身。石堅,塔方能通天。
3 答讀者問
構建龐大的上層建築不是和小而美的理念沖突了嗎?
我記得小而美的概念最早指的是 Linux 中的命令設計。然而讓我們絕大部分真正感到受益的卻是操作系統之上各種各樣的應用。所以上層建築與小而美並不沖突。上層建築指從跨層次的概念,是縱向的。小而美指的是在某一個層面的概念的設計上,是橫向的。應用就是操作系統的上層建築,即使實現很復雜,但設計很簡潔,功能專注,那么對用戶來說也是小而美的。另外,上層建築的意義在於,摩天大樓能提供給人的視野絕不是小平房能比的。小平房蓋得再多,也提供不了高樓帶來的風景。
這不是造輪子嗎?
在我們團隊實施這套方案的初期,確實也受到了“重復造輪子”的指責。我們的輪子大、重、耐高溫,很多人無法理解,但是當裝到飛機上,飛機起飛后,就沒有人再說話了。所以,在造輪子時首先要捫心自問一下,是為了工作績效、名聲、還是更遠大的理想?如果是遠大理想就一定要堅持。同樣,在指責別人造輪子的時候,也好好思考下,別人到底是浪費人力、不懂合作,還是自己的技術視野高度不如別人。畢竟夏蟲不可語冰,可悲的是蟲。
這個方案看起來就是換了種組件的寫法,好像沒什么特殊的?
它的本身當然沒有什么特殊的。特殊的是組件的寫法可以有無窮多種,我們為什么使用了這一種。我們想用它干什么。請關注它的上層建築。上一篇文章介紹的可視化搭建系統就是基於這樣的規范:頁面搭建工具的死與生。基於這套規范的應用框架和測試框架我們也會在近期開源。
將數據和事件函數都暴露到全局,不是破壞了封裝的原則嗎?
從另一個角度來說,這套方案與其說是“組件規范”,其實不如說是“組件層與應用框架層的接口規范”更為合適。如果在系統中真的有“影響視圖,但外界絕對不可能需要的數據”。那么我們仍然可以先封裝出一個標准的、原子的 React 組件,將這些數據包裹住。再在外層包裝成 lego 組件。
方案中好像沒有描述公共模塊、構建等內容?
因為這類的內容通常與具體的組件引擎相關,並且社區內基本都有成熟的案例參考。因此不在文中贅述。
寫文章是不是為了招聘?
是的!我們正在做可視化的系統搭建平台,具體可以參見我上一篇文章。感興趣的同學可以發簡歷到 ariesate@outlook.com :)