react-lazyload懶加載控件源碼解析

簡介

本篇文章主要介紹一個優秀的基於react實現的懶加載控件:github.com/twobin/reac…javascript

優勢

  • 易於使用,好比
<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應該會涉及一些事件清除等移除即將銷燬組件遺留狀態的工做

源碼細節

在通過一個簡單猜想後,咱們仍是實際仍是應該一步步去看着代碼,帶着咱們以前「好奇」的問題,來近一步探尋這個精巧的懶加載組件是如何完成的。css

細節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組件進行判斷他們是否顯示加載。java

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,想到了會怎麼來設計?node

細節4 - checkNormalVisible

這是處理正常全屏幕容器懶加載組件是否可見的狀況,其實不看代碼,咱們也能大概知道,是一個判斷當前的組件是否和可視區域有交集的,能夠抽象成二維平面,兩個四邊形是否相交的問題,相交則證實組件屬於可視區域,反之亦然。react

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);
}
複製代碼

image.png

細節5 - checkOverflowVisible

一樣是判斷是否相交的邏輯,下面的代碼區別於細節4的狀況,主要在於容器非全屏幕的狀況,容器只是瀏覽器視窗的一個子集,因此在處理相較邏輯上會稍稍作一些改變,看起來應該要多一些相對距離的計算邏輯,具體咱們來看代碼
git

image.png

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這種狀況的相交判斷,只是正常相交判斷的特殊版本,二者的代碼邏輯是一致的,甚至也能夠寫作一個函數,不過從閱讀的感受上來看,這種狀況,分開寫閱讀起來會更友好,更能分清楚不一樣的狀況,也給咱們日常實現相似邏輯時,提供一點參考。 github

總結

整個倉庫的代碼不算上測試用例的話,估計不到1千行,但實現了很豐富場景的懶加載的狀況,也對不一樣場景的性能優化增長了api支持,整個閱讀過程下來受到了很多的啓發:api

  • 高階組件的一種運用場景,非侵入性的加強了功能(特性)
  • 靈活應用了模塊的單例模式,同一模塊中的變量複用,好比listeners這個數組,實現了在不一樣懶加載組件中,共享同一個事件監聽來處理相同事務。
  • 相應性能優化的api很受啓發,加深了對react開發中,不一樣編碼方式及api使用場景的體感。
  • 一個短小精悍的庫真的很受國內外同行歡迎,日常也能夠嘗試開發相似的組件,鍛鍊本身的設計及編碼能力。
相關文章
相關標籤/搜索