React 圖片懶加載庫源碼解析

文章同步於 Pines-Cheng/blog

最近在作React 圖片懶加載的,本來覺得比較簡單,輕輕鬆鬆就能搞定,結果碰到了一系列的問題,可謂是一波三折,不過通過此次折騰,對圖片懶加載及相關的實現有了更深入的瞭解,特此記錄一下。javascript

jasonslyvia/react-lazyload

一開始的時候,沒打算本身造輪子。直接在網上搜索到了 react-lazyload 的庫,用上之後,demo測試也沒問題,但是在商品列表卻沒生效。因而直接去看源碼找緣由。html

圖片懶加載通常涉及到的流程爲:滾動容器 -> 綁定事件 -> 檢測邊界 -> 觸發事件 -> 圖片加載前端

基本使用

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。java

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。node

scrollParent 代碼以下:react

/**
 * @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 的節點。而後返回該節點,做爲滾動容器。git

看到這裏,我就基本知道商品列表懶加載無效的緣由了,react-lazyload 僅支持 overflow 的滾動方式,而商品列表因爲特殊緣由,選用了 transform 的滾動方式。那是否有必要對其進行一下改造呢?接下來,咱們繼續往下看。程序員

passiveEvent

上面的 passiveEvent 以下,在您的觸摸和滾輪事件偵聽器上設置 passive 選項可提高滾動性能。github

// 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 listenerssegmentfault

事件回調

這裏對 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 方法移植了過來,自動查找父節點的滾動容器,完美!

參考

相關文章
相關標籤/搜索