外观
大文件上传系统设计与框架落地方案
适合人群:需要在 Vue2、Vue3、React、Nuxt、小程序、Node.js、NestJS、Express 项目中实现大文件上传、暂停、继续、断点重传、秒传、失败重试、并发控制和上传进度的开发者。
学习目标:不仅知道“分片上传”这个概念,还能设计前后端协议,并把方案落地到具体框架。
核对时间:2026-06-30。本文参考 MDN File API / Blob.slice / AbortController / FormData / Web Worker、NestJS File Upload、Express、Multer、微信小程序 uploadFile、uni-app/Taro uploadFile、Amazon S3 Multipart Upload、tus resumable upload protocol 等官方资料整理。
目录
- 大文件上传要解决什么问题
- 三种主流上传方案
- 核心概念:分片、暂停、断点、秒传
- 推荐接口协议设计
- 前端通用实现
- Vue3 项目实现
- Vue2 项目实现
- React 项目实现
- Nuxt 项目实现
- 小程序项目实现
- Express 服务端实现
- NestJS 服务端实现
- 对象存储 Multipart Upload 方案
- 暂停、继续、断点重传的真实处理逻辑
- 安全、可靠性与生产注意事项
- 测试清单
- 官方参考资料
1. 大文件上传要解决什么问题
普通上传通常是:
text
选择文件 -> FormData -> POST /upload -> 服务端保存这对 5MB、20MB 的文件可以接受,但对 500MB、2GB、10GB 的文件会遇到问题:
- 网络中断后要重传整个文件。
- 上传耗时长,用户想暂停。
- 浏览器刷新后进度丢失。
- 服务端一次性接收压力大。
- 代理层可能有 body size 限制。
- 移动端和小程序弱网失败率高。
- 多文件并发时容易卡死。
- 用户不知道上传到哪里了。
大文件上传的目标是:
- 可暂停。
- 可继续。
- 可断点重传。
- 可失败重试。
- 可显示准确进度。
- 可并发但不压垮网络。
- 服务端可校验完整性。
- 上传完成后能合并或提交对象存储。
2. 三种主流上传方案
2.1 普通表单上传
适合:
- 小文件。
- 头像。
- 图片。
- Excel。
- 低频后台上传。
协议:
http
POST /upload
Content-Type: multipart/form-data优点:
- 简单。
- 框架支持好。
缺点:
- 失败后通常要重传。
- 暂停和断点难做。
- 大文件容易超时。
2.2 应用服务器分片上传
适合:
- 中大文件。
- 需要业务权限校验。
- 文件最终存本地、内网存储或对象存储。
- 想完全控制上传协议。
流程:
text
前端切片 -> init -> 上传 chunk -> 查询状态 -> complete 合并优点:
- 暂停、续传、重试可控。
- 业务逻辑灵活。
- 容易和权限、审计、配额结合。
缺点:
- 应用服务器承受上传流量。
- 合并文件需要 IO。
- 多实例部署需要共享临时存储或对象存储。
2.3 对象存储 Multipart Upload
适合:
- 超大文件。
- 高并发。
- 生产级视频、安装包、素材上传。
- 希望绕过应用服务器带宽。
流程:
text
前端请求服务端创建 multipart upload
服务端返回 uploadId 和预签名 URL
前端直传每个 part 到 S3/OSS/COS/MinIO
前端把 ETag 列表交给服务端
服务端 complete multipart upload优点:
- 应用服务器不承受文件流量。
- 对象存储原生支持分片、合并、失败重试。
- 更适合生产高并发。
缺点:
- 接入复杂。
- 需要处理签名、跨域、part ETag。
- 和具体云厂商 API 相关。
3. 核心概念:分片、暂停、断点、秒传
3.1 分片
分片就是把一个大文件切成多个小块:
text
file.mp4 1GB
chunk 0: 0MB - 5MB
chunk 1: 5MB - 10MB
chunk 2: 10MB - 15MB
...浏览器里用 Blob.slice():
ts
const chunk = file.slice(start, end);3.2 暂停
前端没有真正“冻结 HTTP 上传”的通用能力。
实际暂停一般是:
- 中断当前正在上传的请求。
- 停止调度新的 chunk。
- 保留已成功上传的 chunk 记录。
- 用户点击继续时,从未上传 chunk 继续。
浏览器中断请求用 AbortController。
小程序中断上传用 UploadTask.abort() 或 RequestTask.abort()。
3.3 断点重传
断点重传不是前端单方面能实现的。
必须依赖服务端记录:
- uploadId。
- fileHash。
- totalChunks。
- 已上传 chunk index。
- 每个 chunk 的大小或 hash。
- 上传会话状态。
继续上传时:
text
前端重新选择同一个文件
计算 hash 或 fingerprint
调用 /uploads/init 或 /uploads/status
服务端返回已上传 chunk
前端只上传缺失 chunk3.4 秒传
秒传指服务端已经存在完整文件,前端不用再上传。
前提:
- 前端能计算完整文件 hash。
- 服务端根据 hash 找到已存在文件。
流程:
text
前端计算 fileHash
POST /uploads/init
服务端发现 fileHash 已存在
返回 shouldUpload: false
前端直接显示上传完成注意:
- 完整 hash 计算大文件会耗时。
- 可以用 Web Worker 避免阻塞 UI。
- 只用文件名和大小不能作为安全秒传依据,只能做快速 fingerprint。
3.5 内部机制:秒传与断点续传的完整性校验设计
秒传和断点续传都是“服务端用某种指纹代表文件内容”,一旦指纹判断错误,后果是用户 A 拿到了用户 B 的文件内容或残缺文件。完整性必须分层设计:
text
第一层 快速 fingerprint(秒传判定入口)
name + size + lastModified 组合,或抽样 hash
作用:快速返回,命中后仍要能兜底复核
第二层 内容 hash(完整性依据)
MD5/SHA-256 全文摘要
作用:真正的“同内容=同文件”判定
第三层 分片 hash(传输通道校验)
每个 chunk 单独 hash,服务端落盘前比对
作用:定位坏片,续传时只重传坏片
第四层 合并后复核(最终防线)
complete 时服务端把合并文件重算全文 hash,与 init 声明值比对
不一致 → 删除合并产物、标记 session 失效、返回明确错误码关键决策点:
| 问题 | 推荐做法 | 理由 |
|---|---|---|
| 用什么 hash 算法? | 秒传去重场景 MD5 通常够用且快(Web Crypto crypto.subtle.digest("MD5", ...) 不支持,需用 wasm 版 hash-wasm);安全敏感(防篡改、签名)必须 SHA-256 | MD5 碰撞可被人为构造,绝不能用于“证明文件来源可信”;纯去重则两可 |
| 大文件算不动全文 hash? | 抽样 fingerprint:头部 N 字节 + 中间 M 字节 + 尾部 K 字节 + size 做 hash(sparkline 式) | 抽样只能当“疑似相同”,命中秒传后异步回源复核或要求前端补传全文 hash 校验;不能当最终依据 |
| hash 与文件元信息绑不绑? | 秒传命中返回 fileUrl 时,同时返回 size + hash 让前端自检;私有存储加访问鉴权 | 防止“猜 hash 白嫖他人文件”:纯内容寻址会让知道 hash 的人都能拿到文件,必须叠加“上传者/租户可见”的 ACL |
| chunk 边界变了指纹就变? | 上传协议里把“chunkSize 参与会话标识”写清楚:resume 时 chunkSize 不一致就拒绝复用旧 session | 同一文件按 4MB/5MB 切片,服务端已存 chunk 不可混用 |
| 断网瞬间“服务端已存、客户端以为失败” | chunk 上传响应要幂等可重试 + status 接口以服务端记录为准 | 客户端的 uploadedChunks 只是缓存,真相在服务端 |
⚠️ 最常见的错误设计:init 用 name-size-lastModified 当 fileHash 直接查“已完成文件表”做秒传。同名同大小同修改时间的两个不同内容文件会被错误秒传,且无法在 complete 阶段发现(根本没有真 hash 可比)。fingerprint 只能定位 session,不能证明内容。
💡 增量式 hash 的工程折中:把“完整 fileHash”从 init 前置改为随最后一个 chunk 提交(每个分片带自己的 hash,最后一片带“分片 hash 列表的聚合 hash”),服务端合并时顺手重算校验。前端免去了阻塞上传启动的全文件预扫描,代价是秒传判定推迟——大文件产品通常“首片先行 + 后台补算全文 hash 再决定要不要中断已传流”。
3.6 协议标准化选项:tus
自研协议之外,断点续传有开放标准 tus(v1.0.0),核心只有三个方法:
text
POST /files 创建上传 → 201 + Location
HEAD /files/{id} 查询进度 → Upload-Offset: <已收字节数>
PATCH /files/{id} 续传:Content-Type: application/offset+octet-stream
+ Upload-Offset 必须等于服务端当前 offset,
否则 409 Conflict(客户端应重新 HEAD 再续)- 每个请求/响应都带
Tus-Resumable: 1.0.0协商版本。 - 偏移是字节不是分片号,天然容忍任意切片大小调整。
- 扩展:creation-with-upload(创建时带上首块)、termination(DELETE)、checksum(每块带校验和)、concatenation(并行分片拼接)。
- 前端
tus-js-client、Node 端tusd/tus-node-server,小程序支持有限。
与本文自研协议对比:tus 省掉了“设计/评审/测试一套私有协议”的成本,且幂等语义(409/412/415)定义得很清楚;代价是它按字节流建模,和“固定 5MB 分片 + 分片 hash”的对象存储风格或既有业务表结构需要适配层。选型时值得优先评估。
4. 推荐接口协议设计
4.1 创建上传会话
http
POST /uploads/init
Content-Type: application/json请求:
json
{
"fileName": "video.mp4",
"fileSize": 1048576000,
"contentType": "video/mp4",
"fileHash": "sha256-or-md5",
"chunkSize": 5242880,
"totalChunks": 200
}响应:
json
{
"uploadId": "u_123",
"shouldUpload": true,
"uploadedChunks": [0, 1, 2],
"chunkSize": 5242880
}如果秒传:
json
{
"uploadId": "u_123",
"shouldUpload": false,
"fileUrl": "https://cdn.example.com/video.mp4"
}4.2 上传单个 chunk
multipart 方式:
http
POST /uploads/{uploadId}/chunks/{index}
Content-Type: multipart/form-data字段:
text
chunk: Blob/File
chunkHash: stringraw binary 方式:
http
PUT /uploads/{uploadId}/chunks/{index}
Content-Type: application/octet-stream
X-Chunk-Hash: xxxmultipart 更容易兼容 Web 表单和 Multer。
raw binary 更适合小程序 wx.request 上传 ArrayBuffer。
4.3 查询上传状态
http
GET /uploads/{uploadId}/status响应:
json
{
"uploadId": "u_123",
"status": "uploading",
"uploadedChunks": [0, 1, 2, 5],
"totalChunks": 200
}4.4 完成上传
http
POST /uploads/{uploadId}/complete响应:
json
{
"fileUrl": "https://cdn.example.com/video.mp4",
"fileHash": "sha256-or-md5",
"size": 1048576000
}4.5 取消上传
http
DELETE /uploads/{uploadId}服务端处理:
- 标记会话取消。
- 清理临时 chunk。
- 如果是对象存储,调用 abort multipart upload。
5. 前端通用实现
5.1 切片函数
ts
export interface FileChunk {
index: number;
start: number;
end: number;
blob: Blob;
}
export function createFileChunks(file: File, chunkSize = 5 * 1024 * 1024) {
const chunks: FileChunk[] = [];
let start = 0;
let index = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
chunks.push({
index,
start,
end,
blob: file.slice(start, end),
});
start = end;
index += 1;
}
return chunks;
}5.2 计算文件 hash
大文件 hash 应放到 Web Worker 里,避免阻塞主线程。
简化版主线程示例:
ts
export async function calculateSha256(file: File) {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}生产建议:
- 小文件可以直接完整 hash。
- 大文件用 Worker 分片计算。
- 如果秒传不是强需求,可以先用 fingerprint 创建会话,再后台校验完整 hash。
⚠️ 上面 calculateSha256 的示例一次 file.arrayBuffer() 会把整个文件读进内存——5MB 可以,1GB 文件会直接把标签页内存打爆(arrayBuffer 是完整拷贝)。正确做法是流式分片读 + 增量聚合:
ts
// hash-worker.ts:按 chunk 读取,峰值内存只有单个 chunk
const CHUNK = 4 * 1024 * 1024;
async function hashFile(file: File) {
// crypto.subtle.digest 不支持增量,两种选择:
// a) 循环里逐片 digest 后拼接(得到的是"分片摘要的摘要",与整文件摘要定义不同但同样可做指纹,需服务端同算法)
// b) 引入 hash-wasm(支持 createSHA256().update(chunk).digest() 真增量),推荐
const { createSHA256 } = await import("hash-wasm");
const hasher = createSHA256();
hasher.init();
for (let offset = 0; offset < file.size; offset += CHUNK) {
const buf = await file.slice(offset, offset + CHUNK).arrayBuffer();
hasher.update(new Uint8Array(buf));
self.postMessage({ percent: Math.round(((offset + CHUNK) / file.size) * 100) });
}
return hasher.digest("hex");
}
self.onmessage = async ({ data }: { data: File }) => {
const hash = await hashFile(data);
self.postMessage({ hash });
};要点:File.slice() 只建立引用不占内存,arrayBuffer() 才读;循环读+update 让峰值内存固定在一个 chunk;每片之间天然让出线程,UI 不卡;进度消息回主线程渲染“校验中 43%”。跨浏览器一致性注意:hash 必须基于文件字节,不能混入 name/lastModified,否则服务端无法复算比对。
5.3 上传单个 chunk
ts
export async function uploadChunk(options: {
uploadId: string;
index: number;
chunk: Blob;
signal: AbortSignal;
}) {
const formData = new FormData();
formData.append("chunk", options.chunk);
const response = await fetch(
`/uploads/${options.uploadId}/chunks/${options.index}`,
{
method: "POST",
body: formData,
signal: options.signal,
}
);
if (!response.ok) {
throw new Error(`Chunk ${options.index} upload failed`);
}
}5.4 并发上传调度
ts
export async function uploadChunksWithLimit(options: {
uploadId: string;
chunks: FileChunk[];
uploadedIndexes: Set<number>;
concurrency: number;
onProgress: (uploadedCount: number, totalCount: number) => void;
isPaused: () => boolean;
addController: (controller: AbortController) => void;
removeController: (controller: AbortController) => void;
}) {
const pendingChunks = options.chunks.filter(
(chunk) => !options.uploadedIndexes.has(chunk.index)
);
let cursor = 0;
let uploadedCount = options.uploadedIndexes.size;
async function worker() {
while (cursor < pendingChunks.length && !options.isPaused()) {
const chunk = pendingChunks[cursor];
cursor += 1;
const controller = new AbortController();
options.addController(controller);
try {
await uploadChunk({
uploadId: options.uploadId,
index: chunk.index,
chunk: chunk.blob,
signal: controller.signal,
});
options.uploadedIndexes.add(chunk.index);
uploadedCount += 1;
options.onProgress(uploadedCount, options.chunks.length);
} finally {
options.removeController(controller);
}
}
}
await Promise.all(
Array.from({ length: options.concurrency }, () => worker())
);
}5.5 暂停
ts
let paused = false;
const controllers = new Set<AbortController>();
export function pauseUpload() {
paused = true;
for (const controller of controllers) {
controller.abort();
}
controllers.clear();
}暂停后:
- 当前正在上传的 chunk 会失败或被 abort。
- 已成功上传的 chunk 不需要重传。
- 继续时先查服务端状态,再补传缺失 chunk。
5.6 完整上传流程
ts
export async function uploadLargeFile(file: File) {
const chunkSize = 5 * 1024 * 1024;
const chunks = createFileChunks(file, chunkSize);
const fileHash = await calculateSha256(file);
const initResponse = await fetch("/uploads/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileName: file.name,
fileSize: file.size,
contentType: file.type,
fileHash,
chunkSize,
totalChunks: chunks.length,
}),
});
const initResult = await initResponse.json();
if (!initResult.shouldUpload) {
return initResult.fileUrl;
}
const uploadedIndexes = new Set<number>(initResult.uploadedChunks);
await uploadChunksWithLimit({
uploadId: initResult.uploadId,
chunks,
uploadedIndexes,
concurrency: 4,
onProgress(uploaded, total) {
console.log(Math.round((uploaded / total) * 100));
},
isPaused: () => paused,
addController: (controller) => controllers.add(controller),
removeController: (controller) => controllers.delete(controller),
});
if (paused) {
return null;
}
const completeResponse = await fetch(
`/uploads/${initResult.uploadId}/complete`,
{ method: "POST" }
);
return completeResponse.json();
}6. Vue3 项目实现
6.1 composable
src/features/upload/model/useChunkUpload.ts:
ts
import { computed, ref } from "vue";
import { createFileChunks, uploadChunksWithLimit } from "./chunkUpload";
export function useChunkUpload() {
const status = ref<"idle" | "hashing" | "uploading" | "paused" | "done" | "error">("idle");
const progress = ref(0);
const uploadId = ref<string | null>(null);
const controllers = new Set<AbortController>();
const isUploading = computed(() => status.value === "uploading");
async function start(file: File) {
status.value = "hashing";
const chunkSize = 5 * 1024 * 1024;
const chunks = createFileChunks(file, chunkSize);
const fileHash = `${file.name}-${file.size}-${file.lastModified}`;
const initResult = await $fetch<{
uploadId: string;
shouldUpload: boolean;
uploadedChunks: number[];
fileUrl?: string;
}>("/uploads/init", {
method: "POST",
body: {
fileName: file.name,
fileSize: file.size,
contentType: file.type,
fileHash,
chunkSize,
totalChunks: chunks.length,
},
});
if (!initResult.shouldUpload) {
progress.value = 100;
status.value = "done";
return initResult.fileUrl;
}
uploadId.value = initResult.uploadId;
status.value = "uploading";
const uploadedIndexes = new Set<number>(initResult.uploadedChunks);
await uploadChunksWithLimit({
uploadId: initResult.uploadId,
chunks,
uploadedIndexes,
concurrency: 4,
onProgress(uploaded, total) {
progress.value = Math.round((uploaded / total) * 100);
},
isPaused: () => status.value === "paused",
addController: (controller) => controllers.add(controller),
removeController: (controller) => controllers.delete(controller),
});
if (status.value === "paused") {
return null;
}
const result = await $fetch(`/uploads/${initResult.uploadId}/complete`, {
method: "POST",
});
status.value = "done";
progress.value = 100;
return result;
}
function pause() {
status.value = "paused";
controllers.forEach((controller) => controller.abort());
controllers.clear();
}
return {
status,
progress,
isUploading,
start,
pause,
};
}如果不是 Nuxt 项目,把 $fetch 换成普通 fetch 或 axios。
6.2 页面使用
vue
<script setup lang="ts">
import { useChunkUpload } from "@/features/upload/model/useChunkUpload";
const { status, progress, start, pause } = useChunkUpload();
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
start(file);
}
}
</script>
<template>
<input type="file" @change="onFileChange" />
<progress :value="progress" max="100" />
<button v-if="status === 'uploading'" @click="pause">暂停</button>
<span>{{ status }} {{ progress }}%</span>
</template>7. Vue2 项目实现
7.1 组件方式
vue
<template>
<div>
<input type="file" @change="onFileChange" />
<progress :value="progress" max="100"></progress>
<button v-if="status === 'uploading'" @click="pause">暂停</button>
<button v-if="status === 'paused'" @click="resume">继续</button>
</div>
</template>
<script>
import { createFileChunks, uploadChunksWithLimit } from "@/utils/chunkUpload";
export default {
data() {
return {
file: null,
uploadId: null,
status: "idle",
progress: 0,
uploadedChunks: [],
controllers: new Set(),
};
},
methods: {
async onFileChange(event) {
this.file = event.target.files[0];
await this.start();
},
async start() {
const chunkSize = 5 * 1024 * 1024;
const chunks = createFileChunks(this.file, chunkSize);
const fileHash = `${this.file.name}-${this.file.size}-${this.file.lastModified}`;
const initResult = await this.$http.post("/uploads/init", {
fileName: this.file.name,
fileSize: this.file.size,
contentType: this.file.type,
fileHash,
chunkSize,
totalChunks: chunks.length,
});
this.uploadId = initResult.uploadId;
this.status = "uploading";
await uploadChunksWithLimit({
uploadId: initResult.uploadId,
chunks,
uploadedIndexes: new Set(initResult.uploadedChunks),
concurrency: 3,
onProgress: (uploaded, total) => {
this.progress = Math.round((uploaded / total) * 100);
},
isPaused: () => this.status === "paused",
addController: (controller) => this.controllers.add(controller),
removeController: (controller) => this.controllers.delete(controller),
});
if (this.status !== "paused") {
await this.$http.post(`/uploads/${this.uploadId}/complete`);
this.status = "done";
this.progress = 100;
}
},
pause() {
this.status = "paused";
this.controllers.forEach((controller) => controller.abort());
this.controllers.clear();
},
async resume() {
if (!this.file || !this.uploadId) return;
await this.start();
},
},
};
</script>7.2 Vue2 注意事项
- 老项目如果 axios 版本较旧,取消请求可能使用 CancelToken;新项目推荐 AbortController。
- 不要把所有 chunk Blob 放进 Vue 响应式 data,避免内存和性能问题。
- 大文件 hash 放 Worker。
- 上传状态可以存在 Vuex,但 Blob 和 AbortController 不建议放全局 store。
8. React 项目实现
8.1 hook
tsx
import { useRef, useState } from "react";
import { createFileChunks, uploadChunksWithLimit } from "./chunkUpload";
export function useChunkUpload() {
const [status, setStatus] = useState<"idle" | "uploading" | "paused" | "done" | "error">("idle");
const [progress, setProgress] = useState(0);
const fileRef = useRef<File | null>(null);
const uploadIdRef = useRef<string | null>(null);
const controllersRef = useRef(new Set<AbortController>());
const statusRef = useRef(status);
function updateStatus(nextStatus: typeof status) {
statusRef.current = nextStatus;
setStatus(nextStatus);
}
async function start(file: File) {
fileRef.current = file;
updateStatus("uploading");
const chunkSize = 5 * 1024 * 1024;
const chunks = createFileChunks(file, chunkSize);
const fileHash = `${file.name}-${file.size}-${file.lastModified}`;
const initResponse = await fetch("/uploads/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileName: file.name,
fileSize: file.size,
contentType: file.type,
fileHash,
chunkSize,
totalChunks: chunks.length,
}),
});
const initResult = await initResponse.json();
if (!initResult.shouldUpload) {
setProgress(100);
updateStatus("done");
return initResult.fileUrl;
}
uploadIdRef.current = initResult.uploadId;
await uploadChunksWithLimit({
uploadId: initResult.uploadId,
chunks,
uploadedIndexes: new Set(initResult.uploadedChunks),
concurrency: 4,
onProgress(uploaded, total) {
setProgress(Math.round((uploaded / total) * 100));
},
isPaused: () => statusRef.current === "paused",
addController: (controller) => controllersRef.current.add(controller),
removeController: (controller) => controllersRef.current.delete(controller),
});
if (statusRef.current === "paused") return null;
const completeResponse = await fetch(
`/uploads/${initResult.uploadId}/complete`,
{ method: "POST" }
);
updateStatus("done");
setProgress(100);
return completeResponse.json();
}
function pause() {
updateStatus("paused");
controllersRef.current.forEach((controller) => controller.abort());
controllersRef.current.clear();
}
async function resume() {
if (fileRef.current) {
await start(fileRef.current);
}
}
return {
status,
progress,
start,
pause,
resume,
};
}8.2 组件使用
tsx
import { useChunkUpload } from "./useChunkUpload";
export function UploadPanel() {
const { status, progress, start, pause, resume } = useChunkUpload();
return (
<section>
<input
type="file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) start(file);
}}
/>
<progress value={progress} max={100} />
{status === "uploading" && <button onClick={pause}>暂停</button>}
{status === "paused" && <button onClick={resume}>继续</button>}
<span>{progress}%</span>
</section>
);
}9. Nuxt 项目实现
9.1 前端页面
Nuxt 前端与 Vue3 类似,可以使用 composable。
app/pages/upload.vue:
vue
<script setup lang="ts">
const progress = ref(0);
const status = ref("idle");
async function onFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
status.value = "uploading";
// 调用通用 chunk upload service
}
</script>
<template>
<input type="file" @change="onFileChange" />
<progress :value="progress" max="100" />
</template>9.2 Nuxt server/api 普通 chunk 接口
server/api/uploads/init.post.ts:
ts
const sessions = new Map<string, any>();
export default defineEventHandler(async (event) => {
const body = await readBody<{
fileName: string;
fileSize: number;
fileHash: string;
chunkSize: number;
totalChunks: number;
}>(event);
const uploadId = body.fileHash;
if (!sessions.has(uploadId)) {
sessions.set(uploadId, {
...body,
uploadedChunks: new Set<number>(),
});
}
const session = sessions.get(uploadId);
return {
uploadId,
shouldUpload: true,
uploadedChunks: Array.from(session.uploadedChunks),
};
});server/api/uploads/[uploadId]/chunks/[index].post.ts:
ts
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
export default defineEventHandler(async (event) => {
const uploadId = getRouterParam(event, "uploadId")!;
const index = getRouterParam(event, "index")!;
const parts = await readMultipartFormData(event);
const chunk = parts?.find((part) => part.name === "chunk");
if (!chunk?.data) {
throw createError({ statusCode: 400, statusMessage: "chunk is required" });
}
const dir = join(process.cwd(), ".uploads", uploadId);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, index), chunk.data);
return { ok: true };
});注意:
readMultipartFormData会把当前 chunk 读到内存中,所以 chunk 不要太大。- 生产大文件更推荐对象存储直传,或在 Node 服务中使用流式 multipart parser。
- Nuxt serverless/edge 部署环境不一定适合本地磁盘合并文件。
10. 小程序项目实现
10.1 小程序上传限制
小程序的 wx.uploadFile 适合上传一个文件路径。
它返回 UploadTask,可以:
- 监听进度。
- abort 中断。
但它不直接提供完整的大文件暂停续传协议。断点续传需要自己做分片和服务端状态记录。
10.2 小文件上传
ts
const task = wx.uploadFile({
url: "https://api.example.com/upload",
filePath,
name: "file",
success(res) {
console.log(res);
},
fail(error) {
console.error(error);
},
});
task.onProgressUpdate((progress) => {
console.log(progress.progress);
});
// 暂停等价于中断
task.abort();10.3 小程序分片上传思路
由于 wx.uploadFile 需要 filePath,分片有两种做法:
- 使用
FileSystemManager.readFile读取指定 position/length,得到 ArrayBuffer,用wx.request上传 raw binary。 - 读取分片后写入临时 chunk 文件,再用
wx.uploadFile上传该 chunk 文件。
第一种更直接,但要求后端支持 application/octet-stream。
10.4 小程序 raw chunk 上传
ts
function uploadChunk(options: {
uploadId: string;
index: number;
data: ArrayBuffer;
}) {
return new Promise<void>((resolve, reject) => {
const task = wx.request({
url: `https://api.example.com/uploads/${options.uploadId}/chunks/${options.index}`,
method: "PUT",
header: {
"Content-Type": "application/octet-stream",
},
data: options.data,
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve();
} else {
reject(res);
}
},
fail: reject,
});
// 保存 task,用于暂停时 abort
currentTasks.add(task);
});
}读取分片:
ts
function readChunk(filePath: string, position: number, length: number) {
const fs = wx.getFileSystemManager();
return new Promise<ArrayBuffer>((resolve, reject) => {
fs.readFile({
filePath,
position,
length,
success(res) {
resolve(res.data as ArrayBuffer);
},
fail: reject,
});
});
}调度:
ts
async function uploadMiniProgramFile(filePath: string, fileSize: number) {
const chunkSize = 2 * 1024 * 1024;
const totalChunks = Math.ceil(fileSize / chunkSize);
const initResult = await requestInitUpload({
fileName: filePath.split("/").pop() || "file",
fileSize,
chunkSize,
totalChunks,
});
for (let index = 0; index < totalChunks; index += 1) {
if (paused) break;
if (initResult.uploadedChunks.includes(index)) continue;
const position = index * chunkSize;
const length = Math.min(chunkSize, fileSize - position);
const data = await readChunk(filePath, position, length);
await uploadChunk({
uploadId: initResult.uploadId,
index,
data,
});
}
}暂停:
ts
function pauseMiniProgramUpload() {
paused = true;
currentTasks.forEach((task) => task.abort());
currentTasks.clear();
}继续:
ts
async function resumeMiniProgramUpload(filePath: string, fileSize: number) {
paused = false;
const status = await getUploadStatus(uploadId);
// 根据 status.uploadedChunks 继续上传缺失 chunk
await uploadMiniProgramFile(filePath, fileSize);
}10.5 小程序注意事项
- 临时文件路径可能失效,真正断点续传通常需要用户重新选择文件,或把文件保存到本地持久路径。
- chunk 不宜太大,避免内存压力。
- 小程序网络并发有限,建议并发 1-3。
- 上传失败要记录错误码和网络状态。
- 后端 raw endpoint 要限制 chunk 大小。
11. Express 服务端实现
11.1 安装
bash
pnpm add express multer cors
pnpm add -D typescript @types/express @types/multer11.2 目录
text
src/
app.ts
upload/
upload.routes.ts
upload.service.ts
tmp/
uploads/
storage/11.3 示例数据结构
演示用 Map,生产应使用数据库或 Redis:
ts
interface UploadSession {
uploadId: string;
fileName: string;
fileSize: number;
fileHash: string;
chunkSize: number;
totalChunks: number;
uploadedChunks: Set<number>;
status: "uploading" | "completed" | "cancelled";
}
const sessions = new Map<string, UploadSession>();
const completedFiles = new Map<string, string>();11.4 init 接口
ts
import express from "express";
import { randomUUID } from "node:crypto";
const router = express.Router();
router.post("/uploads/init", express.json(), (req, res) => {
const {
fileName,
fileSize,
fileHash,
chunkSize,
totalChunks,
} = req.body;
const existingUrl = completedFiles.get(fileHash);
if (existingUrl) {
res.json({
shouldUpload: false,
fileUrl: existingUrl,
});
return;
}
const uploadId = fileHash || randomUUID();
if (!sessions.has(uploadId)) {
sessions.set(uploadId, {
uploadId,
fileName,
fileSize,
fileHash,
chunkSize,
totalChunks,
uploadedChunks: new Set(),
status: "uploading",
});
}
const session = sessions.get(uploadId)!;
res.json({
uploadId,
shouldUpload: true,
uploadedChunks: Array.from(session.uploadedChunks),
});
});11.5 multipart chunk 上传
ts
import multer from "multer";
import { mkdir, rename } from "node:fs/promises";
import { join } from "node:path";
const upload = multer({
dest: "tmp/chunks",
limits: {
fileSize: 10 * 1024 * 1024,
},
});
router.post(
"/uploads/:uploadId/chunks/:index",
upload.single("chunk"),
async (req, res, next) => {
try {
const { uploadId, index } = req.params;
const session = sessions.get(uploadId);
if (!session) {
res.status(404).json({ message: "upload session not found" });
return;
}
if (!req.file) {
res.status(400).json({ message: "chunk is required" });
return;
}
const chunkDir = join("tmp/uploads", uploadId);
await mkdir(chunkDir, { recursive: true });
await rename(req.file.path, join(chunkDir, index));
session.uploadedChunks.add(Number(index));
res.json({ ok: true });
} catch (error) {
next(error);
}
}
);11.6 raw binary chunk 上传
适合小程序 wx.request 上传 ArrayBuffer:
ts
router.put(
"/uploads/:uploadId/chunks/:index",
express.raw({
type: "application/octet-stream",
limit: "10mb",
}),
async (req, res, next) => {
try {
const { uploadId, index } = req.params;
const session = sessions.get(uploadId);
if (!session) {
res.status(404).json({ message: "upload session not found" });
return;
}
const chunkDir = join("tmp/uploads", uploadId);
await mkdir(chunkDir, { recursive: true });
await writeFile(join(chunkDir, index), req.body);
session.uploadedChunks.add(Number(index));
res.json({ ok: true });
} catch (error) {
next(error);
}
}
);11.7 status 接口
ts
router.get("/uploads/:uploadId/status", (req, res) => {
const session = sessions.get(req.params.uploadId);
if (!session) {
res.status(404).json({ message: "upload session not found" });
return;
}
res.json({
uploadId: session.uploadId,
status: session.status,
uploadedChunks: Array.from(session.uploadedChunks),
totalChunks: session.totalChunks,
});
});11.8 complete 合并
ts
import { createReadStream, createWriteStream } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import { once } from "node:events";
async function pipeChunkToWriteStream(chunkPath: string, writeStream: NodeJS.WritableStream) {
await new Promise<void>((resolve, reject) => {
const readStream = createReadStream(chunkPath);
readStream.on("error", reject);
readStream.on("end", resolve);
readStream.pipe(writeStream, { end: false });
});
}
router.post("/uploads/:uploadId/complete", async (req, res, next) => {
try {
const session = sessions.get(req.params.uploadId);
if (!session) {
res.status(404).json({ message: "upload session not found" });
return;
}
if (session.uploadedChunks.size !== session.totalChunks) {
res.status(400).json({ message: "chunks are incomplete" });
return;
}
await mkdir("storage", { recursive: true });
const outputPath = join("storage", `${session.fileHash}-${session.fileName}`);
const writeStream = createWriteStream(outputPath);
for (let index = 0; index < session.totalChunks; index += 1) {
await pipeChunkToWriteStream(join("tmp/uploads", session.uploadId, String(index)), writeStream);
}
writeStream.end();
await once(writeStream, "finish");
session.status = "completed";
const fileUrl = `/files/${session.fileHash}-${session.fileName}`;
completedFiles.set(session.fileHash, fileUrl);
await rm(join("tmp/uploads", session.uploadId), { recursive: true, force: true });
res.json({
fileUrl,
fileHash: session.fileHash,
size: session.fileSize,
});
} catch (error) {
next(error);
}
});11.9 Express 生产注意事项
- Map 要换成 Redis / MySQL / PostgreSQL。
- 多实例部署不能只写本地磁盘,除非有共享存储。
- complete 要加锁,避免两个请求同时合并。
- chunk 文件名只能用数字 index,不能信任客户端传路径。
- 定时清理过期 upload session。
12. NestJS 服务端实现
12.1 Controller
ts
import {
Body,
Controller,
Get,
Param,
Post,
UploadedFile,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { UploadService } from "./upload.service";
@Controller("uploads")
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Post("init")
init(@Body() body: InitUploadDto) {
return this.uploadService.init(body);
}
@Post(":uploadId/chunks/:index")
@UseInterceptors(FileInterceptor("chunk", { dest: "tmp/chunks" }))
uploadChunk(
@Param("uploadId") uploadId: string,
@Param("index") index: string,
@UploadedFile() file: Express.Multer.File
) {
return this.uploadService.saveChunk(uploadId, Number(index), file);
}
@Get(":uploadId/status")
status(@Param("uploadId") uploadId: string) {
return this.uploadService.status(uploadId);
}
@Post(":uploadId/complete")
complete(@Param("uploadId") uploadId: string) {
return this.uploadService.complete(uploadId);
}
}12.2 DTO
ts
import { IsInt, IsNumber, IsString, Min } from "class-validator";
export class InitUploadDto {
@IsString()
fileName!: string;
@IsNumber()
@Min(1)
fileSize!: number;
@IsString()
fileHash!: string;
@IsInt()
@Min(1)
chunkSize!: number;
@IsInt()
@Min(1)
totalChunks!: number;
}12.3 Service 设计
ts
import { Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class UploadService {
async init(dto: InitUploadDto) {
// 1. 查 fileHash 是否已存在,实现秒传
// 2. 查 upload session 是否存在,实现断点续传
// 3. 不存在则创建 session
return {
uploadId: dto.fileHash,
shouldUpload: true,
uploadedChunks: [],
};
}
async saveChunk(uploadId: string, index: number, file: Express.Multer.File) {
// 1. 校验 session
// 2. 移动临时 chunk 到 tmp/uploads/{uploadId}/{index}
// 3. DB/Redis 记录 index 已上传
return { ok: true };
}
async status(uploadId: string) {
// 返回 uploadedChunks
throw new NotFoundException(uploadId);
}
async complete(uploadId: string) {
// 1. 校验所有 chunk 都存在
// 2. 加锁
// 3. 顺序合并
// 4. 校验完整 hash
// 5. 写入文件表
// 6. 清理临时文件
return { fileUrl: "" };
}
}12.4 NestJS 生产增强
建议加入:
ValidationPipe。- 鉴权 Guard。
- 上传配额。
- Redis 记录会话。
- 定时任务清理临时文件。
- Swagger 文档。
- Interceptor 记录上传耗时。
- Exception Filter 统一错误。
13. 对象存储 Multipart Upload 方案
13.1 为什么生产更推荐对象存储
如果文件很大、用户很多,让文件先经过应用服务器会带来巨大带宽和磁盘压力。
更好的方式是:
text
浏览器/小程序 -> 对象存储
业务服务器 -> 只负责签名、鉴权、记录元数据、完成确认13.2 S3 Multipart 关键限制
Amazon S3 Multipart Upload 常见规则:
- 每个 part 通常至少 5 MiB,最后一个 part 例外。
- 单个 multipart upload 最多 10,000 个 part。
- 上传后 complete 时需要提交每个 part 的 PartNumber 和 ETag。
- 如果取消上传,要 abort multipart upload 清理未完成 part。
不同云厂商 OSS/COS/OBS API 名称不同,但思想类似。
13.2.1 内部机制:Multipart ETag 不是全文 MD5
一个必须知道的事实:对象存储返回的 ETag 不能当作“整文件 MD5”来校验。规则:
text
单次 PUT 上传: ETag = MD5(整个对象) (未加密时)
Multipart 上传: ETag = MD5( part1的MD5原始字节
‖ part2的MD5原始字节
‖ ... ‖ partN的MD5原始字节 ) + "-N"
例如: ceb8853ddc5086cc4ab9e149f8f09c88-5 → 5 个 part推论:
- 校验直传完整性要在前端按同样的分片大小重算聚合 MD5(对每片 MD5 的二进制摘要拼接再取 MD5,不是拼接十六进制字符串),得到
<hex>-<N>与服务端比对;KMS/SSE 加密对象的 ETag 甚至不是任何 MD5,不能用于内容校验。 - 更稳的做法是不依赖 ETag:init 时约定业务 fileHash,服务端合并/complete 后自算全文 hash 落库(与 3.5 节第四层复核同一思想)。
- 未完成的 multipart 会持续计费已传 part 的存储,所以生命周期规则里必须配 AbortIncompleteMultipartUpload(按天清理),这是账单层面的“清理策略”,与 15.5 节的临时文件清理对应。
- part 上限 10,000 决定了 chunkSize 下限:
chunkSize ≥ fileSize / 10000(单对象最大 5TB)。1GB 文件按 5MB 分片是 200 片,余量充足;10GB 文件就不能再用 1MB 分片。
13.3 接口设计
创建:
http
POST /uploads/multipart/init响应:
json
{
"uploadId": "s3-upload-id",
"objectKey": "videos/xxx.mp4",
"parts": [
{
"partNumber": 1,
"url": "https://signed-url"
}
]
}前端直传:
ts
const response = await fetch(part.url, {
method: "PUT",
body: chunk,
});
const etag = response.headers.get("ETag");完成:
http
POST /uploads/multipart/complete请求:
json
{
"objectKey": "videos/xxx.mp4",
"uploadId": "s3-upload-id",
"parts": [
{ "partNumber": 1, "etag": "\"abc\"" },
{ "partNumber": 2, "etag": "\"def\"" }
]
}13.4 暂停和续传
暂停:
- abort 当前 PUT 请求。
- 保存已完成 part 的 ETag。
- 不调用 AbortMultipartUpload,除非用户取消。
续传:
- 查询后端或对象存储已上传 part。
- 获取缺失 part 的新签名 URL。
- 继续上传缺失 part。
13.5 什么时候不使用对象存储直传
不适合:
- 文件必须先经过应用服务器实时处理。
- 安全策略不允许客户端直连对象存储。
- 文件很小。
- 云存储接入成本高于收益。
14. 暂停、继续、断点重传的真实处理逻辑
14.1 暂停按钮做什么
前端:
- 状态改为
paused。 - 终止当前请求。
- 停止调度新 chunk。
- 保留 uploadId、fileHash、uploadedChunks。
服务端:
- 不删除已上传 chunk。
- session 保持 uploading。
- 等待后续继续或过期清理。
14.2 继续按钮做什么
前端:
- 如果 File 对象还在内存,直接继续。
- 如果页面刷新过,要求用户重新选择同一个文件。
- 重新计算 fileHash 或 fingerprint。
- 调用 init/status。
- 上传缺失 chunk。
服务端:
- 根据 fileHash 找到 session。
- 返回 uploadedChunks。
- complete 时校验 chunk 完整性。
14.3 浏览器刷新后的断点续传
浏览器不能永久保存用户选择的 File 对象。
所以刷新后通常需要用户重新选择同一个文件。
可以持久化:
json
{
"fileName": "video.mp4",
"fileSize": 1048576000,
"lastModified": 1710000000000,
"fileHash": "xxx",
"uploadId": "u_123"
}存在 localStorage 或 IndexedDB。
用户重新选择文件后,对比:
- name。
- size。
- lastModified。
- hash 或 fingerprint。
一致才继续。
14.4 失败重试
单个 chunk 失败不应导致整个上传失败。
建议:
- 每个 chunk 最多重试 3 次。
- 指数退避。
- 网络断开时暂停。
- 401 时刷新 token 后重试。
- 4xx 参数错误不要重试。
示例:
ts
async function retry<T>(fn: () => Promise<T>, times = 3) {
let lastError: unknown;
for (let i = 0; i < times; i += 1) {
try {
return await fn();
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** i));
}
}
throw lastError;
}14.5 进度计算
按 chunk 数计算简单,但不精确:
ts
progress = uploadedChunks / totalChunks;更准确是按字节:
ts
progress = uploadedBytes / file.size;如果要显示当前 chunk 内部进度,使用 XMLHttpRequest upload.onprogress 比 fetch 更方便,因为 fetch 标准上传进度支持仍不如 XHR 普遍。
按字节累计的完整写法(把每个 chunk 的已传字节也计入):
ts
export function uploadChunkWithProgress(options: {
uploadId: string;
index: number;
chunk: Blob;
signal: AbortSignal;
onBytes: (loadedInChunk: number) => void;
}) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
const fd = new FormData();
fd.append("chunk", options.chunk);
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) options.onBytes(e.loaded);
});
xhr.addEventListener("load", () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(Object.assign(new Error(`chunk ${options.index} failed`), { status: xhr.status })));
xhr.addEventListener("error", () => reject(new Error("network error")));
xhr.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
xhr.open("POST", `/uploads/${options.uploadId}/chunks/${options.index}`);
xhr.send(fd);
// fetch 的 signal 不能直接给 XHR 用,桥接一下
options.signal.addEventListener("abort", () => xhr.abort(), { once: true });
});
}全局进度 = (已完成 chunk 的字节总数 + Σ各在传 chunk 的 loaded) / file.size。⚠️ upload.onprogress 的 loaded 是“已交给操作系统的字节”,受 TCP 发送缓冲影响,断网瞬间可能短暂停在非零值;重传后要按服务端 status 校正,不要只信本地计数。
14.6 注意事项:续传时必须重新校验 chunk 内容
“服务端说 chunk 3 已存在”有一个隐藏前提:它存在且内容正确。断点续传恢复时如果只信 index 列表,可能复用一个写坏/被其他会话覆盖的分片。低成本防御:
text
init 响应扩展:
{
"uploadedChunks": [
{ "index": 0, "size": 5242880, "hash": "md5-of-chunk-0" },
{ "index": 1, "size": 5242880, "hash": "md5-of-chunk-1" }
]
}前端恢复时对本地重切出的同名分片算一次快速 hash(Worker 里按 5MB 算 MD5 只需几毫秒/片),与服务端返回的 per-chunk hash 比对;不一致的分片当作未上传重传。这一步把“断点续传后 complete 才发现全文 hash 不对、整个文件重传”的最坏情况,变成“只重传坏片”。
15. 安全、可靠性与生产注意事项
15.1 安全
必须做:
- 鉴权。
- 上传权限校验。
- 文件大小限制。
- chunk 大小限制。
- 文件类型白名单。
- 文件后缀和 MIME 双重检查。
- 不信任客户端文件名。
- 防路径穿越。
- 病毒扫描或异步审核。
- 私有文件访问鉴权。
- 上传频率限制。
15.2 完整性校验
建议:
- chunk hash 可选,用于单片校验。
- 完整 fileHash 必须在 complete 后校验。
- fileSize 必须一致。
- totalChunks 必须一致。
15.3 幂等
chunk 上传要幂等:
- 同一个 uploadId + index 重复上传,应覆盖或直接返回成功。
- complete 重复调用,应返回同一个 fileUrl。
- init 重复调用,应返回已有 session。
15.4 多实例部署
不能把 chunk 只写在某台机器本地磁盘上,除非有粘性会话和清晰回滚策略。
生产推荐:
- 共享存储。
- 对象存储。
- Redis 记录 session。
- 数据库记录文件元数据。
15.5 清理策略
必须清理:
- 过期 upload session。
- 已取消上传。
- complete 后临时 chunk。
- 对象存储未完成 multipart。
建议定时任务:
text
每小时扫描超过 24 小时未完成上传
删除临时 chunk
标记 session expired15.6 Nginx 和网关配置
如果 chunk 是 5MB,网关 body limit 要大于 chunk size。
示例:
nginx
client_max_body_size 10m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;如果使用对象存储直传,业务网关压力会小很多。
16. 测试清单
16.1 基础功能
- 小文件上传成功。
- 大文件分片上传成功。
- complete 后文件可访问。
- 文件大小一致。
- 文件 hash 一致。
16.2 暂停继续
- 上传中点击暂停。
- 暂停后没有继续上传新 chunk。
- 点击继续后只上传缺失 chunk。
- 多次暂停继续。
- 暂停后刷新页面,重新选择同文件继续。
16.3 失败重试
- 单个 chunk 失败自动重试。
- 网络断开后暂停。
- token 过期后刷新再继续。
- 服务端 400 不重试。
16.4 并发和边界
- 文件小于 chunkSize。
- 文件刚好等于 chunkSize。
- 最后一个 chunk 小于 chunkSize。
- 并发 1、3、5 都正常。
- 同一文件重复上传秒传。
- 两个用户上传同名不同文件。
- 两个用户上传同 hash 文件权限隔离正确。
16.5 安全
- 超大 chunk 被拒绝。
- 非法文件类型被拒绝。
- 路径穿越文件名被处理。
- 未登录不能上传。
- 无权限不能访问私有文件。
17. 官方参考资料
- MDN File API:https://developer.mozilla.org/en-US/docs/Web/API/File
- MDN Blob.slice:https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice
- MDN FormData:https://developer.mozilla.org/en-US/docs/Web/API/FormData
- MDN AbortController:https://developer.mozilla.org/en-US/docs/Web/API/AbortController
- MDN Web Workers:https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
- NestJS File Upload:https://docs.nestjs.com/techniques/file-upload
- Express 官方文档:https://expressjs.com/
- Multer:https://github.com/expressjs/multer
- 微信小程序 wx.uploadFile:https://developers.weixin.qq.com/miniprogram/dev/api/network/upload/wx.uploadFile.html
- uni-app uploadFile:https://uniapp.dcloud.net.cn/api/request/network-file.html
- Taro uploadFile:https://docs.taro.zone/docs/apis/network/upload/uploadFile
- Amazon S3 Multipart Upload:https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- tus resumable upload protocol:https://tus.io/protocols/resumable-upload