通天塔之石——企業級前端元件庫方案

發表於2017-06-14

通天塔之石——企業級前端元件庫方案

元件庫是前端大規模開發中提升效率的重要一環,同時也是視覺化頁面搭建、自動化測試等上層建築的基石。因此設計時要考慮的問題涵蓋面非常廣。要設計好非常難,但是設計好之後從上層建築帶來的回報會超過你的想象。

這篇文章中我們先一起來關注和探討元件庫要解決的問題,最後會推匯出一套足夠靈活——適用於大團隊或社群使用,又足夠強大——能支撐起上層建築的元件庫方案。也請讀者注意,結論其實很簡單,文中思考過程才是重點。知道結論並能讓你一躍成為架構師,但知道了如何從系統角度設計區域性卻讓你有機會可以。共勉。

1. 問題域

要理清問題域,我們先要了解元件庫在架構層面處於哪個位置,它都與哪些其他部分有關係,一圖蔽之:

可以看出問題域大體可分為三部分:

一,產生於應用框架等上層建築。例如應用框架可能希望能控制元件的所有狀態,監聽所有事件,以便能提供完整的回滾等功能給使用者。精確到元件資料的測試框架的示例:

二,產生於工程工具。希望得到更多元件內部的資訊。對屬性的自動讀取示例:

三,產生於元件的需求本身。這裡涉及到理想的應用框架中提到的兩個問題:有需求希望元件的邏輯不變,展示稍微變一下怎麼辦?或者前後兩者反過來怎麼辦?

接下來再細化每個部分的問題:

1.1 上層建築

任何底層方案設計時首先要關注的就是上層建築。上層建築是回報的來源,能承載的上層建築越多,回報越大。但是同時,高樓帶給底層的壓力和挑戰也是巨大的。例如從我們上面所舉的例子——測試框架通過對元件所有屬性變化的監聽來實現資料對比或者回滾——現在並沒有哪一個元件引擎天然很好地支援了這樣的能力。即使是類似 react 的除錯工具中顯示的狀態也是利用了引擎的特殊支援。

如果引擎不能提供,或者要 hack 才能實現,那建立上層建築的壓力和風險就太大。對這種需求,很多人可能想到這裡就放棄了。為什麼一定要提供元件級別的狀態回滾這樣的功能呢,以前也沒有人這樣幹過啊?我們還是先蓋個平房吧。

這種想法很可悲,一是認識不到上層建築的價值,二是不能正確剖析問題。其實很多時候只要再邁一步,想想它的本質,解法就躍然紙上了。

對上層的應用框架、除錯工具、測試工具來說,他們功能的本身,就是對元件的控制或資訊展示,所以它們要求完全控制元件的構成成分的是合理的。就像木偶身上的線越多,能控制的動作就越精細。要提供構成成分的控制權,我們先理清楚元件的構成成分有哪些:

  • 用於驅動檢視的資料
  • 改變資料的方法(事件函式)
  • 檢視(通常就是 render 函式或模板)
  • 元件內部使用的幫助函式和快取資料等

最後一個外部不需要,可以忽略。檢視的外部控制會在之後提到,暫時擱置。那麼這裡我們要考慮就只有資料和事件函式了。如果元件的資料能直接暴露給外部,甚至由外部控制,那麼實現除錯時資料的檢視、狀態的回滾等功能就會很簡單。我們在寫元件是也經常發現一個現象:

元件內部的 state,通常都要提供一個同名的 prop 允許外部來控制。

因為使用者越多,需求也就越多,今天有人問屬性 a 能不能配置,明天有人問 b,最後一定會發展到幾乎所有能影響檢視的資料都可以由外部配置。

事件也是一樣,在寫元件的過程中也常會收到這樣的需求:能不能在元件XXX事件之後提供一個回撥?能不能在之前提供一個回撥?能不能提供引數阻止掉預設事件?問題的本質仍然一樣,場景越豐富,外部要求的控制也就越強,最後一定會發展到每一次檢視變化都得對外提供回撥、都提供能阻止預設行為的情況。回想一下,這樣的情況是不是有點似曾相識?原生元件基本上就都是這樣的!想想 input 元件有多少事件就知道了。

