mint-ui 源碼學習四 —— 列表相關組件學習

除了一些基礎的組件,幾個列表組件讓我非常好奇。所以這里來學習一下 loadmore infinite scrollindex list 這四個組件。
PS:我通過問答的方式有針對性的解決這些組件的一些問題,如果有其他問題歡迎在本文后面留言一起探討。

loadmore

如何實現拖拽效果并觸發 load more 行為?

首先來看看如何對列表進行拖拽,首先是在 loadmore 組件最外部的 div 上添加了動態的 style 屬性:

<div :style="{ 'transform': transform }"></div>

在 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 代碼來解決:

  <slot name="top">
    <div class="mint-loadmore-top" v-if="topMethod">
      <spinner v-if="topStatus === 'loading'" class="mint-loadmore-spinner" :size="20" type="fading-circle"></spinner>
      <span class="mint-loadmore-text">{{ topText }}</span>
    </div>
  </slot>
  <slot></slot>
  <slot name="bottom">
    <div class="mint-loadmore-bottom" v-if="bottomMethod">
      <spinner v-if="bottomStatus === 'loading'" class="mint-loadmore-spinner" :size="20" type="fading-circle"></spinner>
      <span class="mint-loadmore-text">{{ bottomText }}</span>
    </div>
  </slot>

可以看到其中有 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 元素位置。

嗯,暫時就這些了。感覺看組件源碼還是很有收獲的。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容