Vue3+SpringBoot 实现大文件分片上传、断点续传、暂停上传、上传进度、速度、剩余时间显示(超详细~)

前言

记录一次从0实现大文件上传方案,前端采用的是 Vue3 和 ElementPlus 组件开发,后端采用的是 SpringBoot2 + Redis,文件存储是用的是腾讯云对象存储。文末有效果图及完整代码地址,感兴趣的同学可以看看。

本文会从以下步骤一步一步实现:

  1. 文件切片上传
  2. 实现进度条
  3. 断点续传
  4. 合并切片
  5. 功能优化

在开始之前,我已初始化好了前后端的基础项目模版,有需要的可前往仓库的 init 文件夹自取

文件切片上传

后端

后端这里主要实现一个分片文件上传的接口,将接收到的分片文件上传到 cos 中,切片信息存入 redis 中。

1. 定义分片请求DTO

java 复制代码
  @Data  
  public class ChunkUploadRequest implements Serializable {  
    private static final long serialVersionUID = 1L;  
  
    /**
     *  文件 hash
     */
    @NotBlank (message = "文件hash不能为空")
    private String fileHash;

    /**
     * 文件名
     */
    @NotBlank (message = "文件名不能为空")
    private String fileName;

    /**
     *  切片索引
     */
    @NotNull (message = "索引不能为空")
    private Integer chunkIndex;


      /**
      *  业务类型
      */
    @NotBlank (message = "业务类型不能为空")
    private String biz;
  
}

2. 实现接口逻辑

java 复制代码
/**  
* 大文件上传  
*  
* @author YL526246  
*/  
@RestController  
@RequestMapping("/chunk/file")  
public class BigFileUploadController {  
    @Resource  
    private BigFileUploadService bigFileUploadService;  

    @Resource  
    UserService userService;  
 
    /**  
    * 上传分片文件  
    * @param chunkFile  
    * @param request  
    * @return  
    */  
    @PostMapping("/upload")  
    public BaseResponse<Boolean> uploadChunk(@Valid @RequestPart("chunk") MultipartFile chunkFile,  
    @Valid ChunkUploadRequest chunkUploadRequest, HttpServletRequest request) {  
        User loginUser = userService.getLoginUser(request);  
        Long userId = loginUser.getId();  

        Boolean result = bigFileUploadService.uploadChunk(chunkUploadRequest, chunkFile, userId);  
        return ResultUtils.success(result);  
    }  
  
}

这里使用的是 Redis 的 Hash 类型来存储分片信息,将用户id、文件hash等信息作为 redis 的 key,将分片索引作为 Hash 的 key,分片的路径作为 Hash 的 value,便于在后面查询哪些分片是已经上传了的

java 复制代码
private static final String FILE_STATUS_KEY_PREFIX = "file_status:";
 
public Boolean uploadChunk(ChunkUploadRequest chunkUploadRequest, MultipartFile chunkFile, Long userId) {  
    try {  
        // 定义该文件在 Redis 中的 key  
        String statusKey = FILE_STATUS_KEY_PREFIX + userId + ":" + chunkUploadRequest.getFileHash();  
        // 构建切片存储路径  
        String chunkPath = String.format("/chunks/%s/%s/%s_%d", chunkUploadRequest.getBiz(), userId, chunkUploadRequest.getFileHash(), chunkUploadRequest.getChunkIndex());  

        // 创建临时文件  
        File tempFile = File.createTempFile("chunk_", null);  
        // 将上传的文件写入临时文件  
        chunkFile.transferTo(tempFile);  

        // 上传切片到COS  
        cosManager.putObject(chunkPath, tempFile);  

        // 将上传的切片信息保存到 Redis 中 (使用 Hash 类型,key 为分片索引,value 为分片路径)  
        redisTemplate.opsForHash().put(statusKey, chunkUploadRequest.getChunkIndex().toString(), chunkPath);  

        // 设置过期时间  
        redisTemplate.expire(statusKey, 24, TimeUnit.HOURS);  

        // 删除临时文件  
        boolean deleteRes = tempFile.delete();  
        return deleteRes;  

    } catch (IOException e) {  
        log.error("上传切片失败: {}", e.getMessage(), e);  
        ThrowUtils.throwIf(true, ErrorCode.SYSTEM_ERROR, "分片上传失败");  
        return false;  
    }  
  
}

前端

1. 实现计算文件 hash 方法

下载依赖:npm install spark-md5 ,创建 fileUploadUtils.js

js 复制代码
import SparkMD5 from "spark-md5";

