如何在 Vue 中构建一个复杂的文件上传组件,支持文件分块上传、断点续传、进度显示和多文件上传?

各位靓仔靓女,晚上好!我是老司机,今天跟大家聊聊Vue里面如何打造一个“豪华版”文件上传组件,让你的文件上传体验丝滑到飞起。我们今天要搞定的功能包括:

  • 文件分块上传: 把大文件切成小块,一块一块传,妈妈再也不用担心我的浏览器崩溃了!
  • 断点续传: 就算网络突然抽风,下次还能接着上次的地方继续传,简直不要太贴心。
  • 进度显示: 清晰地看到上传进度,心里有数,告别焦虑。
  • 多文件上传: 一次性上传多个文件,省时省力,告别重复劳动。

准备好了吗?坐稳扶好,发车咯!

1. 基础架构搭建:先搭个“毛坯房”

首先,我们需要创建一个Vue组件,作为我们上传组件的“毛坯房”。

<template>
  <div class="upload-container">
    <input type="file" multiple @change="handleFileChange" ref="fileInput" />
    <button @click="startUpload">开始上传</button>
    <div v-for="(file, index) in fileList" :key="index">
      <p>{{ file.name }} - {{ file.progress }}%</p>
    </div>
  </div>
</template>

<script>
import SparkMD5 from 'spark-md5'; // 引入计算文件hash的库

export default {
  data() {
    return {
      fileList: [], // 存储文件信息的数组
      chunkSize: 1024 * 1024 * 2, // 2MB 分块大小
      uploadUrl: '/api/upload', // 上传接口地址,后面会换成你自己的
    };
  },
  methods: {
    handleFileChange(event) {
      const files = Array.from(event.target.files);
      this.fileList = files.map(file => ({
        file,
        name: file.name,
        size: file.size,
        progress: 0,
        uploaded: false,
        chunks: [], // 用于存放分片信息
      }));
    },
    startUpload() {
      this.fileList.forEach(fileInfo => {
        this.sliceFile(fileInfo);
      });
    },
    async sliceFile(fileInfo) {
      const file = fileInfo.file;
      const chunkSize = this.chunkSize;
      const totalSize = file.size;
      const chunkCount = Math.ceil(totalSize / chunkSize);

      fileInfo.chunks = []; // 初始化chunks数组
      for (let i = 0; i < chunkCount; i++) {
        const start = i * chunkSize;
        const end = Math.min(totalSize, start + chunkSize);
        const chunk = file.slice(start, end); // 切割文件
        fileInfo.chunks.push({
          chunk: chunk,
          index: i,
          start: start,
          end: end,
          uploaded: false,
        });
      }

      // 计算整个文件的hash,用于唯一标识,防止重复上传
      const fileHash = await this.calculateFileHash(file);
      fileInfo.fileHash = fileHash;

      // 接下来进行分片上传
      this.uploadChunks(fileInfo);
    },
    async calculateFileHash(file) {
      return new Promise((resolve, reject) => {
        const spark = new SparkMD5.ArrayBuffer();
        const fileReader = new FileReader();

        fileReader.onload = (e) => {
          spark.append(e.target.result);
          resolve(spark.end());
        };

        fileReader.onerror = () => {
          reject('文件读取出错');
        };

        fileReader.readAsArrayBuffer(file);
      });
    },
    async uploadChunks(fileInfo) {
      const chunks = fileInfo.chunks;
      const uploadPromises = []; // 并发上传promise

      for (const chunkInfo of chunks) {
        if (!chunkInfo.uploaded) {
          const formData = new FormData();
          formData.append('file', chunkInfo.chunk);
          formData.append('filename', fileInfo.name);
          formData.append('totalSize', fileInfo.size);
          formData.append('chunkIndex', chunkInfo.index);
          formData.append('totalChunks', chunks.length);
          formData.append('fileHash', fileInfo.fileHash);

          const uploadPromise = fetch(this.uploadUrl, {
            method: 'POST',
            body: formData,
          })
          .then(response => {
            if (!response.ok) {
              throw new Error(`HTTP error! status: ${response.status}`);
            }
            return response.json(); // 假设服务器返回JSON
          })
          .then(data => {
            if (data.code === 200) {
              chunkInfo.uploaded = true;
              // 上传成功后更新进度条
              this.updateProgress(fileInfo);
            } else {
              console.error('上传失败:', data.message);
            }
          })
          .catch(error => {
            console.error('上传出错:', error);
          });

          uploadPromises.push(uploadPromise); // 推进并发上传
        }
      }

      // 使用Promise.all等待所有分片上传完成
      await Promise.all(uploadPromises);

      // 检查是否所有分片都已上传
      const allChunksUploaded = chunks.every(chunk => chunk.uploaded);
      if (allChunksUploaded) {
          console.log(`文件 ${fileInfo.name} 上传完成!`);
      }
    },
    updateProgress(fileInfo) {
      const uploadedChunks = fileInfo.chunks.filter(chunk => chunk.uploaded).length;
      fileInfo.progress = Math.floor((uploadedChunks / fileInfo.chunks.length) * 100);
      this.$forceUpdate();  // 强制更新视图
    },
  },
};
</script>

