InfineScroll 无限加载bug

最近使用了一下elementUI最新版本中的无限加载组件,在使用过程中有的情况下可能会发生加载函数无限调用的情况,于是去研究了一下源码。

阅读源码之前,首先要对两个函数了解一下,分别是MutationObserver和throttle函数

MutationObserver函数

作用

  • 监视 DOM 变动的接口

    当监视的 DOM 发生变动时 MutationObserver 将收到通知并触发事先设定好的回调函数。

  • 类似于事件,但是异步触发

    添加监视时,MutationObserver 上的 observer 函数与 addEventListener 有相似之处,但不同于后者的同步触发,MutationObserver 是异步触发,此举是为了避免 DOM 频繁变动导致回调函数被频繁调用,造成浏览器卡顿。

构造函数

1
var observer = new MutationObserver(callback);

callback,即回调函数接收两个参数,第一个参数是一个包含了所有 MutationRecord 对象的数组,第二个参数则是这个MutationObserver 实例本身。

实例方法

Observe
1
Observe(Node target, optional MutationObserverInit options);

给 MutationObserver 实例添加要观察的 DOM 节点,并可通过一个可选的 options 参数来配置观察哪些变动,该 options 为一个名为 MutationObserverInit 的对象。

以下是 MutationObserverInit 对象的各属性及其描述:

属性类型描述
childListBoolean是否观察子节点的变动
attributesBoolean是否观察属性的变动
characterDataBoolean是否节点内容或节点文本的变动
subtreeBoolean是否观察所有后代节点的变动
attributeOldValueBoolean观察 attributes 变动时,是否记录变动前的属性值
characterDataOldValueBoolean观察 characterData 变动时,是否记录变动前的属性值
attributeFilterArray表示需要观察的特定属性(比如[‘class’,‘src’]),不在此数组中的属性变化时将被忽略

注:

  • 不能单独观察 subtree 变动,必须同时指定 childList、attributes 和 characterData 中的一种或多种。
  • 为同一个 DOM 节点多次添加同一个 MutationObserver 是无效的,回调函数将只被触发一次。但如果指定不同的 options 对象(即观察不同的变动),即被视为不同的 MutationObserver。
disconnect

该方法用来停止观察。后续如果 DOM 节点发生变动将不再触发回调函数。

JavaScript

1
observer.disconnect();

其他更多的介绍可以参考文章最后的链接

throttle

debounce 与 throttle 是开发中常用的高阶函数,作用都是为了防止函数被高频调用,换句话说就是,用来控制某个函数在一定时间内执行多少次。

使用场景:比如绑定响应鼠标移动、窗口大小调整、滚屏等事件时,绑定的函数触发的频率会很频繁。若稍处理函数微复杂,需要较多的运算执行时间和资源,往往会出现延迟,甚至导致假死或者卡顿感。为了优化性能,这时就很有必要使用debouncethrottle了。

语法:

1
_.throttle(func, [wait=0], [options={}])

节流函数,在 wait 秒内最多执行 fn 一次的函数。
与不同的是,会有一个阀值,当到达阀值时,fn一定会执行。

InfineScroll源码解析

组件配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const attributes = {
delay: { // 延迟时间,用于throttle函数的阈值
type: Number,
default: 200
},
distance: { // 每次滚动后如果距离大于这个值才触发加载函数
type: Number,
default: 0
},
disabled: { //是否不允许加载新的数据
type: Boolean,
default: false
},
immediate: { //是否在页面初始化就立即调用一次数据加载
type: Boolean,
default: true
}
};

获取配置函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const getScrollOptions = (el, vm) => {   // 获取组件传入的配置,如果没有传入取配置的默认值
if (!isHtmlElement(el)) return {};

return entries(attributes).reduce((map, [key, option]) => {
const { type, default: defaultValue } = option;
let value = el.getAttribute(`infinite-scroll-${key}`);
value = isUndefined(vm[value]) ? value : vm[value];
switch (type) {
case Number:
value = Number(value);
value = Number.isNaN(value) ? defaultValue : value;
break;
case Boolean:
value = isDefined(value) ? value === 'false' ? false : Boolean(value) : defaultValue;
break;
default:
value = type(value);
}
map[key] = value;
return map;
}, {});
};

