InfineScroll 无限加载bug

I recently used the infinite loading component in the latest version of elementUI, and in some cases the loading function may be called infinitely during use, so I went to study the source code.

Before reading the source code, the first two functions to understand a little, respectively, MutationObserver and throttle function

MutationObserver函数

Role

  • Interface for monitoring DOM changes

    The MutationObserver will be notified when the monitored DOM changes and trigger a pre-defined callback function.

  • Similar to events, but triggered asynchronously

    The observer function on the MutationObserver is similar to addEventListener when adding a watch, but unlike the synchronous trigger of the latter, the MutationObserver is triggered asynchronously, which is to avoid frequent DOM changes that cause the callback function to be called frequently and cause browser lag.

Constructors

1
var observer = new MutationObserver(callback);

The first argument is an array of all MutationRecord objects, and the second argument is the MutationObserver instance itself.

Instance Method

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

Add DOM nodes to the MutationObserver instance to observe and configure which changes to observe with an optional options parameter, which is an object named MutationObserverInit.

The following are the properties of the MutationObserverInit object and their descriptions:

PropertiesTypeDescription
childListBooleanWhether to watch for changes in child nodes
attributesBooleanWhether to watch for changes in attributes
characterDataBooleanWhether the node content or node text changes
subtreeBooleanWhether to observe the changes of all descendant nodes
attributeOldValueBooleanWhether to record the value of the attribute before the change when observing changes to attributes
characterDataOldValueBooleanIf or not the value of the attribute before the change is recorded when observing a change in characterData
attributeFilterArrayindicates a specific attribute to be observed (e.g. [‘class’,‘src’]), attribute changes that are not in this array will be ignored

Notes:

  • You cannot observe subtree changes alone; you must specify one or more of childList, attributes, and characterData at the same time.
  • Adding the same MutationObserver multiple times for the same DOM node is not valid and the callback function will only be triggered once. However, if different options objects are specified (i.e., different changes are observed), they are treated as different MutationObservers.
disconnect

This method is used to stop the watch. Subsequent changes to the DOM node will not trigger the callback function.

JavaScript

1
observer.disconnect();

Other more introductions can be found in the links at the end of the article

throttle

Both debounce and throttle are high-order functions that are commonly used in development to prevent functions from being called too often, in other words, to control how many times a function is executed within a certain period of time.

Use scenario: For example, when binding response to mouse movement, window resizing, scrolling and other events, the frequency of binding functions triggered will be very frequent. If the slightly more complex processing function requires more arithmetic execution time and resources, there will often be delays, even leading to false death or a sense of lag. In order to optimize performance, it is necessary to use debounce or throttle at this time.

Syntax:

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

Throttle function, a function that executes fn at most once in wait seconds.
Unlike the difference, there will be a threshold value, and when it is reached, the fn will definitely be executed.

InfineScroll source code analysis

Component Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const attributes = {
delay: { // delay time, threshold for the throttle function
type: Number,
default: 200
},
distance: { // The load function is triggered only if the distance is greater than this value after each scroll
type: Number,
default: 0
},
disabled: { //does not allow new data to be loaded
type: Boolean,
default: false
},
immediate: { // whether to call a data load immediately upon page initialization
type: Boolean,
default: true
}
};

Get Configuration Functions

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) => { // Get the configuration passed in by the component, if not pass in the default value of the configuration
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;
}, {});
};

Entry

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; // function to load the data passed in by the component

const vm = vnode.context;
// only include vertical scroll
const container = getScrollContainer(el, true); // 获取滚动条的容器
const { delay, immediate } = getScrollOptions(el, vm); //get the configuration of the formation, explained next
const onScroll = throttle(delay, handleScroll.bind(el, cb)); // callback function when scrolling occurs, utilizing throttle, the first parameter of throttle is a threshold value, the second parameter is a function that can control the execution of the second at most once within the threshold time The function passed in the parameter

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

if (container) {
container.addEventListener('scroll', onScroll); // Add a listener for scrolling

if (immediate) {
// whether to trigger data loading immediately, it can ensure that the data vertically fill the container, specifically because MutationObserver listens to the container's dom change, once the change immediately triggers data loading, and data loading will change the dom, resulting in a circular call to the data loading function, until a certain call to the bottom of the scrollbar, it will call MutationObserver.disconnect(), stop listening to the dom change
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);
}
}
};

Callback function when a scroll event is listened to

This function is not only called at the start of scrolling, but if immidate is set to true, the component will be called explicitly at the beginning of loading to load data until the container is filled.

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) { //Data loading function, will first determine whether the scrollbar is at the bottom and whether the disabled parameter allows loading
const { el, vm, container, observer } = this[scope];
const { distance, disabled } = getScrollOptions(el, vm);

if (disabled) return;

let shouldTrigger = false;

// Determine if scrolling to the bottom
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) {
// When a data loading function is called but the scrollbar is not yet at the bottom, and the MutationObserver is used to listen for changes in the component's dom, turn off the listener, which will only work if immediate is true and the MutationObserver is registered when the component is registered, resulting in a cyclic call to the data loading function
observer.disconnect();
this[scope].observer = null;
}

};

Conclusion

So that when the container initial no height, and set the IMMEDIATE, will explicitly trigger a scroll callback function, while adding MutationObserver for the container, and this will make the container initialized when the data loaded to fill the container. But because the scrollTop is always 0, there is no way to judge the scroll bar to the bottom, so the logic of disconnect MutationObserver in the data loading function will not be called, and it will be caught in the cycle of calling data loading, container change, call again, and change again.

Solutions

  1. directly set immidate to false, it will not register MutationObserve
  2. assign a height to the container.

Reference article:

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

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