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

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

内容简介:在经过一个简单猜测后,我们还是实际还是应该一步步去看着代码,带着我们之前“好奇”的问题,来近一步探寻这个精巧的懒加载组件是如何完成的。由下面代码可以看出,同一个容器内的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使用场景的体感。
  • 一个短小精悍的库真的很受国内外同行欢迎,平常也可以尝试开发类似的组件,锻炼自己的设计及编码能力。

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

查看所有标签

猜你喜欢:

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

Making Things See

Making Things See

Greg Borenstein / Make / 2012-2-3 / USD 39.99

Welcome to the Vision Revolution. With Microsoft's Kinect leading the way, you can now use 3D computer vision technology to build digital 3D models of people and objects that you can manipulate with g......一起来看看 《Making Things See》 这本书的介绍吧!

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

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

正则表达式在线测试