詳解無限滾動(dòng)插件vue-infinite-scroll源碼解析
最近在項(xiàng)目中遇到一個(gè)需求,有一個(gè)列表需要滾動(dòng)加載,類似于微博的無限滾動(dòng)。當(dāng)時(shí)第一反應(yīng)時(shí)監(jiān)聽滾動(dòng)事件,在判斷滾動(dòng)到達(dá)底部時(shí)加載下一頁(yè),同時(shí)心里也清楚,監(jiān)聽滾動(dòng)事件需要做好截流。順手搜索了下發(fā)現(xiàn)有一個(gè)現(xiàn)成的插件vue-infinite-scroll ,用法也很簡(jiǎn)單,于是乎就用了起來。 需求上線后,對(duì)它的實(shí)現(xiàn)挺好奇的,于是研究了一番源碼,這篇文章就是源碼解析筆記。
插件使用方法
這是一個(gè) vue 的指令,按照 github 倉(cāng)庫(kù)上的介紹,用法挺簡(jiǎn)單的,例如:
<div class="app" v-infinite-scroll="loadMore" infinite-scroll-disabled="busy" infinite-scroll-distance="10"> <div class="content"></div> <div class="loading" v-show="busy">loading.....</div> </div>
.app {
height: 1000px;
border: 1px solid red;
width: 600px;
margin: 0 auto;
overflow: auto;
}
.content {
height: 1300px;
background-color: #ccc;
width: 80%;
margin: 0 auto;
}
.loading {
font-weight: bold;
font-size: 20px;
color: red;
text-align: center;
}
var app = document.querySelector('.app');
new Vue({
el: app,
directives: {
InfiniteScroll,
},
data: function() {
return { busy: false };
},
methods: {
loadMore: function() {
var self = this;
self.busy = true;
console.log('loading... ' + new Date());
setTimeout(function() {
var target = document.querySelector('.content');
var height = target.clientHeight;
target.style.height = height + 300 + 'px';
console.log('end... ' + new Date());
self.busy = false;
}, 1000);
},
},
});
這里的指令宿主元素自身設(shè)置了 overflow:auto ,內(nèi)部元素用來支撐滾動(dòng),當(dāng)滾動(dòng)到底部時(shí),增加內(nèi)部元素的高度從而模擬了無限滾動(dòng)。效果如下:

