详解无限滚动插件vue-infinite-scroll源码解析


Posted in Javascript onMay 12, 2019

最近在项目中遇到一个需求,有一个列表需要滚动加载,类似于微博的无限滚动。当时第一反应时监听滚动事件,在判断滚动到达底部时加载下一页,同时心里也清楚,监听滚动事件需要做好截流。顺手搜索了下发现有一个现成的插件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 ,内部元素用来支撑滚动,当滚动到底部时,增加内部元素的高度从而模拟了无限滚动。效果如下:

详解无限滚动插件vue-infinite-scroll源码解析

另外可以将父元素设置为滚动,当自身滚动到父元素底部时,增加自身的高度,模拟拉取下一页数据的操作。 例如:

<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://stackoverflow.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会变大
 }
};

这里涉及到了多种尺寸值,包括 scrollTopoffsetTopclientHeightscrollHeight 等等,如果不清楚的话整个函数的逻辑就很难看懂,关于它们的具体意义可以参考我之前写的一篇博客。

这里我用两幅图来辅助理解上面的逻辑,相信会好懂很多。

滚动元素是自身

详解无限滚动插件vue-infinite-scroll源码解析

 如下,我们的目标是判断元素是否已滚动到底部的距离阈值之内,很容易可以看出来,距离内容底部的距离公式为:

const { scrollHeight, clientHeight, scrollTop } = scrollEventTarget;
const currentDistance = scrollHeight - clientHeight - scrollTop;

这也就是函数 if 分支的逻辑,当 currentDistance 小于 distance 时,我们就可以加载下一页数据了。

父级元素设置滚动

详解无限滚动插件vue-infinite-scroll源码解析

此时就没有 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 的核心检测逻辑了,同时针对 scrollEventTargetdocument 时做了一些特殊处理,留给大家自己去看。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
关于Javascript模块化和命名空间管理的问题说明
Dec 06 Javascript
开发 Internet Explorer 右键功能表(ContextMenu)
Jul 03 Javascript
js表头排序实现方法
Jan 16 Javascript
jquery使用slideDown实现模块缓慢拉出效果的方法
Mar 27 Javascript
图文详解Heap Sort堆排序算法及JavaScript的代码实现
May 04 Javascript
bootstrap响应式表格实例详解
May 15 Javascript
vue-cli之router基本使用方法详解
Oct 17 Javascript
原生javascript实现文件异步上传的实例讲解
Oct 26 Javascript
Bootstrap popover 实现鼠标移入移除显示隐藏功能方法
Jan 24 Javascript
详解vue 命名视图
Aug 14 Javascript
使用js实现单链解决前端队列问题的方法
Feb 03 Javascript
LayUI+Shiro实现动态菜单并记住菜单收展的示例
May 06 Javascript
ES6中的迭代器、Generator函数及Generator函数的异步操作方法
May 12 #Javascript
浅谈vue.use()方法从源码到使用
May 12 #Javascript
Vue安装浏览器开发工具的步骤详解
May 12 #Javascript
微信小程序缓存过期时间的使用详情
May 12 #Javascript
从0到1搭建element后台框架优化篇(打包优化)
May 12 #Javascript
Vue项目服务器部署之子目录部署方法
May 12 #Javascript
vue配置接口域名方法总结
May 12 #Javascript
You might like
推荐Discuz!5的PHP代码高亮显示与实现可运行代码
2007/03/15 PHP
PHP的array_diff()函数在处理大数组时的效率问题
2011/11/27 PHP
thinkphp模板继承实例简述
2014/11/26 PHP
Bootstrap+PHP实现多图上传功能实例详解
2018/04/08 PHP
javascript权威指南 学习笔记之变量作用域分享
2011/09/28 Javascript
使用js正则控制input标签只允许输入的值
2013/07/29 Javascript
js 获取radio按钮值的实例
2013/08/17 Javascript
JavaScript加强之自定义event事件
2013/09/21 Javascript
jQuery.fn和jQuery.prototype区别介绍
2013/10/05 Javascript
javascript静态页面传值的三种方法分享
2013/11/12 Javascript
node.js中的fs.existsSync方法使用说明
2014/12/17 Javascript
js函数与php函数的区别实例浅析
2015/01/12 Javascript
jQuery实现Flash效果上下翻动的中英文导航菜单代码
2015/09/22 Javascript
jQuery循环遍历子节点并获取值的方法
2016/04/14 Javascript
纯JS实现轮播图
2017/02/22 Javascript
VUE使用vuex解决模块间传值问题的方法
2017/06/01 Javascript
基于JS实现移动端左滑删除功能
2017/07/28 Javascript
vue中element组件样式修改无效的解决方法
2018/02/03 Javascript
vue中利用simplemde实现markdown编辑器(增加图片上传功能)
2019/04/29 Javascript
JS浮点数运算结果不精确的Bug解决
2019/08/01 Javascript
五分钟搞懂Vuex实用知识(小结)
2019/08/12 Javascript
jQuery实现的移动端图片缩放功能组件示例
2020/05/01 jQuery
JavaScript Window浏览器对象模型原理解析
2020/05/30 Javascript
python 多进程通信模块的简单实现
2014/02/20 Python
python中numpy基础学习及进行数组和矢量计算
2017/02/12 Python
python处理DICOM并计算三维模型体积
2019/02/26 Python
如何用Python制作微信好友个性签名词云图
2019/06/28 Python
PyTorch之图像和Tensor填充的实例
2019/08/18 Python
python使用信号量动态更新配置文件的操作
2020/04/01 Python
韩国11街:11STREET
2018/03/27 全球购物
Rag & Bone官网:瑞格布恩高级成衣
2018/04/19 全球购物
中青班党性分析材料
2014/02/16 职场文书
小学优秀教师先进事迹材料
2014/12/16 职场文书
详解盒子端CSS动画性能提升
2021/05/24 HTML / CSS
MySQL系列之四 SQL语法
2021/07/02 MySQL
详解Mysql数据库平滑扩容解决高并发和大数据量问题
2022/05/25 MySQL