React useDropZone Hook:搭建一个文件拖放区(2026)

在一个内部有子元素(一个图标、一段文字、一张预览缩略图)的拖放区上拖动文件,手写的 isOver 布尔值会开始闪烁:truefalsetruefalse,因为光标从容器边界穿到子元素上又穿回来。文件悬停在拖放区上的整段时间里,高亮边框都在闪。这不是事件处理逻辑写错了,问题出在 dragenterdragleave 到底是在什么元素上触发的。

@reactuses/coreuseDropZone 把一个 DOM 元素变成文件拖放目标,且完全没有这种闪烁,还附带一个直接把拖入的 File[] 交给你的回调。它的内部实现短到可以一口气读完,所以这篇文章会走一遍它修复的真实 bug 和真实 API,TypeScript 优先。

手写版本,以及它在哪里散架

最直觉的第一次尝试是把 dragenter/dragleave 配上一个布尔值:

tsx 复制代码
function DropZone() {
  const [isOver, setIsOver] = useState(false);

  return (
    <div
      onDragEnter={() => setIsOver(true)}
      onDragLeave={() => setIsOver(false)}
      onDragOver={(e) => e.preventDefault()}
      onDrop={(e) => {
        e.preventDefault();
        setIsOver(false);
        console.log(e.dataTransfer.files);
      }}
      style={{ border: isOver ? "2px solid blue" : "2px dashed gray" }}
    >
      <span>把文件拖到这里</span>
    </div>
  );
}

看起来没问题,但只要拖放区里有一个子节点,它就立刻散架了。这里有个不那么直观的关键点:dragenterdragleave 是在指针当前所在的那个元素上触发的,而且会冒泡 。把文件拖过上面那个 div 里的 <span>,浏览器会依次触发:div 上的 dragenter,span 上的 dragenter(冒泡到 div,对这个处理函数是空操作),指针跨到 span 上时 div 的 dragleave,然后指针再次进入时 span 的 dragenter。中间那个 dragleave 触发了你的 setIsOver(false),尽管光标根本没有离开拖放区------它只是跨过了一个子元素的边界。只要文件悬停在任何嵌套内容上方,边框就会一直闪。

新手还容易搞错另外两件事:

  • dragover 上漏调用 preventDefault()drop 事件就永远不会触发。 浏览器对 dragover 的默认动作是"拒绝这次放置"------不调用 preventDefault,就没有 drop 事件,没有例外。
  • 放下的内容不是一个普通数组。 event.dataTransfer.filesFileList,不是 File[]------转换之前不能 .map、不能 .filter

useDropZone------用一个计数器修好闪烁

tsx 复制代码
import { useRef } from 'react';
import { useDropZone } from '@reactuses/core';

function DropZone() {
  const ref = useRef<HTMLDivElement>(null);
  const isOver = useDropZone(ref, (files) => {
    console.log(files); // File[] | null
  });

  return (
    <div ref={ref} style={{ border: isOver ? "2px solid blue" : "2px dashed gray" }}>
      <span>把文件拖到这里</span>
    </div>
  );
}

签名:

ts 复制代码
function useDropZone(
  target: BasicTarget<EventTarget>,
  onDrop?: (files: File[] | null) => void
): boolean;

嵌套 bug 的修复方式是一个普通整数,不是防抖,也不是检查 relatedTarget------真实实现短到可以完整贴出来:

ts 复制代码
const counter = useRef(0);

useEventListener('dragenter', (e) => {
  e.preventDefault();
  counter.current += 1;
  setOver(true);
}, target);

useEventListener('dragleave', (e) => {
  e.preventDefault();
  counter.current -= 1;
  if (counter.current === 0) setOver(false);
}, target);

useEventListener('drop', (e: DragEvent) => {
  e.preventDefault();
  counter.current = 0;
  setOver(false);
  const files = Array.from(e.dataTransfer?.files ?? []);
  onDrop?.(files.length === 0 ? null : files);
}, target);

每一次 dragenter------不管是容器还是子元素------都让计数器加一;每一次 dragleave 都让它减一。isOver 只有在计数器归零时才会变回 false,而这恰好发生在指针真正离开了整棵子树(包括容器本身)的那一刻。进入一个子元素会先减一再立刻加一(净值为正),所以悬停期间计数永远不会跌到零。这和解决 mouseenter/mouseleave 链式冒泡问题用的是同一种技巧,只是套用在拖拽事件上。四个事件的 preventDefault() 都已经替你调用好了,所以 drop 能可靠触发,浏览器也不会尝试跳转去打开被拖入的文件。

onDrop 到底给了你什么

  • target 接受任意 EventTarget ,不只是 HTMLElement------它是一个 ref,通过库里其他 DOM hook 共用的同一个 useEventListener 接线,监听器随组件生命周期自动挂载和卸载。
  • 回调收到的是已经转换好的 File[] ,从原始 FileList 转来------调用处不需要再写一次 Array.from
  • 拖入的不是文件------比如一个链接、一段选中文字------回调会传入 null ,而不是空数组。用之前先判断 null,不要想当然地假设 files[0] 存在。
  • 一次 drop 会无条件把计数器重置为 0 即便浏览器的 dragenter/dragleave 记账因为某种原因漂移了,每次完成的 drop 都会让下一轮悬停从干净状态开始。

