大文件上传方案探索:从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. 前端分片策略

// 文件分片工具函数
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组件实现




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后端实现要点

 $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+文件的上传,未出现重大故障。

将组件复制到项目中

示例中已经包含此目录
image

引入组件

image

配置接口地址

接口地址分别对应:文件初始化,文件数据上传,文件进度,文件上传完毕,文件删除,文件夹初始化,文件夹删除,文件列表
参考:http://www.ncmem.com/doc/view.aspx?id=e1f49f3e1d4742e19135e00bd41fa3de
image

处理事件

image

启动测试

image

启动成功

image

效果

image

数据库

image

效果预览

文件上传

文件上传

文件刷新续传

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

文件夹上传

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

批量下载

支持文件批量下载
批量下载

下载续传

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

文件夹下载

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

下载示例

点击下载完整示例

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