Vant 源码解析——IndexBar

概述

本篇笔者来讲解一下 index-barindex-anchor 的实现原理和细节处理,以及结合实际场景会对其进行拓展,来实现Wechat通讯录相似的功能,保证让index-bar变得更加生动有趣,满足更多的业务场景。当然笔者会结合自身的理解,已经为每个核心的方法增加了必要的注释,会尽最大努力将其中的原理讲清楚,若有不妥之处,还望不吝赐教,欢迎批评指正。

预览

index-bar.gif

层级结构

index-bar :主要由 内容van-index-bar__sidebar组成,van-index-bar__sidebar 主要就是用来 点击或者触摸滑动 来滚动到指定的锚点(index-anchor).

index-anchor :主要由一个 div 包裹着一个 van-index-anchor,其中 van-index-anchor 如果 吸顶 了会变成 fixed 定位,以及包裹他的父元素( div )会设置高度,用于弥补其脱离文档流后的高度。

实现原理

笔者觉得 index-bar 中最核心的地方,在于滚动过程中,锚点的吸顶的处理。其中主要包括:获取哪个活跃的锚点将要吸顶,以及上一个活跃的锚点如何退场等。所以我们把核心点关注在:index-bar 所处的滚动容器 scroller 的滚动事件上。

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

onScroll() {
  if (isHidden(this.$el)) {
    return;
  }
  // 获取滚动容器的scrollTop
  const scrollTop = getScrollTop(this.scroller);
  // 返回滚动容器元素的大小及其相对于视口的位置 因为滚动容器可能不是 window/body,而且也有可能距离视口顶部有一段距离
  const scrollerRect = this.getScrollerRect();
  // 计算每一个锚点在滚动容器中的具体位置 top/height
  const rects = this.children.map((item) =>
    item.getRect(this.scroller, scrollerRect)
  );
  // 获取当前活跃的锚点
  const active = this.getActiveAnchorIndex(scrollTop, rects);

  this.activeAnchorIndex = this.indexList[active];

  if (this.sticky) {
    this.children.forEach((item, index) => {
      // 由于要设置 active 和 active-1 锚点的 fixed 属性,所以要把其,父容器的宽高 继承过来
      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) {
        // 这里锚点已经是 fixed 定位
        item.active = true;
        
        // 计算top: 由于锚点 fixed 定位的 top为0,这里设置的top 是用于设置自身锚点的transform.y
        // rects[index].top 是相对于滚动容器的位置,是固定值
        // scrollTop: 是变量,向上滚动 增大, 向下滚动 减小
        item.top =
          Math.max(this.stickyOffsetTop, rects[index].top - scrollTop) +
          scrollerRect.top;
      } else if (index === active - 1) {
        // 由于涉及到上一个活跃锚点 会被新的活跃锚点 随着滚动而顶掉
        const activeItemTop = rects[active].top - scrollTop;
        // 是否活跃:当活跃的锚点的顶部正好和滚动容器的顶部重合
        item.active = activeItemTop > 0;
        // 设置其top
        item.top = activeItemTop + scrollerRect.top - rects[index].height;
      } else {
        item.active = false;
      }
    });
  }
},
// 获取有效的锚点索引
getActiveAnchorIndex(scrollTop, rects) {
  // 细节:从后往前遍历 找到第一个满足条件的锚点退出即可
  for (let i = this.children.length - 1; i >= 0; i--) {
    // 取出上一个活跃(吸顶)锚点的高度
    const prevHeight = i > 0 ? rects[i - 1].height : 0;
    const reachTop = this.sticky ? prevHeight + this.stickyOffsetTop : 0;
    // 判断某个锚点第一次进入临界值 这里计算的都是相对 滚动容器 来计算的 所以是统一坐标系
    if (scrollTop + reachTop >= rects[i].top) {
      return i;
    }
  }
  return -1;
},

Web Api

笔者在看源码的时候,发下了比较好用的API,很好的减轻了许多复杂逻辑处理,特此分享一下,希望大家多去 MDN Web Docs 翻翻好用的API。笔者列出的API,会的你就当做复习,不会的API,你就权当学习啦。

  • getBoundingClientRect
  • elementFromPoint
  • dataset
  • scrollIntoView

拓展

美中不足的是 Vant 大大提供的 index-barindex-anchor 只能满足一些基本所需,一些定制化的需求,比如微信通讯录手机通讯录等样式,还不能提供友好的支撑,笔者这里站在巨人的肩膀上,手把手教大家实现Wechat通讯录相似的功能。以及为index-bar增加更多的特性和拓展性。

而且,本次涉及的拓展,只是UI层面的东西,不会更改vant提供的核心原理(onScoll)的内容,所以,咋们只关注UI相关的东西即可。Let's get it...

微信通讯录

