組件庫設計實戰 – 複雜組件設計

  • 2019 年 10 月 10 日
  • 筆記

作者:誠身 https://zhuanlan.zhihu.com/p/29034015

一個成熟的組件庫通常都由數十個常用的 UI 組件構成,這其中既有按鈕(Button),輸入框(Input)等基礎組件,也有表格(Table),日期選擇器(DatePicker),輪播(Carousel)等自成一體的複雜組件。

這裡我們提出一個組件複雜度的概念,一個組件複雜度的主要來源就是其自身的狀態,即組件自身需要維護多少個不依賴於外部輸入的狀態。參考原先文章中提到過的木偶組件(dumb component)與智慧組件(smart component),二者的區別就是是否需要在組件內部維護不依賴於外部輸入的狀態。

實戰案例 – 輪播組件 在本篇文章中,我們將以輪播(Carousel)組件為例,一步一步還原如何實現一個交互流暢的輪播組件。

最簡單的輪播組件 拋去所有複雜的功能,輪播組件的實質,實際上就是在一個固定區域實現不同元素之間的切換。在明確了這點後,我們就可以設計輪播組件的基礎 DOM 結構為:

<Frame>    <SlideList>      <SlideItem />      ...      <SlideItem />    </SlideList>  </Frame>  

如下圖所示:

Frame 即輪播組件的真實顯示區域,其寬高為內部由使用者輸入的 SlideItem 決定。這裡需要注意的一點是需要設置 Frameoverflow 屬性為 hidden,即隱藏超出其本身寬高的部分,每次只顯示一個 SlideItem

SlideList 為輪播組件的軌道容器,改變其 translateX 的值即可實現在軌道的滑動,以顯示不同的輪播元素。

SlideItem 是使用者輸入的輪播元素的一層抽象,內部可以是 imgdivDOM 元素,並不影響輪播組件本身的邏輯。

實現輪播元素之前的切換

為了實現在不同 SlideItem 之間的切換,我們需要定義輪播組件的第一個內部狀態,即 currentIndex,即當前顯示輪播元素的 index 值。上文中我們提到了改變 SlideListtranslateX 是實現輪播元素切換的關鍵,所以這裡我們需要將 currentIndexSlideListtranslateX 對應起來,即:

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,即可間接改變 SlideListtranslateX,以此實現輪播元素之間的切換。

響應用戶操作

輪播作為一個常見的通用組件,在桌面和移動端都有著非常廣泛的應用,這裡我們先以移動端為例,來闡述如何響應用戶操作。

{map(children, (child, i) => (    <div      className="slideItem"      role="presentation"      key={i}      style={{ width }}      onTouchStart={this.handleTouchStart}      onTouchMove={this.handleTouchMove}      onTouchEnd={this.handleTouchEnd}    >      {child}    </div>  ))}  

在移動端,我們需要監聽三個事件,分別響應滑動開始,滑動中與滑動結束。其中滑動開始與滑動結束都是一次性事件,而滑動中則是持續性事件,以此我們可以確定在三個事件中我們分別需要確定哪些值。

滑動開始

  • 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,    });  }  

滑動結束

  • currentIndex:此次滑動結束後新的 currentIndex
  • endValue:此次滑動結束後軌道的 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,我們的輪播組件便可以做到跟手的用戶體驗,即在單次滑動中,輪播元素會跟隨用戶的操作向左或向右滑動。

實現順滑的切換動畫

在實現了滑動中跟手的用戶體驗後,我們還需要在滑動結束後將顯示的輪播元素定位到新的 currentIndex。根據用戶的滑動方向,我們可以對當前的 currentIndex 進行 +1 或 -1 以得到新的 currentIndex。但在處理第一個元素向左滑動或最後一個元素向右滑動時,新的 currentIndex 需要更新為最後一個或第一個。

這裡的邏輯並不複雜,但卻帶來了一個非常難以解決的用戶體驗問題,那就是假設我們有 3 個輪播元素,每個輪播元素的寬度都為 300px,即顯示最後一個元素時,軌道的 translateX 為 -600px,在我們將最後一個元素向左滑動後,軌道的 translateX 將被重新定義為 0px,此時若我們使用原生的 CSS 動畫:

transition: 1s ease-in-out;  

軌道將會在一秒內從左向右滑動至第一個輪播元素,而這是反直覺的,因為用戶一個向左滑動的操作導致了一個向右的動畫,反之亦然。

