InfineScroll 无限加载bug

712 阅读6分钟

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

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

MutationObserver函数

作用

  • 监视 DOM 变动的接口

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

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

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

构造函数

var observer = new MutationObserver(callback);

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

实例方法

Observe
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

observer.disconnect();

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

throttle

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

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

语法:

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

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

InfineScroll源码解析

组件配置

const attributes = {
  delay: {  // 延迟时间,用于throttle函数的阈值
    type: Number,
    default: 200
  },
  distance: {  // 每次滚动后如果距离大于这个值才触发加载函数
    type: Number,
    default: 0
  },
  disabled: {  //是否不允许加载新的数据
    type: Boolean,
    default: false
  },
  immediate: {  //是否在页面初始化就立即调用一次数据加载
    type: Boolean,
    default: true
  }
};

获取配置函数

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;
  }, {});
};

入口

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,组件会在加载之初显式调用用于加载数据直到填满容器。

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. 对容器赋予一个高度。

参考文章:

juejin.cn/post/684490…

juejin.cn/post/684490…