一文看明白大文件上传

文章详细介绍了大文件上传的实现策略,包括文件切片、MD5校验、WebWorker进行异步处理、并发控制和断点续传。通过前端分片上传、计算MD5值进行秒传检查、使用WebWorker处理大文件以避免阻塞主线程,以及如何处理上传进度和并发请求,确保上传的稳定性和效率。此外,还涵盖了断点续传的实现,以及如何在上传中断后恢复进度。
摘要由CSDN通过智能技术生成

一种方案:
前端uppy.js
后端tusd+minio(都是go实现,一个二进制文件丢上去就能跑,同域名要前置代理)
集群化得自行实现tus协议
不过一般小型项目也用不着,单机跑就完事了
这种应该是超大文件分片断点续传的最简单方案了,也免得前后端在这种事情上定义半天。

细节点

大文件上传相比于传统的小文件上传,需要注意一些细节问题以保证上传过程的稳定和可靠,以下是一些需要注意的细节点:

  1. 文件分片大小:文件分片大小应该根据网络带宽和服务器性能等因素综合考虑,一般建议设置为 1MB~10MB,避免分片过大导致上传失败。
  2. 文件分片序号:在进行分片上传时,需要为每个分片指定一个唯一的序号,以便在上传过程中进行分片排序和合并。
  3. 分片上传进度:为了提高用户体验,需要实时显示上传进度,可以通过前端实时计算已上传的分片大小和总文件大小的比例来展示上传进度。
  4. 断点续传:断点续传是保证上传可靠性的关键,需要在后端记录已上传的分片信息,以便在网络异常或上传中断时,能够从上一次中断的地方继续上传。
  5. 文件校验:在上传完成后,需要对上传的文件进行校验,以确保文件完整性和正确性。
  6. 并发上传限制:对于大文件上传,需要限制同时上传的分片数量,以避免服务器过载和带宽占用过大的问题。
  7. 上传速度控制:需要对上传速度进行控制,以避免网络拥塞和上传过程中出现的错误。
  8. 合并分片:在上传完成后,需要将所有的分片合并成完整的文件,这需要考虑到分片顺序和文件路径等问题。
  9. 文件存储:需要考虑文件存储位置和存储方式,以便后续的访问和管理。大文件上传后需要进行存储,常见的存储方式包括本地存储、对象存储、分布式文件系统等。不同的存储方式有不同的特点和优劣,需要根据业务需求进行选择。
  10. 上传文件类型限制:需要根据业务需求设置上传文件类型的限制,以避免上传非法文件。
  11. 上传超时处理:当上传时间过长或者网络中断时,需要进行相应的超时处理,以避免长时间占用带宽或者服务器资源。
  12. 上传失败重试:上传过程中,可能会出现上传失败的情况,需要对上传失败的分片进行重传,以保证上传完整性。
  13. 文件上传授权:对于需要进行文件上传授权的场景,需要对上传用户进行认证和授权,以确保上传的文件具有合法性和安全性。
  14. 上传文件名字重命名:为了保证文件的唯一性,可能需要对上传的文件进行重命名,例如添加时间戳或者随机字符串。
  15. 文件备份和容灾:为了确保文件的安全性和可靠性,需要对上传的文件进行备份和容灾。可以通过多机房部署、多副本存储、异地容灾等方式进行备份和容灾。

简单文件长传
接上回,继续。

切片上传

简单切片

基本原理:blob 就像字符串一样不可更改,但可以被切割。file 继承了所有 blob 的方法。
文件使用file.slice(start, end)方法进行切割,所以要记录每次切割的块大小,以及每次起始切割的位置。

const handleClickUpload = async () => {
  const formData = new FormData()

  const _bigFile = fileList.value[0].file
  const _fileSize = _bigFile.size
  const _chunkSize = 1 * 1024 * 1024 // 1MB 注意:文件大小单位是字节
  let _currentSize = 0

  while (_currentSize < _fileSize) {
    // 切片
    let _chunk = _bigFile.slice(_currentSize, _currentSize + _chunkSize)
    
    formData.append('avatar', _chunk, _bigFile.name)
    
    _currentSize += _chunkSize

    // 上传
    await fetch('http://localhost:8888/upload', {
      method: 'post',
      body: formData
    })
    
    // 进度,注意一定要在本次上传完毕后更改,要不然同步任务先执行直接 100%
    progress.value = Math.min((_currentSize / _fileSize) * 100, 100)
    // upload(formData)
  }
}

