React 文件上传实战:预览 URL 回收、多文件逐个进度与拖拽放置区踩坑

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 存一条独立进度,别只存一个总数。

第五个坑:拖拽放置区,松手直接打开了文件

不加处理的话,把文件拖进浏览器松手,浏览器会直接导航去打开这个文件 ,你的页面就没了。必须在 onDragOveronDrop 里都 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,监听 onChangeFile 存进 state。
  • 选同一文件第二次不触发?处理完 e.target.value = "" 清空。
  • 预览用 URL.createObjectURL,必须在 useEffect 清理里 revokeObjectURL,否则内存越用越多;别在 render 里直接创建。
  • 要上传进度用 XMLHttpRequestupload.onprogress,fetch 拿不到。
  • 拖拽区 onDragOveronDrop 都要 preventDefault,漏了 dragover 那个,drop 根本不触发。
  • 多文件为每个文件存独立的 {progress, status},别只存一个总进度。

一句话记忆点:上传的坑八成在「浏览器默认行为」和「blob 内存」上 ------ 记得清 value、回收 URL、拦默认导航,组件就稳了。

相关推荐
console.log('npc')1 小时前
06 — Model 层:数据模型与操作
前端·后端·node.js·express
linux_cfan1 小时前
16 · `custom-media-element`:属性拦截与转发
前端·javascript·音视频
右耳朵猫AI1 小时前
Web前端周刊2026W38 | React 19.3 发布、StyleX 深潜、jsdom 30.1 提速
前端·javascript·react.js·typescript·node.js
人工智能培训2 小时前
大语言模型:从语言理解到通用智能的跃迁
linux·服务器·前端·人工智能
一拳不是超人2 小时前
做独立开发三个月,我的第一个产品终于有了 1000 个用户
前端·程序员
Amos_Web2 小时前
Rspack 源码解析(十):Hash 与 Asset 生成
前端·rust·源码阅读
福兮说2 小时前
用 IndexedDB 存用户的文件,我踩过的五个坑
前端·javascript
闪耀之光M782 小时前
前端依赖自动导入:unplugin-auto-import
前端
Ricon组态薄荷糖3 小时前
Ricon组态系统:工业组件开发指南与实践
前端·后端·物联网