大文件上传

153 阅读6分钟

实现思路

  1. 文件选择与预处理

    • 用户通过文件选择器选择文件。
    • 对文件进行初步检查(如文件类型、大小限制)。
    • 如果文件大小超过了阈值,则将其分割成多个较小的部分(分片)。
  2. 计算文件哈希值

    • 为确保文件的完整性,在上传前对整个文件计算一个唯一的哈希值(例如 MD5 或 SHA-256),并保存下来。这将在后续的断点续传中使用。
  3. 文件分片

    • 将文件分成固定大小的块(例如每块 1MB)。每个块都会有自己的索引编号。
    • 对于每个分片,计算其哈希值以确保数据的一致性和完整性。
  4. 分片上传

    • 使用异步请求(如 XMLHttpRequest 或 Fetch API)逐个上传每个分片。
    • 每上传完一个分片,记录该分片的状态(已上传/未上传)。
    • 上传过程中可以显示实时的上传进度。
  5. 断点续传

    • 如果上传过程中发生错误或中断,可以重新开始从最后成功上传的分片继续上传。
    • 这可以通过查询服务器来确定哪些分片已经成功上传。
  6. 合并分片

    • 服务器端接收所有分片后,验证所有分片的哈希值是否与客户端计算的结果一致。
    • 如果所有分片都正确无误,则将它们合并成一个完整的文件。
    • 合并后的文件还需要再次计算哈希值并与原始文件的哈希值进行对比,确保最终文件的完整性。
  7. 错误处理

    • 在上传过程中可能出现各种错误,例如网络问题、服务器错误等。
    • 需要实现错误处理机制,例如重试机制、错误提示等。
  8. 进度条

    • 提供一个可视化的进度条来展示文件上传的整体进度。
    • 可以基于已上传的分片数量与总分片数量的比例来更新进度条。
  9. 用户体验

    • 在上传过程中提供良好的用户体验,例如取消按钮、暂停/恢复功能等。
    • 确保界面简洁易懂。
  10. 服务器端逻辑

    • 服务器端需要实现以下功能:
      • 接收分片。
      • 存储分片的状态。
      • 校验分片的哈希值。
      • 合并分片为完整文件。
      • 返回文件上传状态。

代码实现

安装 Element UI:

npm install element-ui --save

main.js 中引入 Element UI:


import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';

Vue.use(ElementUI);

1. 实现文件上传组件

src/components 目录下创建 FileUploader.vue 文件:

<template>
  <div>
    <!-- 文件上传按钮 -->
    <el-upload
      ref="upload"
      :action="uploadUrl"
      :show-file-list="false"
      :on-change="handleChange"
      :before-upload="beforeUpload"
      :multiple="false"
    >
      <el-button size="small" type="primary">点击上传</el-button>
    </el-upload>

    <!-- 进度条 -->
    <el-progress :percentage="progress" status="active"></el-progress>
  </div>
</template>

<script>
import { ElMessage } from 'element-ui';