另外可以將父元素設(shè)置為滾動(dòng),當(dāng)自身滾動(dòng)到父元素底部時(shí),增加自身的高度,模擬拉取下一頁(yè)數(shù)據(jù)的操作。 例如:
<div class="app"> <div class="content" v-infinite-scroll="loadMore" infinite-scroll-disabled="busy" infinite-scroll-distance="10"></div> <div class="loading" v-show="busy">loading.....</div> </div>
達(dá)到的效果和上面完全相同。
源碼解析
接下來就是看看內(nèi)部怎么實(shí)現(xiàn)的。照例從入口開始看起。因?yàn)檫@個(gè)插件就是一個(gè) vue 的指令,所以入口還是挺簡(jiǎn)單的:
指令入口
export default {
bind(el, binding, vnode) {
el[ctx] = {
el,
vm: vnode.context,
expression: binding.value, // 滾動(dòng)到底部時(shí)需要的監(jiān)聽函數(shù),通常用于加載下一頁(yè)數(shù)據(jù)
};
const args = arguments;
// 監(jiān)聽宿主元素所在組件的mounted事件
el[ctx].vm.$on('hook:mounted', function() {
el[ctx].vm.$nextTick(function() {
// 判斷元素是否已經(jīng)在頁(yè)面上
if (isAttached(el)) {
// 獲取各項(xiàng)指令相關(guān)屬性,執(zhí)行各種事件綁定
doBind.call(el[ctx], args);
}
el[ctx].bindTryCount = 0;
// 間隔50ms輪訓(xùn)10次,判斷元素是否已經(jīng)在頁(yè)面上
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();
});
});
},
unbind(el) {
// 事件解綁
if (el && el[ctx] && el[ctx].scrollEventTarget) el[ctx].scrollEventTarget.removeEventListener('scroll', el[ctx].scrollListener);
},
};
核心就是在宿主元素渲染后,執(zhí)行 doBind 方法,我們猜測(cè)會(huì)在 doBind 綁定滾動(dòng)父元素的 scroll 事件。
isAttached 方法用于判斷一個(gè)元素是否已渲染在頁(yè)面上,判斷方法是查看是否有組件元素的標(biāo)簽名為 HTML :
// 判斷元素是否已經(jīng)在頁(yè)面上
var isAttached = function(element) {
var currentNode = element.parentNode;
while (currentNode) {
if (currentNode.tagName === 'HTML') {
return true;
}
// 11 表示DomFragment
if (currentNode.nodeType === 11) {
return false;
}
currentNode = currentNode.parentNode;
}
return false;
};
參數(shù)解析與事件綁定
現(xiàn)在看看 doBind 方法,邏輯比較多,不過都不難。
var doBind = function() {
if (this.binded) return; // 只綁定一次
this.binded = true;
var directive = this;
var element = directive.el;
// throttleDelayExpr: 截流間隔。 設(shè)置在元素的屬性上
var throttleDelayExpr = element.getAttribute('infinite-scroll-throttle-delay');
var throttleDelay = 200;
if (throttleDelayExpr) {
// 優(yōu)先嘗試組件上的throttleDelayExpr屬性值, 如 <div infinite-scroll-throttle-delay="myDelay"></div>
throttleDelay = Number(directive.vm[throttleDelayExpr] || throttleDelayExpr);
if (isNaN(throttleDelay) || throttleDelay < 0) {
throttleDelay = 200;
}
}
directive.throttleDelay = throttleDelay;
// 監(jiān)聽滾動(dòng)父元素的scroll時(shí)間,監(jiān)聽函數(shù)設(shè)置了函數(shù)截流
directive.scrollEventTarget = getScrollEventTarget(element); // 設(shè)置了滾動(dòng)的父元素
directive.scrollListener = throttle(doCheck.bind(directive), directive.throttleDelay);
directive.scrollEventTarget.addEventListener('scroll', directive.scrollListener);
this.vm.$on('hook:beforeDestroy', function() {
directive.scrollEventTarget.removeEventListener('scroll', directive.scrollListener);
});
// infinite-scroll-disabled: 是否禁用無限滾動(dòng)
// 可以為表達(dá)式
var disabledExpr = element.getAttribute('infinite-scroll-disabled');
var disabled = false;
if (disabledExpr) {
this.vm.$watch(disabledExpr, function(value) {
directive.disabled = value;
// 當(dāng)disable為false時(shí),重啟check
if (!value && directive.immediateCheck) {
doCheck.call(directive);
}
});
disabled = Boolean(directive.vm[disabledExpr]);
}
directive.disabled = disabled;
// 宿主元素到滾動(dòng)父元素底部的距離閾值,小于這個(gè)值時(shí),觸發(fā)listen-for-event監(jiān)聽函數(shù)
var distanceExpr = element.getAttribute('infinite-scroll-distance');
var distance = 0;
if (distanceExpr) {
distance = Number(directive.vm[distanceExpr] || distanceExpr);
if (isNaN(distance)) {
distance = 0;
}
}
directive.distance = distance;
// immediate-check:是否在bind后立即檢查一遍,也會(huì)在disable失效時(shí)立即觸發(fā)檢查
var immediateCheckExpr = element.getAttribute('infinite-scroll-immediate-check');
var immediateCheck = true;
if (immediateCheckExpr) {
immediateCheck = Boolean(directive.vm[immediateCheckExpr]);
}
directive.immediateCheck = immediateCheck;
if (immediateCheck) {
doCheck.call(directive);
}
// 當(dāng)組件上設(shè)置的此事件觸發(fā)時(shí),執(zhí)行一次檢查
var eventName = element.getAttribute('infinite-scroll-listen-for-event');
if (eventName) {
directive.vm.$on(eventName, function() {
doCheck.call(directive);
});
}
};
整個(gè)看下來,核心就是利用各種參數(shù)控制 doCheck 的調(diào)用,包括時(shí)間間隔、 disabled 、距離閾值、 immediate-check 、組件事件。
doCheck 因?yàn)闀?huì)非常頻繁的調(diào)用,所以用 throttle 進(jìn)行了截流,具體邏輯這里不再贅述。
在 getScrollEventTarget 查找滾動(dòng)父元素時(shí),有一個(gè)細(xì)節(jié)就是會(huì)從自身開始查找,這也就是我們上面的 demo 中可以將指令宿主元素賦值給滾動(dòng)元素自身的原因:
// 從自身開始,尋找設(shè)置了滾動(dòng)的父元素。 overflow-y 為scroll或auto
var getScrollEventTarget = function(element) {
var currentNode = element;
// bugfix, see http://w3help.org/zh-cn/causes/SD9013 and http://stackoverflow.com/questions/17016740/onscroll-function-is-not-working-for-chrome
// nodeType 1表示元素節(jié)點(diǎn)
while (currentNode && currentNode.tagName !== 'HTML' && currentNode.tagName !== 'BODY' && currentNode.nodeType === 1) {
var overflowY = getComputedStyle(currentNode).overflowY;
if (overflowY === 'scroll' || overflowY === 'auto') {
return currentNode;
}
currentNode = currentNode.parentNode;
}
return window;
};
doCheck
這個(gè)函數(shù)用于判斷是否已經(jīng)滾動(dòng)到底部,可以說是整個(gè)插件的核心邏輯。由于滾動(dòng)的元素可以是自身,也可以是某個(gè)父元素,所以判斷會(huì)分成兩個(gè)分支。
var doCheck = function(force) {
var scrollEventTarget = this.scrollEventTarget; // 滾動(dòng)父元素
var element = this.el;
var distance = this.distance; // 距離閾值
if (force !== true && this.disabled) return;
var viewportScrollTop = getScrollTop(scrollEventTarget); // 被隱藏在內(nèi)容區(qū)上方的像素?cái)?shù)
// viewportBottom: 元素底部與文檔坐標(biāo)頂部的距離; visibleHeight:元素不帶邊框的高度
var viewportBottom = viewportScrollTop + getVisibleHeight(scrollEventTarget);
var shouldTrigger = false;
// 滾動(dòng)元素就是自身
if (scrollEventTarget === element) {
// scrollHeight - 在沒有滾動(dòng)條的情況下,元素內(nèi)容的總高度,是元素的內(nèi)容區(qū)加上內(nèi)邊距再加上任何溢出內(nèi)容的尺寸。
// shouldTrigger為true表示已經(jīng)滾動(dòng)到元素的足夠底部了。
// 參考https://hellogithub2014.github.io/2017/10/19/dom-element-size-summary/
shouldTrigger = scrollEventTarget.scrollHeight - viewportBottom <= distance;
} else {
// 當(dāng)前元素與不是父元素,此時(shí)通常意味著當(dāng)前元素的高度比滾動(dòng)父元素要高,這樣父元素才會(huì)出現(xiàn)滾動(dòng)
// getElementTop(element) - getElementTop(scrollEventTarget) 當(dāng)前元素頂部與滾動(dòng)父元素頂部的距離
// offsetHeight元素帶邊框的高度
// elementBottom: 元素底部與文檔坐標(biāo)頂部的距離
var elementBottom = getElementTop(element) - getElementTop(scrollEventTarget) + element.offsetHeight + viewportScrollTop;
shouldTrigger = viewportBottom + distance >= elementBottom;
}
if (shouldTrigger && this.expression) {
this.expression(); // 觸發(fā)綁定的無限滾動(dòng)函數(shù),通常是獲取下一頁(yè)數(shù)據(jù)。 之后scrollEventTarget.scrollHeight會(huì)變大
}
};
這里涉及到了多種尺寸值,包括 scrollTop 、 offsetTop 、 clientHeight 、 scrollHeight 等等,如果不清楚的話整個(gè)函數(shù)的邏輯就很難看懂,關(guān)于它們的具體意義可以參考我之前寫的一篇博客。
這里我用兩幅圖來輔助理解上面的邏輯,相信會(huì)好懂很多。
滾動(dòng)元素是自身