MD5 校验文件一致性

SparkMD5.js
SparkMD5是一个JavaScript库,用于计算字符串的MD5哈希值。它可以在浏览器和Node.js环境中使用。SparkMD5采用了基于Bit的流式哈希算法,具有高效性和可靠性。它被广泛用于计算文件的MD5值,以及在密码学和数据完整性验证中使用。

// 计算字符串MD5哈希
const hash = SparkMD5.hash('hello world');
console.log(hash); // 输出: 5eb63bbbe01eeed093cb22bb8f5acdc3

// 计算文件MD5哈希
const fileInput = document.querySelector('input[type="file"]');
const file = fileInput.files[0];
const fileReader = new FileReader();

fileReader.onload = function() {
  const spark = new SparkMD5.ArrayBuffer();
  spark.append(fileReader.result);
  const hash = spark.end();
  console.log(hash); // 输出: 文件的MD5哈希值
};

fileReader.readAsArrayBuffer(file);

为什么需要计算 MD5?
因为秒传功能需要通过 MD5 来判断文件是否已经上传过。
在传送之前,将文件 hash 值发给服务器,服务器会保留已上传文件的 hash,一对比,就知道文件是否重复上传,并通知客户端不要上传,进度条直接百分之百。

WebWorker

需要注意的是,切割一个较大的文件,比如10G,那分割为1Mb大小的话,将会生成一万个切片,并且还要计算整个文件的 hash 值。众所周知,js是单线程模型,如果这个计算过程在主线程中的话,那我们的页面必然会直接崩溃,这时,就该我们的 Web Worker 来上场了。
WebWorker
webWorker 加载第三方模块
在 worker.js 中,我们需要对文件进行切片,并返回这些切片组成的数组以及整个文件的 hash 值给主线程。

但存在一个问题,添加到 spark 中的需要是 buffer,也就是 file 切割后得到的 blob,需要通过fileReader.readAsArrayBuffer(blob)转成 buffer。最重要的是,结果得在fileReader.onload = fn(){}回调中拿,这是个异步操作。这就导致一个问题,添加到(spark.append(e.target.result))spark 中的分片顺序可能是错乱的,这就是会导致最后计算整体 hash 时,得出一个错误的值。

解决办法:递归处理下一个分片。
只有明确上一个分片已经被添加到 spark 中,才会开始下一个分片的裁剪。

// worker.js
importScripts("./SparkMD5.js");
// 接收文件对象及切片大小
self.onmessage = (e) => {
  const { file, DefualtChunkSize } = e.data;
  const blobSlice =
    File.prototype.slice ||
    File.prototype.mozSlice ||
    File.prototype.webkitSlice;
  const chunks = Math.ceil(file.size / DefualtChunkSize); // 分片数
  let currentChunk = 0 // 当前分片索引
  const chunkList = [] // 分片数组
  const spark = new SparkMD5.ArrayBuffer();
  const fileReader = new FileReader();

  fileReader.onload = function (e) {
 
    spark.append(e.target.result);
    currentChunk++;

    if (currentChunk < chunks) {
      loadNext();
    } else {
      const fileHash = spark.end();
      console.info("finished computed hash", fileHash);
      // 此处为重点,计算完成后,仍然通过postMessage通知主线程
      postMessage({ fileHash, chunkList });
      self.close() // 关闭线程
    }
  };

  fileReader.onerror = function () {
    console.warn("oops, something went wrong.");
  };

  function loadNext() {
    let start = currentChunk * DefualtChunkSize;
    let end =
      start + DefualtChunkSize >= file.size
        ? file.size
        : start + DefualtChunkSize;
    const chunk = blobSlice.call(file, start, end);
    chunkList.push({chunk, currentChunk})
    fileReader.readAsArrayBuffer(chunk);
  }

  loadNext();
};

// 响应给主线程的数据结构
// { fileHash, [{blobChunk, currentChunk}, {}, {}...] }

上传分片

每一块分片作为一个单独的文件进行上传。而不是将整个分片数组一起上传,这是为了更精细化地控制,也是并发上传的前提。

const uploadChunk = (fileChunk, fileHash, fileName) => {

  const formData = new FormData()
  formData.append('chunk', fileChunk.chunk); // 每个分片
  formData.append('chunkIndex', fileChunk.currentChunk); // 块索引
  formData.append('fileName', fileName); // 文件名称
  formData.append('fileHash', fileHash); // 整个文件的 hash

  return lcRequest.post({
    url: '/upload',
    data: formData
  })
}

