Vant 源碼解析——IndexBar

概述

本篇筆者來(lái)講解一下 index-barindex-anchor 的實(shí)現(xiàn)原理和細(xì)節(jié)處理,以及結(jié)合實(shí)際場(chǎng)景會(huì)對(duì)其進(jìn)行拓展,來(lái)實(shí)現(xiàn)Wechat通訊錄相似的功能,保證讓index-bar變得更加生動(dòng)有趣,滿足更多的業(yè)務(wù)場(chǎng)景。當(dāng)然筆者會(huì)結(jié)合自身的理解,已經(jīng)為每個(gè)核心的方法增加了必要的注釋,會(huì)盡最大努力將其中的原理講清楚,若有不妥之處,還望不吝賜教,歡迎批評(píng)指正。

預(yù)覽

index-bar.gif

層級(jí)結(jié)構(gòu)

index-bar :主要由 內(nèi)容van-index-bar__sidebar組成,van-index-bar__sidebar 主要就是用來(lái) 點(diǎn)擊或者觸摸滑動(dòng) 來(lái)滾動(dòng)到指定的錨點(diǎn)(index-anchor).

index-anchor :主要由一個(gè) div 包裹著一個(gè) van-index-anchor,其中 van-index-anchor 如果 吸頂 了會(huì)變成 fixed 定位,以及包裹他的父元素( div )會(huì)設(shè)置高度,用于彌補(bǔ)其脫離文檔流后的高度。

實(shí)現(xiàn)原理

筆者覺(jué)得 index-bar 中最核心的地方,在于滾動(dòng)過(guò)程中,錨點(diǎn)的吸頂?shù)奶幚?。其中主要包括:獲取哪個(gè)活躍的錨點(diǎn)將要吸頂,以及上一個(gè)活躍的錨點(diǎn)如何退場(chǎng)等。所以我們把核心點(diǎn)關(guān)注在:index-bar 所處的滾動(dòng)容器 scroller 的滾動(dòng)事件上。

mixins: [
  TouchMixin,
  ParentMixin('vanIndexBar'),
  BindEventMixin(function (bind) {
    // bind: on/off 函數(shù)
    if (!this.scroller) {
      this.scroller = getScroller(this.$el);
    }
    bind(this.scroller, 'scroll', this.onScroll);
  }),
],

onScroll() {
  if (isHidden(this.$el)) {
    return;
  }
  // 獲取滾動(dòng)容器的scrollTop
  const scrollTop = getScrollTop(this.scroller);
  // 返回滾動(dòng)容器元素的大小及其相對(duì)于視口的位置 因?yàn)闈L動(dòng)容器可能不是 window/body,而且也有可能距離視口頂部有一段距離
  const scrollerRect = this.getScrollerRect();
  // 計(jì)算每一個(gè)錨點(diǎn)在滾動(dòng)容器中的具體位置 top/height
  const rects = this.children.map((item) =>
    item.getRect(this.scroller, scrollerRect)
  );
  // 獲取當(dāng)前活躍的錨點(diǎn)
  const active = this.getActiveAnchorIndex(scrollTop, rects);

  this.activeAnchorIndex = this.indexList[active];

  if (this.sticky) {
    this.children.forEach((item, index) => {
      // 由于要設(shè)置 active 和 active-1 錨點(diǎn)的 fixed 屬性,所以要把其,父容器的寬高 繼承過(guò)來(lái)
      if (index === active || index === active - 1) {
        const rect = item.$el.getBoundingClientRect();
        item.left = rect.left;
        item.width = rect.width;
      } else {
        item.left = null;
        item.width = null;
      }

      // 核心代碼
      if (index === active) {
        // 這里錨點(diǎn)已經(jīng)是 fixed 定位
        item.active = true;
        
        // 計(jì)算top: 由于錨點(diǎn) fixed 定位的 top為0,這里設(shè)置的top 是用于設(shè)置自身錨點(diǎn)的transform.y
        // rects[index].top 是相對(duì)于滾動(dòng)容器的位置,是固定值
        // scrollTop: 是變量,向上滾動(dòng) 增大, 向下滾動(dòng) 減小
        item.top =
          Math.max(this.stickyOffsetTop, rects[index].top - scrollTop) +
          scrollerRect.top;
      } else if (index === active - 1) {
        // 由于涉及到上一個(gè)活躍錨點(diǎn) 會(huì)被新的活躍錨點(diǎn) 隨著滾動(dòng)而頂?shù)?        const activeItemTop = rects[active].top - scrollTop;
        // 是否活躍:當(dāng)活躍的錨點(diǎn)的頂部正好和滾動(dòng)容器的頂部重合
        item.active = activeItemTop > 0;
        // 設(shè)置其top
        item.top = activeItemTop + scrollerRect.top - rects[index].height;
      } else {
        item.active = false;
      }
    });
  }
},
// 獲取有效的錨點(diǎn)索引
getActiveAnchorIndex(scrollTop, rects) {
  // 細(xì)節(jié):從后往前遍歷 找到第一個(gè)滿足條件的錨點(diǎn)退出即可
  for (let i = this.children.length - 1; i >= 0; i--) {
    // 取出上一個(gè)活躍(吸頂)錨點(diǎn)的高度
    const prevHeight = i > 0 ? rects[i - 1].height : 0;
    const reachTop = this.sticky ? prevHeight + this.stickyOffsetTop : 0;
    // 判斷某個(gè)錨點(diǎn)第一次進(jìn)入臨界值 這里計(jì)算的都是相對(duì) 滾動(dòng)容器 來(lái)計(jì)算的 所以是統(tǒng)一坐標(biāo)系
    if (scrollTop + reachTop >= rects[i].top) {
      return i;
    }
  }
  return -1;
},

