本文是我学习 ElementUI 源码的第五篇文章,上一篇文章学习了 ElementUI 中 Message 组件的实现,这篇文章来学习一下 ElementUI 是如何实现 InfinityScroll(无限滚动)组件的。

组件效果

从效果上看,无限滚动是很简单的,就是在滚动条到达一个阈值时执行函数,加载更多的数据。

在平时的业务中,很多朋友可能都自己实现过,通过监听滚动事件,在滚动条距离末尾的距离达到阈值时执行加载函数就可以了。

但这是在业务中,很多情况都是在我们的掌握之中的,如果我们编写的是一个公用组件,又有哪些额外的情形需要考虑呢?

组件实现

我们从导出的对象出发,逐步分析其内部实现,无限滚动导出的对象如下:

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));

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

    if (container) {
      container.addEventListener('scroll', onScroll);

      if (immediate) {
        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);
    }
  }
};







 
 
 

 




 
 
 
 
 





 



可以看到,导出的对象似乎和前几篇文章中的不太一样,因为 ElementUI 编写的无限滚动组件采用的是自定义指令的方式。

为什么没有采用组件的方式呢?因为如果采用组件的方式,会多一层 div,而这个多出来的 div 是会对用户的 DOM 结构造成影响的,但是这个 div 本身又没有什么实用价值,纯粹就是一个容器而已,因此用自定义指令的方式对用户更友好。

提示

对自定义指令不熟悉的朋友,请点击这里open in new window查看官方文档。

insertedunbind 是自定义指令的两个生命周期钩子(更多钩子请查看文档),前者是在“被绑定元素插入父节点时调用(仅保证父节点存在,但不一定已被插入文档中)”,后者是在“指令与元素解绑时调用,只执行一次”。

binding.value 是指令的绑定值,一般都是用于加载数据的函数,在官网的例子 v-infinite-scroll="load" 中就是 load 函数。

vnode.context 需要特别注意,通过这个 context 拿到的是 v-infinite-scroll 指令所在“标签”的父组件

  • 第 8 行

getScrollContainer 方法用于获取滚动容器,位于 element-ui/src/utils/dom 中,其内容如下:

export const getScrollContainer = (el, vertical) => {
  if (isServer) return;

  let parent = el;
  while (parent) {
    if ([window, document, document.documentElement].includes(parent)) {
      return window;
    }
    if (isScroll(parent, vertical)) {
      return parent;
    }
    parent = parent.parentNode;
  }

  return parent;
};

这个函数首先会判断是否为服务器渲染,isServer 的内容如下:

const isServer = Vue.prototype.$isServer;

这个判断在后面的多个函数中都会使用。

接着,它会做两个判断(目前无限滚动只支持垂直方向,因此 vertical 直接传入的 true,也许以后会支持水平方向),第一层是判断是否为 [window, document, document.documentElement] 三者中的一个,如果是,则将滚动容器设为 window;第二层判断在 isScroll 函数中进行的,其内容如下:

export const isScroll = (el, vertical) => {
  if (isServer) return;

  const determinedDirection = vertical !== null || vertical !== undefined;
  const overflow = determinedDirection
    ? vertical
      ? getStyle(el, 'overflow-y')
      : getStyle(el, 'overflow-x')
    : getStyle(el, 'overflow');

  return overflow.match(/(scroll|auto)/);
};

这个函数和 getScrollContainer 配合,用于向上查找可滚动的元素。之所以这样处理是因为,有可能用户使用无限滚动的标签本身又设置了 overflow:hidden(作为用户来说,绝大多数情况下都不会这样做,但也不排除例外😂),那么对其应用滚动事件是无效的。当然,对于这种情况的处理也不仅有 ElementUI 这一种方式,简单的提示用户不能将无限滚动指令应用于 overflow:hidden 的元素上也是可以的。

getStyle 方法用于查找传入属性的值,其内容如下:

