7
文章同步于Pines-Cheng/blog

最近在做React 图片懒加载的,原本以为比较简单,轻轻松松就能搞定,结果碰到了一系列的问题,可谓是一波三折,不过经过这次折腾,对图片懒加载及相关的实现有了更深刻的了解,特此记录一下。

jasonslyvia/react-lazyload

一开始的时候,没打算自己造轮子。直接在网上搜索到了 react-lazyload 的库,用上以后,demo测试也没问题,可是在商品列表却没生效。于是直接去看源码找原因。

图片懒加载一般涉及到的流程为:滚动容器 -> 绑定事件 -> 检测边界 -> 触发事件 -> 图片加载

基本使用

import React from 'react';
import ReactDOM from 'react-dom';
import LazyLoad from 'react-lazyload';
import MyComponent from './MyComponent';

const App = () => {
  return (
    <div className="list">
      <LazyLoad height={200}>
        <img src="tiger.jpg" /> /*
                                  Lazy loading images is supported out of box,
                                  no extra config needed, set `height` for better
                                  experience
                                 */
      </LazyLoad>
      <LazyLoad height={200} once >
                                /* Once this component is loaded, LazyLoad will
                                 not care about it anymore, set this to `true`
                                 if you're concerned about improving performance */
        <MyComponent />
      </LazyLoad>
      <LazyLoad height={200} offset={100}>
                              /* This component will be loaded when it's top
                                 edge is 100px from viewport. It's useful to
                                 make user ignorant about lazy load effect. */
        <MyComponent />
      </LazyLoad>
      <LazyLoad>
        <MyComponent />
      </LazyLoad>
    </div>
  );
};

ReactDOM.render(<App />, document.body);

滚动容器及绑定事件

react-lazyload 有一个props为 overflow,默认为false。

if (this.props.overflow) { // overflow 为true,向上查找滚动容器
      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);// finalLazyLoadHandler 及passiveEvent 见下面
        }
        parent.setAttribute(LISTEN_FLAG, listenerCount);
      }
    } else if (listeners.length === 0 || needResetFinalLazyLoadHandler) {  // 否则直接绑定window
      const { scroll, resize } = this.props;

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

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

通过源码可以看到,这里当 overflow 为true时,调用 scrollParent 获取滚动容器,否者直接将滚动事件绑定在 window。

scrollParent 代码如下:

/**
 * @fileOverview Find scroll parent
 */

export default (node) => {
  if (!node) {
    return document.documentElement;
  }

  const excludeStaticParent = node.style.position === 'absolute';
  const overflowRegex = /(scroll|auto)/;
  let parent = node;

  while (parent) {
    if (!parent.parentNode) {
      return node.ownerDocument || document.documentElement;
    }

    const style = window.getComputedStyle(parent); //获取节点的所有样式
    const position = style.position;
    const overflow = style.overflow;
    const overflowX = style['overflow-x'];
    const overflowY = style['overflow-y'];

    if (position === 'static' && excludeStaticParent) {
      parent = parent.parentNode;
      continue;
    }

    if (overflowRegex.test(overflow) && overflowRegex.test(overflowX) && overflowRegex.test(overflowY)) {
      return parent;
    }

    parent = parent.parentNode;
  }

  return node.ownerDocument || node.documentElement || document.documentElement;
};

这段代码比较简单,可以看到,scrollParent 默认是迭代向上查找 parentNode 样式的 overflow ,直到找到第一个 overflow 为 auto 或 scroll 的节点。然后返回该节点,作为滚动容器。

看到这里,我就基本知道商品列表懒加载无效的原因了,react-lazyload 仅支持 overflow 的滚动方式,而商品列表由于特殊原因,选用了 transform 的滚动方式。那是否有必要对其进行一下改造呢?接下来,我们继续往下看。

passiveEvent

上面的 passiveEvent 如下,在您的触摸和滚轮事件侦听器上设置 passive 选项可提升滚动性能。

// if they are supported, setup the optional params
// IMPORTANT: FALSE doubles as the default CAPTURE value!
const passiveEvent = passiveEventSupported ? { capture: false, passive: true } : false;

详细可以参考:移动Web滚动性能优化: Passive event listeners

事件回调

这里对 scroll 事件的回调函数 finalLazyLoadHandler 进行了节流或去抖的处理,时间是300毫秒。看起来还不错。

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

lazyLoadHandler 如下:

const lazyLoadHandler = () => {
  for (let i = 0; i < listeners.length; ++i) {
    const listener = listeners[i];
    checkVisible(listener); //检测元素是否可见,并设置组件的props:visible
  }
  // Remove `once` component in listeners
  purgePending(); //移除一次性组件的监听
};

这里大家千万不要被函数方法名 checkVisible 给迷惑,这里绝仅仅做了函数名字面意义的事情,而是做了一大堆的事。包括检测是否可见,设置组件 props,更新监听list,还有 component.forceUpdate!也是够了。。。

/**
 * 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); //如果只触发一次,则放入pending的列表,然后在purgePending中移除监听
      }

      component.visible = true; //设置组件的props为true
      component.forceUpdate(); //强制更新
    }
  } else if (!(component.props.once && component.visible)) {
    component.visible = false;
    if (component.props.unmountIfInvisible) {
      component.forceUpdate();
    }
  }
};

检测边界

检测组件滚动到可见位置的方法如下:

/**
 * Check if `component` is visible in overflow container `parent`
 * @param  {node} component React component
 * @param  {node} parent    component's scroll parent
 * @return {bool}
 */
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);
};

看起来好像代码比较多,其实核心方法就一个:getBoundingClientRect()Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置。

通过 getBoundingClientRect 方法获取组件的滚动位置(top height等),然后经过一系列计算,就可以判断组件是否已经鼓动到合适的位置上了。

