vue 大文件分片上传

602 阅读4分钟

最近项目上遇到了一个大文件上传的需求,故有这篇文章

我所用的框架是 element-ui vue2

使用的分片上传的组件插件是 spark-md5

1. 下载插件

npm i spark-md5

2. html的文件内容

          <div class="upload-container">
            <el-upload
              class="upload-demo"
              action="#"
              :http-request="customUploadRequest"
              :before-upload="beforeUpload"
              :on-progress="handleProgress"
              :show-file-list="true"
              :file-list="fileList"
              :on-remove="handleRemove"
            >
              <el-button size="small" type="primary" icon="el-icon-upload2" :disabled="uploading || isParsingLargeFile">点击上传</el-button>
            </el-upload>

            <div class="upload-progress" v-if="uploading">
              <el-progress
                :percentage="uploadProgress"
                :stroke-width="18"
              ></el-progress>
              <div class="chunk-info">{{ currentChunk }}/{{ totalChunks }} 上传中...</div>
              <!-- <el-button type="danger" @click="pauseUpload" icon="el-icon-video-pause">暂停上传</el-button> -->
            </div>

            <div class="resume-upload" v-if="canResume && !uploading">
              <el-button type="primary" @click="resumeUpload" icon="el-icon-video-play">继续上传</el-button>
            </div>
          </div>

3. script 文件所在的内容

