上周帮朋友看一个后台系统的上传模块,需求描述只有一句话:能点按钮选文件,也能把文件拖进去。结果我在他们仓库里翻出三套几乎不相干的上传逻辑——PC 端一套、移动端一套、拖拽单独一套,三套的校验规则还各写各的,最后出现"同一个文件点选能过、拖进去被拒"这种让人哭笑不得的问题。**前端文件上传(点击+拖拽)**这件事,看起来是交互层的活,真正做起来会发现它牵扯到浏览器事件模型、File API、网络层进度回传、甚至安全边界的划定。
这篇就把我在实际项目里踩过的、带新人时反复讲的这套东西整理出来。内容覆盖点击选择和拖拽投放两个入口的底层差异、怎么把它们收敛到同一条上传流水线、大文件该怎么做取舍、以及几个只在真机和老浏览器上才会暴露的坑。适合已经能写 CRUD 页面、但还没系统做过上传模块的前端同学;如果你只想抄一段能跑的代码,第 2 到第 4 节的代码块可以直接拿走用,但我强烈建议把第 5 节的排查过程也读一遍,那些问题基本是必踩的,早点知道能省下大半天调试时间。
1. 点击上传和拖拽上传,到底是不是两套代码
1.1 交互路径不同,拿到的却是同一种东西
点击上传走的是<input type="file">,浏览器弹出系统文件选择框,用户选完之后input.files给出一个FileList。拖拽上传走的是 HTML5 拖放事件链,在drop事件触发时,从event.dataTransfer.files里拿到的同样是一个FileList。这两个FileList里的元素都是标准的File对象,继承自Blob,带着name、size、type、lastModified这几个只读属性和一个slice()方法。
把这一点想清楚,整个模块的设计就顺了:交互层只负责"拿到一批文件",之后的校验、去重、排队、上传、进度回显、失败重试全部共用同一份代码。所谓"两套逻辑",绝大多数情况下是一开始没抽这一层,等两个入口都写完了、发现重复时已经改不动,只能各修各的,bug 也就开始分叉。
我在项目里习惯把职责切成三层:入口层(input 和 drop 两个事件源)、队列层(文件数组、状态机、并发控制)、传输层(XHR 或 fetch、分片、重试)。入口层薄到只有几十行,队列层是核心,传输层可以按项目需要换实现。
1.2 先定协议,再动手写交互
很多人拿到需求就开始写input.click(),写到一半发现后端接口只收单文件,于是又回来改成循环调用。我现在的顺序是先跟后端把这几件事敲定,再写一行前端代码:
| 需要确认的点 | 常见选项 | 对前端的影响 |
|---|---|---|
| 单次请求文件数 | 单文件 / 多文件 | 决定要不要做并发池 |
| 字段名 | file/files[]/upload | 决定 FormData 怎么 append |
| 是否分片 | 直传 / 分片 + 合并 | 决定要不要算 slice 和序号 |
| 幂等标识 | 无 / 文件哈希 / 前端生成 id | 决定重试和去重的实现方式 |
| 返回结构 | 直接返回 URL / 返回文件元信息 | 决定列表怎么渲染 |
| 错误形态 | HTTP 状态码 / 业务 code | 决定失败判断写在哪儿 |
这张表看着啰嗦,但它能省掉后面大量的返工。举个最常见的例子:如果接口是单文件上传,而你用的是Promise.all并发五个请求,服务端限流一开,五个里挂三个,用户看到的就是"传了但没完全传"。
1.3 组件对外应该暴露哪些状态
上传组件的复杂度不在代码量,而在状态多。我一般收敛成四个交互状态加五个文件状态。交互状态是idle、dragging、uploading、disabled;单个文件的状态是pending、uploading、success、error、canceled。界面上所有的视觉变化都由这两组状态推导出来,而不是散落在各个事件回调里手动改 class。
这样做的好处是排查问题时特别直观:拖拽高亮不消失,就去看dragging是不是卡在true;进度条不走,就去看这个文件项的状态是不是还停在pending。状态理顺了,第 5 节那些"玄学 bug"基本都能自己定位。
2. 点击上传:input[type=file] 那些文档不会细说的细节
2.1 把 input 藏起来的三种方式,代价各不相同
原生<input type="file">的样式在各个浏览器里差异很大,基本没人会直接用它的默认外观。常见做法有三种:
display: none隐藏,用外部按钮触发click()。这是最主流的方案,缺点是这个 input 完全脱离可访问性树,读屏软件和键盘 Tab 都摸不到它。opacity: 0覆盖在自定义按钮上方。input 仍然在 DOM 里、仍然可聚焦,点击事件直接落到它身上,不需要 JS 转发。缺点是它盖住了下面元素,层级和指针事件要小心处理。<label for="fileInput">包裹。点 label 等于点 input,不需要任何 JS,可访问性最好,也是我个人最推荐的写法。
<div class="uploader" id="uploader"> <input id="fileInput" type="file" multiple class="visually-hidden"> <label for="fileInput" class="pick-btn">选择文件</label> <p class="hint">也可以把文件拖到这里</p> </div>/* 保留在可访问性树中,但不占视觉空间 */ .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }注意visibility: hidden和display: none都不行,它们会让 input 无法通过 Tab 聚焦。上面这段clip方案是社区里用了很多年的经典写法,视觉上完全看不见,但键盘仍然能 Tab 到并回车触发。
2.2 连续选同一个文件不触发 change,原因在值没变
这是新人必踩的一个坑:用户先选了a.png,上传成功后想再传一次同一个文件,发现点了选择框、也点了确定,但什么都没发生。原因是input的value从上一次选择后就没变过,浏览器的判断是"值没变,不派发change"。
修法就一行,处理完立即清空:
fileInput.addEventListener('change', (e) => { const files = Array.from(e.target.files || []); handleFiles(files); // 关键:清空 value,让下一次选同一个文件也能触发 change e.target.value = ''; });注意:清空
value的同时,e.target.files也会被清空。所以一定要先把files转成数组存下来,再执行value = '',顺序反了就会拿到一个空数组,而且这个错误非常隐蔽,因为代码看起来完全正常。
2.3 accept、multiple、capture 三个属性的真实边界
accept是给文件选择框的过滤器,不是校验器。它只是让系统对话框默认只显示匹配的文件,用户在某些系统上仍然可以手动切换成"所有文件"然后选中一个.exe。所以accept只能提升体验,真正的类型判断必须在change回调里再用file.type和扩展名做一次。
| 场景 | 推荐写法 | 说明 |
|---|---|---|
| 只允许图片 | accept="image/*" | 部分安卓机对image/*支持不完整 |
| 指定几种扩展名 | accept=".pdf,.docx,.xlsx" | 比 MIME 更稳,建议和 MIME 混写 |
| 移动端调用后置摄像头 | accept="image/*" capture="environment" | 加了capture后部分机型不再给"从相册选"的入口 |
| 允许多选 | 加multiple | iOS Safari 老版本对多选支持很晚 |
capture这个属性有个副作用很多人不知道:一旦写上,部分安卓浏览器会直接拉起相机,用户想从相册里挑一张已经拍好的图反而找不到入口。所以如果你的场景是"上传头像",更稳妥的做法是不写capture,让系统自己弹选择菜单。
2.4 触发 click 的时机,比你想的更敏感
用 JS 触发fileInput.click()时,浏览器会检查这次调用是否处于"用户激活"(user activation)状态。写在按钮的同步click回调里没问题,但如果中间夹了await、setTimeout或者一个还没 resolve 的 Promise,Safari 和部分移动端浏览器就会直接忽略这次调用——表现就是按钮点了没反应,控制台也不报错。
// 这种写法在 Safari 上有概率失效 btn.addEventListener('click', async () => { const canUpload = await checkQuota(); if (canUpload) fileInput.click(); // 用户激活可能已经过期 }); // 更稳的做法:先弹选择框,在 change 里再做异步检查 btn.addEventListener('click', () => fileInput.click());如果业务上确实需要先做一次异步校验,那就改成"先选文件,选完再校验,不通过就提示并清空",用户体验上几乎没差别,但不会有兼容性问题。
3. 拖拽上传:从 dragenter 到 drop 的完整链路拆解
3.1 不调 preventDefault,就永远等不到 drop
HTML5 拖放的默认行为是"浏览器打开这个文件"。也就是说,如果你在dragover阶段不调用preventDefault(),浏览器会认为这个元素不接受拖放,drop事件根本不会触发,松手之后浏览器直接跳去打开那个文件,页面就没了。
必须成对处理的三个事件是dragenter、dragover、drop,全部要preventDefault()。其中dragover是高频触发的(鼠标每移动几像素就触发一次),绑定在这个事件上的回调一定要轻,别在里面做 DOM 查询或者重排。
['dragenter', 'dragover', 'drop'].forEach((type) => { uploader.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation(); }); });另外别忘了给window也挂一个兜底,否则用户手一抖把文件丢在拖拽区外面,整个页面就被替换成那张图片了,用户只能按返回键,体验极差。
// 全局兜底,防止误拖到页面其他位置导致跳转 ['dragover', 'drop'].forEach((type) => { window.addEventListener(type, (e) => e.preventDefault()); });3.2 dragenter 和 dragleave 会冒泡,所以要用计数法
拖拽高亮闪烁是拖拽上传里最经典的 bug。根因是dragleave会在鼠标从父元素移到子元素时也触发——你从拖拽区移到里面那个"选择文件"的文字上,浏览器认为你离开了拖拽区,于是去掉高亮;再移回来,又加上高亮。鼠标稍微抖一抖,高亮就开始闪。
单纯判断e.target === uploader解决不了,因为子元素上的事件会冒泡。可靠的方案是维护一个进入计数器:
let dragDepth = 0; uploader.addEventListener('dragenter', (e) => { e.preventDefault(); dragDepth += 1; uploader.classList.add('is-dragover'); }); uploader.addEventListener('dragleave', (e) => { e.preventDefault(); dragDepth -= 1; if (dragDepth <= 0) { dragDepth = 0; uploader.classList.remove('is-dragover'); } }); uploader.addEventListener('drop', (e) => { dragDepth = 0; uploader.classList.remove('is-dragover'); // ...取文件 }); // 保险措施:拖拽结束或窗口失焦时强制复位 window.addEventListener('dragend', () => { dragDepth = 0; uploader.classList.remove('is-dragover'); });dragend和blur这两个兜底很关键。用户拖着文件、中途按了 Esc 或者切了窗口,dragleave不一定能正常触发,计数器就会一直停在正数,高亮永远不消失。
3.3 DataTransfer 里到底装了什么,什么时候能读
DataTransfer这个对象有个很少被提到的限制:在dragover阶段读dataTransfer.files通常是空的,出于安全考虑,浏览器只在drop时才会把真实的文件列表放进去。所以在dragover里判断"拖的是不是文件"不能靠files,要靠dataTransfer.types:
uploader.addEventListener('dragover', (e) => { e.preventDefault(); // types 里包含 'Files' 说明拖的是文件而不是选中的文字 if (Array.from(e.dataTransfer.types).includes('Files')) { uploader.classList.add('is-dragover'); } });如果你做的是"拖进来一段文字就填到输入框"之类的功能,也是在drop里用e.dataTransfer.getData('text/plain')取,注意读getData同样只能在drop阶段。
| 事件 | 触发时机 | 必须做的处理 |
|---|---|---|
dragenter | 文件进入元素边界 | preventDefault,计数 +1 |
dragover | 在元素上持续移动 | preventDefault(否则 drop 不触发) |
dragleave | 文件离开元素边界 | preventDefault,计数 -1 |
drop | 松手释放 | preventDefault,读dataTransfer.files |
3.4 拖拽文件夹:webkitGetAsEntry 和 readEntries 的 100 条上限
如果用户拖进来的是一个文件夹,dataTransfer.files里拿到的往往是一个大小为 0、类型为空的空壳,直接用会传上去一个坏文件。要真正读出目录内容,得用webkitGetAsEntry()配合递归:
async function readEntry(entry, out) { if (entry.isFile) { const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); out.push(file); return; } if (entry.isDirectory) { const reader = entry.createReader(); // readEntries 一次最多返回 100 条,必须循环读到空 let batch; do { batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); for (const child of batch) { await readEntry(child, out); } } while (batch.length > 0); } } // 在 drop 里 const entries = Array.from(e.dataTransfer.items) .map((item) => item.webkitGetAsEntry()) .filter(Boolean); const collected = []; for (const entry of entries) { await readEntry(entry, collected); }
readEntries一次最多返回 100 个条目,这是规范里的行为,不是 bug。我见过有项目直接调一次就当成拿全了,测试时用了 30 个文件的小目录一直没发现问题,上线后用户拖了一个 300 张图的素材文件夹,只传上去 100 张,排查了半天。
目录拖拽还有个额外成本:你得自己控制递归深度和文件总数上限,不然用户拖一个几万文件的node_modules进来,浏览器会直接卡死。
4. 把两种入口收敛成一条上传流水线
4.1 FileList 到 File 数组的归一化
两个入口给的都是FileList,而FileList是个类数组对象,没有map、filter、forEach这些数组方法(老浏览器连Array.from支持都不全)。第一件事永远是归一化:
function toArray(fileList) { return Array.prototype.slice.call(fileList || []); }去重键的选择也有讲究。用name去重太粗暴,两个不同目录下的同名文件会被误杀;用name + size好一些,但同一张图被压缩后重新导出还是可能撞车。我一般用name + size + lastModified三个拼起来当 key,误判率极低:
function fileKey(file) { return `${file.name}::${file.size}::${file.lastModified}`; }4.2 校验规则怎么写才既严格又不误伤
校验要分成"必须挡住的"和"提醒一下的"。大小、数量、扩展名这三项适合硬拦;MIME 类型适合提醒。因为file.type在很多情况下是不可靠的——.md、.log、.conf这类文件在部分系统上type直接是空字符串,如果你用"type 必须在白名单里"来卡,这些文件会全部被拒。
const MAX_SIZE = 20 * 1024 * 1024; const MAX_COUNT = 20; const ALLOW_EXT = ['jpg', 'jpeg', 'png', 'webp', 'pdf', 'zip']; function extOf(name) { const idx = name.lastIndexOf('.'); return idx === -1 ? '' : name.slice(idx + 1).toLowerCase(); } function validate(file, picked) { const ext = extOf(file.name); if (!ext) return '文件缺少扩展名'; if (!ALLOW_EXT.includes(ext)) return `不支持的格式:.${ext}`; if (file.size === 0) return '文件内容为空'; if (file.size > MAX_SIZE) return `超过 ${MAX_SIZE / 1024 / 1024}MB 限制`; if (picked.has(fileKey(file))) return '该文件已在列表中'; picked.add(fileKey(file)); return null; }一个实操经验:扩展名判断要区分大小写吗?我建议统一
toLowerCase(),因为用户在 Windows 上导出的图片经常是.JPG。但要注意双扩展名的情况,report.pdf.exe这种取最后一个点才是对的,取第一个点会把.pdf.exe当成
picked这个 Set 要跟着文件列表一起维护,用户删除某个文件时同步delete,否则会出现"删了再传提示重复"的怪现象。
4.3 并发控制与进度回显:为什么用 XHR 而不是 fetch
上传进度这件事上,fetch目前仍然没有标准的可读上传进度(ReadableStream传 body 的方案各家实现不一致,移动端基本不可用)。所以做带进度的上传,老老实实用XMLHttpRequest:
function uploadFile(file, { url, field = 'file', onProgress, signal }) { return new Promise((resolve, reject) => { const form = new FormData(); form.append(field, file, file.name); const xhr = new XMLHttpRequest(); xhr.open('POST', url, true); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { onProgress(Math.round((e.loaded / e.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch (err) { reject(new Error('返回内容不是合法 JSON')); } } else { reject(new Error(`服务端返回 ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('网络异常')); xhr.ontimeout = () => reject(new Error('请求超时')); if (signal) { signal.addEventListener('abort', () => xhr.abort()); } xhr.send(form); }); }并发池直接写一个最简版本就够了,别上什么三方库:
function createPool(limit = 3) { const queue = []; let active = 0; const next = () => { if (active >= limit || queue.length === 0) return; active += 1; const { task, resolve, reject } = queue.shift(); task() .then(resolve, reject) .finally(() => { active -= 1; next(); }); }; return (task) => new Promise((resolve, reject) => { queue.push({ task, resolve, reject }); next(); }); } const run = createPool(3); // 使用:所有文件先进队列 files.forEach((file) => { run(() => uploadFile(file, { url: '/api/upload', onProgress: (p) => updateProgress(file, p), })) .then((res) => markSuccess(file, res)) .catch((err) => markError(file, err.message)); });并发数设多少?我一般给 3,最多不超过 6。原因有两条:浏览器对同域名 HTTP/1.1 的连接数限制默认就是 6,超出去的请求还是排队,只是排队的位置从你的代码挪到了浏览器,反而不便于你控制重试;另外服务端通常有单 IP 并发限制,设太高容易被限流甚至被判定为异常流量。
一个容易忽略的点:进度条涨到 100% 不等于上传成功。XHR 的
upload.onprogress走完只代表请求体发完了,服务端处理、回写响应还要时间。所以上传状态应该拆成"传输中"和"服务端处理中"两段,或者干脆在进度到 100% 后显示一个不确定态的加载动画,别让用户盯着一个停在 100% 的进度条等三秒。
4.4 分片上传的取舍:什么时候值得做
分片不是默认选项,它带来的是实打实的复杂度:切片、编号、并发序、合并请求、断点续传的记录、失败重传的粒度。我判断的标准很朴素——单文件超过 50MB 且有明确的失败重传需求,才值得上分片。
const CHUNK_SIZE = 5 * 1024 * 1024; function sliceFile(file) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + CHUNK_SIZE, file.size); chunks.push({ index: chunks.length, blob: file.slice(start, end) }); start = end; } return chunks; }分片大小选 5MB 是个比较平衡的值:太小会导致请求数暴涨,一个 1GB 的文件按 1MB 切就是 1024 个请求,服务端光建临时文件就够呛;太大则单个分片失败重传的成本高。切完之后并发上传这些分片,全部成功后调一次合并接口,合并时把文件的size和分片数一起传给服务端做一次校验,防止漏片。
5. 排查实录:拖拽上传里最容易翻车的四个现场
5.1 拖拽高亮疯狂闪烁
现象是鼠标在拖拽区里移动时,边框和背景色以肉眼可见的频率闪。排查顺序是这样的:先看有没有在dragover里切换 class——dragover高频触发,如果在里面同时 add 和 remove,就会闪;再看dragleave是不是被子元素触发。第 3.2 节的计数法是标准解法,但还有一个补充技巧:给拖拽区的子元素加pointer-events: none,让子元素完全不吃鼠标事件,dragleave就只会来自父元素本身。
.is-dragover .inner-hint { pointer-events: none; }不过这个方案要慎用,如果子元素里有"点击移除文件"这类按钮,pointer-events: none会把按钮也一起废掉。更保险的还是计数法。
5.2 松手后浏览器把文件当页面打开了
这个现场问题很好认:页面整个被替换成用户拖进来的那张图或者 PDF。原因就是drop事件没有preventDefault,或者 preventDefault 挂错了元素。常见的挂错有两种:一是只挂在了拖拽区上,用户丢在了拖拽区外;二是拖拽区被某个绝对定位的遮罩盖住,事件实际落在遮罩上。
排查时可以在window上加一段临时监听,把事件路径打出来,一眼就能看出事件到底落在哪个元素上:
window.addEventListener('dragover', (e) => { console.log('dragover target:', e.target, 'defaultPrevented:', e.defaultPrevented); });如果defaultPrevented一直是false,说明你的preventDefault没生效在正确的节点上。
5.3 拖拽结束后 is-dragover 类名卡住不消失
用户拖着文件从窗口外进来、按 Esc 取消,或者切到别的应用再切回来,高亮就一直挂着。根因是计数器的归零依赖dragleave,而这些场景下dragleave未必触发。修法是在三处做强制复位:drop里、dragend里、以及窗口失焦时。
['drop', 'dragend'].forEach((type) => { window.addEventListener(type, resetDragState); }); window.addEventListener('blur', resetDragState); document.addEventListener('visibilitychange', () => { if (document.hidden) resetDragState(); }); function resetDragState() { dragDepth = 0; uploader.classList.remove('is-dragover'); }我在一个项目里遇到过一个更刁钻的变体:拖拽区在弹窗里,用户拖文件进来后高亮出现了,然后手滑松在弹窗外面。因为弹窗关闭时把 DOM 整个卸载了,
drop的监听随之消失,全局兜底又把事件 preventDefault 掉了,结果既没上传也没报错,用户以为传上去了。后来我在弹窗关闭前主动调了一次resetDragState,并且给弹窗加了"拖拽中禁止点击遮罩关闭"的限制。
5.4 点击上传的小毛病:页面刷新与移动端相机
点按钮选文件时页面整个刷新,九成是因为按钮在<form>里且没写type,浏览器把它的默认类型当成了submit。修法很直接,给所有非提交按钮显式写type="button"。
<form id="uploadForm"> <button type="button" class="pick-btn">选择文件</button> <!-- 省略其他字段 --> </form>移动端的问题不太一样。部分安卓机型在accept="image/*" capture="environment"下会直接拉起相机,用户想从相册选就没办法;而 iOS 上如果不写capture,有时候又会弹出一个"照片图库 / 拍照 / 浏览文件"的三选菜单,看起来啰嗦但其实是好事。我的经验是:面向 C 端用户的场景一律不写capture,让系统自己决定;只有明确"必须现拍"的场景(比如打卡、身份核验)才加,并且要在界面文案上写清楚会打开相机。
6. 上传模块的安全底线与可访问性补丁
6.1 前端校验只是体验,不是防线
这句话我说过很多遍但还是得重复:前端的大小、类型、数量校验,唯一的价值是让用户在本地立刻知道"这个文件不行",从而节省一次网络往返。任何人打开开发者工具都能绕过它,所以服务端必须再做一遍完整的校验,而且这一遍才是真的。
服务端至少要做到几点:校验文件大小上限;校验扩展名和真实内容类型是否一致(不能只看请求头里的Content-Type);限制单次请求的文件数量;文件不以用户提交的原始文件名落盘,而是生成一个随机名,原始名只存进数据库用于展示;存储目录和静态资源目录分开,避免被直接访问执行。这几条是通用的工程实践,跟用什么语言无关。
前端这边能做的配合是:把校验规则和错误提示做得清楚,让用户知道"为什么不行""限制是多少",比如错误提示写成"图片需小于 20MB,当前 32MB",比一句"文件不合法"有用得多。
6.2 文件名回显里的 XSS 隐患
文件名是用户可控的内容,而很多上传列表是这样渲染的:
// 危险写法 item.innerHTML = `<span class="name">${file.name}</span>`;如果用户把文件命名成一段带标签的字符串,这段内容就会被当成 HTML 解析。正确的做法是永远用textContent,或者用模板引擎/框架的自动转义:
const span = document.createElement('span'); span.className = 'name'; span.textContent = file.name; span.title = file.name; item.appendChild(span);如果确实需要拼接 HTML 字符串,那就必须手写转义:
function escapeHtml(str) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return String(str).replace(/[&<>"']/g, (c) => map[c]); }顺带提醒一个容易忽略的地方:文件名在下载链接的
href或者download属性里也会用到,拼接 URL 时同样的转义逻辑必须再走一遍。我见过一个项目把download属性直接用文件名拼接,文件名里带引号就把属性截断了,加上onclick就能在用户点击下载时执行任意代码。文件名这类用户可控内容,在任何进入 DOM 的位置都要当成不可信数据处理。
6.3 键盘操作与读屏软件
上传区如果只支持鼠标拖拽,键盘用户的体验就是一片空白。补丁其实很小:
- 拖拽区容器加
tabindex="0"和role="button",让它可以被 Tab 聚焦; - 监听
keydown,当按下的键是 Enter 或空格时,触发fileInput.click(); - 高亮用的类名不要只靠颜色区分,同时用边框粗细变化,照顾色觉障碍用户;
- 上传结果的提示(成功、失败、重复)放在
aria-live="polite"的区域里,让读屏软件能播报。
<div class="uploader" id="uploader" tabindex="0" role="button" aria-label="选择或拖拽文件上传"> <!-- ... --> </div> <div class="upload-feedback" aria-live="polite"></div>uploader.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } });这些都不是必须品,但在做面向企业客户或者政务场景的项目时,可访问性检查往往是验收项之一,早做比返工便宜得多。
最后再分享一个我自己一直在用的习惯:上传模块写完后的第一件事,不是跑正常流程,而是造几个"脏数据"来测——一个 0 字节的空文件、一个.JPG大写的图片、两个同名不同内容的文件、一个中文名带空格的文件、一个 200MB 的大文件,再故意在拖拽中途按 Esc。这五六个用例跑一遍,第 5 节里说的坑基本全能提前暴露出来,比上线后被用户投诉再回来查要省事太多。