如何优雅地上传大文件?分片上传实战指南

一、背景与流程

当文件体积较大(如视频)时,直接上传容易超时、失败,且无法展示进度。分片上传将文件切成多块依次上传,再在服务端合并,可提升稳定性和体验。

整体流程

scss 复制代码
初始化(init) → 判断是否分片 → 切片 → 逐块上传(upload) → 合并(merge)
                                    ↑
                            异常/取消时调用 abort

二、核心步骤

2.1 初始化

向服务端发起初始化请求,获取本次上传的 uploadIdfid 及建议的 chunkSize。若返回的 chunkCount <= 1,则走普通单文件上传,不分片。

项目 说明
请求方式 GET
参数 fileName、fileSize、chunkSize(可选)
响应 chunkCount、fid、uploadId、chunkSize

2.2 切片

  • 使用 File.prototype.slice(start, end) 切分文件
  • 每片建议 5M~70M,每片 ≥ 5M
  • 若最后一块 < 5M,需与倒数第二块合并

2.3 逐块上传

  • 请求方式:POST,Content-Type: multipart/form-data
  • 每块需传:fid、uploadId、chunkIndex(从 0 开始)、file
  • 每块返回 chunkTag(JSON 字符串),需按顺序收集,供合并使用
  • 建议控制并发数,避免压垮服务端

2.4 合并

  • 请求方式:POST
  • 参数:fid、uploadId、chunkTagList(按 partNumber 顺序的 JSON 数组字符串)
  • 成功即上传完成,返回最终文件标识

2.5 放弃上传(abort)

初始化后若异常或用户取消,应调用 abort 接口,传入 fid、uploadId,释放服务端资源。


三、关键代码示例

3.1 切片与合并最后小块

javascript 复制代码
const MIN_CHUNK = 5 * 1024 * 1024  // 5M
const CHUNK_SIZE = 50 * 1024 * 1024  // 50M

function createChunkList(file, chunkSize = CHUNK_SIZE) {
  let chunkCount = Math.ceil(file.size / chunkSize)
  const chunkList = []
  for (let i = 0; i < chunkCount; i++) {
    const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize)
    chunkList.push(new File([chunk], file.name))
  }
  // 最后一块 < 5M 必须与前一块合并
  if (chunkList.length > 1 && chunkList[chunkList.length - 1].size < MIN_CHUNK) {
    const lastTwo = new Blob([
      chunkList[chunkList.length - 2],
      chunkList[chunkList.length - 1]
    ])
    chunkList[chunkList.length - 2] = new File([lastTwo], file.name)
    chunkList.pop()
  }
  return chunkList
}

3.2 分片上传主流程

javascript 复制代码
async function uploadChunks(file, initResult, options) {
  const { fid, uploadId, chunkSize } = initResult
  const chunkList = createChunkList(file, chunkSize)
  const chunkTagList = []
  const total = chunkList.length

  for (let i = 0; i < chunkList.length; i++) {
    if (options.abortRequested) {
      await callAbort(fid, uploadId)
      options.onError('已取消上传')
      return
    }
    const chunkTag = await uploadOneChunk({
      file: chunkList[i],
      fid,
      uploadId,
      chunkIndex: i,
      onProgress: options.onProgress,
      partPercent: ((i + 1) / total) * 100
    })
    if (!chunkTag) {
      options.onError('分片上传失败')
      return
    }
    chunkTagList.push(chunkTag)
  }

  await mergeChunks(fid, uploadId, chunkTagList, options)
}

3.3 单块上传(含进度)

javascript 复制代码
import axios from 'axios'

function uploadOneChunk({ file, fid, uploadId, chunkIndex, onProgress, partPercent }) {
  const formData = new FormData()
  formData.append('fid', fid)
  formData.append('uploadId', uploadId)
  formData.append('chunkIndex', chunkIndex)
  formData.append('file', file)

  return axios.post(`${UPLOAD_BASE}/chunk/upload/${fid}`, formData, {
    onUploadProgress: (e) => {
      if (e.lengthComputable) {
        const percent = (e.loaded / e.total) * partPercent
        onProgress({ percent })
      }
    }
  }).then(res => {
    const data = res.data
    if (data.code === 200 && data.result?.chunkTag) {
      return JSON.parse(data.result.chunkTag)
    }
    return null
  }).catch(() => null)
}

3.4 合并

javascript 复制代码
import axios from 'axios'

function mergeChunks(fid, uploadId, chunkTagList, options) {
  const formData = new FormData()
  formData.append('fid', fid)
  formData.append('uploadId', uploadId)
  formData.append('chunkTagList', JSON.stringify(chunkTagList))

  axios.post(`${UPLOAD_BASE}/chunk/merge/${fid}`, formData).then(res => {
    const data = res.data
    if (data.code === 200) options.onSuccess(data)
    else options.onError(data.msg || '合并失败')
  }).catch(() => options.onError('合并失败'))
}