export default {
  data() {
    return {
      // 服务器端的上传接口
      uploadUrl: '/api/upload',
      // 当前选中的文件
      selectedFile: null,
      // 文件的哈希值
      fileHash: '',
      // 文件分片数组
      fileChunks: [],
      // 当前正在上传的分片索引
      currentChunkIndex: 0,
      // 文件上传进度百分比
      progress: 0,
      // 用于显示上传进度的事件监听器
      uploadProgress: 0,
    };
  },
  methods: {
    // 文件改变时触发
    handleChange(file) {
      this.selectedFile = file.raw; // 保存原始文件对象
      this.fileHash = this.calculateFileHash(this.selectedFile); // 计算文件的哈希值
      this.fileChunks = this.getChunks(this.selectedFile); // 获取文件分片
      this.uploadNextChunk(); // 开始上传第一个分片
    },
    // 在上传前执行的验证函数
    beforeUpload(file) {
      // 验证文件大小不超过 2GB
      const isLt2M = file.size / 1024 / 1024 < 2048;
      if (isLt2M) {
        return true;
      }
      ElMessage.error('上传文件大小不能超过 2GB!');
      return false;
    },
    // 上传下一个分片
    async uploadNextChunk() {
      if (this.currentChunkIndex >= this.fileChunks.length) {
        return; // 所有分片已上传完成
      }

      const chunk = this.fileChunks[this.currentChunkIndex]; // 获取当前分片
      const chunkHash = await this.calculateFileHash(chunk); // 计算分片的哈希值

      // 发送分片到服务器
      fetch(this.uploadUrl, {
        method: 'POST',
        body: chunk,
        headers: {
          'Content-Type': 'application/octet-stream',
          'X-File-Hash': this.fileHash,
          'X-Chunk-Index': this.currentChunkIndex,
          'X-Chunk-Hash': chunkHash,
        },
      })
        .then((response) => {
          if (!response.ok) {
            throw new Error('Network response was not ok');
          }
          this.currentChunkIndex++; // 更新当前分片索引
          this.updateProgress(); // 更新进度条
          this.uploadNextChunk(); // 递归上传下一个分片
        })
        .catch((error) => {
          console.error('Error uploading chunk:', error);
          // 错误处理,例如重试
        });
    },
    // 更新进度条的百分比
    updateProgress() {
      this.progress = (this.currentChunkIndex / this.fileChunks.length) * 100;
      this.$emit('progress-update', this.progress);
    },
    // 将文件分割成指定大小的分片
    getChunks(file, chunkSize = 1 * 1024 * 1024) { // 1MB
      const chunks = [];
      let start = 0;
      while (start < file.size) {
        const end = Math.min(start + chunkSize, file.size);
        chunks.push(file.slice(start, end)); // 添加分片到数组
        start += chunkSize;
      }
      return chunks;
    },
    // 计算文件或分片的哈希值
    async calculateFileHash(file) {
      return new Promise((resolve) => {
        const reader = new FileReader();
        reader.onload = () => {
          // 使用浏览器提供的 SubtleCrypto API 计算哈希值
          const hash = crypto.subtle.digest('SHA-256', reader.result).then((digest) => {
            const hex = Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
            resolve(hex);
          });
        };
        reader.readAsArrayBuffer(file);
      });
    },
  },
};
</script>

2. 更新 App.vue

src/App.vue 中引入 FileUploader.vue 并使用它:

<template>
  <div id="app">
    <FileUploader />
  </div>
</template>

<script>
import FileUploader from './components/FileUploader.vue';

export default {
  name: 'App',
  components: {
    FileUploader,
  },
};
</script>

3. 配置服务器端

为了实现服务器端逻辑,我们可以使用 Express.js 构建一个简单的 HTTP 服务器。在项目的根目录下安装 Express.js

npm install express --save

创建一个 server.js 文件:


const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
const port = 3000;

// 设置静态文件目录
app.use(express.static(path.join(__dirname, 'dist')));

// 文件上传处理
app.post('/api/upload', (req, res) => {
  const fileHash = req.headers['x-file-hash'];
  const chunkIndex = parseInt(req.headers['x-chunk-index'], 10);
  const chunkHash = req.headers['x-chunk-hash'];

  // 检查文件夹是否存在
  const uploadDir = path.join(__dirname, 'uploads');
  if (!fs.existsSync(uploadDir)) {
    fs.mkdirSync(uploadDir);
  }

  // 保存分片
  const chunkPath = path.join(uploadDir, `${fileHash}_${chunkIndex}`);
  fs.writeFile(chunkPath, req.body, (err) => {
    if (err) {
      console.error(err);
      res.status(500).send('Error saving file');
    } else {
      res.send('Chunk uploaded successfully');
    }
  });
});

app.listen(port, () => {
  console.log(`Server listening at http://localhost:${port}`);
});

运行项目

启动服务器:

    node server.js

