写在前面
做浏览器自动化、桌面应用(Electron/CEF)脚本注入、或者写文件上传组件的单元测试时,经常会撞上同一个问题:<input type="file"> 这个控件正常情况下必须由用户手动点开系统的文件选择对话框才能选中文件,脚本没法"隔空"往里塞一个文件------毕竟如果 JS 能随便读用户磁盘上的任意文件塞给网页,那是个巨大的安全漏洞,浏览器当然要挡住。
但"挡住"挡的是"读用户磁盘上任意文件"这件事,不是挡"给这个控件一个文件对象"这件事本身。只要文件的内容是你自己(脚本)已经合法拿到手的------不管是自己构造的一段文本、fetch 回来的图片、还是通过 Node fs 读到的本地文件------浏览器是允许你把这个文件对象直接"喂"给 input.files 的。这篇文章讲的就是这个官方支持、有正式规范依据、但很多人不知道的技巧:File + DataTransfer。
一、为什么 input.files 不能直接赋值一个数组
先说清楚"挡住"的具体是什么。你可能试过这样写:
js
const input = document.querySelector('input[type="file"]')
input.files = [someFile] // ❌ 报错或者静默失败
这行会报错------HTMLInputElement.files 这个属性的类型是 FileList,而不是普通数组。FileList 是浏览器内部的一个只读集合类型,你没法自己 new FileList()(这个构造函数根本不对外暴露),也不能拿一个普通数组硬塞进去糊弄类型检查。
那浏览器是彻底不让改这个属性吗?并不是。HTML 标准里 input.files 这个 IDL 属性其实是可写的 ,只是"能写进去的值"被限定得很死:只能写一个真正的 FileList 实例 。而拿到一个真正 FileList 实例的唯一"合法"官方途径,除了用户真的选了文件之外,就是 ------ DataTransfer 对象的 .files 属性 ,它本身就是浏览器原生实现、类型完全对得上的 FileList。
二、DataTransfer 是什么,为什么它能帮上忙
DataTransfer 本来是给拖拽(Drag and Drop API)用的容器对象------你把文件从桌面拖到网页里,浏览器内部就是用一个 DataTransfer 对象把被拖拽的文件传递给 drop 事件。既然它天生就是"装文件、传给页面"这个用途,浏览器就顺理成章地允许开发者自己 new DataTransfer() 构造一个空的出来,往里面塞自己的 File 对象,再把它长出来的 .files(一个如假包换的 FileList)整个赋给某个 input.files。
这不是什么"绕过安全限制的黑客手法",是 MDN 官方文档 明确写出来的用法,Testing Library、Playwright 这些正经的测试/自动化工具库内部模拟"用户上传文件"用的就是这条路。
三、最简单的代码 Demo
一个完整、可以直接复制到浏览器控制台跑的例子:
html
<input type="file" id="fileInput" accept=".txt" />
<button id="btn">模拟选择文件</button>
<script>
document.getElementById('fileInput').addEventListener('change', (e) => {
const file = e.target.files[0]
console.log('收到文件:', file?.name, file?.size, '字节')
})
document.getElementById('btn').addEventListener('click', () => {
// 第一步:准备文件内容。这里用一段纯文本模拟,
// 实际场景可以换成 fetch() 回来的图片 Blob,或者 Node fs.readFileSync() 读到的本地文件字节。
const textContent = 'Hello, this is a fake file created by script.'
const blob = new Blob([textContent], { type: 'text/plain' })
// 第二步:用 File 构造函数把 Blob 包装成一个"真正的" File 对象。
// File 是 Blob 的子类,只是多了 name(文件名)和 lastModified(修改时间)这两个字段,
// 这也是它跟普通 Blob 唯一的区别------浏览器判断"这是不是个文件",靠的就是这两个字段在不在。
const file = new File([blob], 'hello.txt', {
type: 'text/plain',
lastModified: Date.now(),
})
// 第三步:造一个空的 DataTransfer 对象(拖拽 API 用的容器),把 File 塞进去。
const dt = new DataTransfer()
dt.items.add(file)
// 第四步(关键一步):把 dt.files(一个真正的 FileList)整个赋值给 input.files。
const input = document.getElementById('fileInput')
input.files = dt.files
// 第五步:手动触发一次 change 事件。
// 因为这次赋值是脚本干的,不是用户在对话框里点了"确定",浏览器不会自动帮你 fire change 事件;
// 但监听 change 的业务代码根本不关心这个事件是不是"真人点出来的",只要收到事件该干嘛干嘛。
input.dispatchEvent(new Event('change', { bubbles: true }))
})
</script>
点一下"模拟选择文件"按钮,控制台会打印:
收到文件: hello.txt 47 字节
input.files.length 变成 1,input.files[0].name 是 'hello.txt'------跟用户真的在对话框里选了一个叫 hello.txt 的文件、点了"打开"之后的状态,浏览器内部完全没有区别。
四、进阶:把"假文本文件"换成真实图片
上面的 demo 用纯文本只是图个简单、不依赖任何外部资源,方便你直接复制粘贴运行。实战里通常是两种真实文件来源:
来源一:网页里已经有的图片/Blob (比如从服务器 fetch 下载的图片、<canvas> 导出的截图):
js
const response = await fetch('https://example.com/some-image.png')
const blob = await response.blob()
const file = new File([blob], 'downloaded.png', { type: blob.type })
// 后面跟前面一样:new DataTransfer() → items.add(file) → input.files = dt.files → dispatchEvent
来源二:本地磁盘上的文件(Node 环境,比如 Electron 的 preload 脚本、或者 Node 写的浏览器扩展/自动化脚本):
js
const fs = require('fs')
const bytes = fs.readFileSync('/path/to/photo.jpg') // 得到一个 Node Buffer
const file = new File([bytes], 'photo.jpg', { type: 'image/jpeg' })
// Buffer 本身就是 Uint8Array 的子类,File 构造函数接受的 BlobPart 数组里放一个
// TypedArray/Buffer 是完全合法的,不需要额外转换
这里有个容易被忽略但很重要的细节:File/DataTransfer 是浏览器 Web 平台自带的能力 ,跟"你的脚本是在网页自己的 JS 里跑,还是在浏览器扩展的 content script 里跑,还是在 Electron 的 preload 脚本里跑"没有关系------只要这段 JS 运行在某个浏览器渲染进程的 JS 环境里,File/DataTransfer/Blob 这些构造函数就都在。这就是为什么"Node 环境读本地文件字节 + 浏览器 Web API 构造 File"这两件事能在 Electron 的 preload 脚本里无缝衔接------preload 脚本本身既有 Node 的 fs 权限,又跑在浏览器渲染进程里天然带着完整的 Web API,两头都不缺。
五、一个真实场景:桌面客户端里自动化操作第三方网页
我自己在做的一个 Electron 多渠道客户端项目里,需要在应用内自动往 X.com(Twitter)发一条带图片的帖子。X.com 的发图入口本质上就是一个隐藏起来的 <input type="file">,正常用户点"添加图片"按钮,浏览器弹出系统的文件选择对话框,选完之后这个 input 的 change 事件一响,X.com 自己的前端代码就把文件读出来做预览、准备上传。
我们没法(也不需要)去模拟"用户在系统对话框里点了哪个文件"这个操作系统层面的交互,而是直接:
- 在应用主进程/桌面自动化脚本里读到要发的图片文件(
fs.readFileSync); - 找到 X.com 页面里真正的那个
<input type="file">元素; - 用上面这套
File+DataTransfer的写法,把图片字节包成File塞进这个 input; dispatchEvent(new Event('change', { bubbles: true }))。
X.com 自己的前端代码完全感知不出这张图是"脚本喂进来的"还是"用户真的选的"------因为从浏览器的角度看,这两种情况下 input.files 里躺着的都是同一种类型的真实 FileList/File 对象,没有任何"这是脚本构造的"标记字段。实测效果是:图片预览框正常出现、"移除图片"按钮正常可点、发布按钮从灰色变可点------跟真人手动选完图片之后的界面状态完全一样。
六、这个技巧的边界在哪
写到这里要泼盆冷水,讲清楚几个容易被过度解读的地方:
- 这个方法只能用在"你自己能执行 JS 代码"的环境里 :你自己的网页、浏览器扩展的 content script、Electron 的 preload 脚本、或者 Puppeteer/Playwright 这类自动化框架的
page.evaluate()里都行;但你不能拿着这段代码去改别人网站在用户浏览器里跑的那份 JS------如果你没有能力往目标页面注入代码,这个技巧无从谈起,这从来都不是"隔空往任意网页塞文件"的手段。 - 浏览器兼容性 :
input.files = dataTransfer.files这个赋值写法在现代 Chromium(Chrome/Edge/新版 Electron)和 Firefox 里都是支持的标准行为;Safari 历史上对这个支持得相对晚一些,如果要兼容旧版 Safari 建议先做一次特性检测。 change事件要自己手动dispatchEvent:赋值input.files这个动作本身不会像真人操作那样自动触发change事件(因为这本来就不是"用户交互"),监听这个 input 的业务代码是靠change事件驱动的,忘了这一步会导致"文件明明塞进去了,页面却好像什么都没发生"。- 目标页面如果用了 React/Vue 这类框架,个别情况下可能需要多留意 :一般框架监听的就是原生
change事件,跟上面 demo 的写法完全兼容;但如果目标组件对<input>做了很奇怪的自定义封装(比如整个隐藏起来、外面套了一层完全靠 JS 状态驱动的自定义 UI),流程上可能需要多确认一下它到底监听的是什么事件,这个需要针对具体页面实测,没有放之四海皆准的答案。
七、总结
<input type="file">.files 挡住的是"网页脚本随便读用户磁盘上任意文件"这件事,不是挡"给这个控件一个合法的 File 对象"这件事。File 构造函数负责把任意字节数据(文本、fetch 来的 Blob、Node 读到的本地文件)包装成浏览器认可的文件对象,DataTransfer 则是浏览器官方提供的、能产出真正 FileList 实例的容器------两者组合起来,"模拟用户选择文件"这件事就成了一个完全标准、有 MDN 背书、不需要碰任何私有/非官方 API 的正经技巧。下次再遇到"脚本要往文件输入框里塞文件"这种需求,不用想着去模拟操作系统对话框,File + DataTransfer 就是官方给的答案。