详解无限滚动插件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 语法基础 想学习js的朋友可以看看
Dec 16 Javascript
js和php如何获取当前url的内容
Sep 22 Javascript
JS关闭窗口或JS关闭页面的几种代码分享
Oct 25 Javascript
js实现的复制兼容chrome和IE
Apr 03 Javascript
jQuery自动添加表单项的方法
Jul 13 Javascript
JavaScript自定义函数实现查找两个字符串最长公共子串的方法
Nov 24 Javascript
javascript中数组(Array)对象和字符串(String)对象的常用方法总结
Dec 15 Javascript
Bootstrap fileinput组件封装及使用详解
Mar 10 Javascript
Javascript中八种遍历方法的执行速度深度对比
Apr 25 Javascript
使用SVG基本操作API的实例讲解
Sep 14 Javascript
element-ui 实现响应式导航栏的示例代码
May 08 Javascript
深入理解Vue的数据响应式
May 15 Vue.js
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
php中3种方法统计字符串中每种字符的个数并排序
2012/08/27 PHP
PHP实现连接设备、通讯和发送命令的方法
2015/10/13 PHP
CodeIgniter控制器之业务逻辑实例分析
2016/01/20 PHP
php版微信开发之接收消息,自动判断及回复相应消息的方法
2016/09/23 PHP
tp5.1 框架join方法用法实例分析
2020/05/26 PHP
Tab页界面 用jQuery及Ajax技术实现(php后台)
2011/10/12 Javascript
javascript使用中为什么10..toString()正常而10.toString()出错呢
2013/01/11 Javascript
使用js如何实现全选与全不选
2013/12/30 Javascript
JavaScript+CSS控制打印格式示例介绍
2014/01/07 Javascript
详解JavaScript中的blink()方法的使用
2015/06/08 Javascript
JavaScript实现点击按钮直接打印
2016/01/06 Javascript
bootstrap table表格插件之服务器端分页实例代码
2018/09/12 Javascript
js设置默认时间跨度过程详解
2019/07/17 Javascript
利用H5api实现时钟的绘制(javascript)
2020/09/13 Javascript
[47:50]Secret vs VP 2018国际邀请赛小组赛BO2 第二场 8.17
2018/08/20 DOTA
python中日期和时间格式化输出的方法小结
2015/03/19 Python
使用Python生成url短链接的方法
2015/05/04 Python
使用简单工厂模式来进行Python的设计模式编程
2016/03/01 Python
Django中ORM表的创建和增删改查方法示例
2017/11/15 Python
Python爬虫实现“盗取”微信好友信息的方法分析
2019/09/16 Python
python nohup 实现远程运行不宕机操作
2020/04/16 Python
彻底搞懂python 迭代器和生成器
2020/09/07 Python
Pandas之缺失数据的实现
2021/01/06 Python
Kenneth Cole官网:纽约时尚优雅品牌
2016/11/14 全球购物
美国庭院家具购物网站:AlphaMarts
2019/04/10 全球购物
阿里巴巴美国:Alibaba美国
2019/11/24 全球购物
大三学生做职业规划:给未来找个方向
2014/02/24 职场文书
高中综合实践活动总结
2014/07/07 职场文书
餐饮服务食品安全责任书
2014/07/25 职场文书
2015年元旦主持词开场白
2014/12/14 职场文书
乡镇一岗双责责任书
2015/01/29 职场文书
学校国庆节活动总结
2015/03/23 职场文书
追讨欠款律师函
2015/05/27 职场文书
庆祝教师节主题班会
2015/08/17 职场文书
长辈生日祝福语大全(72句)
2019/08/09 职场文书
浅谈Web Storage API的使用
2021/06/23 Javascript