// 分片上传代码
    beforeUpload (file) {
      // 设定最大文件大小(单位:字节),比如100MB
      const MAX_SIZE = 100 * 1024 * 1024
      if (file.size > MAX_SIZE) {
        // 文件过大,给用户提示
        // this.$message.warning('文件过大,正在解析,请稍等片刻')
        this.isParsingLargeFile = true
        this.parsingMessageInstance = this.$message({
          message: '正在解析中,请稍等',
          type: 'warning',
          duration: 0, // 不自动关闭
          showClose: false
        })
      } else {
        this.isParsingLargeFile = false
      }

      // 生成文件唯一标识,用于断点续传
      this.file = file
      this.calculateFileHash(file).then(hash => {
        this.fileHash = hash
        console.log(hash, 'hashhashhash')

        // 检查是否可以断点续传
        this.checkFileExists(hash).then(({ data: exists }) => {
          console.log(exists, 111111)

          if (!exists.data) {
            console.log(exists, 'exists')

            // 获取已上传的分片列表
            this.getUploadedChunks(hash).then(uploaded => {
              this.uploadedChunks = uploaded
              this.canResume = true
              this.isParsingLargeFile = false
              if (this.parsingMessageInstance) {
                this.parsingMessageInstance.close()
                this.parsingMessageInstance = null
              }

              // 自动调用上传逻辑
              this.uploadFile(file).catch(err => {
                this.$message.error('自动上传失败: ' + err.message)
                this.isParsingLargeFile = false
                if (this.parsingMessageInstance) {
                  this.parsingMessageInstance.close()
                  this.parsingMessageInstance = null
                }
                this.canResume = false
                this.uploading = false
              })
            })
          }
        })
      }).finally(() => {
        this.isParsingLargeFile = false
        if (this.parsingMessageInstance) {
          this.parsingMessageInstance.close()
          this.parsingMessageInstance = null
        }
      })

      // 返回false阻止默认上传
      return false
    },

    // 计算文件哈希,用于断点续传标识
    calculateFileHash (file) {
      console.log('第一个请求', file)

      return new Promise(resolve => {
        const spark = new SparkMD5.ArrayBuffer()
        const reader = new FileReader()
        const chunks = Math.ceil(file.size / (2 * 1024 * 1024))
        let currentChunk = 0

        // 只计算文件部分内容的哈希,提高性能
        const loadNext = () => {
          const start = currentChunk * (2 * 1024 * 1024)
          const end = Math.min(start + 2 * 1024 * 1024, file.size)
          reader.readAsArrayBuffer(file.slice(start, end))
        }

        reader.onload = e => {
          spark.append(e.target.result)
          currentChunk++

          if (currentChunk < chunks) {
            loadNext()
          } else {
            resolve(spark.end())
          }
        }

        loadNext()
      })
    },

    // 检查文件是否已存在
    checkFileExists (hash) {
      return uploadCheckFile(
        {
          fileHash: hash
        }
      )
    },

    // 获取已上传的分片
    getUploadedChunks (hash) {
      return uploadedChunks({
        fileHash: hash
      })
    },

    // 文件切片
    createFileChunks (file) {
      // 1. 分片开始时弹出不自动关闭的提示消息,并保存实例
      if (this.parsingMessageInstance) {
        this.parsingMessageInstance.close()
      }
      this.parsingMessageInstance = this.$message({
        message: '正在解析中,请稍等',
        type: 'info',
        duration: 0, // 不自动关闭
        showClose: false
      })

      const chunks = []
      let cur = 0
      while (cur < file.size) {
        chunks.push({
          file: file.slice(cur, cur + this.chunkSize),
          index: chunks.length
        })
        cur += this.chunkSize
      }
      this.totalChunks = chunks.length

      // 2. 分片完成后关闭提示消息
      if (this.parsingMessageInstance) {
        this.parsingMessageInstance.close()
        this.parsingMessageInstance = null
      }
      return chunks
    },

    // 自定义上传请求
    customUploadRequest ({ file, onProgress, onSuccess, onError }) {
      // 这里使用我们自己的上传逻辑,不使用默认的
      this.uploadFile(file).then(onSuccess).catch(onError)
    },

    // 上传文件
    uploadFile (file) {
      console.log(file, '文件')

      this.uploading = true
      this.uploadProgress = 0
      this.file = file
      this.chunks = this.createFileChunks(file)
      this.uploadedChunks = []
      this.currentChunk = 0

      // 检查是否可以断点续传
      return this.checkFileExists(this.fileHash).then(({ data: exists }) => {
        console.log(exists.data, 'exists66666')

        if (exists.data) {
          return this.getUploadedChunks(this.fileHash).then(({ data: uploaded }) => {
            console.log(uploaded, 'uploaded')

            this.uploadedChunks = uploaded.data
            this.canResume = true
            // 上传剩余分片
            return this.uploadChunks()
          })
        } else {
          // 生成唯一上传ID
          return initUpload({
            fileHash: this.fileHash,
            fileName: file.name,
            fileSize: file.size
          }).then(({ data: res }) => {
            this.uploadId = res.data.uploadId
            // 上传所有分片
            return this.uploadChunks()
          })
        }
      })
    },

    // 上传所有分片
    uploadChunks () {
      // 过滤已上传的分片
      const chunksToUpload = this.chunks.filter((_, index) => !this.uploadedChunks.includes(index))

      // 没有需要上传的分片,直接提示分片已上传完成
      if (chunksToUpload.length === 0) {
        this.uploading = false
        this.uploadProgress = 100
        this.canResume = false
        this.$message.success('所有分片上传完成,请点击确定提交后合并文件')

        // 添加到 fileList,标记为“待合并”
        // 避免重复添加
        if (!this.fileList.some(f => f.name === this.file.name && f.status === 'pending')) {
          this.fileList.push({
            name: this.file.name,
            status: 'pending' // 自定义字段,表示待合并
            // 你可以加更多字段,比如 fileHash、uploadId 等
          })
        }

        // 1秒后进度条归零,提升体验
        setTimeout(() => {
          this.uploadProgress = 0
        }, 1000)

        return Promise.resolve()
      }

      // 批量上传分片,控制并发

      return new Promise((resolve, reject) => {
        let completed = 0
        let failed = 0
        let index = 0

        const uploadNext = () => {
          if (index >= chunksToUpload.length) {
            if (failed > 0) {
              reject(new Error(`${failed}个分片上传失败`))
            } else {
              // 上传完成后,不再自动调用mergeChunks // 交给后端做合并
              this.fileList.push({
                fileHash: this.fileHash,
                fileName: this.file.name,
                fileSize: this.file.size,
                totalChunks: this.totalChunks,
                uploadId: this.uploadId,
                name: this.file.name
              })
              this.uploading = false
              this.uploadProgress = 100
              this.canResume = false // 上传完成后禁用继续上传按钮
              this.$message.success('文件上传成功!')
              resolve()
            }
            return
          }

          const chunk = chunksToUpload[index]
          const formData = new FormData()
          formData.append('file', chunk.file)
          formData.append('fileHash', this.fileHash)
          formData.append('fileName', this.file.name)
          formData.append('chunkIndex', chunk.index)
          formData.append('totalChunks', this.totalChunks)
          formData.append('uploadId', this.uploadId)

          uploadChunk(formData, {
            onUploadProgress: progressEvent => {
              // 计算当前分片的进度百分比
              const percent = Math.floor((progressEvent.loaded * 100) / progressEvent.total)
              // 计算总进度 = (已上传分片数 + 当前分片进度) / 总分片数
              // this.uploadedChunks.length 表示已上传分片数
              // completed 表示本轮已完成的分片数
              // percent/100 表示当前分片的上传进度
              const alreadyUploaded = this.uploadedChunks.length
              const currentUploading = completed + percent / 100
              const totalPercent = ((alreadyUploaded + currentUploading) / this.totalChunks) * 100
              this.uploadProgress = Math.floor(totalPercent)
            }
          })
            .then(() => {
              completed++
              this.currentChunk = completed // 实时更新当前已上传分片数
              this.uploadedChunks.push(chunk.index)
              // 上传完成后再更新一次进度条,确保进度条到达正确位置
              this.uploadProgress = Math.floor((this.uploadedChunks.length / this.totalChunks) * 100)
              index++
              uploadNext()
            })
            .catch(err => {
              console.error(`分片 ${chunk.index} 上传失败:`, err)
              failed++
              index++
              uploadNext()

              // 如果失败次数超过3次,可以认为上传失败
              if (failed >= 3) {
                reject(err)
                this.$message.error('上传失败,请刷新页面重新上传')
              }
            })
        }

        // 开始上传
        uploadNext()
      })
    },
        // 合并分片
    mergeChunks () {
      this.$message.info('所有分片上传完成,正在合并文件,请稍候...')
      return mergeChunks({
        fileHash: this.fileHash,
        fileName: this.file.name,
        fileSize: this.file.size,
        totalChunks: this.totalChunks,
        uploadId: this.uploadId
      })
        .then(({ data: res }) => {
          this.uploading = false
          this.uploadProgress = 100
          this.canResume = false // 上传完成后禁用继续上传按钮
          this.$message.success('文件上传成功!')

          // 添加到文件列表
          this.fileList.push({
            name: this.file.name,
            url: res.data.url
          })

          return res.data
        })
        .catch(err => {
          this.uploading = false
          this.$message.error('合并文件失败: ' + err.message)
          throw err
        })
    },