export class FileUploadUtils {
  constructor( chunkSize = 2 * 1024 * 1024) { 
    this.CHUNK_SIZE  = chunkSize  // 分片大小
  }

  /**
   * 计算文件hash
   * @param {File} file 
   * @returns 
   */
  async calculateFileHash(file) {
    return new Promise((resolve, reject) => {
      const spark = new SparkMD5.ArrayBuffer()
      const fileReader = new FileReader()
      
      // 计算分片数量
      const chunks = Math.ceil(file.size / this.CHUNK_SIZE)
      let currentChunk = 0 

      // 读取文件
      fileReader.onload = (e) => {
        // 计算hash
        spark.append(e.target.result)
        currentChunk++
        if (currentChunk < chunks) {
          loadNext()
        } else {
          // 返回hash
          resolve(spark.end())
        }
      }
      
      // 读取失败 
      fileReader.onerror = (e) => {
        console.error('文件读取失败', e)
        reject(e)
      }
     
      // 读取下一个分片
      const loadNext = () => {
        // 计算分片开始和结束位置
        const start = currentChunk * this.CHUNK_SIZE
        const end = Math.min(start + this.CHUNK_SIZE, file.size)
        // 读取分片
        const chunk = file.slice(start, end)
        fileReader.readAsArrayBuffer(chunk)
      }
      loadNext()
    })
  }
  
}

2. 实现文件分片方法

在 fileUploadUtils,js 中增加这个方法

js 复制代码
  /**
   * 创建文件分片
   * @param {File} file 
   * @returns 
   */
  createFileChunks(file){
    const chunks = []
    // 计算分片数量
    const chunkCount = Math.ceil(file.size / this.CHUNK_SIZE)
    // 遍历分片
    for (let i = 0; i < chunkCount; i++) {
      // 计算分片开始和结束位置
      const start = i * this.CHUNK_SIZE
      const end = Math.min(start + this.CHUNK_SIZE, file.size)
      // 读取分片
      const chunk = file.slice(start, end)
      // 添加到chunks
      chunks.push({
        chunk,
        index: i,
        start,
        end
      })  
    }
    // 返回分片数组
    return chunks
  }

3. 创建上传任务

在上传组件中,选择完文件后点击上传时,遍历所选择的文件,将每个文件传入到 createUploadTask 中,在 createUploadTask 中定义了一个 uploadFile 对象,这个对象包含了该文件上传流程所需要的参数,然后调用前面实现的获取文件 hash 值方法和分片方法,最后将 uploadFile 对象传入到并发上传函数 uploadFileChunks 方法执行

js 复制代码
const submitUpload = async ( ) => {
  // 校验是否已登录
  await getLoginInfo()
  if(!fileList.value.length){
    ElMessage.warning('请选择文件')
    return
  }
  console.log(fileList.value);
  const uploadTasks = fileList.value.map(file => createUploadTask(file.raw))
  console.log(uploadTasks);
  
  await Promise.all(uploadTasks)
 
}

// 创建上传任务
const createUploadTask = async (file) => {
  const uploadFile = reactive({
    uid: file.uid,
    name: file.name,
    size: file.size,
    percentage: 0,
    status: 'uploading',
    speed:"0 kb/s",
    remainingTime:"--",
    uploadedChunks:[], // 已上传的分片
    totalChunks:0, // 总分片数
    fileHash:"", // 文件hash
    chunks:[], // 分片列表
    startTime:new Date(), // 开始时间
    endTime:null, // 结束时间
    uploadedBytes:0, // 已上传的字节数
    isPaused:false, // 是否暂停
    abortController: new AbortController(), // 中止控制器

  })
  uploadingFiles.value.push(uploadFile)

  try{
    // 计算文件hash
    uploadFile.fileHash = await fileUploadUtils.calculateFileHash(file)

    // 创建分片
    uploadFile.chunks = fileUploadUtils.createFileChunks(file)
    uploadFile.totalChunks = uploadFile.chunks.length

    // 上传分片
    await uploadFileChunks(uploadFile)

  }catch(error){
    console.log(error)
  }
 
}

4. 实现并发上传

接收 uploadFile 对象,定义并发限制数量和待上传的分片索引数组,通过 Math.min 取出最大并发数,然后通过循环遍历实现同时调起多个 uploadChunkWorker 来执行上传操作

js 复制代码
const uploadFileChunks = async (uploadFile) => {
  const concurrentLimit  = 3
  const pendingChunks  = [] // 待上传的分片索引

  // 将未上传的分片索引加入待上传队列
  for(let i = 0; i < uploadFile.chunks.length; i++){ 
    if(!uploadFile.uploadedChunks.includes(i)){
      pendingChunks.push(i)
    }
  }

  // 并发上传 
  for(let i = 0; i < Math.min(concurrentLimit,pendingChunks.length); i++){
    uploadChunkWorker(uploadFile, pendingChunks)
  }
}

