突破浏览器限制,别让 <img> 拖慢你的页面

前言

在日常的前端开发中,<img> 标签是我们最熟悉的元素之一。大多数时候,它工作得很好——给一个 src,浏览器帮你搞定一切。但当你的页面需要同时加载几十甚至上百张图片时,你可能会发现:图片一张一张地"挤"着加载,滚动到新区域时图片迟迟不出现,甚至已经离开视口的图片还在继续下载。

这篇文章介绍一个轻量的 React 组件——FetchControlledImage,它用 fetch + AbortController 替代浏览器原生的图片加载机制,解决上述问题。

效果

使用原生的 Img 标签来加载大量图片
NativeImg
下面是使用了 FetchControlledImage 后的效果
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 后,它的回调不会再触发 onLoad
  • loadedSrcRef 跨 effect 实例共享,记录已经成功加载的 src,防止同一个 src 触发多次 onLoad

适用场景

图片密集型列表

电商商品列表、图片瀑布流、相册等场景,一次可能渲染几十上百张图片。使用 FetchControlledImage 可以避免请求排队,让图片更快地呈现给用户。

虚拟滚动

在使用 react-virtualizedreact-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 项目。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值