Glooory

浏览器里如何判断用户拖入了一个空文件夹

最近在做一个类似网盘的功能,看了下类似的网站才发现原来在浏览器里可以选/拖文件夹来上传整个文件夹。

查了下文档发现是基于被现代浏览器广泛实现但不是标准的接口(webkitdirectory 属性)来实现的:

<input type="file" webkitdirectory multiple />

如果是 React 项目可能需要改一下写法:

declare module 'react' {
  interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
    directory?: string;
    webkitdirectory?: string;
  }
}
 
 
<input type="file" webkitdirectory="" onChange={e => console.log(e.target.files)} />

所以其实实现选/拖文件夹并不难,给 input 标签加上一个 webkitdirectory 属性即可,然后就可以通过 onChange 回调拿到文件夹里面的所有文件。每个文件的路径信息(webkitRelativePath)也是可以拿到的:

// console.log(e.target.files)
 
[
  {
    "name": "111.pdf",
    "webkitRelativePath": "AAA/111.pdf",
    "size": 13163,
    "type": "application/pdf"
    ...
  },
  {
    "name": "444.pdf",
    "webkitRelativePath": "AAA/CCC/DDD/444.pdf",
    "size": 45382,
    "type": "application/pdf"
    ...
  }
]

拿到所有的文件数据应该就好办了,是不是很简单?

但是真正让我扣脑袋的是一个看上去很不起眼的需求:如果用户选/拖的是一个空文件夹则需要提醒一下用户。

如果是鼠标点击按钮选择文件夹的情况,其实每次浏览器有自带的提示框提示(有多少个文件):

+--------------------------------------------------------------+
| 将 0 个文件上传到此网站?                                        |
|                                                              |
| 此操作会上传“XXX”下的所有文件。请仅在你信任该网站的情况下执行此操作。   |
|                                                              |
|                                                              |
|                                       [取消]      [上传]      |
+--------------------------------------------------------------+

难点在于如果是拖入的一个空文件夹,根本就不会触发 input 标签的 onChange 回调,所以拖入的情况依靠 onChange 回调来实现提示不可能,只能换其他的方式。
既然拖入空文件夹时通过 onChange 回调实现不了,那 onDrop 回调应该能判断拖入的是一个空文件夹吧?

试一下就知道:

<input
  type="file"
  webkitdirectory=""
  onChange={(e) => console.log(e.target.files)}
  onDrop={(e) => console.log(e.dataTransfer.files)}
/>

下面是拖入一个空文件夹 onDrop 回调的打印结果:

[
  {
    "name": "EEE",
    "size": 64,
    "[[Prototype]]": {
      "type": ""
    }
    ...
  }
]

下面是拖入一个文件 onDrop 回调的打印结果:

[
  {
    "name": "333.pdf",
    "size": 1516414,
    "[[Prototype]]": {
      "type": "application/pdf"
    }
    ...
  }
]

看上面的两种结果貌似没法区分拖入的是 一个空文件夹 还是 一个文件(靠 namesizetype 属性判断不靠谱),它们都是 File 对象,区分不出来是文件还是文件夹。
所以现在解决问题的关键就是怎么区分文件和文件夹了,好消息是可以通过 onDrop 回调参数的 event.dataTransfer.items 里面的 webkitGetAsEntry 函数结果来区分。
如果是文件夹的话还需要使用 readEntries 函数递归看看它里面是否有任何文件,如果遍历完不包含任何文件则是空文件夹,至此我们实现需求的思路也基本清晰明朗了。详细的代码片段参考:

const UploadDirectoryDemo = () => {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log("onChange files:", e.target.files);
    // 拿到文件数据,开始上传...
  };
 
  const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
    // 如果 preventDefault 了的话,不会再触发 onChange,这里不用 preventDefault 以延用 onChange 里面的逻辑,
    // e.preventDefault();
 
    const items = e.dataTransfer.items;
 
    const checkIfDirectoryHasFiles = (
      directoryEntry: FileSystemEntry & {
        createReader?: () => FileSystemDirectoryReader;
      }
    ): Promise<boolean> => {
      return new Promise((resolve, reject) => {
        let pendingDirectories = 0;
 
        const readEntries = (
          entry: FileSystemEntry & {
            createReader?: () => FileSystemDirectoryReader;
          }
        ) => {
          pendingDirectories++;
 
          const reader = entry?.createReader?.();
          reader?.readEntries((entries: FileSystemEntry[]) => {
            const fileEntries: FileSystemEntry[] = [];
            const directoryEntries: FileSystemEntry[] = [];
 
            entries.forEach((entry) => {
              if (entry.isDirectory) {
                directoryEntries.push(entry);
              } else {
                fileEntries.push(entry);
              }
            });
 
            if (fileEntries.length > 0) {
              resolve(true);
              return;
            }
 
            for (const childEntry of directoryEntries) {
              readEntries(childEntry);
            }
 
            pendingDirectories--;
 
            if (pendingDirectories === 0) {
              resolve(false);
            }
          }, reject);
        };
 
        readEntries(directoryEntry);
      });
    };
 
    Promise.all(
      Array.from(items)
        .filter((item) => !!item.webkitGetAsEntry()?.isDirectory)
        .map((item) => {
          const entry = item.webkitGetAsEntry()!;
          return checkIfDirectoryHasFiles(entry).then((hasFiles) => {
            return { name: entry.name, hasFiles };
          });
        })
    ).then((res) => {
      const emptyDirectories = res
        .filter((entry) => !entry.hasFiles)
        .map((entry) => entry.name);
      if (emptyDirectories.length) {
        // 提醒用户拖入了空文件夹
        console.log(`Empty Directory ${emptyDirectories.join(", ")} Detected!`);
      }
    });
  };
 
  return (
    <input
      multiple
      type="file"
      webkitdirectory=""
      onChange={handleChange}
      onDrop={handleDrop}
    />
  );
};

现在看,判断是否拖入了空文件夹比想象中的要复杂些,好在最后也算能实现这个需求,不过使用的都不是标准的接口,虽然目前大部分的现代浏览器都能使用,但是这确实是一个不确定的因素。

拖/选空文件夹 Live Demo ↓