組件庫設計實戰 - 組件分類、文檔管理與打包發布

在上篇《重新設計 React 組件庫》中我們從宏觀層面一起討論了構成自由且數據解耦的 React 組件庫應該如何設計,在本文中我們將從實踐的角度來和大家一起探討如何將這樣的設計落地。

組件庫架構

組件分類

在傳統的組件庫設計中,組件分類一直都不是一個必選項,大多數人都認為一個組件究竟是屬於組件類還是控制項類,不過是名字上的不同而已,並沒有實際的意義。但在將組件代碼寫法區分為純函數與 ES6 class 兩種之後,我們發現組件的寫法同時也代表了組件的類型,這時我們也就可以給予不同組件一個更清晰的定義,分別是:

  • 不含有內部狀態的以純函數寫法表示的無交互的純渲染組件

  • 含有內部狀態以 ES6 class 寫法表示的有交互的智能控制項

在進行了這樣清晰的分類之後,每當我們需要新增一個組件時,我們都可以從是否含有內部狀態、是否有交互等幾個方面來將其歸入組件或控制項,並以此來確認其相應的代碼規範。

延伸來說,除了基礎的組件與控制項的分別之外,我們還推薦大家從業務的角度出發再劃分出一種組件類型,叫做容器。

舉例來說,在 Material Design 大行其道的今天,應該不會有人對卡片這樣一種基礎的內容展示形式感到陌生,對應到前端組件庫中,作為內容的骨架,卡片本身應當是一個純渲染組件,但在我們代入具體的業務場景後就會發現,卡片本身其實是有狀態的,最常見的如數據載入中、數據為空、數據錯誤等。這樣一個無交互但含有自身狀態的組件無論歸於上述的哪個分類都會讓人感到奇怪,所以我們又引入了容器這樣一個新的分類,專門用來存放卡片這類組件。看到這裡,相信聰明的你應該能體會到組件分類的真正意義了,那就是用組件分類這樣一種形式來強迫工程師去思考每一個組件的本質,然後再利用 pure render 等方法去優化組件性能。作為離用戶最近的一批工程師,前端工程師所應該關心的,除了代碼本身之外,用戶體驗、人機交互等領域方面的經驗與知識,也是判斷一位前端工程師是否優秀的一把標尺。

另一方面來講,我們又可以從卡片這樣一個容器組件延伸出強依賴數據的組件應該如何設計這樣一個更加抽象的問題。從組件庫設計的角度來講,正如上一篇文章中所提到的,我們是不建議將數據獲取等邏輯放在組件里去做的,但結合業務場景來說,統一數據獲取等邏輯確實是提升業務開發效率的不二選擇,這方面的具體實踐大家可以參考@瓊玖之前的文章《React實踐 - Component Generator》。

回到代碼本身,拋開純函數組件不談,我們這裡再來討論一個編寫智能組件時經常會踩到的坑。

在 React 的生命周期函數中,有一個功能十分強大的函數,那就是 componentWillReceiveProps,在這個函數中,我們既可以拿到 this.props 又可以拿到 nextProps,所以從理論上來講,我們可以在這裡利用這些數據對組件做任何邏輯上的變更。另一方面,智能組件一般需要支持木偶與智能兩種調用方式,以方便使用者在使用時根據是否需要在業務代碼中保存組件狀態使用。木偶組件標配的 props 一般為 value 加一個回調函數 onChange,這時組件本身就只負責根據接收到的 props 來進行渲染。而智能組件的標配 props 一般就只有一個 defaultValue,也就是外部只負責定義組件的初始狀態,接下來組件自己會根據交互來改變內部狀態。這時很多「偷懶」的開發者就會想到,我能不能這樣來寫代碼呢?

constructor(props) { super(props); this.state = { value: props.defaultValue, };}componentWillReceiveProps(nextProps) { if (this.props.defaultValue !== nextProps.defaultValue) { this.setState({ value: defaultValue, }); }}handleChange(value) { this.setState({ value, }); this.props.onChange(value);}render() { const { value } = this.state; return <input value={value} onChange={::this.handleChange} />;}

