上传文件时,某一段失败,如果每次都从头上传,文件越大,重来的成本越高。分片上传把文件分成小块,每块可以独立发送和重试,最后再合并。
分片不只是前端 slice 一下。服务端还要知道这些块属于哪个文件、顺序是什么、有没有缺块,以及最后拼出来的内容是否和原文件一致。
服务端保存分片并校验完整性
下面做一个本机小例子,文件上限 8MB。分片存在内存中,重启就丢失,不用它承担正式文件存储。
创建 upload.mjs:
import { randomBytes, createHash } from "node:crypto";
export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
export function createUploads() {
const uploads = new Map();
return {
begin({ size, total, hash }) {
if (
!Number.isInteger(size) ||
size < 1 ||
size > 8 * 1024 * 1024 ||
!Number.isInteger(total) ||
total < 1 ||
total > 128 ||
!/^[a-f0-9]{64}$/.test(hash)
)
throw new Error("invalid metadata");
const id = randomBytes(16).toString("hex");
uploads.set(id, { size, total, hash, chunks: new Map() });
return id;
},
put(id, index, bytes) {
const item = uploads.get(id);
if (
!item ||
!Number.isInteger(index) ||
index < 0 ||
index >= item.total ||
bytes.length > 2 * 1024 * 1024
)
throw new Error("invalid chunk");
const previous = item.chunks.get(index);
if (previous && !previous.equals(bytes)) throw new Error("conflicting retry");
if (
!previous &&
[...item.chunks.values()].reduce((sum, b) => sum + b.length, bytes.length) >
item.size
)
throw new Error("size exceeded");
item.chunks.set(index, bytes);
},
status(id) {
const item = uploads.get(id);
if (!item) throw new Error("unknown upload");
return [...item.chunks.keys()].sort((a, b) => a - b);
},
finish(id) {
const item = uploads.get(id);
if (!item || item.chunks.size !== item.total) throw new Error("missing chunks");
const result = Buffer.concat(
Array.from({ length: item.total }, (_, i) => item.chunks.get(i)),
);
if (result.length !== item.size || sha256(result) !== item.hash)
throw new Error("integrity mismatch");
return result;
},
};
}
初始化时记录文件大小、分片数量和完整文件的 SHA-256。相同编号重复上传同样的内容允许成功,重复上传不同内容则拒绝,避免重试悄悄覆盖已经收下的块。
finish 先检查块数,再按编号合并,检查总长度和完整文件哈希。块数够了不代表文件就对了,顺序错误或者内容被替换都要被发现。
创建 server.mjs,把这些方法接到 HTTP 接口:
import http from "node:http";
import { readFile } from "node:fs/promises";
import { createUploads } from "./upload.mjs";
const uploads = createUploads();
const files = new Map([
["/", ["index.html", "text/html"]],
["/dist/main.js", ["dist/main.js", "text/javascript"]],
]);
const json = (res, status, value) => {
res.writeHead(status, {
"Content-Type": "application/json",
"Cache-Control": "no-store",
});
res.end(JSON.stringify(value));
};
async function body(req) {
const parts = [];
let length = 0;
for await (const part of req) {
length += part.length;
if (length > 2 * 1024 * 1024) throw new Error("body too large");
parts.push(part);
}
return Buffer.concat(parts);
}
http
.createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
try {
if (url.pathname === "/api/upload" && req.method === "POST")
return json(res, 201, { id: uploads.begin(JSON.parse(await body(req))) });
const match = url.pathname.match(
/^\/api\/upload\/([a-f0-9]+)(?:\/(\d+|finish))?$/,
);
if (match) {
const [, id, action] = match;
if (action === "finish" && req.method === "POST")
return json(res, 200, { size: uploads.finish(id).length });
if (action && req.method === "PUT") {
uploads.put(id, Number(action), await body(req));
return json(res, 200, { ok: true });
}
if (!action && req.method === "GET")
return json(res, 200, { received: uploads.status(id) });
}
const file = files.get(url.pathname);
if (file && req.method === "GET") {
res.setHeader("Content-Type", file[1] + "; charset=utf-8");
return res.end(await readFile(new URL(file[0], import.meta.url)));
}
json(res, 404, { error: "not found" });
} catch (error) {
json(res, 400, { error: error.message });
}
})
.listen(3000, "127.0.0.1", () => console.log("http://127.0.0.1:3000"));
请求体有单次大小限制,静态文件也只开放两个明确路径。所有示例文件放在同一个目录里,服务通过模块位置读取文件,不依赖在哪个目录启动。
React 负责切片、进度和有限重试
创建一个空目录,安装 React 和打包工具。示例不用 JSX,省去 Babel 配置,组件逻辑和 JSX 的写法是一样的。
npm init -y
npm install --save-exact react@18.3.1 react-dom@18.3.1
npm install --save-dev --save-exact webpack@5.99.9
创建 build.cjs:
const webpack = require("webpack");
const path = require("path");
webpack(
{
mode: "development",
entry: path.resolve("main.js"),
output: { path: path.resolve("dist"), filename: "main.js" },
devtool: "source-map",
},
(error, stats) => {
if (error || stats.hasErrors()) {
console.error(error || stats.toString("errors-only"));
process.exitCode = 1;
}
},
);
创建 index.html:
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8" /><meta name="viewport" content="width=device-width" />
<title>React 实验</title>
<div id="root"></div>
<script src="./dist/main.js"></script>
</html>
把下面的代码存为 main.js,运行 node build.cjs,然后用浏览器打开 index.html。这里用开发构建和根节点 StrictMode,观察次数时要把这两个条件算进去。
import React, {
useState,
useRef,
useEffect,
useMemo,
useContext,
memo,
StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
const e = React.createElement;
window.lab = { effects: [], contexts: { stable: 0, unstable: 0 }, measurements: [] };
function Upload() {
const [status, setStatus] = useState("等待文件");
const controller = useRef(null);
useEffect(() => () => controller.current?.abort(), []);
async function send(file) {
controller.current?.abort();
const current = new AbortController();
controller.current = current;
const options = { signal: current.signal };
try {
const bytes = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", bytes);
const hash = Array.from(new Uint8Array(digest), (v) =>
v.toString(16).padStart(2, "0"),
).join("");
const chunkSize = 64 * 1024;
const total = Math.ceil(file.size / chunkSize);
const begin = await fetch("/api/upload", {
...options,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ size: file.size, total, hash }),
});
if (!begin.ok) throw new Error("初始化失败");
const { id } = await begin.json();
for (let index = 0; index < total; index++) {
let success = false;
for (let attempt = 0; attempt < 3; attempt++) {
const response = await fetch("/api/upload/" + id + "/" + index, {
...options,
method: "PUT",
body: file.slice(index * chunkSize, (index + 1) * chunkSize),
});
if (response.ok) {
success = true;
break;
}
if (response.status < 500) throw new Error("分片不合法");
}
if (!success) throw new Error("重试失败");
if (controller.current === current)
setStatus("已发送 " + (index + 1) + "/" + total);
}
const end = await fetch("/api/upload/" + id + "/finish", {
...options,
method: "POST",
});
if (!end.ok) throw new Error("合并失败");
const merged = await end.json();
if (controller.current === current) setStatus("完成 " + merged.size + " 字节");
} catch (error) {
if (controller.current === current)
setStatus(current.signal.aborted ? "取消" : error.message);
}
}
return e(
"section",
null,
e("h2", null, "分片上传(本机内存存储)"),
e("input", {
id: "upload",
type: "file",
onChange: (event) => event.target.files[0] && send(event.target.files[0]),
}),
e("button", { onClick: () => controller.current?.abort() }, "取消上传"),
e("p", { id: "upload-status" }, status),
);
}
createRoot(document.querySelector("#root")).render(e(StrictMode, null, e(Upload)));
这次不要直接打开 index.html,运行 node server.mjs,访问 http://127.0.0.1:3000。前端和接口同源,fetch 使用相对地址即可。
选文件后计算完整哈希,每块 64KB,按顺序上传。某块收到 5xx 时最多尝试三次;4xx 直接失败。当前代码没有自动重试网络断连,fetch 抛错会进入失败提示,不能把它说成任意网络错误都能恢复。
换文件会取消上一轮请求。写状态时还检查 controller 是否仍属于当前任务,避免旧任务的提示覆盖新任务。取消请求不会删除服务端已经收到的分片。
怎么判断真的传对了
正常文件上传后显示完成和字节数。可以在浏览器请求拦截工具里让第一块首次返回 503,下一次放行,确认同一编号重试后仍能完成;持续返回 503,应在第三次之后停止。
服务端还可以直接调用 begin、put、finish 检查乱序上传、缺块和错误哈希。内容正确时顺序上传和乱序上传应得到相同字节,缺块和哈希不一致都应拒绝。
这个实现没有持久化、上传配额和过期清理,也没有刷新页面后的续传。虽然接口能查询已收编号,完整续传仍需要保存任务 id、验证任务归属和文件一致性。全文件 arrayBuffer 也会占内存,所以不能把 8MB 的做法直接扩大到几个 GB。