如果要上传整个文件的分片,只要分片数组循环一下就行。下面这个例子是严格按顺序一个一个上传分片。

合并请求

上传完毕后,通常会发送一个合并请求给服务器,告知服务器已经上传完毕,可以合并分片了。

// 合并请求
const mergeChunkRequest = (filename, fileHash) => {
  return lcRequest.post({
    url: '/api/merge',
    data: { filename, hash: fileHash }
  })
}
worker.postMessage({ file: file, DefualtChunkSize: chunkSize })
worker.onmessage = async e => {
  
	const { chunkList, fileHash } = e.data

  // 循环上传分片
	for (const chunk of chunkList) {
    const uploadChunkFinish = await uploadChunk(chunk, fileHash, file.name, file.size)
    console.log(uploadChunkFinish);
  }

  // 合并请求
  const res = await mergeChunkRequest(file.name, fileHash)
  console.log(res);
}

断点续传和秒传

秒传

在发送文件之前,需要验证一下该文件是否已经上传过。

const verifyUpload = (filename, hash) => {
  return lcRequest.post({ // lcRequest 封装自 axios
    url: '/verify',
    data: { filename, hash },
    headers: { 'Content-Type': 'application/json' },
  })
}

服务端接口一般会有三种响应:

  1. 文件已上传,返回 true
  2. 文件未上传,返回 false
  3. 文件上传了一半,返回已上传的大小,以及文件的表单字段名。(误)

断点续传

第三种响应也就是断点续传。其实通过返回已上传的大小,是不合适的。它只适用单文件上传,或者分片是严格按顺序上传的情况。

const resumeUpload = (uploadedSize, fileSize, defualtChunkSize) => {
  const progress = 100 * uploadedSize / fileSize

  // 计算恢复上传的切片索引
  const index = uploadedSize % defualtChunkSize
  return {index, progress}
}

如果前端是并发上传,就可能出现,前一个分片断开,后一个分片已经上传完了的情况。此时就需要服务器明确返回哪些分片已经上传完毕。
服务器一般会返回已上传完毕的分片片的索引。如果服务器没有区分完全上传完毕的分片和只上传了一部分的分片,将它们的索引都返回了。那只要判断一下这些分片哪些大小比较小,小的直接丢弃。
我们拿到完整的分片数组,过滤掉已上传完毕的分片,就得到未上传的分片数组,此时再去一片一片上传就行。

// uploadedList 为 [切片1, 切片2, ..., 切片n] 
// 已上传切片的地址文件名为文件hash+分片索引。 eg:[13717432cb479f2f51abce2ecb318c13-1.mp3]

const resumeUpload = (uploadedList, chunkList) => {

  // 从服务器返回的数据中拿到分片的索引
  const uploadedIndexList = uploadedList.map(item => {
    return item.match(/-(\d+)\./)
  })
  // 过滤出未上传的切片
  const resumeChunkList = chunkList.filter(item => {
    return !uploadedIndexList.includes(item.currentChunk)
  })
  
  return resumeChunkList 
}

上传过程的主体结构

worker.postMessage({ file: file, DefualtChunkSize: chunkSize })
worker.onmessage = async e => {
  const { chunkList, fileHash } = e.data

  // 秒传验证
  const { uploadedSignal, uploadedList } = await verifyUpload(file.name, fileHash)
  if (uploadedSignal === true) {
    // 秒传,进度条百分之百
    progress.value = 100
  } else if (uploadedSignal === false && uploadedList.length === 0) {
    // 从0上传
  	...
  } else {
    // 续传
    const { resumeProgress, resumeChunkList } = resumeUpload(uploadedList, chunkList)
    ...
  }
}

上传进度

单体文件上传进度

fetch 无法监听上传进度,xhr 可以。axios 也提供了对上传和下载进度的监听。
axios 监听上传进度

  • onUploadProgress:配置上传进度的回调
  • onDownloadProgress:配置下载进度的回调

它们都是浏览器才有的配置。

axios.post('/upload', formData, { onUploadProgress: uploadProgress })

const uploadProgress = (progressEvent) => {
  // 计算上传进度,loaded 和 total 是该事件对象固有的属性
  const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
  console.log(percentCompleted);
}

