組件庫(kù)設(shè)計(jì)實(shí)戰(zhàn) - 復(fù)雜組件設(shè)計(jì)

作者:誠(chéng)身
https://zhuanlan.zhihu.com/p/29034015
一個(gè)成熟的組件庫(kù)通常都由數(shù)十個(gè)常用的 UI 組件構(gòu)成,這其中既有按鈕(Button),輸入框(Input)等基礎(chǔ)組件,也有表格(Table),日期選擇器(DatePicker),輪播(Carousel)等自成一體的復(fù)雜組件。
這里我們提出一個(gè)組件復(fù)雜度的概念,一個(gè)組件復(fù)雜度的主要來(lái)源就是其自身的狀態(tài),即組件自身需要維護(hù)多少個(gè)不依賴(lài)于外部輸入的狀態(tài)。參考原先文章中提到過(guò)的木偶組件(dumb component)與智能組件(smart component),二者的區(qū)別就是是否需要在組件內(nèi)部維護(hù)不依賴(lài)于外部輸入的狀態(tài)。
實(shí)戰(zhàn)案例 - 輪播組件
在本篇文章中,我們將以輪播(Carousel)組件為例,一步一步還原如何實(shí)現(xiàn)一個(gè)交互流暢的輪播組件。
最簡(jiǎn)單的輪播組件
拋去所有復(fù)雜的功能,輪播組件的實(shí)質(zhì),實(shí)際上就是在一個(gè)固定區(qū)域?qū)崿F(xiàn)不同元素之間的切換。在明確了這點(diǎn)后,我們就可以設(shè)計(jì)輪播組件的基礎(chǔ) DOM 結(jié)構(gòu)為:
<Frame>
<SlideList>
<SlideItem />
...
<SlideItem />
</SlideList>
</Frame>
如下圖所示:

