批量文件夹上传实现,大量文件快速读取

前言

本次开发对象云存储(OSS)运用到了文件上传,文件夹上传和拖拽上传这三个功能。开发基于VUE,但是其他框架的需要应用到的都可以应用。然后由于操作流程不一样需要自己去完成一个拖拽上传组件,但是网上查询的其他同学写的代码,2w-3w文件读取需要40s-60s 甚至更长,原因是递归方式的写法上有问题,关键是什么时候读取结束也是不可掌控的。这样的话做读取的loading状态也不方便。所以自己用了一段时间去完善上传。最终效果是2w-3w读取效果在4-6s

  1. 文件上传就是 input[file] 类型。

  1. 文件夹上传是在input[file]类型的基础上添加了webkitdirectory属性。

  1. 文件拖拽上传基于 webkitGetAsEntry + createReader原生API实现文件夹内列表的读取。

说明:优先考虑需求实现再去谈兼容,拖拽使用webkitGetAsEntry 和 createReader都有兼容性得,但是目前浏览器都在尽量的适配这些API,因为这也是前端开发常用的需求。

注意:createReader 这个API Opera目前任然不支持,webkitGetAsEntry 这个API各大版本均有支持。

实现的效果如下

一、什么场景使用拖拽上传?

  1. 如果有10个文件夹,使用原生的input[file] + webkitdirectory 去上传文件夹只能一个一个的选择。此时拖拽就派上用场了。

  1. 操作的易用性,我们都知道点击文件夹上传会发现文件夹下不展示文件列表,因为webkitdirectory 属性会过滤掉文件,只剩下文件夹,而拖拽会清晰的展示结构。如果使用input 上传会发现,上传文件就只能选择文件类型。上传文件夹就只能选择文件夹。拖拽上传不用管这些,拖什么就上传什么。

二、实现方式 - JS版本

1.HTML 部分-js

<body>
  <button class="upload_file">上传文件</button>
  <button class="upload_dir">上传文件夹</button>

  <input type="file" class="file" hidden>
  <input type="file" webkitdirectory class="dir" hidden>

  <div class="drag_upload">
    <div class="tip">拖拽上传或者点击 <button>上传文件</button></div>
  </div>
</body>

2. JS 部分

<script>
      const upload_file = document.querySelector('.upload_file')
      const upload_dir = document.querySelector('.upload_dir')
      const drag_upload = document.querySelector('.drag_upload')
      const file_input = document.querySelector('.file')
      const dir_input = document.querySelector('.dir')
      // 点击上传文件按钮
      upload_file.addEventListener('click', () => {
        file_input.click()
      })
      // 点击上传文件夹按钮
      upload_dir.addEventListener('click', () => {
        dir_input.click()
      })
    
      drag_upload.addEventListener('dragenter', dragEnter)
      drag_upload.addEventListener('dragover', dragOver)
      drag_upload.addEventListener('drop', drop)
    
      function dragEnter(e) {
        e.preventDefault()
      }
    
      function dragOver(e) {
        e.preventDefault()
      }
    
      function drop(e) {
        e.preventDefault()
        const dataTransfer = e.dataTransfer
        if (
          dataTransfer.items &&
          dataTransfer.items[0] &&
          dataTransfer.items[0].webkitGetAsEntry
        ) {
          webkitReadDataTransfer(dataTransfer)
        }
      }
    
      function webkitReadDataTransfer(dataTransfer) {
        var fileNum = dataTransfer.items.length
        var files = []
        const items = dataTransfer.items
        // 根据拖拽得数量去遍历每一项
        for (var i = 0; i < items.length; i++) {
          var entry = items[i].webkitGetAsEntry()
          if (!entry) {
            decrement()
            return
          }
          console.log(entry)
          if (entry.isFile) {
            readFiles(items[i].getAsFile(), entry.fullPath)
          } else {
            readDirectory(entry.createReader())
          }
        }
    
        function readDirectory(reader) {
          // readEntries() 方法用于检索正在读取的目录中的目录条目,并将它们以数组的形式传递给提供的回调函数。
          reader.readEntries((entries) => {
            if (entries.length) {
              fileNum += entries.length
              entries.forEach((entry) => {
                if (entry.isFile) {
                  entry.file((file) => {
                    readFiles(file, entry.fullPath)
                  }, readError)
                } else if (entry.isDirectory) {
                  readDirectory(entry.createReader())
                }
              })
    
              readDirectory(reader)
            } else {
              decrement()
            }
          }, readError)
        }
    
        function readFiles(file, fullPath) {
          file.relativePath = fullPath.substring(1)
          files.push(file)
          decrement()
        }
    
        function readError(fileError) {
          console.log(fileError)
          throw fileError
        }
    
        function decrement() {
          if (--fileNum === 0) {
            console.log(files, 123)
          }
        }
      }
