<kbd id="5sdj3"></kbd>
<th id="5sdj3"></th>

  • <dd id="5sdj3"><form id="5sdj3"></form></dd>
    <td id="5sdj3"><form id="5sdj3"><big id="5sdj3"></big></form></td><del id="5sdj3"></del>

  • <dd id="5sdj3"></dd>
    <dfn id="5sdj3"></dfn>
  • <th id="5sdj3"></th>
    <tfoot id="5sdj3"><menuitem id="5sdj3"></menuitem></tfoot>

  • <td id="5sdj3"><form id="5sdj3"><menu id="5sdj3"></menu></form></td>
  • <kbd id="5sdj3"><form id="5sdj3"></form></kbd>

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

    共 10485字,需瀏覽 21分鐘

     ·

    2023-03-07 22:07

    作者:誠(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è)置 Frameoverflow 屬性為 hidden,即隱藏超出其本身寬高的部分,每次只顯示一個(gè) SlideItem。

    SlideList 為輪播組件的軌道容器,改變其 translateX 的值即可實(shí)現(xiàn)在軌道的滑動(dòng),以顯示不同的輪播元素。

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

    實(shí)現(xiàn)輪播元素之前的切換

    為了實(shí)現(xiàn)在不同 SlideItem 之間的切換,我們需要定義輪播組件的第一個(gè)內(nèi)部狀態(tài),即 currentIndex,即當(dāng)前顯示輪播元素的 index 值。上文中我們提到了改變 SlideListtranslateX 是實(shí)現(xiàn)輪播元素切換的關(guān)鍵,所以這里我們需要將 currentIndexSlideListtranslateX 對(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,即可間接改變 SlideListtranslateX,以此實(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é)到了什么。

    與各位共勉。


    歡迎評(píng)論區(qū)留下你的精彩評(píng)論~

    覺(jué)得文章不錯(cuò)可以分享到朋友圈讓更多的小伙伴看到哦~

    客官!在看一下
    瀏覽 61
    點(diǎn)贊
    評(píng)論
    收藏
    分享

    手機(jī)掃一掃分享

    分享
    舉報(bào)
    評(píng)論
    圖片
    表情
    推薦
    點(diǎn)贊
    評(píng)論
    收藏
    分享

    手機(jī)掃一掃分享

    分享
    舉報(bào)

    <kbd id="5sdj3"></kbd>
    <th id="5sdj3"></th>

  • <dd id="5sdj3"><form id="5sdj3"></form></dd>
    <td id="5sdj3"><form id="5sdj3"><big id="5sdj3"></big></form></td><del id="5sdj3"></del>

  • <dd id="5sdj3"></dd>
    <dfn id="5sdj3"></dfn>
  • <th id="5sdj3"></th>
    <tfoot id="5sdj3"><menuitem id="5sdj3"></menuitem></tfoot>

  • <td id="5sdj3"><form id="5sdj3"><menu id="5sdj3"></menu></form></td>
  • <kbd id="5sdj3"><form id="5sdj3"></form></kbd>
    日韩精品成人一区二区三区蜜桃 | 欧美pmⅴ | 中文在线A∨在线 | 国产精品成人一区二区三区电影毛片 | 大粗鸡巴久久久 |