Frame 即輪播組件的真實(shí)顯示區(qū)域,其寬高為內(nèi)部由使用者輸入的 SlideItem 決定。這里需要注意的一點(diǎn)是需要設(shè)置 Frame 的 overflow 屬性為 hidden,即隱藏超出其本身寬高的部分,每次只顯示一個(gè) SlideItem。
SlideList 為輪播組件的軌道容器,改變其 translateX 的值即可實(shí)現(xiàn)在軌道的滑動(dòng),以顯示不同的輪播元素。
SlideItem 是使用者輸入的輪播元素的一層抽象,內(nèi)部可以是 img 或 div 等 DOM 元素,并不影響輪播組件本身的邏輯。
實(shí)現(xiàn)輪播元素之前的切換
為了實(shí)現(xiàn)在不同 SlideItem 之間的切換,我們需要定義輪播組件的第一個(gè)內(nèi)部狀態(tài),即 currentIndex,即當(dāng)前顯示輪播元素的 index 值。上文中我們提到了改變 SlideList 的 translateX 是實(shí)現(xiàn)輪播元素切換的關(guān)鍵,所以這里我們需要將 currentIndex 與 SlideList 的 translateX 對(duì)應(yīng)起來(lái),即:
translateX = -(width) * currentIndex
width 即為單個(gè)輪播元素的寬度,與 Frame 的寬度相同,所以我們可以在 componentDidMount 時(shí)拿到 Frame 的寬度并以此計(jì)算出軌道的總寬度。
componentDidMount() {
const width = get(this.container.getBoundingClientRect(), 'width');
}
render() {
const rest = omit(this.props, Object.keys(defaultProps));
const classes = classnames('ui-carousel', this.props.className);
return (
<div
{...rest}
className={classes}
ref={(node) => { this.container = node; }}
>
{this.renderSildeList()}
{this.renderDots()}
</div>
);
}
至此,我們只需要改變輪播組件中的 currentIndex,即可間接改變 SlideList 的 translateX,以此實(shí)現(xiàn)輪播元素之間的切換。
響應(yīng)用戶(hù)操作
輪播作為一個(gè)常見(jiàn)的通用組件,在桌面和移動(dòng)端都有著非常廣泛的應(yīng)用,這里我們先以移動(dòng)端為例,來(lái)闡述如何響應(yīng)用戶(hù)操作。
{map(children, (child, i) => (
<div
className="slideItem"
role="presentation"
key={i}
style={{ width }}
onTouchStart={this.handleTouchStart}
onTouchMove={this.handleTouchMove}
onTouchEnd={this.handleTouchEnd}
>
{child}
</div>
))}
在移動(dòng)端,我們需要監(jiān)聽(tīng)三個(gè)事件,分別響應(yīng)滑動(dòng)開(kāi)始,滑動(dòng)中與滑動(dòng)結(jié)束。其中滑動(dòng)開(kāi)始與滑動(dòng)結(jié)束都是一次性事件,而滑動(dòng)中則是持續(xù)性事件,以此我們可以確定在三個(gè)事件中我們分別需要確定哪些值。
滑動(dòng)開(kāi)始
startPositionX:此次滑動(dòng)的起始位置
handleTouchStart = (e) => {
const { x } = getPosition(e);
this.setState({
startPositionX: x,
});
}
滑動(dòng)中
moveDeltaX:此次滑動(dòng)的實(shí)時(shí)距離
direction:此次滑動(dòng)的實(shí)時(shí)方向
translateX:此次滑動(dòng)中軌道的實(shí)時(shí)位置,用于渲染
handleTouchMove = (e) => {
const { width, currentIndex, startPositionX } = this.state;
const { x } = getPosition(e);
const deltaX = x - startPositionX;
const direction = deltaX > 0 ? 'right' : 'left';
this.setState({
moveDeltaX: deltaX,
direction,
translateX: -(width * currentIndex) + deltaX,
});
}
滑動(dòng)結(jié)束
currentIndex:此次滑動(dòng)結(jié)束后新的 currentIndex
endValue:此次滑動(dòng)結(jié)束后軌道的 translateX
handleTouchEnd = () => {
this.handleSwipe();
}
handleSwipe = () => {
const { children, speed } = this.props;
const { width, currentIndex, direction, translateX } = this.state;
const count = size(children);
let newIndex;
let endValue;
if (direction === 'left') {
newIndex = currentIndex !== count ? currentIndex + 1 : START_INDEX;
endValue = -(width) * (currentIndex + 1);
} else {
newIndex = currentIndex !== START_INDEX ? currentIndex - 1 : count;
endValue = -(width) * (currentIndex - 1);
}
const tweenQueue = this.getTweenQueue(translateX, endValue, speed);
this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex));
}
因?yàn)槲覀冊(cè)诨瑒?dòng)中會(huì)實(shí)時(shí)更新軌道的 translateX,我們的輪播組件便可以做到跟手的用戶(hù)體驗(yàn),即在單次滑動(dòng)中,輪播元素會(huì)跟隨用戶(hù)的操作向左或向右滑動(dòng)。
實(shí)現(xiàn)順滑的切換動(dòng)畫(huà)
在實(shí)現(xiàn)了滑動(dòng)中跟手的用戶(hù)體驗(yàn)后,我們還需要在滑動(dòng)結(jié)束后將顯示的輪播元素定位到新的 currentIndex。根據(jù)用戶(hù)的滑動(dòng)方向,我們可以對(duì)當(dāng)前的 currentIndex 進(jìn)行 +1 或 -1 以得到新的 currentIndex。但在處理第一個(gè)元素向左滑動(dòng)或最后一個(gè)元素向右滑動(dòng)時(shí),新的 currentIndex 需要更新為最后一個(gè)或第一個(gè)。
這里的邏輯并不復(fù)雜,但卻帶來(lái)了一個(gè)非常難以解決的用戶(hù)體驗(yàn)問(wèn)題,那就是假設(shè)我們有 3 個(gè)輪播元素,每個(gè)輪播元素的寬度都為 300px,即顯示最后一個(gè)元素時(shí),軌道的 translateX 為 -600px,在我們將最后一個(gè)元素向左滑動(dòng)后,軌道的 translateX 將被重新定義為 0px,此時(shí)若我們使用原生的 CSS 動(dòng)畫(huà):
transition: 1s ease-in-out;
軌道將會(huì)在一秒內(nèi)從左向右滑動(dòng)至第一個(gè)輪播元素,而這是反直覺(jué)的,因?yàn)橛脩?hù)一個(gè)向左滑動(dòng)的操作導(dǎo)致了一個(gè)向右的動(dòng)畫(huà),反之亦然。
這個(gè)問(wèn)題從上古時(shí)期就困擾著許多前端開(kāi)發(fā)者,筆者也見(jiàn)過(guò)以下幾種解決問(wèn)題的方法:
將軌道寬度定義為無(wú)限長(zhǎng)(幾百萬(wàn) px),無(wú)限次重復(fù)有限的輪播元素。這種解決方案顯然是一種 hack,并沒(méi)有從實(shí)質(zhì)上解決輪播組件的問(wèn)題。
只渲染三個(gè)輪播元素,即前一個(gè),當(dāng)前一個(gè),下一個(gè),每次滑動(dòng)后同時(shí)更新三個(gè)元素。這種解決方案實(shí)現(xiàn)起來(lái)非常復(fù)雜,因?yàn)榻M件內(nèi)部要維護(hù)的狀態(tài)從一個(gè) currentIndex 增加到了三個(gè)擁有各自狀態(tài)的 DOM 元素,且因?yàn)橐煌5膭h除和新增 DOm 節(jié)點(diǎn)導(dǎo)致性能不佳。
這里讓我們?cè)賮?lái)思考一下滑動(dòng)操作的本質(zhì)。除去第一和最后兩個(gè)元素,所有中間元素滑動(dòng)后新的 translateX 的值都是固定的,即 -(width * currentIndex),這種情況下的動(dòng)畫(huà)都可以輕松地完美實(shí)現(xiàn)。而在最后一個(gè)元素向左滑動(dòng)時(shí),因?yàn)檐壍赖?translateX 已經(jīng)到達(dá)了極限,面對(duì)這種情況我們?nèi)绾尾拍軐?shí)現(xiàn)順滑的切換動(dòng)畫(huà)呢?
這里我們選擇將最后一個(gè)及第一個(gè)元素分別拼接至軌道的頭尾,以保證在 DOM 結(jié)構(gòu)不需要改變的前提下實(shí)現(xiàn)順滑的切換動(dòng)畫(huà):

