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

各位靓仔靓女,老司机们大家好!今天咱们来聊聊Vue应用中如何打造一个牛逼哄哄的通用文件上传组件。这玩意儿听起来高大上,其实只要掌握了几个核心技巧,就能轻松搞定,让你的应用在文件上传这块儿一骑绝尘。

咱们的目标是:支持文件分片上传、断点续传,还能预览,用户体验直接拉满!

一、 磨刀不误砍柴工:技术选型和准备工作

首先,工欲善其事必先利其器。咱们先来确定一下要用到的技术:

  • Vue.js: 这是咱们的大本营,不用多说。
  • Axios/Fetch: 用于发起HTTP请求,跟后端老哥交流的桥梁。
  • File API: 浏览器提供的强大API,用于操作文件,比如读取文件内容、切割文件等。
  • SparkMD5 (可选): 用于计算文件/分片的MD5值,用于校验文件完整性和实现断点续传。
  • 一个能处理文件上传的后端服务: 这个咱们就不细说了,后端同学会搞定的,比如Node.js + Koa/Express, Java + Spring Boot, Python + Django/Flask等等。要求后端提供分片上传的接口和合并分片的接口。

二、 组件结构搭建:搭好咱们的舞台

先创建一个Vue组件,名字就叫 FileUpload.vue 吧,简单粗暴!

<template>
  <div class="file-upload">
    <input type="file" @change="handleFileChange" ref="fileInput" multiple/>
    <div class="preview" v-if="filePreviews.length">
      <div v-for="(preview, index) in filePreviews" :key="index">
        <img :src="preview" alt="预览" v-if="isVideo(files[index])"/>
        <span v-else>{{ files[index].name }}</span>
        <button @click="removeFile(index)">移除</button>
      </div>
    </div>
    <button @click="uploadFiles" :disabled="isUploading">
      {{ isUploading ? '上传中...' : '开始上传' }}
    </button>
    <div class="progress-bar" v-if="uploadProgress > 0">
      <div class="progress" :style="{ width: uploadProgress + '%' }"></div>
    </div>
    <div class="message" v-if="message">{{ message }}</div>
  </div>
</template>

<script>
import SparkMD5 from 'spark-md5'; // 如果需要的话

