組件庫是前端大規模開發中提高效率的重要一環,同時也是可視化頁面搭建、自動化測試等上層建築的基石。所以設計時要考慮的問題涵蓋面很是廣。要設計好很是難,可是設計好以後從上層建築帶來的回報會超過你的想象。css
這篇文章中咱們先一塊兒來關注和探討組件庫要解決的問題,最後會推導出一套足夠靈活——適用於大團隊或社區使用,又足夠強大——能支撐起上層建築的組件庫方案。也請讀者注意,結論其實很簡單,文中思考過程纔是重點。知道結論並能讓你一躍成爲架構師,但知道了如何從系統角度設計局部卻讓你有機會能夠。共勉。html
要理清問題域,咱們先要了解組件庫在架構層面處於哪一個位置,它都與哪些其餘部分有關係,一圖蔽之:前端
能夠看出問題域大致可分爲三部分:node
一,產生於應用框架等上層建築。例如應用框架可能但願能控制組件的全部狀態,監聽全部事件,以便能提供完整的回滾等功能給用戶。精確到組件數據的測試框架的示例:react
二,產生於工程工具。但願獲得更多組件內部的信息。對屬性的自動讀取示例:git
三,產生於組件的需求自己。這裏涉及到理想的應用框架中提到的兩個問題:有需求但願組件的邏輯不變,展現稍微變一下怎麼辦?或者先後二者反過來怎麼辦?github
接下來再細化每一個部分的問題:數組
任何底層方案設計時首先要關注的就是上層建築。上層建築是回報的來源,能承載的上層建築越多,回報越大。可是同時,高樓帶給底層的壓力和挑戰也是巨大的。例如從咱們上面所舉的例子——測試框架經過對組件全部屬性變化的監聽來實現數據對比或者回滾——如今並無哪個組件引擎自然很好地支持了這樣的能力。即便是相似 react 的調試工具中顯示的狀態也是利用了引擎的特殊支持。緩存
若是引擎不能提供,或者要 hack 才能實現,那創建上層建築的壓力和風險就太大。對這種需求,不少人可能想到這裏就放棄了。爲何必定要提供組件級別的狀態回滾這樣的功能呢,之前也沒有人這樣幹過啊?咱們仍是先蓋個平房吧。架構
這種想法很可悲,一是認識不到上層建築的價值,二是不能正確剖析問題。其實不少時候只要再邁一步,想一想它的本質,解法就躍然紙上了。
對上層的應用框架、調試工具、測試工具來講,他們功能的自己,就是對組件的控制或信息展現,因此它們要求徹底控制組件的構成成分的是合理的。就像木偶身上的線越多,能控制的動做就越精細。要提供構成成分的控制權,咱們先理清楚組件的構成成分有哪些:
最後一個外部不須要,能夠忽略。視圖的外部控制會在以後提到,暫時擱置。那麼這裏咱們要考慮就只有數據和事件函數了。若是組件的數據能直接暴露給外部,甚至由外部控制,那麼實現調試時數據的查看、狀態的回滾等功能就會很簡單。咱們在寫組件是也常常發現一個現象:
組件內部的 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]} />
綜上,對外提供控制權的基本思路都是組件先只定義,而後統一通過二次包裝再變成組件。想一想若是組件庫不統一這樣設計,而是每一個組件、而且每一個數據和事件函數都單獨支持這樣的能力,得多花費多少時間!
這裏先記住這個結論,至於建立組件是在組件層仍是外部,先不作決定,留下空間,由於還要考慮其餘幾個層次的問題。
工程工具一般指的文檔、示例、版本發佈工具等。有的人會把測試也劃入到工程工具中,咱們前面已經提到,因此這裏再也不贅述。
工程工具遇到最主要的問題就是更新不一樣步,例如組件今天加了個新屬性,文檔忘了寫。這種狀況還算好,若是是屬性刪了,文檔忘了更新那就會收到一大批 issue 了。因此稍微大點的工程,稍微有點追求的工程師,都會想作自動化。可能會使用 jsDoc 之類的工具,將註釋自動變成文檔等。
工程工具的核心也正是自動化。
示例,自動讀取的文檔:
示例,提交代碼時的文檔自動檢測:
那麼自動化的前提是什麼呢,或者說對組件層的要求是什麼?若是我刪了一個屬性,工具要自動幫我刪掉相應的文檔,前提是否是工具必須知道我刪掉的「是一個屬性」,而不是任何其餘無關的數據?怎麼知道?簡單,建立組件時,屬性一般會以某種方式聲明出來。例如 React 中聲明的 propTypes。同理,若是今天刪掉的是一個回調呢?若是組件也以某種方式聲明函數式一個回調,那麼固然就也能識別,就也能自動化。除了代碼中的聲明,用註解的方式也能夠實現。總之就是要告訴外部,什麼東西是幹什麼用的,而且告訴得越多越好。這裏就引出了咱們設計組件庫時最重要的一個概念:
組件元素的語義化。工程自動化的前提就是組件提供足夠多的語意。
咱們繼續看實現中的問題。首先會注意到,現代的組件框架中,語意是不夠的,例如用戶聲明在組件上的一個方法,你怎麼知道它是個工具方法?仍是用來改變數據而且會引發從新渲染的?一樣,用戶傳入的函數,你是用來作某種判斷呢?仍是用來作回調?這些語意不明確下來,工程工具就沒法實現它的功能。
組件框架不設計這樣的區別是能夠理解的,由於從它的角度來講,並不須要這樣的語意。須要這些語意的是更上層的建築。因此,咱們的方案中須要有個組件的原始定義來保存住足夠多的語意。所以第一步的方案中,組件只作聲明,由外部來包裝這個方案更好。
雖然有告終論,可是到這裏思考尚未結束。語意的聲明是對每一個數據、函數都再加個描述字段嗎?那這樣寫起來和 jsDoc 的註解沒有本質區別。這種方式和文檔的風險同樣,也會忘記寫,並且無感知。最好的開發體驗應該是一旦沒寫,就調試、運行不了,但同時又沒有增長開發者的負擔。知足這個條件只有一種狀況,就是聲明自己是組件的一部分。咱們注意到組件中的屬性,一般都會有默認值。聲明默認值的過程,不就是聲明屬性的過程嗎?一樣,聲明事件函數的時候,若是不是直接把函數粗暴的暴露出來,而是放在一個指定的字段下,那麼就也能輕鬆地辨識。因此,把組件定義寫成一個語意明確的鍵值對,不就解決了嗎:
const Com = { defaultState: {}, // 事件函數 defaultListeners: {}, // 攔截器 defaultIntercepters: {}, // ... }
再回頭想一想第一個問題,上層框架要精確控制組件層,語意也是必不可少!要精確控制數據和事件函數,自己就須要先知道哪些函數是事件函數。
維護過組件庫的讀者會發現,有一類比例很大的需求很累人,就是增長配置項。例如,把 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 事件就好了。原組件不須要任何特殊支持。
提供視圖覆寫能力的意義在於,開發者不須要知道外部需求的細節,始終只維護一份組件源碼,就能自動支持海量的視圖需求!
綜上,回顧問題域的三個部分,咱們有了如下結論:
一份完整的組件聲明以下圖所示:
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,自動加上回調以及組織默認事件的功能。
然而,除去規範自己,咱們更但願讀者關注到的是它爲構建上層建築所提供的架構基礎,以及咱們是如何從系統角度去考慮問題的呃。在作底層基礎設施建設時,必定不能只關注自己。石堅,塔方能通天。
我記得小而美的概念最先指的是 Linux 中的命令設計。然而讓咱們絕大部分真正感到受益的倒是操做系統之上各類各樣的應用。因此上層建築與小而美並不衝突。上層建築指從跨層次的概念,是縱向的。小而美指的是在某一個層面的概念的設計上,是橫向的。應用就是操做系統的上層建築,即便實現很複雜,但設計很簡潔,功能專一,那麼對用戶來講也是小而美的。另外,上層建築的意義在於,摩天大樓能提供給人的視野毫不是小平房能比的。小平房蓋得再多,也提供不了高樓帶來的風景。
在咱們團隊實施這套方案的初期,確實也受到了「重複造輪子」的指責。咱們的輪子大、重、耐高溫,不少人沒法理解,可是當裝到飛機上,飛機起飛後,就沒有人再說話了。因此,在造輪子時首先要捫心自問一下,是爲了工做績效、名聲、仍是更遠大的理想?若是是遠大理想就必定要堅持。一樣,在指責別人造輪子的時候,也好好思考下,別人究竟是浪費人力、不懂合做,仍是本身的技術視野高度不如別人。畢竟夏蟲不可語冰,可悲的是蟲。
它的自己固然沒有什麼特殊的。特殊的是組件的寫法能夠有無窮多種,咱們爲何使用了這一種。咱們想用它幹什麼。請關注它的上層建築。上一篇文章介紹的可視化搭建系統就是基於這樣的規範:頁面搭建工具的死與生。基於這套規範的應用框架和測試框架咱們也會在近期開源。
從另外一個角度來講,這套方案與其說是「組件規範」,其實不如說是「組件層與應用框架層的接口規範」更爲合適。若是在系統中真的有「影響視圖,但外界絕對不可能須要的數據」。那麼咱們仍然能夠先封裝出一個標準的、原子的 React 組件,將這些數據包裹住。再在外層包裝成 lego 組件。
由於這類的內容一般與具體的組件引擎相關,而且社區內基本都有成熟的案例參考。所以不在文中贅述。
是的!咱們正在作可視化的系統搭建平臺,具體能夠參見我上一篇文章。感興趣的同窗能夠發簡歷到 ariesate@outlook.com :)