這裡只用了 defaultValue 一個 props,就「支持」了組件木偶與智能兩種調用方式,也就是說外部傳過來的 defaultValue 如果是一個常量,它就表現得像智能組件一樣,如果外部是一個變數,他每次也會去更新自己內部的 state,表現得像木偶組件一樣。

這樣一個看似機智的處理方式,其實違反了 React 一個非常根本的設計原則,那就是單一數據源。如果外部傳給 defaultValue 的值是一個變數的話,相當於這個值會在外部與組件內部同時保存兩份,只不過是通過 componentWillReceiveProps 來強行保證了兩邊值相同,而 componentWillReceiveProps 也不過只能保證外部的值改變了內部的值會跟著改變,卻不能夠保證內部 setState 過後,外部也會更新(這個是由外部的 onChange 方法來保證的,組件本身無能為力)。而且在雙方互相通信的過程中,其實很多步驟是冗餘的,極大地降低了組件本身的性能。

我們來看一下正確的寫法:

constructor(props) { super(props); this.state = { innerValue: props.defaultValue, };}handleChange(value) { if (this.props.value) { this.props.onChange(value); } else { this.setState({ innerValue: value, }); }}render() { const { value } = this.props; const { innerValue } = this.state; return <input value={value || innerValue} onChange={::this.handleChange} />;}

value 與 onChange 永遠配套使用,而 defaultValue 只在初始化組件狀態時使用,渲染時優先使用 props 中的 value,若沒有再使用 state 中的 value。只有這樣才能夠保證組件數據流的清晰,減少使用者在使用時出錯的幾率,讓組件本身的調試工作變得更加簡單。

文檔管理

編寫組件庫本身並不是我們的最終目的,讓更多的人在業務開發中使用起來才是,所以一個組件庫的文檔是否足夠清晰、完善,也是決定一個組件庫成敗的關鍵。

什麼樣的文檔是好的?我想它起碼應該達到以下兩個要求:

  • 屬性全覆蓋
    • 屬性名、具體描述、數據類型、是否有默認值、是否必須等
  • 示例豐富

屬性全覆蓋的重要性在這裡就不贅述了,因為使用者在不閱讀源碼的前提下想要了解組件的所有功能,除了閱讀組件文檔外就沒有其他的方法了,大部分的組件庫也都可以做到這一點,但示例豐富,就是體現組件庫開發者良心之處的地方了。

是的,編寫大量的示例非常耗時,但由於 React 組件本身是高度可定製的,如果開發者不能夠提供具體的示例,使用者在使用組件進行一個複雜業務開發時就將因為缺少指導而變得異常痛苦。另一方面,豐富的示例也是對組件單元測試的一次具象,在未來維護組件增加新功能時,就會體現出示例豐富的好處了。當你增加了一些邏輯,而原先所有的示例都仍可以完美運行時,你應該會更有信心地向同事們安利這個新功能吧。

打包發布

作為業務項目的基礎依賴,組件庫一般都需要打包發布至 npm 以方便業務項目使用,與普通 npm 包不同的是,組件庫一般都需要在入口文件中使用 module.exports 的方式將組件一次性暴露出去。

import components from "./components";import controls from "./controls";import containers from "./containers";module.exports = { ...components, ...controls, ...containers,};

這裡需要注意的一點是,如果使用 module.exports 的寫法的話,相應地需要在 webpack 中配置 output 中的 libraryTarget 為 commonjs2,否則外部將引用不到相應的組件。

output: { path: path.resolve(__dirname, "build"), filename: "[name].js", library: "xxx", libraryTarget: "commonjs2",}

相較於將整個組件庫打包成一個文件,將每個組件單獨打包為一個個獨立的 npm 包也不失為一種很好的打包方式。對於業務項目來說,使用單獨組件的 npm 包,可以減小最終線上代碼的大小,提升頁面載入速度。另一方面,我們在維護老的業務項目時,很可能這個項目本身已經引用了一套其他的組件庫,而新的需求又需要使用屬於新開發的組件庫的某個組件開發。這時如果新開發的組件庫能夠支持單個組件獨立引用,我們就可以以最小的成本接入某幾個組件並實現需求了。

