简介

本篇文章主要介绍一个优秀的基于react实现的懒加载控件:https://github.com/twobin/react-lazyload

优点

  • 易于使用,比如

    1. <Lazyload throttle={200} height={300}>
    2. <img src="http://ww3.sinaimg.cn/mw690/62aad664jw1f2nxvya0u2j20u01hc16p.jpg" />
    3. </Lazyload>
  • 代码不侵入,可以懒加载任何的东西,不仅限于图片

  • 源代码短小精悍,易于理解,易于修改
  • star数3k+,生命力不错

好奇

  • 如何实现懒加载
  • 怎么处理相对位置固定大小容器的懒加载
  • 懒加载组件的每个api具体做什么用的,真需要这么多么,我们自己实现的话能想到哪些

    1. LazyLoad.propTypes = {
    2. once: PropTypes.bool,
    3. height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
    4. offset: PropTypes.oneOfType([PropTypes.number, PropTypes.arrayOf(PropTypes.number)]),
    5. overflow: PropTypes.bool,
    6. resize: PropTypes.bool,
    7. scroll: PropTypes.bool,
    8. children: PropTypes.node,
    9. throttle: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
    10. debounce: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
    11. placeholder: PropTypes.node,
    12. scrollContainer: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
    13. unmountIfInvisible: PropTypes.bool
    14. };
  • 如何判断一个组件需要加载 or 不加载的,边界测试如何实现

  • 支持横向懒加载么?为什么api里没有width这个选项