如下,我們的目標(biāo)是判斷元素是否已滾動(dòng)到底部的距離閾值之內(nèi),很容易可以看出來,距離內(nèi)容底部的距離公式為:
const { scrollHeight, clientHeight, scrollTop } = scrollEventTarget;
const currentDistance = scrollHeight - clientHeight - scrollTop;
這也就是函數(shù) if 分支的邏輯,當(dāng) currentDistance 小于 distance 時(shí),我們就可以加載下一頁(yè)數(shù)據(jù)了。
父級(jí)元素設(shè)置滾動(dòng)

此時(shí)就沒有 scrollTop 屬性可以操作了,但是元素的高度仍然可以用上面的屬性:滾動(dòng)父元素的高度可以用 scrollEventTarget.clientHeight ,子元素內(nèi)容高度可以用 element.offsetHeight ,剩下的就是計(jì)算 topGap 了。
我們知道 DOM 的坐標(biāo)有兩種:文檔坐標(biāo)、視口坐標(biāo),計(jì)算 topGap 只要始終在其中一個(gè)坐標(biāo)系計(jì)算就可以了,這里我們采用視口坐標(biāo)。 ele.getBoundingClientRect().top 可以知道一個(gè)元素距離視口頂部的距離,那么 topGap 的計(jì)算公式就是:
const topGap = scrollEventTarget.getBoundingClientRect().top - element.getBoundingClientRect().top;
綜上,子元素底部與父元素底部的距離公式就是:
const currentDistance = element.offsetHeight - scrollEventTarget.clientHeight - (scrollEventTarget.getBoundingClientRect().top - element.getBoundingClientRect().top);
這也就是函數(shù)的 else 分支邏輯。
以上就是 doCheck 的核心檢測(cè)邏輯了,同時(shí)針對(duì) scrollEventTarget 為 document 時(shí)做了一些特殊處理,留給大家自己去看。
以上就是本文的全部?jī)?nèi)容,希望對(duì)大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
vue實(shí)現(xiàn)進(jìn)入某個(gè)頁(yè)面后替換地址欄路徑的操作方法
vue頁(yè)面在實(shí)際開發(fā)中,經(jīng)常會(huì)遇到改變url參數(shù),重新加載頁(yè)面數(shù)據(jù)的需求,但是只改變頁(yè)面url并不會(huì)觸發(fā)組件的生命周期,這就需要用其他方法來實(shí)現(xiàn)了,本文重點(diǎn)介紹vue實(shí)現(xiàn)進(jìn)入某個(gè)頁(yè)面后替換地址欄路徑的操作方法,感興趣的朋友跟隨小編一起看看吧2024-04-04
vue移動(dòng)UI框架滑動(dòng)加載數(shù)據(jù)的方法
這篇文章主要介紹了vue移動(dòng)UI框架滑動(dòng)加載的方法,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-03-03
vue2實(shí)現(xiàn)封裝動(dòng)態(tài)表單組件
這篇文章主要介紹了vue2實(shí)現(xiàn)封裝動(dòng)態(tài)表單組件,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價(jià)值,需要的小伙伴可以參考一下2022-08-08
vue如何自定義InputNumber計(jì)數(shù)器組件
這篇文章主要介紹了vue如何自定義InputNumber計(jì)數(shù)器組件問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-10-10
vue2如何實(shí)現(xiàn)vue3的teleport
這篇文章主要介紹了vue2如何實(shí)現(xiàn)vue3的teleport,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-04-04
vue里面v-bind和Props 利用props綁定動(dòng)態(tài)數(shù)據(jù)的方法
今天小編就為大家分享一篇vue里面v-bind和Props 利用props綁定動(dòng)態(tài)數(shù)據(jù)的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-08-08
axios的interceptors多次執(zhí)行問題解決
這篇文章主要為大家介紹了axios中interceptors多次執(zhí)行問題解決,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-06-06
vue基礎(chǔ)ESLint?Prettier配置教程詳解
這篇文章主要介紹了vue基礎(chǔ)ESLint?Prettier配置教程詳解,本文使用VsCode?+?Vue?+?ESLint?+?Prettier?實(shí)現(xiàn)代碼格式規(guī)范?+?保存自動(dòng)修復(fù)代碼js+vue2022-07-07