入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export default {
name: 'InfiniteScroll',
inserted(el, binding, vnode) {
const cb = binding.value; // 组件传入的加载数据的函数

const vm = vnode.context;
// only include vertical scroll
const container = getScrollContainer(el, true); // 获取滚动条的容器
const { delay, immediate } = getScrollOptions(el, vm); //获取组建的配置,接下来有解释
const onScroll = throttle(delay, handleScroll.bind(el, cb)); // 发生滚动时的回调函数,利用的是throttle,throttle第一个参数是一个阈值,第二个参数是函数,它能够控制在阈值时间内最多执行一次第二个参数传入的函数

el[scope] = { el, vm, container, onScroll };

if (container) {
container.addEventListener('scroll', onScroll); // 添加滚动的监听事件

if (immediate) {
// 是否立即触发数据加载,它能够保证数据纵向填满容器,具体是因为MutationObserver监听了container的dom变化,一旦变化立即触发数据加载,而数据加载又会改变dom,导致循环调用数据加载函数,直到某一次调用滚动条到底,就会调用MutationObserver.disconnect(),停止监听dom变化
const observer = el[scope].observer = new MutationObserver(onScroll);
observer.observe(container, { childList: true, subtree: true });
onScroll();
}
}
},
unbind(el) {
const { container, onScroll } = el[scope];
if (container) {
container.removeEventListener('scroll', onScroll);
}
}
};

监听到滚动事件时的回调函数

这个函数不仅在出发滚动时会调用,如果设置了immidate为true,组件会在加载之初显式调用用于加载数据直到填满容器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const scope = 'ElInfiniteScroll';

const getElementTop = el => el.getBoundingClientRect().top;

const handleScroll = function(cb) { //数据加载函数,会先判断滚动条是否到底并且disabled参数是否允许加载
const { el, vm, container, observer } = this[scope];
const { distance, disabled } = getScrollOptions(el, vm);

if (disabled) return;

let shouldTrigger = false;

// 判断是否滚动到底部
if (container === el) {
// be aware of difference between clientHeight & offsetHeight & window.getComputedStyle().height
const scrollBottom = container.scrollTop + getClientHeight(container);
shouldTrigger = container.scrollHeight - scrollBottom <= distance;
} else {
const heightBelowTop = getOffsetHeight(el) + getElementTop(el) - getElementTop(container);
const offsetHeight = getOffsetHeight(container);
const borderBottom = Number.parseFloat(getStyleComputedProperty(container, 'borderBottomWidth'));
shouldTrigger = heightBelowTop - offsetHeight + borderBottom <= distance;
}

if (shouldTrigger && isFunction(cb)) {
cb.call(vm);
} else if (observer) {
// 当调用某次数据加载函数但是滚动条还没有到底,并且用MutationObserver监听了组件的dom变化时,把该监听关闭,只会出现在 immediate 为 true ,组件注册时注册了MutationObserver导致循环调用数据加载函数时有效
observer.disconnect();
this[scope].observer = null;
}

};

结论

所以说当container初始没有height,并且设置了immediate时,会显式触发一次滚动回调函数,同时为容器添加MutationObserver,而这样一来就会使得容器初始化的时候数据就加载到充满容器。但是因为scrollTop一直为0,导致没办法判断到滚动条到底,所以数据加载函数中关于disconnect MutationObserver 的逻辑不会被调用到,就会陷入调用数据加载,容器改变,再调用,再改变的循环之中。

解决方案

  1. 直接就将immidate设置为false,就不会注册MutationObserve
  2. 对容器赋予一个高度。

参考文章:

https://juejin.im/entry/5a9d4eea518825556b6c440d

https://juejin.im/entry/57de3fc30bd1d00057f2ea33