上传过程中,会伺机调用 onUploadProgress 配置的回调函数。
image.png

分片上传进度

因为我们是一片一片的上传分片,所以如果使用默认的 axios 进度监听方案,那么只会显示出某一个分片的进度,而且也分不出是哪个分片的进度,更不清楚总体进度会是多少,所以这里需要做一些计算,计算方法也很简单,就是将每个分片已经上传的文件大小进行累加,用总大小一除即可。

分片就相当于一个个独立的文件,所以这个方法也可以说是多文件上传的总体进度计算方法。

从0上传的进度
const progressArr = [] // 记录每个分片已经上传的大小
const uploadProgress = (progressEvent, chunkIndex, totalSize) => {
  if (progressEvent.total) {
    // 将当前分片的已经上传的大小按分片索引保存在数组中
    progressArr[chunkIndex] = progressEvent.loaded * 100;
    // reduce 累加每块分片已上传的部分
    const curTotal = progressArr.reduce(
      (accumulator, currentValue) => accumulator + currentValue,
      0,
    );
    // 计算百分比进度
    progress.value = Math.min((curTotal / totalSize).toFixed(2), 100)
    // setProgress((curTotal / totalSize).toFixed(2)); // 发布订阅者模式
  }
};

进度条可以应用发布订阅者模式,接收数据的变化,当然也可以不用。

注意:默认 axios 上传进度的监听回调只有一个参数,想要传递多个参数,可以用函数包裹一层。
比如上面处理监听的回调,里面就有多个参数,就无法直接绑定到onUploadProgress

onUploadProgress: (progressEvent) => {
  return uploadProgress(progressEvent, formData.get('chunkIndex', fileSize)
})

因为进度计算需要文件的总大小,所以分片上传函数的参数也要多加一个 fileSize。

const uploadChunk = (fileChunk, fileHash, fileName, fileSize) => {
  const formData = new FormData()
  
  formData.append('file', fileChunk.chunk); // 切片
  formData.append('chunkIndex', fileChunk.currentChunk); // 块索引
  formData.append('filename', fileName); // 文件名称
  formData.append('hash', fileHash); // 文件 hash

  return lcRequest.post({
    url: '/api/upload',
    data: formData,
    // 默认回调只有一个参数,想要传递两个,可以用函数包裹一层
    onUploadProgress: (progressEvent) => uploadProgress(progressEvent, formData.get('chunkIndex'), fileSize)
  })
}
断点续传的进度恢复

从上面可知,我们可以拿到已上传完毕的分片的索引数组。既然进度计算是在progressArr中记录每个分片已上传的大小来实现的,那只要将已经上传完毕的分片先填满本是空的progressArr不就行了。
改造一下断点续传的函数 resumeUpload,将已上传的索引数组也返回:

// 断点续传
const resumeUpload = (uploadedList, chunkList) => {
  const uploadedIndexList = uploadedList.map(item => {
    return item.match(/-(\d+)\./)
  })
  const resumeChunkList = chunkList.filter(item => {
    return !uploadedIndexList.includes(item.currentChunk)
  })
  return { uploadedIndexList, resumeChunkList }
}

先填好已经上传的分片大小到 progressArr 中,然后再进行累加:

const { uploadedIndexList, resumeChunkList } = resumeUpload(uploadedList, chunkList)

// 填入 progressArr
uploadedIndexList.forEach(index => {
  progressArr[index] = chunkSize
});

// 将未上传的分片数组中将分片一个一个循环串行上传
for (const chunk of resumeChunkList) {
  const uploadChunkFinish = await uploadChunk(chunk, fileHash, file.name, file.size)
  console.log(uploadChunkFinish);
}

const res = await mergeChunkRequest(file.name, fileHash)
console.log(res);

取消上传

停止上传可以是关闭当前页面或浏览器,也可以是后端掉线,还可以是使用前端的 API 主动停止。前面两种情况都是一些意外情况,这里我们主要讨论使用前端的 API 主动停止的情况。
取消 axios 请求
只需给切片上传的方法添加一个 signal 参数对 axios 进行配置即可。