將所有的組件單獨打包聽上去並不是一件難事,我們只需要給每個組件都配一個相應的 package.json 和 webpack.config.js 就可以了,但事實卻遠比想像中要複雜。首先,我們不希望每個組件都是一個獨立的 git repo,因為這樣非常不利於代碼管理,也會造成大量公用代碼的冗餘。所以我們需要將所有的組件都放在一個 git repo 中做統一管理,且使用 npm link 的方式保證組件之間相互引用時使用的都是對方最新的代碼。

這裡推薦一個專註於做 package split 的工具 Lerna。

在使用了 Lerna 後,項目的基本文件結構會有一定的改變,如下圖:

my-lerna-repo/ package.json packages/ package-1/ package.json package-2/ package.json

所有的組件都需要放在 packages 目錄下,這樣我們在使用

lerna bootstrap

命令後 lerna 將自動根據每個包的 package.json 安裝其所需要的依賴並將 packages 目錄下已有的包進行 symlinks。

這樣我們就優雅地完成了 js 文件之間的依賴管理,但沒有依賴管理工具的 css 仍然是一個令人討厭的問題。

這裡筆者提供兩種思路供大家參考,第一種思路是將所有組件的 css 都統一打包成一個 main.css。這種方式簡單粗暴,也可以解決問題,但缺點一是打包出來的 css 文件會龐大很多,可能其中 99% 的樣式在業務項目中都用不到,而且需要提醒用戶即使你引了很多個單獨的組件,但 css 只需要 import 一次即可,與常識相悖。第二種思路就是自己來做 css 的依賴管理,通過 import 的方式自己在每個組件的 css 文件中引入其需要用到的其他組件的 css,如下圖 button 組件依賴 icon 組件的 css:

@import "~xui-icon/build/main.css";.xui-button { font-size: 16px;}

這種方式的缺點就是比較麻煩,需要人為地去做依賴管理,也會導致單獨打包的組件代碼與整體打包的組件代碼有所不同,不便於維護。

當然還有一種更激進的做法就是 CSS in JS,上篇文章中提到的 material-ui 就是這樣做的,但因為筆者還未在實際工作中深入實踐 CSS in JS 這種做法,所以這裡暫時將這個問題拋回給大家,以供討論。不過,我們應該馬上會在新的架構中嘗試 CSS in JS,到時如果能沉澱出良好的經驗的話,也一定會再總結成文章,回饋給大家。

小結

在本文中,我們主要從組件分類、文檔管理、打包發布三個方面闡述了如何將構成自由且數據解耦的 React 組件庫落到實處。

按照計劃,原本還將組件庫國際化方案與複雜組件設計也放在了這篇文章中與大家分享,但因篇幅所限,我們會將以上這兩部分內容一起放在下一篇文章中,敬請期待。

文末彩蛋

在上一篇文章發出後,我們非常榮幸地得到了@徐飛老師的回復,也在當天下午和民工叔一起當面交流了很久。民工叔和我們分享了許多 redux、rxjs 以及公用數據層方面的經驗與思考,團隊的小夥伴們都表示收益頗豐,第一本@流形老師簽名的《深入 React 技術棧》也送給了民工叔,在這裡也歡迎各位繼續多多留言,讓我們在交流與討論中一起成長!

《深入 React 技術棧》購買鏈接:

亞馬遜

china-pub

京東

噹噹


推薦閱讀:

工作除了擼代碼,你還幹了什麼?學習是不是你工作的責任?
賀師俊(hax)在技術上的積累為什麼這麼深厚?學習路線是怎麼樣的?
如何解決blur事件和click事件衝突問題?
最近在自學前端,需要買域名和伺服器嗎?
阿里巴巴iconfont怎麼是正確的使用方式?

TAG:前端组件 | React | 前端开发 |