React 文件上传实战:受控/非受控选择、预览 URL 回收、多文件与拖拽放置区
文件上传看着简单,一个 <input type="file"> 而已,可真写起来到处是坑:预览图内存越用越多、选同一个文件第二次不触发、多文件上传进度只能看总量、拖拽区域松手时浏览器直接把文件当新页面打开了。这篇把这些坑一个个填平,最后给你一个能直接抄进项目的上传组件。
第一个坑:file input 到底是受控还是非受控
先说结论:<input type="file"> 只能是非受控的 。你没法给它设 value(出于安全,浏览器不允许 JS 把一个文件路径塞进去),所以别想着用 useState 双向绑定它。
正确姿势是监听 onChange,自己把 File 对象存进 state:
jsx
import { useState } from "react";
function SimpleUpload() {
const [file, setFile] = useState(null);
function handleChange(e) {
// e.target.files 是 FileList,不是数组
const picked = e.target.files?.[0];
if (picked) setFile(picked);
}
return (
<div>
<input type="file" accept="image/*" onChange={handleChange} />
{file && <p>已选:{file.name}({(file.size / 1024).toFixed(1)} KB)</p>}
</div>
);
}
注意 e.target.files 是一个类数组的 FileList,想遍历得先 Array.from(e.target.files)。
第二个坑:选同一个文件第二次,onChange 不触发
用户选了 a.png,发现选错了想重新选还是 a.png(比如刚在本地改了图),结果 onChange 一声不吭。因为 input 的 value 没变化,浏览器认为「没变」就不触发事件。
解法:每次处理完把 input 的 value 清空,这样下次哪怕选同一个文件也算「从空变成有」:
jsx
function handleChange(e) {
const picked = e.target.files?.[0];
if (picked) setFile(picked);
// 关键:清空,保证下次选同一个文件也触发 onChange
e.target.value = "";
}
第三个坑:预览图会漏内存
要显示本地预览,常见做法是 URL.createObjectURL(file)。但很多人只创建、不释放,选十几张图后 blob 一直挂在内存里。createObjectURL 生成的 URL 会一直占用内存直到你手动 revokeObjectURL 或页面卸载。
正确做法:在 useEffect 里创建,并在清理函数里回收:
jsx
import { useState, useEffect } from "react";
function ImagePreview({ file }) {
const [url, setUrl] = useState("");
useEffect(() => {
if (!file) return;
const objectUrl = URL.createObjectURL(file);
setUrl(objectUrl);
// 清理:file 变了或组件卸载时,回收上一个 URL,避免内存泄漏
return () => URL.revokeObjectURL(objectUrl);
}, [file]);
if (!file) return null;
return <img src={url} alt="预览" style={{ maxWidth: 200 }} />;
}
千万别在 render 里直接 src={URL.createObjectURL(file)} ------ 每次重渲染都会新建一个 URL,旧的全泄漏。
第四个坑:上传进度用 fetch 拿不到
fetch 到今天也没有靠谱的上传进度回调。想要进度条,老老实实用 XMLHttpRequest,它的 upload.onprogress 才给你上传字节数:
jsx
function uploadWithProgress(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append("file", file);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () =>
xhr.status < 400 ? resolve(xhr.responseText) : reject(new Error(xhr.status));
xhr.onerror = () => reject(new Error("network error"));
xhr.open("POST", "/api/upload");
xhr.send(form);
});
}
多文件想显示每个文件各自的进度,就给每个 file 存一条独立进度,别只存一个总数。
第五个坑:拖拽放置区,松手直接打开了文件
不加处理的话,把文件拖进浏览器松手,浏览器会直接导航去打开这个文件 ,你的页面就没了。必须在 onDragOver 和 onDrop 里都 preventDefault(),缺一不可:
jsx
import { useState } from "react";
function DropZone({ onFiles }) {
const [dragging, setDragging] = useState(false);
function handleDrop(e) {
e.preventDefault(); // 必须,否则浏览器打开文件
setDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length) onFiles(files);
}
return (
<div
onDragOver={(e) => {
e.preventDefault(); // 必须,否则 drop 不会触发
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
style={{
border: `2px dashed ${dragging ? "#4f8cff" : "#bbb"}`,
padding: 40,
textAlign: "center",
color: "#666",
}}
>
{dragging ? "松手上传" : "把文件拖到这里,或点击选择"}
</div>
);
}
onDragOver 里的 preventDefault 尤其容易漏 ------ 少了它,onDrop 根本不会触发,你会一脸懵为什么松手没反应。
组装:多文件 + 预览 + 逐个进度
把上面几块拼起来,得到一个能用的上传器。核心是用一个数组管理每个文件的 { file, progress, status }:
jsx
import { useState } from "react";
function Uploader() {
const [items, setItems] = useState([]); // { file, progress, status }
function addFiles(files) {
setItems((prev) => [
...prev,
...files.map((file) => ({ file, progress: 0, status: "ready" })),
]);
}
function updateItem(index, patch) {
setItems((prev) =>
prev.map((it, i) => (i === index ? { ...it, ...patch } : it))
);
}
async function startUpload() {
for (let i = 0; i < items.length; i++) {
if (items[i].status !== "ready") continue;
updateItem(i, { status: "uploading" });
try {
await uploadWithProgress(items[i].file, (p) =>
updateItem(i, { progress: p })
);
updateItem(i, { status: "done", progress: 100 });
} catch {
updateItem(i, { status: "error" });
}
}
}
return (
<div>
<DropZone onFiles={addFiles} />
<input
type="file"
multiple
onChange={(e) => {
addFiles(Array.from(e.target.files));
e.target.value = ""; // 清空,支持重复选同一批
}}
/>
<ul>
{items.map((it, i) => (
<li key={i}>
{it.file.name} --- {it.status} {it.progress}%
</li>
))}
</ul>
<button onClick={startUpload}>开始上传</button>
</div>
);
}
这里 key 用 index 是因为列表只追加、不做中间删除排序;如果你要支持删除某一项,请改用文件的唯一标识(比如 file.name + file.lastModified)当 key,否则删除后进度会错位。
小结
- file input 天生非受控 ,别绑
value,监听onChange把File存进 state。 - 选同一文件第二次不触发?处理完
e.target.value = ""清空。 - 预览用
URL.createObjectURL,必须在 useEffect 清理里revokeObjectURL,否则内存越用越多;别在 render 里直接创建。 - 要上传进度用
XMLHttpRequest的upload.onprogress,fetch拿不到。 - 拖拽区
onDragOver和onDrop都要preventDefault,漏了 dragover 那个,drop 根本不触发。 - 多文件为每个文件存独立的
{progress, status},别只存一个总进度。
一句话记忆点:上传的坑八成在「浏览器默认行为」和「blob 内存」上 ------ 记得清 value、回收 URL、拦默认导航,组件就稳了。