export default {
  name: 'FileUpload',
  data() {
    return {
      files: [], // 存储选择的文件
      filePreviews: [], // 存储文件预览
      chunkSize: 1024 * 1024 * 2, // 2MB 分片大小
      uploadProgress: 0, // 上传进度
      isUploading: false, // 是否正在上传
      message: '', // 消息提示
      uploadedList:[], //存储已经上传的文件
    };
  },
  methods: {
    handleFileChange(event) {
      this.files = Array.from(event.target.files);
      this.createPreviews();
    },
    createPreviews() {
      this.filePreviews = [];
      this.files.forEach(file => {
        if (file.type.startsWith('image/')) {
          const reader = new FileReader();
          reader.onload = (e) => {
            this.filePreviews.push(e.target.result);
          };
          reader.readAsDataURL(file);
        } else if (file.type.startsWith('video/')) {
          // 创建视频预览
          const reader = new FileReader();
          reader.onload = (e) => {
            this.filePreviews.push(e.target.result);
          };
          reader.readAsDataURL(file);
        }
        else {
          this.filePreviews.push(file.name); // 其他文件类型显示文件名
        }
      });
    },
    removeFile(index) {
      this.files.splice(index, 1);
      this.filePreviews.splice(index, 1);
    },
    isVideo(file) {
      return file.type.startsWith('video/');
    },
    async uploadFiles() {
      this.isUploading = true;
      this.uploadProgress = 0;
      this.message = '';

      for (const file of this.files) {
        try {
          await this.uploadFile(file);
        } catch (error) {
          this.message = `文件 ${file.name} 上传失败:${error.message}`;
          this.isUploading = false;
          return;
        }
      }

      this.message = '所有文件上传成功!';
      this.isUploading = false;
      this.uploadProgress = 100;
      // 清空选择的文件
      this.$refs.fileInput.value = null; // Reset the file input
      this.files = []; // Clear the files array
      this.filePreviews = []; // Clear the previews array
    },

    async uploadFile(file) {
      const fileHash = await this.calculateFileHash(file); // 计算文件hash
      const fileName = file.name;
      const fileSize = file.size;
      const chunkCount = Math.ceil(fileSize / this.chunkSize);
      let uploadedList = await this.getUploadedList(fileHash, fileName);
      console.log('uploadedList', uploadedList);
      for (let i = 0; i < chunkCount; i++) {
        //如果已上传,跳过
        if (uploadedList.includes(i)) {
          console.log(`slice ${i} has been uploaded`);
          continue;
        }
        const chunk = file.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('fileHash', fileHash);
        formData.append('fileName', fileName);
        formData.append('chunkIndex', i);
        formData.append('chunkCount', chunkCount);

        try {
          await this.uploadChunk(formData, file, i, chunkCount);
        } catch (error) {
          throw new Error(`分片 ${i + 1} 上传失败:${error.message}`);
        }

        this.uploadProgress = Math.round(((i + 1) / chunkCount) * 100);

      }

      // 合并文件
      try {
        await this.mergeChunks(fileHash, fileName);
      } catch (error) {
        throw new Error(`文件合并失败:${error.message}`);
      }
    },
    //获取已经上传的列表
    async getUploadedList(fileHash, fileName) {
      try {
        const response = await fetch('/api/uploadedList', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            fileHash,
            fileName,
          }),
        });
        const data = await response.json();
        return data.data || [];
      } catch (error) {
        console.error('获取已上传分片列表失败:', error);
        return [];
      }
    },
    async uploadChunk(formData, file, chunkIndex, chunkCount) {
      try {
        const response = await fetch('/api/upload', {
          method: 'POST',
          body: formData,
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const data = await response.json();
        if (data.code !== 200) {
          throw new Error(data.message || '上传失败');
        }
      } catch (error) {
        console.error(`分片 ${chunkIndex + 1} 上传失败:`, error);
        throw error;
      }
    },
    async mergeChunks(fileHash, fileName) {
      try {
        const response = await fetch('/api/merge', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            fileHash,
            fileName,
          }),
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const data = await response.json();
        if (data.code !== 200) {
          throw new Error(data.message || '合并失败');
        }
      } catch (error) {
        console.error('合并失败:', error);
        throw error;
      }
    },
    calculateFileHash(file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader();
        const spark = new SparkMD5.ArrayBuffer();
        reader.onload = (event) => {
          spark.append(event.target.result);
          resolve(spark.end());
        };
        reader.onerror = (error) => {
          reject(error);
        };
        reader.readAsArrayBuffer(file);
      });
    },
  },
};
</script>

<style scoped>
.file-upload {
  width: 500px;
  margin: 20px auto;
  padding: 20px;
  border: 1px solid #ccc;
  border-radius: 5px;
}

.preview {
  margin-top: 10px;
}

.preview div {
  display: flex;
  align-items: center;
  margin-bottom: 5px;
}

.preview img {
  width: 50px;
  height: 50px;
  margin-right: 10px;
}

.progress-bar {
  height: 10px;
  background-color: #f0f0f0;
  border-radius: 5px;
  margin-top: 10px;
}

.progress {
  height: 100%;
  background-color: #4caf50;
  border-radius: 5px;
  width: 0; /* 初始值为0 */
}

.message {
  margin-top: 10px;
  font-weight: bold;
}
</style>

代码解释:

  • template: 定义了组件的HTML结构,包括文件选择框、预览区域、上传按钮和进度条。
  • data: 存储组件的状态数据,比如选择的文件、预览图片、分片大小、上传进度等。
  • handleFileChange: 当用户选择文件时触发,将选择的文件存储到 files 数组中,并创建预览。
  • createPreviews: 根据文件类型创建预览,图片和视频使用 FileReader 读取数据并显示,其他文件类型显示文件名。
  • removeFile: 移除选择的文件。
  • uploadFiles: 开始上传文件,遍历 files 数组,逐个上传文件。
  • uploadFile: 上传单个文件,计算文件Hash,然后将文件分成多个分片,逐个上传分片,最后合并分片。
  • uploadChunk: 上传单个分片,使用 fetch 发送请求到后端。
  • mergeChunks: 通知后端合并分片,完成文件上传。
  • calculateFileHash: 计算文件的 MD5 值,用于校验文件完整性和实现断点续传。这里使用了 spark-md5 库,需要先安装: npm install spark-md5

