mint-ui 源码学习四 —— 列表相关组件学习

除了一些基础的组件,几个列表组件让我非常好奇。所以这里来学习一下 loadmore infinite scrollindex list 这四个组件。
PS:我通过问答的方式有针对性的解决这些组件的一些问题,如果有其他问题欢迎在本文后面留言一起探讨。

loadmore

如何实现拖拽效果并触发 load more 行为?

首先来看看如何对列表进行拖拽,首先是在 loadmore 组件最外部的 div 上添加了动态的 style 属性:

在 computed 方法中通过 translate 属性的变化计算出 transform 的变化结果。

computed: {
  transform() {
    return this.translate === 0 ? null : 'translate3d(0, ' + this.translate + 'px, 0)';
  }
},

至此,只要根据初始位置和手指移动的位置计算距离即可实现列表随手指拖动。
可以从 touch 事件中找到拖拽开始、移动和结束的具体做法。下面是 3 个 touch 事件的绑定。

// 绑定 touch事件
bindTouchEvents() {
  this.$el.addEventListener('touchstart', this.handleTouchStart);
  this.$el.addEventListener('touchmove', this.handleTouchMove);
  this.$el.addEventListener('touchend', this.handleTouchEnd);
},

在 touchmove 事件中计算了 translate 来实现列表拖拽移动。代码如下(部分代码):

// touch move
handleTouchMove(event) {
  if (this.startY < this.$el.getBoundingClientRect().top && this.startY > this.$el.getBoundingClientRect().bottom) {
    return;
  }
  this.currentY = event.touches[0].clientY;
  let distance = (this.currentY - this.startY) / this.distanceIndex;
  this.direction = distance > 0 ? 'down' : 'up';
  if (typeof this.topMethod === 'function' && this.direction === 'down' &&
    this.getScrollTop(this.scrollEventTarget) === 0 && this.topStatus !== 'loading') {
    event.preventDefault();
    event.stopPropagation();
    if (this.maxDistance > 0) {
      this.translate = distance <= this.maxDistance ? distance - this.startScrollTop : this.translate;
    } else {
      this.translate = distance - this.startScrollTop;
    }
    if (this.translate < 0) {
      this.translate = 0;
    }
    this.topStatus = this.translate >= this.topDistance ? 'drop' : 'pull';
  }

  if (this.direction === 'up') {
    this.bottomReached = this.bottomReached || this.checkBottomReached();
  }
  if (typeof this.bottomMethod === 'function' && this.direction === 'up' &&
    // 内容与 bottom 类似
  }
  this.$emit('translate-change', this.translate);
},

当列表在正常区域内拖动时,不触发下拉上拉事件,直接 return。
当列表下拉一定距离后触发 pull down 的行为计算 translate 并判断下拉状态是 pull 还是 drop。从而实现列表下拉拉伸的界面行为。
最后触发 translate-change 监听事件。
最后,来说下如何触发 load more 的行为。即下拉后放手的行为判断。这里就得看到 touchend 行为了。

handleTouchEnd() {
  if (this.direction === 'down' && this.getScrollTop(this.scrollEventTarget) === 0 && this.translate > 0) {
    this.topDropped = true;
    if (this.topStatus === 'drop') {
      this.translate = '50';
      this.topStatus = 'loading';
      // run topMethod function after drop element
      this.topMethod();
    } else {
      this.translate = '0';
      this.topStatus = 'pull';
      // just change status,do nothing
    }
  }
  // ditto
  if (this.direction === 'up' && this.bottomReached && this.translate < 0) {
    // ……
  }
  this.$emit('translate-change', this.translate);
  this.direction = '';
}

当下拉距离足够时,topStatus 为 drop。那么就会触发用户自定义的 topMethod 方法,并且触发加载中界面行为。
而下拉距离不足时,只会将元素的 translate 变为 0 产生列表回弹到正常样式的界面行为。

如何实现 autoFill 行为?

在 loadmore 中有一个 autoFill 属性。当属性为 true 则组件会自动判断元素边界内列表是否填充满了,如果不满会触发填充行为。
看源码:

fillContainer() {
  if (this.autoFill) {
    this.$nextTick(() => {
      if (this.scrollEventTarget === window) {
        this.containerFilled = this.$el.getBoundingClientRect().bottom >=
          document.documentElement.getBoundingClientRect().bottom;
      } else {
        this.containerFilled = this.$el.getBoundingClientRect().bottom >=
          this.scrollEventTarget.getBoundingClientRect().bottom;
      }
      if (!this.containerFilled) {
        this.bottomStatus = 'loading';
        // bottomMethod function in props
        this.bottomMethod();
      }
    });
  }
},

