前言
最近项目正在做一个web版客服聊天工具,里面有聊天窗口需要滚动加载聊天记录,刚好记得饿了么团队有个vue-infinite-scroll插件,看了下源码发现只支持向下滚动,但是聊天记录是向上滚动加载,于是在其基础上拓展了向上滚动的功能。下面主要对vue-infinite-scroll插件进行源码解析以及讲一下如何去拓展向上滚动加载的功能。
插件Usage
用法1:将指令宿主元素自身设置了overflow:auto,内部元素用来支撑滚动,当滚动到底部时,增加内部元素的高度从而模拟了无限滚动。
<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);
},
},
});
效果如下:
用法2:将父元素设置为滚动,当自身滚动到父元素底部时,增加自身的高度,模拟拉取下一页数据的操作。:
<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>
达到的效果和上面完全相同。
源码解析
入口
我们从入口开始看,由于这个插件就是一个vue指令,所以入口还是挺好理解的
export default {
bind (el, binding, vnode) {
// 官方警告:除了 el 之外,其它参数都应该是只读的,切勿进行修改。如果需要在钩子之间共享数据,建议通过元素的 dataset 来进行。
el[ctx] = {
el,
vm: vnode.context, // vue实例
expression: binding.value // 滚动到底部或顶部时需要的回调函数,通常用于加载下一页数据
}
const args = arguments
el[ctx].vm.$once('hook:mounted', function () {
el[ctx].vm.$nextTick(function () {
// 判断元素是否已经在页面上
if (isAttached(el)) {
doBind.call(el[ctx], args)
}
el[ctx].bindTryCount = 0
// 间隔50ms轮询10次,判断元素是否已经在页面上
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) }
}
}
核心就是在宿主元素渲染后,执行doBind方法,我们猜测会在doBind绑定滚动父元素的scroll事件。
isAttached方法用于判断一个元素是否已渲染在页面上,判断方法是查看祖先元素的标签名是否为HTML:
var isAttached = function (element) {
var currentNode = element.parentNode
while (currentNode) {
if (currentNode.tagName === 'HTML') {
return true
}
// nodeType === 11 表示节点是 DocumentFragment
if (currentNode.nodeType === 11) {
return false
}
currentNode = currentNode.parentNode
}
return false
}
小知识:
nodeType==11 节点是 DocumentFragment,DocumentFragment 节点 不属于文档树, 如果一个元素的父节点是 DocumentFragment, 那么说明这个元素还没有插入到文档树中,是没有父节点的。
DocumentFragment 接口表示文档的一部分(或一段)。更确切地说,它表示一个或多个邻接的 Document 节点和它们的所有子孙节点。
DocumentFragment 节点不属于文档树,继承的 parentNode 属性总是 null。
不过它有一种特殊的行为,该行为使得它非常有用,即当请求把一个 DocumentFragment 节点插入文档树时,插入的不是 DocumentFragment 自身,而是它的所有子孙节点。这使得 DocumentFragment 成了有用的占位符,暂时存放那些一次插入文档的节点。它还有利于实现文档的剪切、复制和粘贴操作,尤其是与 Range 接口一起使用时更是如此。
可以用 Document.createDocumentFragment() 方法创建新的空 DocumentFragment 节点。
绑定
这里通过获取元素属性来获取用户配置项,并找到宿主最近的可滚动父级元素然后绑定滚动事件,通过滚动事件去检查什么时候该触发一次事件
var doBind = function () {
// 只绑定一次,绑定过就返回
if (this.binded) return
this.binded = true
var directive = this
var element = directive.el
// 截流间隔
var throttleDelayExpr = element.getAttribute('infinite-scroll-throttle-delay')
// 默认200毫秒
var throttleDelay = 200
if (throttleDelayExpr) {
// 优先尝试实例上的throttleDelayExpr对应的属性
throttleDelay = Number(directive.vm[throttleDelayExpr] || throttleDelayExpr)
if (isNaN(throttleDelay) || throttleDelay < 0) {
throttleDelay = 200
}
}
directive.throttleDelay = throttleDelay
directive.scrollEventTarget = getScrollEventTarget(element)
directive.scrollListener = throttle(doCheck.bind(directive), directive.throttleDelay)
directive.scrollEventTarget.addEventListener('scroll', directive.scrollListener)
this.vm.$once('hook:beforeDestroy', function () {
directive.scrollEventTarget.removeEventListener('scroll', directive.scrollListener)
})
// 是否禁用无限滚动
var disabledExpr = element.getAttribute('infinite-scroll-disabled')
// 默认不禁用
var disabled = false
// 如果配置了该项,则监听实例上的disabledExpr对应的属性
if (disabledExpr) {
this.vm.$watch(disabledExpr, function (value) {
directive.disabled = value
// 当disable为false时,重启check
if (!value && directive.immediateCheck) {
doCheck.call(directive)
}
})
disabled = Boolean(directive.vm[disabledExpr])
}
directive.disabled = disabled
// 滚动条与顶部或底部的距离阈值,小于这个值时候执行doCheck
var distanceExpr = element.getAttribute('infinite-scroll-distance')
// 默认为0
var distance = 0
if (distanceExpr) {
distance = Number(directive.vm[distanceExpr] || distanceExpr)
if (isNaN(distance)) {
distance = 0
}
}
directive.distance = distance
// 是否立即执行doCheck
var immediateCheckExpr = element.getAttribute('infinite-scroll-immediate')
// 默认为true
var immediateCheck = true
if (immediateCheckExpr) {
immediateCheck = Boolean(directive.vm[immediateCheckExpr])
}
directive.immediateCheck = immediateCheck
if (immediateCheck) {
doCheck.call(directive, false)
}
// 当组件上设置的此事件触发时,执行一次检查,一般用于手动触发检查
var eventName = element.getAttribute('infinite-scroll-listen-for-event')
if (eventName) {
directive.vm.$on(eventName, function () {
doCheck.call(directive)
})
}
}
doBind实际上就是获取用户配置,包括触发间隔、是否立即触发、是否禁用、距离多少触发、手动触发事件,通过这些配置项控制doCheck执行时机。
查找滚动父元素时是从自身开始查找的,所以我们可以像用法1那样将指令设置在滚动元素自身上
// 从自身开始,寻找设置了滚动的父元素。 overflow-y 为scroll或auto
var getScrollEventTarget = function(element) {
var currentNode = element;
// 解决监听body和html上滚动事件的兼容问题
// nodeType 1表示元素节点
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
这个函数用于检查是否已经滚动到底部。这里要分滚动的元素可以是自身和某个父元素两种情况。
var doCheck = function(force) {
var scrollEventTarget = this.scrollEventTarget;
var element = this.el;
var distance = this.distance;
if (force !== true && this.disabled) return;
// 滚动元素顶部与文档坐标顶部的距离
var viewportScrollTop = getScrollTop(scrollEventTarget);
// viewportBottom: 滚动元素底部与文档坐标顶部的距离; visibleHeight:滚动元素不带边框的高度
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(); // 触发绑定的无限滚动函数
}
};
滚动元素是宿主元素
滚动元素是宿主元素的父元素
拓展
从源码中我们可以看到,该插件只判断了向下滚动的情况,前言中我们提到业务需要向上滚动加载。分析了源码,我们知道插件内部是通过ele.getAttribute()来获取配置项的,然后在doCheck的时候去做判断并触发用户绑定的无线滚动函数。于是我们就有了如下想法:
1、增加触发类型的配置项
// 触发类型是向上滚动还是向下滚动
var triggerTypeExpr = element.getAttribute('infinite-scroll-trigger-type')
// 默认触发类型为向下滚动
var triggerType = 'scrollDown'
if (triggerTypeExpr) {
triggerType = directive.vm[triggerTypeExpr] || triggerTypeExpr
// 可选值为'scrollDown', 'scrollUp'
if (!['scrollDown', 'scrollUp'].includes(triggerType)) {
triggerType = 'scrollDown'
}
}
directive.triggerType = triggerType
2、doCheck的时候为触发类型'scrollUp'做触发条件判断
这里我们实际上要判断宿主元素顶部和滚动元素顶部的距离是否小于等于距离阈值即distance
// 滚动元素是宿主元素自身
if (scrollEventTarget === element) {
if (triggerType === 'scrollDown') {
shouldTrigger = scrollEventTarget.scrollHeight - viewportBottom <= distance
} else {
shouldTrigger = viewportScrollTop <= distance
}
} else {
// 滚动元素是宿主元素的父元素
// 自身与父元素顶部距离差
var topGap = getElementTop(element) - getElementTop(scrollEventTarget)
// elementBottom:宿主元素底部与文档坐标顶部的距离
var elementBottom = topGap + element.offsetHeight + viewportScrollTop
if (triggerType === 'scrollDown') {
shouldTrigger = viewportBottom + distance >= elementBottom
} else {
shouldTrigger = topGap <= distance
}
}
拓展后的配置项如下,用法跟上面基本无差,只是多了个配置项
| Option | Description |
|---|---|
| infinite-scroll-disabled | infinite scroll will be disabled if the value of this attribute is true. |
| infinite-scroll-distance | Number(default = 0) - the minimum distance between the bottom of the element and the bottom of the viewport before the v-infinite-scroll method is executed. |
| infinite-scroll-immediate-check | Boolean(default = true) - indicates that the directive should check immediately after bind. Useful if it's possible that the content is not tall enough to fill up the scrollable container. |
| infinite-scroll-listen-for-event | infinite scroll will check again when the event is emitted in Vue instance. |
| infinite-scroll-throttle-delay | Number(default = 200) - interval(ms) between next time checking and this time. |
| infinite-scroll-trigger-type | String(default = 'scrollDown') - choose between 'scrollDown' and 'scrollUp'. |