Web Api

筆者在看源碼的時(shí)候,發(fā)下了比較好用的API,很好的減輕了許多復(fù)雜邏輯處理,特此分享一下,希望大家多去 MDN Web Docs 翻翻好用的API。筆者列出的API,會(huì)的你就當(dāng)做復(fù)習(xí),不會(huì)的API,你就權(quán)當(dāng)學(xué)習(xí)啦。

拓展

美中不足的是 Vant 大大提供的 index-barindex-anchor 只能滿足一些基本所需,一些定制化的需求,比如微信通訊錄手機(jī)通訊錄等樣式,還不能提供友好的支撐,筆者這里站在巨人的肩膀上,手把手教大家實(shí)現(xiàn)Wechat通訊錄相似的功能。以及為index-bar增加更多的特性和拓展性。

而且,本次涉及的拓展,只是UI層面的東西,不會(huì)更改vant提供的核心原理(onScoll)的內(nèi)容,所以,咋們只關(guān)注UI相關(guān)的東西即可。Let's get it...

微信通訊錄

特性

  • 微信通訊錄的index-bar增加了點(diǎn)擊或者觸摸tag,會(huì)在tag左側(cè)彈出一個(gè)hint,且松手后,會(huì)回到index-bar最大能吸頂?shù)?code>tag和anchor
  • 可以設(shè)置tag觸摸或點(diǎn)擊,不彈出hint,比如搜索??``tag。
  • tag以及hint能支持用戶自定義,即提供插槽。

實(shí)現(xiàn)

針對(duì)特性一,我們需要監(jiān)聽(tīng)用戶的touchstart、touchmove、touchendtouchcancel觸摸事件,并且要知道當(dāng)前是觸摸index-bar的狀態(tài),還是滾動(dòng)內(nèi)容的狀態(tài),因?yàn)樯婕暗侥膫€(gè)index-bar上哪個(gè)tag高亮。具體代碼如下:

// 開(kāi)始觸摸
onTouchStart(event) {
  // 正在觸摸
  this.isTouching = true
  // 調(diào)用touch start方法
  this.touchStart(event)

  // 處理事件
  this.handleTouchEvent(event)
},

// 正在觸摸
onTouchMove(event) {
  this.touchMove(event);

  if (this.direction === 'vertical') {
    // 阻止默認(rèn)事件
    preventDefault(event);

    // 處理touch事件
    this.handleTouchEvent(event)
  }
},

