4

react-lazy-load粗读

近来没什么特别要做的事,下班回来的空闲时间也比较多,所以抽空看看懒加载是怎么实现的,特别是看了下 react-lazy-load 的库的实现。

懒加载

这里懒加载场景不是路由分割打包那种,而是单个页面中有一个很长的列表,列表中的图片进行懒加载的效果。

jquery 时代,这种列表图片懒加载效果就已经有了,那么我们想一想这种在滚动的时候才去加载图片等资源的方式该如何去实现呢?

大致原理

浏览器解析 html 的时候,在遇到 img 标签以及发现 src 属性的时候,浏览器就会去发请求拿图片去了。这里就是切入点,根据这种现象,做下面几件事:

  1. 把列表中所有的图片的 img 标签的 src 设为空
  2. 把真实的图片路径存成一个 dom 属性,打个比方: <img data-src='/xxxxx.jpg' />
  3. 写一个检测列表某一项是否是可见状态
  4. 全局滚动事件做一个监听,检测当前列表的项是否是可见的,如果可见则给 img 标签上存着真实图片路径赋值给 src 属性

react-lazy-load

知道懒加载的大概原理,来看一下 react-lazy-load 是怎么做的。

大体看了下 react-lazy-load 的实现的总体思路就更加简单了,本质上就是让需要懒加载的组件包含在这个包提供的 LazyLoad 组件中,不渲染这个组件,然后去监听这个 LazyLoad 组件是否已经是可见了,如果是可见了那么就去强制渲染包含在 LazyLoad 组件内部需要懒加载的组件了。

这种方式相较于手动去控制 img 标签来的实在是太方便了,完全以组件为单位,对组件进行懒加载。这样的话,完全就不需要感知组件内部的逻辑和渲染逻辑,无论这个需要懒加载的组件内部是有几个 img 标签,也完全不用去手动操控 src 属性的赋值。

react-lazy-load 之 render

class LazyLoad extends React.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" />
    }
}

render 函数能够看出来,依据当前 visible 的值来确定是否渲染 this.props.children,如果为 false 则去渲染节点的占位符。如果外部传入一个占位节点,就用这个传入的占位节点,否则就用默认的占位符去占位。注意到:shouldComponentUpdate 依据 this.visible 的值去判断是否更新组件。剩下的,该去看看如何监听事件以及修改 this.visible、强制重新渲染组件的。

react-lazy-load 之 componentDidMount

  componentDidMount() {
    // It's unlikely to change delay type on the fly, this is mainly
    // designed for tests
    const needResetFinalLazyLoadHandler = (this.props.debounce !== undefined && delayType === 'throttle')
      || (delayType === 'debounce' && this.props.debounce === undefined);

    if (needResetFinalLazyLoadHandler) {
      off(window, '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;
      }
    }

    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) {
      const { scroll, resize } = this.props;

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

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

    listeners.push(this);
    checkVisible(this);
  }

needResetFinalLazyLoadHandler 先别关注,按他给注释说测试用。 finalLazyLoadHandler 依据外部 debouncethrottle 来选择是防抖还是节流还是都不用。根据外部传入的overflow 来确定是否是在某一个节点中 overflow 的下拉框的懒加载还是普通的整个 window 的懒加载。然后就是依据是 scroll 还是 resize 来给 window 增加监听事件 finalLazyLoadHandler。 最后就是把这个组件实例放到了 listeners 这个数组里,然后调用 checkVisible 检查是否可见。

react-lazy-load 之 checkVisible

/**
 * Detect if element is visible in viewport, if so, set `visible` state to true.
 * If `once` prop is provided true, remove component as listener after checkVisible
 *
 * @param  {React} component   React component that respond to scroll and resize
 */
const checkVisible = function checkVisible(component) {
  const node = ReactDom.findDOMNode(component);
  if (!node) {
    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) {
        pending.push(component);
      }

      component.visible = true;
      component.forceUpdate();
    }
  } else if (!(component.props.once && component.visible)) {
    component.visible = false;
    if (component.props.unmountIfInvisible) {
      component.forceUpdate();
    }
  }
};

parent 就是找到这个组件的上层组件的 dom 节点,通过 checkOverflowVisiblecheckNormalVisible这两个函数拿到该节点是否在可视区域内得到 visible。然后依据 visible的值修改 componentvisible的值,然后调用组件的 forceUpdate 方法,强制让组件重新渲染。主要到组件的 visible 并不是挂载到 state 上,所以这里不是用 setState 来重新渲染。

react-lazy-load 之 checkNormalVisible

/**
 * Check if `component` is visible in document
 * @param  {node} component React component
 * @return {bool}
 */
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 {
    ({ top, height: elementHeight } = node.getBoundingClientRect());
  } catch (e) {
    ({ top, height: elementHeight } = defaultBoundingClientRect);
  }

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

  const offsets = Array.isArray(component.props.offset) ?
                component.props.offset :
                [component.props.offset, component.props.offset]; // Be compatible with previous API

  return (top - offsets[0] <= windowInnerHeight) &&
         (top + elementHeight + offsets[1] >= 0);
};

主要逻辑就是拿到组件的 dom 节点的 getBoundingClientRect 返回值和 window.innerHeight 进行比较来判断是否是在可视范围内。这里在比较的时候还有个 component.props.offset 也参与了比较,说明设置了 offset 的时候,组件快要出现在可视范围的时候就会去重新渲染组件而不是出现在可视范围内才去重新渲染。

react-lazy-load 之 lazyLoadHandler

lazyLoadHandler 是组件绑定事件时会触发的函数。

const lazyLoadHandler = () => {
  for (let i = 0; i < listeners.length; ++i) {
    const listener = listeners[i];
    checkVisible(listener);
  }
  // Remove `once` component in listeners
  purgePending();
};

每次监听事件执行的时候,都去检查一下组件,如果满足条件就去强制渲染组件。

react-lazy-load 之 componentWillUnmount

 componentWillUnmount() {
    if (this.props.overflow) {
      const parent = scrollParent(ReactDom.findDOMNode(this));
      if (parent && typeof parent.getAttribute === 'function') {
        const listenerCount = (+parent.getAttribute(LISTEN_FLAG)) - 1;
        if (listenerCount === 0) {
          parent.removeEventListener('scroll', finalLazyLoadHandler, passiveEvent);
          parent.removeAttribute(LISTEN_FLAG);
        } else {
          parent.setAttribute(LISTEN_FLAG, listenerCount);
        }
      }
    }

    const index = listeners.indexOf(this);
    if (index !== -1) {
      listeners.splice(index, 1);
    }

    if (listeners.length === 0) {
      off(window, 'resize', finalLazyLoadHandler, passiveEvent);
      off(window, 'scroll', finalLazyLoadHandler, passiveEvent);
    }
  }

组件卸载的时候,把一些绑定事件解绑一下,细节也不说了。

总结

抛开 react-lazy-load 一些实现细节,从总体把握整个懒加载的过程,其实懒加载的原理并不难。当时我也看了一下 vue 那边的 vue-lazyLoad 这个库想写一个对比的文章,我以为这个 vue 库的内容会写的和 react-lazy-load 差不多,结果发现 vue-lazyLoad 代码很长而且好像比较复杂,所以也就没看了。


李梦克
142 声望4 粉丝

下一篇 »
react使用指南