实现一个大文件切片上传(vue.js+egg.js)

762 阅读6分钟

记录一次实现大文件上传(包含失败重传,失败删除为传完的切片,限制并发等功能)。

上传的几种方式

  1. 点击上传

    • 通过file类型的input标签的
    <!-- 实例 -->
      const uploadInput = document.createElement("input");
      uploadInput.type = "file";
      uploadInput.click();
      uploadInput.addEventListener("change", async (e) => {});
    
  2. 拖拽上传

    • 通过监听H5的拖拽属性
      1. 拖动事件:
        • dragstart 在元素开始被拖动时触发
        • dragend 在拖动操作完成时触发
        • drag 在元素被拖动时触发
      2. 释放区事件:
        • dragenter 被拖动元素进入到释放区所占据得屏幕空间时触发
        • dragover 当被拖动元素在释放区内移动时触发
        • dragleave 当被拖动元素没有放下就离开释放区时触发
        • drop 当被拖动元素在释放区里放下时触发
    <!--实例-->
    <template>
    	<div>
    		<div
                  class="drag-wrapper" @dragover="onDragOver" @drop="onDrop" @dragleave="onDragleave">
    			移动到此处
    		</div>
    	</div>
    </template>
    
    const onDragOver = (e) => {
      e.preventDefault();
      // console.log(e, "onDragOver");
    };
    
    const onDragleave = (e) => {
    	e.preventDefault();
    }
    
    const onDrop = (e) => {
      // 阻止默认事件
      e.preventDefault();
      const { dataTransfer } = e;
      if (dataTransfer) {
        const { files } = dataTransfer;
    	onUpload(files)
        console.log(files, "files");
      }
    };
    
    

3.剪切板上传

  • 通过监听paste事件,获取到clipboardData
    document
    	.getElementById("ClipboardUpload")
    	.addEventListener("paste", function (e) {
    		console.log(e);
    		if (e.clipboardData && e.clipboardData.items) {
    			 Array.prototype.forEach.call(e.clipboardData.items, function (item) {
    				 console.log(item.getAsFile(), 'file')
    			 })
    		}
    	});
    

大文件上传

分片上传

  • File 文件(File)接口提供有关文件的信息,并允许网页中的 JavaScript 访问其内容。 通常情况下, File 对象是来自用户在一个 元素上选择文件后返回的 FileList 对象,也可以是来自由拖放操作生成的 DataTransfer 对象,或者来自 HTMLCanvasElement 上的 mozGetAsFile() API. File 对象是特殊类型的 Blob,且可以用在任意的 Blob 类型的 context 中。比如说, FileReader, URL.createObjectURL(), createImageBitmap() (en-US), 及 XMLHttpRequest.send() 都能处理 Blob 和 File。MDN

  • Blob Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。 MDN

文件上传

大文件上传

大概思路步骤:

  1. 将文件切割成片
  2. 计算文件的md5值
  3. 发送文件的md5值请求给服务端 查询是否已经上传过,如果上传终止上传
  4. 查询是否有上传过的切片,有则只上传未上传的
  5. 将切割的切片并发上传
  6. 全部发送成功后 发送合并请求
文件切片
    1. 首先了解Blob

    Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作.

    1. File

    文件(File)接口提供有关文件的信息,并允许网页中的 JavaScript 访问其内容。 通常情况下, File 对象是来自用户在一个 元素上选择文件后返回的 FileList 对象,也可以是来自由拖放操作生成的 DataTransfer 对象 File 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

  • 文件切割 就是通过拖拽或者input拿到文件File,利用Blob的slice方法对文件进行切割。
    // 切割文件
    const createfileChunkList = (file, size = SIZE) => {
    	const fileChunkList = [];
    	let cur = 0;
    	while (cur < file.size) {
    		fileChunkList.push({ file: file.slice(cur, cur + size, file.type) });
    		cur += size;
    	}
    	return fileChunkList;
    };
    
计算文件hash值

