在一个内部有子元素(一个图标、一段文字、一张预览缩略图)的拖放区上拖动文件,手写的 isOver 布尔值会开始闪烁:true、false、true、false,因为光标从容器边界穿到子元素上又穿回来。文件悬停在拖放区上的整段时间里,高亮边框都在闪。这不是事件处理逻辑写错了,问题出在 dragenter 和 dragleave 到底是在什么元素上触发的。
@reactuses/core 的 useDropZone 把一个 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>
);
}
看起来没问题,但只要拖放区里有一个子节点,它就立刻散架了。这里有个不那么直观的关键点:dragenter 和 dragleave 是在指针当前所在的那个元素上触发的,而且会冒泡 。把文件拖过上面那个 div 里的 <span>,浏览器会依次触发:div 上的 dragenter,span 上的 dragenter(冒泡到 div,对这个处理函数是空操作),指针跨到 span 上时 div 的 dragleave,然后指针再次进入时 span 的 dragenter。中间那个 dragleave 触发了你的 setIsOver(false),尽管光标根本没有离开拖放区------它只是跨过了一个子元素的边界。只要文件悬停在任何嵌套内容上方,边框就会一直闪。
新手还容易搞错另外两件事:
- 在
dragover上漏调用preventDefault(),drop事件就永远不会触发。 浏览器对dragover的默认动作是"拒绝这次放置"------不调用preventDefault,就没有drop事件,没有例外。 - 放下的内容不是一个普通数组。
event.dataTransfer.files是FileList,不是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 在顶层从不触碰 document 或 window------四个监听器全部通过 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 拿来用,别再手动调试拖拽事件的冒泡问题了。