<style scoped>
.upload-container {
  padding: 20px;
  border: 1px dashed #ccc;
}
</style>

代码解释:

  • input[type="file"]:文件选择框,multiple属性允许选择多个文件。
  • handleFileChange:当文件选择发生变化时触发,将选择的文件信息存储到fileList数组中。
  • sliceFile:将文件分割成多个 chunk。
  • calculateFileHash:使用spark-md5库计算文件的hash值,作为文件的唯一标识。
  • uploadChunks:遍历文件,将每个 chunk 通过fetch API 发送到服务器。
  • updateProgress:更新文件的上传进度。
  • startUpload:遍历 fileList,对每个文件进行分片并开始上传。

2. 分块上传:化整为零,各个击破

核心思想:将大文件分割成若干小块,然后并发上传这些小块。

好处:

  • 提高上传速度: 并发上传,充分利用带宽。
  • 降低服务器压力: 每次只处理小块数据,减轻服务器负担。
  • 实现断点续传: 如果某个 chunk 上传失败,只需要重新上传这个 chunk 即可。

在上面的代码里,我们已经实现了文件分块,关键在于sliceFile方法:

    async sliceFile(fileInfo) {
      const file = fileInfo.file;
      const chunkSize = this.chunkSize;
      const totalSize = file.size;
      const chunkCount = Math.ceil(totalSize / chunkSize);

      fileInfo.chunks = []; // 初始化chunks数组
      for (let i = 0; i < chunkCount; i++) {
        const start = i * chunkSize;
        const end = Math.min(totalSize, start + chunkSize);
        const chunk = file.slice(start, end); // 切割文件
        fileInfo.chunks.push({
          chunk: chunk,
          index: i,
          start: start,
          end: end,
          uploaded: false,
        });
      }

      // 计算整个文件的hash,用于唯一标识,防止重复上传
      const fileHash = await this.calculateFileHash(file);
      fileInfo.fileHash = fileHash;

      // 接下来进行分片上传
      this.uploadChunks(fileInfo);
    },

代码解释:

  1. 计算分块数量: chunkCount = Math.ceil(totalSize / chunkSize) 计算需要分割成多少个 chunk。
  2. 循环切割文件: file.slice(start, end) 方法用于切割文件,返回一个新的 Blob 对象,代表文件的一个 chunk。
  3. chunk 信息存储: 将每个 chunk 的信息(chunk 数据、index、start、end)存储到fileInfo.chunks数组中。

3. 断点续传:网络波动?不存在的!

核心思想:在上传每个 chunk 之前,先检查服务器是否已经存在该 chunk,如果存在,则跳过上传。

实现步骤:

  1. 服务器端: 需要记录每个 chunk 的上传状态(已上传/未上传)。
  2. 客户端: 在上传每个 chunk 之前,向服务器发送一个请求,检查该 chunk 是否已经上传。

修改 uploadChunks 方法:

    async uploadChunks(fileInfo) {
      const chunks = fileInfo.chunks;
      const uploadPromises = []; // 并发上传promise

      for (const chunkInfo of chunks) {
        if (!chunkInfo.uploaded) {
          const formData = new FormData();
          formData.append('file', chunkInfo.chunk);
          formData.append('filename', fileInfo.name);
          formData.append('totalSize', fileInfo.size);
          formData.append('chunkIndex', chunkInfo.index);
          formData.append('totalChunks', chunks.length);
          formData.append('fileHash', fileInfo.fileHash);

          const uploadPromise = fetch(this.uploadUrl, {
            method: 'POST',
            body: formData,
          })
          .then(response => {
            if (!response.ok) {
              throw new Error(`HTTP error! status: ${response.status}`);
            }
            return response.json(); // 假设服务器返回JSON
          })
          .then(data => {
            if (data.code === 200) {
              chunkInfo.uploaded = true;
              // 上传成功后更新进度条
              this.updateProgress(fileInfo);
            } else {
              console.error('上传失败:', data.message);
            }
          })
          .catch(error => {
            console.error('上传出错:', error);
          });

          uploadPromises.push(uploadPromise); // 推进并发上传
        }
      }

      // 使用Promise.all等待所有分片上传完成
      await Promise.all(uploadPromises);

      // 检查是否所有分片都已上传
      const allChunksUploaded = chunks.every(chunk => chunk.uploaded);
      if (allChunksUploaded) {
          console.log(`文件 ${fileInfo.name} 上传完成!`);
      }
    },

