前端直传 R2 / S3:上传进度、取消与 mock API
这段代码的本质是:把 XHR 的上传进度、成功、失败、取消事件封装成一个 Promise,让前端可以用 await 控制 R2 / S3 presigned URL 上传,同时用 onProgress 更新 UI,用 AbortController 取消上传。
可以把它抽象成一句话:
uploadToR2WithProgress(params): Promise<void>意思是:开始上传,上传成功就 resolve,上传失败或取消就 reject。
一、为什么是前端直传
传统上传路径是:
浏览器 -> 业务后端 -> 对象存储直传对象存储的路径是:
浏览器 -> R2 / S3业务后端只负责生成一个临时上传地址,也就是 presignedUrl。浏览器拿到这个地址后,直接把文件上传到对象存储。
这样做的好处是:
- 文件流量不经过业务后端,后端压力更小。
- 上传大文件时链路更短。
- 前端可以独立控制上传进度、取消和重试。
二、mock 版接口约定
笔记里的接口全部使用 mock,不依赖真实 API。
前端先向 mock 后端请求一个上传地址:
type MockPresignedUploadResponse = {
presignedUrl: string;
objectKey: string;
publicUrl: string;
};示例请求:
async function getMockPresignedUploadUrl(file: File): Promise<MockPresignedUploadResponse> {
const response = await fetch('/api/mock/presigned-upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: file.name,
contentType: file.type || 'application/octet-stream',
size: file.size,
}),
});
if (!response.ok) {
throw new Error(`Create mock presigned URL failed: ${response.status}`);
}
return response.json();
}mock 后端可以返回固定数据:
export async function POST() {
return Response.json({
presignedUrl: 'https://mock-r2-upload.example.test/demo-bucket/mock-file.png?signature=mock-signature',
objectKey: 'uploads/mock-file.png',
publicUrl: 'https://cdn.example.test/uploads/mock-file.png',
});
}这里的域名都是示例域名:
/api/mock/presigned-upload
https://mock-r2-upload.example.test
https://cdn.example.test不要在笔记、测试或 demo 里写真实 bucket、真实 token、真实 API 网关地址。
三、上传函数签名
type UploadToR2WithProgressParams = {
presignedUrl: string;
file: File;
onProgress?: (percent: number) => void;
signal?: AbortSignal;
};参数含义:
presignedUrl 后端生成的临时上传地址
file 浏览器 File 对象,也就是用户选择的文件
onProgress 上传进度回调,例如 setProgress
signal 用于取消上传的 AbortSignal返回值是:
Promise<void>这个函数只关心“上传是否成功”,不直接返回文件 URL。通常文件 URL、对象 key、访问地址是在请求 presignedUrl 时就已经拿到了。
四、为什么不用 fetch
这里使用 XMLHttpRequest,主要原因是:XHR 原生支持上传进度事件。
xhr.upload.onprogress = (event) => {
// event.loaded / event.total
};fetch 更现代,但它没有同样直接的上传进度 API。
如果 UI 需要展示:
上传中 18%
上传中 57%
上传完成XHR 仍然是简单可靠的选择。
五、完整上传函数
export function uploadToR2WithProgress({
presignedUrl,
file,
onProgress,
signal,
}: UploadToR2WithProgressParams): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(createUploadAbortError());
return;
}
const xhr = new XMLHttpRequest();
const abortUpload = () => {
xhr.abort();
};
const cleanup = () => {
signal?.removeEventListener('abort', abortUpload);
};
xhr.open('PUT', presignedUrl, true);
xhr.setRequestHeader(
'Content-Type',
file.type || 'application/octet-stream'
);
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
const percent = Math.round((event.loaded / event.total) * 100);
onProgress?.(percent);
};
xhr.onload = () => {
cleanup();
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
return;
}
reject(new Error(`Upload failed: ${xhr.status}`));
};
xhr.onerror = () => {
cleanup();
reject(new Error('Upload network error'));
};
xhr.onabort = () => {
cleanup();
reject(createUploadAbortError());
};
signal?.addEventListener('abort', abortUpload, { once: true });
xhr.send(file);
});
}
function createUploadAbortError() {
return new DOMException('Upload aborted', 'AbortError');
}六、为什么要包一层 Promise
XMLHttpRequest 是事件回调风格,不能直接这样写:
await xhr.send(file);所以要手动包装成 Promise:
xhr.onload -> resolve 或 reject
xhr.onerror -> reject
xhr.onabort -> reject这样外部就可以写:
await uploadToR2WithProgress({
presignedUrl,
file,
onProgress: setProgress,
signal,
});本质上是把“事件式 API”封装成“async/await 友好的 API”。
七、取消上传的链路
取消上传不是 AbortController 直接取消 XHR。
AbortController 只负责发出一个取消信号,真正停止请求的是:
xhr.abort();完整链路是:
controller.abort()
↓
signal 触发 abort 事件
↓
执行 abortUpload()
↓
调用 xhr.abort()
↓
触发 xhr.onabort
↓
reject(createUploadAbortError())这就是这段代码里最重要的取消链路。
八、上传进度怎么计算
核心代码是:
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
const percent = Math.round((event.loaded / event.total) * 100);
onProgress?.(percent);
};event 里常用的字段是:
event.loaded 已经上传的字节数
event.total 总字节数
event.lengthComputable 是否可以计算总进度例如文件总大小是 10MB,已经上传 3MB:
event.loaded = 3145728
event.total = 10485760计算出来就是:
30%lengthComputable 要判断,因为不是所有场景都能拿到可靠的总大小。普通 File 上传一般可以计算,但保留这个判断更稳。
九、成功、失败、取消的区别
xhr.onload 表示请求完成并收到响应,但不代表一定成功。
例如:
403 Forbidden
500 Internal Server Error这些也会进入 onload,所以必须判断状态码:
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}xhr.onerror 处理网络层错误,例如:
断网
DNS 错误
CORS 被浏览器拦截
TLS 错误
请求无法发出去xhr.onabort 处理主动取消:
xhr.onabort = () => {
cleanup();
reject(createUploadAbortError());
};主动取消和网络失败应该区分开。用户主动取消上传,通常不应该按业务错误提示。
十、完整 mock 使用示例
async function uploadSelectedFile(file: File) {
const controller = new AbortController();
try {
const { presignedUrl, publicUrl } = await getMockPresignedUploadUrl(file);
await uploadToR2WithProgress({
presignedUrl,
file,
signal: controller.signal,
onProgress: (percent) => {
console.log(`mock upload progress: ${percent}%`);
},
});
console.log('mock upload success:', publicUrl);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('mock upload aborted');
return;
}
console.error('mock upload failed:', error);
}
return () => {
controller.abort();
};
}如果用户点击“取消上传”按钮:
controller.abort();十一、真实项目里要注意什么
1. Content-Type 要和签名保持一致
如果后端生成 presigned URL 时把 Content-Type 参与了签名,前端上传时传的 Content-Type 必须一致。
否则对象存储可能会返回:
403 SignatureDoesNotMatch更稳的写法是:
xhr.setRequestHeader(
'Content-Type',
file.type || 'application/octet-stream'
);2. 不要随便加 Cache-Control
例如:
xhr.setRequestHeader('Cache-Control', 'max-age=86400');这个 header 可能影响签名、CORS 和后续缓存策略。更稳的做法是:由后端决定是否允许这个 header,然后前端按后端返回的配置设置。
if (cacheControl) {
xhr.setRequestHeader('Cache-Control', cacheControl);
}3. 文件 key 最好不要覆盖
如果同一个 URL 后续会被覆盖,浏览器或 CDN 缓存可能导致用户看到旧文件。
更推荐的对象存储策略是:
uploads/{uuid}-{filename}
uploads/{content-hash}.{ext}不要频繁覆盖同一个 key。
4. 可以返回 ETag
R2 / S3 上传成功后,响应头里可能有:
ETag如果后续要校验文件完整性,可以读取:
const etag = xhr.getResponseHeader('ETag');然后把函数返回值从:
Promise<void>升级成:
Promise<{ etag: string | null }>十二、整体流程
1. 前端选择文件
2. 前端请求 /api/mock/presigned-upload
3. mock 后端返回 presignedUrl、objectKey、publicUrl
4. 前端创建 XMLHttpRequest
5. 前端用 PUT 把 file 上传到 presignedUrl
6. 上传中通过 xhr.upload.onprogress 更新进度
7. 用户取消时通过 AbortController 触发 xhr.abort()
8. 上传成功后 resolve
9. HTTP 失败、网络失败、主动取消分别 reject核心设计只有三点:
- 上传进度:
xhr.upload.onprogress - 取消上传:
signal.addEventListener('abort', () => xhr.abort()) - Promise 封装:
return new Promise(...)
这三个点组合起来,就能把对象存储直传封装成一个可复用、可等待、可取消的前端上传能力。