三、 文件分片:化整为零,各个击破

文件分片的核心思想就是把一个大文件分割成多个小文件(分片),然后逐个上传。这样可以避免一次性上传大文件导致的网络拥堵、上传失败等问题。

uploadFile 方法中,我们使用 file.slice() 方法来切割文件:

const chunk = file.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
  • i * this.chunkSize: 分片的起始位置。
  • (i + 1) * this.chunkSize: 分片的结束位置。

然后,我们将分片数据放到 FormData 中,并通过 fetch 发送到后端:

const formData = new FormData();
formData.append('file', chunk);
formData.append('fileHash', fileHash); //文件hash
formData.append('fileName', fileName); // 文件名
formData.append('chunkIndex', i); // 分片索引
formData.append('chunkCount', chunkCount); // 分片总数

四、 断点续传:上次没传完,这次接着传

断点续传的关键在于记录哪些分片已经上传成功,哪些没有上传。这样,下次上传的时候,只需要上传没有上传的分片即可。

  1. 计算文件Hash: 计算文件的 MD5 值,作为文件的唯一标识。
  2. 后端记录: 后端需要记录每个文件已经上传的分片,可以使用数据库或者 Redis 等存储。
  3. 前端查询: 在上传文件之前,前端先向后端查询哪些分片已经上传成功。
  4. 跳过已上传的分片: 在上传分片的时候,跳过已经上传成功的分片。
//获取已经上传的列表
async getUploadedList(fileHash, fileName) {
  try {
    const response = await fetch('/api/uploadedList', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        fileHash,
        fileName,
      }),
    });
    const data = await response.json();
    return data.data || [];
  } catch (error) {
    console.error('获取已上传分片列表失败:', error);
    return [];
  }
},

这段代码中,前端通过 fetch 向后端发送请求,获取已经上传的分片列表。后端需要提供 /api/uploadedList 接口,接收 fileHashfileName 参数,并返回已经上传的分片列表。

然后在上传分片的时候,跳过已经上传成功的分片:

for (let i = 0; i < chunkCount; i++) {
  //如果已上传,跳过
  if (uploadedList.includes(i)) {
    console.log(`slice ${i} has been uploaded`);
    continue;
  }
  // ... 上传分片的代码
}

五、 文件预览:眼见为实,心中有数

文件预览可以提升用户体验,让用户在上传之前可以确认选择的文件是否正确。

createPreviews 方法中,我们根据文件类型创建预览:

createPreviews() {
  this.filePreviews = [];
  this.files.forEach(file => {
    if (file.type.startsWith('image/')) {
      const reader = new FileReader();
      reader.onload = (e) => {
        this.filePreviews.push(e.target.result);
      };
      reader.readAsDataURL(file);
    } else if (file.type.startsWith('video/')) {
      // 创建视频预览
      const reader = new FileReader();
      reader.onload = (e) => {
        this.filePreviews.push(e.target.result);
      };
      reader.readAsDataURL(file);
    }
    else {
      this.filePreviews.push(file.name); // 其他文件类型显示文件名
    }
  });
},
  • 图片: 使用 FileReader 读取图片数据,然后将数据URL赋值给 img 标签的 src 属性。
  • 视频: 同样使用 FileReader 读取视频数据,然后将数据URL赋值给 video 标签的 src 属性。
  • 其他文件类型: 显示文件名。

六、 异常处理:防患于未然,有备无患

在文件上传过程中,可能会出现各种异常,比如网络错误、后端错误、文件损坏等等。因此,我们需要做好异常处理,保证上传过程的稳定性。

  • try…catch: 使用 try...catch 语句捕获异常,并进行处理。
  • 错误提示: 向用户显示友好的错误提示信息。
  • 重试机制: 对于可重试的错误,可以尝试重新上传。

uploadChunk 方法中,我们使用 try...catch 语句捕获异常:

try {
  await this.uploadChunk(formData, file, i, chunkCount);
} catch (error) {
  throw new Error(`分片 ${i + 1} 上传失败:${error.message}`);
}

