实战指南

IntersectionObserver 图片懒加载:从原理到 React Hook 封装

深入剖析基于 IntersectionObserver 的图片懒加载实现,掌握自定义 useLazyLoad Hook 的设计与优化技巧

2026年2月8日15 分钟966 阅读
IntersectionObserver懒加载React Hook性能优化
IntersectionObserver 图片懒加载:从原理到 React Hook 封装

IntersectionObserver 图片懒加载实现

图片懒加载是前端性能优化的核心手段之一。通过延迟加载视口外的图片,可以显著减少首屏请求数量、降低带宽消耗。本文将深入分析一个基于 IntersectionObserver API 的 React 懒加载 Hook 实现,从观察器配置到生命周期管理,逐步拆解每一个设计决策。

核心 API:IntersectionObserver

IntersectionObserver 是浏览器原生提供的交叉观察器 API,用于异步检测目标元素与祖先元素(或视口)的交叉状态。相比传统的 scroll 事件监听方案,它具有更好的性能表现。

传统方案 vs IntersectionObserver

typescript
// 传统方案:监听 scroll 事件(性能差)
window.addEventListener("scroll", () => {
	images.forEach(img => {
		const rect = img.getBoundingClientRect();
		if (rect.top < window.innerHeight) {
			img.src = img.dataset.src; // 频繁触发,需要节流
		}
	});
});

// 现代方案:IntersectionObserver(性能好)
const observer = new IntersectionObserver(entries => {
	entries.forEach(entry => {
		if (entry.isIntersecting) {
			const img = entry.target as HTMLImageElement;
			img.src = img.dataset.src!;
			observer.unobserve(img); // 异步回调,浏览器优化调度
		}
	});
});

性能对比

特性scroll 事件方案IntersectionObserver
触发频率极高,需手动节流浏览器智能调度
主线程阻塞同步执行,阻塞渲染异步回调,不阻塞
计算方式手动 getBoundingClientRect浏览器内部优化
代码复杂度需要节流 + 手动计算声明式 API,简洁
兼容性全兼容现代浏览器均支持

观察器配置解析

typescript
const observerOptions: IntersectionObserverInit = {
	rootMargin: "100px 0px",
};

IntersectionObserverInit 完整配置

属性类型默认值说明
rootElement | nullnull(视口)观察的根元素,null 表示浏览器视口
rootMarginstring"0px"根元素的外边距,扩展/缩小观察区域
thresholdnumber | number[]0触发回调的交叉比例阈值

rootMargin 的作用

rootMargin 示意图 - 视口上下各扩展 100px 的预加载区域
rootMargin 示意图 - 视口上下各扩展 100px 的预加载区域

useLazyLoad Hook 完整实现

核心代码

typescript
"use client";
import { useRef, RefObject, useEffect } from "react";

// 观察器配置:上下各扩展 100px 预加载区域
const observerOptions: IntersectionObserverInit = {
	rootMargin: "100px 0px",
};

function useLazyLoad(ref: RefObject<HTMLElement | null>, data: any[]) {
	// 持久化存储观察器实例,跨渲染周期保持引用
	const observerRef = useRef<IntersectionObserver | null>(null);

	// 懒加载回调函数
	const lazyLoadCallback: IntersectionObserverCallback = entries => {
		entries.forEach(entry => {
			if (entry.isIntersecting) {
				const img = entry.target as HTMLImageElement;
				const realSrc = img.getAttribute("data-src");
				if (realSrc) {
					img.src = realSrc; // 设置真实图片地址
					img.removeAttribute("data-src"); // 移除 data-src 标记
					observerRef.current?.unobserve(img); // 停止监听已加载的图片
				}
			}
		});
	};

	const initLazyLoad = () => {
		// 1. 环境判断:仅客户端执行
		if (typeof window === "undefined" || !window.IntersectionObserver) return;
		// 2. 前置校验:ref 不存在/数据未就绪,直接返回
		if (!ref.current || !data || data.length === 0) return;
		// 3. 销毁旧的观察器(避免重复监听)
		if (observerRef.current) {
			observerRef.current.disconnect();
		}
		// 4. 初始化新的观察器
		observerRef.current = new IntersectionObserver(
			lazyLoadCallback,
			observerOptions
		);
		// 5. 监听容器内所有带 data-src 的图片
		const images = ref.current.querySelectorAll("img[data-src]");
		images.forEach(img => observerRef.current!.observe(img));
	};

	// 核心:监听 ref + 异步数据 的变化,数据就绪后初始化懒加载
	useEffect(() => {
		initLazyLoad();
		return () => {
			if (observerRef.current) {
				observerRef.current.disconnect();
				observerRef.current = null;
			}
		};
	}, [ref, data]);
}