这个方法主要做了两件事,一是判断列表内容是否完全填充容器,二是当容器未被填充完毕则执行 bottomMethod 方法进行 loading 行为(从这里也可以看出 autoFill 属性只作用于上拉加载更多上)。
那么这里有一个疑问。可能会有容器太大需要多次请求数据才能填充完整,那这是如何实现的呢?
这得看下 fillContainer 方法用在了哪里。它分别用在了 init 和 onBottomLoaded 方法中。其中 init 方法是在组件初始化的时候执行的,这很好理解初始化填充一次数据。而多次填充数据的关键在于 onBottomLoaded 方法。先看下 onBottomLoaded 方法:

onBottomLoaded() {
  this.bottomStatus = 'pull';
  this.bottomDropped = false;
  this.$nextTick(() => {
    if (this.scrollEventTarget === window) {
      document.body.scrollTop += 50;
    } else {
      this.scrollEventTarget.scrollTop += 50;
    }
    this.translate = 0;
  });
  if (!this.bottomAllLoaded && !this.containerFilled) {
    this.fillContainer();
  }
},

可以看到 onBottomLoaded 方法中调用了 fillContainer 方法。而这个 onBottomLoaded 方法的用法如下:

loadBottom() {
  setTimeout(() => {
    this.$refs.loadmore.onBottomLoaded();
  }, 1500);
}

这里的 loadBottom 就是组件的 bottomMethod 方法,而在 bottomMethod 方法中使用 this.refs.loadmore 来获取组件实例并调用 onBottomLoaded() 方法。这样一来就可以理解不断请求数据填充列表的行为了:

init() -> fillContainer() -> bottomMethod() -> onBottomLoaded() -> fillContainer() -> bottomMethod() -> onBottomLoaded() -> fillContainer() -> bottomMethod() -> onBottomLoaded() -> 填充满

加载中的文本和图标从何而来

这个问题可以通过看下 loadmore 组件的 HTML 代码来解决:

  
    
{{ topText }}
{{ bottomText }}

可以看到其中有 3 个 slot 标签,第一个 slot 是显示下拉刷新的内容,第二个 slot 用于显示列表内容,第三个 slot 用于显示加载更多的内容。
其中 top 和 bottom 中的显示内容都可以自定义甚至直接替换掉。

如何在上拉时拼接列表

用法如下:

loadBottom() {
  setTimeout(() => {
    let lastValue = this.list[this.list.length - 1];
    if (lastValue < 40) {
      for (let i = 1; i <= 10; i++) {
        this.list.push(lastValue + i);
      }
    } else {
      this.allLoaded = true;
    }
    this.$refs.loadmore.onBottomLoaded();
  }, 1500);
}

数据拼接直接添加到 list 数组中,而加载完后的行为控制使用 this.$refs.loadmore.onBottomLoaded(); 方法来完成。

infinite scroll

说完了 loadmore 再来看下貌似和 loadmore 差不多的 infinite scroll 有何玄机。

和 pull down 有何不同?

最大的不同就是 loadmore 是组件,而 infinite scroll 是命令。即使用了 Vue.directive('InfiniteScroll', InfiniteScroll); 方法来注册或获取全局指令。

实现步骤

从入口文件往前推:

  1. infinite scroll 是一个插件形式全局安装的。
// index.js
Vue.use(InfiniteScroll);
  1. 从插件的定义中,可以看到使用了 Vue.directive() 方法来注册全局指令的。即 v-infinite-scroll
const install = function(Vue) {
  // v-infinite-scroll
  Vue.directive('InfiniteScroll', InfiniteScroll);
};
  1. 从 InfiniteScroll 中可以看到它就是个 Vue 自定义指令的定义。
export default {
  bind(el, binding, vnode) {}
  unbind(el, binding, vnode) {}
}

如何实现监听滚动到列表底部,并且执行加载方法的?

从上面的代码中可以知道关键在 Vue 指令的 bind(el, binding, vnode) {} 方法中。

bind(el, binding, vnode) {
  el[ctx] = {
    el,
    vm: vnode.context,
    expression: binding.value
  };
  const args = arguments;
  var cb = function() {
    el[ctx].vm.$nextTick(function() {
      if (isAttached(el)) {
        doBind.call(el[ctx], args);
      }

      el[ctx].bindTryCount = 0;

      var tryBind = function() {
        if (el[ctx].bindTryCount > 10) return; //eslint-disable-line
        el[ctx].bindTryCount++;
        if (isAttached(el)) {
          doBind.call(el[ctx], args);
        } else {
          setTimeout(tryBind, 50);
        }
      };

      tryBind();
    });
  };
  if (el[ctx].vm._isMounted) {
    cb();
    return;
  }
  el[ctx].vm.$on('hook:mounted', cb);
},

以上代码主要做了 3 件事:定义元素对象;定义回调函数;监听并触发回调函数。
由于我们的目的是找到滑动到底部的行为监听,所以关键在于 doBind.call(el[ctx], args); 方法上。
在 doBind 方法中有这么一段监听元素滚动的行为:

  var directive = this;
  var element = directive.el;

  directive.scrollEventTarget = getScrollEventTarget(element);
  directive.scrollListener = throttle(doCheck.bind(directive), 200);
  directive.scrollEventTarget.addEventListener('scroll', directive.scrollListener);