// 結(jié)束或取消touch
onTouchEnd() {
  this.active = null;

  // 結(jié)束觸摸
  this.isTouching = false
},


// 觸摸事件處理
handleTouchEvent(event){
  const { clientX, clientY } = event.touches[0];
  // https://developer.mozilla.org/zh-CN/docs/Web/API/Document/elementFromPoint
  // 獲取點(diǎn)擊的元素
  const target = document.elementFromPoint(clientX, clientY);
  if (target) {
    // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
    // const { index } = target.dataset;
    const index = this.findDatasetIndex(target)

    /* istanbul ignore else */
    if (index && this.touchActiveIndex !== index) {
      
      this.touchActiveIndex = index;

      // 記錄手指觸摸下的索引
      this.touchActiveAnchorIndex = index

      this.scrollToElement(target);
    }
  }
},
// 渲染索引
renderIndexes(){
  return this.indexList.map((index) => {
    
    // const active = index === this.activeAnchorIndex;
    // 這里區(qū)分一下 按下和松手 這兩個(gè)狀態(tài)的 活躍索引 
    const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);
    const ignore = this.ignoreTags.some((value) => {
      return value === index
    })

    return (
      <span
        class={bem('index', { active })}
        style={active ? this.highlightStyle : null}
        data-index={index}
      >
        {this.renderIndexTag(index, active, ignore)}
        {this.renderIndexHint(index, active, ignore)}
      </span>
    );
  });
},

這里涉及到 isTouching 的設(shè)置,以及touchActiveAnchorIndex的記錄,這里會(huì)后面渲染索引列表中哪個(gè)tag高亮做準(zhǔn)備。

// 這里區(qū)分一下 按下和松手 這兩個(gè)狀態(tài)的 活躍索引 
const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);

tag左側(cè)彈出一個(gè)hint,利用子絕父相布局,這個(gè)功能比較好實(shí)現(xiàn)。即:一個(gè)父元素tag,就會(huì)對(duì)應(yīng)一個(gè)子元素hint。然后哪個(gè)tagactive并且isTouching = true時(shí),其子元素hint就會(huì)彈出。

針對(duì)特性二,點(diǎn)擊某個(gè)tag,不彈出hint,這個(gè)功能也比較簡(jiǎn)單,在index-barprops新增一個(gè)屬性,類型為string[] | number[]ignoreTags:忽略的Tags,這些忽略Tag, 不會(huì)高亮顯示,點(diǎn)擊或長(zhǎng)按 不會(huì)彈出 tagHint。

// 這里區(qū)分一下 按下和松手 這兩個(gè)狀態(tài)的 活躍索引 
const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);

// 去ignoreTags中查找,這個(gè)tag是否被忽略
const ignore = this.ignoreTags.some((value) => {
  return value === index
})

針對(duì)特性三,我們只需要為taghint提供一個(gè)具名插槽,并且拋出一個(gè)帶index,active,ignore三個(gè)參數(shù)的對(duì)象即可。這樣就可以滿足用戶的自定義了。具體代碼如下

// 渲染索引tag
renderIndexTag(index, active, ignore) {
  // 有插槽
  const slot = this.slots('tag', { index, active, ignore });
  if (slot) {
    return slot
  }

  // 默認(rèn)狀態(tài)下的樣式
  const style = {}
  // 活躍狀態(tài)且不忽略的場(chǎng)景下
  if (active&&!ignore) {
    if (this.highlightColor) {
      style.color = this.highlightColor;
    }
    if (this.highlightBackgroundColor) {
      style.backgroundColor = this.highlightBackgroundColor;
    }
  }
  return <span style={style} data-index={index}>{index}</span>
},

// 渲染索引Hint
renderIndexLeftHint(index, active, ignore) {
  // 顯示hint的場(chǎng)景
  const show = active && this.isTouching && !ignore
  // 獲取插槽內(nèi)容
  const slot = this.slots('hint', { index, active, ignore });
  
  if (slot) {
    return show ? slot : ''
  }

  // 默認(rèn)場(chǎng)景
  return (
    <div vShow={show} class={bem('hint','pop')}>
      <span>{index}</span>
    </div>
  )
}