5. 分片上传工作函数

进入循环持续上传任务,从待上传的数组中取出一个分片,调用上传接口,如果上传失败则将重新添加到 pendingChunks,上传成功则添加到已完成的数组,直到清空 pendingChunks 或者暂停

js 复制代码
const uploadChunkWorker = async (uploadFile, pendingChunks) => {
  while (pendingChunks.length > 0 && !uploadFile.isPaused) {
    const chunkIndex = pendingChunks.shift()
    if (chunkIndex === undefined) {
      break
    }

    const chunk = uploadFile.chunks[chunkIndex] 
    const formData = new FormData()
    formData.append('fileHash', uploadFile.fileHash)
    formData.append('fileName', uploadFile.name)
    formData.append('chunkIndex', chunkIndex.toString())
    formData.append('chunk', chunk.chunk)
    formData.append('totalChunks', uploadFile.totalChunks.toString())
    // TODO
    formData.append('biz', 'big_file')

    try {
      await chunkFileUpload(formData)
      uploadFile.uploadedChunks.push(chunkIndex)
      console.log( '上传成功的分片索引:', chunkIndex)
    } catch (error) { 
       if (error.name === 'AbortError') {
        console.log('上传中止')
        break
      }
      pendingChunks.unshift(chunkIndex)
      console.log(error)
    }
  }
}

到这一步分片上传已完成了,这里设置的并发数是3,可以看到同时只存在3个分片的网络请求

上传进度条

在定义上传分片请求接口函数时增加一个上传进度的回调函数 onUploadProgress

js 复制代码
  export const chunkFileUpload = (data,onUploadProgress) =>{
    return request({
      url: '/api/chunk/file/upload',
      method: 'post',
      data,
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      onUploadProgress 
    })
  }

实现更新进度的方法

js 复制代码
const updateUploadProgress = (uploadFile, progressEvent) => {
  const chunkProgress = progressEvent.loaded / progressEvent.total // 计算分片进度
  const completedChunks = uploadFile.uploadedChunks.length // 已完成的分片数
  const totalChunks = uploadFile.totalChunks // 总分片数
  const totalProgress = Math.round(((completedChunks + chunkProgress) / totalChunks) * 100)
  console.log('上传总进度:', totalProgress+'%')
  uploadFile.percentage = totalProgress // 更新文件的上传进度字段
}

在前面调用 chunkFileUpload 上传分片接口的回调函数中执行 updateUploadProgress

js 复制代码
  await chunkFileUpload(formData, (e) => {
        updateUploadProgress(uploadFile, chunkIndex, e) 
     })

输出效果图:

断点续传

后端

实现一个检查文件状态的接口,告诉前端该文件处于什么状态(待上传、上传中、已完成)。 这个 statusKey 跟前面上传切片时的 key 是一样的,如果某个文件上传过切片的话,那么在 Redis 中是有记录的,我们就通过这个 statusKey 去 redis 中查询已上传了的分片信息,如果是已上传完成的话就直接返回文件的url(这个url 我们会在后面合并切片时设置),然后判断是否有记录,没有的话就是待上传(pending),有的话就返回分片索引

java 复制代码
    @PostMapping("/check/{fileHash}")
    public BaseResponse<Map<String, Object>> checkFileStatus(@PathVariable String fileHash, HttpServletRequest request) {
        ThrowUtils.throwIf(fileHash == null, ErrorCode.PARAMS_ERROR);
        User loginUser = userService.getLoginUser(request);
        Long userId = loginUser.getId();
        Map<String, Object> result = bigFileUploadService.checkFileStatus(fileHash, userId);
        return ResultUtils.success(result);
    }
java 复制代码
public Map<String, Object> checkFileStatus(String fileHash, Long userId) {  
    // 定义该文件在 Redis 中的 key  
    String statusKey = FILE_STATUS_KEY_PREFIX + userId + ":" + fileHash;  
    // 定义返回结果  
    HashMap<String, Object> result = new HashMap<>();  

    // 检查文件是否上传完成  
    Boolean isCompleted = (Boolean) redisTemplate.opsForHash().get(statusKey, "completed");  
    if (isCompleted != null && isCompleted) {  
        // 文件上传成功  
        result.put("status", "completed");  
        result.put("url", redisTemplate.opsForHash().get(statusKey, "url"));  
        return result;  
    }  

    // 获取已上传的分片索引  
    Set<Object> uploadedChunks = redisTemplate.opsForHash().keys(statusKey);  
    if (uploadedChunks.isEmpty()) {  
        // 文件未上传  
        result.put("status", "pending");  
        result.put("uploadedChunks", new Integer[0]);  
        return result;  
    }  
    // 将已上传的分片索引转换为整数列表  
    List<Integer> uploadedChunkIndexes = uploadedChunks.stream()  
    .map(obj -> Integer.valueOf(obj.toString()))  
    .collect(Collectors.toList());  

    result.put("uploadedChunks", uploadedChunkIndexes);  
    result.put("status", "uploading");  
    return result;  
  
}