实现思路

  1. class LazyLoad extends Component {
  2. constructor(props) {
  3. super(props)
  4. this.visible = false;
  5. }
  6. componentDidMount() {
  7. ...
  8. }
  9. shouldComponentUpdate() {
  10. return this.visible;
  11. }
  12. componentWillUnmount() {
  13. ...
  14. }
  15. render() {
  16. return this.visible ?
  17. this.props.children :
  18. this.props.placeholder ?
  19. this.props.placeholder :
  20. <div style={{ height: this.props.height }} className="lazyload-placeholder" />;
  21. }

简单猜测

  • 首先,组件加不加载,LazyLoad这个组件以高阶组件的形式内含了我们所要使用懒加载的组件,由内置的this.visible控制,而这个变量将会是组件与外界(包含容器)产生联系的地方,比如由监听事件触发后,来判断并改变this.visible的值,由此控制了组件的加载不加载。
  • 而改变this.visible的逻辑,应该会与事件扯上联系,比较我们的懒加载是基于视窗变化来实现组件按需加载的一种概念。所以看起来这部分逻辑应该就是省略的componentDidMount部分了。
  • componentWillUnmount应该会涉及一些事件清除等移除即将销毁组件遗留状态的工作

源码细节

在经过一个简单猜测后,我们还是实际还是应该一步步去看着代码,带着我们之前“好奇”的问题,来近一步探寻这个精巧的懒加载组件是如何完成的。

细节1 - componentDidMount阶段具体做了什么

  1. componentDidMount() {
  2. // It's unlikely to change delay type on the fly, this is mainly
  3. // designed for tests
  4. let scrollport = window; // 这个地方不难理解,正常我们懒加载滑动窗口都是window
  5. const { // 设置完默认的scrollport,从正常需求来看,会存在滑动窗口并非是window的情况,
  6. scrollContainer, // 所以props上会暴露一个scrollContainer的api来处理这种情况
  7. } = this.props;
  8. if (scrollContainer) {
  9. if (isString(scrollContainer)) {
  10. scrollport = scrollport.document.querySelector(scrollContainer);
  11. }
  12. // TODO(疑问):如果scrollContainer是Object的情况呢?api是支持这个数据类型的
  13. }
  14. // 这里从变量名来看应该是判断是不是需要重载 debounce 或则 throttle的
  15. // TODO(疑问),看起来这里似乎有点费解,是不是有bug?
  16. const needResetFinalLazyLoadHandler = (this.props.debounce !== undefined && delayType === 'throttle')
  17. || (delayType === 'debounce' && this.props.debounce === undefined);
  18. if (needResetFinalLazyLoadHandler) {
  19. off(scrollport, 'scroll', finalLazyLoadHandler, passiveEvent);
  20. off(window, 'resize', finalLazyLoadHandler, passiveEvent);
  21. finalLazyLoadHandler = null;
  22. }
  23. if (!finalLazyLoadHandler) {
  24. if (this.props.debounce !== undefined) {
  25. finalLazyLoadHandler = debounce(lazyLoadHandler, typeof this.props.debounce === 'number' ?
  26. this.props.debounce :
  27. 300);
  28. delayType = 'debounce';
  29. } else if (this.props.throttle !== undefined) {
  30. finalLazyLoadHandler = throttle(lazyLoadHandler, typeof this.props.throttle === 'number' ?
  31. this.props.throttle :
  32. 300);
  33. delayType = 'throttle';
  34. } else {
  35. finalLazyLoadHandler = lazyLoadHandler;
  36. }
  37. }
  38. // 这个overflow api从下面的逻辑看,应该是判断组件是否包含在非window对象的容器中的懒加载
  39. if (this.props.overflow) {
  40. // 如果是,就找到包含该组件的父及容器
  41. const parent = scrollParent(ReactDom.findDOMNode(this));
  42. if (parent && typeof parent.getAttribute === 'function') {
  43. // 这个打标记的意义在哪需要再观察观察,逻辑上是为了保证监听事件只进行一次,不再重复监听
  44. const listenerCount = 1 + (+parent.getAttribute(LISTEN_FLAG));
  45. if (listenerCount === 1) {
  46. parent.addEventListener('scroll', finalLazyLoadHandler, passiveEvent);
  47. }
  48. parent.setAttribute(LISTEN_FLAG, listenerCount);
  49. }
  50. } else if (listeners.length === 0 || needResetFinalLazyLoadHandler) {
  51. // 从下面逻辑看,listeners数组是存储被懒加载的组件集合(单例)
  52. // 结合之前的内容看(scrollport),这里是在对没传overflow参数时,事件绑定的处理
  53. // TODO(疑问):这里是不是也应该用上面打标记计数的方式,标记一个容器只能被监听一次
  54. const { scroll, resize } = this.props;
  55. if (scroll) {
  56. on(scrollport, 'scroll', finalLazyLoadHandler, passiveEvent);
  57. }
  58. if (resize) {
  59. on(window, 'resize', finalLazyLoadHandler, passiveEvent);
  60. }
  61. }
  62. listeners.push(this);
  63. // 此处应该是改变this.visible的地方,后面的细节3会详细讲解这部分逻辑
  64. checkVisible(this);
  65. }

细节2 - lazyLoadHandler

由下面代码可以看出,同一个容器内的scroll/resize事件监听只会进行一次,多次的合并是通过listener数组做到的。那么这里也有一个疑问:当前的逻辑,似乎无法满足当一个页面中有多个容器的懒加载时,每次事件触发,只会扫描对应容器下有关的listener,我理解这可能是这个组件库可以有待改进的地方(或许是个能pr好机会哟~)。
总之,这个函数大致意思也就是在 scroll/resize 事件触发时,集中对涉及到的lazyload组件进行判断他们是否显示加载。

  1. const lazyLoadHandler = () => {
  2. for (let i = 0; i < listeners.length; ++i) {
  3. const listener = listeners[i];
  4. // 这个函数在ComponentDidMount阶段也被调用过,细节3将会更详细的讲解他的逻辑
  5. checkVisible(listener);
  6. }
  7. // Remove `once` component in listeners
  8. purgePending(); // 这个地方属于非主线细节,就暂时略过了,感兴趣的可以看源码
  9. };

细节3 - checkVisible

  1. const checkVisible = function checkVisible(component) {
  2. const node = ReactDom.findDOMNode(component); // 获取真实的dom元素
  3. if (!(node instanceof HTMLElement)) { // 容错处理
  4. return;
  5. }
  6. const parent = scrollParent(node); // 找到对应懒加载组件的包裹容器
  7. const isOverflow = component.props.overflow && // 判断容器是否是"全屏幕"的
  8. parent !== node.ownerDocument &&
  9. parent !== document &&
  10. parent !== document.documentElement;
  11. const visible = isOverflow ? // 根据容器是否是为"全屏幕"的,
  12. //采取不同的处理方法来计算组件是否需要显示
  13. checkOverflowVisible(component, parent) :
  14. checkNormalVisible(component);
  15. if (visible) {
  16. // Avoid extra render if previously is visible
  17. if (!component.visible) {
  18. if (component.props.once) { // 这个once的api应该是用来做性能优化的"剪枝"操作的,
  19. pending.push(component); // 避免不必要的listen再次被扫到处理
  20. } // 这里可以想想如果是我们设计这个组件时,是否会考虑到这个api
  21. component.visible = true; // 一旦组件是需要显示的,就会调用 component.forceUpdate
  22. component.forceUpdate(); // 来对组件进行更新操作了
  23. }
  24. } else if (!(component.props.once && component.visible)) { // 这里应该是考虑到被懒加载的组件
  25. component.visible = false; // 后续可能会因为外部props导致
  26. if (component.props.unmountIfInvisible) { // 更新,把非视区的组件先暂时隐藏,
  27. component.forceUpdate(); // 这样想也是另一场景下的性能优化
  28. }
  29. }
  30. };

从上面代码看,作者考虑到了不同场景下的一些优化性能的方式,基于此设计了相应的once,unmountIfInVisible 的api,可谓是很全面的了,可以想想假设是我们自己来设计时,是否能想到这些api,想到了会怎么来设计?

细节4 - checkNormalVisible

这是处理正常全屏幕容器懒加载组件是否可见的情况,其实不看代码,我们也能大概知道,是一个判断当前的组件是否和可视区域有交集的,可以抽象成二维平面,两个四边形是否相交的问题,相交则证明组件属于可视区域,反之亦然。

  1. const checkNormalVisible = function checkNormalVisible(component) {
  2. const node = ReactDom.findDOMNode(component);
  3. // If this element is hidden by css rules somehow, it's definitely invisible
  4. if (!(node.offsetWidth || node.offsetHeight || node.getClientRects().length)) return false;
  5. let top;
  6. let elementHeight;
  7. try {
  8. // Element.getBoundingClientRect()方法返回元素的大小及其相对于视口的位置
  9. // 这里只获取了组件的盒模型高及相对的top位置,由此能判断当前的懒加载组件只处理垂直方向的懒加载
  10. ({ top, height: elementHeight } = node.getBoundingClientRect());
  11. } catch (e) { // 容错方案,细节可看源码
  12. ({ top, height: elementHeight } = defaultBoundingClientRect);
  13. }
  14. // 因为是全屏幕的容器,所以另一个用来判断是否与组件盒子有交集的四边形就是window了
  15. const windowInnerHeight = window.innerHeight || document.documentElement.clientHeight;
  16. // 这里可的offsets的api设计,可以理解为懒加载的“提前量”需要,做过类似需求的朋友应该能有体会
  17. const offsets = Array.isArray(component.props.offset) ?
  18. component.props.offset :
  19. [component.props.offset, component.props.offset]; // Be compatible with previous API
  20. // 在垂直方向,判断是否有交集的逻辑,为什么是这么判断呢
  21. // 其实很好理解,交不交差其实都是按边界情况考虑的,如果组件的上边界相对视窗位置(top-offsets[0])
  22. // 超过了视窗的下边界的位置windowHeight,那再也不可能相较了。
  23. // 同理,如果组件的下边界位置,超过了视窗上边界的位置,那同样也不可能再相交,由此得出了这个计算式子
  24. return (top - offsets[0] <= windowInnerHeight) &&
  25. (top + elementHeight + offsets[1] >= 0);
  26. }

image.png

细节5 - checkOverflowVisible

同样是判断是否相交的逻辑,下面的代码区别于细节4的情况,主要在于容器非全屏幕的情况,容器只是浏览器视窗的一个子集,所以在处理相较逻辑上会稍稍做一些改变,看起来应该要多一些相对距离的计算逻辑,具体我们来看代码
image.png

  1. const checkOverflowVisible = function checkOverflowVisible(component, parent) {
  2. const node = ReactDom.findDOMNode(component);
  3. let parentTop;
  4. let parentHeight;
  5. try {
  6. // 由于多了一个非全屏幕的容器,所以此处需要获取父级容器的位置是比较容易理解的
  7. ({ top: parentTop, height: parentHeight } = parent.getBoundingClientRect());
  8. } catch (e) {
  9. ({ top: parentTop, height: parentHeight } = defaultBoundingClientRect);
  10. }
  11. const windowInnerHeight = window.innerHeight || document.documentElement.clientHeight;
  12. // calculate top and height of the intersection of the element's scrollParent and viewport
  13. // 有了非全屏幕容器的存在,所以需要计算真正可视区域的上边界
  14. const intersectionTop = Math.max(parentTop, 0); // intersection's top relative to viewport
  15. // 获取真正可视区域的高,也就是获取可视区域的下边界
  16. const intersectionHeight = Math.min(windowInnerHeight, parentTop + parentHeight) - intersectionTop; // height
  17. // check whether the element is visible in the intersection
  18. let top;
  19. let height;
  20. try {
  21. ({ top, height } = node.getBoundingClientRect());
  22. } catch (e) {
  23. ({ top, height } = defaultBoundingClientRect);
  24. }
  25. const offsetTop = top - intersectionTop; // element's top relative to intersection
  26. const offsets = Array.isArray(component.props.offset) ?
  27. component.props.offset :
  28. [component.props.offset, component.props.offset]; // Be compatible with previous API
  29. // 利用求得的实际上下边界,进行与全屏幕视窗时相同的计算方式来进行相交判断,便能计算出组件是否可见了
  30. return (offsetTop - offsets[0] <= intersectionHeight) &&
  31. (offsetTop + height + offsets[1] >= 0);
  32. };

所以,其实对于checkOverflow这种情况的相交判断,只是正常相交判断的特殊版本,两者的代码逻辑是一致的,甚至也可以写做一个函数,不过从阅读的感觉上来看,这种情况,分开写阅读起来会更友好,更能分清楚不同的情况,也给我们平常实现类似逻辑时,提供一点参考。

总结

整个仓库的代码不算上测试用例的话,估计不到1千行,但实现了很丰富场景的懒加载的情况,也对不同场景的性能优化增加了api支持,整个阅读过程下来受到了不少的启发:

  • 高阶组件的一种运用场景,非侵入性的增强了功能(特性)
  • 灵活应用了模块的单例模式,同一模块中的变量复用,比如listeners这个数组,实现了在不同懒加载组件中,共享同一个事件监听来处理相同事务。
  • 相应性能优化的api很受启发,加深了对react开发中,不同编码方式及api使用场景的体感。
  • 一个短小精悍的库真的很受国内外同行欢迎,平常也可以尝试开发类似的组件,锻炼自己的设计及编码能力。