文章同步於 Pines-Cheng/blog
最近在作React 圖片懶加載的,本來覺得比較簡單,輕輕鬆鬆就能搞定,結果碰到了一系列的問題,可謂是一波三折,不過通過此次折騰,對圖片懶加載及相關的實現有了更深入的瞭解,特此記錄一下。javascript
一開始的時候,沒打算本身造輪子。直接在網上搜索到了 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
以下,在您的觸摸和滾輪事件偵聽器上設置 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
滾動component.visible = true;
違背React原則,太暴力component.forceUpdate()
,在滾動列表長,滾動速度快的時候,可能會有性能隱患getBoundingClientRect
性能不太好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
的瀏覽器,直接一次性顯示圖片。做爲一個不輕易造輪子的程序員,最後我仍是選用了 verlok/lazyload ,不過添加 IntersectionObserver
的 polyfill
。 順便提一下,IntersectionObserver
的polyfill
也是基於 getBoundingClientRect
實現的。
而後將第一個庫的 scrollParent
方法移植了過來,自動查找父節點的滾動容器,完美!