這樣我們就統(tǒng)一了每次滑動(dòng)結(jié)束后 endValue 的計(jì)算方式,即
// left
endValue = -(width) * (currentIndex + 1)
// right
endValue = -(width) * (currentIndex - 1)
使用 requestAnimationFrame 實(shí)現(xiàn)高性能動(dòng)畫(huà)
requestAnimationFrame 是瀏覽器提供的一個(gè)專(zhuān)注于實(shí)現(xiàn)動(dòng)畫(huà)的 API,感興趣的朋友可以再重溫一下《React Motion 緩動(dòng)函數(shù)剖析》這篇專(zhuān)欄。
所有的動(dòng)畫(huà)本質(zhì)上都是一連串的時(shí)間軸上的值,具體到輪播場(chǎng)景下即:以用戶(hù)停止滑動(dòng)時(shí)的值為起始值,以新 currentIndex 時(shí) translateX 的值為結(jié)束值,在使用者設(shè)定的動(dòng)畫(huà)時(shí)間(如0.5秒)內(nèi),依據(jù)使用者設(shè)定的緩動(dòng)函數(shù),計(jì)算每一幀動(dòng)畫(huà)時(shí)的 translateX 值并最終得到一個(gè)數(shù)組,以每秒 60 幀的速度更新在軌道的 style 屬性上。每更新一次,將消耗掉動(dòng)畫(huà)值數(shù)組中的一個(gè)中間值,直到數(shù)組中所有的中間值被消耗完畢,動(dòng)畫(huà)結(jié)束并觸發(fā)回調(diào)。
具體代碼如下:
const FPS = 60;
const UPDATE_INTERVAL = 1000 / FPS;
animation = (tweenQueue, newIndex) => {
if (isEmpty(tweenQueue)) {
this.handleOperationEnd(newIndex);
return;
}
this.setState({
translateX: head(tweenQueue),
});
tweenQueue.shift();
this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex));
}
getTweenQueue = (beginValue, endValue, speed) => {
const tweenQueue = [];
const updateTimes = speed / UPDATE_INTERVAL;
for (let i = 0; i < updateTimes; i += 1) {
tweenQueue.push(
tweenFunctions.easeInOutQuad(UPDATE_INTERVAL * i, beginValue, endValue, speed),
);
}
return tweenQueue;
}
在回調(diào)函數(shù)中,根據(jù)變動(dòng)邏輯統(tǒng)一確定組件當(dāng)前新的穩(wěn)定態(tài)值:
handleOperationEnd = (newIndex) => {
const { width } = this.state;
this.setState({
currentIndex: newIndex,
translateX: -(width) * newIndex,
startPositionX: 0,
moveDeltaX: 0,
dragging: false,
direction: null,
});
}
完成后的輪播組件效果如下圖:

