前端知库

图片上传前怎样本地预览?FileReader 和 URL.createObjectURL 有什么区别?

用户选好图片后,用 URL.createObjectURL(file) 同步生成一个 blob 地址赋给 img.src 即可预览,地址短、不用读取整个文件,但要在不再显示时调用 revokeObjectURL 释放。FileReader.readAsDataURL 异步读出 base64 的 data URL,是普通字符串,不用释放,适合需要把数据存下来或嵌入的场景。

参考答案

简答

用户在 <input type="file"> 里选了一张头像,上传之前想先在页面上看到。拿到 input.files[0] 这个 File 对象以后,有两种办法把它变成 img 能显示的地址。

第一种是 URL.createObjectURL(file),它直接返回一个 blob: 开头的短地址,指向浏览器内存里的这份文件,同步返回,不用先把文件读完。第二种是 FileReader 的 readAsDataURL(file),它把整个文件读出来编码成 base64,读完后在 load 事件里从 reader.result 拿到 data:image/png;base64,... 这样的长字符串。

所以单纯预览一般用 object URL,又快又省事。data URL 是普通字符串,不用释放,需要把图片存进 localStorage、或者把 base64 交给别的接口时才用它。

要注意 object URL 只要还存在,它指向的数据就不能被回收,所以换图、删除预览或组件卸载时要调用 URL.revokeObjectURL()。也别在图片 load 后马上释放,MDN 提醒那样用户就没法右键保存或在新标签打开这张图了。

用 object URL 预览,并在换图时释放

下面把“生成地址”和“释放上一个地址”封装起来。urlApi 在浏览器里传全局的 URL;示例在 Node 里传一个假的对象,记录哪些地址被释放了。

JavaScript
function createPreviewer(urlApi) {
  let currentUrl = null;
  return {
    show(file) {
      // 换图前释放上一张,否则旧图片的数据一直不能被回收。
      if (currentUrl) urlApi.revokeObjectURL(currentUrl);
      currentUrl = urlApi.createObjectURL(file);
      return currentUrl;
    },
    clear() {
      if (currentUrl) urlApi.revokeObjectURL(currentUrl);
      currentUrl = null;
    },
  };
}

const revoked = [];
let count = 0;
const fakeUrlApi = {
  createObjectURL: () => `blob:demo-${++count}`,
  revokeObjectURL: (url) => revoked.push(url),
};

const previewer = createPreviewer(fakeUrlApi);
const file = new File(['fake'], 'a.png', { type: 'image/png' });
console.log(previewer.show(file)); // blob:demo-1
console.log(previewer.show(file)); // blob:demo-2
previewer.clear();
console.log(revoked.join(',')); // blob:demo-1,blob:demo-2

同一个文件 show 了两次,得到两个不同的地址。这和 MDN 的说法一致:每调用一次 createObjectURL() 都会生成一个新地址,即使是同一个对象。所以不能指望“同一张图只占一份”,每个地址都要各自释放。

为什么非释放不可?MDN 写明,只要还有一个 object URL 有效,它背后的数据就不能被垃圾回收。浏览器会在文档卸载时自动释放,可是单页应用里页面一直不卸载,用户反复换图,旧图片就一直占着内存。所以 show 里先释放上一个,clear 在删除预览或组件卸载时调用。

在浏览器里接上文件选择框是这样的:

JavaScript
const previewer = createPreviewer(URL);

input.addEventListener('change', () => {
  const file = input.files?.[0];
  if (!file || !file.type.startsWith('image/')) return;
  preview.src = previewer.show(file);
});

用 FileReader 读成 data URL

readAsDataURL 没有返回值,读取是异步的,结果要在事件里拿:

JavaScript
const reader = new FileReader();
reader.addEventListener('load', () => {
  preview.src = reader.result; // data:image/png;base64,...
});
reader.readAsDataURL(file);

为什么预览大图时不推荐它?因为它要把整个文件读完、编码成 base64 才能显示,base64 每 3 个字节变成 4 个字符,字符串比原文件大约三分之一。而 object URL 同步返回一个很短的地址,图片数据由浏览器直接加载。

可是 data URL 也有自己的好处:它就是一个字符串,不依赖当前文档,不用手动释放,可以存起来下次再用。MDN 还提醒,reader.result 开头带着 data:image/png;base64, 这样的声明,只要 base64 部分时要先把逗号前面的去掉。

两种方式怎么选

只是上传前看一眼,用 object URL。要把图片存进本地存储、塞进 JSON 发给只收 base64 的接口,用 data URL。要读文件的原始字节做处理,比如计算哈希、裁剪压缩,用 file.arrayBuffer() 或者把图片画到 Canvas 上,这两种地址都不是必需的。File、Blob 本身的关系见《File、Blob 和 object URL 怎样用于文件预览?》。

常见追问

图片太大,想压缩后再上传怎么办?

可以先把图片画到 Canvas 上缩小,再用 canvas.toBlob() 生成一个新的 Blob 上传。预览只是给用户看的,真正上传什么文件由你决定。

前端检查了 file.type,服务端还要校验吗?

要。file.type 是浏览器给出的类型,通常根据扩展名推断,改个扩展名就能骗过去。前端检查只用来提示用户,服务端必须按文件内容重新校验。

React 组件里怎样释放 object URL?

在 useEffect 里根据当前文件创建地址,返回的清理函数里调用 URL.revokeObjectURL。换文件时旧的 effect 被清理,组件卸载时也会清理,就不会漏掉。