這個問題從上古時期就困擾著許多前端開發者,筆者也見過以下幾種解決問題的方法:

  • 將軌道寬度定義為無限長(幾百萬 px),無限次重複有限的輪播元素。這種解決方案顯然是一種 hack,並沒有從實質上解決輪播組件的問題。
  • 只渲染三個輪播元素,即前一個,當前一個,下一個,每次滑動後同時更新三個元素。這種解決方案實現起來非常複雜,因為組件內部要維護的狀態從一個 currentIndex 增加到了三個擁有各自狀態的 DOM 元素,且因為要不停的刪除和新增 DOm 節點導致性能不佳。

這裡讓我們再來思考一下滑動操作的本質。除去第一和最後兩個元素,所有中間元素滑動後新的 translateX 的值都是固定的,即 -(width * currentIndex),這種情況下的動畫都可以輕鬆地完美實現。而在最後一個元素向左滑動時,因為軌道的 translateX 已經到達了極限,面對這種情況我們如何才能實現順滑的切換動畫呢?

這裡我們選擇將最後一個及第一個元素分別拼接至軌道的頭尾,以保證在 DOM 結構不需要改變的前提下實現順滑的切換動畫:

這樣我們就統一了每次滑動結束後 endValue 的計算方式,即

// left  endValue = -(width) * (currentIndex + 1)    // right  endValue = -(width) * (currentIndex - 1)  

使用 requestAnimationFrame 實現高性能動畫

requestAnimationFrame 是瀏覽器提供的一個專註於實現動畫的 API,感興趣的朋友可以再重溫一下《React Motion 緩動函數剖析》這篇專欄。

所有的動畫本質上都是一連串的時間軸上的值,具體到輪播場景下即:以用戶停止滑動時的值為起始值,以新 currentIndextranslateX 的值為結束值,在使用者設定的動畫時間(如0.5秒)內,依據使用者設定的緩動函數,計算每一幀動畫時的 translateX 值並最終得到一個數組,以每秒 60 幀的速度更新在軌道的 style 屬性上。每更新一次,將消耗掉動畫值數組中的一個中間值,直到數組中所有的中間值被消耗完畢,動畫結束並觸發回調。

具體程式碼如下:

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;  }  

在回調函數中,根據變動邏輯統一確定組件當前新的穩定態值:

handleOperationEnd = (newIndex) => {    const { width } = this.state;      this.setState({      currentIndex: newIndex,      translateX: -(width) * newIndex,      startPositionX: 0,      moveDeltaX: 0,      dragging: false,      direction: null,    });  }  

完成後的輪播組件效果如下圖:

優雅地處理特殊情況

  • 處理用戶誤觸:在移動端,用戶經常會誤觸到輪播組件,即有時手不小心滑過或點擊時也會觸發 onTouch 類事件。對此我們可以採取對滑動距離添加閾值的方式來避免用戶誤觸,閾值可以是輪播元素寬度的 10% 或其他合理值,在每次滑動距離超過閾值時,才會觸發輪播組件後續的滑動。
  • 桌面端適配:對於桌面端而言,輪播組件所需要響應的事件名稱與移動端是完全不同的,但又可以相對應地匹配起來。這裡還需要注意的是,我們需要為輪播組件添加一個 dragging 的狀態來區分移動端與桌面端,從而安全地復用 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();    }  }  

小結

至此我們就實現了一個只有 tween-functions 一個第三方依賴的輪播組件,打包後大小不過 2KB,完整的源碼大家可以參考這裡 carousel/index.js。

除了節省的程式碼體積,更讓我們欣喜的還是徹底弄清楚了輪播組件的實現模式以及如何使用 requestAnimationFrame 配合 setState 來在 react 中完成一組動畫。

感想

大家應該都看過上面這幅漫畫,有趣之餘也蘊含著一個樸素卻深刻的道理,那就是在解決一個複雜問題時,最重要的是思路,但僅僅有思路也仍是遠遠不夠的,還需要具體的執行方案。這個具體的執行方案,必須是連續的,其中不可以欠缺任何一環,不可以有任何思路或執行上的跳躍。所以解決任何複雜問題都沒有銀彈也沒有捷徑,我們必須把它弄清楚,搞明白,然後才能真正地解決它。

至此,組件庫設計實戰系列文章也將告一段落。在全部四篇文章中,我們分別討論了組件庫架構,組件分類,文檔組織,國際化以及複雜組件設計這幾個核心的話題,因筆者能力所限,其中自然有許多不足之處,煩請各位諒解。

組件庫作為提升前端團隊工作效率的重中之重,花再多時間去研究它都不為過。再加上與設計團隊對接,形成設計語言,與後端團隊對接,統一數據結構,組件庫也可以說是前端工程師在拓展自身工作領域上的必經之路。

不要害怕重複造輪子,關鍵是每造一次輪子後,從中學到了什麼。

與各位共勉。