如何在 Vue 项目中,设计一个通用的文件上传组件,支持文件分片上传、断点续传和预览功能?

各位靓仔靓女,晚上好!我是你们的老朋友,今天咱们不开车,来聊聊 Vue 项目里的文件上传那些事儿。目标只有一个:打造一个牛逼哄哄的通用文件上传组件,支持分片、断点续传、预览,让你在面试官面前都能抬头挺胸!

开场白:文件上传,痛点在哪里?

文件上传,听起来简单,不就一个 <input type="file"> 吗?Too young, too simple! 当文件稍微大一点,问题就来了:

  • 上传慢如蜗牛: 大文件一口气传,网络一波动,GG!
  • 流量嗖嗖跑: 用户传个电影,你扣人家半个月流量?会被投诉的!
  • 服务器压力山大: 一堆大文件同时上传,服务器直接躺平。
  • 用户体验差: 传到一半断了?重来?用户想砸电脑的心都有了!

所以,我们需要一套更优雅、更高效的文件上传方案,也就是今天的主角:分片上传 + 断点续传

第一幕:组件设计蓝图

首先,我们来规划一下这个通用文件上传组件的结构。考虑到通用性,我们需要一些可配置的选项,例如:

配置项 类型 描述 默认值
uploadUrl String 上传接口地址
chunkSize Number 分片大小,单位字节 (B) 1024 * 1024 (1MB)
maxSize Number 文件最大大小,单位字节 (B) 1024 1024 1024 (1GB)
accept String 允许上传的文件类型,例如:'.jpg,.png,.gif'
headers Object 上传请求头 {}
params Object 上传请求参数 {}
onProgress Function 上传进度回调函数,参数为进度百分比 (0-100)
onSuccess Function 上传成功回调函数,参数为服务器返回的数据
onError Function 上传失败回调函数,参数为错误信息
preview Boolean 是否开启预览功能 true

第二幕:Vue 组件骨架搭建

有了蓝图,我们开始撸代码,先搭个 Vue 组件的架子:

<template>
  <div class="upload-container">
    <input type="file" @change="handleFileChange" :accept="accept" ref="fileInput">
    <button @click="handleUpload">上传</button>

    <div v-if="preview && file" class="preview-container">
      <img v-if="isFileImage" :src="previewUrl" alt="预览">
      <video v-else-if="isFileVideo" :src="previewUrl" controls></video>
      <div v-else>{{ file.name }}</div>
    </div>

    <div v-if="progress > 0" class="progress-bar">
      <div class="progress" :style="{ width: progress + '%' }"></div>
      <span>{{ progress }}%</span>
    </div>
  </div>
</template>

<script>
import axios from 'axios'; // 引入 axios,用于发送请求

export default {
  props: {
    uploadUrl: {
      type: String,
      required: true,
    },
    chunkSize: {
      type: Number,
      default: 1024 * 1024, // 1MB
    },
    maxSize: {
      type: Number,
      default: 1024 * 1024 * 1024, // 1GB
    },
    accept: {
      type: String,
      default: '',
    },
    headers: {
      type: Object,
      default: () => ({}),
    },
    params: {
      type: Object,
      default: () => ({}),
    },
    onProgress: {
      type: Function,
      default: () => {},
    },
    onSuccess: {
      type: Function,
      default: () => {},
    },
    onError: {
      type: Function,
      default: () => {},
    },
    preview: {
      type: Boolean,
      default: true,
    },
  },
  data() {
    return {
      file: null,
      chunks: [],
      uploadedList: [], // 记录已上传的分片
      progress: 0,
      previewUrl: null,
      isFileImage: false,
      isFileVideo: false,
    };
  },
  methods: {
    handleFileChange(event) {
      this.file = event.target.files[0];
      if (!this.file) return;

      // 文件大小校验
      if (this.file.size > this.maxSize) {
        alert('文件大小超出限制');
        this.$refs.fileInput.value = null; // 清空 input,防止再次选择相同文件不触发 change 事件
        this.file = null;
        return;
      }

      this.createChunks();
      this.previewFile();
    },
    handleUpload() {
      if (!this.file) {
        alert('请选择文件');
        return;
      }
      this.uploadChunks();
    },
    createChunks() {
      const fileSize = this.file.size;
      const chunkSize = this.chunkSize;
      let start = 0;

      while (start < fileSize) {
        const end = Math.min(start + chunkSize, fileSize);
        this.chunks.push(this.file.slice(start, end));
        start = end;
      }
    },
    async uploadChunks() {
      // 上传分片逻辑将在下一幕展开
    },
    previewFile() {
      if (!this.preview) return;

      const fileReader = new FileReader();
      fileReader.onload = (event) => {
        this.previewUrl = event.target.result;
        this.isFileImage = this.isImage(this.file.type);
        this.isFileVideo = this.isVideo(this.file.type);
      };

      fileReader.readAsDataURL(this.file);
    },
    isImage(fileType) {
      return fileType.startsWith('image/');
    },
    isVideo(fileType) {
      return fileType.startsWith('video/');
    },
  },
};
</script>

<style scoped>
.upload-container {
  /* 样式自己发挥 */
}

.preview-container {
  /* 样式自己发挥 */
}

.progress-bar {
  /* 样式自己发挥 */
}
</style>

第三幕:分片上传,化整为零