對這兩個場景,可供選擇的解決方案有很多。小的方案可以是提供一些工具類,在宣告資料或者事件的時候使用工具類包裝一下。例如:

大的方案可以是不直接建立元件,而只是將資料和事件宣告出來,由上層建築根據自己的需要使用統一的方法來建立元件。例如:

無論哪種方式,看起來都是簡化定義,但同時又能夠支援元件常見的行為。例如上例我們定義了 onChange。那麼使用者在使用時應該能自動用類似以下這下方式傳入回撥或阻止預設事件:

綜上,對外提供控制權的基本思路都是元件先只定義,然後統一經過二次包裝再變成元件。想想如果元件庫不統一這樣設計,而是每個元件、並且每個資料和事件函式都單獨支援這樣的能力,得多花費多少時間!

這裡先記住這個結論,至於建立元件是在元件層還是外部,先不做決定,留下空間,因為還要考慮其他幾個層次的問題。

1.2 工程工具

工程工具通常指的文件、示例、版本釋出工具等。有的人會把測試也劃入到工程工具中,我們前面已經提到,所以這裡不再贅述。

工程工具遇到最主要的問題就是更新不同步,例如元件今天加了個新屬性,文件忘了寫。這種情況還算好,如果是屬性刪了,文件忘了更新那就會收到一大批 issue 了。所以稍微大點的工程,稍微有點追求的工程師,都會想做自動化。可能會使用 jsDoc 之類的工具,將註釋自動變成文件等。

工程工具的核心也正是自動化。

示例,自動讀取的文件:

示例,提交程式碼時的文件自動檢測:

那麼自動化的前提是什麼呢,或者說對元件層的要求是什麼?如果我刪了一個屬性,工具要自動幫我刪掉相應的文件,前提是不是工具必須知道我刪掉的“是一個屬性”,而不是任何其他無關的資料?怎麼知道?簡單,建立元件時,屬性通常會以某種方式宣告出來。例如 React 中宣告的 propTypes。同理,如果今天刪掉的是一個回撥呢?如果元件也以某種方式宣告函式式一個回撥,那麼當然就也能識別,就也能自動化。除了程式碼中的宣告,用註解的方式也可以實現。總之就是要告訴外部,什麼東西是幹什麼用的,並且告訴得越多越好。這裡就引出了我們設計元件庫時最重要的一個概念:

元件元素的語義化。工程自動化的前提就是元件提供足夠多的語意。

我們繼續看實現中的問題。首先會注意到,現代的元件框架中,語意是不夠的,例如使用者宣告在元件上的一個方法,你怎麼知道它是個工具方法?還是用來改變資料並且會引起重新渲染的?同樣,使用者傳入的函式,你是用來做某種判斷呢?還是用來做回撥?這些語意不明確下來,工程工具就無法實現它的功能。

元件框架不設計這樣的區別是可以理解的,因為從它的角度來說,並不需要這樣的語意。需要這些語意的是更上層的建築。所以,我們的方案中需要有個元件的原始定義來儲存住足夠多的語意。因此第一步的方案中,元件只做宣告,由外部來包裝這個方案更好。

雖然有了結論,但是到這裡思考還沒有結束。語意的宣告是對每個資料、函式都再加個描述欄位嗎?那這樣寫起來和 jsDoc 的註解沒有本質區別。這種方式和文件的風險一樣,也會忘記寫,而且無感知。最好的開發體驗應該是一旦沒寫,就除錯、執行不了,但同時又沒有增加開發者的負擔。滿足這個條件只有一種情況,就是宣告本身是元件的一部分。我們注意到元件中的屬性,通常都會有預設值。宣告預設值的過程,不就是宣告屬性的過程嗎?同樣,宣告事件函式的時候,如果不是直接把函式粗暴的暴露出來,而是放在一個指定的欄位下,那麼就也能輕鬆地辨識。所以,把元件定義寫成一個語意明確的鍵值對,不就解決了嗎:

再回頭想想第一個問題,上層框架要精確控制元件層,語意也是必不可少!要精確控制資料和事件函式,本身就需要先知道哪些函式是事件函式。

1.3 元件擴充套件