// 切片上传
const controller = new AbortController();
const uploadChunk = (fileChunk, fileHash, fileName, fileSize) => {
	...
  
  return lcRequest.post({
    url: '/api/upload',
    data: formData,
    signal: controller.signal,
    onUploadProgress: (progressEvent) => uploadProgress(progressEvent, formData.get('chunkIndex'), fileSize)
  })
const handleClickAbortUpload = () => {
  controller.abort()
}

暂停、恢复请求

暂停与恢复就是取消请求与断点续传。

并发控制

上面的分片上传方式是串行的,效率不够高。我们可以并发上传,但是浏览器最多并发6个请求,所以需要对并发请求进行控制。
并发请求控制

async function controlConcurrency(requests, limit) {
  const results = []; // 结果数组
  const running = []; // 并发数组

  for (const request of requests) {
    const promise = request();

    results.push(promise);
    running.push(promise);

    if (running.length >= limit) {
      await Promise.race(running);
    }

    promise.finally(() => {
      running.splice(running.indexOf(promise), 1);
    });
  }

  return Promise.all(results);
}

分片数组并发上传:

// 分片数组并发上传
const chunkListUpload = (chunkList, uploadChunk, fileHash, file, concurrentControlFn, limit = 3) => {
  // 构建请求数组
  const requestsArr = []
  for (let index = 0; index < chunkList.length; index++) {
    requestsArr[index] = () => uploadChunk(chunkList[index], fileHash, file.name, file.size)
  }
  // 并发控制
  return concurrentControlFn(requestsArr, limit)
}

完整的上传控制流程:

worker.postMessage({ file: file, DefualtChunkSize: chunkSize })
worker.onmessage = async e => {
  const { chunkList, fileHash } = e.data

  // 1. 秒传验证
  const { uploadedSignal, uploadedList } = await verifyUpload(file.name, fileHash)
  if (uploadedSignal === true) {
    // 秒传,进度条百分之百
    progress.value = 100
  } else if (uploadedSignal === false && uploadedList.length === 0) {
    // 2. 从0上传
  	const chunkListUploadRes = await chunkListUpload(chunkList, uploadChunk, fileHash, file, controlConcurrency, 2)
    console.log(chunkListUploadRes);
  	// 合并
    const res = await mergeChunkRequest(file.name, fileHash)
    console.log(res);
  } else {
    // 3. 续传
    const { resumeProgress, resumeChunkList } = resumeUpload(uploadedList, chunkList)
    // 填入 progressArr
    uploadedIndexList.forEach(index => {
      progressArr[index] = chunkSize
    });
    
    const chunkListUploadRes = await chunkListUpload(resumeChunkList, uploadChunk, fileHash, file, controlConcurrency, 2)
    console.log(chunkListUploadRes);
    
    const res = await mergeChunkRequest(file.name, fileHash)
    console.log(res);
  }
}

服务器

服务器这里只是简单实现了一下分片拼接,并没有实现断点续传与秒传。

处理过程:

  1. 分片上传

分片上传实际就是一个多文件上传,所以要使用 multer 的 array 方法接收多个分片。分片主要存在一个以 hash 为名的临时文件夹中。以 hash 为名是保证了临时文件夹的唯一性,也为断点续传打下基础。

  1. 合并分片

合并分片就是将临时文件夹中的分片按索引排好序,然后依次读取再输出为一个文件就行。
fs-extra 模块可以方便的对文件进行处理,合并分片:fse.appendFileSync(合并到的文件, 分片的二进制数据)

const Koa = require("koa");
const cors = require("koa-cors");
const Router = require("koa-router");
const bodyParser = require("koa-bodyparser");
const multer = require("@koa/multer");
const path = require('path')
const fse = require("fs-extra");

const app = new Koa();

const router = new Router({
  prefix: '/api',
})

const UPLOAD_DIR = './upload/'
// 新建一个以 hash 为名的临时文件夹保存分片,然后读取分片合并成原始文件,然后删除临时文件夹
// 发现 req 中获取不到 hash 值,于是暂时固定一个临时文件夹使用,用完了就删,然后再新建。
// 但是这样就无法保存传了一半的文件了。
const tempDir = './upload/temp'

// 上传文件存储配置
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    // console.log(req.body.hash); // req 中无法获取到 hash 值,用来创建临时文件夹
    cb(null, tempDir);
  },
  filename: (req, file, cb) => {
    cb(null, `${file.originalname}_${Date.now()}${path.extname(file.originalname)}`);
  },
});

const upload = multer({ storage });

router.get("/test", (ctx) => {
  console.log("get request coming");
  ctx.response.body = JSON.stringify({ msg: "get success" });
});

