一、背景与流程
当文件体积较大(如视频)时,直接上传容易超时、失败,且无法展示进度。分片上传将文件切成多块依次上传,再在服务端合并,可提升稳定性和体验。
整体流程:
scss
初始化(init) → 判断是否分片 → 切片 → 逐块上传(upload) → 合并(merge)
↑
异常/取消时调用 abort
二、核心步骤
2.1 初始化
向服务端发起初始化请求,获取本次上传的 uploadId、fid 及建议的 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-Origin、Access-Control-Expose-Headers: Content-Length, Content-Range |
| 部分时段可拖动、部分不可 | 视频 moov 在文件末尾 | MP4 的 moov 元数据在末尾时,需先下载到末尾才能 seek。用 ffmpeg -movflags faststart 将 moov 移到文件头 |
| 小文件可拖动、大文件不可 | 大文件未做 Range 支持 | 确认 CDN / 对象存储 / 反向代理均已开启 Range 支持 |
6.3 快速排查步骤
- 打开开发者工具 → Network,播放视频并拖动进度条。
- 观察视频请求:
- 是否有
Request Headers: Range: bytes=xxx-xxx - 响应状态是否为
206 Partial Content - 响应头是否包含
Accept-Ranges: bytes
- 是否有
- 若为跨域视频,检查响应头是否包含
Access-Control-Allow-Origin等 CORS 头。 - 若服务端不支持 Range,在 Nginx 等配置中可添加:
nginx
add_header Accept-Ranges bytes;
- 若为 MP4 格式,可用 ffmpeg 优化:
bash
ffmpeg -i input.mp4 -movflags faststart output.mp4
6.4 前端 preload 影响
<video preload="metadata"> 只加载元数据,不预加载内容。在未缓冲到的区域拖动时,必须依赖服务端 Range 支持 才能跳转。若改为 preload="auto" 可预加载更多,可 seek 范围更大,但会增加流量消耗。