mcpField
W
web-files-file-upload-patterns

web-files-file-upload-patterns

github.com/agents-inc开发2676 次安装2 天前更新
代码仓库

关于 web-files-file-upload-patterns

File Upload Patterns 是用于构建浏览器文件选择、验证和上传流程的 Agent Skill,涵盖直传存储与断点续传,并关注无障碍和安全。

主要功能

  1. 01点击、键盘和拖放选择文件
  2. 02客户端验证与文件签名检查
  3. 03XHR 上传进度与取消
  4. 04使用预签名 URL 直传
  5. 05分块上传、重试与续传
  6. 06无障碍状态和进度播报

适用场景

  1. 01构建支持键盘操作的拖放区
  2. 02显示上传进度并支持取消
  3. 03将文件直接上传到对象存储
  4. 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.yaml
reference.md
examples/
accessibility.md
core.md
presigned-upload.md
preview.md
progress.md
resumable.md
validation.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/ 可用于识别具体类型。

完整代码:examples/validation.md

模式 5:预签名上传

分四步完成,且应用程序不会接触文件数据:

  1. 客户端向你的服务端请求 URL,并发送文件名、类型和大小。
  2. 服务端授权请求、清理文件名、生成键,并签署一个短时有效的 URL。
  3. 客户端通过该 URL PUT 文件。
  4. 客户端将键告知服务端,由服务端记录。
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 追加数据。

完整代码:examples/resumable.md


<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>

评论

支持 Markdown