12k
All articles

如何在上传前生成图片缩略图

上传前创建图片缩略图:FileReader 预览、Canvas toBlob 缩放、FormData 上传和 multer 字段匹配。

OpenReplay Team
OpenReplay Team
如何在上传前生成图片缩略图

预览(preview)以较小的尺寸显示用户选中的文件,但上传的仍是完全相同的字节;而缩略图(thumbnail)是真正经过重新编码的、更小的图片,你可以将它与原图一起上传——或者干脆取而代之。

如果你曾经上线过一个自以为是「缩略图」的功能,却眼睁睁看着 12 MB 的手机照片照样慢吞吞地爬向服务器,你就已经明白这个区别有多重要了。大多数教程都模糊了这一区别,把一个被缩小显示的 <img> 称为「缩略图」,这是错误的:用 CSS 在屏幕上缩小图片,在网络传输层面上什么也没改变。本文将区分这两者,然后从最简单的 FileReader 预览,一路讲到可真正上传的 Canvas 重采样缩略图,并涵盖那些在生产环境中会拖垮上传 UI 的清理与校验陷阱。

核心要点

  • FileReader.readAsDataURL 预览仅用于显示:它上传的是原始字节,在网络传输上没有任何缩减。
  • 要生成更小的文件,你必须把图片重绘到 <canvas> 上,并用 canvas.toBlob(callback, 'image/jpeg', 0.7) 重新编码。
  • canvas.toBlob() 是异步的,它把 Blob 交给回调函数而不是直接返回,因此需要用 Promise 包装才能 await 结果。
  • 在同一个 FormData 中上传两个文件(原始 File 以及带显式文件名的缩略图 Blob),然后在服务端用 multer 的 upload.fields() 接收。
  • 每一次 URL.createObjectURL() 都必须配对一次 URL.revokeObjectURL(),否则底层文件会一直驻留在内存中,直到文档卸载。

预览 vs. 真正的缩略图:你需要哪一个?

在写任何代码之前先做决定,因为这两条路径几乎没有共用逻辑。预览用于从视觉上确认选对了文件。缩略图则是一个全新的、更小的图片资源(更少的字节、更小的尺寸),它能减少上传时间和服务端处理开销,并且可以直接作为网格图使用,无需再次处理原图。

目标技术方案是否产生更小的新文件?适用场景
立即显示所选图片FileReaderURL.createObjectURL(file)只需要视觉确认
减少上传字节 / 存储小尺寸变体Canvas + toBlob()需要上传到服务器或 CDN
拖放、进度、多种尺寸、校验使用库(FilePond)想要开箱即用的完整方案

FileReaderreadAsDataURL 预览不会在网络传输层面缩小任何东西。要真正减少上传字节,你必须把图片重绘到 <canvas> 上,并用 canvas.toBlob() 重新编码。

如何用 FileReader 预览图片?

对于纯展示型预览,监听文件输入框的 change 事件,用 FileReader.readAsDataURL 读取文件,然后在 reader.onload 中把得到的 data URL 赋给一个 <img>。这是最快能上线的方案,而且它不会改变文件尺寸。

<input type="file" id="fileInput" accept="image/*" multiple>
<div id="previews"></div>
const input = document.getElementById('fileInput');
const previews = document.getElementById('previews');

input.addEventListener('change', (e) => {
  previews.innerHTML = '';
  Array.from(e.target.files).forEach((file) => {
    if (!file.type.startsWith('image/')) return;
    const reader = new FileReader();
    reader.onload = (ev) => {
      const img = new Image();
      img.src = ev.target.result;            // base64 data URL
      img.alt = `Preview of ${file.name}`;
      previews.appendChild(img);
    };
    reader.readAsDataURL(file);
  });
});

注意这里的 Array.from(files).forEach(...) 写法。常见的 jQuery 多文件教程会在 for 循环内复用同一个 reader 变量,导致每个 onload 都闭包捕获了最后一个文件:这是一个闭包 bug,表现为反复显示同一张图片。每个文件都需要在迭代内拥有自己的 FileReader,而 forEach 天然就能做到这一点。

对于大文件,更轻量的替代方案是 URL.createObjectURL(file),它返回一个简短的 blob URL 而非 base64 字符串,避免了把整个文件编码成 data URL 所带来的内存膨胀,代价是之后必须调用 revokeObjectURL()

如何用 Canvas 生成缩放后的缩略图?

要生成真正更小的文件,需要加载图片、在保持宽高比的前提下缩放其尺寸、绘制到 canvas 上,然后重新编码。要保持宽高比,请用同一个系数(maxSize / longestSide)同时缩放两个维度,而不是分别设置宽和高。

canvas.toBlob(callback, 'image/jpeg', 0.7) 是异步的:它把 Blob 交给回调函数而不是返回它,所以如果你想 await 缩略图,就得用 Promise 包一层。普通 canvas 没有返回 Promise 的形式;只有 OffscreenCanvas.convertToBlob() 原生返回 Promise。

function canvasToBlob(canvas, type, quality) {
  return new Promise((resolve, reject) => {
    canvas.toBlob(
      (blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
      type,
      quality
    );
  });
}

async function makeThumbnail(file, maxSize = 200) {
  const url = URL.createObjectURL(file);
  try {
    const img = await new Promise((res, rej) => {
      const i = new Image();
      i.onload = () => res(i);
      i.onerror = rej;
      i.src = url;
    });

    const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
    const w = Math.round(img.width * scale);
    const h = Math.round(img.height * scale);

    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    canvas.getContext('2d').drawImage(img, 0, 0, w, h);

    return await canvasToBlob(canvas, 'image/jpeg', 0.7);
  } finally {
    URL.revokeObjectURL(url);
  }
}

toBlob 的第三个参数设置编码质量,取值范围是 0 到 1。只有有损格式会读取它,因此它会影响 image/jpegimage/webp 的输出,而对 image/png 毫无作用。0.6–0.8 是实践中的最佳区间。把一张数百万像素的照片重新编码成 200px 的 JPEG,通常能把体积压缩一到两个数量级——这正是在客户端做这件事的全部意义所在。

显示缩略图并上传两个文件

URL.createObjectURL(blob) 显示生成的 Blob,然后把它连同原图一起发送到服务器。上传时,把两个文件都追加到同一个 FormData 中:原始 File 和缩略图 Blob,并把文件名作为第三个参数传入。用 fetch 发起 POST 请求,服务端 multer 的 upload.fields() 会分别接收这两个字段。

async function upload(file) {
  const thumb = await makeThumbnail(file);

  const preview = new Image();
  preview.src = URL.createObjectURL(thumb);   // remember to revoke later
  document.body.appendChild(preview);

  const form = new FormData();
  form.append('originalFiles', file, file.name);
  form.append('thumbnails', thumb, `thumb-${file.name}.jpg`);

  await fetch('/api/upload', { method: 'POST', body: form });
}

在后端,multer 负责解析 multipart 请求体。upload.fields() 中的字段名必须与 FormData.append 的键完全一致,否则该字段会被静默丢弃:

const upload = multer({ dest: 'uploads/' });
app.post('/api/upload', upload.fields([
  { name: 'originalFiles', maxCount: 10 },
  { name: 'thumbnails', maxCount: 10 },
]), (req, res) => res.json({ ok: true }));

Multer 的 2.x 分支包含了 1.x 分支所缺失的安全修复,其 package.json 将最低要求定在 Node.js 10.16.0,而不是 Node 18。Node 18 的最低要求要到 3.x 分支才会引入,而该分支目前仍处于 alpha 阶段。multer changelog 中最新的稳定版本是 2.2.0,锁定版本前请先去那里确认。

使用库的方案:FilePond 及同类工具

如果你还需要拖放、上传进度、校验以及多种缩放变体,就别再手工造轮子了,直接使用 FilePond 及其图片插件。filepond-plugin-image-preview 插件负责渲染预览,filepond-plugin-image-resize 写入缩放元数据,而 filepond-plugin-image-transform 执行实际的缩放并把输出的 Blob 交给你。

FilePond.registerPlugin(
  FilePondPluginImagePreview,
  FilePondPluginImageResize,
  FilePondPluginImageTransform
);
FilePond.create(document.querySelector('input[type="file"]'), {
  imageResizeTargetWidth: 256,
  imageResizeMode: 'contain',
});

正如 FilePond 安装文档所建议的,请将 FilePond 锁定在版本 4 分支(@^4)。4.x 是稳定分支,changelog 中的版本为 4.32.12,而 v5 仍处于 beta 阶段。不加版本锁定地加载 unpkg.com/filepond 会跟随 latest 标签,所以今天它提供的是当前稳定版,但一旦下一个大版本被提升为稳定版,你就会在那天被切换过去。

若特别追求更高质量的降采样,Pica10.0.2)会应用真正的重采样滤波器,并且可以运行在 Web Worker 中;通过 filter 选项选择算法,例如 { filter: 'lanczos3' }。browser-image-compression 包(2.0.2)是另一个选择,但它最后一次发布是在 2023 年 3 月,且 Snyk 将其维护状态评定为不活跃,采用前请权衡这一点。

陷阱与最佳实践

以下是上传 UI 的会话回放中经常暴露出来的故障模式(隐性内存增长、方向错乱的缩略图,以及处理超大文件时卡死的标签页):

  • 释放对象 URL。 移除预览时务必调用 URL.revokeObjectURL():每一次 createObjectURL() 都会让底层文件驻留在内存中,直到该 URL 被显式释放或文档卸载。不加约束的对象 URL 是标签页内存缓慢增长的典型来源。
  • FileList 是只读的。 由于 FileList 只读,你无法从 <input> 中剔除某个文件,因此要自行维护一个可编辑的数组,并基于它重建上传内容。
  • 处理前先校验。accept="image/*" 与运行时的 file.type.startsWith('image/') 检查以及体积上限结合使用。accept 只是 UX 提示,并非强制约束。
  • 限制尺寸以保护标签页。 超大图片可能在解码期间耗尽内存并使标签页崩溃;应拒绝超过字节阈值的文件,并在绘制前对 maxSize 加以限制。
  • EXIF 旋转。 通过 canvas 缩放可能会丢弃 EXIF 方向标记,导致竖拍的手机照片变成横的。请务必用手机拍摄的真实竖版照片进行测试。
  • 降采样质量。 对于大幅缩小,单次 drawImage() 的效果可能比较粗糙;可以分步缩放,或使用 Pica 这类库,它会应用真正的重采样滤波器以获得更锐利的缩略图。
  • Canvas 污染只影响从其他域加载的跨源图片;用户选择的本地文件绝不会污染 canvas,所以这里不需要任何 crossOrigin 处理。
  • 无障碍性。 为每个预览 <img> 提供有意义的 alt 文本,并用 ARIA 为移除按钮添加标签,让用户在看不到缩略图的情况下也能使用界面。

总结

选择与你的目标相匹配的路径:只需要视觉确认时用 FileReader 或对象 URL 预览;确实需要减少网络传输字节时,用 Canvas 的 toBlob 生成缩略图。从上面那个用 Promise 包装的 makeThumbnail 辅助函数开始,把原图和缩略图放进同一个 FormData 一起上传,并且从第一次提交代码起就接好 revokeObjectURL 清理逻辑,而不是等内存泄漏暴露之后再打补丁。

常见问题

创建预览会减小用户上传的文件体积吗?

不会。FileReader 的 readAsDataURL 预览或 URL.createObjectURL 预览都只用于显示,上传的仍是原始字节,在网络传输上没有任何缩减。要真正缩小上传的文件,你必须以更小的尺寸把图片重绘到 canvas 上,并用 canvas.toBlob 重新编码,然后上传那个 Blob 来替代原图,或与原图一并上传。

为什么我用 canvas 生成的缩略图方向是错的?

通过 canvas 缩放图片可能会丢弃手机在竖拍照片上写入的 EXIF 方向标记,于是方向本来正确的原图在缩略图里就变成了横向。浏览器会对普通的 img 元素自动校正方向,但 drawImage 并不总是把该方向信息带到 canvas 上。请务必用手机拍摄的真实竖版照片来测试你的缩放流程,而不要只用横版测试图。

canvas.toBlob 只接受回调,我该如何 await 它?

用 Promise 包装它,因为 canvas.toBlob 是异步的,它把 Blob 交给回调函数而不是返回。写一个返回 new Promise 的辅助函数,在其中以 resolve 调用 canvas.toBlob,并在回调收到 null 时 reject。普通 canvas 没有原生返回 Promise 的形式;只有 OffscreenCanvas.convertToBlob 会直接返回 Promise。

为什么我的缩略图字段从来没到达 multer 后端?

传给 multer 的 upload.fields 的字段名必须与客户端 FormData.append 的键完全一致,否则该字段会被静默丢弃且不报错。如果你在客户端 append 的是 'thumbnails',那么服务端就必须在 upload.fields 中声明 name 为 'thumbnails'。同时也要检查你的 multer 版本:2.x 分支包含了 1.x 分支所缺失的安全修复。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.