执行流程图

执行流程图 - 从组件挂载到数据加载再到懒加载初始化的完整生命周期
执行流程图 - 从组件挂载到数据加载再到懒加载初始化的完整生命周期

关键设计点详解

1. data-src 占位模式

typescript
// JSX 中:使用 data-src 存储真实地址,不设置 src
<img
	data-src={`https://picsum.photos/200/${180 + index}`}
	alt="懒加载图片"
	style={{ width: "200px", height: "200px" }}
/>;

// 回调中:进入视口时替换为真实地址
const realSrc = img.getAttribute("data-src");
if (realSrc) {
	img.src = realSrc; // 触发浏览器加载图片
	img.removeAttribute("data-src"); // 清除标记,避免重复加载
	observerRef.current?.unobserve(img); // 取消监听,释放资源
}
步骤操作目的
初始data-src="url", 无 src阻止浏览器自动请求图片
进入视口img.src = realSrc触发浏览器开始加载图片
清除标记removeAttribute("data-src")防止重复触发加载
取消监听unobserve(img)释放观察器资源

2. 异步数据驱动的初始化时机

typescript
useEffect(() => {
	initLazyLoad();
	return () => {
		/* cleanup */
	};
}, [ref, data]); // 依赖 ref 和 data

为什么要将 data 作为依赖项?

时间线:
  0ms    → 组件挂载,data = [],DOM 中无 <img> 元素
  1000ms → 异步数据返回,data = [1...100],DOM 渲染 100 个 <img>

如果不监听 data,观察器只会在组件挂载时初始化(此时 DOM 中没有图片),导致懒加载永远不会生效。

3. 观察器生命周期管理

typescript
const observerRef = useRef<IntersectionObserver | null>(null);

const initLazyLoad = () => {
	// 销毁旧的观察器(避免重复监听)
	if (observerRef.current) {
		observerRef.current.disconnect();
	}
	// 创建新的观察器
	observerRef.current = new IntersectionObserver(
		lazyLoadCallback,
		observerOptions
	);
	// ...
};

// useEffect cleanup
return () => {
	if (observerRef.current) {
		observerRef.current.disconnect(); // 断开所有监听
		observerRef.current = null; // 释放引用
	}
};
生命周期阶段操作目的
data 变化时disconnect() 旧观察器避免重复监听同一元素
重新初始化创建新 IntersectionObserver监听新渲染的图片
组件卸载时disconnect() + 置 null防止内存泄漏

4. 环境兼容性处理

typescript
// SSR 安全检查
if (typeof window === "undefined" || !window.IntersectionObserver) return;
  • typeof window === 'undefined':防止 Node.js 服务端渲染时报错
  • !window.IntersectionObserver:兼容不支持该 API 的旧浏览器

使用示例

基础用法

typescript
export default function ImageGallery() {
	const containerRef = useRef<HTMLDivElement>(null);
	const [images, setImages] = useState<string[]>([]);

	// 自定义 Hook:传入容器 ref 和数据
	useLazyLoad(containerRef, images);

	useEffect(() => {
		// 模拟异步加载图片列表
		fetchImages().then(setImages);
	}, []);

	return (
		<div ref={containerRef}>
			{images.map((url, index) => (
				<img
					key={index}
					data-src={url} // 真实地址存储在 data-src
					alt={`图片 ${index + 1}`}
					style={{ width: "200px", height: "200px" }}
				/>
			))}
		</div>
	);
}

完整 Demo 组件

typescript
"use client";
import { useRef, useEffect, useState } from "react";

