react-lazyload懒加载控件源码解析

栏目: IOS · Android · 发布时间: 5年前

内容简介:在经过一个简单猜测后,我们还是实际还是应该一步步去看着代码,带着我们之前“好奇”的问题,来近一步探寻这个精巧的懒加载组件是如何完成的。由下面代码可以看出,同一个容器内的scroll/resize事件监听只会进行一次,多次的合并是通过listener数组做到的。那么这里也有一个疑问:当前的逻辑,似乎无法满足当一个页面中有多个容器的懒加载时,每次事件触发,只会扫描对应容器下有关的listener,我理解这可能是这个组件库可以有待改进的地方(或许是个能pr好机会哟~)。总之,这个函数大致意思也就是在
  • 易于使用,比如
<Lazyload throttle={200} height={300}>
  <img src="http://ww3.sinaimg.cn/mw690/62aad664jw1f2nxvya0u2j20u01hc16p.jpg" />
</Lazyload>
复制代码
  • 代码不侵入,可以懒加载任何的东西,不仅限于图片
  • 源代码短小精悍,易于理解,易于修改
  • star数3k+,生命力不错

好奇

  • 如何实现懒加载
  • 怎么处理相对位置固定大小容器的懒加载
  • 懒加载组件的每个api具体做什么用的,真需要这么多么,我们自己实现的话能想到哪些
LazyLoad.propTypes = {
  once: PropTypes.bool,
  height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  offset: PropTypes.oneOfType([PropTypes.number, PropTypes.arrayOf(PropTypes.number)]),
  overflow: PropTypes.bool,
  resize: PropTypes.bool,
  scroll: PropTypes.bool,
  children: PropTypes.node,
  throttle: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
  debounce: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
  placeholder: PropTypes.node,
  scrollContainer: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
  unmountIfInvisible: PropTypes.bool
};
复制代码
  • 如何判断一个组件需要加载 or 不加载的,边界测试如何实现
  • 支持横向懒加载么?为什么api里没有width这个选项

实现思路

class LazyLoad extends Component {
  constructor(props) {
     super(props)
     this.visible = false;
  }
  componentDidMount() {
  	... 
  }
  shouldComponentUpdate() {
    return this.visible;
  }
  componentWillUnmount() {
   	... 
  }
	render() {
    return this.visible ?
           this.props.children :
             this.props.placeholder ?
                this.props.placeholder :
                <div style={{ height: this.props.height }} className="lazyload-placeholder" />;
  }
复制代码

简单猜测

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

源码细节

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

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

componentDidMount() {
    // It's unlikely to change delay type on the fly, this is mainly
    // designed for tests
    let scrollport = window; // 这个地方不难理解,正常我们懒加载滑动窗口都是window
    const {              // 设置完默认的scrollport,从正常需求来看,会存在滑动窗口并非是window的情况,
      scrollContainer,   // 所以props上会暴露一个scrollContainer的api来处理这种情况
    } = this.props;
    if (scrollContainer) {
      if (isString(scrollContainer)) {
        scrollport = scrollport.document.querySelector(scrollContainer);
      }
      // TODO(疑问):如果scrollContainer是Object的情况呢?api是支持这个数据类型的
    }
    
    // 这里从变量名来看应该是判断是不是需要重载 debounce 或则 throttle的
    // TODO(疑问),看起来这里似乎有点费解,是不是有bug?
    const needResetFinalLazyLoadHandler = (this.props.debounce !== undefined && delayType === 'throttle')
      || (delayType === 'debounce' && this.props.debounce === undefined);

    if (needResetFinalLazyLoadHandler) {
      off(scrollport, 'scroll', finalLazyLoadHandler, passiveEvent);
      off(window, 'resize', finalLazyLoadHandler, passiveEvent);
      finalLazyLoadHandler = null;
    }

    if (!finalLazyLoadHandler) {
      if (this.props.debounce !== undefined) {
        finalLazyLoadHandler = debounce(lazyLoadHandler, typeof this.props.debounce === 'number' ?
                                                         this.props.debounce :
                                                         300);
        delayType = 'debounce';
      } else if (this.props.throttle !== undefined) {
        finalLazyLoadHandler = throttle(lazyLoadHandler, typeof this.props.throttle === 'number' ?
                                                         this.props.throttle :
                                                         300);
        delayType = 'throttle';
      } else {
        finalLazyLoadHandler = lazyLoadHandler;
      }
    }

    // 这个overflow api从下面的逻辑看,应该是判断组件是否包含在非window对象的容器中的懒加载
    if (this.props.overflow) {
      // 如果是,就找到包含该组件的父及容器
      const parent = scrollParent(ReactDom.findDOMNode(this));
      if (parent && typeof parent.getAttribute === 'function') {
        // 这个打标记的意义在哪需要再观察观察,逻辑上是为了保证监听事件只进行一次,不再重复监听
        const listenerCount = 1 + (+parent.getAttribute(LISTEN_FLAG));
        if (listenerCount === 1) {
          parent.addEventListener('scroll', finalLazyLoadHandler, passiveEvent);
        }
        parent.setAttribute(LISTEN_FLAG, listenerCount);
      }
    } else if (listeners.length === 0 || needResetFinalLazyLoadHandler) {
      // 从下面逻辑看,listeners数组是存储被懒加载的组件集合(单例)
      // 结合之前的内容看(scrollport),这里是在对没传overflow参数时,事件绑定的处理
      // TODO(疑问):这里是不是也应该用上面打标记计数的方式,标记一个容器只能被监听一次
      const { scroll, resize } = this.props;

      if (scroll) {
        on(scrollport, 'scroll', finalLazyLoadHandler, passiveEvent);
      }

      if (resize) {
        on(window, 'resize', finalLazyLoadHandler, passiveEvent);
      }
    }

    listeners.push(this);
    // 此处应该是改变this.visible的地方,后面的细节3会详细讲解这部分逻辑
    checkVisible(this);
  }
复制代码

细节2 - lazyLoadHandler

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

总之,这个函数大致意思也就是在 scroll/resize 事件触发时,集中对涉及到的lazyload组件进行判断他们是否显示加载。

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

细节3 - checkVisible

const checkVisible = function checkVisible(component) {
  const node = ReactDom.findDOMNode(component);  // 获取真实的dom元素
  if (!(node instanceof HTMLElement)) { // 容错处理
    return;
  }

  const parent = scrollParent(node); // 找到对应懒加载组件的包裹容器
  const isOverflow = component.props.overflow &&  // 判断容器是否是"全屏幕"的
                     parent !== node.ownerDocument &&
                     parent !== document &&
                     parent !== document.documentElement;
  const visible = isOverflow ? // 根据容器是否是为"全屏幕"的,
        											//采取不同的处理方法来计算组件是否需要显示
                  checkOverflowVisible(component, parent) :
                  checkNormalVisible(component);
  if (visible) {
    // Avoid extra render if previously is visible
    if (!component.visible) {
      if (component.props.once) { // 这个once的api应该是用来做性能优化的"剪枝"操作的,
        pending.push(component);  // 避免不必要的listen再次被扫到处理
      }                           // 这里可以想想如果是我们设计这个组件时,是否会考虑到这个api

      component.visible = true;  // 一旦组件是需要显示的,就会调用 component.forceUpdate
      component.forceUpdate();   // 来对组件进行更新操作了
    }
  } else if (!(component.props.once && component.visible)) { // 这里应该是考虑到被懒加载的组件
    component.visible = false;                               // 后续可能会因为外部props导致
    if (component.props.unmountIfInvisible) {             // 更新,把非视区的组件先暂时隐藏,
      component.forceUpdate();                           // 这样想也是另一场景下的性能优化
    }
  }
};
复制代码

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

细节4 - checkNormalVisible

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

const checkNormalVisible = function checkNormalVisible(component) {
  const node = ReactDom.findDOMNode(component);

  // If this element is hidden by css rules somehow, it's definitely invisible
  if (!(node.offsetWidth || node.offsetHeight || node.getClientRects().length)) return false;

  let top;
  let elementHeight;

  try {
    // Element.getBoundingClientRect()方法返回元素的大小及其相对于视口的位置
    // 这里只获取了组件的盒模型高及相对的top位置,由此能判断当前的懒加载组件只处理垂直方向的懒加载
    ({ top, height: elementHeight } = node.getBoundingClientRect());
  } catch (e) { // 容错方案,细节可看源码
    ({ top, height: elementHeight } = defaultBoundingClientRect);
  }

  // 因为是全屏幕的容器,所以另一个用来判断是否与组件盒子有交集的四边形就是window了
  const windowInnerHeight = window.innerHeight || document.documentElement.clientHeight;

  // 这里可的offsets的api设计,可以理解为懒加载的“提前量”需要,做过类似需求的朋友应该能有体会
  const offsets = Array.isArray(component.props.offset) ?
                component.props.offset :
                [component.props.offset, component.props.offset]; // Be compatible with previous API

  // 在垂直方向,判断是否有交集的逻辑,为什么是这么判断呢
  // 其实很好理解,交不交差其实都是按边界情况考虑的,如果组件的上边界相对视窗位置(top-offsets[0])
  // 超过了视窗的下边界的位置windowHeight,那再也不可能相较了。
  // 同理,如果组件的下边界位置,超过了视窗上边界的位置,那同样也不可能再相交,由此得出了这个计算式子
  return (top - offsets[0] <= windowInnerHeight) &&
         (top + elementHeight + offsets[1] >= 0);
}
复制代码
react-lazyload懒加载控件源码解析

细节5 - checkOverflowVisible

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

react-lazyload懒加载控件源码解析
const checkOverflowVisible = function checkOverflowVisible(component, parent) {
  const node = ReactDom.findDOMNode(component);

  let parentTop;
  let parentHeight;
   
  try {
    // 由于多了一个非全屏幕的容器,所以此处需要获取父级容器的位置是比较容易理解的
    ({ top: parentTop, height: parentHeight } = parent.getBoundingClientRect());
  } catch (e) {
    ({ top: parentTop, height: parentHeight } = defaultBoundingClientRect);
  }

  const windowInnerHeight = window.innerHeight || document.documentElement.clientHeight;

  // calculate top and height of the intersection of the element's scrollParent and viewport
  // 有了非全屏幕容器的存在,所以需要计算真正可视区域的上边界
  const intersectionTop = Math.max(parentTop, 0); // intersection's top relative to viewport
  // 获取真正可视区域的高,也就是获取可视区域的下边界
  const intersectionHeight = Math.min(windowInnerHeight, parentTop + parentHeight) - intersectionTop; // height

  // check whether the element is visible in the intersection
  let top;
  let height;

  try {
    ({ top, height } = node.getBoundingClientRect());
  } catch (e) {
    ({ top, height } = defaultBoundingClientRect);
  }

  const offsetTop = top - intersectionTop; // element's top relative to intersection

  const offsets = Array.isArray(component.props.offset) ?
                component.props.offset :
                [component.props.offset, component.props.offset]; // Be compatible with previous API
  
  // 利用求得的实际上下边界,进行与全屏幕视窗时相同的计算方式来进行相交判断,便能计算出组件是否可见了
  return (offsetTop - offsets[0] <= intersectionHeight) &&
         (offsetTop + height + offsets[1] >= 0);
};
复制代码

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

总结

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

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

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

科技投资新时代:TMT投资方法、趋势与热点聚焦

科技投资新时代:TMT投资方法、趋势与热点聚焦

马军、宋辉、段迎晟 / 人民邮电出版社 / 2018-3 / 69.00

中国 TMT 行业(科技、媒体及通信)起步较晚但充满朝气。2017 年,TMT 板块的IPO 数量占到了总数的四分之一;对于投资者来说,投资 TMT 的收益非常可观。那么,TMT 的投资趋势如何? TMT 行业又有哪些投资热点? 本书立足于 TMT 投资现状,在介绍了 TMT 投资的基本概念之后,作者详细讲述了TMT 投资的基本研究方法、分析视角、整体行情及趋势分析,同时从行业视角分析了包括......一起来看看 《科技投资新时代:TMT投资方法、趋势与热点聚焦》 这本书的介绍吧!

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具