infinite-scroll/infinite-scroll.vue

<template>
    <div class="ion-infinite-scroll" :threshold="threshold" :state="state">
        <slot></slot>
    </div>
</template>
<style lang="scss" src="./style.scss"></style>
<script type="text/javascript">
  /**
   * @component InfiniteScroll
   * @description
   *
   * ## 数据加载 / InfiniteScroll无限滚动组件
   *
   * ### 说明
   *
   * 当页面滚动到页面底部一定距离时, InfiniteScroll组件会触发`onInfinite`事件, 通过回调传入的参数`infiniteScroll`来处理对应的状态.
   *
   * 比如: 当需要异步请求AJAX数据时, 数据请求后, 需要执行`infiniteScroll.complete()` 来变更InfiniteScroll组件的状态,当继续向下滚动时, 还能继续出发`onInfinite`事件, 如此往复.
   *
   * 当通过AJAX请求的数据已经全部请求完毕(没有更多的数据时), 则执行`infiniteScroll.enable(false)`, 表明InfiniteScroll任务全部结束. 此时, 将解除对Content组件的`onScroll`事件的监听.
   *
   * ### 其他
   *
   * InfiniteScroll组件会附带InfiniteScrollContent组件, InfiniteScrollContent组件 是默认的显示组件,它只是起到显示状态的作用, 你也可以自己定义显示状态, 只要写好相应的css样式就好.
   *
   * 其中, 标示状态的`state`有三种值: enabled/disabled/loading, 且这三种状态会写在父组件上, 因此可以用这个特性定义子组件的样式. 比如像下面的demo
   *
   * ### 建议
   *
   * 首屏的数据至少占满两个以上屏幕高度, 通过 InfiniteScroll 加载的数据也至少能占满两个以上的屏幕高度, 这样的体验效果会好点
   *
   * ### 注意
   *
   * - 组件中的方法是在事件的回调参数中定义的.
   * - 组件支持js滚动监听, 如何设置参考Content组件
   *
   * ### 如何引入
   * ```
   * // 引入
   * import { InfiniteScroll, InfiniteScrollContent } from 'vimo'
   * // 安装
   * Vue.component(InfiniteScroll.name, InfiniteScroll)
   * Vue.component(InfiniteScrollContent.name, InfiniteScrollContent)
   * // 或者
   * export default{
   *   components: {
   *     InfiniteScroll, InfiniteScrollContent
   *  }
   * }
   *```
   *
   *
   * @props {Boolean} [enabled=true] - 设置当前组件的可用状态, 如果为false, 则移除当前组件绑定的全部事件处理函数, 隐藏组件并且将状态设置为disabled
   * @props {String} [threshold='15%'] - 激活`onInfinite`事件的阈值. 阈值可以使百分比也可以是具体的px值. 如果为百分比(10%), 则距离底部10%的位置将为激活点; 如果为具体px值(100px), 则页面底部向下100px出为激活点.
   *
   * @fires component:InfiniteScroll#onInfinite
   *
   * @demo #/infinite-scroll
   * @see component:Content
   *
   * @usage
   *
   * <InfiniteScroll threshold="20%" @onInfinite="onInfinite">
   *    <InfiniteScrollContent loadingSpinner="ios" loadingText="正在加载..."></InfiniteScrollContent>
   *    <h5 class="loadedAll" text-center>全部加载完毕</h5>
   * </InfiniteScroll>
   *
   * // ....
   *  .ion-infinite-scroll{
   *      .loadedAll{
   *          display: none;
   *      }
   *   }
   *  .ion-infinite-scroll[state=disabled]{
   *      .loadedAll{
   *          display: block;
   *      }
   *  }
   *
   * // ....
   *
   * methods: {
   *    onInfinite(infiniteScroll){
   *      let _start = this.i;
   *      if(_start < 40){
   *        setTimeout(() => {
   *          for (; (10 + _start) > this.i; this.i++) {
   *           this.list.push(`item - ${this.i}`)
   *         }
   *         // 当前异步完成
   *         infiniteScroll.complete();
   *       }, 100)
   *     }else{
   *       // 当前异步结束, 没有新数据了
   *       infiniteScroll.enable(false);
   *     }
   *  }
   *  // ....
   * */

  import { setElementClass } from '../../util/util'

  const STATE_ENABLED = 'enabled'
  const STATE_DISABLED = 'disabled'
  const STATE_LOADING = 'loading'
  export default {
    name: 'InfiniteScroll',
    inject: {
      contentComponent: {
        from: 'contentComponent',
        default: null
      }
    },
    props: {
      // 可用状态
      enabled: {
        type: Boolean,
        default: true
      },
      // 阈值
      threshold: {
        type: String,
        default: '15%' // 15%  150px
      }
    },
    data () {
      return {
        lastCheck: 0,  // 节流, 少于32ms的事件变动不必监听
        state: (this.enabled ? STATE_ENABLED : STATE_DISABLED) // 内部状态
      }
    },
    watch: {
      enabled () {
        this.enable(this.enabled)
      }
    },
    computed: {
      // 阈值
      thr () {
        return this.threshold
      },
      // 阈值(px单位)
      thrPx () {
        if (this.threshold.indexOf('%') > -1) {
          return 0
        } else {
          return parseFloat(this.threshold)
        }
      },
      // 阈值(百分比)
      thrPc () {
        if (this.threshold.indexOf('%') > -1) {
          return (parseFloat(this.threshold) / 100)
        } else {
          return 0
        }
      }
    },
    methods: {
      /**
       * @function complete
       * @description
       * 在 `onInfinite` 事件的回调中(参数为当前InfiniteScroll组件的this)执行`complete()`这个方法, 表示异步操作完毕.
       * 比如在异步情况下通过AJAX获取数据增加新行列, 数据获取完毕更新UI后, 执行`complete()`这个方法,
       * 表示loading已经完成, InfiniteScroll组件的状态将由`loading` 转为 `enabled`.
       * */
      complete () {
        this.$nextTick(() => {
          if (this.state === STATE_LOADING) {
            this.state = STATE_ENABLED
            // 重新计算尺寸, 必须
            this.contentComponent && this.contentComponent.resize()
          }
        })
      },

      /**
       * @function enable
       * @description
       * 设置InfiniteScroll组件的状态. 当在已经没有数据或者不再需要InfiniteScroll组件时执行这个方法.
       * 需要在`onInfinite` 事件的回调中执行 `infiniteScroll.enable(false)`.
       * @param {boolean} shouldEnable - 组件当前状态, 如果为`false`, 则移除scroll的所有监听函数, 并隐藏组件
       */
      enable (shouldEnable) {
        this.state = (shouldEnable ? STATE_ENABLED : STATE_DISABLED)
        this.$_setListeners(shouldEnable)
      },

      /**
       * @function waitFor
       * @description
       * 传入Promise对象, 在传入的Promise完成数据的加载, 当加载完毕执行`resolve()`, 这个`resolve()`
       * 在这里相当于执行了`this.complete()`, 用法如下所示
       * @param {Promise} action - 执行方法
       *
       * @example
       *
       * <Content>
       *  <List>
       *      <Item v-for="item of items">{{item}}</Item>
       *  </List>
       *  <InfiniteScroll @onInfinite="$event.waitFor(doInfinite())">
       *      <InfiniteScrollContent></InfiniteScrollContent>
       *  </InfiniteScroll>
       * </Content>
       *
       * @example
       * // ...
       *   doInfinite () {
       *     console.log('Begin async operation');
       *     return new Promise((resolve) => {
       *       setTimeout(() => {
       *         for (var i = 0; i < 30; i++) {
       *           this.items.push( this.items.length );
       *         }
       *         console.log('Async operation has ended');
       *         resolve();
       *       }, 500);
       *     })
       * }
       * // ...
       */
      waitFor (action) {
        const enable = this.complete.bind(this)
        action.then(enable, enable)
      },

      /**
       * @param {boolean} shouldListen - 是否监听
       * @private
       */
      $_setListeners (shouldListen) {
        if (shouldListen) {
          // 监听Content组件的onScroll事件
          // NOTICE: 这里是监听的是Content组件自己内部维护的事件`onScroll`
          this.contentComponent && this.contentComponent.$on('onScroll', this.$_onScrollHandler)
        } else {
          // 解除onScroll事件监听(Content组件)
          this.contentComponent && this.contentComponent.$off('onScroll', this.$_onScrollHandler)
        }
      },

      /**
       * @param {ScrollEvent} ev - 滚动事件对象
       * @return {Number} - 1:loading/disabled; 2:还在滚动呢; 3:没有滚动高度; 5:loading状态; 6:一般滚动状态
       * @private
       * */
      $_onScrollHandler (ev) {
        if (this.state === STATE_LOADING || this.state === STATE_DISABLED) {
          return 1
        }

        if (this.lastCheck + 32 > ev.timeStamp) {
          // 少于32ms的事件变动不必监听
          return 2
        }
        this.lastCheck = ev.timeStamp

        const infiniteHeight = this.$el.scrollHeight

        if (!infiniteHeight) {
          // 如果滚动高度不存在则什么都不做
          return 3
        }

        const d = this.contentComponent && this.contentComponent.scrollView.ev

        let reloadY = window.innerHeight

        if (this.thrPc) {
          reloadY += (reloadY * this.thrPc)
        } else {
          reloadY += this.thrPx
        }

        const distanceFromInfinite = ((d.scrollHeight - infiniteHeight) - d.scrollTop) - reloadY

        if (distanceFromInfinite < 0) {
          if (this.state !== STATE_LOADING && this.state !== STATE_DISABLED) {
            this.state = STATE_LOADING

            /**
             * @event component:InfiniteScroll#onInfinite
             * @description 触发无限滚动
             * @param {InfiniteScroll} infiniteScroll - InfiniteScroll组件的实例对象
             * */
            this.$emit('onInfinite', this)
          }
          return 5
        }
        return 6
      }
    },
    mounted () {
      // console.assert(this.contentComponent, 'InfiniteScroll组件必须要在Content组件下使用')
      if (this.contentComponent) {
        setElementClass(this.contentComponent.$el, 'has-infinite-scroll', true)
      }
      this.$_setListeners(this.state !== STATE_DISABLED)
    },
    destroy () {
      this.$_setListeners(false)
    }
  }
</script>