export default function Test() {
	const container = useRef<HTMLDivElement>(null);
	const containerRef = useRef<HTMLDivElement>(null);

	const [options, setOptions] = useState<Array<number | string>>([]);
	useLazyLoad(containerRef, options);

	useEffect(() => {
		// 模拟 1 秒后异步加载 100 条数据
		new Promise(resolve => {
			setTimeout(() => {
				setOptions(
					Array(100)
						.fill("")
						.map((_, i) => i + 1)
				);
				resolve(true);
			}, 1000);
		});
	}, []);

	return (
		<div ref={containerRef} className="flex justify-center pt-8">
			<div className="grid gap-1 items-center">
				{options.map((item, index) => (
					<img
						data-src={`https://picsum.photos/200/${180 + index}`}
						alt="懒加载图片"
						style={{ width: "200px", height: "200px" }}
						key={index}
					/>
				))}
			</div>
		</div>
	);
}

优化建议

1. 添加加载占位符

typescript
// 使用 CSS 占位,避免图片加载时的布局抖动
<img
	data-src={url}
	alt="图片"
	className="bg-gray-200 animate-pulse" // 骨架屏效果
	style={{ width: "200px", height: "200px" }}
/>

2. 添加加载失败处理

typescript
const lazyLoadCallback: IntersectionObserverCallback = entries => {
	entries.forEach(entry => {
		if (entry.isIntersecting) {
			const img = entry.target as HTMLImageElement;
			const realSrc = img.getAttribute("data-src");
			if (realSrc) {
				// 加载失败处理
				img.onerror = () => {
					img.src = "/fallback-image.png"; // 兜底图片
				};
				img.src = realSrc;
				img.removeAttribute("data-src");
				observerRef.current?.unobserve(img);
			}
		}
	});
};

3. 支持自定义 rootMargin

typescript
function useLazyLoad(
	ref: RefObject<HTMLElement | null>,
	data: any[],
	rootMargin: string = "100px 0px" // 允许自定义预加载距离
) {
	// ...
	observerRef.current = new IntersectionObserver(lazyLoadCallback, {
		rootMargin,
	});
}

// 使用:更大的预加载区域
useLazyLoad(containerRef, images, "300px 0px");

4. 考虑原生 loading="lazy"

typescript
// 现代浏览器已原生支持懒加载
<img src={url} loading="lazy" alt="图片" />

// 但自定义实现的优势:
// 1. 更精细的控制(自定义 rootMargin、回调逻辑)
// 2. 配合异步数据加载
// 3. 统一的加载状态管理

常见面试题

1. 为什么用 IntersectionObserver 而不是 scroll 事件?

scroll 事件触发频率极高(每帧都可能触发),即使加了节流也需要频繁调用 getBoundingClientRect(),会触发浏览器重排。IntersectionObserver 是浏览器原生优化的异步 API,在独立线程中计算交叉状态,不阻塞主线程。

2. 为什么要在 useEffect 的 cleanup 中 disconnect?

如果组件卸载时不断开观察器,观察器仍会持有对已卸载 DOM 元素的引用,导致内存泄漏。disconnect() 会停止所有监听并释放内部资源。

3. data-src 方案有什么局限性?

  • 需要手动管理 data-srcsrc 的切换
  • 不设置 src 的图片没有原生的加载/错误事件
  • SEO 不友好(搜索引擎爬虫可能看不到真实图片地址)
  • 不适用于 CSS 背景图

4. 如何处理动态新增的图片?

当前实现依赖 data 变化重新初始化观察器。如果图片是通过其他方式动态添加的(如无限滚动),可以使用 MutationObserver 监听 DOM 变化,自动为新增图片注册观察。

总结

"

基于 IntersectionObserver 的图片懒加载是现代前端性能优化的标准方案。将其封装为 React Hook,可以实现声明式的懒加载能力,同时保持良好的可复用性。

实现懒加载 Hook 的核心要点:

  1. data-src 占位模式 - 阻止浏览器自动加载,按需触发
  2. 异步数据驱动 - 将数据作为 useEffect 依赖,确保 DOM 就绪后初始化
  3. 观察器生命周期 - 数据变化时重建,组件卸载时销毁,防止内存泄漏
  4. 预加载策略 - 通过 rootMargin 提前加载即将进入视口的图片

在实际项目中,建议优先考虑浏览器原生 loading="lazy" 属性,只有在需要更精细控制(自定义预加载距离、配合异步数据、统一加载状态)时才使用自定义 IntersectionObserver 方案。

文章标签

# IntersectionObserver# 懒加载# React Hook# 性能优化
返回首页