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

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

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

實戰案例 - 輪播組件

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

最簡單的輪播組件

拋去全部複雜的功能,輪播組件的實質,實際上就是在一個固定區域實現不一樣元素之間的切換。在明確了這點後,咱們就能夠設計輪播組件的基礎 DOM 結構爲:git

<Frame>
  <SlideList>
    <SlideItem />
    ...
    <SlideItem />
  </SlideList>
</Frame>複製代碼

以下圖所示:github

carousel

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

SlideList 爲輪播組件的軌道容器,改變其 translateX 的值便可實如今軌道的滑動,以顯示不一樣的輪播元素。數組

SlideItem 是使用者輸入的輪播元素的一層抽象,內部能夠是 imgdiv 等 DOM 元素,並不影響輪播組件自己的邏輯。瀏覽器

實現輪播元素以前的切換

爲了實如今不一樣 SlideItem 之間的切換,咱們須要定義輪播組件的第一個內部狀態,即 currentIndex,即當前顯示輪播元素的 index 值。上文中咱們提到了改變 SlideListtranslateX 是實現輪播元素切換的關鍵,因此這裏咱們須要將 currentIndexSlideListtranslateX 對應起來,即:安全

translateX = -(width) * currentIndex複製代碼

width 即爲單個輪播元素的寬度,與 Frame 的寬度相同,因此咱們能夠在 componentDidMount 時拿到 Frame 的寬度並以此計算出軌道的總寬度。bash

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

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

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

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

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

與各位共勉。

相關文章
相關標籤/搜索