前后端大文件切片上传(详细步骤)

目录

一、为什么选择用切片

二、切片原理解析

三、 代码展示

前端:

后端:

四、技术问题总结

显示进度:

暂停上传:

Hash的优化空间:

限制请求个数:


一、为什么选择用切片

        对于文件通常前端向后端发送post或表单请求,基本是没什么问题的,但是大文件的上传是一个特殊的情况,不管怎样简单的需求,在量级达到一定层次时,都会变得异常复杂; 上传大文件时,以下几个变量会影响我们的用户体验,服务器处理数据的能力,请求超时,网络波动,失败后又需要重新上传等等。

        其实分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块 (Part) 来进行分片上传,从而减少请求时间,如何有某个分片请求失败,只需要重新发送即可,就不需要重新上传!

二、切片原理解析

        在 JavaScript 中,文件 File 对象是 Blob 对象的子类,Blob 对象包含一个重要的方法 slice,通过这个方法,我们就可以对二进制文件进行拆分。将文件拆分成 size 大小(可以是100k、500k、1M…)的分块,得到一个 file 的数组 fileChunkList,然后每次请求只需要上传这一个部分的分块即可。

 // 生成文件切片
function createFileChunk(file, size = SIZE) {
  const fileChunkList = []
  let cur = 0
  while (cur < file.size) {
    fileChunkList.push({
      file: file.slice(cur, cur + size),
    })
    cur += size
  }
  return fileChunkList
}

        拿到原文件的 hash 值是关键的一步,同一个文件就算改文件名,hash 值也不会变,就可以避免文件改名后重复上传的问题。这里,我们使用 spark-md5.min.js 来根据文件的二进制内容计算文件的 hash。我们传入切片后的 fileChunkList,利用 FileReader读取每个切片的 ArrayBuffer并不断传入 spark-md5 中,每计算完一个切片通过 postMessage 向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程。

// 计算 hash 代码
// public/hash.js
self.onmessage = e => {
	const { fileChunkList } = e.data
	const spark = new self.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(count)
}

        注意:考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash。

三、 代码展示

前端:

        验证文件是否已经在服务端存在,如果 shouldUpload 为 false,则表明这个文件不需要上传,相当于秒传成功。

/**
 * 返回值说明
 * shouldUpload:标识这个文件是否还需要上传
 * uploadedList: 服务端存在该文件的切片List
 */
const { shouldUpload, uploadedList } = await verifyUpload(
  container.file.name,
  container.hash
)

        在前端,需要使用 JavaScript 将文件分割成多个部分,并向后端发送分块数据。以下是一个示例代码:

javascript复制代码
function uploadFile(file) {
    const chunkSize = 1024 * 1024; // 每个部分的大小(1MB)
    const totalChunks = Math.ceil(file.size / chunkSize); // 总部分数
 
    let currentChunk = 1;
    let startByte = 0;
 
    while (startByte < file.size) { // 分割文件为多个部分,并上传每个部分
        const endByte = Math.min(startByte + chunkSize, file.size);
        const chunk = file.slice(startByte, endByte);
 
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('fileName', file.name);
        formData.append('chunkNumber', currentChunk);
        formData.append('totalChunks', totalChunks);
 
        axios.post('/upload', formData);
 
        startByte += chunkSize;
        currentChunk++;
    }
}

后端:

        需要在项目中添加以下依赖项:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
 
<dependency>
    <groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
    <version>1.4</version>
</dependency>

        存储分片和合并这里是写在一起了,可以拆开写,服务端根据文件标识,分片顺序进行合并,合并完以后删除分片文件。

@RestController
public class FileUploadController {
 
    private static final String UPLOAD_DIRECTORY = "/tmp/uploads";
 
    @PostMapping("/upload")
    public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file,
                                          @RequestParam("fileName") String fileName,
                                          @RequestParam("chunkNumber") int chunkNumber,
                                          @RequestParam("totalChunks") int totalChunks) throws IOException {
        
        
        File uploadDirectory = new File(UPLOAD_DIRECTORY);
        if (!uploadDirectory.exists()) {
            uploadDirectory.mkdirs();
        }
 
        File destFile = new File(UPLOAD_DIRECTORY + File.separator + fileName + ".part" + chunkNumber);
        FileUtils.copyInputStreamToFile(file.getInputStream(), destFile);
 
        if (chunkNumber == totalChunks) { // 如果所有部分都已上传,则将它们组合成一个完整的文件
            String targetFilePath = UPLOAD_DIRECTORY + File.separator + fileName;
            for (int i = 1; i <= totalChunks; i++) {
                File partFile = new File(UPLOAD_DIRECTORY + File.separator + fileName + ".part" + i);
                try (FileOutputStream fos = new FileOutputStream(targetFilePath, true)) {
                    FileUtils.copyFile(partFile, fos);
                    partFile.delete();
                }
            }
        }
 
        return ResponseEntity.ok("Upload successful");
    }
}

四、技术问题总结

显示进度:

        我们可以通过 onprogress 事件来实时显示进度,默认情况下这个事件每 50ms 触发一次。需要注意的是,上传过程和下载过程触发的是不同对象 onprogress事件:上传触发的是xhr.upload对象的onprogress 事件,下载触发的是 xhr 对象的 onprogress 事件。

xhr.onprogress = updateProgress;
xhr.upload.onprogress = updateProgress;

function updateProgress(event) {
  if (event.lengthComputable) {
    var completedPercent = event.loaded / event.total;
  }
}

PS 特别提醒:xhr.upload.onprogress 要写在 xhr.send 方法前面。

暂停上传:

        一个请求能被取消的前提是,我们需要将未收到响应的请求保存在一个列表中,然后依次调用每个 xhr 对象的 abort 方法。调用这个方法后,xhr 对象会停止触发事件,将请求的 status 置为 0,并且无法访问任何与响应有关的属性。

/**
 * 暂停
 */
function handlePause() {
  requestListArr.value.forEach((xhr) => xhr?.abort())
  requestListArr.value = []
}

        从后端的角度看,一个上传请求被取消,意味着当前浏览器不会再向后端传输数据流,后端此时会报错,如下,错误信息也很清楚,就是文件还没到末尾就被客户端中断。当前文件切片写入失败。

Hash的优化空间:

        计算hash 耗时的问题,不仅可以通过 web-workder,还可以参考 React 的 FFiber 架构,通过 requestIdleCallback 来利用浏览器的空闲时间计算,也不会卡死主线程;如果觉得文件全量 Hash 比较慢的话,还有一种方式就是计算抽样 Hash,减少计算的字节数可以大幅度减少耗时;在前文的代码中,我们是将大文件切片后,全量传入 spark-md5.min.js 中来根据文件的二进制内容计算文件的 hash 的。那么,举个例子,我们可以这样优化: 文件切片以后,取第一个和最后一个切片全部内容,其他切片的取 首中尾 三个地方各2各字节来计算 hash。这样来计算文件 hash 会快很多。

限制请求个数:

        解决了大文件计算 hash 的时间优化问题;下一个问题是:如果一个大文件切了成百上千来个切片,一次发几百个 http 请求,容易把浏览器搞崩溃。那么就需要控制并发,也就是限制请求个数。思路就是我们把异步请求放在一个队列里,比如并发数是4,就先同时发起4个请求,然后有请求结束了,再发起下一个请求即可。我们通过并发数 max 来管理并发数,发起一个请求 max--,结束一个请求 max++ 即可。

  • 5
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

10JQK炸

如果对您有所帮助,请给点鼓励吧

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

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

打赏作者

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

抵扣说明:

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

余额充值