详解无限滚动插件vue-infinite-scroll源码解析
最近在项目中遇到一个需求,有一个列表需要滚动加载,类似于微博的无限滚动。当时第一反应时监听滚动事件,在判断滚动到达底部时加载下一页,同时心里也清楚,监听滚动事件需要做好截流。顺手搜索了下发现有一个现成的插件vue-infinite-scroll ,用法也很简单,于是乎就用了起来。 需求上线后,对它的实现挺好奇的,于是研究了一番源码,这篇文章就是源码解析笔记。
插件使用方法
这是一个 vue 的指令,按照 github 仓库上的介绍,用法挺简单的,例如:
<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); }, }, });
这里的指令宿主元素自身设置了 overflow:auto
,内部元素用来支撑滚动,当滚动到底部时,增加内部元素的高度从而模拟了无限滚动。效果如下:
另外可以将父元素设置为滚动,当自身滚动到父元素底部时,增加自身的高度,模拟拉取下一页数据的操作。 例如:
<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[ctx] = { el, vm: vnode.context, expression: binding.value, // 滚动到底部时需要的监听函数,通常用于加载下一页数据 }; const args = arguments; // 监听宿主元素所在组件的mounted事件 el[ctx].vm.$on('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; } // 11 表示domfragment if (currentnode.nodetype === 11) { return false; } currentnode = currentnode.parentnode; } return false; };
参数解析与事件绑定
现在看看 dobind
方法,逻辑比较多,不过都不难。
var dobind = function() { if (this.binded) return; // 只绑定一次 this.binded = true; var directive = this; var element = directive.el; // throttledelayexpr: 截流间隔。 设置在元素的属性上 var throttledelayexpr = element.getattribute('infinite-scroll-throttle-delay'); var throttledelay = 200; if (throttledelayexpr) { // 优先尝试组件上的throttledelayexpr属性值, 如 <div infinite-scroll-throttle-delay="mydelay"></div> throttledelay = number(directive.vm[throttledelayexpr] || throttledelayexpr); if (isnan(throttledelay) || throttledelay < 0) { throttledelay = 200; } } directive.throttledelay = throttledelay; // 监听滚动父元素的scroll时间,监听函数设置了函数截流 directive.scrolleventtarget = getscrolleventtarget(element); // 设置了滚动的父元素 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: 是否禁用无限滚动 // 可以为表达式 var disabledexpr = element.getattribute('infinite-scroll-disabled'); var disabled = false; 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; // 宿主元素到滚动父元素底部的距离阈值,小于这个值时,触发listen-for-event监听函数 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后立即检查一遍,也会在disable失效时立即触发检查 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); } // 当组件上设置的此事件触发时,执行一次检查 var eventname = element.getattribute('infinite-scroll-listen-for-event'); if (eventname) { directive.vm.$on(eventname, function() { docheck.call(directive); }); } };
整个看下来,核心就是利用各种参数控制 docheck
的调用,包括时间间隔、 disabled
、距离阈值、 immediate-check
、组件事件。
docheck
因为会非常频繁的调用,所以用 throttle
进行了截流,具体逻辑这里不再赘述。
在 getscrolleventtarget
查找滚动父元素时,有一个细节就是会从自身开始查找,这也就是我们上面的 demo
中可以将指令宿主元素赋值给滚动元素自身的原因:
// 从自身开始,寻找设置了滚动的父元素。 overflow-y 为scroll或auto var getscrolleventtarget = function(element) { var currentnode = element; // bugfix, see http://w3help.org/zh-cn/causes/sd9013 and http://*.com/questions/17016740/onscroll-function-is-not-working-for-chrome // 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) { // scrollheight - 在没有滚动条的情况下,元素内容的总高度,是元素的内容区加上内边距再加上任何溢出内容的尺寸。 // shouldtrigger为true表示已经滚动到元素的足够底部了。 // 参考https://hellogithub2014.github.io/2017/10/19/dom-element-size-summary/ shouldtrigger = scrolleventtarget.scrollheight - viewportbottom <= distance; } else { // 当前元素与不是父元素,此时通常意味着当前元素的高度比滚动父元素要高,这样父元素才会出现滚动 // getelementtop(element) - getelementtop(scrolleventtarget) 当前元素顶部与滚动父元素顶部的距离 // offsetheight元素带边框的高度 // elementbottom: 元素底部与文档坐标顶部的距离 var elementbottom = getelementtop(element) - getelementtop(scrolleventtarget) + element.offsetheight + viewportscrolltop; shouldtrigger = viewportbottom + distance >= elementbottom; } if (shouldtrigger && this.expression) { this.expression(); // 触发绑定的无限滚动函数,通常是获取下一页数据。 之后scrolleventtarget.scrollheight会变大 } };
这里涉及到了多种尺寸值,包括 scrolltop
、 offsettop
、 clientheight
、 scrollheight
等等,如果不清楚的话整个函数的逻辑就很难看懂,关于它们的具体意义可以参考我之前写的一篇博客。
这里我用两幅图来辅助理解上面的逻辑,相信会好懂很多。
滚动元素是自身
如下,我们的目标是判断元素是否已滚动到底部的距离阈值之内,很容易可以看出来,距离内容底部的距离公式为:
const { scrollheight, clientheight, scrolltop } = scrolleventtarget; const currentdistance = scrollheight - clientheight - scrolltop;
这也就是函数 if
分支的逻辑,当 currentdistance
小于 distance
时,我们就可以加载下一页数据了。
父级元素设置滚动
此时就没有 scrolltop
属性可以操作了,但是元素的高度仍然可以用上面的属性:滚动父元素的高度可以用 scrolleventtarget.clientheight
,子元素内容高度可以用 element.offsetheight
,剩下的就是计算 topgap
了。
我们知道 dom
的坐标有两种:文档坐标、视口坐标,计算 topgap
只要始终在其中一个坐标系计算就可以了,这里我们采用视口坐标。 ele.getboundingclientrect().top
可以知道一个元素距离视口顶部的距离,那么 topgap
的计算公式就是:
const topgap = scrolleventtarget.getboundingclientrect().top - element.getboundingclientrect().top;
综上,子元素底部与父元素底部的距离公式就是:
const currentdistance = element.offsetheight - scrolleventtarget.clientheight - (scrolleventtarget.getboundingclientrect().top - element.getboundingclientrect().top);
这也就是函数的 else
分支逻辑。
以上就是 docheck
的核心检测逻辑了,同时针对 scrolleventtarget
为 document
时做了一些特殊处理,留给大家自己去看。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。
上一篇: Java自动解压文件实例代码