ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端点击与拖拽上传:从 File API 到统一上传流水线

前端点击与拖拽上传:从 File API 到统一上传流水线 上周帮朋友看一个后台系统的上传模块需求描述只有一句话能点按钮选文件也能把文件拖进去。结果我在他们仓库里翻出三套几乎不相干的上传逻辑——PC 端一套、移动端一套、拖拽单独一套三套的校验规则还各写各的最后出现同一个文件点选能过、拖进去被拒这种让人哭笑不得的问题。**前端文件上传点击拖拽**这件事看起来是交互层的活真正做起来会发现它牵扯到浏览器事件模型、File API、网络层进度回传、甚至安全边界的划定。这篇就把我在实际项目里踩过的、带新人时反复讲的这套东西整理出来。内容覆盖点击选择和拖拽投放两个入口的底层差异、怎么把它们收敛到同一条上传流水线、大文件该怎么做取舍、以及几个只在真机和老浏览器上才会暴露的坑。适合已经能写 CRUD 页面、但还没系统做过上传模块的前端同学如果你只想抄一段能跑的代码第 2 到第 4 节的代码块可以直接拿走用但我强烈建议把第 5 节的排查过程也读一遍那些问题基本是必踩的早点知道能省下大半天调试时间。1. 点击上传和拖拽上传到底是不是两套代码1.1 交互路径不同拿到的却是同一种东西点击上传走的是input typefile浏览器弹出系统文件选择框用户选完之后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[typefile] 那些文档不会细说的细节2.1 把 input 藏起来的三种方式代价各不相同原生input typefile的样式在各个浏览器里差异很大基本没人会直接用它的默认外观。常见做法有三种display: none隐藏用外部按钮触发click()。这是最主流的方案缺点是这个 input 完全脱离可访问性树读屏软件和键盘 Tab 都摸不到它。opacity: 0覆盖在自定义按钮上方。input 仍然在 DOM 里、仍然可聚焦点击事件直接落到它身上不需要 JS 转发。缺点是它盖住了下面元素层级和指针事件要小心处理。label forfileInput包裹。点 label 等于点 input不需要任何 JS可访问性最好也是我个人最推荐的写法。div classuploader iduploader input idfileInput typefile multiple classvisually-hidden label forfileInput classpick-btn选择文件/label p classhint也可以把文件拖到这里/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和扩展名做一次。场景推荐写法说明只允许图片acceptimage/*部分安卓机对image/*支持不完整指定几种扩展名accept.pdf,.docx,.xlsx比 MIME 更稳建议和 MIME 混写移动端调用后置摄像头acceptimage/* captureenvironment加了capture后部分机型不再给从相册选的入口允许多选加multipleiOS Safari 老版本对多选支持很晚capture这个属性有个副作用很多人不知道一旦写上部分安卓浏览器会直接拉起相机用户想从相册里挑一张已经拍好的图反而找不到入口。所以如果你的场景是上传头像更稳妥的做法是不写capture让系统自己弹选择菜单。2.4 触发 click 的时机比你想的更敏感用 JS 触发fileInput.click()时浏览器会检查这次调用是否处于用户激活user activation状态。写在按钮的同步click回调里没问题但如果中间夹了await、setTimeout或者一个还没 resolve 的 PromiseSafari 和部分移动端浏览器就会直接忽略这次调用——表现就是按钮点了没反应控制台也不报错。// 这种写法在 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就永远等不到 dropHTML5 拖放的默认行为是浏览器打开这个文件。也就是说如果你在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.typesuploader.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计数 1dragover在元素上持续移动preventDefault否则 drop 不触发dragleave文件离开元素边界preventDefault计数 -1drop松手释放preventDefault读dataTransfer.files3.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当成.pdf放过去。picked这个 Set 要跟着文件列表一起维护用户删除某个文件时同步delete否则会出现删了再传提示重复的怪现象。4.3 并发控制与进度回显为什么用 XHR 而不是 fetch上传进度这件事上fetch目前仍然没有标准的可读上传进度ReadableStream传 body 的方案各家实现不一致移动端基本不可用。所以做带进度的上传老老实实用XMLHttpRequestfunction 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。修法很直接给所有非提交按钮显式写typebutton。form iduploadForm button typebutton classpick-btn选择文件/button !-- 省略其他字段 -- /form移动端的问题不太一样。部分安卓机型在acceptimage/* captureenvironment下会直接拉起相机用户想从相册选就没办法而 iOS 上如果不写capture有时候又会弹出一个照片图库 / 拍照 / 浏览文件的三选菜单看起来啰嗦但其实是好事。我的经验是面向 C 端用户的场景一律不写capture让系统自己决定只有明确必须现拍的场景比如打卡、身份核验才加并且要在界面文案上写清楚会打开相机。6. 上传模块的安全底线与可访问性补丁6.1 前端校验只是体验不是防线这句话我说过很多遍但还是得重复前端的大小、类型、数量校验唯一的价值是让用户在本地立刻知道这个文件不行从而节省一次网络往返。任何人打开开发者工具都能绕过它所以服务端必须再做一遍完整的校验而且这一遍才是真的。服务端至少要做到几点校验文件大小上限校验扩展名和真实内容类型是否一致不能只看请求头里的Content-Type限制单次请求的文件数量文件不以用户提交的原始文件名落盘而是生成一个随机名原始名只存进数据库用于展示存储目录和静态资源目录分开避免被直接访问执行。这几条是通用的工程实践跟用什么语言无关。前端这边能做的配合是把校验规则和错误提示做得清楚让用户知道为什么不行限制是多少比如错误提示写成图片需小于 20MB当前 32MB比一句文件不合法有用得多。6.2 文件名回显里的 XSS 隐患文件名是用户可控的内容而很多上传列表是这样渲染的// 危险写法 item.innerHTML span classname${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 { : amp;, : lt;, : gt;, : quot;, : #39; }; return String(str).replace(/[]/g, (c) map[c]); }顺带提醒一个容易忽略的地方文件名在下载链接的href或者download属性里也会用到拼接 URL 时同样的转义逻辑必须再走一遍。我见过一个项目把download属性直接用文件名拼接文件名里带引号就把属性截断了加上onclick就能在用户点击下载时执行任意代码。文件名这类用户可控内容在任何进入 DOM 的位置都要当成不可信数据处理。6.3 键盘操作与读屏软件上传区如果只支持鼠标拖拽键盘用户的体验就是一片空白。补丁其实很小拖拽区容器加tabindex0和rolebutton让它可以被 Tab 聚焦监听keydown当按下的键是 Enter 或空格时触发fileInput.click()高亮用的类名不要只靠颜色区分同时用边框粗细变化照顾色觉障碍用户上传结果的提示成功、失败、重复放在aria-livepolite的区域里让读屏软件能播报。div classuploader iduploader tabindex0 rolebutton aria-label选择或拖拽文件上传 !-- ... -- /div div classupload-feedback aria-livepolite/divuploader.addEventListener(keydown, (e) { if (e.key Enter || e.key ) { e.preventDefault(); fileInput.click(); } });这些都不是必须品但在做面向企业客户或者政务场景的项目时可访问性检查往往是验收项之一早做比返工便宜得多。最后再分享一个我自己一直在用的习惯上传模块写完后的第一件事不是跑正常流程而是造几个脏数据来测——一个 0 字节的空文件、一个.JPG大写的图片、两个同名不同内容的文件、一个中文名带空格的文件、一个 200MB 的大文件再故意在拖拽中途按 Esc。这五六个用例跑一遍第 5 节里说的坑基本全能提前暴露出来比上线后被用户投诉再回来查要省事太多。
返回列表