</script>

注意事项

注意点:需要注我用的是HTML文件实现的。如果您也应用的是HTML,直接打开上传会报错。需要以项目方式启动。这里推荐vscode使用 Go Live 插件去启动这个html。上面这个示例只是一个小demo,input 没有注册事件,可以自行完善。可以参考下面完全版本。

三、VUE 版本 - 完全版本,也是上面示例动图的版本

<template>
  <div class="upload_demo">
    <div class="table_info">
      <div>
        <el-button @click="clearList">清空列表</el-button>
        <el-button
          @click="uploadFile('file')"
          :disabled="tableData.length >= 100"
          >上传文件</el-button
        >
        <el-button
          @click="uploadFile('folder')"
          :disabled="tableData.length >= 100"
          >上传文件夹</el-button
        >
      </div>
      <div class="info">
        <span>{{ tableData.length }}/ 100文件</span>
        <span>大小 {{ handleStorage(totalSize) }}</span>
      </div>
    </div>

    <div
      ref="drag"
      draggable="true"
      class="drag tableBox"
      @dragover="dragover"
      :class="{ drag_border: tableData.length }"
      @drop="onDrop"
    >
      <div class="el-upload__text" v-show="!tableData.length">
        <i class="el-icon-upload" style="margin-right: 6px"></i
        >拖拽文件/文件夹到此处或者
        <el-button type="text" @click="addFiles">添加文件</el-button>
      </div>
      <div v-show="!tableData.length" class="el-upload__text">
        文件上传数量不能超过100个,总大小不超过5GB
      </div>
      <el-table
        :data="tableData"
        v-show="tableData.length"
        class="table_Height"
        stripe
        height="240px"
      >
        <el-table-column prop="name" label="名称">
          <template v-slot="{ row }">
            <span v-if="row.isFolder" style="margin-right: 6px">
              <i class="el-icon-folder-opened"></i>
            </span>
            <span v-else style="margin-right: 6px">
              <i class="el-icon-document"></i>
            </span>
            <span>{{ row.name }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="size" label="文件大小" width="200px">
          <template v-slot="{ row }">
            {{ handleStorage(row.size) }}
          </template>
        </el-table-column>
        <el-table-column label="操作" width="100px">
          <template v-slot="{ row, $index }">
            <el-button
              type="text"
              :disabled="!!row.loaded"
              @click.stop="deleteFile(row, $index)"
              >删除</el-button
            >
          </template>
        </el-table-column>
      </el-table>
    </div>
    <!-- 文件读取中不能上传,文件上传数量不能超过100,总大小不能超过 5 GB -->
    <div class="flex_center" style="margin-top: 20px">
      <el-button
        type="primary"
        :loading="loading"
        :disabled="
          loading ||
          tableData.length > 100 ||
          totalSize > 5 * 1024 * 1024 * 1024
        "
        @click="uploadFileList"
        >{{ loading ? "文件读取中" : "上传" }}</el-button
      >
    </div>
    <!-- 上传文件 -->
    <input type="file" ref="file" hidden multiple @change="uploadeFile" />

    <!-- 上传文件夹-->
    <input
      type="file"
      ref="folder"
      hidden
      @change="folderChange"
      multiple
      webkitdirectory
    />
  </div>
</template>
<script>
export default {
  data() {
    return {
      tableData: [],
      uploading: false,
      time: 0,
      loading: false,
    };
  },

  computed: {
    totalSize() {
      let total = 0;
      this.tableData.forEach((item) => {
        total += item.size;
      });
      return total;
    },
  },

  methods: {
    addFiles() {
      this.$nextTick(() => {
        this.$refs["file"].click();
      });
    },

    dragover(e) {
      e.preventDefault();
    },

    onDrop(e) {
      e.preventDefault();
      const dataTransfer = e.dataTransfer;
      if (
        dataTransfer.items &&
        dataTransfer.items[0] &&
        dataTransfer.items[0].webkitGetAsEntry
      ) {
        this.webkitReadDataTransfer(dataTransfer, e);
      }
    },

    // 拖拽文件处理
    webkitReadDataTransfer(dataTransfer) {
      let fileNum = dataTransfer.items.length;
      let files = [];
      this.loading = true;

      // 递减计数,当fileNum为0,说明读取文件完毕
      const decrement = () => {
        if (--fileNum === 0) {
          this.handleFiles(files);
          this.loading = false;
        }
      };

      // 递归读取文件方法
      const readDirectory = (reader) => {
        // readEntries() 方法用于检索正在读取的目录中的目录条目,并将它们以数组的形式传递给提供的回调函数。
        reader.readEntries((entries) => {
          if (entries.length) {
            fileNum += entries.length;
            entries.forEach((entry) => {
              if (entry.isFile) {
                entry.file((file) => {
                  readFiles(file, entry.fullPath);
                }, readError);
              } else if (entry.isDirectory) {
                readDirectory(entry.createReader());
              }
            });

            readDirectory(reader);
          } else {
            decrement();
          }
        }, readError);
      };
      // 文件对象
      const items = dataTransfer.items;
      // 拖拽文件遍历读取
      for (var i = 0; i < items.length; i++) {
        var entry = items[i].webkitGetAsEntry();
        if (!entry) {
          decrement();
          return;
        }

        if (entry.isFile) {
          readFiles(items[i].getAsFile(), entry.fullPath, "file");
        } else {
          // entry.createReader() 读取目录。
          readDirectory(entry.createReader());
        }
      }

      function readFiles(file, fullPath) {
        file.relativePath = fullPath.substring(1);
        files.push(file);
        decrement();
      }
      function readError(fileError) {
        throw fileError;
      }
    },

    handleFiles(files) {
      // 按文件名称去存储列表,考虑到批量拖拽不会有同名文件出现
      const dirObj = {};
      files.forEach((item) => {
        // relativePath 和 name 一致表示上传的为文件,不一致为文件夹
        // 文件直接放入table表格中
        if (item.relativePath === item.name) {
          this.tableData.push({
            name: item.name,
            filesList: [item.file],
            isFolder: false,
            size: item.size,
          });
        }
        // 文件夹,需要处理后放在表格中
        if (item.relativePath !== item.name) {
          const filderName = item.relativePath.split("/")[0];
          if (dirObj[filderName]) {
            // 放入文件夹下的列表内
            let dirList = dirObj[filderName].filesList || [];
            dirList.push(item);
            dirObj[filderName].filesList = dirList;
            // 统计文件大小
            let dirSize = dirObj[filderName].size;
            dirObj[filderName].size = dirSize ? dirSize + item.size : item.size;
          } else {
            dirObj[filderName] = {
              filesList: [item],
              size: item.size,
            };
          }
        }
      });

      // 放入tableData
      Object.keys(dirObj).forEach((key) => {
        this.tableData.push({
          name: key,
          filesList: dirObj[key].filesList,
          isFolder: true,
          size: dirObj[key].size,
        });
      });
    },

    // input选择文件夹只能单选
    folderChange(e) {
      const filesList = e.target.files;
      let size = 0;
      const fileName = filesList[0].webkitRelativePath.split("/")[0];
      Array.from(filesList).forEach((item) => {
        item.relativePath = item.webkitRelativePath;
        size += item.size;
      });

      const fileObj = {
        name: fileName,
        filesList,
        isFolder: true,
        size,
      };

      this.tableData.push(fileObj);
    },

    deleteFile(row, index) {
      // 至于为什么不用filter,而是通过下标删除,需要考虑文件同名同样大小问题。
      // 当然通过index去删除也不是最好办法,最好办法是生成为一hash,可以通过md5去计算。大批量文件md5也比较耗费时间
      this.tableData.splice(index, index + 1);
    },

    uploadFile(ref, e) {
      if (e) e.stopPropagation();
      this.$refs[ref].click();
    },

    // 清空文件
    clearList() {
      this.tableData = [];
    },

    // 选择文档后的处理
    async uploadeFile() {
      try {
        const dom = this.$refs["file"];
        const files = dom.files;
        if (files.length > 200) {
          this.$message.warning("每次最多上传200个文件");
          return;
        }
        Array.from(files).forEach((file) => {
          this.tableData.push({
            name: file.name,
            filesList: [file],
            isFolder: false,
            size: file.size,
          });
        });
        dom.value = "";
      } catch (error) {
        console.log(error);
      }
    },

    // 文件上传,需要注意浏览器不同允许并发的数量也不同大多在4-8个区间 edeg 和 chrome 允许6个同时发送
    async uploadFileList() {},

    handleStorage(value) {
      let size = "";
      if (value / Math.pow(1024, 3) > 1024) {
        size = (value / Math.pow(1024, 4)).toFixed(2) + " TB";
      } else if (value / Math.pow(1024, 2) > 1024) {
        size = (value / Math.pow(1024, 3)).toFixed(2) + " GB";
      } else if (value / 1024 > 1024) {
        size = (value / Math.pow(1024, 2)).toFixed(2) + " MB";
      } else if (value > 1024) {
        size = (value / 1024).toFixed(2) + " KB";
      } else {
        size = value + " B";
      }
      return size;
    },
  },
};
</script>

<style lang="scss" scoped>
.upload_demo {
  width: 600px;
  margin: 100px auto;
}

.flex_center {
  display: flex;
  align-items: center;
  justify-content: center;
}

.el-upload__text {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  line-height: 20px;
  text-align: center;
  color: #999999;
}

.table_info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  .info {
    font-size: 12px;
    color: #999999;
    & span:first-child {
      margin-right: 10px;
    }
  }
}