总结

至此,react-lazyload 的代码我们已经大致看完了,总结一下这个库的缺点吧:

  • 代码质量一般
  • 只能够使用 overflow 滚动
  • 直接修改组件的props:component.visible = true; 违背React原则,太暴力
  • 使用 component.forceUpdate() ,在滚动列表长,滚动速度快的时候,可能会有性能隐患
  • getBoundingClientRect 性能不太好

verlok/lazyload

LazyLoad 是一个快速的,轻量级的,灵活的图片懒加载库,本质是基于 img 标签的 srcset 属性。

简单使用

HTML

<img alt="..." 
     data-src="../img/44721746JJ_15_a.jpg"
     width="220" height="280">

Javascript

var myLazyLoad = new LazyLoad();

滚动容器及回调

入口文件,这里主要是 this._setObserver 方法和 this.update 方法。

 var LazyLoad = function LazyLoad(instanceSettings, elements) {
        this._settings = _extends({}, defaultSettings, instanceSettings);
        this._setObserver();
        this.update(elements);
    };

_setObserver 方法,核心是执行 new IntersectionObserver()

IntersectionObserver 是浏览器原生提供的构造函数,接受两个参数:onIntersection 是可见性变化时的回调函数,option是配置对象(该参数可选)。

构造函数的返回值 this._observer 是一个观察器实例。实例的 observer 方法可以指定观察哪个 DOM 节点。

onIntersection 回调用于在图片可见时设置 src 加载图片。

下面可以看到,滚动容器默认为 ducument,否则需手动传一个 DOM 节点 进来。

_setObserver: function _setObserver() {
            var _this = this;

            if (!("IntersectionObserver" in window)) { // IntersectionObserver 方法不存在,直接返回
                return;
            }

            var settings = this._settings;
            var onIntersection = function onIntersection(entries) {
                entries.forEach(function (entry) {
                    if (entry.intersectionRatio > 0) { // intersectionRatio:目标元素的可见比例,即intersectionRect占boundingClientRect的比例,完全可见时为1,完全不可见时小于等于0
                        var element = entry.target;
                        revealElement(element, settings); // 设置img的src
                        _this._observer.unobserve(element); // 停止观察
                    }
                });
                _this._elements = purgeElements(_this._elements);
            };
            this._observer = new IntersectionObserver(onIntersection, { // 获取观察器实例IntersectionObserver对象
                root: settings.container === document ? null : settings.container, // 滚动容器默认为document
                rootMargin: settings.threshold + "px"
            });
        },

其中 revealElement 方法如下:

    var revealElement = function revealElement(element, settings) {
        if (["IMG", "IFRAME"].indexOf(element.tagName) > -1) {
            addOneShotListeners(element, settings);
            addClass(element, settings.class_loading);
        }
        setSources(element, settings); // 设置img的src
        setData(element, "was-processed", true);
        callCallback(settings.callback_set, element);
    };

绑定事件

update 方法,获取需要懒加载的 img 元素,指定观察节点。

        update: function update(elements) {
            var _this2 = this;

            var settings = this._settings;
            var nodeSet = elements || settings.container.querySelectorAll(settings.elements_selector); // 获取所有需要懒加载的的img元素

            this._elements = purgeElements(Array.prototype.slice.call(nodeSet)); // nodeset to array for IE compatibility
            if (this._observer) {
                this._elements.forEach(function (element) {
                    _this2._observer.observe(element); // 开始观察
                });
                return;
            }
            // Fallback: load all elements at once
            this._elements.forEach(function (element) {
                revealElement(element, settings);
            });
            this._elements = purgeElements(this._elements);
        },

检测可见

检测可见这里使用的是 IntersectionObserver

传统的实现方法是,监听到scroll事件后,调用目标元素(绿色方块)的 getBoundingClientRect() 方法,得到它对应于视口左上角的坐标,再判断是否在视口之内。这种方法的缺点是,由于scroll事件密集发生,计算量很大,容易造成性能问题。

目前有一个新的 IntersectionObserver API ,可以自动"观察"元素是否可见,Chrome 51+ 已经支持。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做"交叉观察器"。

详细可见文章下面的参考。

触发事件

下面的代码很好懂,无非就是将 data-src 的值赋给 src 而已,这样,图片就开始加载了。

    var setSourcesForPicture = function setSourcesForPicture(element, settings) {
        var dataSrcSet = settings.data_srcset;

        var parent = element.parentNode;
        if (parent.tagName !== "PICTURE") {
            return;
        }
        for (var i = 0, pictureChild; pictureChild = parent.children[i]; i += 1) {
            if (pictureChild.tagName === "SOURCE") {
                var sourceSrcset = getData(pictureChild, dataSrcSet);
                if (sourceSrcset) {
                    pictureChild.setAttribute("srcset", sourceSrcset);
                }
            }
        }
    };

总结

改懒加载库一共只有两百多行代码,且没有任何依赖。使用 IntersectionObserver 配合 data-src 也极大的提升了性能。不过缺点如下:

  • IntersectionObserver 兼容性不好,不支持 IntersectionObserver 的浏览器,直接一次性显示图片。
  • 需要手动传容器组件,不能自己向上查找。

image

写在最后

作为一个不轻易造轮子的程序员,最后我还是选用了 verlok/lazyload ,不过添加 IntersectionObserverpolyfill。 顺便提一下,IntersectionObserverpolyfill 也是基于 getBoundingClientRect 实现的。

然后将第一个库的 scrollParent 方法移植了过来,自动查找父节点的滚动容器,完美!

参考


Pines_Cheng
6.5k 声望1.2k 粉丝

不挑食的程序员,关注前端四化建设。