HTML5结合Vue3实现百万文件分块上传的思路是什么?

大文件上传方案探索:从WebUploader到自定义分片上传的实践

作为一名前端开发工程师,最近遇到了一个颇具挑战性的需求:需要在Vue项目中实现4GB左右大文件的稳定上传,且要兼容Chrome、Firefox、Edge等主流浏览器,后端使用PHP接收。此前我们采用了百度开源的WebUploader组件,但在实际使用中遇到了几个难以解决的问题:

  1. 分片上传过程中偶尔会出现断点续传失效的情况
  2. 对新版浏览器的兼容性不够理想
  3. 缺乏官方技术支持,社区活跃度下降
  4. 自定义UI的灵活性不足

方案选型思考

经过技术调研,我评估了以下几个主流方案:

  1. Plupload:功能全面但文档不够友好,对Vue集成支持一般
  2. Uppy:现代感强但体积较大,学习曲线较陡
  3. Resumable.js:专注分片上传但UI较为基础
  4. 自定义实现:基于XMLHttpRequest/Fetch API实现核心分片逻辑

最终决定采用自定义分片上传方案,主要基于以下考虑:

  • 完全控制上传流程
  • 可以针对业务需求深度优化
  • 减少第三方依赖,降低维护成本
  • 与Vue生态无缝集成

核心实现思路

1. 前端分片策略

javascript 复制代码
// 文件分片工具函数
const chunkFile = (file, chunkSize = 5 * 1024 * 1024) => {
  const chunks = []
  let current = 0
  while (current < file.size) {
    chunks.push({
      file: file.slice(current, current + chunkSize),
      chunkIndex: chunks.length,
      totalChunks: Math.ceil(file.size / chunkSize),
      fileName: file.name,
      fileSize: file.size,
      fileType: file.type,
      fileLastModified: file.lastModified,
      identifier: generateFileIdentifier(file) // 生成唯一标识用于断点续传
    })
    current += chunkSize
  }
  return chunks
}

// 生成文件唯一标识(基于文件内容)
const generateFileIdentifier = (file) => {
  return new Promise((resolve) => {
    const reader = new FileReader()
    reader.onload = (e) => {
      const arr = new Uint8Array(e.target.result)
      const hashArray = Array.from(arr).map(b => b.toString(16).padStart(2, '0'))
      resolve(hashArray.join('').substring(0, 16))
    }
    reader.readAsArrayBuffer(file.slice(0, 1024 * 1024)) // 取前1MB计算哈希
  })
}

2. Vue组件实现

vue 复制代码
export default {
  data() {
    return {
      file: null,
      chunks: [],
      uploadStatus: 'idle', // idle, uploading, paused, completed, error
      progress: 0,
      error: null,
      currentChunk: 0,
      abortController: null
    }
  },
  methods: {
    async handleFileChange(e) {
      this.file = e.target.files[0]
      if (!this.file) return
      
      // 生成文件标识(简化版,实际项目应使用更可靠的算法)
      const identifier = await this.generateSimpleIdentifier(this.file)
      
      // 检查服务器是否有未完成的上传记录
      const res = await this.checkUploadStatus(identifier)
      if (res.exists) {
        if (confirm('检测到未完成的上传,是否继续?')) {
          this.currentChunk = res.uploadedChunks
        } else {
          // 清除服务器记录(实际项目应实现)
        }
      }
      
      this.chunks = this.chunkFile(this.file)
      this.progress = Math.round((this.currentChunk / this.chunks.length) * 100)
    },
    
    async startUpload() {
      if (!this.file) return
      
      this.uploadStatus = 'uploading'
      this.error = null
      this.abortController = new AbortController()
      
      try {
        for (let i = this.currentChunk; i < this.chunks.length; i++) {
          if (this.uploadStatus !== 'uploading') break // 处理暂停情况
          
          const chunk = this.chunks[i]
          const formData = new FormData()
          formData.append('file', chunk.file)
          formData.append('chunkIndex', chunk.chunkIndex)
          formData.append('totalChunks', chunk.totalChunks)
          formData.append('fileName', chunk.fileName)
          formData.append('fileSize', chunk.fileSize)
          formData.append('fileType', chunk.fileType)
          formData.append('identifier', chunk.identifier)
          
          await this.uploadChunk(formData)
          
          this.currentChunk = i + 1
          this.progress = Math.round(((i + 1) / this.chunks.length) * 100)
        }
        
        if (this.uploadStatus === 'uploading') {
          await this.mergeChunks(this.chunks[0].identifier, this.chunks[0].fileName)
          this.uploadStatus = 'completed'
          this.$emit('upload-complete')
        }
      } catch (err) {
        console.error('上传失败:', err)
        this.error = err.message || '上传过程中出现错误'
        this.uploadStatus = 'error'
      }
    },
    
    async uploadChunk(formData) {
      return fetch('/api/upload-chunk.php', {
        method: 'POST',
        body: formData,
        signal: this.abortController.signal
      })
    },
    
    async mergeChunks(identifier, fileName) {
      return fetch('/api/merge-chunks.php', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ identifier, fileName })
      })
    },
    
    // 简化版标识生成(实际项目应使用更可靠的算法)
    generateSimpleIdentifier(file) {
      return `${file.name}-${file.size}-${file.lastModified}`
    },
    
    async checkUploadStatus(identifier) {
      // 实际项目应实现与后端的交互
      return { exists: false, uploadedChunks: 0 }
    },
    
    pauseUpload() {
      if (this.uploadStatus === 'uploading') {
        this.abortController.abort()
        this.uploadStatus = 'paused'
      }
    },
    
    resumeUpload() {
      if (this.uploadStatus === 'paused') {
        this.startUpload()
      }
    }
  }
}

