☰
文件流图片前端展示:Blob与ObjectURL的完整实践
2026/9/30 3:26:45 网站建设 项目流程

这本来是我给自己留的工作笔记。后台管理系统的接口设计得很“老实”——上传图片那一套没问题,但列表页和详情页的图片不返回 URL,而是直接把二进制文件流塞进响应体。前端拿到手就是一团 Blob,需要在浏览器里渲染成肉眼能看到的图片,这就是“文件流前端展示图片”场景。

这件事说起来一句话,真做起来处处是坑:鉴权头怎么带、responseType 该不该写、对象 URL 要不要手动回收、图片方向会不会翻转、甚至接口失败时返回的是 JSON 而成功时返回二进制,都会让页面在某个角落里翻车。如果你也是那种被后端同事一句“图片直接展示,接口返回文件流”打发的人,这篇内容可以当一份能直接抄作业的索引。下面我把方案、代码、坑,一条条拆开讲。

1. 场景与核心问题:文件流图片到底卡在哪里

1.1 最典型的“流式返回”接口长什么样

假设后端有这样的接口:

GET /api/file/preview/{fileId}

正常情况响应头是Content-Type: image/jpeg,响应体直接就是图片字节。这种接口在后端管理系统中太常见了——文件上传后落在对象存储或本地目录,后端读出来往输出流一写,权限校验也顺手做了。前端如果不做任何处理,直接把接口地址塞进<img src>,遇到不需要鉴权的接口确实能显示。

但一旦接口要求带Authorization头,事情就变了。浏览器在渲染<img>标签时,只会发出一个最普通的 GET 请求,它不会帮你把 header 里的 token 带上,更不会去执行“先登录再取图”的逻辑。后端的鉴权中间件一看没有凭证,要么返回 401,要么直接重定向。你以为图片裂开了,其实是被网关拦住了。

另外还有一类接口会设置Content-Disposition: attachment,这种设计本意是让浏览器下载而不是内联展示。就算你有权限、token 也在 URL 里,浏览器还是会弹出下载框,而不是乖乖渲染成图片。

1.2 为什么<img>不能直接加载带鉴权的文件流

有人会说:那把 token 拼在 URL 上不行吗?<img src="/api/file/preview/1?token=xxx">。这种方案在很多老项目里确实存在,但我劝你尽量不要这么做。token 经过 URL 传递,会被浏览器历史记录、Nginx access log、甚至 Referer 头泄露到第三方地方,后端也不敢保证这类 token 的过期策略和 header 里的一致。它属于典型的“能跑但是埋雷”的写法。

更干净的方案是绕开<img>原生请求,改成两步:先用axios或fetch带着自定义请求头把文件流完整拿回来,拿到的是一个 Blob 对象;再用URL.createObjectURL(blob)生成一个本地可访问的临时地址,把这个地址赋给<img>的src。这个临时地址只在当前页面内有效,不会经过网络,不会泄露 token,图片在毫秒级内就能显示。整条链路看起来多了一步,但解决了三个核心问题:自定义鉴权头、可控的请求生命周期、可捕获的错误处理。

1.3 这篇笔记实际要串起来的三个环节

文件流展示图片,拆开无非三个阶段:

  • 请求阶段:带鉴权、带参数,把二进制数据取回来
  • 转换阶段:把 Blob / ArrayBuffer / base64 转化成<img>能用的地址
  • 渲染与销毁阶段:把地址挂到图片上,并在不再需要时释放内存

大部分刚入门的同学只看完了第二阶段,拿到 Blob 生成 URL 就完事,结果页面多了几次跳转之后越来越卡。这就是没有意识到对象 URL 是需要手动释放的。下文我会把三个阶段分别讲透,再给一个可以直接复制的封装函数。

2. 方案选型:动手前先把“流”想明白

2.1 Blob 和 ArrayBuffer 别搞混

很多人一看到“二进制流”就想到 ArrayBuffer,因为这是XMLHttpRequest时代最常见的类型。但它们两个并不是一回事。

