详解无限滚动插件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 相关文章推荐
创建一个复制UBB软件信息的链接或按钮的js代码
Jan 06 Javascript
基于jquery的实现简单的表格中增加或删除下一行
Aug 01 Javascript
getElementByIdx_x js自定义getElementById函数
Jan 24 Javascript
用最通俗易懂的代码帮助新手理解javascript闭包 推荐
Mar 01 Javascript
js中 关于undefined和null的区别介绍
Apr 16 Javascript
JavaScript的Number对象的toString()方法
Dec 18 Javascript
js 基础篇必看(点击事件轮播图的简单实现)
Aug 20 Javascript
三种方式实现瀑布流布局
Feb 10 Javascript
jQuery+Ajax请求本地数据加载商品列表页并跳转详情页的实现方法
Jul 12 jQuery
解决Vue调用springboot接口403跨域问题
Sep 02 Javascript
Vue.js中的高级面试题及答案
Jan 13 Javascript
create-react-app开发常用配置教程
Jun 25 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
PHP调用三种数据库的方法(2)
2006/10/09 PHP
PHP制作百度词典查词采集器
2015/01/29 PHP
php使用curl获取https请求的方法
2015/02/11 PHP
php实现的pdo公共类定义与用法示例
2017/07/19 PHP
Laravel监听数据库访问,打印SQL的例子
2019/10/24 PHP
PHP与Web页面交互操作实例分析
2020/06/02 PHP
关于PhpStorm设置点击编辑文件自动定位源文件的实现方式
2020/12/30 PHP
javascript 控制 html元素 显示/隐藏实现代码
2009/09/01 Javascript
jquery 经典动画菜单效果代码
2010/01/26 Javascript
nodejs实用示例 缩址还原
2010/12/28 NodeJs
Bootstrap创建可折叠的组件
2016/02/23 Javascript
js前端日历控件(悬浮、拖拽、自由变形)
2017/03/02 Javascript
xmlplus组件设计系列之下拉刷新(PullRefresh)(6)
2017/05/03 Javascript
移动端手指放大缩小插件与js源码
2017/05/22 Javascript
详解在IDEA中将Echarts引入web两种方式(使用js文件和maven的依赖导入)
2020/07/11 Javascript
Node.js web 应用如何封装到Docker容器中
2020/09/01 Javascript
基于JS实现操作成功之后自动跳转页面
2020/09/25 Javascript
[01:10]3.19DOTA2发布会 三代刀塔人第一代
2014/03/25 DOTA
[01:07:57]DOTA2-DPC中国联赛 正赛 Ehome vs Magma BO3 第二场 1月19日
2021/03/11 DOTA
github配置使用指南
2014/11/18 Python
python杀死一个线程的方法
2015/09/06 Python
python编码最佳实践之总结
2016/02/14 Python
Python文件读写保存操作的示例代码
2018/09/14 Python
Python的条件表达式和lambda表达式实例
2019/01/31 Python
Python进阶之全面解读高级特性之切片
2019/02/19 Python
Python 3.8 新功能全解
2019/07/25 Python
基于python生成英文版词云图代码实例
2020/05/16 Python
阿玛尼美国官方网站:Armani.com
2016/11/25 全球购物
Dr.Jart+美国官网:韩国药妆品牌
2019/01/18 全球购物
优秀管理者获奖感言
2014/02/17 职场文书
体育节口号
2014/06/19 职场文书
班子查摆四风个人对照检查材料思想汇报
2014/10/04 职场文书
学习与创新自我评价
2015/03/09 职场文书
2015年高考寄语或鼓励的话
2015/03/23 职场文书
百日宴上的祝酒词
2015/08/10 职场文书
2016高考寄语集锦
2015/12/04 职场文书