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

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

不加处理的话,把文件拖进浏览器松手,浏览器会直接导航去打开这个文件 ,你的页面就没了。必须在 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、拦默认导航,组件就稳了。

相关推荐
小码哥_常3 小时前
一套自己部署的 IM,客户端是同一份 Flutter
前端·后端
蜗牛攻城狮5 小时前
微前端四大方案选型对比与最佳实践:qiankun、micro-app、wujie、Module Federation
前端·微前端·qiankun·前端架构·micro-app·wujie
她说可以呀6 小时前
Vue插槽
前端·javascript·vue.js
qq_448011167 小时前
Vue + Jinja2两套模版与法冲突造成undefinedError
前端·javascript·vue.js
aixingpan7 小时前
aixingpan.cn API开发文档:前端查询夏令时和时区接口指南
前端·php
2601_958352907 小时前
23.5×19mm 换掉一整条音频链?|A-68 集成了什么:ADC / DAC / 24.576MHz 时钟 / AEC / 波束降噪,主控仅需 6 根线
前端·音视频·ai降噪·回声消除·语音模块
志尊宝8 小时前
AI原生前端开发(039):AI辅助的Nuxt 4全栈开发
前端·vue.js·ai-native
FRing039 小时前
JavaScript入门到精通三十二(深拷贝、浅拷贝、递归函数)
开发语言·前端·javascript
excel9 小时前
双 Token 与前端使用 Crypto 公私钥鉴权换 Token,哪种方案更好?
前端·后端
外收内放9 小时前
Python基础语法练习题(73)
java·前端·python