ArrayBuffer 是一段定长的原始二进制数据缓冲区,你可以理解成一块没有贴标签的内存。你想要往里面写数据,得通过DataView或者Uint8Array这类视图对象去操作,拿到底层字节。而 Blob 则是一个“自带说明书的文件对象”,它有type属性,比如image/png、application/pdf,还有size属性。你在浏览器里接触到的 File 对象,其实就是 Blob 的子类,只是多了文件名和最后修改时间。

对“展示图片”这个需求来说,直接操作 ArrayBuffer 完全没必要。你用responseType: 'blob'拿回来的 Blob 已经保住了文件的 MIME 类型,URL.createObjectURL直接用,浏览器会根据 MIME 决定怎么渲染。如果非要用 ArrayBuffer,你得自己判断数据类型、自己拼 Blob,这不是不行,而是把简单问题复杂化。记住:能拿 Blob 就拿 Blob,别去碰底层字节。

2.2 二进制流和 base64 的取舍,前端也得想清楚

接口返回图片,除了二进制流之外还有一种常见形态:base64 字符串,有些后端会把它包在 JSON 里:{ "code": 0, "data": "data:image/png;base64,iVBORw0..." }。这种方案在某些小型系统里很流行,理由是好调试、传输方便,但代价也不小。

base64 的本质是用 4 个 ASCII 字符表示 3 个字节的二进制数据,体积直接膨胀约 33%。一张 3MB 的图片转成 base64,光字符串就接近 4MB。前端拿到这么长的字符串赋给<img src>,浏览器要先把字符串解析还原成二进制,再解码渲染。这中间存在不小的 CPU 开销和内存开销,尤其在大列表里塞了几十张图的时候,页面卡顿非常明显。

反过来,二进制流没有这种膨胀问题,体积小、传输快,浏览器拿到 Blob 后原生解码,不需要额外做字符串转换。所以我一直主张:只要是后端能控制返回体形态,优先让接口直接回二进制流。base64 适合小图标或者需要内联进 HTML/CSS 的场景,不适合作为大面积图片展示的主通道。

2.3 展示用 ObjectURL,不要上来就 FileReader

好,现在前端已经拿到了 Blob,下一步改成src。很多教程在这里教你用FileReader.readAsDataURL,读成 base64 再去显示:

const reader = new FileReader(); reader.onload = (e) => { img.src = e.target.result; }; reader.readAsDataURL(blob);

这条路能用,但性能上是下策。FileReader会把整个文件读进内存并生成一串 base64 字符串,文件多大,字符串就多大。而URL.createObjectURL(blob)做的事情很轻量——它没有复制文件数据,只是给浏览器内部已有的这个 Blob 分配了一个引用标识,返回一段看起来像blob:http://localhost:3000/uuid的地址,图片显示速度非常快。

那 FileReader 是不是完全没用?也不是。如果你确实需要拿到图片数据的 base64 形态,比如要传给一个不接受 Blob 的第三方接口,或者需要把图片内容读出后重新加工,那再考虑 FileReader。除此之外,纯预览场景一律用URL.createObjectURL。

注意:ObjectURL 和页面生命周期绑定,页面关掉或者你手动revokeObjectURL之后,这个地址就失效了。不能用完就不管,后面会有专门一节讲怎么回收。

3. 实操:把文件流变成 img 能识别的地址

3.1 最简单稳定的 axios 写法

项目里用 axios 的同学最多,写法也最直白。关键就一句话:在请求配置里写死responseType: 'blob'。

