React 图片懒加载库源码解析
文章同步于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
的浏览器,直接一次性显示图片。- 需要手动传容器组件,不能自己向上查找。
写在最后
作为一个不轻易造轮子的程序员,最后我还是选用了 verlok/lazyload ,不过添加 IntersectionObserver
的 polyfill
。 顺便提一下,IntersectionObserver
的polyfill
也是基于 getBoundingClientRect
实现的。
然后将第一个库的 scrollParent
方法移植了过来,自动查找父节点的滚动容器,完美!