我们通过启用web-work计算文件hash值

  • 配置web-work(以vue为例)

    1. 安装 worker-loader
    2. 在vue.config.js 配置 worker-loader
      chainWebpack: config => {
        config.module.rule('worker').test(/\.worker\.js$/).use('worker-loader').loader('worker-loader').options({
          inline: 'fallback'
        }).end();
        config.module.rule('js').exclude.add(/\.worker\.js$/);
        config.output.globalObject("this");
      },
    
  • 计算文件hash

    import Worker from '@/worker/hash.worker.js'
    
    // 计算hash
    const calculateHash = (fileChunkList) => {
      return new Promise((resolve) => {
        state.worker = new Worker()
        state.worker.postMessage({
          fileChunkList,
        });
        state.worker.onmessage = (e) => {
          const { percentage, hash } = e.data;
          state.hashProgress = percentage;
          if (hash) {
            resolve(hash);
          }
        };
      });
    };
    
      // hash.worker.js
      import SparkMD5 from 'spark-md5';
    
      // 生成文件 hash
      self.onmessage = (e) => {
        const { fileChunkList } = e.data;
        const spark = new SparkMD5.ArrayBuffer();
        let percentage = 0;
        let count = 0;
        const loadNext = (index) => {
          const reader = new FileReader();
          reader.readAsArrayBuffer(fileChunkList[index].file);
          reader.onload = (e) => {
            count++;
            spark.append(e.target.result);
            if (count === fileChunkList.length) {
              self.postMessage({
                percentage: 100,
                hash: spark.end(),
              });
              self.close();
            } else {
              percentage += 100 / fileChunkList.length;
              self.postMessage({
                percentage,
              });
              // 递归计算下一个切片
              loadNext(count);
            }
          };
        };
        loadNext(0);
      };
    