前端

前面上传分片时我们是直接上传的,现在需要在执行 uploadFileChunks 上传之前,调用校验文件状态的接口,如果是上传中的状态的话,就把接口返回的切片索引加入到 uploadFile 的 uploadedChunks 数组中。

修改 createUploadTask 方法

js 复制代码
const createUploadTask = async (file) => {
  const uploadFile = reactive({
    uid: file.uid,
    name: file.name,
    size: file.size,
    percentage: 0,
    status: 'uploading',
    speed: '0 kb/s',
    remainingTime: '--',
    uploadedChunks: [], // 已上传的分片
    totalChunks: 0, // 总分片数
    fileHash: '', // 文件hash
    chunks: [], // 分片列表
    startTime: new Date(), // 开始时间
    endTime: null, // 结束时间
    uploadedBytes: 0, // 已上传的字节数
    isPaused: false, // 是否暂停
    abortController: new AbortController(), // 中止控制器
  })
  uploadingFiles.value.push(uploadFile)

  try {
    // 计算文件hash
    uploadFile.fileHash = await fileUploadUtils.calculateFileHash(file)

    // 创建分片
    uploadFile.chunks = fileUploadUtils.createFileChunks(file)
    uploadFile.totalChunks = uploadFile.chunks.length

    // 检查文件上传状态
    const res = await chunkFileCheck({
      fileHash: uploadFile.fileHash,
    })
    // console.log(res)
    // 文件已完全上传
    if (res.status === 'completed') {
      console.log('文件已完全上传',res.url)
      uploadFile.percentage = 100
      uploadFile.status = 'success'
      return
    }
    if (res.status === 'uploading') {
      console.log('文件部分已上传')
      uploadFile.uploadedChunks = res.uploadedChunks || []
    }

    // 上传分片
    await uploadFileChunks(uploadFile)
 
  } catch (error) {
      uploadFile.status = 'exception'
    console.log(error)
  }
}

在前面的 uploadFileChunks 方法中,我们定义待上传的分片索引数组时,已经排除了uploadFile 的 uploadedChunks 数组,至此就已经实现断点续传功能了,当上传一半刷新页面重新上传同一个文件时只会上传后面那部分的切片了

合并分片

后端

定义合并分片请求所需要的参数

java 复制代码
@Data  
public class MergeChunkRequest implements Serializable {  
    private static final long serialVersionUID = 1L;  

    /**  
    * 文件 hash  
    */  
    @NotBlank(message = "文件hash不能为空")  
    private String fileHash;  

    /**  
    * 文件名  
    */  
    @NotBlank(message = "文件名不能为空")  
    private String fileName;  

    /**  
    * 业务类型  
    */  
    @NotBlank(message = "业务类型不能为空")  
    private String biz;  

    /**  
    * 分片数量  
    */  
    @NotNull(message = "分片数量不能为空")  
    private Integer totalChunks;  
}

实现合并请求的逻辑,先根据 statusKey 取出所有分片信息校验数量是否缺少以及路径是否都存在,然后创建临时合并文件、文件名称,遍历所有分片,根据分片路径去 cos 取出分片文件并写入到合并文件中,所有分片写入完成后将这个合并文件上传到 cos 中,最后在 redis 中删除这个文件切片记录,并写入这个文件的状态为已完成以及保存这个文件的url。