// 解析 formdata 文件,分片上传
// array(字段)必须和前端发送的表单字段一致
router.post("/upload", upload.array('file'), (ctx, dispatch) => {
  const { filename, hash } = ctx.request.body;
  const fileInfoList = ctx.request.files
  const uploadFile = []
  fileInfoList.forEach(item => {
    uploadFile.push(item.originalname)
  })
  ctx.response.body = JSON.stringify({ msg: "post success" , uploadFile });
});

// 合并分片
router.post("/merge", async (ctx) => {
  const { filename, hash } = ctx.request.body;

  // 获取文件后缀
  const ext = filename.slice(filename.lastIndexOf('.') + 1)

  // 从临时文件夹中获取分片
  const chunks = await fse.readdir(tempDir);

  // 将分片排序后,循环读入内存然后添加到一个文件中
  chunks
    .sort((a, b) => Number(a) - Number(b))
    .forEach((chunk) => {
      // 合并文件
      fse.appendFileSync(
        path.join(UPLOAD_DIR, `${hash}.${ext}`), 
    		fse.readFileSync(path.join(tempDir, chunk))
      );
    });
  
  // 删除临时文件夹
  fse.removeSync(tempDir);
  fse.mkdir(tempDir)
  
  // 可能会返回文件下载地址
  ctx.body = "合并成功";
});


app.use(bodyParser());
app.use(cors());
app.use(router.routes());
app.use(router.allowedMethods());

app.listen(8888, "127.0.0.1", () => {
  console.log("server start...");
});

完整代码

<template>
  <div class="upload-box">
    <div class="head">
      <h3> 切片上传</h3>
      <input class="upload-btn" type="file" @change="handleFileChange">
    </div>
    <div class="preview-box">
      待上传文件:
      <div class="files-info">
        <template v-for="item in fileList" :key="item.lastModified">
          <div class="card">
            <div class="delete">
              <!-- <img :src="item.preUrl" alt="预览图"> -->
              <video :src="item.preUrl"></video>
              <div class="name">{{ item.file.name }}</div>
            </div>
          </div>
        </template>
      </div>
    </div>
    <template v-if="progress != 0">
      <div class="progress">
        <div class="background">
          <div class="foreground" :style="{ width: progress + '%' }"></div>
        </div>
      </div>
    </template>
    <div class="result-box">
      上传反馈:
      <div class="result">

        <div>{{ result.msg }}</div>
        <div>{{ result.uploadFile }}</div>
      </div>
    </div>
    <div class="action">
      <button @click="handleClickUpload">上传</button>
      <button @click="handleClickAbortUpload">取消</button>
      <a href="" download="">xia zai</a>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import lcRequest from '../service/request';
import { controlConcurrency } from '../utils/concurrentControl';

const result = ref('')
const fileList = ref([])
const progress = ref('0')
const fileType = ['png', 'mp4', 'mkv']
const chunkSize = 10 * 1024 * 1024

// 切片上传
const controller = new AbortController();
const uploadChunk = (fileChunk, fileHash, fileName, fileSize) => {
  console.log(fileChunk);
  console.log(fileSize);
  const formData = new FormData()
  formData.append('file', fileChunk.chunk); // 切片
  formData.append('chunkIndex', fileChunk.currentChunk); // 块索引
  formData.append('filename', fileName); // 文件名称
  formData.append('hash', fileHash); // 文件 hash

  return lcRequest.post({
    url: '/api/upload',
    data: formData,
    signal: controller.signal,
    // 默认回调只有一个参数,想要传递两个,可以用函数包裹一层
    onUploadProgress: (progressEvent) => uploadProgress(progressEvent, formData.get('chunkIndex'), fileSize)
  })
}

// 进度计算
const progressArr = []
const uploadProgress = (progressEvent, chunkIndex, totalSize) => {
  if (progressEvent.total) {
    // 将当前分片的已经上传的字符保存在数组中
    progressArr[chunkIndex] = progressEvent.loaded * 100;
    // reduce 累加每块分片已上传的部分
    const curTotal = progressArr.reduce(
      (accumulator, currentValue) => accumulator + currentValue,
      0,
    );
    // 计算百分比进度
    progress.value = Math.min((curTotal / totalSize).toFixed(2), 100)
  }
};

// 合并请求
const mergeChunkRequest = (filename, fileHash) => {
  return lcRequest.post({
      url: '/api/merge',
      data: { filename, hash: fileHash }
    })
}