如果用戶使用tag插槽的場(chǎng)景下,這里有個(gè)比較細(xì)節(jié)的地方,對(duì)于renderIndexTag,默認(rèn)不使用插槽時(shí),其內(nèi)容如下:<span style={style} data-index={index}>{index}</span> 這里我們可以看到這里有個(gè)data-index={index},因?yàn)?code>tag點(diǎn)擊事件或者sidbar觸摸事件,獲取對(duì)應(yīng)的索引都是通過(guò)const { index } = element.dataset;去獲取索引的,但是如果用戶自定義tag時(shí),用戶不會(huì)知道還要傳個(gè)data-index={index},導(dǎo)致傳統(tǒng)的方法const { index } = element.dataset;獲取的index為空。導(dǎo)致點(diǎn)擊無(wú)效。

解決辦法就是在tag的父元素身上也添加一個(gè)data-index={index},如果用戶在自定義tag傳了data-index={index},則使用用戶傳的index;反之,則使用其父元素提供的index。具體方法如下:

// 查詢dataset index
findDatasetIndex(target) {
  if (target) {
    const { index } = target.dataset;
    if (index) {
      return index
    }
    return this.findDatasetIndex(target.parentElement)
  }
  return undefined
},

手機(jī)通訊錄

手機(jī)通訊錄微信通信錄,可謂是如出一轍,唯一不同的就是,tagHint彈出的位置不同罷了,前者居中彈出,而后者是tag左側(cè)彈出。大家可能第一時(shí)間想到的就是依葫蘆畫(huà)瓢,把微信通訊錄hintposition: absolute;改成position: fixed;不就可以了么?理想很豐滿,現(xiàn)實(shí)很骨感 我只能這么說(shuō)!

由于van-index-bar__sidebarcss設(shè)置了transform: translateY(-50%);導(dǎo)致其子元素設(shè)置的position: fixed;都會(huì)失效。所以我們采用的是將hint放在van-index-bar中去即可。關(guān)鍵代碼如下:

// 渲染索引中間Hint
renderIndexCenterHint() {

  if (this.hintType !== 'center') {
    return null
  }

  const index = this.touchActiveAnchorIndex
  const active = index !== null
  const ignore = this.ignoreTags.some((value) => {
    return value === index
  })

  // 顯示hint的場(chǎng)景
  const show = active && this.isTouching && !ignore
  // 獲取插槽內(nèi)容
  const slot = this.slots('hint', { index, active, ignore });
  
  if (slot) {
    return show ? slot : ''
  }

  // 默認(rèn)場(chǎng)景
  return (
    <div vShow={show} class={bem('hint','pop-center')}>
      <span>{index}</span>
    </div>
  )
}
// UI層
render() {
  const Indexes = this.renderIndexes()
  const centerHint = this.renderIndexCenterHint()
  return (
    <div class={bem()}>
      <div
        class={bem('sidebar')}
        style={this.sidebarStyle}
        onClick={this.onClick}
        onTouchstart={this.onTouchStart}
        onTouchmove={this.onTouchMove}
        onTouchend={this.onTouchEnd}
        onTouchcancel={this.onTouchEnd}
      >
        {Indexes}
      </div>
      {this.slots('default')}
      {centerHint}
    </div>
  );
}

期待

  1. 文章若對(duì)您有些許幫助,請(qǐng)給個(gè)喜歡??,畢竟碼字不易;若對(duì)您沒(méi)啥幫助,請(qǐng)給點(diǎn)建議??,切記學(xué)無(wú)止境。
  2. 針對(duì)文章所述內(nèi)容,閱讀期間任何疑問(wèn);請(qǐng)?jiān)谖恼碌撞吭u(píng)論指出,我會(huì)火速解決和修正問(wèn)題。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源碼地址:vant-learn
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容