3.5 进度计算

普通上传:percent = (loaded / total) * 100

分片上传:按块权重累加

javascript 复制代码
// 当前块进度 × 该块占总进度的权重
const partPercent = ((currentChunkIndex + 1) / totalChunks) * 100
const overallPercent = (chunkLoaded / chunkTotal) * partPercent

四、实现要点

说明
FormData axios 传 FormData 时无需手动设置 Content-Type,由 axios 自动添加 boundary
chunkTag 响应为 JSON 字符串,需 JSON.parse 后按顺序 push 到 chunkTagList
并发 建议串行或限制并发数,避免服务端压力过大
取消 用户取消或异常时调用 abort,释放服务端资源
鉴权 按项目约定在 FormData 或请求头中附带 token 等鉴权信息

五、检查清单

  • 初始化用 GET,chunkCount ≤ 1 时走普通上传
  • chunkSize 在 5M~70M,每片 ≥ 5M,最后小块已合并
  • 切片顺序与 chunkIndex 一致,chunkTagList 按 partNumber 顺序
  • 分片上传控制并发,取消/异常时调用 abort

六、视频播放:进度条拖动与 Range 请求排查

分片上传后,视频通常通过 CDN 或文件服务直链播放。若 <video> 标签的进度条无法拖动、或拖动后跳转失败,多半与 HTTP Range 请求 有关。

6.1 原理简述

拖动进度条本质是「跳转播放位置」。浏览器会发起带 Range 头的请求,按需拉取视频片段:

ini 复制代码
GET /video.mp4
Range: bytes=1048576-2097151

服务端需返回 206 Partial Content 及对应字节范围,否则无法按需跳转。

6.2 常见问题与排查

现象 可能原因 排查方法
进度条拖动无效 服务端未支持 Range 在 Network 面板查看请求是否有 Range 头,响应是否为 206
只能从头播放 未返回 Accept-Ranges: bytes 检查响应头是否包含 Accept-Ranges: bytes
跨域视频无法 seek CORS 未正确配置 服务端需返回 Access-Control-Allow-OriginAccess-Control-Expose-Headers: Content-Length, Content-Range
部分时段可拖动、部分不可 视频 moov 在文件末尾 MP4 的 moov 元数据在末尾时,需先下载到末尾才能 seek。用 ffmpeg -movflags faststart 将 moov 移到文件头
小文件可拖动、大文件不可 大文件未做 Range 支持 确认 CDN / 对象存储 / 反向代理均已开启 Range 支持

6.3 快速排查步骤

  1. 打开开发者工具 → Network,播放视频并拖动进度条。
  2. 观察视频请求:
    • 是否有 Request Headers: Range: bytes=xxx-xxx
    • 响应状态是否为 206 Partial Content
    • 响应头是否包含 Accept-Ranges: bytes
  3. 若为跨域视频,检查响应头是否包含 Access-Control-Allow-Origin 等 CORS 头。
  4. 若服务端不支持 Range,在 Nginx 等配置中可添加:
nginx 复制代码
add_header Accept-Ranges bytes;
  1. 若为 MP4 格式,可用 ffmpeg 优化:
bash 复制代码
ffmpeg -i input.mp4 -movflags faststart output.mp4

6.4 前端 preload 影响

<video preload="metadata"> 只加载元数据,不预加载内容。在未缓冲到的区域拖动时,必须依赖服务端 Range 支持 才能跳转。若改为 preload="auto" 可预加载更多,可 seek 范围更大,但会增加流量消耗。

相关推荐
DFT计算杂谈4 分钟前
AMSET 设置多核并行计算
java·前端·css·html·css3
花椒技术8 分钟前
AI 协同开发落地复盘:1 小时生成首版后,为什么 Review 和修正又花了 2-3 天
前端·人工智能·架构
万少42 分钟前
万少用9个AI工具,帮朋友完成了一个"不可能"的项目
前端
小小小小宇44 分钟前
Vue `import` 为什么可以异步加载
前端
WMYeah1 小时前
【无标题】
前端·rust·抽奖程序·跨平台抽奖程序
Unbelievabletobe1 小时前
免费外汇api的响应时间在不同时段下的波动分析
大数据·开发语言·前端·python
大哥,带带弟弟1 小时前
Grafana 前端嵌入与 JWT 鉴权实战
前端·grafana
小小小小宇1 小时前
前端 V8 引擎垃圾回收机制与内存问题排查
前端
前端老石人1 小时前
CSS 值定义语法
前端·css
sheeta19981 小时前
Vue 前端基础笔记
前端·vue.js·笔记