七、 优化建议:锦上添花,更上一层楼

  • 进度条: 显示上传进度,让用户了解上传进度。
  • 并发上传: 可以同时上传多个分片,提高上传速度。但是需要注意控制并发数量,避免对服务器造成过大的压力。
  • 取消上传: 提供取消上传的功能,让用户可以随时停止上传。
  • 拖拽上传: 支持拖拽上传,提升用户体验。
  • 文件类型限制: 限制上传文件的类型,避免上传恶意文件。
  • 文件大小限制: 限制上传文件的大小,避免上传过大的文件。

八、 后端配合:前后端齐心,其利断金

前端只是负责将文件分片,然后发送到后端,真正干活的还是后端老哥。所以,后端需要提供以下接口:

  • /api/upload: 接收分片数据,并将分片数据保存到临时目录。
  • /api/merge: 接收文件Hash和文件名,将临时目录中的分片合并成一个完整的文件。
  • /api/uploadedList: 接收文件Hash和文件名,返回已经上传的分片列表。

后端代码示例 (Node.js + Koa):

const Koa = require('koa');
const Router = require('koa-router');
const koaBody = require('koa-body');
const fs = require('fs');
const path = require('path');

const app = new Koa();
const router = new Router();

const UPLOAD_DIR = path.resolve(__dirname, 'upload');

// 确保上传目录存在
if (!fs.existsSync(UPLOAD_DIR)) {
  fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}

// 获取已上传分片列表
router.post('/api/uploadedList', koaBody(), async (ctx) => {
  const { fileHash, fileName } = ctx.request.body;
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
  if (!fs.existsSync(chunkDir)) {
    ctx.body = { code: 200, data: [] };
    return;
  }
  const uploadedChunks = fs.readdirSync(chunkDir);
  const uploadedList = uploadedChunks.map(chunk => parseInt(chunk.split('-')[1]));
  ctx.body = { code: 200, data: uploadedList };
});

// 上传分片
router.post('/api/upload', koaBody({ multipart: true }), async (ctx) => {
  const file = ctx.request.files.file;
  const { fileHash, fileName, chunkIndex, chunkCount } = ctx.request.body;

  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
  if (!fs.existsSync(chunkDir)) {
    fs.mkdirSync(chunkDir, { recursive: true });
  }

  const chunkPath = path.resolve(chunkDir, `${fileName}-${chunkIndex}`);

  const reader = fs.createReadStream(file.path);
  const writer = fs.createWriteStream(chunkPath);
  reader.pipe(writer);

  console.log(`Chunk ${chunkIndex} uploaded for ${fileName}`);

  ctx.body = { code: 200, message: '分片上传成功' };
});

// 合并分片
router.post('/api/merge', koaBody(), async (ctx) => {
  const { fileHash, fileName } = ctx.request.body;
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
  const targetPath = path.resolve(UPLOAD_DIR, fileName);

  const chunkFiles = fs.readdirSync(chunkDir);
  chunkFiles.sort((a, b) => {
    const indexA = parseInt(a.split('-')[1]);
    const indexB = parseInt(b.split('-')[1]);
    return indexA - indexB;
  });

  const writeStream = fs.createWriteStream(targetPath);

  for (const chunkFile of chunkFiles) {
    const chunkPath = path.resolve(chunkDir, chunkFile);
    const readStream = fs.createReadStream(chunkPath);
    readStream.pipe(writeStream, { end: false });
    await new Promise((resolve) => readStream.on('end', resolve));
    fs.unlinkSync(chunkPath); // 删除分片
  }

  writeStream.on('finish', () => {
    fs.rmdirSync(chunkDir, { recursive: true }); // 删除分片目录
    console.log(`File ${fileName} merged successfully`);
  });

  ctx.body = { code: 200, message: '文件合并成功' };
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

九、 总结:功夫不负有心人

好了,各位。一个支持文件分片上传、断点续传和预览功能的通用 Vue 文件上传组件就完成了。当然,这只是一个基础版本,还有很多可以优化的地方。希望大家能够在此基础上,打造出更加强大、更加易用的文件上传组件。

记住,撸代码就像谈恋爱,需要耐心、细心和激情!

最后,祝大家早日成为代码界的弄潮儿!溜了溜了~

发表回复

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