前情提要:
在前端无论是Vue还是React技术栈,都离不开上传业务代码
一般情况下,前端上传文件就是new FormData,然后把文件 append 进去,然后post发送给后端就完事了,但是大文件可不能这么搞,因为美元办法预料到各种意外事件,用户浏览器退出,网络情况不好,网络断开等等,会造成我们的上传直接清零,这种在大文件,网络情况慢,传输时间长的条件下十分容易出现那我们应该如何去优化和解决这个问题呢
对于大文件上传了,确保能让用户在上传的时候想停就停(暂停功能),就算断网了也能继续接着上传(断点上传),如果是之前上传过这个文件了(服务器还存着),就不需要做二次上传了(秒传)
相关知识:浏览器同一域名只能同时请求6个。
一、Web Worker(用于分片)
Web Worker 是一种在Web开发中用于创建多线程环境的API,使得JavaScript代码能够在后台线程中执行,从而不会阻塞主线程的运行。它主要用于处理耗时任务,比如复杂的计算、数据处理等,以提高Web应用的性能和响应速度。所以我们需要采用Web Worker来实现分片。
核心代码:Blob.prototype.slice
文件分片和 Spark-MD5 通常结合使用,用于实现大文件的分片上传和校验,保证数据完整性。所以这边需要借助于spark-md5库
Spark-MD5 介绍:它是一个快速的 MD5 算法实现,适用于浏览器和 Node.js 环境。结合文件分片,Spark-MD5 主要用于计算文件或分片的 MD5 校验和,确保数据在传输过程中的完整性。
直接操作
const worker = new Worker(new URL('@/worker/hash-worker.js', import.meta.url)
)
二、上传进度
不严谨版本:我们可以采用常规的onUploadPrigress方法可以实现接口请求进度,可以通过(e.loaded / e.total) * 100)去计算。但是大文件会有些不适用,出现100%也有可能上传失败
严谨版本:服务器返回上传成功的分片数
三、切片上传
vue文件代码
methods: {handleClick() {// 点击触发选取文件this.$refs.fileInput.dispatchEvent(new MouseEvent('click'))},async hanldeUploadFile() {const fileEle = this.$refs.fileInput// 如果没有文件内容if (!fileEle || !fileEle.files || fileEle.files.length === 0) {return false}const files = fileEle.files// 多文件Array.from(files).forEach(async (item, i) => {const file = item// 单个上传文件let inTaskArrItem = {id: new Date() + i, // 因为forEach是同步,所以需要用指定id作为唯一标识state: 0, // 0不做任何处理,1是计算hash中,2是正在上传中,3是上传完成,4是上传失败,5是上传取消fileHash: '',fileName: file.name,fileSize: file.size,allChunkList: [], // 所有请求的数据whileRequests: [], // 正在请求中的请求个数,目前是要永远都保存请求个数为6finishNumber: 0, //请求完成的个数errNumber: 0, // 报错的个数,默认是0个,超多3个就是直接上传中断percentage: 0, // 单个文件上传进度条}this.uploadFileList.push(inTaskArrItem)// 开始处理解析文件inTaskArrItem.state = 1if (file.size === 0) {// 文件大小为0直接取消该文件上传this.uploadFileList.splice(i, 1)// 继续执行for循环}// 计算文件hashconst { fileHash, fileChunkList } = await this.useWorker(file)console.log(fileHash, '文件hash计算完成')// 解析完成开始上传文件let baseName = ''// 查找'.'在fileName中最后出现的位置const lastIndex = file.name.lastIndexOf('.')// 如果'.'不存在,则返回整个文件名if (lastIndex === -1) {baseName = file.name}// 否则,返回从fileName开始到'.'前一个字符的子串作为文件名(不包含'.')baseName = file.name.slice(0, lastIndex)// 这里要注意!可能同一个文件,是复制出来的,出现文件名不同但是内容相同,导致获取到的hash值也是相同的// 所以文件hash要特殊处理inTaskArrItem.fileHash = `${fileHash}${baseName}`inTaskArrItem.state = 2// 初始化需要上传所有切片列表inTaskArrItem.allChunkList = fileChunkList.map((item, index) => {return {// 总文件hashfileHash: `${fileHash}${baseName}`,// 总文件sizefileSize: file.size,// 总文件namefileName: file.name,index: index,// 切片文件本身chunkFile: item.chunkFile,// 单个切片hash,以 - 连接chunkHash: `${fileHash}-${index}`,// 切片文件大小chunkSize: this.chunkSize,// 切片个数chunkNumber: fileChunkList.length,// 切片是否已经完成finish: false,}})// 逐步对单个文件进行切片上传this.uploadSignleFile(inTaskArrItem)})console.log(this.uploadFileList, 'uploadFileList')},// 生成文件 hash(web-worker)useWorker(file) {return new Promise((resolve) => {const worker = new Worker(new URL('@/worker/hash-worker.js', import.meta.url),{type: 'module',})worker.postMessage({ file, chunkSize: this.chunkSize })worker.onmessage = (e) => {const { fileHash, fileChunkList } = e.dataif (fileHash) {resolve({fileHash,fileChunkList,})}}})},},
work文件代码
import SparkMD5 from './spark-md5.min.js'
// 创建文件切片
function createFileChunk(file, chunkSize) {return new Promise((resolve, reject) => {let fileChunkList = []let cur = 0while (cur < file.size) {// Blob 接口的 slice() 方法创建并返回一个新的 Blob 对象,该对象包含调用它的 blob 的子集中的数据。fileChunkList.push({ chunkFile: file.slice(cur, cur + chunkSize) })cur += chunkSize}// 返回全部文件切片resolve(fileChunkList)})
}// 加载并计算文件切片的MD5
async function calculateChunksHash(fileChunkList) {// 初始化脚本const spark = new SparkMD5.ArrayBuffer()// 计算切片进度(拓展功能,可自行添加)let percentage = 0// 计算切片次数let count = 0// 递归函数,用于处理文件切片async function loadNext(index) {if (index >= fileChunkList.length) {// 所有切片都已处理完毕return spark.end() // 返回最终的MD5值}return new Promise((resolve, reject) => {const reader = new FileReader()reader.readAsArrayBuffer(fileChunkList[index].chunkFile)reader.onload = (e) => {count++spark.append(e.target.result)// 更新进度并处理下一个切片percentage += 100 / fileChunkList.lengthself.postMessage({ percentage }) // 发送进度到主线程resolve(loadNext(index + 1)) // 递归调用,处理下一个切片}reader.onerror = (err) => {reject(err) // 如果读取错误,则拒绝Promise}})}try {// 开始计算切片const fileHash = await loadNext(0) // 等待所有切片处理完毕self.postMessage({ percentage: 100, fileHash, fileChunkList }) // 发送最终结果到主线程self.close() // 关闭Worker} catch (err) {self.postMessage({ name: 'error', data: err }) // 发送错误到主线程self.close() // 关闭Worker}
}// 监听消息
self.addEventListener('message',async (e) => {try {const { file, chunkSize } = e.dataconst fileChunkList = await createFileChunk(file, chunkSize) // 创建文件切片await calculateChunksHash(fileChunkList) // 等待计算完成} catch (err) {// 这里实际上不会捕获到calculateChunksHash中的错误,因为错误已经在Worker内部处理了// 但如果未来有其他的异步操作,这里可以捕获到它们console.error('worker监听发生错误:', err)}},false
)// 主线程可以监听 Worker 是否发生错误。如果发生错误,Worker 会触发主线程的error事件。
self.addEventListener('error', function (event) {console.log('Worker触发主线程的error事件:', event)self.close() // 关闭Worker
})
秒传
原理:在上传文件之前先询问这个文件服务器端是否已存在,若已存在,则不需要上传了,直接让前端显示“已上传成功!”
断点上传
原理:询问后端这个文件我给你上传了多少切片,然后在前端把剩下的切片上传一下,然后再调用一下文件合并接口。
暂停上传
我们需要用到axios的AbortController方法去取消接口请求,或者使用cancelToekn方法也可以。
继续/恢复上传
原理:继续开始把剩下的分片的传给后台即可。