搭配 useFileDialog:补上可访问性缺口

拖放有一个真实的缺口:它只支持鼠标和触屏。"从桌面拖一个文件"没有键盘等价物,所以一个只有 拖放功能的区域,对键盘用户和屏幕阅读器用户来说是不可用的。修法是加一个可见的"或浏览文件"兜底,useFileDialog 正是为这种搭配而生------它不需要隐藏的 <input> 就能打开原生文件选择器:

tsx 复制代码
import { useRef } from 'react';
import { useDropZone, useFileDialog } from '@reactuses/core';

function Uploader({ onFiles }: { onFiles: (files: File[]) => void }) {
  const ref = useRef<HTMLDivElement>(null);
  const isOver = useDropZone(ref, (files) => files && onFiles(files));
  const [dialogFiles, open] = useFileDialog({ accept: 'image/*' });

  return (
    <div ref={ref} style={{ border: isOver ? "2px solid blue" : "2px dashed gray" }}>
      <p>把文件拖到这里,或者</p>
      <button onClick={() => open()}>浏览文件</button>
    </div>
  );
}

同一个视觉拖放目标,再加一个键盘用户能 tab 到并激活的真正 <button>------两条路径通向同一个上传流程。

真实使用场景

  • 自定义样式的上传组件。 默认的 <input type="file"> 在大多数浏览器里几乎没法重新设计样式;useDropZone + useFileDialog 的组合能让拖放目标的外观完全可控,同时保留拖拽和点击两条路径。
  • 图片画廊和媒体管理器。 把一批图片拖到画廊网格上,把每个 File 喂给 useObjectUrl,在任何上传请求完成之前就拿到即时的 blob: 预览地址。
  • CMS 和表单构建器的拖放目标。 接受拖入资源的编辑器(一个主图字段、一个文档附件槽位)正好需要这种不闪烁的 isOver 状态,才能渲染出让人信服的"拖到这里"高亮。
  • 多拖放区上传器。 因为 target 只是一个 ref,同一个 hook 分别绑定不同的 ref,就能让每个拖放区(比如一个表单里的"封面图"和"画廊图片")拥有各自独立、正确隔离的悬停状态。

SSR 安全性

useDropZone 在顶层从不触碰 documentwindow------四个监听器全部通过 useEventListener 挂载,而它只在组件已经在客户端挂载之后的 effect 里运行。在服务端,isOver 就渲染成它的初始值 false,此时也还没有 DOM 可以挂载监听器,所以完全没有需要防范的 hydration 不匹配------不像那些读取"服务端和客户端可能不一致的值"(比如 cookie、localStorage)的 hook。这里不需要额外配置任何东西。

要点回顾

  • 嵌套闪烁是一个冒泡问题,不是逻辑 bug ------朴素的布尔值切换,只要拖放区里有任何子元素就会立刻出问题。useDropZone 用一个 enter/leave 计数器修复它,只有指针真正离开整棵子树时才会归零。
  • dragover 上的 preventDefault() 没有商量余地 ------漏调用,drop 就永远不会触发。hook 已经替你在全部四个事件上调用好了。
  • onDrop 给你的是真正的 File[](或 null ------已经从原始 FileList 转换好,null 用来区分"没有拖入任何文件"和"拖入了零个文件"这两种情况。
  • 单靠拖放会把键盘用户挡在门外 ------搭配 useFileDialog,让同一个上传流程始终有一条可点击、可 tab 到的路径。
  • 不需要任何 SSR 仪式------这个 hook 在客户端挂载之前什么都不会附加,所以完全不用为服务端/客户端不一致而设计。

@reactuses/core 拿来用,别再手动调试拖拽事件的冒泡问题了。

相关推荐
思码梁田2 小时前
CSS 定位详解:从相对到固定,掌握网页布局的核心
前端·javascript·css
软件开发技术深度爱好者2 小时前
HTML5实现数学函数画图器
前端·javascript·html5
蜡台2 小时前
JS 浏览器 / App WebView 检测工具
开发语言·javascript·ecmascript
taocarts_bidfans2 小时前
Taoify 站点访问地区限制与 IP 管控配置
前端·javascript·tcp/ip·taoify
今日无bug3 小时前
深入理解 JavaScript 变量提升(Hoisting)
javascript·面试
大猫会长3 小时前
获取favicon.ico的方法
前端·javascript·html
无人生还3 小时前
从 Vue3 到 React · 快速上手系列第 5 篇:事件处理与表单(受控组件 vs v-model)
前端·vue.js·react.js
默_笙3 小时前
🎀 大模型说"呀"还是"吗"?temperature 和 Top K 在偷偷控制它的"性格"
前端·javascript
乌夷3 小时前
vue知识点
前端·javascript·vue.js