export const getStyle = ieVersion < 9 ? function(element, styleName) {
  if (isServer) return;
  if (!element || !styleName) return null;
  styleName = camelCase(styleName);
  if (styleName === 'float') {
    styleName = 'styleFloat';
  }
  try {
    switch (styleName) {
      case 'opacity':
        try {
          return element.filters.item('alpha').opacity / 100;
        } catch (e) {
          return 1.0;
        }
      default:
        return (element.style[styleName] || element.currentStyle ? element.currentStyle[styleName] : null);
    }
  } catch (e) {
    return element.style[styleName];
  }
} : function(element, styleName) {
  if (isServer) return;
  if (!element || !styleName) return null;
  styleName = camelCase(styleName);
  if (styleName === 'float') {
    styleName = 'cssFloat';
  }
  try {
    var computed = document.defaultView.getComputedStyle(element, '');
    return element.style[styleName] || computed ? computed[styleName] : null;
  } catch (e) {
    return element.style[styleName];
  }
};

可以看到,这个函数对 IE9 以下的浏览器做了兼容处理,因为这个函数不仅仅是为无限滚动组件准备的。ieVersion 的判断如下:

const ieVersion = isServer ? 0 : Number(document.documentMode);

非常简洁,document.documentMode 是 IE 浏览器特有的属性,在 IE 浏览器上调用时会返回渲染文档的模式

documentMode_ie

在其他浏览器上则返回 undefined

documentMode_chrome

需要特别注意的是,它返回的是渲染文档的模式,并不是真正的 IE 版本。也就是说,如果在 IE11 上开启仿真,使用 IE8 模式,则返回的值会是 8,而不是 11。

isScroll 中的传入的属性有 overflow-yoverflow-x 这种带短横线的,而 CSS 中都是以驼峰命名的,所以要先做转换。camelCase 的内容如下:

const camelCase = function(name) {
  return name.replace(SPECIAL_CHARS_REGEXP, function(_, separator, letter, offset) {
    return offset ? letter.toUpperCase() : letter;
  }).replace(MOZ_HACK_REGEXP, 'Moz$1');
};

这个函数同样也不仅仅是为无限滚动组件准备的,它的功能更多,能够去掉字符串中的 .- __:,并将以这些作为分隔符的字符串转换为驼峰形式,然后还会去掉 firefox 的属性前缀。

replace

SPECIAL_CHARS_REGEXPMOZ_HACK_REGEXP 的内容如下:

const SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g;
const MOZ_HACK_REGEXP = /^moz([A-Z])/;

经过这一番操作,拿到可滚动的容器。

  • 第 9 行

getScrollOptions 用于获取用户传入的配置,其内容如下:

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

isHtmlElement 用于判断传入的 el 是否为 html 元素,它和后面要用到的几个函数都位于 element-ui/src/utils/types 中:

export function isHtmlElement(node) {
  return node && node.nodeType === Node.ELEMENT_NODE;
}

export const isFunction = (functionToCheck) => {
  var getType = {};
  return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]';
};

export const isUndefined = (val)=> {
  return val === void 0;
};

export const isDefined = (val) => {
  return val !== undefined && val !== null;
};






 









有些好奇的是 isFunction 中使用 tostring 的方式,ElementUI 是创建了一个空对象 getType,但实际上直接调用 Object.prototype.toString 就可以了。

entries 方法用于转换传入的对象,其内容如下:

const entries = (obj) => {
  return Object.keys(obj || {})
    .map(key => ([key, obj[key]]));
};

有些好奇的是,ElementUI 并没有使用 ES6 中的 Object.entries() 方法。

参数 attributes 的定义如下:

const attributes = {
  delay: {
    type: Number,
    default: 200
  },
  distance: {
    type: Number,
    default: 0
  },
  disabled: {
    type: Boolean,
    default: false
  },
  immediate: {
    type: Boolean,
    default: true
  }
};

getScrollOptions 中的一番操作就是为了拿到 attributes 实际的值,但我个人觉得这个实现过程有些繁琐了,因为最终获取用户传入的值的方式实际上是通过 el.getAttribute(infinite-scroll-${key}) 实现的,完全可以通过对 attributes 对象进行遍历得到。

  • 第 10 行

