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

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

這樣我們就統(tǒng)一了每次滑動結(jié)束后 endValue 的計算方式,即
// left
endValue = -(width) * (currentIndex + 1)
// right
endValue = -(width) * (currentIndex - 1)
使用 requestAnimationFrame 實現(xiàn)高性能動畫
requestAnimationFrame 是瀏覽器提供的一個專注于實現(xiàn)動畫的 API,感興趣的朋友可以再重溫一下《React Motion 緩動函數(shù)剖析》這篇專欄。
所有的動畫本質(zhì)上都是一連串的時間軸上的值,具體到輪播場景下即:以用戶停止滑動時的值為起始值,以新 currentIndex 時 translateX 的值為結(jié)束值,在使用者設(shè)定的動畫時間(如0.5秒)內(nèi),依據(jù)使用者設(shè)定的緩動函數(shù),計算每一幀動畫時的 translateX 值并最終得到一個數(shù)組,以每秒 60 幀的速度更新在軌道的 style 屬性上。每更新一次,將消耗掉動畫值數(shù)組中的一個中間值,直到數(shù)組中所有的中間值被消耗完畢,動畫結(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ù)變動邏輯統(tǒ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)雅地處理特殊情況
處理用戶誤觸:在移動端,用戶經(jīng)常會誤觸到輪播組件,即有時手不小心滑過或點擊時也會觸發(fā) onTouch 類事件。對此我們可以采取對滑動距離添加閾值的方式來避免用戶誤觸,閾值可以是輪播元素寬度的 10% 或其他合理值,在每次滑動距離超過閾值時,才會觸發(fā)輪播組件后續(xù)的滑動。
桌面端適配:對于桌面端而言,輪播組件所需要響應(yīng)的事件名稱與移動端是完全不同的,但又可以相對應(yīng)地匹配起來。這里還需要注意的是,我們需要為輪播組件添加一個 dragging 的狀態(tài)來區(qū)分移動端與桌面端,從而安全地復(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é)
至此我們就實現(xiàn)了一個只有 tween-functions 一個第三方依賴的輪播組件,打包后大小不過 2KB,完整的源碼大家可以參考這里 carousel/index.js。
除了節(jié)省的代碼體積,更讓我們欣喜的還是徹底弄清楚了輪播組件的實現(xiàn)模式以及如何使用 requestAnimationFrame 配合 setState 來在 react 中完成一組動畫。
感想

大家應(yīng)該都看過上面這幅漫畫,有趣之余也蘊含著一個樸素卻深刻的道理,那就是在解決一個復(fù)雜問題時,最重要的是思路,但僅僅有思路也仍是遠遠不夠的,還需要具體的執(zhí)行方案。這個具體的執(zhí)行方案,必須是連續(xù)的,其中不可以欠缺任何一環(huán),不可以有任何思路或執(zhí)行上的跳躍。所以解決任何復(fù)雜問題都沒有銀彈也沒有捷徑,我們必須把它弄清楚,搞明白,然后才能真正地解決它。
?? 看完三件事
如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個小忙:
點個【在看】,或者分享轉(zhuǎn)發(fā),讓更多的人也能看到這篇內(nèi)容
關(guān)注公眾號【趣談前端】,不定期分享 前端工程化 / 可視化 / 低代碼 等技術(shù)文章。

