ARTICLE DETAIL

资讯详情

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

HTML文件上传accept详解:类型限制、MIME、移动端与校验

HTML文件上传accept详解:类型限制、MIME、移动端与校验 上周线上出了个小插曲运营同事在后台传资质文件产品要求只允许图片前端老老实实写了acceptimage/*结果测试同学点开文件选择器把筛选器切成所有文件选了个.txt传上去后端接口直接 500。这事不怪测试也不怪后端——问题出在很多人对accept的期待值过高以为它是道门禁其实它只是文件选择器上的一个预设筛选条件。input typefile是 HTML 里最古老也最容易被低估的表单控件之一而accept属性几乎是每个做上传功能的人都会碰、但很少真正吃透的东西。它管的是打开文件对话框时默认给用户看什么类型的文件写对了能让用户少点两次筛选、少传错一次文件写错了轻则用户体验打折重则像上面那样把脏数据放进系统。这篇内容我按实战视角把accept的取值体系、格式限制写法、图片限制写法、移动端差异、以及 accept 之外必须补的校验全部串一遍不管你是在写后台管理系统、H5 活动页还是小程序 WebView都能直接抄走。1. accept 的真实作用边界它是过滤器不是守门员很多人第一次接触accept都是在限制只能上传图片这个需求里写完发现好像也没完全限制住于是开始怀疑自己写错了。其实不是写法问题而是定位问题。先把它的职责说清楚后面的所有写法才有意义。1.1 浏览器打开文件对话框时到底发生了什么当你点击一个带accept的 file input浏览器做的是这样一件事把accept的值解析成一份类型描述列表然后交给操作系统原生的文件选择对话框作为默认的筛选条件传进去。注意关键词是默认的筛选条件不是唯一可选的文件。在 Windows 上这个筛选条件会体现为对话框右下角那个下拉框通常显示成自定义文件或Custom Files在 macOS 上则表现为 Finder 里灰色不可选的非匹配文件移动端 iOS 和 Android 则表现为相册、拍照、文件三个入口的可见性变化。也就是说accept影响的是用户在对话框里第一眼能看到什么它是一个 UI 层面的便利设施。这也解释了一个常见困惑为什么不同同事、不同电脑上同一个accept的表现不一样因为中间还隔着操作系统对扩展名和 MIME 的注册表映射浏览器只是把清单递过去而已。1.2 为什么用户依然能选中被限制的文件三种最常见的绕过路径你大概率都遇到过切换筛选器。桌面端 Chrome、Edge 的文件对话框里那个筛选下拉框通常保留了所有文件 (*.*)选项用户切过去之后什么都能选。这个行为不是 bug是浏览器刻意保留的逃生通道。拖拽上传。用户把文件从桌面直接拖到页面上这条路径完全不经过文件对话框accept在这里没有任何约束力。粘贴上传。从截图工具直接 CtrlV 粘贴一张图或者从资源管理器复制文件再粘贴同理。还有一个更隐蔽的情况某些安卓机的文件管理器在返回结果时会忽略筛选条件把用户手动输入路径选中的文件直接返回。所以只要你的业务对文件类型有硬要求就不能把宝押在accept上。1.3 一条铁律accept 只负责体验校验必须落在服务端我给自己定的规矩很简单前端所有和文件类型相关的判断目标只有一个——让正常用户的正常操作更顺真正的安全边界永远在服务端。原因也很直白任何前端校验都能被绕过。用户改个文件名、用命令行直接发请求、用 Postman 构造一个 multipart 请求accept连被触发的机会都没有。所以正确的分工是前端用accept收窄选择范围用 File API 做即时反馈让用户在选完文件的一瞬间就知道这个不行。服务端用扩展名白名单、MIME 白名单、文件头魔数三重校验做兜底任何一条不过就拒绝。存储时用自己生成的随机文件名保留原始文件名仅作为展示字段绝不参与路径拼接。提示accept写成什么样都不能成为你在服务端省掉校验的理由。这条如果踩过一次基本就记住了。2. accept 的合法取值只有三类扩展名、MIME、通配符搞清楚了定位接下来是写法。HTML 规范里accept的取值只有三种合法形态用逗号分隔可以混用但每一类都有自己的脾气。2.1 扩展名写法以点开头大小写不敏感扩展名写法长这样input typefile accept.png,.jpg,.jpeg,.gif,.webp规则有两条必须记住第一必须以英文句点开头。写成png或者*.png都是错的浏览器会直接忽略这个无效项有些浏览器会把整个 accept 都当成无效。*.png这种通配符写法只在某些老旧的 Flash 上传组件里见过标准 HTML 里不认。第二用逗号分隔逗号后面的空格会被自动忽略所以.png, .jpg和.png,.jpg是等价的。为了可读性我一般会留一个空格但这纯属个人习惯。关于大小写规范里扩展名的匹配是大小写不敏感的主流浏览器的实现也遵循这一点.JPG和.jpg在筛选时效果一样。但如果这个值同时还要参与你前端 JS 的校验逻辑记得在 JS 里统一toLowerCase()再比对别因为一个用户把照片命名成IMG_0001.JPG就被拒之门外。扩展名写法最大的价值在于MIME 不可靠的场景。比如.dwgCAD 图纸、.csv、.md这些格式在 Windows 上不同软件注册出来的 MIME 千奇百怪用 MIME 过滤经常过滤不出来改用扩展名反而更准。2.2 MIME 类型写法别写 image/jpg 这种不存在的类型MIME 写法长这样input typefile acceptimage/jpeg,image/png,application/pdf这里有几个高频错误我踩过至少两个错误一把image/jpg当成合法类型。不存在的JPEG 图片的正确 MIME 是image/jpeg扩展名才是.jpg。写了image/jpg之后Chrome 会静默忽略这一项结果就是用户选 jpg 的时候发现文件是灰的。错误二给 MIME 加参数。比如text/plain;charsetutf-8这种带参数的形式不属于合法的 accept 值浏览器解析时会出问题。accept 里只写主类型和子类型参数一律不写。错误三大小写混着来。MIME 类型本身是大小写不敏感的但规范建议全小写写Image/PNG虽然理论上能匹配但没必要给自己找麻烦。常用 MIME 速查这几个我基本是肌肉记忆了格式正确 MIMEJPG / JPEGimage/jpegPNGimage/pngGIFimage/gifWebPimage/webpSVGimage/svgxmlBMPimage/bmpPDFapplication/pdfTXTtext/plainJSONapplication/jsonMP3audio/mpegWAVaudio/wavMP4video/mp42.3 通配符 image/*、audio/*、video/* 的边界在哪通配符只有三个image/*、audio/*、video/*。注意只有这三种不存在text/*、application/*这种写法写了也不生效。!-- 允许任意图片格式 -- input typefile acceptimage/* !-- 允许图片和视频常用于短视频投稿 -- input typefile acceptimage/*,video/*image/*的覆盖范围比很多人想象的要大它包含 jpg、png、gif、webp、bmp、svg、avif、heic 等等所有系统登记为图片的类型。所以这里有个安全细节值得单独拎出来说——SVG 也算image/*。SVG 本质上是 XML里面可以内嵌script。如果你把用户上传的 SVG 用它原始内容注入到页面上比如innerHTML或者作为 HTML 片段渲染那就是一个标准的 XSS 入口。我的处理方式是如果业务只需要展示照片就别用image/*直接枚举光栅格式input typefile acceptimage/jpeg,image/png,image/webp,image/gif反过来如果确实要支持矢量图上传那在预览环节也只用img srcblob:...渲染绝不把 SVG 内容当 HTML 插入。2.4 多个取值的组合规则是或关系不是且这是最容易被误读的一条。accept.png,image/jpeg的含义是扩展名是 .png或者MIME 是 image/jpeg而不是两者都要满足。所以下面这种写法是纯粹的多余!-- 冗余两者是并集关系.png 那一项单独就能生效 -- input typefile acceptimage/png,.png真正要注意的是另一种情况如果你同时写了image/*和具体的扩展名由于image/*已经覆盖了大部分图片扩展名后面的项就成了摆设。混写的意义在于补漏——比如某些环境下 MIME 识别不准你补上扩展名能提升筛选的成功率。还有一种写法是完全不写 accept那就等于告诉浏览器什么都能选筛选器会显示所有文件。3. 按业务场景整理的可直接复制的 accept 对照表理论讲完上干货。下面这些写法我在不同项目里都用过可以直接复制走唯一需要提醒的是桌面端和移动端的策略要做区分原因在第五章展开。3.1 图片场景头像、身份证、商品图!-- 头像只收常见光栅图排除 SVG -- input typefile acceptimage/jpeg,image/png,image/webp !-- 身份证/证件照同上一般还需要 capture 引导拍照 -- input typefile acceptimage/* captureenvironment !-- 商品图多图上传兼容性优先 -- input typefile acceptimage/* multiple头像场景我的习惯是明确枚举格式而不是用image/*。因为用户上传头像几乎不会用到 SVG 和 HEIC枚举能顺便把风险格式挡在外面同时在服务端校验时也更容易做到前后端白名单一致——两边白名单不一致是上传功能里最常见的低级 bug。证件照场景则相反移动端优先用image/*保证 iOS 的 HEIC 照片能被看到第五章细说配合captureenvironment引导用户直接开后置摄像头。3.2 文档场景PDF、Word、Excel、PPT 的完整 MIME这块是重灾区因为 Office 的 MIME 又长又容易写错我把完整版整理如下文件扩展名写法MIME 写法旧版 Word.docapplication/msword新版 Word.docxapplication/vnd.openxmlformats-officedocument.wordprocessingml.document旧版 Excel.xlsapplication/vnd.ms-excel新版 Excel.xlsxapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet旧版 PPT.pptapplication/vnd.ms-powerpoint新版 PPT.pptxapplication/vnd.openxmlformats-officedocument.presentationml.presentationPDF.pdfapplication/pdfCSV.csvtext/csv不稳定见下写这么一长串 MIME 显然不现实实际项目里我基本都用扩展名input typefile accept.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx原因有两个一是可读性好维护的人一眼就知道收什么二是 MIME 在这些格式上不够可靠——尤其是 CSVWindows 上装了 Office 的机器经常把.csv报成application/vnd.ms-excel跟.xls一模一样你压根分不出来。注意.docx、.xlsx、.pptx本质上都是 ZIP 包它们的文件头魔数都是50 4B 03 04。这意味着靠文件头区分这三种格式是做不到的必须结合扩展名一起判断。3.3 音视频与压缩包!-- 音频投稿 -- input typefile acceptaudio/* !-- 视频投稿 -- input typefile acceptvideo/* !-- 压缩包扩展名更可靠 -- input typefile accept.zip,.rar,.7z音视频用通配符是合理的因为这两种格式家族庞大mp3、wav、flac、aac、ogg、m4a……枚举容易漏。压缩包则必须用扩展名因为.zip的 MIME 在不同系统上有application/zip、application/x-zip-compressed、application/x-zip至少三种写法.rar也有新旧两套用 MIME 过滤基本是自找麻烦。3.4 一张表兜住 90% 场景业务需求推荐 accept 写法只收 JPG/PNG 头像image/jpeg,image/png收支付宝/微信收款码截图image/*收 PDF 合同.pdf或application/pdf收 Office 文档.doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf收短音频audio/*或audio/mpeg,audio/wav收视频video/*或video/mp4,video/webm收压缩包.zip,.rar,.7z收矢量设计稿.svg,.ai,.eps收数据文件.csv,.xlsx,.json4. accept 之外的三道校验前端还得自己把门前面说了 accept 拦不住东西那前端到底应该怎么补我一般分三道按成本从低到高排越靠后的越少用但关键业务一个都不能少。4.1 第一道File API 校验 MIME 与扩展名用户选完文件input.files里拿到的是File对象它有name、size、type三个属性。第一道校验就是拿这三个值比对白名单const ALLOWED_MIME [image/jpeg, image/png, image/webp]; const ALLOWED_EXT [.jpg, .jpeg, .png, .webp]; const MAX_SIZE 5 * 1024 * 1024; function validate(file) { const ext . file.name.split(.).pop().toLowerCase(); if (!ALLOWED_EXT.includes(ext)) { return 不支持的文件格式${ext}; } if (file.type !ALLOWED_MIME.includes(file.type)) { return 文件类型不匹配${file.type}; } if (file.size MAX_SIZE) { return 文件不能超过 ${MAX_SIZE / 1024 / 1024} MB; } return null; }这里有两个细节值得说。一是file.type可能为空字符串。某些系统在读取不到扩展名映射时会返回空这时候不能直接判它不合法应该以扩展名校验为准放行后再交给服务端。二是这里用的是扩展名和 MIME 都要过的逻辑跟 accept 的或关系不同。这是刻意的前端这一层要严一点因为用户没有任何理由传一个扩展名和 MIME 打架的文件除非它在有意绕过。4.2 第二道读文件头魔数识别改过后缀的文件扩展名和 MIME 都能伪造改个名字、改个注册表就行。真正能识别文件本质的是文件头也就是常说的魔数。const MAGIC { image/jpeg: [0xFF, 0xD8, 0xFF], image/png: [0x89, 0x50, 0x4E, 0x47], image/gif: [0x47, 0x49, 0x46, 0x38], application/pdf: [0x25, 0x50, 0x44, 0x46], application/zip: [0x50, 0x4B, 0x03, 0x04] }; async function sniff(file, expect) { const buf new Uint8Array(await file.slice(0, 4).arrayBuffer()); const sig MAGIC[expect]; if (!sig) return true; return sig.every((b, i) buf[i] b); }这段代码只读文件的前 4 个字节开销极小即使用在几百 KB 的文件上也感觉不到。它主要防的是用户把一个 exe 改成 .png 传上来这类情况。但要清楚它的局限只能识别固定头部的格式WebP 的头部在偏移 8 字节处RIFF....WEBPMP4 的ftyp在第 4 个字节起需要单独处理。而且魔数匹配也只是启发式判断真正的安全判断必须在服务端做。4.3 第三道体积、数量、重名与时序控制这三样经常被归到上传逻辑里但它们和格式限制是同一类问题——都属于用户能直接绕过前端的部分所以前端做了服务端也要做。体积前端限制的意义是省流量、快反馈服务端限制的意义是防攻击。两边数值最好一致前端可以稍微宽松一点点比如前端限 5MB、服务端限 6MB避免因编码差异导致的误拒。数量multiple状态下用户一次可以选几十个文件。我的做法是在change事件里检查input.files.length超了就提示并且只取前 N 个而不是整批拒绝——后者会让用户很烦躁。重名同一个文件选两次或者多个文件同名前端展示时要加序号或时间戳区分否则用户看到两个一模一样的条目会以为页面卡了。时序控制多个文件并发上传时要控制并发数否则浏览器对同一域名的并发连接限制会拖慢整体速度。我的经验值是 3 到 5 个并发比较合适配合一个简单的 Promise 池。5. 移动端与特殊环境里 accept 的脾气同一个accept桌面端行为基本一致到了移动端就是另一回事。这块的坑我踩过不止一次而且每次都是用户反馈上来的。5.1 iOS 相册里的 HEIC为什么用户找不到自己的照片iPhone 从 iOS 11 开始默认用 HEIC 格式拍照这个格式的 MIME 是image/heic。如果你写了input typefile acceptimage/jpeg,image/png会发生什么部分 iOS 版本下用户在相册里看到的照片会变成灰色不可选因为系统认为 HEIC 不在你允许的范围内。用户会很自然地反馈我选不了我的照片然后你排查半天发现是 accept 写得太窄。解决办法有两条路移动端页面统一放宽到image/*把格式校验交给服务端。系统在把 HEIC 传给网页时通常会根据场景自动转成 JPEG你拿到的file.type大概率就是image/jpeg。如果业务确实不接受 HEIC那也要保证 UI 上有明确提示而不是让用户面对一个灰掉的相册。我个人的做法是移动端一律用image/*桌面端才是精细化枚举的地方。因为桌面端用户上传的图片往往已经过处理格式可控移动端用户拍什么就是什么收窄范围只会制造麻烦。5.2 capture 与 multiple 的互斥与取舍capture属性用来引导移动端直接调用摄像头它有几种取值取值效果captureuser优先唤起前置摄像头captureenvironment优先唤起后置摄像头capture布尔等价于user但capture和multiple放在一起时会打架。因为调起摄像头一次只能拍一张multiple就失去了意义。安卓 Chrome 上的表现是忽略其中一方iOS Safari 上则可能只返回一张。所以如果业务需要多图上传就别加capture让用户自己从相册多选如果业务是现场拍证件照那加capture并且去掉multiple。还有一个细节加了capture之后用户在部分机型上就没法从相册选图了直接进相机。这个体验对补传照片的场景很不友好所以用之前一定要想清楚业务场景。5.3 微信、钉钉等内置浏览器的降级策略内置浏览器的文件选择走的是宿主 App 自己实现的桥接层不完全是系统原生对话框。表现上会有这些差异accept 的筛选有时候不生效用户能看到全部文件部分版本里先弹出拍照/从相册选择/从文件选择的菜单你可能拿到的是相册直出的图某些版本对capture的支持不完整加了也没反应。我的降级策略很朴素在内置浏览器里前端只做体积和数量的硬限制格式限制全部靠服务端返回的错误信息来兜底。同时把服务端的错误文案设计得具体一点比如当前仅支持 JPG 和 PNG 格式您上传的是 PDF而不是笼统的文件格式不正确。6. 从选文件到上传成功的交互细节格式限制只是上传功能的一小部分但从用户点按钮到文件传完中间有一堆细节决定了这个功能好不好用。6.1 隐藏原生 input 的正确姿势原生 file input 的样式几乎没法改所以几乎所有项目都会把它隐藏起来用一个自定义按钮触发。标准做法是input typefile idfileInput acceptimage/* hidden label forfileInput classupload-btn选择图片/label用label for...关联而不是用 JS 去调input.click()好处是无障碍支持更好、移动端也更容易触发。如果用 JS 触发注意必须放在用户手势的事件回调里否则部分浏览器会拦截。还有一个细节hidden属性在个别老浏览器里被元素样式覆盖后可能失效稳妥点可以直接用styledisplay:none或者在 CSS 里写position:absolute; opacity:0; width:0; height:0;。6.2 拖拽与粘贴上传时 accept 完全失效前面提过拖拽不经过文件对话框accept 在这里毫无作用。所以如果你做了拖拽上传必须自己在drop事件里检查dropZone.addEventListener(drop, (e) { e.preventDefault(); const files Array.from(e.dataTransfer.files).filter(f { return f.type.startsWith(image/); }); // 剩下交给统一的校验函数 });粘贴上传同理从e.clipboardData.files里拿文件一样要走校验流程。这里有个容易漏的点拖拽上传完之后原生 input 的files属性并不会更新。如果你后面的上传逻辑是读input.files那拖拽进来的文件就丢了。两种解法一是统一用自己维护的文件数组不依赖input.files二是用DataTransfer构造一个 FileList 赋值回去。我一般选第一种逻辑更清晰。6.3 重复选择同一文件不触发 change 的坑这是个小坑但很烦人用户选了个文件上传失败重新选同一个文件结果change事件没触发页面毫无反应用户以为按钮坏了。原因是 input 的value没变浏览器认为没有变化就不派发事件。解决方法很简单在处理完文件之后清空它input.addEventListener(change, async (e) { const files Array.from(e.target.files); e.target.value ; // 关键允许重复选择同一文件 await handleFiles(files); });注意清空要放在取出files之后顺序反了就什么都拿不到了。6.4 预览与内存回收图片上传前做本地预览URL.createObjectURL(file)是最常用的方式比 FileReader 转 base64 快得多也不占内存。但它有个必须配合的操作URL.revokeObjectURL(url)。如果不回收用户连续换十几次图片页面就会一直挂着十几个 blob URL在低端机上能明显感觉到卡顿。我的写法是每次设置新预览前先回收上一个组件卸载时也回收一次。另外大图预览建议先在 canvas 里压缩一遍再上传。手机拍的照片动辄 4MB 到 8MB压缩到长边 1600px、质量 0.8通常能降到 300KB 以内上传时间和服务器存储都会舒服很多。压缩后的格式统一输出成image/jpeg这样服务端也只需要处理一种格式。7. 上传被拒时的排查顺序功能上线后总会有人反馈传不上去这时候有一套固定的排查顺序能省很多时间。7.1 先分清是选不到还是传不上去这两类问题的根因完全不同选不到问题在 accept在文件对话框层面。表现是文件在对话框里是灰的或者筛选器默认过滤掉了用户想要的文件。传不上去问题在 JS 校验或服务端校验。表现是能选中但点上传后报错。我一般会先问一句你在选文件的时候能看到这个文件吗这一句话就能把排查范围砍掉一半。7.2 常见错误与根因对照表现象大概率根因处理方式图片在对话框里是灰色的accept 写了image/jpg等不存在的 MIME改成image/jpeg用户切所有文件就能传任意格式把 accept 当成了安全边界服务端补白名单校验拖拽进来的文件没走校验drop 事件没做类型判断在 drop 里复用统一校验函数上传同一个文件第二次没反应input.value 未清空处理完置空 valueiOS 用户找不到自己的照片accept 收窄HEIC 被过滤移动端放宽到image/*后端报文件类型不支持但前端显示正常前端白名单和服务端白名单不一致抽一份配置两边共用偶发上传失败重试就好并发数过高或超时设置太短控制并发增加重试CSV 文件被拒Windows 把 csv 报成application/vnd.ms-excel服务端以扩展名为主MIME 为辅这张表里的每一条我基本都在真实项目里见过至少一次。其中最值钱的是前后端白名单不一致这一条——它往往发生在需求变更的时候前端改了 accept服务端白名单忘了同步然后就是持续几天的用户投诉。还有一个排查技巧值得分享在服务端把每次拒绝的原始信息filename、content-type、size、文件头前 8 字节的十六进制都打一条日志。出问题的时候日志一看就知道是用户真的传了不支持的文件还是你的校验规则写错了。没有这条日志排查就是纯猜。我在实际项目里最后沉淀下来的做法是把允许的格式清单抽成一个独立的配置文件前端构建时读一次生成 accept 字符串服务端启动时读一次生成校验白名单两边永远同源。accept 该写的还是要写它是体验的一部分但真正让我睡得着觉的是服务端那一层和这份共用配置。
返回列表