运行 Vue 项目:

npm run serve

现在你可以访问 http://localhost:8080 来测试文件上传功能了。

注意事项

  • 确保服务器端能够处理客户端发送的分片。
  • 在服务器端实现断点续传、分片合并和哈希值校验的逻辑。
  • 增加更完善的错误处理和用户体验功能。

代码解释

1. 文件选择与预处理

HTML 模板

  • el-upload: 使用 Element UI 的 el-upload 组件来创建一个文件上传按钮。

    • ref="upload": 通过 ref 属性可以访问到这个组件的实例。
    • :action="uploadUrl": 设置上传文件的目标 URL。
    • :show-file-list="false": 不显示文件列表。
    • :on-change="handleChange": 当文件发生变化时调用 handleChange 方法。
    • :before-upload="beforeUpload": 上传文件前调用 beforeUpload 方法来进行验证。
    • :multiple="false": 禁止多选文件。

Vue.js 脚本

  • data: 初始化数据属性。

    • uploadUrl: 服务器端接收文件的 URL。
    • selectedFile: 存储用户选择的文件。
    • fileHash: 文件的哈希值。
    • fileChunks: 文件分片数组。
    • currentChunkIndex: 当前正在上传的分片索引。
    • progress: 文件上传的进度百分比。
  • methods:

    • handleChange(file): 当文件发生变化时调用此方法。

      • file.raw: 获取原始文件对象。
      • 调用 calculateFileHash 方法计算文件哈希值。
      • 调用 getChunks 方法获取文件分片。
      • 调用 uploadNextChunk 方法开始上传第一个分片。
    • beforeUpload(file): 在上传文件前执行的验证函数。

      • 验证文件大小不超过 2GB。
      • 如果验证失败,则显示错误消息并阻止上传。
    • uploadNextChunk(): 上传下一个分片。

    • updateProgress(): 更新进度条的百分比。

    • getChunks(file, chunkSize = 1 * 1024 * 1024): 将文件分割成指定大小的分片。

    • calculateFileHash(file): 计算文件或分片的哈希值。

2. 计算文件哈希值

calculateFileHash(file)

  • 创建 FileReader 实例读取文件为 ArrayBuffer。
  • 使用浏览器提供的 SubtleCrypto API 计算文件的 SHA-256 哈希值。
  • 将计算得到的哈希值转换为十六进制字符串。

3. 文件分片

getChunks(file, chunkSize = 1 * 1024 * 1024)

  • 将文件分割成大小为 chunkSize 的分片。
  • 使用 file.slice(start, end) 方法获取每个分片。
  • 将每个分片添加到 chunks 数组中。

4. 分片上传

uploadNextChunk()

  • 上传当前分片到服务器。
  • 使用 fetch 发送 POST 请求。
  • 设置 Content-Type 为 application/octet-stream 表示发送的是二进制数据。
  • 设置自定义头部 X-File-HashX-Chunk-IndexX-Chunk-Hash 用于传递文件哈希值、当前分片索引和分片哈希值。
  • 成功上传后更新当前分片索引并递归调用 uploadNextChunk 上传下一个分片。
  • 失败时记录错误,并保存失败的分片索引以便后续重试。

5. 断点续传

uploadNextChunk() 中的错误处理

  • 当上传过程中发生错误时,保存当前分片索引到 localStorage 中。
  • 可以在后续尝试重新上传时从失败的位置开始上传。

6. 合并分片

这部分逻辑需要在服务器端实现,负责接收分片、验证分片的完整性和正确性,并将它们合并成完整的文件。

7. 错误处理

错误处理已经在 uploadNextChunk() 方法中实现,通过 .catch 捕获异常并进行处理。

8. 进度条

updateProgress()

  • 根据已上传的分片数计算进度百分比。
  • 更新进度条的百分比。

9. 用户体验

  • 使用 Element UI 的 el-progress 组件显示上传进度。
  • el-upload 组件提供了良好的用户交互。