維護過元件庫的讀者會發現,有一類比例很大的需求很累人,就是增加配置項。例如,把 Table 的翻頁放在 Table 上面的,還有要求上下都要有的。還有要求給某個元件增加某些攔截器功能,在攔截器成功時就執行預設事件,否則不執行。元件的功能越多,用的場景越多,這樣的需求也就越多。並且最後的結果只有兩種,一是支援,加上了各種選項,元件配置越來越冗雜。二是不支援,請提需求的人自己改改原始碼以滿足需求。

第二種情況下,站在改元件的人角度來看,又會發現新問題。有時原始碼是用 ts 或者其他變種寫的,改起來很不習慣。通常元件庫內還有大量的內部約定或者公用程式碼,要改動的話還得全盤熟悉。又或是打包釋出時發現要改寫只能重發布一套元件庫,單獨釋出元件還要大改釋出的程式碼。這種種限制,讓覆寫步步維艱。

其實增加配置項這類需求的本質就是覆寫,無論是改一點點樣式還是改一點點行為,都是覆寫。如果不想無休止地支援配置項,那麼我們就該讓覆寫變得簡單一點。在前面的結論下,你會發現這個問題已經天然地被解決了。因為我的元件在開發階段只是定義,都還沒有被真正封裝成元件,你直接拿來覆蓋掉其中的一部分定義即可。並且無論元件原本元什麼語言寫的,在你拿到的時候,仍然只是個標準的 js 物件,這樣就也不再存在工程問題。

那麼到這裡,方案看起來已經可以確定了?

等等,還有一個問題。就是檢視內部的覆寫。這個問題討論得比較少。

這個覆寫包括樣式的覆寫、內容的覆寫和功能的覆寫三種。目前業界樣式的覆寫基本上都是通過覆寫 css 實現的。雖然對 css 獨立還是 css-in-js 多有爭論,但實施上兩者並沒有很明顯的優劣,這裡先不討論。

內容的覆寫指的是:“元件內的文案寫的太差,能不能動態換掉”?”icon 更不能換個更好看的“?”某一塊區域能不能高亮“?如果這些細節都要寫成配置由外部傳入,那元件開發將沒完沒了,毫無樂趣。但如果讓使用者像複寫邏輯一樣完全複寫 render,又太重,複雜的元件實施難度大。有沒有可能在框架層面天生提供這樣的能力?

當然可以。拿個場景來思考——我們想要替換掉某一部分的文案——先不論用什麼方式,是不是必須先知道哪一塊展示的是文案?怎樣知道?法寶,語義化!是的,又是語義化。如果我能以種方式告訴外界檢視的某一部分是文案,再提供外界覆蓋的能力,那麼就實現了。以 React 為例:

這個例子裡面,我們可以通過外部配置得到無數種樣式的 Com 元件例項,但 Com 在定義時完全無感知!

一個 Card 元件,動態覆寫的效果示例:

有了這個方案,檢視覆寫的世界已經為你開啟了一扇巨大的門。樣式的覆寫變得更簡單,我不再需要了解元件本身的實現方式,原元件到底是 css 還是 css-in-js 我都不管,我只需要關注我想要的就好,至於我怎麼實現樣式也與原元件沒有衝突。再舉個例子,國際化,再次基礎上我們就有了更好的方案。過去的國際化通常都需要元件瞭解國際化工具的存在,並且形成約定,例如 react-intl。而現在通過框架統一的覆寫,元件與國際化工具完全解耦了。

再發揮一下想象力,我們剛剛還提到了功能的覆寫。這裡有個典型場景:“視覺化編輯中的元件拖拽功能”。拖拽對於普通的元件還好,容器類的元件是個麻煩。例如 Tabs。我要將子元件拖到 Tabs 中,那 Tabs 必須要實現 onDrop 事件我才能收到訊息。而誰會在開發 Tabs 的時候就考慮拖拽的問題呢?所以很多視覺化的工具的解決方案是:為這一類元件再單獨開發了一個長得一樣的替身,專門用於編輯時的拖拽。這種方法簡單,但是卻讓維護成本翻倍。一旦原元件改了,替身很可能也要修改。而如果用剛剛的方案,只要元件明確了標籤的語意,並且接受從外部傳入覆蓋,那麼我們只要在傳入的元件中實現 onDrop 事件就行了。原元件不需要任何特殊支援。