createChunks 方法已经将文件分割成了若干个小块,接下来就是上传这些小块了。uploadChunks 方法将扮演主角:

    async uploadChunks() {
      this.progress = 0;
      this.uploadedList = []; // 重置已上传列表
      const totalChunks = this.chunks.length;

      for (let i = 0; i < totalChunks; i++) {
        if (this.uploadedList.includes(i)) {
          // 如果已经上传过,跳过
          continue;
        }

        const chunk = this.chunks[i];
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('filename', this.file.name);
        formData.append('chunkIndex', i);
        formData.append('totalChunks', totalChunks);

        try {
          const response = await axios.post(this.uploadUrl, formData, {
            headers: {
              'Content-Type': 'multipart/form-data',
              ...this.headers,
            },
            params: {
              ...this.params,
            },
            onUploadProgress: (progressEvent) => {
              // 计算总进度
              const loaded = progressEvent.loaded + (this.chunkSize * this.uploadedList.length); //已上传的分片大小加上当前分片已上传的大小
              const total = this.file.size; // 总大小
              this.progress = Math.round((loaded / total) * 100); // 计算进度
              this.onProgress(this.progress);
            },
          });

          if (response.status === 200) {
            // 标记为已上传
            this.uploadedList.push(i);

            // 检查是否所有分片都已上传
            if (this.uploadedList.length === totalChunks) {
              this.onSuccess(response.data);
              this.progress = 100;
              alert('上传成功');
            }
          } else {
            throw new Error(`上传失败,状态码:${response.status}`);
          }
        } catch (error) {
          this.onError(error);
          alert(`上传失败:${error.message}`);
          break; // 停止上传
        }
      }
    },

这段代码的核心逻辑是:

  1. 循环上传分片: 遍历 chunks 数组,逐个上传。
  2. FormData 封装: 将分片数据、文件名、分片索引、总分片数等信息封装到 FormData 中。
  3. axios 发送请求: 使用 axios 发送 POST 请求到服务器。
  4. 进度监听: 监听 onUploadProgress 事件,计算并更新上传进度。
  5. 成功/失败处理: 根据服务器返回的状态码,判断上传是否成功,并调用相应的回调函数。
  6. 已上传列表: 使用 uploadedList 记录已上传的分片索引。
  7. 断点续传雏形: 在循环开始前,判断当前分片是否已上传,如果是,则跳过。

第四幕:断点续传,重拾希望

上面的代码已经有了断点续传的雏形,但还不够完善。我们需要在服务器端配合,实现真正的断点续传。

服务器端要做什么?

  • 记录已上传分片: 服务器需要记录每个文件已经上传的分片索引。可以使用数据库、Redis 等方式存储。
  • 合并分片: 当所有分片都上传完成后,服务器需要将这些分片合并成完整的文件。

组件端如何配合?

  1. 初始化时查询已上传分片:handleFileChange 方法中,向服务器查询当前文件已上传的分片索引,更新 uploadedList
    async handleFileChange(event) {
      this.file = event.target.files[0];
      if (!this.file) return;

      // 文件大小校验...

      try {
        // 向服务器查询已上传分片
        const response = await axios.get('/check-uploaded', {
          params: {
            filename: this.file.name,
            totalChunks: this.chunks.length,
          },
        });

        if (response.status === 200 && response.data.uploadedList) {
          this.uploadedList = response.data.uploadedList;
        }
      } catch (error) {
        console.error('查询已上传分片失败', error);
      }

      this.createChunks();
      this.previewFile();
    },
  1. 服务器返回的数据格式: 服务器应该返回一个包含已上传分片索引的数组,例如:
{
  "uploadedList": [0, 1, 3, 5] // 已经上传了第 0, 1, 3, 5 个分片
}

第五幕:预览,眼见为实

代码中已经包含了预览功能的简单实现,通过 FileReader 读取文件内容,根据文件类型显示图片、视频或文件名。

补充说明:

  • 安全问题: 上传文件时,务必进行安全校验,防止恶意文件上传。
  • 错误处理: 需要更完善的错误处理机制,例如:重试机制、错误提示等。
  • 用户体验: 可以添加更友好的用户界面,例如:上传队列、进度条动画等。
  • 后端接口: 代码中 /check-uploadedthis.uploadUrl 只是占位符,需要替换成实际的后端接口地址。
  • 取消上传: 可以添加取消上传的功能,通过 axios.CancelToken 实现。
  • 暂停/恢复: 可以添加暂停和恢复上传的功能,在 uploadChunks 方法中使用 axios.CancelToken 控制请求的取消和恢复。需要注意的是,暂停后恢复,需要重新查询已上传分片,避免重复上传。

第六幕:代码优化,精益求精

  • 使用 async/await 简化异步代码: 上面的代码大量使用了 async/await,可以使异步代码更易读、易维护。
  • 提取公共方法: 可以将一些公共方法提取出来,例如:文件类型判断、进度计算等。
  • 使用 Vuex 管理状态: 如果需要在多个组件中使用上传状态,可以使用 Vuex 进行集中管理。
  • 使用 TypeScript 增强类型安全: 可以使用 TypeScript 编写组件,提高代码的可维护性和可读性。

总结:

至此,我们已经完成了一个基本的文件上传组件,支持分片上传、断点续传和预览功能。 当然,这只是一个起点,还有很多可以优化和完善的地方。 记住,代码不是一次写成的,需要不断迭代、优化,才能达到最佳效果。 希望今天的分享对你有所帮助! 祝大家早日成为编程高手! 下课!

发表回复

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