ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

文件上传原生API详解:input与showOpenFilePicker的选型与实践

文件上传原生API详解:input与showOpenFilePicker的选型与实践 做后台管理系统做得多了你会发现一个很奇怪的现象很多团队一遇到文件上传第一反应是去找 UI 组件库或者大而全的上传插件但真正到了定制需求的时候比如“上传前要做单文件质检”“需要记住用户上次选择的目录”“需要把文件直接写回磁盘”那些封装好的组件反而成了枷锁。这个时候回到浏览器原生能力反而最清醒。浏览器提供了两套原生的文件对话框打开方案一套是藏在input[typefile]里的老牌方案兼容性拉满另一套是showOpenFilePicker这个现代文件系统访问 API能拿到句柄级能力。这篇文章就把这两条路彻底讲透包括原理、代码、兼容边界和踩坑记录适合需要自己封装文件选择模块、或者被组件库坑过之后想回归原生方案的前端开发者。1. 文件选择对话框的两种原生实现先分清谁是谁1.1 需求场景一眼看明白文件选择几乎渗透在每一个前端项目里上传头像需要过滤图片格式限制大小导入 Excel 做批量数据校验要求选完文件立刻读内容加载本地 JSON 配置文件用于恢复用户的自定义设置浏览器端的在线图片编辑器需要读取图片并生成预览内部后台系统希望把处理结果直接写回原文件。这些场景听起来不一样但底层的第一个动作完全相同打开一个文件选择对话框然后拿到用户选择的文件。大多数组件库的做法是替你封装好了一个input typefile再做一层 UI 和事件包装。这个方案的好处是省事坏处是如果你要改交互细节、要适配多选和目录选择、要接管文件读取进度组件库的抽象层反而会挡手。所以真正可靠的做法是理解原生方案自己控制整个链路。1.2 两种方案的本质区别第一套方案的核心是一个隐藏的input typefile元素通过程序调用它的click()方法让浏览器弹出文件选择框。选完文件之后通过change事件拿到File对象。这个方案从 HTML 2.0 时代就有了所有现代浏览器、WebView、小程序容器里都能跑是典型的“万金油”。第二套方案的核心是 File System Access API 里的showOpenFilePicker()方法它直接返回一个FileSystemFileHandle句柄。这个句柄不仅能让我们读取文件内容还能在用户授权之后写回文件内容甚至可以存到 IndexedDB 里下次打开页面直接恢复句柄。这个 API 把浏览器里的文件操作从“上传”推进到了“本地文件应用”的层次。简单说input方案是“选一个文件把内容拿过来”showOpenFilePicker方案是“拿到这把文件的钥匙以后还能开锁”。2. 经典方案把 input[typefile] 藏起来再用 click() 点亮它2.1 最小可用代码与完整读取链路最朴素的写法是这样input typefile idfileInput acceptimage/* /document.getElementById(fileInput).addEventListener(change, function (e) { const file e.target.files e.target.files[0]; if (!file) return; console.log(file.name, file.size, file.type, file.lastModified); });e.target.files是一个FileList看起来像数组但不是真正的数组所以如果你想遍历最好先转成数组const files Array.from(e.target.files);拿到File对象之后读取内容通常有两条路。第一条路是FileReader。它适合读取文本内容、DataURL、ArrayBuffer适合文件内容解析、图片压缩这类场景const reader new FileReader(); reader.onload function (e) { const dataUrl e.target.result; // dataUrl 可以直接赋给 img.src也可以用于 Canvas 处理 console.log(dataUrl.slice(0, 100)); }; reader.readAsDataURL(file);第二条路是URL.createObjectURL。它生成一个临时的blob:地址适合直接放到img、video、audio标签里做预览开销比 DataURL 小得多const url URL.createObjectURL(file); img.src url; // 用完之后一定记得释放 img.onload () URL.revokeObjectURL(url);这里的核心点在于FileReader适合“拿数据做计算”createObjectURL适合“拿地址做展示”。很多人两个混着用在只做图片预览的时候也去readAsDataURL大图场景下内存直接翻倍完全没有必要。2.2 label、动态节点与用户手势的三个隐藏门槛第一个隐藏门槛用户手势限制。浏览器不允许未经用户交互直接弹出文件选择框必须在用户点击事件的同步执行过程中调用input.click()不能在setTimeout回调里或者异步接口返回之后调用否则会被浏览器拦截。这也是为什么很多人封装组件后点了按钮没反应就是因为await了一圈回来再click()用户手势上下文已经失效了。第二个隐藏门槛用label标签关联可以省掉click()调用。比如这样label classupload-btn 选择文件 input typefile classhidden-input / /label用户点击label时浏览器会自动触发关联的input打开文件框。这个方案的好处是不需要 JS但缺点是label的可控性不如点击事件而且如果input被设置了display: none某些老旧浏览器会有兼容问题。更稳妥的做法是input用position: absolute; opacity: 0; width: 0; height: 0这种视觉隐藏方案代替display: none既保证了布局不占用空间又保证了在所有浏览器里的可点击性。第三个隐藏门槛动态创建的input用完后要清理。很多团队直接在click处理函数里document.createElement(input)选择完文件后这个节点还挂在 body 上反复点击会产生一堆垃圾 DOM。正确的做法是监听change结束后把节点移除function openFilePicker(options {}) { return new Promise((resolve) { const input document.createElement(input); input.type file; if (options.multiple) input.multiple true; if (options.accept) input.accept options.accept; input.onchange function (e) { const files Array.from(e.target.files || []); // 用完即毁 input.remove(); resolve(files); }; // 为了让事件能够触发input 需要先挂到 DOM 上 document.body.appendChild(input); input.click(); }); }注意input.remove()一定要放在resolve之前还是之后都不重要重要的是不要漏掉。有些人还会在创建节点后加一层visibility: hidden样式防止某些浏览器渲染出可见元素。2.3 accept、multiple、capture 的真实行为accept属性在最理想的情况下可以过滤文件类型比如acceptimage/*只让用户看到图片文件。但它的本质是给文件管理器一个“默认筛选条件”不是安全边界。用户可以切换到“所有文件”手动选择任意类型所以后端和前端逻辑都必须再做一遍真实类型校验。读一下文件的二进制头往往比看扩展名靠谱比如判断一个文件到底是不是 PNG可以检查前 8 个字节是不是89 50 4E 47 0D 0A 1A 0A。multiple属性会允许用户一次选择多个文件但多选时浏览器通常会把文件按用户点击顺序放进FileList这个顺序在绝大多数现代浏览器里是稳定的不过依赖这个顺序本身就是不够严谨的做法最好让用户明确上传的顺序由文件名或自定义排序决定。capture属性在移动端很常用它告诉浏览器可以调用摄像头或录音机比如input typefile acceptimage/* captureenvironment /这个属性在部分 Android WebView 里效果不稳定后面第 6 章会展开讲。最容易被忽视的是“用户取消文件框”这件事。input方案里用户取消选择时change事件不会触发也没有cancel事件可以监听整个 Promise 会一直挂着。这是经典方案的结构性盲区想要区分“用户没做任何操作”和“用户主动取消”几乎没有可靠的原生手段。如果业务上必须要区分只能做超时兜底或者改用现代 API。3. 现代方案showOpenFilePicker 带来的句柄级文件能力3.1 基本用法与 FileSystemFileHandleshowOpenFilePicker是 File System Access API 的一部分调用之后浏览器会弹出文件选择框但返回结果不再是简单的File而是FileSystemFileHandleconst handles await window.showOpenFilePicker({ multiple: false, types: [ { description: Excel 文件, accept: { application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: [.xlsx] } } ], excludeAcceptAllOption: true }); const handle handles[0]; const file await handle.getFile();handles是数组multiple: false时数组长度为 1。通过handle.getFile()能拿到和经典方案里一模一样的File对象后续读内容的方式完全一致。这个方案在体验上更接近桌面应用对话框由浏览器原生提供UI 和行为在各平台保持一致不需要猜测 “这个系统的文件管理器是不是不支持某个过滤条件”。3.2 读回、写回与权限状态机FileSystemFileHandle比File多出来的核心能力是写回。File 只是文件数据的快照是只读的而 handle 保留了原文件的引用可以在用户授权后直接覆盖写入。写入的流程是先检查权限再请求权限拿到可写流之后写入并关闭// 检查是否已有写权限 let permission await handle.queryPermission({ mode: readwrite }); if (permission ! granted) { // 请求用户授权 permission await handle.requestPermission({ mode: readwrite }); if (permission ! granted) { // 用户拒绝授权只能读不能写 return; } } const writable await handle.createWritable(); // 用新内容覆盖原文件 await writable.write(new Blob([newText], { type: text/plain })); // 必须 close写入才会真正落盘 await writable.close();这里的权限模型是一个状态机prompt-granted/denied。每次会话首次加载页面时权限通常是prompt用户授权后当前页面刷新前都是granted。如果你的应用需要记住授权状态可以把 handle 存进 IndexedDB下次打开页面直接恢复// 保存 const db await indexedDB.open(file-handles, 1); // 把 handle 序列化后存储 // 恢复 const restoredHandle await indexedDBGet(key); const hasPermission (await restoredHandle.queryPermission({ mode: readwrite })) granted;这个能力对“桌面编辑器”“本地数据处理工具”这类应用帮助很大。用户不用再“导出文件”再手动覆盖直接在浏览器里点保存就能写回原始的本地文件。3.3 支持范围和安全上下文的账要算清楚showOpenFilePicker目前只在 Chromium 系浏览器里可用Chrome 和 Edge 从 86 版本开始支持Firefox 和 Safari 至今没有实现。这意味着如果你的用户群体里有大量 Firefox 或 Safari 用户这个方案就不能作为唯一入口。除了浏览器版本它还有两个硬性限制只允许在安全上下文中使用即https://或localhost在 iframe 里使用时iframe 元素需要添加allowshow-file-picker权限策略。第一个限制意味着在内网 IP 地址直接部署时如果站点不是 HTTPS这个 API 会直接不存在。第二个限制在嵌入第三方编辑器的场景里经常踩到报错信息很绕实际上就是 iframe 权限策略没配。另外showOpenFilePicker必须在用户手势的同步调用栈里执行这一点和input.click()的限制一致异步调用会被直接拒绝。由于这些限制实践中普遍的做法是“能力检测 降级”。检测方式很简单const hasNativePicker showOpenFilePicker in window;拿到这个结果之后再把经典方案作为 fallback 接在后面。4. 方案选型把两种方式摆在同一张桌子上对比4.1 关键能力对照表维度input[typefile] click()showOpenFilePicker兼容范围几乎所有浏览器和 WebView仅 Chromium 系Firefox/Safari 不支持安全上下文无要求仅 HTTPS 或 localhost读取文件File 对象只读FileSystemFileHandle - File可读写回原文件不支持授权后可 createWritable 覆盖目录选择可用 webkitdirectory兼容性一般showDirectoryPicker 原生支持区分用户取消无法可靠捕获有 abort 事件可捕获过滤文件类型依赖 accept且不是强约束types 按 MIME/扩展名过滤可选 excludeAcceptAllOptioniframe 中使用基本无限制需要 allow 权限策略UI 定制可以完全自定义触发元素对话框由浏览器原生提供这张表信息量很大但最关键的是三件事兼容性、写回能力、取消检测。大多数普通网页应用只需要读取文件没必要为了写回能力承担兼容性成本但如果你做的是内部工具、Chrome 内核浏览器环境下的后台系统showOpenFilePicker的价值就非常突出。4.2 不同项目形态下的选择建议面向普通互联网用户的产品首选input click()。用户用的浏览器五花八门Safari 和 Firefox 的占比不可忽略与其做两套逻辑时刻判断环境不如直接统一到兼容性最好的方案上。企业内部后台系统尤其是已经指定使用 Chrome 或 Edge 的环境可以直接上showOpenFilePicker。这类系统里“把处理结果写回文件”的需求特别常见比如导出模板编辑、批量配置调整原生 API 做起来非常简单。大型文件上传场景比如视频投递、设计稿上传建议用showOpenFilePicker搭配 IndexedDB 存储句柄。原因很现实上传大文件通常需要断点续传而浏览器刷新后input方案无法恢复已经选中的文件引用但 handle 可以恢复配合getFile()重新读取文件内容续传逻辑可以做得非常流畅。我个人的习惯是封装成统一模块优先走showOpenFilePicker不支持就自动降级到input用户完全没有感知。这样既能拿到现代 API 的红利又不会有兼容性风险。下一章就详细讲封装思路。5. 一套代码兼容两种方案封装可复用文件选择模块5.1 封装思路统一 Promise 接口目标很简单不管内部用哪种方案对外都提供一个统一的 Promise 接口调用方拿到的是文件数组和可选的句柄。async function pickFiles(options {}) { const { multiple false, accept , types, excludeAcceptAllOption false } options; const hasNativePicker showOpenFilePicker in window; if (hasNativePicker) { try { const handles await window.showOpenFilePicker({ multiple, types: types || (accept ? [ { description: 选择的文件, accept: { */*: accept.split(,) } } ] : []), excludeAcceptAllOption }); const files await Promise.all(handles.map((h) h.getFile())); return { files, handles }; } catch (err) { // 用户取消时AbortError 会抛出来 if (err.name AbortError) { return { files: [], handles: [], canceled: true }; } // 某些情况比如 iframe 权限不足需要降级到 input if (err.name SecurityError || err.name NotAllowedError) { return fallbackToInput({ multiple, accept }); } throw err; } } return fallbackToInput({ multiple, accept }); }fallbackToInput的实现就是第 2 章里那段动态创建逻辑只是把返回值统一成{ files }结构。这里注意把取消场景单列出来原生 API 能通过AbortError识别用户取消而input方案识别不了所以降级路径里canceled字段永远不可能是true。封装时还有一个常见的小问题默认配置和用户配置的合并。不要手写一堆if (options.xxx undefined)直接合并默认值const config Object.assign( { multiple: false, accept: , types: [], excludeAcceptAllOption: false }, userOptions );这和 JavaScript 合并对象的基础知识直接相关很多人在配置多、字段多的时候容易写出又长又重复的代码Object.assign或展开运算符能省掉大量模板代码。5.2 字符串回调映射消灭一长串 if-else封装文件选择器之后下一步往往会遇到“不同文件类型走不同处理函数”的需求。比如const handlers { onImage: (file) renderImagePreview(file), onExcel: (file) parseExcel(file), onJson: (file) applyJsonConfig(file), onError: (err) toast(err.message), onFinally: () loading.hide() };如果这个逻辑散落在各个业务页面里代码会变成一长串if (file.type.includes(image/)) ... else if ...。更合理的方式是把处理函数名配置化通过字符串映射到函数const routeConfig { image/*: onImage, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: onExcel, application/json: onJson }; function routeFile(file) { const matchedType Object.keys(routeConfig).find((type) file.type.includes(type.replace(*, ))); const handlerName routeConfig[matchedType]; const handler handlers[handlerName]; if (typeof handler function) { handler(file); } else { handlers.onError(new Error(未找到对应的处理函数: handlerName)); } }这种“字符串到函数”的映射方式本质上是把分支逻辑变成查表。它比直接写window[funcName]()更安全因为函数来源是配置文件里的白名单而不是用户输入。如果函数名来自用户输入或 URL 参数直接动态调用会有严重的安全隐患一定要先做白名单校验宁可用对象索引也不用全局查找。5.3 再聊一个容易卡住的点构造函数里为什么能调用 prototype 方法封装模块的时候有人会写成 class 形式class FilePicker { constructor() { // 这里 this.init() 为什么能调用new 不是还没执行完吗 this.init(); } init() { // 绑定事件、初始化状态 } }这个问题在 JavaScript 基础不扎实的同学脑子里经常绕不过去。其实new的执行过程是先创建一个新对象然后把新对象的原型指向构造函数的prototype最后再执行构造函数体。也就是说在构造函数第一行代码执行之前this对象的原型链已经和FilePicker.prototype连上了。所以this.init()在 constructor 里能调用是因为init不在 constructor 内联的局部函数里而是在原型对象上而this已经能沿着原型链找到它。这并不是什么“未 new 完就能用”的黑魔法而是 JavaScript 对象模型在构造阶段就完成了原型连接。在实际封装里我建议把所有共享方法放在prototype或 class 方法里实例相关的状态才放在 constructor 的this上。这样既节省内存也能保证this上全是“数据字段”调用关系一目了然。6. 实测录这些坑不踩一遍很难意识到6.1 同一个文件连选两次change 事件装死这是经典方案里最经典的坑。用户第一次选了一个文件处理完成后想换个角度重新选同一个文件再点开文件框选同一个文件change事件死活不触发。因为input.files里的值在两次选择之间没有变化浏览器认为值没变就不触发事件。解决方法是在每次处理完成后手动把input.value清空input.value ;清空之后input.files会变成空FileList下次再选同一个文件就能正常触发change。这个操作虽然简单但容易漏尤其是封装在组件库里的时候外部使用者根本不知道内部还要做这一步。如果是动态创建的input因为用一次就销毁不会遇到这个问题。问题主要出在常驻的隐藏input上。6.2 createObjectURL 生成的地址不释放会造成内存泄漏URL.createObjectURL创建的blob:地址背后占用的是一块内存引用只要不调用URL.revokeObjectURL释放浏览器就会一直保存这块文件数据。在图片预览、视频预览这类高频操作里内存会持续上涨页面越来越卡最后直接崩掉。正确姿势是在资源加载完毕后立即释放地址function previewImage(file) { const url URL.createObjectURL(file); const img new Image(); img.onload () { document.body.appendChild(img); URL.revokeObjectURL(url); }; img.src url; }有人担心revokeObjectURL之后img.src还有效吗结论是在img已经加载完成之后调用 revoke图片依然能正常显示因为浏览器已经完成了资源加载并缓存了解码数据。所以安全的顺序是先显示、后释放、不回头。6.3 移动端 WebView 和微信里的行为差异移动端的文件选择框坑更多。首先accept在 iOS Safari 和 Android Chrome 上的表现不同。iOS 上acceptimage/*通常只显示“拍照”“照片图库”两个选项Android 上则可能直接打开文件管理器。这两者对用户习惯的影响很大不能一概而论。其次capture属性的表现并不可靠。在 Android 的某些 WebView 里acceptimage/* capture组合会强制调起相机用户根本看不到“从相册选择”的入口。在微信内置浏览器里这个问题更明显不同机型表现完全不一样。最后iOS WKWebView 里的input有时会出现点击后不响应的问题尤其是在页面里同时有多个隐藏 input、或者 input 被动态移动过位置时。这种情况下最稳妥的方案是每次打开文件框前重新创建一个新的input节点并挂到 body 上用完立即移除不给 WebView 留任何缓存状态。如果你在移动端引入过原生桥接能力比如 iOS 的WKScriptMessageHandler或 Android 的addJavascriptInterface会发现 WebView 里文件选择的规则和原生 App 的文件选择完全是两套体系。前端代码并不能保证所有 WebView 都按标准执行遇到极端机型问题时与其死磕兼容不如直接降级成一个原生 App 提供的能力入口专门接 WebView 的桥方法。6.4 大文件场景进度反馈与分片上传思路选文件只是一个动作真正的挑战在选完之后。大文件上传时用户最需要的是进度反馈。FileReader其实自带progress事件可以拿到读取进度const reader new FileReader(); reader.onprogress function (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); console.log(percent %); } }; reader.readAsArrayBuffer(file);这个事件在做本地大文件的内容校验、Canvas 处理时非常有用。但在上传场景下FileReader读出来的完整内容仍然要作为整体发送这种方式对内存的消耗很大。更合理的是分片读取 分片上传这也是断点续传的基础const CHUNK_SIZE 2 * 1024 * 1024; // 2MB 一片 let offset 0; function readNextChunk(file, callback) { const slice file.slice(offset, offset CHUNK_SIZE); offset CHUNK_SIZE; callback(slice); }每次取file.slice切出一个 Blob 片段再用 FormData 单独上传。这比一次性读完整个文件更省内存也更容易实现重试和断点续传。另外一个容易忽视的点是File对象本身是一个“快照”如果用户在选完文件之后修改了磁盘上的原文件File对象里的数据不会跟着变。但在showOpenFilePicker方案里handle.getFile()每次都会读取磁盘上的最新内容这是两套方案一个相当隐形的差异。大文件场景里如果业务上需要确保读取的是最新版本优先用handle.getFile()重新获取而不是缓存第一个 File 对象。这几种方案我在实际项目里都试过最后沉淀下来的习惯是普通页面用input方案组件化封装加能力检测能走showOpenFilePicker的内部工具尽量走现代 API再把取消、权限、内存释放这些边界问题全部在封装层处理干净。文件对话框只是第一步但这一步处理好了后面所有文件相关的功能都能稳得住。
返回列表