提供檢視覆寫能力的意義在於,開發者不需要知道外部需求的細節,始終只維護一份元件原始碼,就能自動支援海量的檢視需求!

2 方案

綜上,回顧問題域的三個部分,我們有了以下結論:

  • 只宣告,不封裝,封裝交由外部處理。這樣上層能獲得最大的控制權。
  • 宣告元件時保證足夠的語意,讓工程工具能夠更好理解元件。

一份完整的元件宣告如下圖所示:

本質上,無論什麼元件框架都能使用這套方案。甚至可以實現同一個元件宣告,由不同的引擎渲染。我們的團隊目前已經在多個專案中實踐這套元件規範,並提供了 React 版的工具倉庫,可以將元件定義封裝成單獨可用的元件。上面的 Card 覆蓋效果就是其中一個 React 實現的例子。這裡可以在看一個元件只宣告 onChange,自動加上回撥以及組織預設事件的功能。

然而,除去規範本身,我們更希望讀者關注到的是它為構建上層建築所提供的架構基礎,以及我們是如何從系統角度去考慮問題的呃。在做底層基礎設施建設時,一定不能只關注本身。石堅,塔方能通天。

3 答讀者問

構建龐大的上層建築不是和小而美的理念衝突了嗎?

我記得小而美的概念最早指的是 Linux 中的命令設計。然而讓我們絕大部分真正感到受益的卻是作業系統之上各種各樣的應用。所以上層建築與小而美並不衝突。上層建築指從跨層次的概念,是縱向的。小而美指的是在某一個層面的概念的設計上,是橫向的。應用就是作業系統的上層建築,即使實現很複雜,但設計很簡潔,功能專注,那麼對使用者來說也是小而美的。另外,上層建築的意義在於,摩天大樓能提供給人的視野絕不是小平房能比的。小平房蓋得再多,也提供不了高樓帶來的風景。

這不是造輪子嗎?

在我們團隊實施這套方案的初期,確實也受到了“重複造輪子”的指責。我們的輪子大、重、耐高溫,很多人無法理解,但是當裝到飛機上,飛機起飛後,就沒有人再說話了。所以,在造輪子時首先要捫心自問一下,是為了工作績效、名聲、還是更遠大的理想?如果是遠大理想就一定要堅持。同樣,在指責別人造輪子的時候,也好好思考下,別人到底是浪費人力、不懂合作,還是自己的技術視野高度不如別人。畢竟夏蟲不可語冰,可悲的是蟲。

這個方案看起來就是換了種元件的寫法,好像沒什麼特殊的?

它的本身當然沒有什麼特殊的。特殊的是元件的寫法可以有無窮多種,我們為什麼使用了這一種。我們想用它幹什麼。請關注它的上層建築。上一篇文章介紹的視覺化搭建系統就是基於這樣的規範:頁面搭建工具的死與生。基於這套規範的應用框架和測試框架我們也會在近期開源。

將資料和事件函式都暴露到全域性,不是破壞了封裝的原則嗎?

封裝的目的之一是為了將“與外界不相關”的資訊或者邏輯隱藏起來。那麼什麼是不相關的資訊呢?或者反過來問,什麼是與外界相關的資訊?回想一下上文中曾提到的關於需求的例子,為什麼會有人不斷提出要求說這個屬性要暴露那個屬性要暴露呢?因為這些屬性都是會影響檢視的屬性,都是檢視的一種狀態。而任何一種檢視狀態,都可能產生需求。例如可能有需求要查詢 collapse 是否是開啟的狀態,因此要暴露表示開啟的屬性。也可能有需求要查詢Tab 的當前選中項,因此要暴露。我們在規範中向外暴露的資料都是會影響檢視的,因此都是“相關的”,與封裝不矛盾。

從另一個角度來說,這套方案與其說是“元件規範”,其實不如說是“元件層與應用框架層的介面規範”更為合適。如果在系統中真的有“影響檢視,但外界絕對不可能需要的資料”。那麼我們仍然可以先封裝出一個標準的、原子的 React 元件,將這些資料包裹住。再在外層包裝成 lego 元件。

方案中好像沒有描述公共模組、構建等內容?

因為這類的內容通常與具體的元件引擎相關,並且社群內基本都有成熟的案例參考。因此不在文中贅述。

相關文章