3. PHP后端实现要点

php 复制代码
 $chunkIndex,
            'totalChunks' => $totalChunks,
            'fileName' => $fileName,
            'uploaded' => time()
        ]));
    
    echo json_encode(['status' => 'success']);
} else {
    http_response_code(500);
    echo json_encode(['status' => 'error', 'message' => 'Failed to save chunk']);
}

// merge-chunks.php - 合并分片
header('Content-Type: application/json');

$uploadDir = '/path/to/upload/dir/';
$tempDir = $uploadDir . 'temp/';

$data = json_decode(file_get_contents('php://input'), true);
$identifier = $data['identifier'] ?? '';
$fileName = $data['fileName'] ?? '';

// 检查标识符和文件名
if (empty($identifier) || empty($fileName)) {
    http_response_code(400);
    echo json_encode(['status' => 'error', 'message' => 'Invalid parameters']);
    exit;
}

// 检查上传状态文件
$statusFile = $tempDir . $identifier . '.upload';
if (!file_exists($statusFile)) {
    http_response_code(404);
    echo json_encode(['status' => 'error', 'message' => 'Upload not found']);
    exit;
}

$status = json_decode(file_get_contents($statusFile), true);
$totalChunks = $status['totalChunks'] ?? 0;

// 合并文件
$finalPath = $uploadDir . $fileName;
if ($fp = fopen($finalPath, 'wb')) {
    for ($i = 0; $i < $totalChunks; $i++) {
        $chunkPath = $tempDir . $identifier . '_' . $i;
        if (!file_exists($chunkPath)) {
            fclose($fp);
            unlink($finalPath); // 删除已创建的部分文件
            http_response_code(400);
            echo json_encode(['status' => 'error', 'message' => 'Missing chunk ' . $i]);
            exit;
        }
        
        $content = file_get_contents($chunkPath);
        fwrite($fp, $content);
        unlink($chunkPath); // 删除已合并的分片
    }
    fclose($fp);
    
    // 删除状态文件
    unlink($statusFile);
    
    echo json_encode(['status' => 'success', 'path' => $finalPath]);
} else {
    http_response_code(500);
    echo json_encode(['status' => 'error', 'message' => 'Failed to create final file']);
}
?>

方案优势与改进点

优势

  1. 完全可控:从分片策略到上传逻辑完全自主实现
  2. 深度优化:可以根据网络状况动态调整分片大小
  3. 良好兼容:基于标准Web API实现,兼容所有现代浏览器
  4. 断点续传:通过文件标识实现可靠的断点续传
  5. 进度可视化:精确计算上传进度

可改进方向

  1. 并发上传:当前实现是顺序上传,可优化为并发上传提高速度
  2. 文件校验:增加MD5/SHA校验确保文件完整性
  3. 更可靠的标识生成:当前简化版标识可能存在冲突风险
  4. 服务端清理:实现自动清理未完成上传的临时文件
  5. 拖拽上传:增强用户体验,支持拖放文件上传

实施建议

  1. 渐进式实现:先实现基本分片上传,再逐步添加断点续传、并发上传等功能
  2. 充分测试:在不同网络环境和浏览器下进行全面测试
  3. 监控上报:添加上传失败监控和错误上报机制
  4. 性能优化:根据实际测试结果调整分片大小和并发数

通过这种自定义实现方式,我们成功解决了WebUploader带来的各种问题,同时获得了更好的性能和更灵活的控制能力。目前该方案已在我们项目中稳定运行数月,处理了数百个4GB+文件的上传,未出现重大故障。

将组件复制到项目中

示例中已经包含此目录

引入组件

配置接口地址

接口地址分别对应:文件初始化,文件数据上传,文件进度,文件上传完毕,文件删除,文件夹初始化,文件夹删除,文件列表

参考:http://www.ncmem.com/doc/view.aspx?id=e1f49f3e1d4742e19135e00bd41fa3de

处理事件

启动测试

启动成功

效果

数据库

效果预览

文件上传

文件刷新续传

支持离线保存文件进度,在关闭浏览器,刷新浏览器后进行不丢失,仍然能够继续上传

文件夹上传

支持上传文件夹并保留层级结构,同样支持进度信息离线保存,刷新页面,关闭页面,重启系统不丢失上传进度。

批量下载

支持文件批量下载

下载续传

文件下载支持离线保存进度信息,刷新页面,关闭页面,重启系统均不会丢失进度信息。

文件夹下载

支持下载文件夹,并保留层级结构,不打包,不占用服务器资源。

下载示例

点击下载完整示例

相关推荐
San30.2 小时前
现代前端工程化实战:从 Vite 到 React Router demo的构建之旅
前端·react.js·前端框架
kirinlau2 小时前
vue3+vite+scss项目使用tailwindcss
前端·css·scss
阿贾克斯的黎明2 小时前
现代前端的魔法标签:HTML5 语义化标签全解析
前端·html·html5
菠菜盼娣2 小时前
vue3知识点
前端·vue.js
JIngJaneIL2 小时前
基于java+ vue建筑材料管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot
土豆12502 小时前
终端自治时代的 AI 开发范式:Claude Code CLI 全方位实操指南
前端·人工智能·程序员
Gazer_S2 小时前
【Vue Router 路由守卫(Navigation Guards)指南:概念、执行顺序、beforeResolve、异步路由组件】
前端·javascript·vue.js
半梅芒果干2 小时前
vue3 新建文件store自动导入
开发语言·前端·javascript