判断文件是否已上传(文件秒传)
  • 将生成的文件hash发到服务查看是否存在该文件
  // 点击上传
  const onUpload = async () => {
    const uploadInput = document.createElement("input");
    uploadInput.type = "file";
    uploadInput.click();
    uploadInput.addEventListener("change", async (e) => {
      const [file] = e.target.files;
      const fileChunkList = createfileChunkList(file);
      // 计算hash
      const hash = await calculateHash(fileChunkList);
      // 检查是否有该文件
      const {
        code,
        data: { isExist, url },
      } = await checkFile({
        hash,
        fileName: file.name,
      });
      if (code === 200 && isExist) {
        state.url = url;
        return;
      }
      ...
  };
  ```
  ```js
  // 服务端接口(Egg.js)
  // 查找是否存在该文件
  async checkFile() {
    const { hash, fileName } = this.ctx.request.body;
    const filePath = path.join(UPLOADPATH, hash + path.extname(fileName));
    // 判断该该路径文件是否存在
    const isExist = fs.existsSync(filePath);
    this.ctx.body = {
      code: 200,
      data: {
        isExist,
        url: `download?filename=${fileName}&${hash + path.extname(fileName)}`,
      },
    };
  }

判断文件是否上传过切片
  • 判断文件是否有上传过的切片,如果有则只需要上传没有上传过的
  // 点击上传
  const onUpload = async () => {
    const uploadInput = document.createElement("input");
    uploadInput.type = "file";
    uploadInput.click();
    uploadInput.addEventListener("change", async (e) => {
      const [file] = e.target.files;
      // console.log(file, 'fiel')
      const fileChunkList = createfileChunkList(file);
      // 计算 文件md5
      const hash = await calculateHash(fileChunkList);
      // 是否上传过文件
      const {
        code,
        data: { isExist, url },
      } = await checkFile({
        hash,
        fileName: file.name,
      });
      if (code === 200 && isExist) {
        state.url = url;
        return;
      }
      // 检查是否存在切片
      const existChunkList = await getExistChunk(hash);
      // 整理切片数据,加上进度、状态、失败重传次数等
      // 已经上传过的切片 进度设置为100% 且状态为success
      const chunkList = fileChunkList.map((fileChunk, index) => ({
        chunk: fileChunk.file,
        fileName: file.name,
        index,
        hash,
        progress: existChunkList.includes(index) ? 100 : 0, // 传输文件的进度
        status: existChunkList.includes(index) ? "success" : "ready", // 状态
        retryNum: 0, // 失败重传次数
        source: CancelToken.source(), // 上传取消
      }));
      state.chunkList = chunkList;
      state.hash = hash;
      state.fileName = file.name;
      // 上传切片
      uploadChunks(chunkList.filter((item) => item.status !== "success"));
    });
  };
  ```
  ```js
  const getExistChunk = async (hash) => {
    const {
      code,
      data: { existChunk },
    } = await getExistFileChunk({
      hash,
    });
    if (code === 200) {
      return existChunk;
    }
    return [];
  };
  • 查找切片接口只需要要 通过查看是否存在该文件hash命名的文件夹,如果存在读取该文件夹。
async getExistFileChunk() {
  const { hash } = this.ctx.request.query;
  // 首先判断是否存在装切片的文件夹
  const dirPath = path.join(UPLOADPATH, hash);
  if (fs.existsSync(dirPath)) {
  // 读取该文件夹
    const files = fs.readdirSync(dirPath);
    this.ctx.body = {
      code: 200,
      data: {
        existChunk: files.map(item => +path.basename(item, path.extname(item))),
      },
    };
  } else {
    this.ctx.body = {
      code: 200,
      data: {
        existChunk: [],
      },
    };
  }
}
上传切片
  • 上传未上传的切片
  • 上传成功后 发送合并请求
  // 上传切片
  const uploadChunks = async (chunkList) => {
    try {
      // 限制并发上传切片请求
      await limitRequest(chunkList);
      const { hash, fileName } = state;
      // 如果全部完成 则发起合并请求接口
      const { code, data } = await mergeChunks({
        hash,
        fileName,
      });
      if (code === 200) {
        state.url = data.url;
      }
    } catch (err) {
      console.log(err)
    }
  };
  • 限制每次并发3个请求
  • 失败进行重传
const limitRequest = (chunklist, size = 3) => {
  return new Promise(async (resolve, reject) => {
    // 发送成功数量
    let count = 0;
    if (!chunklist.length) {
      resolve();
    }
    const request = () => {
      while (count < chunklist.length && size > 0) {
        size--;
        // 等待发送的切片
        const fileChunkIndex = chunklist.findIndex((chunk) => {
          return chunk.status === "error" || chunk.status === "ready";
        });
        // 如果没有找到 符合要求的切片则 退出循环
        if (fileChunkIndex < 0) {
          break
        }
        chunklist[fileChunkIndex].status = "pedding";
        const { chunk, fileName, index, hash, source } =
          chunklist[fileChunkIndex];
        const chunkIndex = state.chunkList.findIndex(
          (item) => item.index === index
        );
        const formData = new FormData();
        formData.append("hash", hash);
        formData.append("filename", fileName);
        formData.append("index", index);
        formData.append("file", chunk, fileName);
        uploadChunk(formData, {
          onUploadProgress: (e) => {
            const { total, loaded } = e;
            state.chunkList[chunkIndex].progress = (loaded / total) * 100;
          },
          cancelToken: source.token,
        })
          .then((res) => {
            state.chunkList[chunkIndex].status = "success";
            size++;
            count++;
            // 全部上传成功
            if (count === chunklist.length) {
              resolve({
                hash,
                fileName,
              });
            } else {
              request();
            }
          })
          .catch((e) => {
            const source = CancelToken.source()
            chunklist[fileChunkIndex].source = source
            state.chunkList[chunkIndex].source = source
            chunklist[fileChunkIndex].status = "error";
            chunklist[fileChunkIndex].retryNum++;
            state.chunkList[chunkIndex].status = "error";
            // 切片重试上传超过十次则上传失败
            if (chunklist[fileChunkIndex].retryNum > 10) {
              reject(e);
            }
            size++;
          });
      }
    };
    request();
  });
};
  • 接收切片的接口 先判断是否存在该文件夹,不存在则创建文件夹再写入
  async uploadChunk() {
    const stream = await this.ctx.getFileStream();
    const hash = stream.fields.hash;
    // 利用hash创建文件
    function mkdirsSync(hash) {
      if (fs.existsSync(hash)) {
        return true;
      }
      if (mkdirsSync(path.dirname(hash))) {
        fs.mkdirSync(hash);
        return true;
      }
    }
    mkdirsSync(path.join(UPLOADPATH, hash));
    // 生成写入路径
    const target = path.join(UPLOADPATH, hash, stream.fields.index + path.extname(stream.filename));
    const writeStream = fs.createWriteStream(target);
    // 如果监听到取消 则删除文件
    this.ctx.req.on('aborted', () => {
      writeStream.close();
      fs.unlinkSync(target);
    });
    try {
      // 异步把文件流 写入
      await awaitWriteStream(stream.pipe(writeStream));
    } catch (err) {
      // 如果出现错误,关闭管道
      await sendToWormhole(stream);
      writeStream.close();
      this.ctx.body = {
        code: 500,
      };
    }
    this.ctx.body = {
      code: 200,
      data: {},
    };
  }
合并切片
  • 合并切片的接口只需要将文件夹下的文件依次写入通道
async merge() {
  const { hash, fileName } = this.ctx.request.body;
  const dirPath = path.join(UPLOADPATH, hash);
  const filePath = path.join(UPLOADPATH, hash + path.extname(fileName));
  try {
    const files = fs.readdirSync(dirPath).sort();
    const targetStream = fs.createWriteStream(filePath);
    const readStream = function(files) {
      const name = files.shift();
      const filePath = path.join(dirPath, name);
      const originStream = fs.createReadStream(filePath);
      // 依次将流写入通道
      originStream.pipe(targetStream, { end: false });
      originStream.on('end', function() {
        fs.unlinkSync(filePath);
        if (files.length > 0) {
          readStream(files);
        } else {
          targetStream.close();
          originStream.close();
          fs.rmdirSync(dirPath);
        }
      });
    };
    readStream(files);
  } catch (e) {
    this.ctx.body = {
      code: 500,
    };
  }
  this.ctx.body = {
    code: 200,
    data: {
      url: `/download?filename=${fileName}&${hash + path.extname(fileName)}`,
    },
  };
}