java 复制代码
   public String mergeChunks(MergeChunkRequest mergeChunkRequest, Long userId) {
        // 定义该文件在 Redis 中的 key
        String statusKey = FILE_STATUS_KEY_PREFIX + userId + ":" + mergeChunkRequest.getFileHash();
        int totalChunks = mergeChunkRequest.getTotalChunks();
        // 获取文件已上传的分片信息
        Map<Object, Object> chunks = redisTemplate.opsForHash().entries(statusKey);
        // 检查分片数量是否正确
        if (chunks.size() != totalChunks) {
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "文件缺失,请重新上传");
        }
        // 取出所有分片路径
        ArrayList<Object> chunkPaths = new ArrayList<>();
        for (int i = 0; i < totalChunks; i++) {
            String path = (String) chunks.get(String.valueOf(i));
            ThrowUtils.throwIf(path == null, ErrorCode.OPERATION_ERROR, "切片" + i + "不存在");
            chunkPaths.add(path);
        }

        String uuid = IdUtil.fastSimpleUUID();
        String fileName = uuid + "-" + mergeChunkRequest.getFileName(); // 生成随机文件名
        String finalPath = String.format("/%s/%s/%s", mergeChunkRequest.getBiz(), userId, fileName);  // 构建最终存储路径
        // 创建合并文件
        File mergeFile = FileUtil.createTempFile("merge_", ".tmp", null, true);
        try(BufferedOutputStream outputStream = FileUtil.getOutputStream(mergeFile)) {
            //  遍历所有分片,将每个分片内容写入合并文件
            for (Object chunkPath : chunkPaths) {
                File chunkFile = FileUtil.createTempFile("chunk_", ".tmp", null, true);
                try( BufferedInputStream inputStream = FileUtil.getInputStream(chunkFile)) {
                    cosManager.getObject(chunkPath.toString(), chunkFile);
                    // 将分片内容写入合并文件
                    IoUtil.copy(inputStream, outputStream);
                } finally {
                    //  删除分片文件
                    FileUtil.del(chunkFile);
                }
            }
            // 上传合并文件到COS
            cosManager.putObject(finalPath, mergeFile);
            //  删除cos中的分片文件
            for (Object chunkPath : chunkPaths) {
                cosManager.deleteObject(chunkPath.toString());
            }
            // 删除切片记录
            redisTemplate.delete(statusKey);
            // 获取文件的URL
            String fileUrl = FileConstant.COS_HOST + finalPath;
            // 设置文件状态及url
            redisTemplate.opsForHash().put(statusKey, "completed", true);
            redisTemplate.opsForHash().put(statusKey, "url", fileUrl);
            // 设置过期时间
            redisTemplate.expire(statusKey, 24 * 30, TimeUnit.HOURS);
            return fileUrl;
        } catch (Exception e) {
            log.error("合并文件失败: {}", e.getMessage(), e);
            redisTemplate.delete(statusKey);
            cosManager.deleteObject(finalPath);
            ThrowUtils.throwIf(true, ErrorCode.SYSTEM_ERROR, "合并文件失败");
            return null;
        } finally {
            // 删除临时合并文件
            FileUtil.del(mergeFile);
        }

    }

前端

我们需要等 uploadFileChunks 全部分片上传完成再执行合并操作,所以前面实现的 uploadFileChunks 方法需要改一下,让其返回 Promise

js 复制代码
const uploadFileChunks = async (uploadFile) => {
  const concurrentLimit = 3 // 并发限制
  const pendingChunks = [] // 待上传的分片索引

  // 将未上传的分片索引加入待上传队列
  for (let i = 0; i < uploadFile.chunks.length; i++) {
    if (!uploadFile.uploadedChunks.includes(i)) {
      pendingChunks.push(i)
    }
  }

  // 并发上传
  const uploadPromises = []
  for (let i = 0; i < Math.min(concurrentLimit, pendingChunks.length); i++) {
     uploadPromises.push(uploadChunkWorker(uploadFile, pendingChunks))
  }
  await Promise.all(uploadPromises)
}

然后在 createUploadTask 执行完 uploadFileChunks 后,调用合并接口,合并成功后修改 uploadFile 的进度为100,状态为 success

