前言
懒加载(Lazy Load)是一种优化网站性能的技术,其作用是在网页加载时延迟加载图片、视频、音频等资源,仅在用户滚动到它们的位置时才加载。这种方式可以降低网站的初始加载时间,提高用户体验,减少带宽消耗和服务器负载。
1. 懒加载的概念和作用
懒加载的概念类似于一种"按需加载"的模式,即只有当需要使用资源时才会加载它们,而不是一次性加载所有资源。这种方式对于长网页和页面中包含大量图片和其他资源的情况尤为适用,可以显著降低页面的加载时间和带宽消耗。
懒加载技术在移动端的应用也越来越广泛,因为移动端网络条件相对较差,懒加载可以在一定程度上减少页面加载时间,提高用户体验。
2. 懒加载的使用条件
懒加载使用的前置条件是需要提前知道图片的宽高。原因如下:
-
使用
div
作为占位元素放置在懒加载区域,如果不知道图片的宽高,div
就无法知道自身应该如何布局和占位。 -
懒加载通常有个功能是移动到距离懒加载区域多远处就可以开始加载,比如 react-lazyload 中使用 offset 属性来标志,所以高度对于这个功能的计算是至关重要的。
通常我们会在上传图片的时候,将图片的宽高信息存储在链接上,如以下格式
https://cdn域名/图片名称_info_w=450_h=450_s=188145.jpg
下面提供一个拿到链接上宽高的方法,如果链接目前不是类似格式的小伙伴,可以让服务端处理,或者自己先手动拼接一下,测测效果。
/**
* 获取图片期望高度
* src 图片链接
* length 每行需要展示图片个数
*/
const getExpectImageHeight = (src: string, length = 1): number => {
const match = /w=(\d )_h=(\d )/.exec(src);
if (!match) {
return 0;
}
const [, w, h] = match;
const parsedW = parseInt(w, 10);
const parsedH = parseInt(h, 10);
if (Number.isNaN(parsedW) || Number.isNaN(parsedH) || parsedW === 0) {
return 0;
}
return parsedH / ((parsedW / window.innerWidth) * length);
};
还有一种情况是:图片不需要自适应高度,都是根据设计稿固定宽高的,这种情况下,可以使用宽高数据 * 对应屏幕缩放比来得到真实高度。
3. 懒加载的核心原理
下面通过对 react-lazyload
的源码进行分析,和大家聊聊懒加载的核心原理。
-
src
-
utils
-
index.jsx
-
整体代码是非常简洁的,虽然不是 ts 的,阅读起来还是比较舒服,函数基本上是语义化的。我们先看看 index.jsx
。大体上分为这 3 个部分:
- 找到滚动的容器,添加滚动事件监听
滚动的容器的默认值是 window
这可以适用于很多场景,但是还是有情况下,需要自己指定滚动容器,目前 react-lazyload
提供了两种模式。
- 根据
props
传入的scrollContainer
,格式为dom
的选择器即可。 - 根据
props
传入的overflow
,使用scrollParent
方法一级一级往父元素上找到overflow
属性为auto|scroll
的元素,认定为滚动容器。
找到滚动父容器后,为他增加 scroll
事件
- 判断子元素是否可见
两种不同的滚动父容器模式,会影响判断子元素是否可视的判断,项目分别定义了 checkOverflowVisible
和 checkNormalVisible
方法来检查元素是否可视。核心原理是利用当前元素的 getBoundingClientRect
方法,获取到他相对于视窗的 left
top
width
height
,然后通过和与 offset
的加减,判断是否处于可视区域。
可以看到,checkNormalVisible
比较简单,只是对比了高度,这说明他并不能实现横向的懒加载。
// checkNormalVisible
return (
// 元素距离页面顶部距离 - 预加载范围 <= 页面高度
top - offsets[0] <= windowInnerHeight &&
// 元素距离页面顶部距离 元素高度 预加载范围 >= 0
top elementHeight offsets[1] >= 0
);
// checkOverflowVisible
return (
// 元素距离父容器顶部距离 - 预加载范围 <= 父容器最下方
offsetTop - offsets[0] <= intersectionHeight &&
// 元素距离父容器顶部距离 元素高度 预加载范围 >= 0
offsetTop height offsets[1] >= 0 &&
// 元素距离父容器左侧距离 - 预加载范围 <= 父容器最右方
offsetLeft - offsets[0] <= intersectionWidth &&
// 元素距离父容器左侧距离 元素宽度 预加载范围 >= 0
offsetLeft width offsets[1] >= 0
);
- 更新视图
当检测到元素处于可视区域时,改变组件 visible
属性,并进行 forceUpdate
. 这样被包裹的子元素就可以被正常展示了。很多懒加载文章里也提到了如果给 img
标签设置了 src
即使他是 display: none
也会发起网络请求,而标准的懒加载的属性Lazy
(Chrome77 , Safari16
) 兼容性还不是很好,所以这个项目也是采用了不可视就不渲染的方式进行处理。
render() {
const {
height,
children,
placeholder,
className,
classNamePrefix,
style
} = this.props;
return (
<div className={`${classNamePrefix}-wrapper ${className}`} ref={this.setRef} style={style}>
{this.visible ? (
children
) : placeholder ? (
placeholder
) : (
<div
style={{ height: height }}
className={`${classNamePrefix}-placeholder`}
/>
)}
</div>
);
}
-
项目的其他细节点,整体来看还是蛮优秀的
-
项目处理了滚动事件的防抖和截流模式
-
项目处理了避免父滚动容器多次绑定
scroll
事件 -
项目处理了
once
模式,这个模式的好处是减少了滚动时,对已经可视的元素进行不必要的checkVisible
-
项目封装了
event
,因为需要对dom
直接进行事件绑定而非使用React
的事件绑定,所以要考虑兼容性。 -
passiveEvent
让滚动中执行的代码逻辑不影响滚动
-
4. 针对自身项目,对 react-lazyload 进行简化&改造
从 github 提交记录可以发现这个项目已经很久没有进行迭代了,所以是存在一些可以改进的空间的,针对项目自身的需求,可以对 react-lazyload
进行改造。
以下代码,对 react-lazyload
进行了 typescript
改造,并做了以下改动
-
perf: 优化合并了
checkNormalVisible
和checkOverflowVisible
方法,因为实现原理是一致的。checkOverflowVisible
是另一个方法的超集。另外原来checkNormalVisible
中高度都取自window
并没有来自scrollContainer
的对应dom
,其实是有bug
的。 -
perf: 优化了
checkVisible
的实现,老代码中,无论是否启用overflow
属性,都会去遍历寻找滚动的父元素的,这是消耗性能的。
// 老代码
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);
// 新代码
const visible = checkElementVisible(component, component.parent);
-
perf: 优化了
once
的实现。- 调整了
purgePending()
的执行顺序,因为pending
中的元素是已经展示了,才被添加到pending
中的,所以可以再checkVisible
前执行。 - 另外修改了
once
默认为true
, 因为我的项目只是单纯的想做懒加载,没有防爬虫和复杂列表性能问题,所以修改为true
,而且我认为更高性能的默认值可以降低使用者的使用成本。
- 调整了
// 老代码
const lazyLoadHandler = () => {
for (let i = 0; i < listeners.length; i) {
const listener = listeners[i];
checkVisible(listener);
}
purgePending();
}
// 新代码
const lazyLoadHandler = () => {
purgePending();
for (let i = 0; i < listeners.length; i) {
const listener = listeners[i];
checkVisible(listener);
}
}
// 老代码 props.once 默认值 false
// 新代码
-
perf: 优化了
overflow
的实现,对parent
的赋值放到了componentDidMount
中,而不用在checkVisible
时,每次都去找父滚动容器。 -
perf&break-change: 简化了
scrollParent
的实现- 如果想用
overflow
属性来自动寻找父滚动容器的方法,需要保证子元素的第一个能找到的overflow-x|-y
为auto
或scroll
的就是滚动容器,原代码中对overflow
的校验是x, y
都要是自动或滚动,不太符合我的场景。 - 目前项目中想自动寻找父容器的原因是,公共组件在不同路由使用,可能多个路由的滚动容器类名不同,如果每个路由都要单独传
scrollContainer
改造成本太大,所以采用自动寻找模式
- 如果想用
// 新代码
const findScrollParent = (node: HTMLElement | null): HTMLElement => {
if (!node || !(node instanceof HTMLElement)) {
return document.documentElement;
}
const overflowRegex = /(auto|scroll)/;
let parent = node.parentElement;
while (parent) {
const { overflow, overflowX, overflowY } = window.getComputedStyle(parent);
if (overflowRegex.test(overflow) || overflowRegex.test(overflowX) || overflowRegex.test(overflowY)) {
return parent;
}
parent = parent.parentElement;
}
return node.ownerDocument?.documentElement || document.documentElement;
};
-
break-change:移除了
throttle
和debounce
实现,因为很多项目都有自己的防抖和截流,而且每个子元素都可以修改父容器公共的scroll
事件,其实不太科学,增加了绑定,解绑scroll
事件的复杂度,如果想实现,可能会再增加全局方法,可以对scroll
事件做统一的防抖,截流设置。 -
fix:修复了占位消失时,图片未出现,导致
getBoundingClientRect
取值错误的问题
完整代码
/**
* 懒加载组件
*/
import React, { Component } from "react";
const LISTEN_FLAG = "data-lazyload-listened";
const listeners: LazyLoad[] = [];
let pending: LazyLoad[] = [];
let passiveEvent: { capture: false; passive: true } | false = false;
const testPassiveEventSupported = () => {
// try to handle passive events
let passiveEventSupported = false;
try {
const opts = Object.defineProperty({}, "passive", {
// eslint-disable-next-line getter-return
get() {
passiveEventSupported = true;
},
});
window.addEventListener("test", () => {}, opts);
} catch (e) {
console.log(e);
}
// if they are supported, setup the optional params
// IMPORTANT: FALSE doubles as the default CAPTURE value!
passiveEvent = passiveEventSupported
? { capture: false, passive: true }
: false;
};
testPassiveEventSupported();
const findScrollParent = (node: HTMLElement | null): HTMLElement => {
if (!node || !(node instanceof HTMLElement)) {
return document.documentElement;
}
const overflowRegex = /(auto|scroll)/;
let parent = node.parentElement;
while (parent) {
const { overflow, overflowX, overflowY } = window.getComputedStyle(parent);
if (
overflowRegex.test(overflow) ||
overflowRegex.test(overflowX) ||
overflowRegex.test(overflowY)
) {
return parent;
}
parent = parent.parentElement;
}
return node.ownerDocument?.documentElement || document.documentElement;
};
/**
* Check if `component` is visible in overflow container `parent`
* @param {node} component React component
* @param {node} parent component's scroll parent
* @return {bool}
*/
const checkElementVisible = (
component: LazyLoad,
parent: HTMLElement | undefined
) => {
const node = component.ref.current;
if (!node) return false;
if (!parent) return null;
const {
top: parentTop,
left: parentLeft,
height: parentHeight,
width: parentWidth,
} = parent.getBoundingClientRect();
const windowInnerHeight =
window.innerHeight || document.documentElement.clientHeight;
const windowInnerWidth =
window.innerWidth || document.documentElement.clientWidth;
// 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 intersectionLeft = Math.max(parentLeft, 0); // intersection's left relative to viewport
const intersectionHeight =
Math.min(windowInnerHeight, parentTop parentHeight) - intersectionTop; // height
const intersectionWidth =
Math.min(windowInnerWidth, parentLeft parentWidth) - intersectionLeft; // width
// check whether the element is visible in the intersection
const { top, left, height, width } = node.getBoundingClientRect();
const offsetTop = top - intersectionTop; // element's top relative to intersection
const offsetLeft = left - intersectionLeft; // element's left 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 &&
offsetLeft - offsets[0] <= intersectionWidth &&
offsetLeft width offsets[1] >= 0
);
};
/**
* 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: LazyLoad) {
const node = component.ref.current;
if (!(node instanceof HTMLElement)) {
return;
}
const visible = checkElementVisible(component, component.parent);
if (visible) {
// Avoid extra render if previously is visible
if (!component.visible) {
if (component.props.once) {
pending.push(component);
}
// eslint-disable-next-line no-param-reassign
component.visible = true;
component.forceUpdate();
}
} else if (!(component.props.once && component.visible)) {
// eslint-disable-next-line no-param-reassign
component.visible = false;
if (component.props.unmountIfInvisible) {
component.forceUpdate();
}
}
};
const purgePending = function purgePending() {
pending.forEach((component) => {
const index = listeners.indexOf(component);
if (index !== -1) {
listeners.splice(index, 1);
}
});
pending = [];
};
const lazyLoadHandler = () => {
// Remove `once` component in listeners
purgePending();
for (let i = 0; i < listeners.length; i) {
const listener = listeners[i];
checkVisible(listener);
}
};
type LazyLoadProps = {
/**
* 类名
*/
className?: string;
/**
* 前缀
*/
classNamePrefix?: string;
/**
* 是否仅检测一次
*/
once?: boolean;
/**
* 元素高度
*/
height: number;
/**
* 预渲染范围
*/
offset: number | number[];
/**
* 自动检查父元素
*/
overflow?: boolean;
/**
* 窗口大小变化时是否检查
*/
resize?: boolean;
/**
* 滚动容器滚动时检查
*/
scroll?: boolean;
/**
* 子元素
*/
children: React.ReactNode;
/**
* 占位元素
*/
placeholder?: React.ReactNode;
/**
* 滚动容器选择器
*/
scrollContainer?: string;
/**
* 不可见时更新视图
*/
unmountIfInvisible?: boolean;
/**
* 样式
*/
style?: React.CSSProperties;
};
class LazyLoad extends Component<LazyLoadProps> {
visible = false;
ref = React.createRef<HTMLDivElement>();
parent: HTMLElement | undefined;
componentDidMount() {
// It's unlikely to change delay type on the fly, this is mainly
// designed for tests
let scrollport: HTMLElement | null = document.documentElement;
const { scrollContainer } = this.props;
if (scrollContainer) {
scrollport = document.querySelector<HTMLElement>(scrollContainer);
}
if (!scrollport) return;
this.parent = scrollport;
if (this.props.overflow) {
const parent = findScrollParent(this.ref.current);
if (parent && typeof parent.getAttribute === "function") {
const listenerCount = 1 Number(parent.getAttribute(LISTEN_FLAG) || 0);
if (listenerCount === 1) {
parent.addEventListener("scroll", lazyLoadHandler, passiveEvent);
}
parent.setAttribute(LISTEN_FLAG, `${listenerCount}`);
}
this.parent = parent;
} else if (listeners.length === 0) {
const { scroll, resize } = this.props;
if (scroll) {
scrollport.addEventListener("scroll", lazyLoadHandler);
}
if (resize) {
scrollport.addEventListener("resize", lazyLoadHandler);
}
}
listeners.push(this);
checkVisible(this);
}
shouldComponentUpdate() {
return this.visible;
}
componentWillUnmount() {
const { parent } = this;
if (this.props.overflow) {
if (parent && typeof parent.getAttribute === "function") {
const listenerCount = Number(parent.getAttribute(LISTEN_FLAG) || 0) - 1;
if (listenerCount === 0) {
parent.removeEventListener("scroll", lazyLoadHandler, 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 && parent) {
parent.removeEventListener("resize", lazyLoadHandler, passiveEvent);
parent.removeEventListener("scroll", lazyLoadHandler, passiveEvent);
}
}
render() {
const { height, children, placeholder, className, classNamePrefix, style } =
this.props;
return (
<div
className={`${classNamePrefix}-wrapper ${className}`}
ref={this.ref}
style={{ height, ...style }}
>
{this.visible
? children
: placeholder || (
<div
style={{ height }}
className={`${classNamePrefix}-placeholder`}
/>
)}
</div>
);
}
}
(LazyLoad as any).defaultProps = {
className: "",
classNamePrefix: "lazyload",
once: true,
offset: 0,
overflow: false,
resize: false,
scroll: true,
unmountIfInvisible: false,
};
export default LazyLoad;
我也将这个项目发布到了 npm ,想尝试的同学可以试试。
yarn add react-lazyload-typescript
5. 结语
监听滚动事件,使用 getBoundingClientRect
进行元素可视判断是懒加载主要实现形式之一,随着浏览器日渐升级,很多懒加载的库开始转用 intersection-observer
来实现懒加载,之后会出专门的文章介绍,感兴趣的朋友可以点点关注和小心心,谢谢大家。
本文出至:学新通技术网
标签: