在React项目里做图片上传,如果只是简单地把input选中的文件丢给接口,体验往往很糟糕:用户不知道自己选了哪些图,原图动辄五六MB,上传又慢又费流量。比较完善的做法是选中文件后立刻在页面上生成预览缩略图,同时用canvas把原图压缩到合适尺寸再提交。这篇文章就来完整拆解这套流程,涉及FileReader读取本地图片、base64预览、canvas压缩以及React组件的封装思路。

一、用FileReader把本地图片转成可预览的base64
浏览器出于安全策略限制,页面脚本无法直接访问用户磁盘上的文件内容,只能拿到File对象的元信息(文件名、大小、类型)。要展示预览图,必须借助FileReader这个浏览器原生API把文件内容异步读出来。最常用的是readAsDataURL方法,它会把文件读成带MIME前缀的base64字符串,可以直接塞进img标签的src属性里渲染。
先看一个最基础的单文件读取例子,理解整个流程:
function readImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result); // base64字符串
reader.onerror = (e) => reject(reader.error);
reader.readAsDataURL(file);
});
}
把这段逻辑放进React组件,配合<input type="file">的multiple属性就能实现多图选择。需要注意input的value在同一文件重复选择时不会触发change事件,每次处理完要手动清空value,否则用户第二次选同一张图会没反应。下面是组件的核心结构:
import { useState } from 'react';
function ImageUploader() {
const [images, setImages] = useState([]);
const handleChange = async (e) => {
const files = Array.from(e.target.files);
const results = await Promise.all(files.map(readImage));
setImages((prev) => [...prev, ...results]);
e.target.value = ''; // 允许重复选择同一文件
};
return (
<div>
<input type="file" accept="image/*" multiple onChange={handleChange} />
{images.map((src, i) => (
<img key={i} src={src} style={{ width: 120, margin: 4 }} />
))}
</div>
);
}
这里有一个取舍要说明:除了FileReader,还可以用URL.createObjectURL(file)生成一个blob URL来做预览。后者速度快得多,因为不需要真正读取文件内容、不用做base64编码,只是生成一个指向文件的临时引用。但blob URL的生命周期需要手动管理,组件卸载时要调用URL.revokeObjectURL释放,否则会造成内存泄漏。如果只是纯预览,blob URL是更好的选择;如果后续要在canvas里做压缩处理,base64用起来更直接,两种方式各有适用场景。
二、canvas实现图片压缩:等比缩放是核心
压缩的基本思路是:先用Image对象把图片解码成位图,画到canvas上缩放,再用canvas.toDataURL或toBlob导出压缩后的结果。关键参数是最大宽高和质量系数,等比缩放保证图片不变形。
function compressImage(file, maxWidth = 1000, quality = 0.8) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
// 等比缩放计算
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
resolve(new File([blob], file.name, { type: 'image/jpeg' }));
}, 'image/jpeg', quality);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
几个容易被忽视的点值得展开讲讲。第一,toBlob比toDataURL更适合上传场景,toBlob返回的是二进制Blob对象,体积比base64字符串小25%左右,直接塞进FormData即可,省去了base64解码的步骤。第二,导出格式建议统一为image/jpeg,PNG原图转JPEG能显著减小体积,除非业务明确需要透明通道。第三,quality系数并非越小越好,经验值在0.7到0.85之间,低于0.6画质损失肉眼可见,高于0.9基本没有体积收益。
还有一个移动端经典问题:iPhone拍出的照片方向信息记录在EXIF里,直接画到canvas上可能出现横竖颠倒。现代浏览器支持createImageBitmap并带有imageOrientation选项,可以一步解决:
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
// 用bitmap代替Image对象绘制到canvas即可自动修正方向
三、封装成可复用的React上传组件
把读取、压缩、预览、删除整合起来,才是一个能直接落地的组件。完整实现要考虑状态管理、压缩并发、错误处理这几个方面。下面给出一个结构清晰的完整版本:
import { useState } from 'react';
function Uploader({ maxCount = 9, onUpload }) {
const [items, setItems] = useState([]); // {id, preview, file}
const handleChange = async (e) => {
const files = Array.from(e.target.files).slice(0, maxCount - items.length);
const compressed = await Promise.all(
files.filter(f => f.type.startsWith('image/'))
.map(async (f) => {
const compressedFile = await compressImage(f);
const preview = URL.createObjectURL(compressedFile);
return { id: Date.now() + Math.random(), preview, file: compressedFile };
})
);
setItems((prev) => [...prev, ...compressed]);
e.target.value = '';
onUpload && onUpload(compressed.map((c) => c.file));
};
const remove = (id) => {
setItems((prev) => {
const target = prev.find((p) => p.id === id);
if (target) URL.revokeObjectURL(target.preview); // 释放内存
return prev.filter((p) => p.id !== id);
});
};
return (
<div>
{items.map(({ id, preview }) => (
<div key={id} style={{ position: 'relative', display: 'inline-block' }}>
<img src={preview} width={110} height={110} style={{ objectFit: 'cover' }} />
<button onClick={() => remove(id)} style={{ position: 'absolute', top: 0, right: 0 }}>
删除
</button>
</div>
))}
{items.length < maxCount && (
<label>
+ 添加图片
<input type="file" hidden multiple accept="image/*" onChange={handleChange} />
</label>
)}
</div>
);
}
这个组件有几个设计细节。用label包裹hidden的input,可以完全自定义触发按钮的样式,比直接美化原生input灵活得多。预览采用blob URL方案并在删除时revoke,避免长列表场景下的内存膨胀。压缩放在选择阶段而不是上传前,用户在预览时看到的就是最终上传的图,所见即所得。
如果对上传体验有更高要求,还可以在此基础上加进度条(用axios的onUploadProgress)、失败重试、图片数量和单文件大小校验、拖拽排序等功能。对于超大图片批量处理的场景,建议把压缩逻辑放到Web Worker里执行,避免主线程被canvas绘制卡住导致页面掉帧。这一套方案在后台管理系统和移动端H5里都经过大量验证,直接复用即可稳定运行。
React多图上传FileReader图片压缩修改时间:2026-09-08 01:06:34