VUE March 10, 2025

vue3实现大文件分片上传和断点续传

Words count 8.2k Reading time 7 mins. Read count 0

首先,前端部分需要用到Vue3的Composition API,可能需要用ref和reactive来管理状态。然后,文件上传要用到File API,特别是Blob对象的slice方法来分片。每个分片需要有一个唯一的标识,可能是文件名加上分片索引,或者使用文件内容的hash,但为了简化,可能先使用文件名和索引的组合。

接下来,上传进度的显示,需要计算已上传的分片数和总分片数,然后更新进度条。断点续传的话,需要后端支持,记录已上传的分片,所以前端在上传前应该先询问后端哪些分片已经上传过了,避免重复上传。

然后,关于并发控制,可能需要限制同时上传的分片数,避免过多的请求导致浏览器崩溃或服务器压力过大。可以使用Promise.all或者类似的机制来管理并发请求。

后端的部分,虽然用户可能更关注前端,但需要简单提一下后端的实现要点。比如,每个分片需要保存,并在所有分片上传完成后合并成原文件。后端需要提供接口检查分片状态,接收分片数据,以及合并文件的接口。

可能遇到的问题包括文件分片的大小设置,太大或太小都可能影响性能。通常设置为1MB左右。另外,文件唯一标识的生成,可能需要用到文件名、大小、最后修改时间等,不过对于精确识别,可能需要计算文件的hash,比如MD5,但这在前端计算可能会比较耗时,特别是大文件。

断点续传的实现,需要前端在上传前先发送一个请求,检查哪些分片已经上传,然后过滤掉已上传的分片,只上传剩下的。这需要后端有一个机制来记录每个文件的分片上传状态。

另外,合并分片时,后端需要按顺序将分片合并成原文件,确保文件的正确性。分片的顺序需要前端在上传时维护,比如按索引顺序上传,或者在上传时附带分片顺序信息。

前端的代码结构大概包括:选择文件,计算分片,检查分片状态,上传分片,显示进度,合并请求。需要使用axios或其他HTTP库来发送分片数据,可能需要用FormData来包装分片和元数据。

对于Vue3组件,可能需要一个文件输入框,一个上传按钮,进度条显示。状态管理包括当前文件、分片列表、已上传的分片索引、上传进度等。

测试的时候,可以模拟网络中断,或者手动停止上传,然后再次选择同一文件,检查是否跳过已上传的分片,继续上传。

可能还需要处理文件重命名的问题,如果用户上传同名但内容不同的文件,需要确保不会覆盖。可以通过生成唯一标识来解决,比如时间戳或随机字符串附加到文件名。

总结一下,步骤大概是:

选择文件,读取文件信息。
分片,生成每个分片的唯一标识。
检查后端,获取已上传的分片列表。
过滤掉已上传的分片,上传剩下的。
并发上传分片,控制并发数。
所有分片上传完成后,发送合并请求。
显示上传进度和状态。
需要注意的点包括:分片大小、唯一标识生成、并发控制、错误重试、进度计算、后端接口设计等。

在Vue3中实现大文件分片上传和断点续传,可以按照以下步骤进行:

一、核心实现思路

  1. 文件分片:将大文件切割为固定大小的块(如1MB)
  2. 唯一标识:使用文件内容hash或文件元数据生成唯一标识
  3. 断点续传:服务端记录已上传分片,前端上传前先查询
  4. 分片上传:并发上传文件分片(需控制并发数)
  5. 分片合并:所有分片上传完成后通知服务端合并

二、前端实现代码(Vue3 + TypeScript)

<template>
  <div>
    <input type="file" @change="handleFileChange" />
    <button @click="startUpload">开始上传</button>
    <progress :value="progress" max="100"></progress>
    <div>{{ progress }}%</div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';

interface Chunk {
  index: number;
  file: Blob;
  hash: string;
}

const CHUNK_SIZE = 1024 * 1024; // 1MB
const CONCURRENCY = 3; // 并发数

const file = ref<File | null>(null);
const progress = ref(0);
const uploadedChunks = ref<Set<string>>(new Set());

// 生成文件唯一标识(简化版)
const generateFileHash = async (file: File) => {
  return `${file.name}-${file.size}-${file.lastModified}`;
};

// 文件分片
const createChunks = (file: File, fileHash: string): Chunk[] => {
  const chunks: Chunk[] = [];
  let index = 0;
  
  for (let cur = 0; cur < file.size; cur += CHUNK_SIZE) {
    const chunk = file.slice(cur, cur + CHUNK_SIZE);
    chunks.push({
      index,
      file: chunk,
      hash: `${fileHash}-${index}`
    });
    index++;
  }
  
  return chunks;
};

// 检查已上传分片
const checkUploadedChunks = async (fileHash: string) => {
  try {
    const { data } = await axios.get('/api/check', {
      params: { fileHash }
    });
    uploadedChunks.value = new Set(data.uploadedChunks);
  } catch (error) {
    console.error('检查已上传分片失败:', error);
  }
};