特性

  • 微信通讯录的index-bar增加了点击或者触摸tag,会在tag左侧弹出一个hint,且松手后,会回到index-bar最大能吸顶的taganchor
  • 可以设置tag触摸或点击,不弹出hint,比如搜索``tag
  • tag以及hint能支持用户自定义,即提供插槽。

实现

针对特性一,我们需要监听用户的touchstarttouchmovetouchendtouchcancel触摸事件,并且要知道当前是触摸index-bar的状态,还是滚动内容的状态,因为涉及到哪个index-bar上哪个tag高亮。具体代码如下:

// 开始触摸
onTouchStart(event) {
  // 正在触摸
  this.isTouching = true
  // 调用touch start方法
  this.touchStart(event)

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

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

  if (this.direction === 'vertical') {
    // 阻止默认事件
    preventDefault(event);

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

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

  // 结束触摸
  this.isTouching = false
},


// 触摸事件处理
handleTouchEvent(event){
  const { clientX, clientY } = event.touches[0];
  // https://developer.mozilla.org/zh-CN/docs/Web/API/Document/elementFromPoint
  // 获取点击的元素
  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;
    // 这里区分一下 按下和松手 这两个状态的 活跃索引 
    const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);
    const ignore = this.ignoreTags.some((value) => {
      return value === index
    })

    return (
      
        {this.renderIndexTag(index, active, ignore)}
        {this.renderIndexHint(index, active, ignore)}
      
    );
  });
},

这里涉及到 isTouching 的设置,以及touchActiveAnchorIndex的记录,这里会后面渲染索引列表中哪个tag高亮做准备。

// 这里区分一下 按下和松手 这两个状态的 活跃索引 
const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);

tag左侧弹出一个hint,利用子绝父相布局,这个功能比较好实现。即:一个父元素tag,就会对应一个子元素hint。然后哪个tagactive并且isTouching = true时,其子元素hint就会弹出。

针对特性二,点击某个tag,不弹出hint,这个功能也比较简单,在index-barprops新增一个属性,类型为string[] | number[]ignoreTags:忽略的Tags,这些忽略Tag, 不会高亮显示,点击或长按 不会弹出 tagHint

// 这里区分一下 按下和松手 这两个状态的 活跃索引 
const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);

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

针对特性三,我们只需要为taghint提供一个具名插槽,并且抛出一个带indexactiveignore三个参数的对象即可。这样就可以满足用户的自定义了。具体代码如下

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

  // 默认状态下的样式
  const style = {}
  // 活跃状态且不忽略的场景下
  if (active&&!ignore) {
    if (this.highlightColor) {
      style.color = this.highlightColor;
    }
    if (this.highlightBackgroundColor) {
      style.backgroundColor = this.highlightBackgroundColor;
    }
  }
  return {index}
},

// 渲染索引Hint
renderIndexLeftHint(index, active, ignore) {
  // 显示hint的场景
  const show = active && this.isTouching && !ignore
  // 获取插槽内容
  const slot = this.slots('hint', { index, active, ignore });
  
  if (slot) {
    return show ? slot : ''
  }

  // 默认场景
  return (
    
{index}
) }

如果用户使用tag插槽的场景下,这里有个比较细节的地方,对于renderIndexTag,默认不使用插槽时,其内容如下:{index} 这里我们可以看到这里有个data-index={index},因为tag点击事件或者sidbar触摸事件,获取对应的索引都是通过const { index } = element.dataset;去获取索引的,但是如果用户自定义tag时,用户不会知道还要传个data-index={index},导致传统的方法const { index } = element.dataset;获取的index为空。导致点击无效。

解决办法就是在tag的父元素身上也添加一个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
},

手机通讯录

手机通讯录微信通信录,可谓是如出一辙,唯一不同的就是,tagHint弹出的位置不同罢了,前者居中弹出,而后者是tag左侧弹出。大家可能第一时间想到的就是依葫芦画瓢,把微信通讯录hintposition: absolute;改成position: fixed;不就可以了么?理想很丰满,现实很骨感 我只能这么说!

由于van-index-bar__sidebarcss设置了transform: translateY(-50%);导致其子元素设置的position: fixed;都会失效。所以我们采用的是将hint放在van-index-bar中去即可。关键代码如下:

// 渲染索引中间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的场景
  const show = active && this.isTouching && !ignore
  // 获取插槽内容
  const slot = this.slots('hint', { index, active, ignore });
  
  if (slot) {
    return show ? slot : ''
  }

  // 默认场景
  return (
    
{index}
) } // UI层 render() { const Indexes = this.renderIndexes() const centerHint = this.renderIndexCenterHint() return (
{Indexes}
{this.slots('default')} {centerHint}
); }

期待

  1. 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议,切记学无止境。
  2. 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源码地址:vant-learn

你可能感兴趣的:(Vant 源码解析——IndexBar)