// 文件类型限制
const fileTypeCheck = (file, typesArr) => {
  const index = file.name.lastIndexOf('.')
  const ext = file.name.slice(index + 1)

  if (!typesArr.includes(ext)) {
    alert(`${ext} 文件不允许上传!`)
    return false
  }
  return true
} 

// 获取文件对象
const handleFileChange = e => {
  const file = e.target.files[0]
  const isLegalType = fileTypeCheck(file.name, fileType)
  // 将每次选择的文件添加到待上传数组中
  isLegalType || fileList.value.push({ file, preUrl: URL.createObjectURL(file) })
}

// 启动 worker 线程
const worker = new Worker('/src/utils/worker.js')

// 秒传
const verifyUpload = (filename, hash) => {
  // return { uploadedSignal: false, uploadedList: [] } // 强制从0开始上传
  return lcRequest.post({
    url: '/verify',
    data: { filename, hash },
    headers: { 'Content-Type': 'application/json' },
  })
}
  
// 断点续传
const resumeUpload = (uploadedList, chunkList) => {

  const uploadedIndexList = uploadedList.map(item => {
    return item.match(/-(\d+)\./)
  })
  const resumeChunkList = chunkList.filter(item => {
    return !uploadedIndexList.includes(item.currentChunk)
  })
  return { uploadedIndexList, resumeChunkList }
}

// 分片数组并发上传
const chunkListUpload = (chunkList, uploadChunk, fileHash, file, concurrentControlFn, limit = 3) => {
  // 构建请求数组
  const requestsArr = []
  for (let index = 0; index < chunkList.length; index++) {
    requestsArr[index] = () => uploadChunk(chunkList[index], fileHash, file.name, file.size)
  }
  return concurrentControlFn(requestsArr, limit)
}

// 点击上传
const handleClickUpload = async () => {
  // 测试的是大文件单文件上传,如果是多文件,遍历文件数组处理即可
  const file = fileList.value[0].file
  console.log(file);

  worker.postMessage({ file: file, DefualtChunkSize: chunkSize })
  
  worker.onmessage = async e => {
    const { chunkList, fileHash } = e.data

    // 1. 秒传验证
    const { uploadedSignal, uploadedList } = await verifyUpload(file.name, fileHash)
    if (uploadedSignal === true) {
      progress.value = 100 // 秒传,进度条百分之百
    } else if (uploadedSignal === false && uploadedList.length === 0) {
      // 2. 从0上传
      const chunkListUploadRes = await chunkListUpload(chunkList, uploadChunk, fileHash, file, controlConcurrency, 2)
      console.log(chunkListUploadRes);
      const res = await mergeChunkRequest(file.name, fileHash)
      console.log(res);
    } else {
      // 3. 断点续传
      const { uploadedIndexList, resumeChunkList } = resumeUpload(uploadedList, chunkList)
      // 先填入 progressArr
      uploadedIndexList.forEach(index => {
        progressArr[index] = chunkSize
      });
      
      const chunkListUploadRes = await chunkListUpload(resumeChunkList, uploadChunk, fileHash, file, controlConcurrency, 2)
      console.log(chunkListUploadRes);
      const res = await mergeChunkRequest(file.name, fileHash)
      console.log(res);
    }
  }
}

// 取消请求
const handleClickAbortUpload = () => {
  controller.abort()
  alert("请求取消")
}

</script>
<style scoped>
.upload-box {
  width: 600px;
  padding: 0 10px;
  border: 1px solid;
  border-radius: 10px;
}

.head {
  width: 100%;
  height: 50px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid;
}

.preview-box {
	height: 500px;
}

.files-info {
  display: flex;
  justify-content: space-evenly;
  flex-flow: row wrap;
  overflow: auto;
}

.card .name {
  padding: 4px 0;
  text-align: center;
}

.result-box {
  height: 200px;
  border-bottom: 1px solid;
}

.result-box .result {
  display: flex;
  flex-flow: column;
  justify-content: space-evenly;
}

.action {
  height: 50px;
  display: flex;
  justify-content: space-around;
  align-items: center;
}

img,
video {
  width: 100px;
  object-fit: contain;
}

/* 进度条 */
.progress {
  height: 10px;
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.progress .background {
  height: 4px;
  width: 200px;
  border: 1px solid;
  border-radius: 10px;
}

.progress .background .foreground {
  height: 100%;
  background-color: pink;
}
</style>
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值