4.所需要的后端接口

Vue2 Element UI 分片上传前端实现方案

在Vue2和Element UI环境下实现分片上传功能,主要思路是将大文件分割成多个小文件片段并行或串行上传,最终在服务端合并。以下是具体实现步骤:

核心实现代码

<template>
  <div>
    <el-upload
      class="upload-demo"
      drag
      action="#"
      :http-request="customUploadRequest"
      :before-upload="beforeUpload"
      :on-progress="handleProgress"
      :show-file-list="true"
      :file-list="fileList"
    >
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
      <div class="el-upload__tip" slot="tip">只能上传jpg/png文件,且不超过500MB</div>
    </el-upload>
    
    <el-progress v-if="uploading" :percentage="uploadProgress" :stroke-width="18"></el-progress>
    <div v-if="uploading">{{ currentChunk }}/{{ totalChunks }} 分片上传中...</div>
    <el-button v-if="canResume && !uploading" type="primary" @click="resumeUpload">继续上传</el-button>
    <el-button v-if="uploading" type="danger" @click="pauseUpload">暂停上传</el-button>
  </div>
</template>

<script>
import SparkMD5 from 'spark-md5';
import axios from 'axios';

export default {
  name: 'ChunkUploader',
  data() {
    return {
      fileList: [],
      uploading: false,
      uploadProgress: 0,
      file: null,
      chunkSize: 2 * 1024 * 1024, // 2MB 每片
      chunks: [],
      currentChunk: 0,
      totalChunks: 0,
      uploadedChunks: [], // 已上传的分片索引
      canResume: false,
      fileHash: '',
      uploadId: '' // 服务端生成的上传ID
    };
  },
  methods: {
    beforeUpload(file) {
      // 生成文件唯一标识,用于断点续传
      this.file = file;
      this.calculateFileHash(file).then(hash => {
        this.fileHash = hash;
        // 检查是否可以断点续传
        this.checkFileExists(hash).then(exists => {
          if (exists) {
            // 获取已上传的分片列表
            this.getUploadedChunks(hash).then(uploaded => {
              this.uploadedChunks = uploaded;
              this.canResume = true;
              this.$message.success('文件已存在,可继续上传');
            });
          }
        });
      });
      
      // 返回false阻止默认上传
      return false;
    },
    
    // 计算文件哈希,用于断点续传标识
    calculateFileHash(file) {
      return new Promise(resolve => {
        const spark = new SparkMD5.ArrayBuffer();
        const reader = new FileReader();
        const chunks = Math.ceil(file.size / (2 * 1024 * 1024));
        let currentChunk = 0;
        
        // 只计算文件部分内容的哈希,提高性能
        const loadNext = () => {
          const start = currentChunk * (2 * 1024 * 1024);
          const end = Math.min(start + (2 * 1024 * 1024), file.size);
          reader.readAsArrayBuffer(file.slice(start, end));
        };
        
        reader.onload = e => {
          spark.append(e.target.result);
          currentChunk++;
          
          if (currentChunk < chunks) {
            loadNext();
          } else {
            resolve(spark.end());
          }
        };
        
        loadNext();
      });
    },
    
    // 检查文件是否已存在
    checkFileExists(hash) {
      return axios.get(`/api/checkFile?fileHash=${hash}`);
    },
    
    // 获取已上传的分片
    getUploadedChunks(hash) {
      return axios.get(`/api/uploadedChunks?fileHash=${hash}`);
    },
    
    // 文件切片
    createFileChunks(file) {
      const chunks = [];
      let cur = 0;
      while (cur < file.size) {
        chunks.push({ 
          file: file.slice(cur, cur + this.chunkSize),
          index: chunks.length
        });
        cur += this.chunkSize;
      }
      this.totalChunks = chunks.length;
      return chunks;
    },
    
    // 自定义上传请求
    customUploadRequest({ file, onProgress, onSuccess, onError }) {
      // 这里使用我们自己的上传逻辑,不使用默认的
      this.uploadFile(file).then(onSuccess).catch(onError);
    },
    
    // 上传文件
    uploadFile(file) {
      this.uploading = true;
      this.uploadProgress = 0;
      this.file = file;
      this.chunks = this.createFileChunks(file);
      this.uploadedChunks = [];
      this.currentChunk = 0;
      
      // 检查是否可以断点续传
      return this.checkFileExists(this.fileHash).then(exists => {
        if (exists) {
          return this.getUploadedChunks(this.fileHash).then(uploaded => {
            this.uploadedChunks = uploaded;
            this.canResume = true;
            // 上传剩余分片
            return this.uploadChunks();
          });
        } else {
          // 生成唯一上传ID
          return axios.post('/api/initUpload', {
            fileHash: this.fileHash,
            fileName: file.name,
            fileSize: file.size
          }).then(res => {
            this.uploadId = res.data.uploadId;
            // 上传所有分片
            return this.uploadChunks();
          });
        }
      });
    },
    
    // 上传所有分片
    uploadChunks() {
      // 过滤已上传的分片
      const chunksToUpload = this.chunks.filter((_, index) => 
        !this.uploadedChunks.includes(index)
      );
      
      // 没有需要上传的分片,直接合并
      if (chunksToUpload.length === 0) {
        return this.mergeChunks();
      }
      
      // 批量上传分片,控制并发
      const concurrency = 4; // 同时上传的分片数
      const total = chunksToUpload.length;
      
      return new Promise((resolve, reject) => {
        let completed = 0;
        let failed = 0;
        let index = 0;
        
        const uploadNext = () => {
          if (index >= chunksToUpload.length) {
            if (failed > 0) {
              reject(new Error(`${failed}个分片上传失败`));
            } else {
              this.mergeChunks().then(resolve).catch(reject);
            }
            return;
          }
          
          const chunk = chunksToUpload[index];
          const formData = new FormData();
          formData.append('file', chunk.file);
          formData.append('fileHash', this.fileHash);
          formData.append('fileName', this.file.name);
          formData.append('chunkIndex', chunk.index);
          formData.append('totalChunks', this.totalChunks);
          formData.append('uploadId', this.uploadId);
          
          axios.post('/api/uploadChunk', formData, {
            onUploadProgress: progressEvent => {
              // 更新单个分片的进度
              const percent = Math.floor((progressEvent.loaded * 100) / progressEvent.total);
              // 整体进度 = 已上传分片大小 / 总文件大小
              const totalPercent = ((completed + percent / 100) / total) * 100;
              this.uploadProgress = Math.floor(totalPercent);
            }
          }).then(() => {
            completed++;
            this.uploadedChunks.push(chunk.index);
            index++;
            uploadNext();
          }).catch(err => {
            console.error(`分片 ${chunk.index} 上传失败:`, err);
            failed++;
            index++;
            uploadNext();
            
            // 如果失败次数超过3次,可以认为上传失败
            if (failed >= 3) {
              reject(err);
            }
          });
        };
        
        // 开始上传
        uploadNext();
      });
    },
    
    // 合并分片
    mergeChunks() {
      this.$message.info('所有分片上传完成,正在合并文件...');
      return axios.post('/api/mergeChunks', {
        fileHash: this.fileHash,
        fileName: this.file.name,
        fileSize: this.file.size,
        totalChunks: this.totalChunks,
        uploadId: this.uploadId
      }).then(res => {
        this.uploading = false;
        this.uploadProgress = 100;
        this.$message.success('文件上传成功!');
        
        // 添加到文件列表
        this.fileList.push({
          name: this.file.name,
          url: res.data.url
        });
        
        return res.data;
      }).catch(err => {
        this.uploading = false;
        this.$message.error('合并文件失败: ' + err.message);
        throw err;
      });
    },
    
    // 暂停上传
    pauseUpload() {
      // 这里可以保存已上传的分片信息到本地存储
      // 实际实现中可能需要调用后端API记录上传状态
      this.uploading = false;
      this.canResume = true;
      this.$message.warning('上传已暂停');
    },
    
    // 继续上传
    resumeUpload() {
      this.uploading = true;
      this.uploadFile(this.file).catch(err => {
        this.$message.error('继续上传失败: ' + err.message);
      });
    },
    
    // 处理上传进度
    handleProgress(event, file) {
      // 在自定义上传中,这个事件可能不会被触发
      // 我们已经在uploadChunks中更新了进度
    }
  }
};
</script>
后端接口说明

后端需要提供以下接口支持:

  1. /api/initUpload- 初始化上传,生成上传ID
  2. /api/uploadChunk- 上传单个分片
  3. /api/mergeChunks- 合并所有分片
  4. /api/checkFile- 检查文件是否已存在
  5. /api/uploadedChunks- 获取已上传的分片列表

5. 最后说一下我这边踩的坑

  1. 分片的时候不能被中断,分片只要丢失一片,文件整体都会上传失败
  2. 分片的时候最好禁用任何能影响到分片上传的按钮功能,
  3. 最后提交的时候,分片可以后端异步合并,如果前端合并的话,会很慢影响用户的体验

好了,今天的分享就到这了,有什么疑问欢迎各位在评论区提出来,我看到会第一时间回复的!