js 复制代码
const createUploadTask = async (file) => {
  const uploadFile = reactive({
    uid: file.uid,
    name: file.name,
    size: file.size,
    percentage: 0,
    status: 'uploading',
    speed: '0 kb/s',
    remainingTime: '--',
    uploadedChunks: [], // 已上传的分片
    totalChunks: 0, // 总分片数
    fileHash: '', // 文件hash
    chunks: [], // 分片列表
    startTime: new Date(), // 开始时间
    endTime: null, // 结束时间
    uploadedBytes: 0, // 已上传的字节数
    isPaused: false, // 是否暂停
    abortController: new AbortController(), // 中止控制器
  })
  uploadingFiles.value.push(uploadFile)

  try {
    // 计算文件hash
    uploadFile.fileHash = await fileUploadUtils.calculateFileHash(file)

    // 创建分片
    uploadFile.chunks = fileUploadUtils.createFileChunks(file)
    uploadFile.totalChunks = uploadFile.chunks.length

    // 检查文件上传状态
    const res = await chunkFileCheck(uploadFile.fileHash)
    // console.log(res)
    // 文件已完全上传
    if (res.status === 'completed') {
      console.log('文件已完全上传',res.url)
      return
    }
    if (res.status === 'uploading') {
      console.log('文件部分已上传')
      uploadFile.uploadedChunks = res.uploadedChunks || []
    }

    // 上传分片
    await uploadFileChunks(uploadFile)

    // 合并分片文件
   const mergeRes = await mergeChunkFile({
      fileHash: uploadFile.fileHash,
      fileName: uploadFile.name,
      totalChunks: uploadFile.totalChunks,
      biz: 'big_file',
    })

    uploadFile.progress = 100
    uploadFile.status = 'success'
    uploadFile.endTime = new Date()
    console.log(mergeRes)

至此,合并分片功能已完成,当合并接口调用成功后会返回文件的url,如果再次上传一个已完成的文件也不会上传分片了,会直接返回 url 了

功能优化

目前主要针对前端相关功能优化,完善以下功能:

上传前校验

这里主要实现文件大小和文件格式的校验,在 fileUploadUtils.js 新增两个方法 checkFileType 和 checkFileSize

js 复制代码
  checkFileType(file, allowedTypes = []) {
    if (allowedTypes.length === 0) return true
    let fileExtension = this.getFileExtension(file.name)
    return allowedTypes.includes(fileExtension)
  }
    /**
   * 获取文件扩展名
   * @param {*} filename 
   * @returns 
   */
  getFileExtension(filename) {
    if (!filename || typeof filename !== 'string') return ''
    const lastDotIndex = filename.lastIndexOf('.')
    if (lastDotIndex <= 0 || lastDotIndex === filename.length - 1) {
      return ''
    }
    return filename.slice(lastDotIndex + 1)
  }
  checkFileSize(file, maxSize = 10 * 1024 * 1024) {
    return file.size <= maxSize
  }

然后在 MyUpload 中把 show-file-list 设置为 false,我们自定义实现文件列表 fileList 的展示,在 on-change 钩子中来校验文件,通过的话就 push 到 fileList

js 复制代码
const handleChange = (file) => {
  let flag = fileUploadUtils.checkFileType(file, ['jpg', 'png', 'mp3','zip'])
  if (!flag) {
    return ElMessage.warning('文件格式不正确')
  }
 if(!fileUploadUtils.checkFileSize(file, 100 * 1024 * 1024)){
    return ElMessage.warning('文件大小不能超过100M')
 }
  fileList.value.push(file)
}

显示上传进度、速度和剩余时间

前面已经实现了上传进度的百分比,这里主要实现上传速度和剩余时间。上传速度计算方式:已上传的字节数 / 已经过的时间(秒) = 每秒上传多少字节 ,剩余时间计算方式:剩余字节 / 每秒能传多少字节 = 大概剩余时间(秒)

1.上传进度

使用 el-progress 组件来显示进度条

js 复制代码
  <div>
    <el-upload
      ref="uploadRef"
      action=""
      :auto-upload="false"
      :show-file-list="false"
      :before-upload="beforeUpload"
      :on-remove="handleRemove"
      :on-change="handleChange"
    >
      <template #trigger>
        <el-button type="primary">选择文件</el-button>
      </template>
      <el-button class="ml-3" type="success" @click="submitUpload"> 上传 </el-button>
    </el-upload>
    <div v-if="fileList.length">
      待上传文件:
      <div v-for="item in fileList" :key="item">
        {{ item.name }}
      </div>
    </div>
    <div v-if="uploadingFiles.length">
      正在上传文件:
      <div v-for="item in uploadingFiles" :key="item">
        {{ item.name }}
        <el-progress :status="item.status" :text-inside="true" :stroke-width="20" :percentage="item.percentage" />
      </div>
    </div>

2. 上传速度

在之前的更新上传进度方法函数 updateUploadProgress 中,增加上传速度的计算

js 复制代码
const updateUploadProgress = (uploadFile, progressEvent) => {
  // 计算上次进度
  const chunkProgress = progressEvent.loaded / progressEvent.total // 计算分片进度
  const completedChunks = uploadFile.uploadedChunks.length // 已完成的分片数
  const totalChunks = uploadFile.totalChunks // 总分片数
  const totalProgress = Math.round(((completedChunks + chunkProgress) / totalChunks) * 100)
  console.log('上传总进度:', totalProgress + '%')
  uploadFile.percentage = totalProgress // 更新文件的上传进度字段

  // 计算上传速度
  const currentTime = new Date()
  const elapsedTime = (currentTime - uploadFile.startTime) / 1000 // 计算已用时间(秒)
  const uploadedBytes =  completedChunks * fileUploadUtils.CHUNK_SIZE + progressEvent.loaded // 计算已上传的总字节数
  const uploadSpeed = uploadedBytes / elapsedTime // 计算上传速度(bytes/s) 
  
  console.log('上传速度:', uploadSpeed + ' bytes/s')
  uploadFile.speed = uploadSpeed  
}

3.剩余时间

依旧还是在 updateUploadProgress 方法中计算预计剩余时间

js 复制代码
const updateUploadProgress = (uploadFile, progressEvent) => {
  // 计算上次进度
  const chunkProgress = progressEvent.loaded / progressEvent.total // 计算分片进度
  const completedChunks = uploadFile.uploadedChunks.length // 已完成的分片数
  const totalChunks = uploadFile.totalChunks // 总分片数
  const totalProgress = Math.round(((completedChunks + chunkProgress) / totalChunks) * 100)
  console.log('上传总进度:', totalProgress + '%')
  uploadFile.percentage = totalProgress // 更新文件的上传进度字段

  // 计算上传速度
  const currentTime = new Date()
  const elapsedTime = (currentTime - uploadFile.startTime) / 1000 // 计算已用时间(秒)
  const uploadedBytes =  completedChunks * fileUploadUtils.CHUNK_SIZE + progressEvent.loaded // 计算已上传的总字节数
  const uploadSpeed = uploadedBytes / elapsedTime // 计算上传速度(bytes/s) 
  
  console.log('上传速度:', uploadSpeed + ' bytes/s')
  uploadFile.speed = uploadSpeed  

  // 计算剩余时间
  if(uploadSpeed > 0) {
    const remainingBytes = uploadFile.size - uploadedBytes // 计算剩余字节数
    const remainingTime = Math.round(remainingBytes / uploadSpeed) // 计算剩余时间(秒)
    console.log('预计剩余时间:', remainingTime + '秒') 
  }
}

4. 优化显示效果

到这里已经实现这些功能了,但是现在上传速度的单位是字节数/秒,我们需要实现一个方法,根据字节数大小自动切换成 Kb、Mb、Gb的形式,还有剩余时间也是不让他固定为秒

js 复制代码
  formatFileSize(bytes) {
    if (bytes === 0) return '0 Bytes'
    const k = 1024
    const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
    // 计算单位,这一步也就是找出 bytes 是 k 的多少次方
    const i = Math.floor(Math.log(bytes) / Math.log(k))
    // 返回格式化后的文件大小 
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
  }
  const formatTime = (seconds) => {
    if (seconds < 60) {
      return Math.round(seconds) + '秒'
    } else if (seconds < 3600) {
      return Math.round(seconds / 60) + '分钟'
    } else {
      return Math.round(seconds / 3600) + '小时'
    }
  }
}

这是正常上传速度效果图:

这是把网络改成慢速4G时上传的效果图:

最终完整的 updateUploadProgress 方法为

js 复制代码
const updateUploadProgress = (uploadFile, progressEvent) => {
  // 计算上次进度
  const chunkProgress = progressEvent.loaded / progressEvent.total // 计算分片进度
  const completedChunks = uploadFile.uploadedChunks.length // 已完成的分片数
  const totalChunks = uploadFile.totalChunks // 总分片数
  const totalProgress = Math.round(((completedChunks + chunkProgress) / totalChunks) * 100)
  console.log('上传总进度:', totalProgress + '%')
  uploadFile.percentage = totalProgress // 更新文件的上传进度字段

  // 计算上传速度
  const currentTime = new Date()
  const elapsedTime = (currentTime - uploadFile.startTime) / 1000 // 计算已用时间(秒)
  const uploadedBytes = completedChunks * fileUploadUtils.CHUNK_SIZE + progressEvent.loaded // 计算已上传的总字节数
  const uploadSpeed = uploadedBytes / elapsedTime // 计算上传速度(bytes/s)
  console.log('上传速度:', fileUploadUtils.formatFileSize(uploadSpeed) + '/s')
  uploadFile.speed = fileUploadUtils.formatFileSize(uploadSpeed) + '/s'

  // 计算剩余时间
  if (uploadSpeed > 0) {
    const remainingBytes = uploadFile.size - uploadedBytes // 计算剩余字节数
    const remainingTime = Math.round(remainingBytes / uploadSpeed) // 计算剩余时间(秒)
    console.log('预计剩余时间:', formatTime(remainingTime))
    uploadFile.remainingTime = formatTime(remainingTime)
  }
}

暂停上传

在前面定义 uploadFile 对象时,有一个属性 abortController: new AbortController() ,调用这个的 abort() 方法就可以实现中止异步操作

js 复制代码
const pauseUpload = (uploadFile) => {
  console.log('暂停上传');
  uploadFile.isPaused = true
  uploadFile.abortController.abort()
  uploadFile.status = 'paused'
}

继续上传

这里也就是重新走一遍上传分片的流程,注意在 createUploadTask 函数中,调用合并分片之前需要判断下这个文件是否是暂停状态,是的话就不要合并了

js 复制代码
const resumeUpload = async (uploadFile) => {
  console.log('继续上传');
  try {
    uploadFile.isPaused = false
    uploadFile.status = 'uploading'
    uploadFile.abortController = new AbortController()

    // 上传分片
    await uploadFileChunks(uploadFile)
    if(uploadFile.isPaused) return

    // 合并分片文件
    const mergeRes = await mergeChunkFile({
      fileHash: uploadFile.fileHash,
      fileName: uploadFile.name,
      totalChunks: uploadFile.totalChunks,
      biz: 'big_file',
    })
    uploadFile.percentage = 100
    uploadFile.status = 'success'
    uploadFile.endTime = new Date()
    console.log(mergeRes)
  } catch (error) {
    uploadFile.status = 'exception'
    console.log(error)
  }
}

取消上传

这里取消上传的功能也就是在暂停的基础上,将这个 uploadFile 从 uploadingFiles 中删除

js 复制代码
const cancelUpload = (uploadFile) => {
  pauseUpload(uploadFile)
  const index = uploadingFiles.value.findIndex((item) => item.uid  === uploadFile.uid )
  if(index > -1){
    uploadingFiles.value.splice(index, 1)
  }
}

动态设置分片大小

前面我们在 fileUploadUtils 定义的分片大小是固定的为2M的,这样当上传一些比较小的文件被分成的片数会很少,出现进度条不是很流畅的效果,我们现在定义一个方法来根据文件的大小计算出分片大小,

js 复制代码
  // 动态计算分片大小
  calculateDynamicChunkSize(fileSize) {
    const MB = 1024 * 1024;
    const minChunkSize = 512 * 1024; // 最小分片大小 
    const maxChunkSize = 5 * MB;     // 最大分片大小
    const step = 256 * 1024;         // 步进:256KB

    // 默认切 50 块
    let idealChunkSize = fileSize / 50;

    // 限制在允许范围内
    idealChunkSize = Math.max(minChunkSize, Math.min(idealChunkSize, maxChunkSize));
    return Math.ceil(idealChunkSize / step) * step;
  }

然后在 uploadFile 加一个 chunkSize 字段,调用计算hash方法之前获取到分片大小并赋值,在使用到分片大小的地方都修改一下就可以了

结语

到这里,本文所有的功能已经完成了,在测试时发现,因为是切片是放到cos中而且是串行处理分片的,所以合并接口处理很慢,然后就改成通过线程池并行处理了,提升了至少一半的速度,不擅长后端所以具体实现就不在这里讲了,另外,代码也实现了将切片放到本地,感兴趣的可以看看。

效果图

以下是在所有功能完成后让ai优化的页面样式效果

完整代码地址:github.com/All8926/big... (求个Star~)

相关推荐
yuren_xia3 小时前
Spring Boot中保存前端上传的图片
前端·spring boot·后端
普通网友4 小时前
Web前端常用面试题,九年程序人生 工作总结,Web开发必看
前端·程序人生·职场和发展
站在风口的猪11085 小时前
《前端面试题:CSS对浏览器兼容性》
前端·css·html·css3·html5
青莳吖7 小时前
使用 SseEmitter 实现 Spring Boot 后端的流式传输和前端的数据接收
前端·spring boot·后端
CodeCraft Studio7 小时前
PDF处理控件Aspose.PDF教程:在 C# 中更改 PDF 页面大小
前端·pdf·c#
拉不动的猪8 小时前
TS常规面试题1
前端·javascript·面试
再学一点就睡8 小时前
实用为王!前端日常工具清单(调试 / 开发 / 协作工具全梳理)
前端·资讯·如何当个好爸爸
Jadon_z8 小时前
vue2 项目中 npm run dev 运行98% after emitting CopyPlugin 卡死
前端·npm
一心赚狗粮的宇叔9 小时前
web全栈开发学习-01html基础
前端·javascript·学习·html·web
IT瘾君9 小时前
JavaWeb:前端工程化-ElementPlus
前端·elementui·node.js·vue