文章同步于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
方法移植了过来,自动查找父节点的滚动容器,完美!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。