元件庫設計實戰系列:複雜元件設計

誠身發表於2017-11-07

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

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

實戰案例 - 輪播元件

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

最簡單的輪播元件

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

<Frame>
  <SlideList>
    <SlideItem />
    ...
    <SlideItem />
  </SlideList>
</Frame>複製程式碼

如下圖所示:

carousel

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

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

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

實現輪播元素之前的切換

為了實現在不同 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 結構不需要改變的前提下實現順滑的切換動畫:

carousel-long

這樣我們就統一了每次滑動結束後 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,
  });
}複製程式碼

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

carousel

優雅地處理特殊情況

  • 處理使用者誤觸:在移動端,使用者經常會誤觸到輪播元件,即有時手不小心滑過或點選時也會觸發 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 中完成一組動畫。

感想

horse

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

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

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

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

與各位共勉。


相關文章