服务器端(示例):

# Python (Flask) 示例
from flask import Flask, request, jsonify
import os

app = Flask(__name__)

UPLOAD_FOLDER = 'uploads'  # 上传文件存储目录
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

@app.route('/api/upload', methods=['POST'])
def upload():
    file = request.files['file']
    filename = request.form['filename']
    total_size = int(request.form['totalSize'])
    chunk_index = int(request.form['chunkIndex'])
    total_chunks = int(request.form['totalChunks'])
    file_hash = request.form['fileHash']

    chunk_filename = f"{file_hash}_{chunk_index}"  # 分片文件名
    chunk_filepath = os.path.join(app.config['UPLOAD_FOLDER'], chunk_filename)

    # 检查分片是否已存在
    if os.path.exists(chunk_filepath):
        return jsonify({'code': 200, 'message': '分片已存在'})

    # 保存分片
    file.save(chunk_filepath)

    # 检查是否所有分片都已上传
    uploaded_chunks = [f for f in os.listdir(app.config['UPLOAD_FOLDER']) if f.startswith(file_hash)]
    if len(uploaded_chunks) == total_chunks:
        # 合并分片
        merged_filename = filename
        merged_filepath = os.path.join(app.config['UPLOAD_FOLDER'], merged_filename)
        with open(merged_filepath, 'wb') as merged_file:
            for i in range(total_chunks):
                chunk_filename = f"{file_hash}_{i}"
                chunk_filepath = os.path.join(app.config['UPLOAD_FOLDER'], chunk_filename)
                with open(chunk_filepath, 'rb') as chunk_file:
                    merged_file.write(chunk_file.read())
                os.remove(chunk_filepath) # 删除分片

        return jsonify({'code': 200, 'message': '上传完成'})

    return jsonify({'code': 200, 'message': '分片上传成功'})

if __name__ == '__main__':
    if not os.path.exists(UPLOAD_FOLDER):
        os.makedirs(UPLOAD_FOLDER)
    app.run(debug=True)

代码解释:

  • 服务器端存储: 使用文件系统的hash值加分片索引来保存分片。
  • 检查分片是否存在: 在保存分片之前,检查分片是否已经存在,如果存在直接返回。
  • 分片合并: 上传完成后,将所有的分片合并成一个完整的文件。

4. 进度显示:让用户心里有数

核心思想:在每次 chunk 上传成功后,更新文件的上传进度,并在 UI 上展示。

修改 updateProgress 方法:

    updateProgress(fileInfo) {
      const uploadedChunks = fileInfo.chunks.filter(chunk => chunk.uploaded).length;
      fileInfo.progress = Math.floor((uploadedChunks / fileInfo.chunks.length) * 100);
      this.$forceUpdate();  // 强制更新视图
    },

代码解释:

  • 计算上传进度: (uploadedChunks / fileInfo.chunks.length) * 100 计算已上传 chunk 的比例。
  • 更新 UI: 将计算出的进度值更新到 file.progress 属性,并在 template 中展示。this.$forceUpdate() 强制更新视图,确保进度条及时更新。

5. 多文件上传:雨露均沾,一个都不能少

多文件上传的功能已经在上面的代码中实现了。input 标签的 multiple 属性允许用户选择多个文件,handleFileChange 方法会将所有选择的文件信息存储到 fileList 数组中。然后,我们只需要遍历 fileList 数组,对每个文件进行分块上传即可。

6. 优化和改进:精益求精,更上一层楼

  • 错误处理: 添加更完善的错误处理机制,例如:上传失败重试、网络错误提示等。
  • 并发控制: 限制并发上传的 chunk 数量,防止浏览器卡顿。
  • 取消上传: 允许用户取消正在上传的文件。
  • 暂停/恢复上传: 允许用户暂停和恢复上传。
  • UI 美化: 使用更美观的 UI 库(例如 Element UI、Ant Design Vue)来美化上传组件。
  • 拖拽上传: 支持拖拽文件到上传区域进行上传。
  • 服务端优化: 使用 CDN 加速文件上传,优化服务器端存储方案。

7. 总结:豪华版上传组件,安排!

今天我们一起打造了一个功能强大的 Vue 文件上传组件,支持文件分块上传、断点续传、进度显示和多文件上传。虽然代码比较多,但是每个部分都进行了详细的解释。希望大家能够理解其中的原理,并将其应用到自己的项目中。

记住,罗马不是一天建成的,一个优秀的组件也不是一蹴而就的。我们需要不断地学习、实践、总结,才能打造出真正好用的组件。

最后,希望大家多多练习,早日成为 Vue 大神! 下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注