優(yōu)雅地處理特殊情況
處理用戶(hù)誤觸:在移動(dòng)端,用戶(hù)經(jīng)常會(huì)誤觸到輪播組件,即有時(shí)手不小心滑過(guò)或點(diǎn)擊時(shí)也會(huì)觸發(fā) onTouch 類(lèi)事件。對(duì)此我們可以采取對(duì)滑動(dòng)距離添加閾值的方式來(lái)避免用戶(hù)誤觸,閾值可以是輪播元素寬度的 10% 或其他合理值,在每次滑動(dòng)距離超過(guò)閾值時(shí),才會(huì)觸發(fā)輪播組件后續(xù)的滑動(dòng)。
桌面端適配:對(duì)于桌面端而言,輪播組件所需要響應(yīng)的事件名稱(chēng)與移動(dòng)端是完全不同的,但又可以相對(duì)應(yīng)地匹配起來(lái)。這里還需要注意的是,我們需要為輪播組件添加一個(gè) dragging 的狀態(tài)來(lái)區(qū)分移動(dòng)端與桌面端,從而安全地復(fù)用 handler 部分的代碼。
// mobile
onTouchStart={this.handleTouchStart}
onTouchMove={this.handleTouchMove}
onTouchEnd={this.handleTouchEnd}
// desktop
onMouseDown={this.handleMouseDown}
onMouseMove={this.handleMouseMove}
onMouseUp={this.handleMouseUp}
onMouseLeave={this.handleMouseLeave}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
onFocus={this.handleMouseOver}
onBlur={this.handleMouseOut}
handleMouseDown = (evt) => {
evt.preventDefault();
this.setState({
dragging: true,
});
this.handleTouchStart(evt);
}
handleMouseMove = (evt) => {
if (!this.state.dragging) {
return;
}
this.handleTouchMove(evt);
}
handleMouseUp = () => {
if (!this.state.dragging) {
return;
}
this.handleTouchEnd();
}
handleMouseLeave = () => {
if (!this.state.dragging) {
return;
}
this.handleTouchEnd();
}
handleMouseOver = () => {
if (this.props.autoPlay) {
clearInterval(this.autoPlayTimer);
}
}
handleMouseOut = () => {
if (this.props.autoPlay) {
this.autoPlay();
}
}
小結(jié)
至此我們就實(shí)現(xiàn)了一個(gè)只有 tween-functions 一個(gè)第三方依賴(lài)的輪播組件,打包后大小不過(guò) 2KB,完整的源碼大家可以參考這里 carousel/index.js。
除了節(jié)省的代碼體積,更讓我們欣喜的還是徹底弄清楚了輪播組件的實(shí)現(xiàn)模式以及如何使用 requestAnimationFrame 配合 setState 來(lái)在 react 中完成一組動(dòng)畫(huà)。
感想

大家應(yīng)該都看過(guò)上面這幅漫畫(huà),有趣之余也蘊(yùn)含著一個(gè)樸素卻深刻的道理,那就是在解決一個(gè)復(fù)雜問(wèn)題時(shí),最重要的是思路,但僅僅有思路也仍是遠(yuǎn)遠(yuǎn)不夠的,還需要具體的執(zhí)行方案。這個(gè)具體的執(zhí)行方案,必須是連續(xù)的,其中不可以欠缺任何一環(huán),不可以有任何思路或執(zhí)行上的跳躍。所以解決任何復(fù)雜問(wèn)題都沒(méi)有銀彈也沒(méi)有捷徑,我們必須把它弄清楚,搞明白,然后才能真正地解決它。
至此,組件庫(kù)設(shè)計(jì)實(shí)戰(zhàn)系列文章也將告一段落。在全部四篇文章中,我們分別討論了組件庫(kù)架構(gòu),組件分類(lèi),文檔組織,國(guó)際化以及復(fù)雜組件設(shè)計(jì)這幾個(gè)核心的話(huà)題,因筆者能力所限,其中自然有許多不足之處,煩請(qǐng)各位諒解。
組件庫(kù)作為提升前端團(tuán)隊(duì)工作效率的重中之重,花再多時(shí)間去研究它都不為過(guò)。再加上與設(shè)計(jì)團(tuán)隊(duì)對(duì)接,形成設(shè)計(jì)語(yǔ)言,與后端團(tuán)隊(duì)對(duì)接,統(tǒng)一數(shù)據(jù)結(jié)構(gòu),組件庫(kù)也可以說(shuō)是前端工程師在拓展自身工作領(lǐng)域上的必經(jīng)之路。
不要害怕重復(fù)造輪子,關(guān)鍵是每造一次輪子后,從中學(xué)到了什么。
與各位共勉。
完
覺(jué)得文章不錯(cuò)可以分享到朋友圈讓更多的小伙伴看到哦~
