好久沒上掘金,發現草稿箱裏存了好幾篇沒發的文章,最近梳理下發出來css
設計前端組件是最能考驗開發者基本功的測試之一,由於調用Material design、Antd、iView 等現成組件庫的 API 每一個人均可以作到,可是不少人並不知道不少經常使用組件的設計原理。html
可否設計出通用前端組件也是區分前端工程師和前端api調用師的標準之一,那麼應該如何設計出一個通用組件呢?前端
下文中提到的組件庫一般是指單個組件,而非集合的概念,集合概念的組件庫是 Antd iView這種,咱們所說的組件庫是指集合中的單個組件,集合性質的組件庫須要考慮的要更多.vue
咱們在學習設計模式的時候會遇到不少種設計原則,其中一個設計原則就是單一職責原則,在組件庫的開發中一樣適用,咱們原則上一個組件只專一一件事情,單一職責的組件的好處很明顯,因爲職責單一就能夠最大可能性地複用組件,可是這也帶來一個問題,過分單一職責的組件也可能會致使過分抽象,形成組件庫的碎片化。node
舉個例子,一個自動完成組件(AutoComplete),他實際上是由 Input 組件和 Select 組件組合而成的,所以咱們徹底能夠複用以前的相關組件,就好比 Antd 的AutoComplete組件中就複用了Select組件,同時Calendar、 Form 等等一系列組件都複用了 Select 組件,那麼Select 的細粒度就是合適的,由於 Select 保持的這種細粒度很容易被複用.react
那麼還有一個例子,一個徽章數組件(Badge),它的右上角會有紅點提示,多是數字也多是 icon,他的職責固然也很單一,這個紅點提示也理所固然也能夠被單獨抽象爲一個獨立組件,可是咱們一般不會將他做爲獨立組件,由於在其餘場景中這個組件是沒法被複用的,由於沒有相似的場景再須要小紅點這個小組件了,因此做爲獨立組件就屬於細粒度太小,所以咱們每每將它做爲 Badge 的內部組件,好比在 Antd 中它以ScrollNumber的名稱做爲Badge的內部組件存在。webpack
因此,所謂的單一職責組件要創建在可複用的基礎上,對於不可複用的單一職責組件咱們僅僅做爲獨立組件的內部組件便可。css3
咱們要設計的自己就是通用組件庫,不一樣於咱們常見的業務組件,通用組件是與業務解耦可是又服務於業務開發的,那麼問題來了,如何保證組件的通用性,通用性高必定是好事嗎?git
好比咱們設計一個選擇器(Select)組件,一般咱們會設計成這樣github
這是一個咱們最多見也最經常使用的選擇器,可是問題是其通用性大打折扣當咱們有一個需求是長這樣的時候,咱們以前的選擇器組件就不符合要求了,由於這個 Select 組件的最下部須要有一個可拓展的條目的按鈕
這個時候咱們難道要從新修改以前的選擇器組件,甚至再造一個符合要求的選擇器組件嗎?一旦有這種狀況發生,那麼只能說明以前的選擇器組件通用性不夠,須要咱們從新設計.
Antd 的 Select 組件預留了dropdownRender
來進行自定義渲染,其依賴的 rc-select
組件中的代碼以下
Antd 依賴了大量以
rc-
開頭的底層組件,這些組件被react-component團隊(同時也就是Antd 團隊)維護,其主要實現組件的底層邏輯,Antd 則是在此基礎上添加Ant Design設計語言而實現的
固然相似的設計還有不少,通用性設計實際上是必定意義上放棄對 DOM 的掌控,而將 DOM 結構的決定權轉移給開發者,dropdownRender
其實就是放棄對 Select 下拉菜單中條目的掌控,Antd 的 Select 組件其實還有一個沒有在文檔中體現的方法getInputElement
應該是對 Input 組件的自定義方法,Antd整個 Select 的組件設計很是複雜,基本將全部的 DOM 結構控制權所有暴露給了開發者,其自己只負責底層邏輯和最基本的 DOM 結構.
這是 Antd 所依賴的 re-select 最終 jsx 的結構,其 DOM 結構很簡單,可是暴露了大量自定義渲染的接口給開發者.
return (
<SelectTrigger onPopupFocus={this.onPopupFocus} onMouseEnter={this.props.onMouseEnter} onMouseLeave={this.props.onMouseLeave} dropdownAlign={props.dropdownAlign} dropdownClassName={props.dropdownClassName} dropdownMatchSelectWidth={props.dropdownMatchSelectWidth} defaultActiveFirstOption={props.defaultActiveFirstOption} dropdownMenuStyle={props.dropdownMenuStyle} transitionName={props.transitionName} animation={props.animation} prefixCls={props.prefixCls} dropdownStyle={props.dropdownStyle} combobox={props.combobox} showSearch={props.showSearch} options={options} multiple={multiple} disabled={disabled} visible={realOpen} inputValue={state.inputValue} value={state.value} backfillValue={state.backfillValue} firstActiveValue={props.firstActiveValue} onDropdownVisibleChange={this.onDropdownVisibleChange} getPopupContainer={props.getPopupContainer} onMenuSelect={this.onMenuSelect} onMenuDeselect={this.onMenuDeselect} onPopupScroll={props.onPopupScroll} showAction={props.showAction} ref={this.saveSelectTriggerRef} menuItemSelectedIcon={props.menuItemSelectedIcon} dropdownRender={props.dropdownRender} ariaId={this.ariaId} > <div id={props.id} style={props.style} ref={this.saveRootRef} onBlur={this.onOuterBlur} onFocus={this.onOuterFocus} className={classnames(rootCls)} onMouseDown={this.markMouseDown} onMouseUp={this.markMouseLeave} onMouseOut={this.markMouseLeave} > <div ref={this.saveSelectionRef} key="selection" className={`${prefixCls}-selection ${prefixCls}-selection--${multiple ? 'multiple' : 'single'}`} role="combobox" aria-autocomplete="list" aria-haspopup="true" aria-controls={this.ariaId} aria-expanded={realOpen} {...extraSelectionProps} > {ctrlNode} {this.renderClear()} {this.renderArrow(!!multiple)} </div> </div> </SelectTrigger>
);
複製代碼
那麼這麼多須要自定義的地方,這個 Select 組件豈不是很難用?由於好像全部地方都須要開發者自定義,通用性設計在將 DOM 結構決定權交給開發者的同時也保留了默認結構,在開發者沒有顯示自定義的時候默認使用默認渲染結構,其實 Select 的基本使用很方便,以下:
<Select defaultValue="lucy" style={{ width: 120 }} disabled>
<Option value="lucy">Lucy</Option>
</Select>
複製代碼
組件的形態(DOM結構)永遠是變幻無窮的,可是其行爲(邏輯)是固定的,所以通用組件的祕訣之一就是將 DOM 結構的控制權交給開發者,組件只負責行爲和最基本的 DOM 結構
因爲CSS 自己的衆多缺陷,如書寫繁瑣(不支持嵌套)、樣式易衝突(沒有做用域概念)、缺乏變量(不便於一鍵換主題)等不一而足。爲了解決這些問題,社區裏的解決方案也是出了一茬又一茬,從最先的 CSS prepocessor(SASS、LESS、Stylus)到後來的後起之秀 PostCSS,再到 CSS Modules、Styled-Components 等。
Antd 選擇了 less 做爲 css 的預處理方案,Bootstrap 選擇了 Scss,這兩種方案孰優孰劣已經爭論了不少年了:
可是無論是哪一種方案都有一個很煩人的點,就是須要額外引入 css,好比 Antd 須要這樣顯示引入:
import Button from 'antd/lib/button';
import 'antd/lib/button/style';
複製代碼
爲了解決這種尷尬的狀況,Antd 用 Babel 插件將這種狀況 Hack 掉了
而material-ui
並不存在這種狀況,他不須要顯示引入 css,這個最流行的 React 前端組件庫裏面只有 js 和 ts 兩種代碼,並不存在 css 相關的代碼,爲何呢?
他們用 jss
做爲css-in-js 的解決方案,jsx 的引入已經將 js 和 html 耦合,css-in-js將 css 也耦合進去,此時組件便不須要顯示引入 css,而是直接引用 js 便可.
這不是退化到史前前端那種寫內聯樣式的時代了嗎?
並非,史前前端的內聯樣式是整個項目耦合的狀態,固然要被拋棄到歷史的垃圾堆中,後來的樣式和邏輯分離,其實是以頁面爲維度將 js css html 解耦的過程,現在的時代是組件化的時代了,jsx 已經將 js 和 html 框定到一個組件中,css 依然處於分離狀態,這就致使了每次引用組件卻還須要顯示引入 css,css-in-js 正式完全組件化的解決方案.
固然,我我的目前在用 styled-components,其優勢引用以下:
首先,styled-components 全部語法都是標準 css 語法,同時支持 scss 嵌套等經常使用語法,覆蓋了全部 css 場景。
在樣式複寫場景下,styled-components 支持在任何地方注入全局 css,就像寫普通 css 同樣
styled-components 支持自定義 className,兩種方式,一種是用 babel 插件, 另外一種方式是使用 styled.div.withConfig({ componentId: "prefix-button-container" }) 至關於添加 className="prefix-button-container"
className 語義化更輕鬆,這也是 class 起名的初衷
更適合組件庫使用,直接引用 import "module" 便可,不然你有三條路能夠走:像 antd 同樣,單獨引用 css,你須要給 node_modules 添加 css-loader;組件內部直接 import css 文件,若是任何業務項目沒有 css-loader 就會報錯;組件使用 scss 引用,全部業務項目都要配置一份 scss-loader 給 node_modules;這三種對組件庫來講,都沒有直接引用來的友好
當你寫一套組件庫,須要單獨發包,又有統同樣式的配置文件需求,若是這個配置文件是 js 的,全部組件直接引用,對外徹底不用關注。不然,若是是 scss 配置文件,擺在面前仍是三條路:每一個組件單獨引用 scss 文件,須要每一個業務項目給 node_modules 添加 scss-loader(若是業務用了 less,還要裝一份 scss 是不);或者業務方只要使用了你的組件庫,就要在入口文件引用你的 scss 文件,好比你的組件叫 button,scss 可能叫 common-css,別人聽都沒聽過,還要查文檔;或者業務方在 webpack 配置中單獨引用你的 common-css,這也不科學,若是用了3個組件庫,每天改 webpack 配置也很不方便。
當 css 設置了一半樣式,另外一半真的須要 js 動態傳入,你不得不 css + css-in-js 混合使用,項目久了,維護的時候發現某些 css-in-js 不變了,能夠固化在 css 裏,css 裏固定的值又由於新去求變得可變了,你又得拿出來放在 css-in-js 裏,實踐過就知道有多麼煩心。
選 Typescript ,由於巨硬大法好...
能夠看看知乎問題下個人回答你爲何不用 Typescript
或者看此文TypeScript體系調研報告
組件的具體實現部分固然是組件庫的核心,可是在現代前端庫中其餘部分也必不可少,咱們須要一堆工具來輔助咱們開發,例如編譯工具、代碼檢測工具、打包工具等等。
市面上打包工具數不勝數,最火爆的固然是須要配置工程師專門配置的webpack,可是在類庫開發領域它有一個強大的對手就是 rollup。
現代市面上主流的庫基本都選擇了 rollup 做爲打包工具,包括Angular React 和 Vue, 做爲基礎類庫的打包工具 rollup 的優點以下:
雖然上面部分功能已經被 webpack 實現了,可是 rollup 明顯引入得更早,而Scope Hoisting更是殺手鐗,因爲 webpack 不得不在打包代碼中構建模塊系統來適應 app 開發(模塊系統對於單一類庫用處很小),Scope Hoisting將模塊構建在一個函數內的作法更適合類庫的打包.
因爲 JavaScript 各類詭異的特性和大型前端項目的出現,代碼檢測工具已是前端開發者的標配了,Douglas Crockford最先於2002創造出了 JSLint,可是其沒法拓展,具備極強的Douglas Crockford我的色彩,Anton Kovalyov因爲沒法忍受 JSLint 沒法拓展的行爲在2011年發佈了可拓展的JSHint,一時之間JSHint成爲了前端代碼檢測的流行解決方案.
隨後的2013年,Nicholas C. Zakas鑑於JSHint拓展的靈活度不夠的問題開發了全新的基於 AST 的 Lint 工具 ESLint,並隨着 ES6的流行統治了前端界,ESLint 基於Esprima進行 JavaScript 解析的特性極易拓展,JSHint 在很長一段時間沒法支持 ES6語法致使被 ESLint 超越.
可是在 Typescript 領域 ESLint 卻處於弱勢地位,TSLint 的出現要比 ESLint 正式支持 Typescript 早不少,目前 TSLint 彷佛是 TS 的事實上的代碼檢測工具.
注: 文章成文較早,我也沒想到前陣子 TS 官方欽點了 ESLint,TSLint 失寵了,面向將來的官方標配的代碼檢測工具確定是 ESLint 了,可是 TSLint 目前依然被大量使用,如今仍然能夠放心使用
代碼檢測工具是一方面,代碼檢測風格也須要咱們作選擇,市面上最流行的代碼檢測風格應該是 Airbnb 出品的eslint-config-airbnb
,其最大的特色就是極其嚴格,沒有給開發者任何選擇的餘地,固然在大型前端項目的開發中這種嚴格的代碼風格是有利於協做的,可是做爲一個類庫的代碼檢測工具而言並不適合,因此咱們選擇了eslint-config-standard
這種相對更爲寬鬆的代碼檢測風格.
如下兩種 commit 哪一個更嚴謹且易於維護?
最開始使用 commit 的時候我也常常犯下圖的錯誤,直到看到不少明星類庫的 commit 才意識到本身的錯誤,寫好 commit message 不只有助於他人 review, 還能夠有效的輸出 CHANGELOG, 對項目的管理實際相當重要.
目前流行的方案是 Angular 團隊的規範,其關於 head 的大體規範以下:
固然規範人們不必定會遵照,我最初知道此類規範的時候也並無嚴格遵循,由於人總會偷懶,直到用commitizen
將此規範集成到工具流中,每一個 commit 就不得不遵循規範了.
我具體參考了這篇文章: 優雅的提交你的 Git Commit Message
業務開發中因爲前端需求變更頻繁的特性,致使前端對測試的要求並無後端那麼高,後端業務邏輯一旦定型變更不多,比較適合測試.
可是基礎類庫做爲被反覆依賴的模塊和較爲穩定的需求是必須作測試的,前端測試庫也可謂是種類繁多了,通過比對以後我仍是選擇了目前最流行也是被三大框架同時選擇了的 Jest 做爲測試工具,其優勢很明顯:
固然以上是主要工具的選擇,還有一些好比:
那麼以上這麼多配置難道要咱們每次都本身寫嗎?組件的具體實現纔是組件庫的核心,咱們爲何要花這麼多時間在配置上面?
咱們在創建 APP 項目時一般會用到框架官方提供的腳手架,好比 React 的 create-react-app,Angular 的 Angular-Cli 等等,那麼能不能有一個專門用於組件開發的快速啓動的腳手架呢?
有的,我最近開發了一款快速啓動組件庫開發的命令行工具--create-component
利用
create-component init <name>
複製代碼
來快速啓動項目,咱們提供了豐富的可選配置,只要你作好技術選型後,根據提示去選擇配置便可,create-component 會自動根據配置生成腳手架,其靈感就來源於 vue-cli和 Angular-cli.
說了不少理論,那麼實戰如何呢?設計一個通用組件試試吧!
輪播圖(Carousel),在 Antd 中被稱爲走馬燈,多是前端開發者最多見的組件之一了,無論是在 PC 端仍是在移動端咱們總能見到他的身影.
那麼咱們一般是如何使用輪播圖的呢?Antd 的代碼以下
<Carousel>
<div><h3>1</h3></div>
<div><h3>2</h3></div>
<div><h3>3</h3></div>
<div><h3>4</h3></div>
</Carousel>
複製代碼
問題是咱們在Carousel
中放入了四組div
爲何一次只顯示一組呢?
圖中被紅框圈住的爲可視區域,可視區域的位置是固定的,咱們只須要移動後面div
的位置就能夠作到1 2 3 4四個子組件輪播的效果,那麼子組件2目前在可視區域是能夠被看到的,1 3 4應該被隱藏,這就須要咱們設置overflow 屬性爲 hidden來隱藏非可視區域的子組件.
所以就比較明顯了,咱們設計一個可視窗口組件Frame
,而後將四個 div
共同放入幻燈片組合組件SlideList
中,並用SlideItem
分別將 div
包裹起來,實際代碼應該是這樣的:
<Frame>
<SlideList>
<SlideItem>
<div><h3>1</h3></div>
</SlideItem>
<SlideItem>
<div><h3>2</h3></div>
</SlideItem>
<SlideItem>
<div><h3>3</h3></div>
</SlideItem>
<SlideItem>
<div><h3>4</h3></div>
</SlideItem>
</SlideList>
</Frame>
複製代碼
咱們不斷利用translateX
來改變SlideList
的位置來達到輪播效果,以下圖所示,每次輪播的觸發都是經過改變transform: translateX()
來操做的
搞清楚基本原理那麼實現起來相對容易了,咱們以移動端的實現爲例,來實現一個基礎的移動端輪播圖.
首先咱們要肯定可視窗口的寬度,由於咱們須要這個寬度來計算出SlideList
的長度(SlideList
的長度一般是可視窗口的倍數,好比要放三張圖片,那麼SlideList
應該爲可視窗口的至少3倍),否則咱們沒法經過translateX
來移動它.
咱們經過getBoundingClientRect
來獲取可視區域真實的長度,SlideList
的長度那麼爲:
slideListWidth = (len + 2) * width
(len 爲傳入子組件的數量,width 爲可視區域寬度)
至於爲何要+2
後面會提到.
/** * 設置輪播區域尺寸 * @param x */
private setSize(x?: number) {
const { width } = this.frameRef.current!.getBoundingClientRect()
const len = React.Children.count(this.props.children)
const total = len + 2
this.setState({
slideItemWidth: width,
slideListWidth: total * width,
total,
translateX: -width * this.state.currentIndex,
startPositionX: x !== undefined ? x : 0,
})
}
複製代碼
獲取到了總長度以後如何實現輪播呢?咱們須要根據用戶反饋來觸發輪播,在移動端一般是經過手指滑動來觸發輪播,這就須要三個事件onTouchStart
onTouchMove
onTouchEnd
.
onTouchStart
顧名思義是在手指觸摸到屏幕時觸發的事件,在這個事件裏咱們只須要記錄下手指觸摸屏幕的橫軸座標 x 便可,由於咱們會經過其橫向滑動的距離大小來判斷是否觸發輪播
/** * 處理觸摸起始時的事件 * * @private * @param {React.TouchEvent} e * @memberof Carousel */
private onTouchStart(e: React.TouchEvent) {
clearInterval(this.autoPlayTimer)
// 獲取起始的橫軸座標
const { x } = getPosition(e)
this.setSize(x)
this.setState({
startPositionX: x,
})
}
複製代碼
onTouchMove
顧名思義是處於滑動狀態下的事件,此事件在onTouchStart
觸發後,onTouchEnd
觸發前,在這個事件中咱們主要作兩件事,一件事是判斷滑動方向,由於用戶可能向左或者向右滑動,另外一件事是讓輪播圖跟隨手指移動,這是必要的用戶反饋.
/** * 當觸摸滑動時處理事件 * * @private * @param {React.TouchEvent} e * @memberof Carousel */
private onTouchMove(e: React.TouchEvent) {
const { slideItemWidth, currentIndex, startPositionX } = this.state
const { x } = getPosition(e)
const deltaX = x - startPositionX
// 判斷滑動方向
const direction = deltaX > 0 ? 'right' : 'left'
this.setState({
direction,
moveDeltaX: deltaX,
// 改變translateX來達到輪播組件跟隨手指移動的效果
translateX: -(slideItemWidth * currentIndex) + deltaX,
})
}
複製代碼
onTouchEnd
顧名思義是滑動完畢時觸發的事件,在此事件中咱們主要作一個件事情,就是判斷是否觸發輪播,咱們會設置一個閾值threshold
,當滑動距離超過這個閾值時纔會觸發輪播,畢竟沒有閾值的話用戶稍微觸碰輪播圖就形成輪播,誤操做會形成不好的用戶體驗.
/** * 滑動結束處理的事件 * * @private * @memberof Carousel */
private onTouchEnd() {
this.autoPlay()
const { moveDeltaX, slideItemWidth, direction } = this.state
const threshold = slideItemWidth * THRESHOLD_PERCENTAGE
// 判斷是否輪播
const moveToNext = Math.abs(moveDeltaX) > threshold
if (moveToNext) {
// 若是輪播觸發那麼進行輪播操做
this.handleSwipe(direction!)
} else {
// 輪播不觸發,那麼輪播圖回到原位
this.handleMisoperation()
}
}
複製代碼
咱們常見的輪播圖確定不是生硬的切換,通常在輪播中會有一個漸變或者緩動的動畫,這就須要咱們加入動畫效果.
咱們製做動畫一般有兩個選擇,一個是用 css3自帶的動畫效果,另外一個是用瀏覽器提供的requestAnimationFrame API
孰優孰劣?css3簡單易用上手快,兼容性好,requestAnimationFrame
靈活性更高,能實現 css3實現不了的動畫,好比衆多緩動動畫 css3都一籌莫展,所以咱們毫無疑問地選擇了requestAnimationFrame
.
雙方對比請看張鑫旭大神的CSS3動畫那麼強,requestAnimationFrame還有毛線用?
想用requestAnimationFrame
實現緩動效果就須要特定的緩動函數,下面就是典型的緩動函數
type tweenFunction = (t: number, b: number, _c: number, d: number) => number
const easeInOutQuad: tweenFunction = (t, b, _c, d) => {
const c = _c - b;
if ((t /= d / 2) < 1) {
return c / 2 * t * t + b;
} else {
return -c / 2 * ((--t) * (t - 2) - 1) + b;
}
}
複製代碼
緩動函數接收四個參數,分別是:
經過這個函數咱們能算出每一幀輪播圖所在的位置, 以下:
在獲取每一幀對應的位置後,咱們須要用requestAnimationFrame
不斷遞歸調用依次移動位置,咱們不斷調用animation
函數是其觸發函數體內的this.setState({ translateX: tweenQueue[0], })
來達到移動輪播圖位置的目的,此時將這數組內的30個位置依次快速執行就是一個緩動動畫效果.
/** * 遞歸調用,根據軌跡運動 * * @private * @param {number[]} tweenQueue * @param {number} newIndex * @memberof Carousel */
private animation(tweenQueue: number[], newIndex: number) {
if (tweenQueue.length < 1) {
this.handleOperationEnd(newIndex)
return
}
this.setState({
translateX: tweenQueue[0],
})
tweenQueue.shift()
this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex))
}
複製代碼
可是咱們發現了一個問題,當咱們移動輪播圖到最後的時候,動畫出現了問題,當咱們向左滑動最後一個輪播圖div4
時,這種狀況下應該是圖片向左滑動,而後第一張輪播圖div1
進入可視區域,可是反常的是圖片快速向右滑動div1
出如今但是區域...
由於咱們此時將位置4設置爲了位置1,這樣才能達到不斷循環的目的,可是也形成了這個反作用,圖片行爲與用戶行爲產生了相悖的狀況(用戶向左划動,圖片向右走).
目前業界的廣泛作法是將圖片首尾相連,例如圖片1前面鏈接一個圖片4,圖片4後跟着一個圖片1,這就是爲何以前計算長度時要+2
slideListWidth = (len + 2) * width
(len 爲傳入子組件的數量,width 爲可視區域寬度)
當咱們移動圖片4時就不會出現上述向左滑圖片卻向右滑的狀況,由於真實狀況是:
圖片4 -- 滑動爲 -> 僞圖片1
也就是位置 5 變成了位置 6
當動畫結束以後,咱們迅速把僞圖片1
的位置設置爲真圖片1
,這實際上是個障眼法,也就是說動畫執行過程當中其實是圖片4
到僞圖片1
的過程,當結束後咱們偷偷把僞圖片1
換成真圖片1
,由於兩個圖如出一轍,因此這個轉換的過程用戶根本看不出來...
如此一來咱們就能夠實現無縫切換的輪播圖了
咱們實現了輪播圖的基本功能,可是其通用性依然存在缺陷:
以上都是能夠對輪播圖進行拓展的方向,相關的還有性能優化方面
咱們的具體代碼中有一個相關實現,咱們的輪播圖實際上是有自動輪播功能的,可是不少時候頁面並不在用戶的可視頁面中,咱們能夠根據是否頁面被隱藏來取消定時器終止自動播放.
github項目地址
以上 demo 僅供參考,實際項目開發中最好仍是使用成熟的開源組件,要有造輪子的能力和不造輪子的覺悟