import axios from 'axios'; async function getImageBlob(fileId, token) { const res = await axios.get(`/api/file/preview/${fileId}`, { responseType: 'blob', headers: { Authorization: `Bearer ${token}` } }); return res.data; // 这里拿到的就是 Blob } const blob = await getImageBlob(1001, 'my-token'); const url = URL.createObjectURL(blob); document.getElementById('myImage').src = url;

这里我要提醒一个新手容易踩的点:不要多此一举再包一层new Blob([response.data], { type: 'image/jpeg' }。当后端响应头本来就有Content-Type: image/jpeg时,axios 会根据 responseType 帮我们保留 Blob 的类型信息,response.data.type已经是image/jpeg。你手动再包一层,等于先解包再打包,还容易把 type 写死成错误格式。除非你明确知道后端返回的 Content-Type 是application/octet-stream而且图像数据确实是 JPG,否则直接用response.data。

3.2 fetch 版同样支持自定义请求头

不想引 axios 的同学,原生 fetch 也能做,代码还更少。核心是收到响应后调用res.blob():

const res = await fetch(`/api/file/preview/${fileId}`, { headers: { Authorization: `Bearer ${token}` } }); // 先看响应是不是正常 if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const blob = await res.blob(); const url = URL.createObjectURL(blob); document.getElementById('myImage').src = url;

fetch 版有一点比 axios 好:你面前直接摆着res,响应头、状态码、Content-Type 都能随手读到。所以我建议用 fetch 做封装,判断逻辑写起来更直接。尤其是后面要做“接口失败返回 JSON”这种兼容时,fetch 一眼就能看出问题。

3.3 如果后端偏偏返回 base64

后端不配合的情况太常见了。你明明说了要二进制流,他转头给你返回{ code: 0, data: "base64字符串" }。这种场景也能兜住。

第一种情况,后端返回完整 data URI:

const url = `data:image/png;base64,${base64Str}`; document.getElementById('myImage').src = url;

第二种情况,你拿到的是 base64 字符串,但你想走 Blob 那一套逻辑,比如要传给createObjectURL,或者要直接用FormData重新上传。那就需要一个转换函数:

function base64ToBlob(base64, mimeType = 'image/png') { const byteCharacters = atob(base64.split(',')[1] || base64); const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += 512) { const slice = byteCharacters.slice(offset, offset + 512); const byteNumbers = new Array(slice.length); for (let i = 0; i < slice.length; i++) { byteNumbers[i] = slice.charCodeAt(i); } byteArrays.push(new Uint8Array(byteNumbers)); } return new Blob(byteArrays, { type: mimeType }); }

这段代码在不少 base64 转 Blob 的面试题里会考,原理也不难:atob把 base64 字符串还原成二进制字符,再用charCodeAt逐个转成字节,最后组装成 Uint8Array 喂给 Blob。为什么要分片 512?主要是避免一次性处理超大字符串时把调用栈卡爆,也是老代码里常见的经验分片。

提示:base64 转出来的 Blob 也是 Blob,照样能URL.createObjectURL,也可以直接塞FormData。这就是“base64 流互转”在实际业务里的闭环。

3.4 上传侧的补充:File、Blob、FormData 一家人

既然是“自用”笔记,和预览配套的上传逻辑也值得一并记下来。前端预览时拿到的 Blob,往往还需要继续往后端传,比如用户先选图、预览、再裁剪压缩、最后提交表单。这个流程里File和Blob是可以无缝互换的。

  • 用户从<input type="file">拿到的File,本身就是 Blob 的子类,可以直接放进FormData。
  • 你用canvas.toBlob()生成的裁剪结果,也是 Blob,也可以直接FormData.append('file', blob, 'cover.png')。
  • 如果后端接口要求 multipart/form-data,那前端只需要:
const formData = new FormData(); formData.append('file', blob, 'filename.jpg'); formData.append('fileId', '1001'); await fetch('/api/file/upload', { method: 'POST', body: formData, // 不用手动设置 Content-Type,浏览器会自动带 boundary });

这里很多人会犯错:手动给 fetch 设置Content-Type: multipart/form-data。千万别写,浏览器会帮你补全并自动生成 boundary,你写死了反而会导致后端解析失败。这些细节说白了都是“前端传参”里最容易踩的坑。

4. 一个可直接抄作业的封装

4.1 设计 loadStreamImage 函数

把前面的思路全部整合进一个函数,用来作为项目里的公共工具。输入是图片地址、token 和超时时间,输出是生成好的 objectURL 和 Blob 元信息,让调用方拿回去自己决定revoke时机。

async function loadStreamImage(options: { url: string; token?: string; timeout?: number; signal?: AbortSignal; }): Promise<{ objectUrl: string; blob: Blob; size: number; contentType: string }> { const { url, token, timeout = 15000, signal } = options; const controller = new AbortController(); let timer: ReturnType<typeof setTimeout> | undefined; if (signal) { // 外部传入 AbortSignal,联动取消 signal.addEventListener('abort', () => controller.abort()); } if (timeout > 0) { // 超时自动取消请求,避免长时间挂起 timer = setTimeout(() => controller.abort(), timeout); } try { const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: controller.signal }); if (!res.ok) { throw new Error(`图片加载失败:HTTP ${res.status}`); } const contentType = res.headers.get('content-type') || ''; // 关键兜底:后端出错时可能返回 JSON,而不是图片二进制 if (contentType.includes('application/json')) { const errData = await res.json(); throw new Error(errData.message || '接口返回 JSON,但预期是文件流'); } const blob = await res.blob(); return { objectUrl: URL.createObjectURL(blob), blob, size: blob.size, contentType }; } finally { if (timer) clearTimeout(timer); } }

这个函数里有两个细节是我从实际项目里带出来的。一是超时控制,文件流接口如果后端卡了,前端会一直在挂起状态,用户体验非常差,所以设个 15 秒兜底。二是JSON 判断,后端在鉴权失败或业务错误时经常返回统一格式的 JSON,比如{ "code": 401, "message": "token 过期" }。如果你直接await res.blob(),拿到的 Blob 类型是application/json,再丢给createObjectURL,图片区域会显示破碎图标,而且错误原因很难排查。提前判断 content-type 能把真实错误抛出来。

4.2 返回后如何渲染到组件里

拿到objectUrl之后,赋值给<img src>很简单:

<img id="preview" alt="文件流预览" />
const { objectUrl } = await loadStreamImage({ url: '/api/file/preview/1001', token: localStorage.getItem('token') || undefined }); const img = document.getElementById('preview'); img.src = objectUrl; img.onload = () => { // onload 之后其实可以 revoke,因为图片已经完成加载 // 但如果你后续还要拿这个 url 做下载,就先别急着 revoke console.log('图片加载完成,大小', img.naturalWidth, img.naturalHeight); }; img.onerror = () => { URL.revokeObjectURL(objectUrl); console.error('图片渲染失败'); };

在真实项目里,图片往往不止一张。多图场景下最佳实践是把对象 URL 缓存起来,同一 fileId 不要重复请求,避免每次渲染都重新拉流。最简单的实现是写一个模块级Map:

const imageUrlCache = new Map(); async function getCachedImageUrl(fileId, token) { if (imageUrlCache.has(fileId)) { return imageUrlCache.get(fileId); } const { objectUrl } = await loadStreamImage({ url: `/api/file/preview/${fileId}`, token }); imageUrlCache.set(fileId, objectUrl); return objectUrl; }

这里要留个心眼:缓存里的 objectUrl 可能因为页面状态切换而被 revoke。如果你在切页时统一清理了所有 URL,记得同时imageUrlCache.clear(),否则下回会拿到一个已失效的blob:地址。我自己的习惯是 Map 只做当前页面的短期缓存,配套一个clearAllImageUrl()方法在组件卸载时调用,两头都干净。

4.3 React 和 Vue 里的生命周期管理

如果用的是 React,把请求放到useEffect里,并在清理函数里revokeObjectURL:

useEffect(() => { const controller = new AbortController(); let objectUrl: string | null = null; loadStreamImage({ url: `/api/file/preview/${fileId}`, token, signal: controller.signal }) .then((res) => { objectUrl = res.objectUrl; setSrc(objectUrl); }) .catch((err) => { if (err.name !== 'AbortError') { console.error('加载图片失败', err); } }); return () => { controller.abort(); if (objectUrl) { URL.revokeObjectURL(objectUrl); } }; }, [fileId]);

Vue 3 里差不多,重点是在onBeforeUnmount里释放:

import { ref, watch, onBeforeUnmount } from 'vue'; const imageSrc = ref(''); let objectUrl: string | null = null; watch(fileId, async (newId) => { if (objectUrl) URL.revokeObjectURL(objectUrl); const res = await loadStreamImage({ url: `/api/file/preview/${newId}`, token }); objectUrl = res.objectUrl; imageSrc.value = res.objectUrl; }); onBeforeUnmount(() => { if (objectUrl) URL.revokeObjectURL(objectUrl); });

注意:在同一个页面里,如果 fileId 快速切换,上一个请求可能还没完成就被下一个覆盖。上面用 AbortController 取消旧请求,同时在.then里保留了当前 objectUrl 以便清理,这两个动作配合才能避免竞态。

5. 会踩的坑:内存、方向、跨域

5.1 不回收 ObjectURL 的内存后果,比你想象严重

我见过一个真实案例:列表页每隔 5 秒轮询刷新图片,前端每次刷新都会调用一次URL.createObjectURL,从不revoke。跑一小时后 Chrome 内存涨到 1.5GB,页面操作开始掉帧,最后只能杀进程。这就是典型的“对象 URL 泄漏”。

原因也不难理解:每创建一个 ObjectURL,浏览器就会保持对底层 Blob 数据的强引用。只要你不主动 revoke,这些图片数据就一直堆在内存里,和一张一张往内存里塞图片效果差不多。加上blob:URL 本身占用的注册表项,长时间运行必然劣化。

所以我的习惯是:对象 URL 只活在自己看得见生命周期的地方。组件卸载时 revoke、图片加载失败时 revoke、切换数据源时先 revoke 旧的。如果你实在不放心手动管理,可以用一个数组统一记录:

const urlsToRevoke = []; // 每次创建 const url = URL.createObjectURL(blob); urlsToRevoke.push(url); // 页面切换或清空列表之前 function revokeAll() { urlsToRevoke.forEach((u) => URL.revokeObjectURL(u)); urlsToRevoke.length = 0; }

对应的,<img>标签的src在 revoke 之后已经加载出来的画面不会被强制清空,但如果此刻你还没赋值给src,那赋值就失效了。所以开发顺序上,总是先赋值,后考虑回收。

5.2 图片方向翻转,Canvas 场景特别明显

很多手机上拍的 JPEG 图片,Exif 信息里带一个Orientation标记。直接扔进<img>时浏览器会自动识别并旋转展示,你不会觉得有问题。但一旦你把这个图片拿去做 Canvas 压缩、截图、裁剪,Canvas 默认会把原始像素直接绘制,方向标记被忽略,结果就是“手机上正常,页面上却是横的”。

两个解法可以并存。简单场景下用 CSS:

img { image-orientation: from-image; }

Canvas 场景下,比较现代的做法是用createImageBitmap读取时传入方向选项:

const bitmap = await createImageBitmap(blob, { imageOrientation: 'from-image' }); canvas.drawImage(bitmap, 0, 0, width, height);

如果你的项目要兼容旧浏览器,那就只能手写 EXIF 解析,或者使用支持方向的图片处理库。我要强调的是:这个问题在“文件流展示”场景里容易忽略,因为流式拿回来的图片照样带着 Exif 信息,跳过了<img>的自动旋转逻辑又去做二次处理时才会爆发。

5.3 跨域与本地开发的心得

带Authorization头的请求,在跨域场景下会触发 CORS 预检请求OPTIONS。如果后端没配置Access-Control-Allow-Headers: Authorization,你会在浏览器控制台看到醒目的 CORS 报错,而实际业务请求根本没发出去。

这种问题在后端团队看来往往是“前端跨域了”,其实根因在后端响应头配置。我在团队里惯用的排查顺序是:先看 Network 面板有没有OPTIONS预检请求,如果没有,说明是请求根本没发出去;如果有但被拦截,看响应头缺了哪项。开发阶段,我更推荐用构建工具的代理把跨域消掉,比如 Vite 的server.proxy或 Webpack 的 devServer.proxy,把/api开头的请求全部转发到后端服务器,浏览器眼里就是同源请求,绕开预检那一堆麻烦。生产环境交给 Nginx 反向代理,前端代码不需要关心跨域。

6. 常见问题排查速查表

文件流展示图片这块的问题,症状和根因往往不是一一对应。我把实际遇到过的和朋友们交流过的整理成一个速查表,按“现象 -> 可能原因 -> 处理方案”来记,排查时对号入座会快很多。

现象可能原因处理方案
图片区域空白,Network 显示 200responseType 没写 blob,拿到的是乱码字符串补上responseType: 'blob',或 fetch 后显式res.blob()
<img>报 DOMException,createObjectURL 失败传给 createObjectURL 的不是 Blob,而是 JSON 对象或字符串检查 response.data 的类型,打印constructor.name确认
接口返回 401/403请求没带 token 或 token 过期检查 headers 是否正确写入 Authorization,确认 token 还有效
带 token 后请求直接 CORS 报错预检请求OPTIONS未通过后端补Access-Control-Allow-Headers,或开发环境用代理绕过跨域
图片能加载但偶尔裂开ObjectURL 被提前 revoke确认 revoke 时机在img.onload之后且不再依赖该 URL
图片能展示,但列表滚动越来越卡大量创建 ObjectURL 未回收用 Map 做缓存,组件卸载统一 revokeAll
打开图片时浏览器直接下载响应头是Content-Disposition: attachment联系后端改成inline,或者前端用 fetch 拿 Blob 再本地展示
页面偶发超时文件流接口太慢,无超时控制封装函数里加 AbortController + timeout

6.1 先学会读“错误的样子”

很多人遇到图片不显示,第一反应是改代码重试。其实最快的方式是点开 Network 面板看三样东西:

  • 响应状态码:401/403/404 基本说明鉴权或地址不对
  • 响应 Content-Type:不是 image/* 而是 text/html 或 application/json,说明你要的是一个页面或错误对象
  • 响应体大小:如果只有几十字节,大概率不是一张正常图片

如果接口返回 200,Content-Type 也正确,但图片区域一直是空白,此时打开 Console 看有没有报错信息。最常见的Failed to execute 'createObjectURL' on 'URL'这类 DOMException,十有八九是你在responseType没设置的情况下,把普通对象放了进来。检查手段很简单:

console.log(response.data instanceof Blob); // true 才是对的 console.log(response.data.constructor.name);

6.2 处理失败时的 JSON 返回体,是项目里最实际的细节

后端接口经常这样设计:成功时返回图片二进制流,失败时为了统一错误结构返回 JSON。前端如果只处理了成功分支,一旦遇到 token 过期,你拿到的 Blob 其实是{ "code": 401, "message": "unauthorized" }的文字字节。图片显示不出来,控制台也没有任何有效错误,最坑。

我的处理方式前面已经在封装函数里给了:调用res.blob()之前,先看content-type。如果包含application/json,说明这是异常响应,主动解析 JSON 抛错。这样至少能在错误监控里看到“图片加载失败:token 过期”,而不是“图片裂开,原因未知”。

6.3 面试视角:这类问题的考点在哪

文件流展示图片在真实项目里只是个基础需求,但面试官很喜欢拿它当引子问深。常见的问题包括:

  • URL.createObjectURL和FileReader.readAsDataURL的区别
  • upload 大文件时为什么不用 base64,而是用 Blob 直接传
  • 为什么说blob:URL 只能在当前页面访问
  • 如何在不暴露 URL 的情况下实现图片下载
  • multipartfile和 base64 文件流互转时的字节处理细节

这些问题的本质,是考察你是否理解浏览器网络层、二进制数据、以及内存生命周期之间的关系。把这一篇的内容吃透,面试时顺着说下去基本上不会断片。

我个人在实际项目里的体会是,文件流展示图片最难的不是 API 不会用,而是接口两面性带来的不确定性。所以封装函数时,我会把“成功返回图片、失败返回 JSON”这个分支放在第二行去判断,而不是调完createObjectURL之后再靠img.onerror去猜。另外,如果你做完一轮就再也不管它,那请一定记得把对象 URL 的生命周期和页面路由绑定,看到revokeObjectURL就顺手写掉。前端二进制这块的坑,大多不是技术门槛高,而是细节没做到位。这份笔记能帮你避开我趟过的大部分雷。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询