怎样实现拖拽上传图片或文件?
在放置区域监听 dragover 并调用 preventDefault,元素才会成为有效的放置目标、drop 事件才会触发;drop 里也要 preventDefault,防止浏览器直接打开文件,然后从 event.dataTransfer.files 取出文件。拖入文件夹要用 webkitGetAsEntry 递归读取。文件统一经过类型、大小、数量校验再上传;粘贴上传从 paste 事件的 clipboardData.files 取文件,同样走这套流程。
简答
用户从桌面把几张图片拖到网页的虚线框里,松手后就开始上传。实现时要在这个区域监听
dragover和drop。dragover里必须调用event.preventDefault(),否则这个元素不算放置目标,drop根本不会触发。drop里也要preventDefault(),然后从event.dataTransfer.files拿到文件列表。为什么还要在
window上处理?因为文件没拖准、落在框外时,浏览器的默认行为是直接打开或下载这个文件,页面就被替换了。MDN 的做法是在window上也监听dragover和drop,拖的是文件时取消默认行为。拖进来的文件、点击选择的文件和粘贴的文件,都应该走同一套校验:类型是否允许、单个文件是否超大、总数是否超限,通过的进入上传队列,不通过的告诉用户原因。粘贴上传就是监听
paste事件,从event.clipboardData.files取文件。要注意几点。
dataTransfer.files只能在drop里读到,dragover里是空的;拖入文件夹时files拿不到里面的文件,要在drop里用webkitGetAsEntry()递归读取;file.type是按扩展名猜的,前端校验只用来提示,服务端必须重新校验。
让区域可以接收文件
下面是最小的拖拽区域。dragover 在拖动经过时会不断触发,所以高亮样式在这里加,离开或放下时去掉。
const dropZone = document.querySelector('#drop-zone');
function hasFiles(event) {
return [...event.dataTransfer.items].some((item) => item.kind === 'file');
}
dropZone.addEventListener('dragover', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
dropZone.classList.add('active');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('active'));
dropZone.addEventListener('drop', (event) => {
event.preventDefault();
dropZone.classList.remove('active');
handleFiles(event.dataTransfer.files);
});
// 文件落在区域外时,阻止浏览器直接打开文件。
window.addEventListener('dragover', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
if (!dropZone.contains(event.target)) event.dataTransfer.dropEffect = 'none';
});
window.addEventListener('drop', (event) => {
if (hasFiles(event)) event.preventDefault();
});
为什么 dragover 里判断用的是 items,而不是 files?因为拖动过程中浏览器不让页面读取文件内容。MDN 写明,dataTransfer.files 只有在 dragstart 和 drop 的处理函数里才能读到,在其他拖放事件里访问得到的是空列表。items 里每一项的 kind 和 type 在拖动过程中是可以看的,所以先用它判断“拖的是不是文件”。
dropEffect 控制鼠标指针的样式,设成 copy 会显示加号,设成 none 表示这里不能放。框外设成 none,用户一眼就知道要拖进框里。
高亮样式还有一个坑:鼠标从区域移到区域内的子元素(比如里面的图标、文字)时,区域本身也会收到 dragleave,高亮会闪一下。常见办法是给子元素加 pointer-events: none,或者用一个计数器在 dragenter 时加一、dragleave 时减一,减到 0 才去掉高亮。另外 MDN 提到,从操作系统拖入文件时不会触发 dragstart 和 dragend,要检测文件拖入拖出,用 dragenter 和 dragleave。
统一校验:类型、大小、数量
拖拽、点击选择、粘贴拿到的都是 File 列表,交给同一个校验函数。下面的函数是纯逻辑,在 Node 里用 File 构造几个假文件就能运行。
function validateFiles(files, { accept, maxSize, maxCount }) {
const accepted = [];
const rejected = [];
for (const file of files) {
if (accepted.length >= maxCount) {
rejected.push(`${file.name}:超过 ${maxCount} 个`);
} else if (!accept.some((type) => file.type === type)) {
rejected.push(`${file.name}:类型不支持`);
} else if (file.size > maxSize) {
rejected.push(`${file.name}:超过大小限制`);
} else {
accepted.push(file);
}
}
return { accepted, rejected };
}
const files = [
new File(['a'.repeat(10)], 'cat.png', { type: 'image/png' }),
new File(['b'.repeat(10)], 'notes.txt', { type: 'text/plain' }),
new File(['c'.repeat(2000)], 'big.jpg', { type: 'image/jpeg' }),
new File(['d'.repeat(10)], 'dog.jpg', { type: 'image/jpeg' }),
new File(['e'.repeat(10)], 'bird.png', { type: 'image/png' }),
];
const result = validateFiles(files, {
accept: ['image/png', 'image/jpeg'],
maxSize: 1024,
maxCount: 2,
});
console.log(result.accepted.map((file) => file.name).join(',')); // cat.png,dog.jpg
console.log(result.rejected.join(' | ')); // notes.txt:类型不支持 | big.jpg:超过大小限制 | bird.png:超过 2 个
5 个文件里,notes.txt 类型不对,big.jpg 有 2000 字节,超过 1024 的限制,cat.png 和 dog.jpg 通过。bird.png 本身没问题,但已经收满 2 个,所以被拒绝。把被拒绝的原因一起返回,界面上就能逐条告诉用户为什么没传上。
为什么数量要按“已接受的个数”算,而不是直接截取前两个?因为前面有不合格的文件,直接截取会把合格的挤掉。
file.type 能信吗?MDN 写明,浏览器不会读取文件内容来判断类型,而是根据扩展名推断,把 PNG 改名成 .txt,得到的就是 text/plain;不常见的扩展名还会得到空字符串。MDN 也建议不要把它当作唯一的校验手段。所以前端校验只为了尽早提示用户,服务端必须按文件内容重新判断。
拖入的是文件夹怎么办
用户拖进来一个文件夹时,dataTransfer.files 里拿不到文件夹里的文件。要在 drop 里对每一项调用 webkitGetAsEntry(),得到文件或目录的入口对象,再递归读取:
async function collectFiles(entry) {
if (entry.isFile) {
return [await new Promise((resolve, reject) => entry.file(resolve, reject))];
}
const reader = entry.createReader();
const result = [];
while (true) {
const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
if (batch.length === 0) break;
for (const child of batch) result.push(...(await collectFiles(child)));
}
return result;
}
dropZone.addEventListener('drop', async (event) => {
event.preventDefault();
// 入口对象必须在 drop 事件同步执行期间取出,await 之后数据就读不到了。
const entries = [...event.dataTransfer.items]
.map((item) => item.webkitGetAsEntry())
.filter(Boolean);
const nested = await Promise.all(entries.map(collectFiles));
handleFiles(nested.flat());
});
为什么 readEntries 要放在循环里反复调用?因为它是分批返回的。MDN 写明,Chromium 系浏览器一次最多只返回 100 项,要拿全就必须在同一个 reader 上反复调用,直到返回空数组为止。只调用一次,文件多的文件夹就会漏文件。
为什么要先把入口对象全部取出来,再去 await?MDN 写明,webkitGetAsEntry() 只有在 dragstart 和 drop 的处理函数里才能读取数据,在别的时机调用会返回 null。处理函数遇到第一个 await 就返回了,之后再调用就拿不到,所以要在同步部分先取完。这个方法目前带着 webkit 前缀,Firefox 也用这个名字实现,MDN 提醒将来可能改名为 getAsEntry()。
粘贴上传
用户截图后直接 Ctrl+V,也是常见需求。粘贴事件的 clipboardData 和拖放用的是同一种 DataTransfer 对象,MDN 写明粘贴时可以在 paste 处理函数里通过 clipboardData 读到文件:
document.addEventListener('paste', (event) => {
const files = event.clipboardData?.files;
if (!files || files.length === 0) return;
event.preventDefault();
handleFiles(files);
});
没有文件时直接返回,不调用 preventDefault(),这样在输入框里粘贴文字仍然正常。拿到的文件交给同一个 handleFiles,所以校验、预览、上传都不用再写一遍。
和上传流程怎样衔接
handleFiles 校验通过以后,给每个文件生成本地预览,放进上传队列,限制同时上传的个数,并显示每个文件的进度和失败重试。预览的写法见《图片上传前怎样本地预览?FileReader 和 URL.createObjectURL 有什么区别?》;单个文件很大时要分片上传,见《大文件上传怎样设计分片、断点续传、秒传和并发控制?》。
拖拽区域也要保留一个 <input type="file" multiple>,点击区域就能打开文件选择框。只靠拖拽,键盘用户和触屏用户就没法上传。在页面里拖动元素排序是另一回事,见《怎样实现拖拽排序?HTML5 拖放 API 和 Pointer 事件怎么选?》。
常见追问
为什么我的 drop 事件不触发?
最常见的原因是
dragover里没有调用preventDefault()。MDN 写明,元素必须取消dragover事件,drop事件才会触发。
拖拽区域能限制只接受图片吗?
可以在
dragover里检查dataTransfer.items每一项的type,不是图片就把dropEffect设成none,用户会看到禁止的指针。但这只是提示,放下后的校验和服务端校验仍然要做。
拖拽上传还要保留点击选择吗?
要。不是所有用户都能拖拽,比如只用键盘的用户、很多触屏设备上的用户。把
<input type="file">放在区域里,点击区域就能选择文件,选出来的文件同样交给handleFiles。