关于 web-files-file-upload-patterns
File Upload Patterns 是用于构建浏览器文件选择、验证和上传流程的 Agent Skill,涵盖直传存储与断点续传,并关注无障碍和安全。
主要功能
- 01点击、键盘和拖放选择文件
- 02客户端验证与文件签名检查
- 03XHR 上传进度与取消
- 04使用预签名 URL 直传
- 05分块上传、重试与续传
- 06无障碍状态和进度播报
适用场景
- 01构建支持键盘操作的拖放区
- 02显示上传进度并支持取消
- 03将文件直接上传到对象存储
- 04中断后续传大型文件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-files-file-upload-patterns安装后在 Claude Code 中输入/web-files-file-upload-patterns确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.yamlreference.mdexamples/accessibility.mdcore.mdpresigned-upload.mdpreview.mdprogress.mdresumable.mdvalidation.md详细介绍
文件上传模式
快速指南: 拖放区是一个可通过键盘操作、包裹隐藏文件输入框的按钮,拖放是增强功能。为了帮助用户,应在客户端进行校验——依次检查扩展名、MIME 类型和文件自身的魔数——并在服务器上再次校验,因为客户端的任何检查都不能保证安全。上传进度需要使用
XMLHttpRequest;fetch没有上传进度事件。文件超过约 100MB 后,应将其分块,这样失败时只需重传一个分块。大文件应通过服务器签发的预签名 URL 直接上传到存储,因此请求正文不会经过代理转发。
详细资源:
- examples/core.md — 文件输入框、拖放区、文件列表状态与渲染、组装后的组件
- examples/validation.md — 基于规则的校验器、魔数检测、尺寸检查、校验 Hook
- examples/progress.md — 使用 XHR 显示进度、速度和预计剩余时间、进度条、格式化函数、并发上传
- examples/preview.md — 所选文件的预览缩略图及清理
- examples/presigned-upload.md — PUT 和 POST 策略上传、服务器契约、分块上传、完整流程的 Hook 封装
- examples/resumable.md — 支持重试的分块上传器、重新加载后恢复上传、tus 客户端、tus 服务器契约
- examples/accessibility.md — 播报文件选择和上传进度、文件对话框关闭后恢复焦点
- reference.md — 按文件大小选择上传方式、过期时间建议、校验顺序、CORS、审查清单
适用的方案
目标位置几乎决定了其他所有事项。
- 文件上传到你自己的端点 — 使用
FormData发送一个POST请求,通过 XHR 获取进度,并设置服务器可强制执行的大小上限。examples/core.md 和 examples/progress.md 涵盖了全部内容。 - 文件上传到对象存储 — 服务器签发预签名 URL,浏览器直接向该 URL 上传,因此字节数据不会经过你的应用。 examples/presigned-upload.md。
- 文件足够大,失败后果严重 — 将文件拆分为多个分块,在并发数量限制下上传,并记录已上传的分块,以便重试时继续上传。 examples/resumable.md。
<critical_requirements>
编写上传代码前
在服务器和浏览器中都进行验证。 客户端验证用于快速告知用户哪些内容会被拒绝;任何人都可以完全跳过它,因此它不能解决任何安全问题。
类型很重要时,读取文件的前几个字节。 扩展名和 MIME 类型都由文件创建者提供,改了名的可执行文件可以通过所有信任这些信息的检查。
撤销创建的每个对象 URL。 预览会让整个文件一直驻留在内存中,直到调用 URL.revokeObjectURL();因此,用户每改变三次主意,就会泄漏三个文件。
确保键盘可以操作拖放区。 使用 role="button"、tabIndex={0} 和一个按 Enter/Space 键打开文件对话框的处理程序,再叠加拖放功能——移动设备完全无法拖放,因此点击才是真正的操作方式。
让服务器签发短期有效的预签名 URL,而不是代理请求体。 这样上传就不会占用应用程序的资源,而且浏览器永远接触不到存储凭证。
</critical_requirements>
自动检测: dropzone、dataTransfer.files、dragenter、dragleave、dragover、input type="file"、event.target.files、accept attribute、xhr.upload.addEventListener、lengthComputable、presigned URL、uploadUrl、multipart upload、UploadPart、ETag、chunked upload、file.slice、Content-Range、resumable upload、tus、Tus-Resumable、Upload-Offset、magic bytes、file signature、FormData append file
适用于:
- 通过点击、键盘或拖放选择文件
- 在发送任何内容之前验证类型、大小和尺寸
- 报告进度、速度和剩余时间,并取消上传
- 使用服务器签名的 URL 直接上传到存储
- 将大文件拆分成分块,并在上传中断后恢复
- 向屏幕阅读器播报文件选择、进度和失败信息
其他部分处理:
- 接收、扫描并存储到达的字节
- 在发送图像之前调整大小、裁剪或转换图像——此技能会发送收到的
File - 存储对象的位置、提供对象的方式,以及对象 URL 的形式
- 已上传媒体的流式播放
理念
上传包含三个彼此独立却常被混为一谈的问题:选择文件、检查文件,以及传输文件字节。将它们分开,才能让每个环节都可以替换。
检查环节有一条绝不妥协的规则。客户端验证是用户体验功能,而服务器验证才是唯一的控制措施。 浏览器对文件了解的一切——文件名、扩展名和 type——都来自文件本身。读取魔数可以提高门槛,但不会改变验证的性质:客户端检查仍然可以被跳过。
传输环节的扩展取决于另一个维度:不是文件数量,而是单个请求持续打开的时间。短请求失败后可以整体重试。长请求会不断累积连接中断的概率,直到整体重试无法接受;到那时,分块才值得用其复杂性来换取收益——而不是达到某个特定字节数时就开始分块。
核心模式
模式 1:拖放区
统计拖入事件,而不是用布尔值进行跟踪。每个嵌套元素都会触发 dragenter 和 dragleave,因此鼠标指针经过子元素时,布尔值会立刻闪变为关闭状态。
const dragCounterRef = useRef(0);
<div
onDragEnter={() => { dragCounterRef.current++; setState("drag-over"); }}
onDragLeave={() => {
dragCounterRef.current--;
if (dragCounterRef.current === 0) setState("idle");
}}
onDragOver={(e) => e.preventDefault()} // without this, drop never fires
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label="File upload area. Click or drag files to upload."
>
<input ref={inputRef} type="file" hidden aria-hidden="true" tabIndex={-1} />
</div>
完整代码:examples/core.md
模式 2:文件列表状态
每个文件各有一个条目和自己的状态,因此失败针对单个文件,而不是整批文件。拒绝结果会附带界面可显示的原因。
interface FileWithId {
id: string;
file: File;
preview?: string;
status: "pending" | "uploading" | "success" | "error";
progress: number;
error?: string;
}
// addFiles returns { added, rejected }, each rejection carrying its reason
// removeFile and clearFiles revoke any preview URL before dropping the entry
完整代码:examples/core.md
### 模式 3:使用 XHR 显示进度fetch 会报告下载进度,但不会报告上传进度,因此要显示上传进度,就需要使用
XMLHttpRequest。对最近几次采样求平均值,否则速度读数会抖动得无法使用。
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) return; // no total: show a spinner, not a bar
const speed = rollingAverageSpeed(event.loaded, performance.now());
setProgress({
loaded: event.loaded,
total: event.total,
percentage: Math.round((event.loaded / event.total) * 100),
speed,
remainingTime: (event.total - event.loaded) / speed,
});
});
xhr.abort() 用于取消。对 fetch 请求体进行流式处理,测量的是你交给流的数据量,而不是实际通过网络传输的数据量,因此它不能替代前者。
完整代码:examples/progress.md
模式 4:魔数检测
读取前 12 个字节,并与已知签名进行比较。切勿读取整个文件——大文件会导致标签页卡死。
const FILE_SIGNATURES = [
{ mime: "image/jpeg", extension: "jpg", signature: [0xff, 0xd8, 0xff] },
{ mime: "image/png", extension: "png", signature: [0x89, 0x50, 0x4e, 0x47] },
{
mime: "application/pdf",
extension: "pdf",
signature: [0x25, 0x50, 0x44, 0x46],
},
{
mime: "application/zip",
extension: "zip",
signature: [0x50, 0x4b, 0x03, 0x04],
},
];
const buffer = await file.slice(0, 12).arrayBuffer();
const bytes = new Uint8Array(buffer);
Office 文档是 ZIP 存档,因此匹配到 ZIP 后需要进一步检查:前 1 KB 中的 word/、xl/ 或 ppt/ 可用于识别具体类型。
模式 5:预签名上传
分四步完成,且应用程序不会接触文件数据:
- 客户端向你的服务端请求 URL,并发送文件名、类型和大小。
- 服务端授权请求、清理文件名、生成键,并签署一个短时有效的 URL。
- 客户端通过该 URL
PUT文件。 - 客户端将键告知服务端,由服务端记录。
const { uploadUrl, key } = await requestPresignedUrl(file);
const xhr = new XMLHttpRequest();
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.addEventListener("progress", reportProgress);
xhr.send(file);
使用 POST 策略 URL 而非 PUT,可让存储服务自行强制限制大小和内容类型——但 FormData 字段顺序会影响结果,文件必须最后追加。
完整代码:examples/presigned-upload.md
模式 6:分块与可恢复上传
对文件进行切片,在并发数限制下上传各个切片,并使用指数退避重试失败的切片,而不是从头开始。
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
const start = chunkIndex * chunkSize;
const chunk = file.slice(start, Math.min(start + chunkSize, file.size));
以 name-size-lastModified 为键保存已完成的分块索引,这样重新加载后即可从断点继续,而非从头开始;并在一天后使该记录过期。如果你需要使用可互操作的协议,而不是自定义协议,tus 的流程是:通过 POST 创建,通过 HEAD 获取偏移量,通过 PATCH 追加数据。
<red_flags>
注意事项
运行时会出错:
dragover上未调用event.preventDefault()——drop不会触发,浏览器会导航到文件——在dragover和drop上都阻止默认行为- 使用布尔值记录拖拽状态——指针经过子元素时状态会闪烁——在 ref 中统计
dragenter和dragleave - 选择文件后未清空文件输入框的值——连续两次选择同一个文件不会触发
change事件——读取files后,将event.target.value = "" - 需要进度时使用
fetch——它没有上传进度事件,且流进度测量的内容不对——使用XMLHttpRequest - 使用
file.text()或readAsDataURL()检查类型——这会将整个文件读入内存——切片读取前 12 个字节 - 在渲染函数体中创建对象 URL——每次渲染都会创建新的 URL,且不会撤销——在 effect 中创建,并在清理函数中撤销
- 存储桶未配置 CORS——直接上传会在预检请求时失败——允许相应的源和方法,并为分段上传公开
ETag
容易产生意外的行为:
- 预签名 URL 是一种持有者令牌:任何持有它的人都能在它过期前执行相应操作
- 扩展名、MIME 类型和
File.type都来自客户端,因此都可以伪造 - 移动浏览器不支持拖放,因此点击是唯一可用的操作方式
- 对于长度未知的请求,
lengthComputable为 false;在它变为 true 之前,百分比没有意义 - 未完成也未中止的分段上传会留下仍计费且不可见的分段;请设置生命周期规则将其过期清除
- Safari 的拖拽事件与 Chromium 的差异较大,值得单独测试
- 照片包含 EXIF 方向信息,浏览器显示时会应用该信息,但画布处理不会
</red_flags>