1. 先把问题说透:分段上传后,真正的“总进度”是谁算出来的
1.1 单请求上传时代,进度条是浏览器白送的
很多人最早写上传进度条,都是基于单个文件直接 POST。前端用一个 XMLHttpRequest,后端 PHP 用 $_FILES['file'] 接住,这个时候前端拿进度几乎不用动脑子:浏览器在发出请求时会自动带上 Content-Length,整个请求体就是一个文件的大小,xhr.upload.onprogress 里拿到的 event.loaded / event.total 就是整个文件的上传百分比。
所以单文件上传的难点根本不在进度条,而在后端超时、内存占用、Nginx 请求体大小限制这些事上。一个 2GB 视频,要么传一半连接断掉,要么服务端 post_max_size 默认只有 8M 直接拒绝。为了绕开这些限制,大家才决定做分段上传。
分段上传的思路很简单:把视频在浏览器端用 File.prototype.slice() 切成若干块,每一块单独发一个 HTTP 请求到 PHP,服务端先把所有分片存到临时目录,最后再合并成一个完整文件。思路一变,问题就来了:浏览器每次只上报“当前分片”的进度,不再是整个视频的进度。如果直接用 event.loaded / event.total,你看到的现象就是进度条从 0% 到 100% 反复横跳,永远不代表真实进度。
1.2 分片之后,进度数据被“局部化”了
假设一个 1GB 视频,切成 5MB 一份,总共 205 份。前端每发一个分片,HTTP 请求体只有 5MB 左右,浏览器给你的是这一份 5MB 的 loaded/total。如果不做累计,第一个分片上传时进度条从 0% 冲到 100%,第二个分片开始又回到 0%,再一次冲到 100%。用户看到这种进度条,第一反应就是“是不是卡了?是不是重新传了?”体验非常差。
正确做法是:前端自己维护“已经上传成功的字节数”和“当前分片内已上传的字节数”,把它们加起来再除整个视频大小。这是所有分片上传进度方案的底层公式。后端的职责反而是接收分片、合并分片,它的响应不能直接替代前端的进度计算。
我做过不少 PHP 上传功能,一个容易混淆的点是:不要用 PHP 脚本执行时间来推断上传进度。在传统 PHP 模型下,浏览器发出一个分片请求后,请求体在服务端被完整接收,PHP 脚本才开始执行 $_FILES 相关逻辑。也就是说,xhr.upload.onprogress 是网络传输过程的反馈,而 PHP 的执行往往发生在“一段数据已经到达服务器之后”。因此进度条主要靠前端算,后端只是在分片完成、合并完成这些节点上返回状态。
1.3 “上传完成的 100%”和“视频能播放的 100%”不是一回事
视频文件还有另一个特殊性:上传到服务器后不一定立刻能播放,尤其当你后面还要做 ffmpeg 转码、封面截取、HLS 切片,或者存到对象存储再回源。很多产品里的“上传进度条”其实只覆盖了“分片全部到达服务器并合并成功”这一段。
如果你的业务只是把上传接口接到本地磁盘,那么合并完成后的 url 返回就可以让前端显示 100%。如果后面还有转码任务,那么 100% 之后应该切换成“转码中”“处理中”这类状态,而不能继续用上传百分比糊弄用户。很多人做出来的进度条卡在 99%,往往不是前端问题,而是它把后面的合并、转码也强行塞进了同一个百分比里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端接口不配合,前端算出来的百分比一定不靠谱
2.1 初始化接口:给前端一个上传任务 ID
分段上传进度计算只是前端的问题,但前提是后端有清晰的任务模型。最简单也最稳定的设计是三步:先初始化,再逐个传分片,最后合并。
初始化接口的作用是让服务端为这次上传生成一个 upload_id,并创建一个临时目录。前端后面发的所有分片请求都带上这个 upload_id,服务端就知道这些分片属于同一个文件。这个 upload_id 同时还是进度条状态管理的唯一标识。
php复制<?php
// api/upload.php?action=init
$action = $_GET['action'] ?? '';
switch ($action) {
case 'init':
$uploadId = md5(uniqid('', true));
$tmpDir = __DIR__ . '/runtime/uploads/' . $uploadId;
mkdir($tmpDir, 0777, true);
echo json_encode([
'code' => 0,
'upload_id' => $uploadId,
'chunk_size' => 5 * 1024 * 1024, // 5MB
]);
break;
}
有人可能会问,初始化接口和进度条有什么关系?其实关系很大。如果不初始化,前端就要在前端生成一个全局唯一字符串当任务标识,服务端还得根据这个字符串去猜目录是否存在;一旦上传到一半服务端重启,双方状态就无法对齐。有了初始化接口,服务端还可以顺手返回“这个任务已经收到哪些分片”,也就是断点续传需要的信息,前端可以跳过已经存在的分片,进度自然也就不会倒退。
2.2 分片接收接口:按编号落盘,而不是乱序覆盖
分片接收接口是核心。前端每个分片发过来时,除了 file 本身,还需要带上 upload_id 和 chunk_index。服务端要做的事很简单:把分片写入临时目录,文件名用分片序号格式化。
这里有一个极其容易踩的坑:不要把分片文件名直接拼用户提交的原始文件名或路径,否则轻则文件结构错乱,重则有路径穿越风险。最安全的做法是像下面这样,只允许 index 作为整数拼进文件名。
php复制<?php
case 'chunk':
$uploadId = $_POST['upload_id'] ?? '';
$index = (int)($_POST['chunk_index'] ?? -1);
if ($uploadId === '' || $index < 0 || empty($_FILES['file'])) {
http_response_code(400);
echo json_encode(['code' => 400, 'message' => '参数错误']);
break;
}
if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['code' => 400, 'message' => '分片上传失败']);
break;
}
$tmpDir = __DIR__ . '/runtime/uploads/' . $uploadId;
if (!is_dir($tmpDir)) {
echo json_encode(['code' => 404, 'message' => '上传任务不存在或已过期']);
break;
}
$partFile = sprintf('%s/%05d.part', $tmpDir, $index);
move_uploaded_file($_FILES['file']['tmp_name'], $partFile);
echo json_encode(['code' => 0, 'index' => $index]);
break;
上面用 sprintf('%05d', $index) 格式化文件名,是为了合并的时候能按字典序排序。比如第 2 个分片和第 12 个分片,如果直接用原始 2.part 和 12.part,用 sort() 排序会得到 12 在前面,合并出来的视频就是花屏或者损坏。补零到固定长度后,合并时按文件名排序即可。
2.3 合并接口:全部上传成功后把散片拼回完整文件
合并接口的逻辑很直接:根据 upload_id 找到临时目录,按索引顺序依次打开所有 .part 文件,用流式方式写入最终文件。不要用 file_get_contents() 把每个分片读进内存再拼接,2GB 视频会直接把 PHP 内存打爆。正确做法是用 fopen() 配合 stream_copy_to_stream()。
php复制<?php
case 'merge':
$uploadId = $_POST['upload_id'] ?? '';
$totalChunks = (int)($_POST['total_chunks'] ?? 0);
if ($uploadId === '' || $totalChunks <= 0) {
http_response_code(400);
echo json_encode(['code' => 400, 'message' => '参数错误']);
break;
}
$tmpDir = __DIR__ . '/runtime/uploads/' . $uploadId;
if (!is_dir($tmpDir)) {
echo json_encode(['code' => 404, 'message' => '上传任务不存在或已过期']);
break;
}
// 先判断分片数量是否足够
$partCount = count(glob($tmpDir . '/*.part'));
if ($partCount < $totalChunks) {
echo json_encode(['code' => 400, 'message' => '分片数量不足,无法合并']);
break;
}
$finalFile = __DIR__ . '/videos/' . $uploadId . '.mp4';
$out = fopen($finalFile, 'wb');
for ($i = 0; $i < $totalChunks; $i++) {
$partFile = sprintf('%s/%05d.part', $tmpDir, $i);
if (!file_exists($partFile)) {
fclose($out);
@unlink($finalFile);
echo json_encode(['code' => 400, 'message' => '缺少分片:' . $i]);
break 2;
}
$in = fopen($partFile, 'rb');
stream_copy_to_stream($in, $out);
fclose($in);
}
fclose($out);
echo json_encode([
'code' => 0,
'url' => '/videos/' . $uploadId . '.mp4'
]);
break;
后端接口给前端返回的应该是“该分片是否成功”“合并是否成功”这类确定状态,而不是实时每秒上传了多少字节。真正的毫秒级进度反馈,由前端 xhr.upload.onprogress 完成。
3. 前端进度折算的正确姿势:XHR、axios 和整体百分比
3.1 原生 XMLHttpRequest:事件本身就是进度来源
先看最基础的一段原生实现。假设你已经通过 /api/upload/init 拿到了 upload_id,并且把整个视频按 5MB 一份切好。
js复制function sendChunk(uploadId, index, chunk, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append('upload_id', uploadId);
form.append('chunk_index', index);
form.append('file', chunk, 'video.mp4');
xhr.open('POST', '/api/upload/chunk');
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
onProgress(event.loaded);
};
xhr.onload = () => {
try {
const json = JSON.parse(xhr.responseText);
json.code === 0 ? resolve(json) : reject(new Error(json.message));
} catch (e) {
reject(e);
}
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.send(form);
});
}
这里最容易犯的错误是:直接把 event.loaded / event.total 当成整个视频进度。event.total 是当前分片请求体的大小,不是视频总大小。所以在调用 onProgress 时,绝不能把它当最终比例。
正确的主循环应该是这样的:
js复制async function uploadWithProgress(file) {
const chunkSize = 5 * 1024 * 1024;
const chunks = [];
for (let start = 0; start < file.size; start += chunkSize) {
chunks.push(file.slice(start, start + chunkSize));
}
const initResp = await fetch('/api/upload/init', { method: 'POST' });
const { upload_id } = await initResp.json();
let uploadedBefore = 0; // 已经成功上传并确认的字节数
for (let i = 0; i < chunks.length; i++) {
await sendChunk(upload_id, i, chunks[i], (loaded) => {
const totalLoaded = uploadedBefore + loaded;
const percent = Math.min(100, (totalLoaded / file.size) * 100);
// 更新页面进度条
document.getElementById('progressBar').style.width = percent.toFixed(2) + '%';
document.getElementById('progressText').textContent =
`已上传 ${formatSize(totalLoaded)} / ${formatSize(file.size)}`;
});
uploadedBefore += chunks[i].size; // 等响应用成功后,再把它计入已确认字节
}
// 所有分片传完,通知后端合并
const mergeForm = new FormData();
mergeForm.append('upload_id', upload_id);
mergeForm.append('total_chunks', chunks.length);
await fetch('/api/upload/merge', { method: 'POST', body: mergeForm });
document.getElementById('progressText').textContent = '上传完成';
}
uploadedBefore 是关键变量。它表示“已经被服务端确认成功的分片字节数”。只有请求成功返回后,才把当前分片的大小加进去;请求还没结束时,当前分片内靠 event.loaded 反馈。用这种方式算整体进度,误差不会超过一个分片的大小。视频传完后页面显示 99.9% 而不是 100% 是很正常的,等合并请求返回后显示“上传完成”即可。
3.2 axios 场景下的 onUploadProgress 怎么接
如果你项目里用的是 axios,也不要期待它能自动算出整个文件的比例。axios 只是把底层 XMLHttpRequest 的 upload.onprogress 封装成了 onUploadProgress 回调,返回的 event.loaded 和 event.total 仍然只是当前请求的局部数据。
axios 写法要稍微注意一件事:onUploadProgress 事件回调的 event.total 在部分浏览器或代理环境下可能是 0。因此判断里要多写一个 event.total > 0,避免出现除零。
js复制async function uploadChunkWithAxios(uploadId, index, chunk, uploadedBefore, fileSize) {
const form = new FormData();
form.append('upload_id', uploadId);
form.append('chunk_index', index);
form.append('file', chunk, 'video.mp4');
await axios.post('/api/upload/chunk', form, {
timeout: 30000,
onUploadProgress: (event) => {
if (!event.total || !fileSize) return;
const currentChunkLoaded = event.loaded;
const totalLoaded = uploadedBefore + currentChunkLoaded;
const percent = Math.min(100, (totalLoaded / fileSize) * 100);
updateProgress(percent, totalLoaded, fileSize);
}
});
}
其实无论用原生 XHR、jQuery 还是 axios,背后的核心逻辑完全一致:需要一个外层变量累计已经确认的分片字节,然后加上当前分片正在上传的字节,最后除以文件总大小。
这一段还要注意性能。xhr.upload.onprogress 的触发频率很高,通常几十毫秒就会触发一次。如果你在回调里做大量 DOM 操作或者直接渲染 canvas,低端手机可能卡顿。生产环境建议做一个简单的节流,比如每 100ms 最多更新一次进度条。
3.3 多分片并发时,按“累计字节数”统一算总进度
串行上传实现简单,但大视频几百个分片,每个分片都等上一个返回,整体速度可能很慢。为了充分利用带宽,很多人会改成 3 到 5 个分片并发上传。并发场景下的进度计算和串行略有区别:不能再依赖 uploadedBefore 这个单一变量,因为同一时间可能有多个分片都在上传中。
一个更稳妥的方式是维护一个数组,completed[index] 表示第 index 个分片已经成功上传或者正在上传的字节数。每个分片触发进度回调时,只更新它自己的值,然后重新求和。
js复制async function uploadConcurrent(file) {
const chunkSize = 5 * 1024 * 1024;
const chunks = [];
for (let start = 0; start < file.size; start += chunkSize) {
chunks.push(file.slice(start, start + chunkSize));
}
const completed = new Array(chunks.length).fill(0);
const totalSize = file.size;
function updateOverall() {
const loadedBytes = completed.reduce((sum, size) => sum + size, 0);
const percent = Math.min(100, (loadedBytes / totalSize) * 100);
updateProgress(percent, loadedBytes, totalSize);
}
async function uploadOne(index) {
return sendChunk(upload_id, index, chunks[index], (loaded) => {
completed[index] = loaded;
updateOverall();
}).then(() => {
completed[index] = chunks[index].size;
updateOverall();
});
}
// 控制并发数为 3
const pool = [];
for (let i = 0; i < chunks.length; i++) {
pool.push(uploadOne(i));
if (pool.length >= 3) {
await Promise.race(pool);
}
}
await Promise.all(pool);
}
进度条在这种模式下会显得“比较跳”,原因是某几个分片可能同一时间完成,字节数会突然涨一大截。这个现象是正常的,用户通常能接受。真正需要注意的反而是后端能不能并发写入不同分片而不互相影响。只要每个分片写的是独立的 .part 文件,就没有问题;如果一个接口把所有分片数据追加到同一个文件里,并发一开基本就废了。
4. 并发分片、断点续传、合并阶段的进度条处理原则
4.1 串行还是并发?先看你的使用场景
不同并发策略下,进度条的表现和服务端压力都不一样。我这里直接给一个判断表,你可以在方案设计时对照:
| 策略 | 进度条平滑度 | 上传速度 | 服务端压力 | 前端复杂度 | 适合场景 |
|---|---|---|---|---|---|
| 串行 | 平滑,不会跳变 |
