前言
在日常的前端开发中,<img> 标签是我们最熟悉的元素之一。大多数时候,它工作得很好——给一个 src,浏览器帮你搞定一切。但当你的页面需要同时加载几十甚至上百张图片时,你可能会发现:图片一张一张地"挤"着加载,滚动到新区域时图片迟迟不出现,甚至已经离开视口的图片还在继续下载。
这篇文章介绍一个轻量的 React 组件——FetchControlledImage,它用 fetch + AbortController 替代浏览器原生的图片加载机制,解决上述问题。
效果
使用原生的 Img 标签来加载大量图片

下面是使用了 FetchControlledImage 后的效果

原生 <img> 到底有什么问题?
1. 浏览器并发限制导致的排队问题
浏览器对同一域名的并发连接数有严格限制,HTTP/1.1 下通常是 6 个。这意味着,如果你的页面同时渲染了 100 张同域图片,浏览器会这样处理:
请求队列(同一域名):
[图片1] [图片2] [图片3] [图片4] [图片5] [图片6] ← 正在下载
[图片7] [图片8] [图片9] ... [图片100] ← 排队等待
前 6 张图片开始下载,剩下的 94 张只能排队。用户看到的效果是:页面上大片区域显示空白或 loading 骨架屏,图片一批一批地"蹦"出来。
更糟糕的是,<img> 标签发起的请求,你无法取消。即使用户快速滚动,那些已经不在视口内的图片仍然占着连接不放,阻塞了当前真正需要显示的图片。
2. 请求无法取消
考虑一个常见场景:用户在一个虚拟滚动列表中快速滚动。大量图片组件被挂载、然后很快被卸载。原生 <img> 的问题在于:
- 组件卸载了,请求不会自动取消
- src 变了,旧的请求仍在继续
- 这些"僵尸请求"持续占用并发连接,让真正需要的图片反而加载不了
3. 错误处理的黑盒
原生 <img> 对错误的暴露非常有限。一个 onerror 回调,告诉你"加载失败了",但:
- 是网络错误还是 CORS 错误?不知道。
- 是 404 还是服务端 500?不知道。
- 想做精细化的错误上报?做不到。
对于需要监控图片加载质量的业务场景,这是一个明显的短板。
FetchControlledImage 的解决方案
核心思路很简单:不让浏览器通过 <img> 标签直接请求图片,而是用 fetch API 手动下载图片数据,再通过 Blob URL 交给 <img> 显示。
传统方式:
<img src="https://cdn.example.com/photo.jpg" />
└── 浏览器自动发起请求(不可控)
FetchControlledImage:
fetch("https://cdn.example.com/photo.jpg")
└── 拿到 Blob → URL.createObjectURL(blob)
└── <img src="blob:..." /> (本地资源,无需网络请求)
这样做的好处是:fetch 发起的请求走的是通用的网络请求通道,而不是浏览器专门为图片维护的请求队列。更关键的是,fetch 配合 AbortController,可以随时取消请求。
核心实现
组件的核心逻辑集中在一个 useEffect 中:
useEffect(() => {
setDisplaySrc(fallback);
if (!src) return;
const abortController = new AbortController();
let objectUrl: string | undefined;
const loadImage = async () => {
try {
const response = await fetch(src, {
credentials: "omit",
signal: abortController.signal,
});
if (!response.ok) {
// 降级:回退到原始 src
setDisplaySrc(src);
return;
}
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setDisplaySrc(objectUrl);
onLoad?.();
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return; // 正常取消,不处理
}
// 降级 + 错误上报
setDisplaySrc(src);
}
};
loadImage();
return () => {
abortController.abort(); // 取消未完成的请求
if (objectUrl) {
URL.revokeObjectURL(objectUrl); // 释放内存
}
};
}, [src, fallback]);
几个关键设计点:
1. credentials: "omit"
图片资源经常会经过 CDN 重定向,而很多 CDN 的 CORS 配置是 Access-Control-Allow-Origin: *。根据规范,* 通配符与 credentials: 'include' 不兼容,会直接导致请求失败。使用 omit 可以避开这个坑。
2. cleanup 中取消请求 + 释放 Blob URL
这是组件的核心价值之一。当 src 变化或组件卸载时,useEffect 的 cleanup 函数会:
- 调用
abortController.abort()取消进行中的 fetch 请求 - 调用
URL.revokeObjectURL()释放 Blob 占用的内存
这两步保证了不会有"僵尸请求"和内存泄漏。
3. 多层降级策略
fetch 成功 → 使用 Blob URL 显示
↓ 失败
response 不 ok → 回退到原始 src(借助浏览器原生加载)
↓ 失败
网络异常 → 回退到原始 src
↓ 都不行
使用 fallback 占位图
降级的核心原则是:先让用户看到图片,再去诊断错误原因。这也是为什么 CORS 错误的探测是在 fallback 之后进行的——诊断逻辑可能会阻塞 JS 执行,不应该影响用户体验。
CORS 错误的智能检测
在实际生产环境中,CORS 错误是图片加载失败的一个常见原因,但它和普通网络错误在 catch 中的表现几乎一样——都是一个 TypeError: Failed to fetch。组件通过一个巧妙的二次探测来区分:
// 降级到原始 src 之后,再做探测
const mayBeCORSError =
error instanceof TypeError &&
error.message.toLowerCase().includes("failed to fetch");
if (mayBeCORSError) {
// 用 no-cors 模式发一个 HEAD 请求
const headResponse = await fetch(src, {
method: "HEAD",
mode: "no-cors",
cache: "no-store",
signal: abortController.signal,
});
if (headResponse.type === "opaque") {
// 资源存在,但不允许跨域访问 → 确认是 CORS 错误
logger?.error("fetch-image-cors-error", error, { src });
return;
}
}
no-cors 模式下,即使跨域,请求也不会失败,只是返回一个 opaque 类型的响应。如果这个请求成功了,说明资源本身是存在的,之前的失败就是 CORS 导致的。这个信息对于排查线上问题非常有价值。
React StrictMode 的兼容处理
React 18 的 StrictMode 会在开发环境中对 useEffect 进行双重调用(mount → unmount → mount)。如果不做处理,onLoad 回调会被触发两次。组件通过两个机制来应对:
const loadedSrcRef = useRef<string | undefined>();
let isActive = true;
// 在 loadImage 成功后:
if (isActive && loadedSrcRef.current !== src) {
loadedSrcRef.current = src;
onLoad?.();
}
// 在 cleanup 中:
return () => {
isActive = false;
// ...
};
isActive标记当前 effect 实例是否有效,第一次 effect 被 cleanup 后,它的回调不会再触发onLoadloadedSrcRef跨 effect 实例共享,记录已经成功加载的 src,防止同一个 src 触发多次onLoad
适用场景
图片密集型列表
电商商品列表、图片瀑布流、相册等场景,一次可能渲染几十上百张图片。使用 FetchControlledImage 可以避免请求排队,让图片更快地呈现给用户。
虚拟滚动
在使用 react-virtualized、react-window 等虚拟滚动库时,用户快速滚动会导致大量图片组件频繁挂载和卸载。FetchControlledImage 能在组件卸载时立即取消请求,把宝贵的连接让给当前可见的图片。
频繁切换的图片
轮播图、Tab 切换、预览大图等场景,src 会频繁变化。每次 src 变化时,旧请求会被立即取消,新请求随即发起,不会出现新旧图片"闪烁"或旧图片覆盖新图片的问题。
需要错误监控的业务
对于图片加载质量有监控需求的业务,可以通过 logger 属性接入自己的监控系统。组件会区分 CORS 错误和普通网络错误,提供结构化的错误上下文,方便排查问题。
需要加载状态管理的场景
配合 onLoad 回调和 fallback 属性,可以轻松实现 loading 态、占位图、加载完成后的过渡动画等效果。
使用方式
基础用法
import { FetchControlledImage } from "fetch-controlled-image";
<FetchControlledImage
src="https://cdn.example.com/photo.jpg"
fallback="/placeholder.png"
alt="商品图片"
/>
配合 loading 状态
function ImageWithLoading({ src }) {
const [loading, setLoading] = useState(true);
return (
<div>
{loading && <Spinner />}
<FetchControlledImage
src={src}
fallback="/placeholder.png"
onLoad={() => setLoading(false)}
style={{ display: loading ? "none" : "block" }}
/>
</div>
);
}
配合 styled-components
const StyledImage = styled.img`
width: 100%;
border-radius: 8px;
`;
<FetchControlledImage
as={StyledImage}
src="https://cdn.example.com/photo.jpg"
/>
接入错误监控
const logger = {
error: (eventName, error, context) => {
Sentry.captureException(error, {
tags: { event: eventName },
extra: context,
});
},
};
<FetchControlledImage
src="https://cdn.example.com/photo.jpg"
logger={logger}
/>
总结
FetchControlledImage 解决的是一个看似简单但实际影响用户体验的问题:在大量图片并发加载的场景下,原生 <img> 标签的不可控性。
它的核心价值在于:
- 请求可控:通过 fetch + AbortController,实现请求的精确取消
- 优雅降级:多层 fallback 策略,保证用户始终能看到图片
- 错误可观测:区分 CORS 错误和普通错误,支持接入监控系统
- 内存安全:自动管理 Blob URL 的生命周期,防止内存泄漏
整个组件只有不到 200 行代码,零外部依赖,作为一个即插即用的 <img> 替代方案,适用于任何对图片加载体验有要求的 React 项目。

1163

被折叠的 条评论
为什么被折叠?