// 上传分片
const uploadChunk = async (chunk: Chunk, fileHash: string) => {
  const formData = new FormData();
  formData.append('file', chunk.file);
  formData.append('hash', chunk.hash);
  formData.append('fileHash', fileHash);
  formData.append('index', chunk.index.toString());

  await axios.post('/api/upload', formData, {
    headers: { 'Content-Type': 'multipart/form-data' }
  });

  uploadedChunks.value.add(chunk.hash);
};

// 开始上传
const startUpload = async () => {
  if (!file.value) return;

  const fileHash = await generateFileHash(file.value);
  const chunks = createChunks(file.value, fileHash);
  
  await checkUploadedChunks(fileHash);
  
  const pendingChunks = chunks.filter(
    chunk => !uploadedChunks.value.has(chunk.hash)
  );

  // 控制并发上传
  const pool = [];
  for (let i = 0; i < pendingChunks.length; i++) {
    const chunk = pendingChunks[i];
    const task = uploadChunk(chunk, fileHash).then(() => {
      pool.splice(pool.indexOf(task), 1);
      updateProgress();
    });
    pool.push(task);
    if (pool.length >= CONCURRENCY) {
      await Promise.race(pool);
    }
  }

  await Promise.all(pool);
  
  // 所有分片上传完成,请求合并
  await axios.post('/api/merge', {
    fileHash,
    totalChunks: chunks.length,
    fileName: file.value.name
  });
};

// 更新进度
const updateProgress = () => {
  const total = file.value?.size || 0;
  const uploaded = Array.from(uploadedChunks.value).reduce(
    (sum, hash) => sum + CHUNK_SIZE,
    0
  );
  progress.value = Math.min(100, (uploaded / total) * 100);
};

const handleFileChange = (e: Event) => {
  const target = e.target as HTMLInputElement;
  if (target.files) {
    file.value = target.files[0];
  }
};
</script>

三、后端实现要点(Node.js示例)

// 检查已上传分片
router.get('/check', (req, res) => {
  const { fileHash } = req.query;
  const uploaded = fs.existsSync(`./uploads/${fileHash}`) 
    ? JSON.parse(fs.readFileSync(`./uploads/${fileHash}/info.json`)) 
    : [];
  res.json({ uploadedChunks: uploaded });
});

// 分片上传
router.post('/upload', (req, res) => {
  const { fileHash, hash, index } = req.body;0
  const chunk = req.files.file;
  
  const chunkDir = `./uploads/${fileHash}`;
  if (!fs.existsSync(chunkDir)) {
    fs.mkdirSync(chunkDir, { recursive: true });
  }
  
  fs.renameSync(chunk.path, `${chunkDir}/${hash}`);
  
  const infoFile = `${chunkDir}/info.json`;
  const uploaded = fs.existsSync(infoFile) 
    ? JSON.parse(fs.readFileSync(infoFile))
    : [];
  if (!uploaded.includes(hash)) {
    uploaded.push(hash);
    fs.writeFileSync(infoFile, JSON.stringify(uploaded));
  }
  
  res.json({ success: true });
});

// 合并文件
router.post('/merge', async (req, res) => {
  const { fileHash, fileName, totalChunks } = req.body;
  const chunkDir = `./uploads/${fileHash}`;
  const files = fs.readdirSync(chunkDir)
    .filter(f => f !== 'info.json')
    .sort((a, b) => a.split('-')[1] - b.split('-')[1]);
  
  const writeStream = fs.createWriteStream(`./uploads/${fileName}`);
  for (const file of files) {
    const chunkPath = `${chunkDir}/${file}`;
    await new Promise(resolve => {
      const readStream = fs.createReadStream(chunkPath);
      readStream.pipe(writeStream, { end: false });
      readStream.on('end', resolve);
    });
  }
  writeStream.end();
  
  // 清理临时文件
  fs.rmSync(chunkDir, { recursive: true });
  
  res.json({ success: true });
});

四、优化建议

  1. 文件Hash生成:使用SparkMD5计算文件内容hash,更准确
  2. 错误重试:为每个分片添加重试机制
  3. 进度计算:使用实际传输字节数代替分片数量计算
  4. 暂停/恢复:添加暂停按钮和恢复上传逻辑
  5. 分片大小自适应:根据网络情况动态调整分片大小
  6. 内存优化:使用流式处理替代整个文件读取

五、注意事项

  1. 服务端需要处理跨域问题
  2. 文件保存路径需要做好安全防护
  3. 生产环境需要添加用户认证和权限控制
  4. 大文件合并时建议使用流式写入
  5. 分布式部署时需要共享存储或使用对象存储服务

完整实现需要结合具体业务需求进行调整,建议使用现成的文件上传库(如tus-js-client)作为基础进行二次开发。

0%