.el-icon-upload {
  font-size: 19px;
  margin: 0;
}

.el-upload__text:first-child {
  margin-top: 40px;
}

.addFiles {
  color: #337dff;
}

.drag {
  width: 100%;
  height: 240px;
  margin-top: 10px;
  border: 2px dashed #edeff3;
}

.tableBox {
  height: 100%;
  min-height: 240px;
}

.drag_border {
  border: 2px dashed #dfe1e5;
}

.el-upload__text:first-child {
  margin-top: 80px;
}
</style>

后记

  1. 建议拖拽文件后,读取过程中触发loading 后,再次拖拽文件读取,此时注意loading状态。可以记录拖拽次数。比如拖拽第一为1。然后这个文件读取过程中,用户又拖拽了文件进来,此时+1。文件读取完成后就减一。最终这个读取次数为0时 loading 才为false。这个这个loading 读取状态时可掌控的。

  1. 拖拽上传中文件有大有小,大的需要切片,小的直接上传,所以计算md5 也是比较重要的。因为md5如果后端有记录的话,说明文件已经上传过,直接标记成功,这就是所谓的秒传。

  1. 断点续传也一样,就是计算的MD5传给后端后,后端返回md5的碎片列表hash,把没传的碎片传过去,传过的碎片标记为成功,然后合并碎片。因为我这边是云对象存储,业务上不追求md5实现断点续传妙传,只要求切片上传。所以我觉得是这一个不完美点。后面我会写个小作文去断点续传,秒传这一块逻辑

  1. 上传接口如果一样的话浏览器允许同时链接个数为4-8个,这个是因为浏览器而异。也就是说如果同时调用20个上传接口的话,会有12个接口被浏览器挂起。所以控制上传接口个数也是一个计算活唷。我这边的实现是如果有切片和文件一起上传那么切片最大允许2个接口同时上传,文件允许4个同时上传。如果只有切片或者文件就只允许6个同时上传。

  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

前端 - wei

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值