可以看到 scroll 事件监听的是 scrollListener 方法,而 scrollListener 就是防抖执行了 doCheck 方法。注意 doCheck 的 bind 方法绑定的上下文其实是 el[ctx] 对象。
顺着这个思路看下 doCheck() 方法:

var doCheck = function(force) {
  var scrollEventTarget = this.scrollEventTarget;
  var element = this.el;
  var distance = this.distance;

  if (force !== true && this.disabled) return; //eslint-disable-line
  var viewportScrollTop = getScrollTop(scrollEventTarget);
  var viewportBottom = viewportScrollTop + getVisibleHeight(scrollEventTarget);

  var shouldTrigger = false;

  if (scrollEventTarget === element) {
    shouldTrigger = scrollEventTarget.scrollHeight - viewportBottom <= distance;
  } else {
    var elementBottom = getElementTop(element) - getElementTop(scrollEventTarget) + element.offsetHeight + viewportScrollTop;

    shouldTrigger = viewportBottom + distance >= elementBottom;
  }

  if (shouldTrigger && this.expression) {
    this.expression();
  }
};

这个方法主要就是计算列表滚动的距离,判断是否需要执行加载方法。如果条件符合并且 this.expression 方法存在,则执行 this.expression 方法。
这个 this.expression 方法哪里来的呢?其实就是定义命令的 bind 方法中的 el[ctx] 对象中的 expression。

    el[ctx] = {
      el,
      vm: vnode.context,
      expression: binding.value
    };

刨根问底,这个 binding.value 代表了什么?其实它就是 v-infinite-scroll 指令所绑定的值。

v-infinite-scroll="loadMore"

所以说,这个 this.expression() 就是调用了 loadMore 方法进行数据的加载。

加载中的效果从何而来?

从实例中看到有加载中的效果,但是看了代码才发现这并不是 InfiniteScroll 显示的,而是根据 InfiniteScroll 的 loading 状态来控制加载中文本的隐藏和显示。

index list

index list 这个组件虽然应用场景不多,但是有一些功能还是很好奇的。带出两个问题:

如何实现点击字母滑动手指,提示的索引字母也会跟着变?

起初我以为是通过 touchmove 方法获取手指当前位置,并且计算当前位置应该显示的字母。但是却在源码中发现了一个新大陆:

Document.elementFromPoint()

根据MDN的解释:该方法返回当前文档上处于指定坐标位置最顶层的元素, 坐标是相对于包含该文档的浏览器窗口的左上角为原点来计算的, 通常 x 和 y 坐标都应为正数。
如此,通过手机滑动获取当前位置元素的文本内容就变得很简单了。

let currentItem = document.elementFromPoint(this.navOffsetX, y);
if (!currentItem || !currentItem.classList.contains('mint-indexlist-navitem')) {
  return;
}
this.currentIndicator = currentItem.innerText;

主列表如何跟随索引列定位到目标点?

在获取到索引列文本内容后,查找列表内容就变得简单多了。一步步来看:
在 index-list 中会使用 mt-index-section 来进行填充。除了样式的实现外还有一个用处就是会将 mt-index-section 组件实例自身传给父级组件。

mounted() {
  this.$parent.sections.push(this);
},

在 index-list 组件中有一个 section 数组,但是它什么没做。一开始没理解怎么回事,原来是子组件帮忙填充了这个数组。
最后是根据索引文本进行主列表定位的行为:

let targets = this.sections.filter(section => section.index === currentItem.innerText);
let targetDOM;
if (targets.length > 0) {
  targetDOM = targets[0].$el;
  this.$refs.content.scrollTop = targetDOM.getBoundingClientRect().top - this.firstSection.getBoundingClientRect().top;
}

至此,实现了主列表根据索引列进行页面跳转的功能。

最后

写的有点长了……列一下学习收获吧:

  • 知道了 loadmore infinite scrollindex list 这几个组件的实现原理。
  • 知道了 Document.elementFromPoint() 方法获取某个位置的元素。
  • 熟悉了Vue的自定义插件的方式和使用插件的内在原理。
  • 组件间通信可以使用 vm.$refs 获取子组件实例,从而调用子组件实例中的方法。
  • 可以使用 vm.$parent 来获取父级组件实例,从而调用父组件的方法,修改父组件的数据。
  • 还有 vm.$children 可用于获取所有子组件。
  • Element.scrollTop 可以获取和控制滚动条像素位置。
  • 组件库的各种拖动效果一般使用 touchstart touchmovetouchend 来获取位置(如果是鼠标则使用 mousedown mousemovemouseup 事件),通过 transform 来改变 DOM 元素位置。

嗯,暂时就这些了。感觉看组件源码还是很有收获的。

你可能感兴趣的:(mint-ui 源码学习四 —— 列表相关组件学习)