react-lazy-load粗讀

limengke123發表於2018-08-16

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 程式碼很長而且好像比較複雜,所以也就沒看了。

相關文章