前端知库

怎样实现拖拽上传图片或文件?

在放置区域监听 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 在拖动经过时会不断触发,所以高亮样式在这里加,离开或放下时去掉。

JavaScript
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 构造几个假文件就能运行。

JavaScript
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(),得到文件或目录的入口对象,再递归读取:

JavaScript
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 读到文件:

JavaScript
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。