handleScroll 是处理滚动加载的主要逻辑,在展开前我们先说一下外层的 throttle,这是 throttle-debounce 库中的节流函数,防止重复操作,源码并不多,感兴趣的朋友可以自行查看。

ElementUI 对传入到 throttle 中的 handleScroll 使用 bind 改变了其 this 指向,将其指向 el,方便内部使用。为什么不使用 call 或者 apply 呢?因为它们俩会立即执行函数 cb,而这里需要的是当用户触发滚动时才执行。

handleScroll 的内容如下:

const handleScroll = function(cb) {
  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) {
    observer.disconnect();
    this[scope].observer = null;
  }

};

可以看到,在函数内部的第一行,从 this[scope] 中引入了一些变量,scope 的值如下:

const scope = 'ElInfiniteScroll';

el 是指令绑定的元素,它身上并没有 elvmcontainerobserver 这些属性,那么这些属性是哪里来的呢?实际上是第 12 行定义的,这就是前文说的在改变 handleScrollthis 指向时为什么只能使用 bind,如果在此时调用了函数,这些变量都将拿不到。

函数内部在执行滚动的时候分为两种情况,第一种是指令所在元素的 overflow 不为 hidden,此时的 elcontainer 是同一个元素,判断逻辑比较简单;第二种是指令所在元素的 overflowhidden,此时的 elcontainer 不是同一个元素,这种情况就要复杂很多,因为滚动容器和指令所在元素本身都有可能是浮动的或者绝对定位的,所以要考虑滚动容器和指令所在元素的位置及高度,再进行计算。

计算中涉及到的辅助函数如下:

const getStyleComputedProperty = (element, property) => {
  if (element === window) {
    element = document.documentElement;
  }

  if (element.nodeType !== 1) {
    return [];
  }
  // NOTE: 1 DOM access here
  const css = window.getComputedStyle(element, null);
  return property ? css[property] : css;
};

const getPositionSize = (el, prop) => {
  return el === window || el === document
    ? document.documentElement[prop]
    : el[prop];
};

const getOffsetHeight = el => {
  return getPositionSize(el, 'offsetHeight');
};

const getClientHeight = el => {
  return getPositionSize(el, 'clientHeight');
};

当满足触发加载的条件时,如果用户传入了回调函数,则执行用户的回调函数,一般就是用户用加载数据的函数。这里有个关键点需要注意的是 cb.call(vm),因为回调函数是在父组件内,所以需要将回调函数的 this 指向 vm

如果用户忘记了传入回调函数,则检测是否有 observer,如果有,则取消 observer 的监测。那么这个 observer 是什么呢?又为什么要取消监测呢?我们回过头去看。

  • 第 17-21 行

在设置了 infinite-scroll-immediatetrue 时(默认情况下原本就是 true),ElementUI 使用 MutationObserver 创建了对滚动容器的监测,这个 MutationObserver 可以监测 DOM 的变动,这里就不展开讲解了,感兴趣的朋友可以查看这篇文章open in new window

在初始状态下,如果不没有内容或者内容高度不够时,不会出现滚动条,而且滚动事件也不会生效,那么无限滚动就失去意义了。所以,ElementUI 默认会立即执行加载函数,同时检测内容的高度是否达到了出现滚动条的要求,如果未达到则继续执行加载函数。

一旦出现了滚动条,滚动事件就能正常执行了,因此也就不需要再对滚动容器做监测了,所以才会调用 disconnect 方法取消监测。

  • 第 27 行

这行就是在移除指令时一并移除事件监听器,代码很简单,但却很重要。

结语

根据官网文档中的描述,InfinityScroll 看上去非常简单,但看了源码之后才发现,里面涉及到的东西还是很多的,这也给我们在编写业务代码时提供了极好的参考,让我们考虑问题能够更加全面,因为如果纯粹为了完成业务需求,我们也许不会考虑这么多情况,监听一下滚动事件就完事了。

本文分析基于 ElementUI 2.12.0 版本。

最近更新:
作者: MeFelixWang