故事背景
当我们在和产品打交道的时候,我们如何才能表现得比较从容 ,答案就是我们对浏览器得能力边界 足够的了解,如果我们对技术不够了解产品就会怀疑我们的能力,从而有很多毫无意义的争论,有些产品提出一些超过能力边界的事情,我们可以理直气壮的直接说:做不了。
就比如说:他居然想让我们实现在浏览器端无感知的磁盘读写,就是那种用户不会感知到任何提示,然后他电脑就多了或者少了些文件。
一、写在前面
之前我一直觉得在浏览器中使用javascript无法直接访问磁盘文件这一点和后端语言简直没法儿比,总显得js比较low的感觉,幸亏nodejs 以及bun等运行时的出现弥补了这一缺陷。
但是javascript真的是不能读写磁盘么?它是故意而为之还是做不到呢?
首先声明一点,是跑在浏览器端javascript做不到,服务端的javascript还是很可以做到的。
很显然强大的chrome团队并不是做不到,而是不能这样做,而最重要的原因大家可能也猜的出来,是为了------安全性
因为javascript最初的设计就是运行在浏览器端的一种脚本语言,主要负责浏览器的交互和一些简单逻辑的执行,它的执行发生在客户端。
如果按照运行的环境来分类,那么这个世界上的编程语言分为两种,一种运行在客户端 ,一种运行在服务端。
我们的个人电脑、手机都是客户端,客户端会在全世界范围内搜索程序,然后下载下来,运行在自己的系统内。而服务端主要是提供下载的资源,编写提供的程序。那么我们就要解决下面的问题。
信任
客户端如何保证自己的下载下来的程序是安全的呢?我们把不好的、对用户可能造成损害的软件称为病毒软件。
通常来讲,一个应用程序是用户在软件商城里面下载下来的,这就已经做了第一次过滤,因为病毒软件通常是很难进入商城的。
其次应用程序如果从互联网上下载下来的,我们的操作系统也会帮助我们检测软件是否合法,是否会侵害用户的隐私。如果不符合标准就会提示用户,甚至不允许安装。如果一个软件通过层层筛查,最终执行在我们的客户端上面,那么我们就基本上是信任这个软件的。如果通过了这么多筛选发现真的是个病毒软件,那么带来的损失将是这个用户自己承担。
浏览器
浏览器被誉为操作系统上的操作系统,它打开一个应用程序的方式实在是太简单了,可能只需要在某个网页上点个链接,就完全有可能进入一个全新的web应用程序,因此对它的限制就需要严格一些。
大家想象一下,假设在浏览器环境下可以随意读写计算机的某一部分磁盘,那么如果有一个恶意的链接不小心一点,可能你电脑上的某个文件就突然没了,这种损失对于用户来说是无法估量的,因此浏览器对运行在其中的脚本做文件读写的限制是完全合理和应该的。
安卓
实际上不光是浏览器,其实几乎所有的客户端应用程序对磁盘读写都有一定的限制,由于我不是安卓开发,但是我问了一些专业的安卓开发人员,才知道安卓应用也是只能读写指定权限的磁盘文件。

Android应用需要在清单文件(
AndroidManifest.xml
)中声明适当的权限,以便访问设备上的文件系统。例如,如果应用需要读取外部存储(SD卡)上的文件,需要声明READ_EXTERNAL_STORAGE
权限。如果应用需要写入到外部存储,需要声明WRITE_EXTERNAL_STORAGE
权限。在Android 6.0(API级别23)及更高版本中,权限由运行时动态请求。 ------ 大家扫一眼就行,因为我也是粘贴的百度的,我们只要明白一个点就行,任何客户端程序都不太可能随意读写本次磁盘文件,所以javascript 可不背这个不能读写磁盘的锅。
二、File System Access
前面废话了这么多其实就是希望说一点,我们需要认识到浏览器的能力边界,浏览器虽然不支持我们无感访问本地磁盘,但是并不代表没有磁盘IO的能力,只要我们以合适的方式,依然可以读写磁盘,进行本地的存储和读取。我们接下来介绍一下浏览器自带的File System
showOpenFilePicker
这个API代表唤出打开一个文件的提示框,我们来看一下他的使用方法以及衍生的其他类和方法。
第一步:首先唤出选择文件的提示框
要读取文件,首先要选择读取哪一个文件,它可以接受一些参数作为限制
js
async function getTheFileHandle() {
const pickerOpts = {
types: [
{
accept: {
"image/*": [".png", ".gif", ".jpeg", ".jpg"],
"text/*": [".txt"]
},
},
],
excludeAcceptAllOption: true,
multiple: false,
};
// 打开一个文件
const [fileHandle] = await window.showOpenFilePicker(pickerOpts);
// 返回一个 FileSystemFileHandle 类型的数组
return fileHandle
}
它会返回一个FileSystemFileHandle
类型的数组,这个类型的定义在这里
实际上这是一个句柄类型,一个句柄代表了用户操作系统上的一个文件或目录,我们先对这个概念有个了解,下面来看看句柄有什么作用

它具有两个属性:kind 和 name ,kind代表文件的类型是一个单个的文件 还是目录,name很好理解代表文件名或者目录名。
当然它的原型上还有三个方法:
-
getFile
getFile用户获取打开的文件的内容
js// 获取到的类型 const fileData = await fileHandle.getFile(); // File类型
当我们得到file类型的文件之后就可以做用户自定义的操作了。
-
createWritable
createWritable用户创建一个可写对象,在打开文件的时候,我们可以选择是读取它,还是写入数据。
jsconst writable = await fileHandle.createWritable(); await writable.write("新增的内容"); await writable.close(); // 写完后记得要释放掉,否则会占用内存。
写入的时候,浏览器还会提示我们进行确认一下,防止恶意代码的恶意写入。
用户确认后就可以看到磁盘的文件真的被写入的,而且默认是覆盖式的写入。
-
move
move用户移动文件到指定的目录中
jsasync function moveFile(fileHandle) { try { // 获取用户选择的目标目录 const directoryHandle = await window.showDirectoryPicker(); // 移动文件到目标目录 await fileHandle.move(directoryHandle, { name: "newFileName.txt" }); console.log("文件移动成功!"); } catch (error) { console.error("移动文件时出错:", error); } }
但是这个API在测试的过程中会报这个错误:
除了以上几个来自原型的方法,它还继承了来自FileSystemHandle
这个类的几个方法:

-
isSameEntry
返回一个
boolean
值,用于表示两个文件或目录是否相同;jsconst [fileHandle1] = await window.showOpenFilePicker(); const [fileHandle2] = await window.showOpenFilePicker(); const result = await fileHandle1.isSameEntry(fileHandle2);
我前后选择了两个一模一样的文件,返回为true
-
queryPermission 与 requestPermission
返回一个
Promise
对象,前者用于查询文件或目录的权限,后者用于请求文件或目录的权限;tstype FileSystemHandlePermissionDescriptor = { mode: 'read' | 'readwrite' } const result = await fileHandle.queryPermission(options:FileSystemHandlePermissionDescriptor);
如果此方法返回了prompt ,则站点必须在对句柄进行任何操作前调用 requestPermission 请求授权。如果此方法返回了denied ,则任何操作都会被拒绝。从本地文件系统句柄构造器返回的句柄通常会在初始时对只读权限状态返回granted 。但是,除开用户收回了权限的情况,从 IndexedDB 获取的句柄也有可能会返回prompt。(句柄就是我们通过打开文件得到的fileHandle)
我们可以使用下面的代码对一个文件句柄的权限先做一个判断,再进行逻辑处理
ts// fileHandle 是一个 FileSystemFileHandle // withWrite 是一个布尔值,如果要求写入则需传入 true async function verifyPermission(fileHandle, withWrite) { const opts = {}; if (withWrite) { opts.mode = "readwrite"; } // 检查是否已经拥有相应权限,如果是,返回 true。 if ((await fileHandle.queryPermission(opts)) === "granted") { return true; } // 为文件请求权限,如果用户授予了权限,返回 true。 if ((await fileHandle.requestPermission(opts)) === "granted") { return true; } // 用户没有授权,返回 false。 return false; }
-
remove
jsconst [fileHandle] = await window.showOpenFilePicker(); const result = await fileHandle.remove();
返回一个
Promise
对象,用于删除文件或目录;
实际上并不推荐使用上面的queryPermission 、requestPermission 、remove这个三个API,首先是他们的浏览器兼容性并不好,他们都无法在firefox和safari中使用:

其次他们中的某些只能在https的环境下才能有效果,在开发环境很难调试。
showOpenDirectoryPicker
这两个API打开一个目录的提示框,使用方法很简单,如下:
js
const directoryHandle = await window.showDirectoryPicker();

这个时候我们就只能选择文件夹了,选择文件夹后得到的是一个文件夹的句柄,它有以下几个方法:

entries
:返回一个AsyncIterable
对象,用于获取目录中的所有文件和目录;keys
:返回一个AsyncIterable
对象,用于获取目录中的所有文件和目录的名称;values
:返回一个AsyncIterable
对象,用于获取目录中的所有文件和目录的FileSystemHandle
句柄对象;getFileHandle
:返回一个Promise
对象,用于获取目录中的文件句柄;getDirectoryHandle
:返回一个Promise
对象,用于获取目录中的目录;removeEntry
:返回一个Promise
对象,用于删除目录中的文件或目录;resolve
:返回一个Promise
对象,用于获取目录中的文件或目录;
下面是使用entries来获取整个文件夹的每个文件句柄对象的例子:
JS
const directoryHandle = await window.showDirectoryPicker();
for await (const [name, handle] of directoryHandle.entries()) {
if (handle.kind === 'file') {
const fileHandle = await directoryHandle.getFileHandle(name);
console.log(fileHandle); // 得到文件句柄
} else {
const directoryHandle_child = await directoryHandle.getDirectoryHandle(name);
console.log(directoryHandle_child);
// 得到文件夹句柄
}
}
如果我们希望递归得到每个文件的句柄也可以这样做;
JS
const directoryHandle = await window.showDirectoryPicker(); // 选择一个文件夹
const getFileHandleLoop = (directoryHandle)=>{
for await (const [name, handle] of directoryHandle.entries()) {
if (handle.kind === 'file') {
const fileHandle = await directoryHandle.getFileHandle(name);
console.log(fileHandle); // 得到文件句柄
} else {
const directoryHandle_child = await directoryHandle.getDirectoryHandle(name);
getFileHandleLoop(directoryHandle_child) // 递归调用
}
}
}
getFileHandleLoop(directoryHandle)
showSaveFilePicker
这个方法可以创建一个文件,通常用于往文件中写入一些数据。
ts
const fileHandle = await window.showSaveFilePicker();
const writable = await fileHandle.createWritable();
await writable.write("hello world!");
await writable.close();
效果如下:

返回的依然是一个FileSystemFileHandle
类型的数组,这个类型在前面已经提过了,因此就不重复分析了。
小结:
通过以上的内容,我们知道了一个结论,至少在主流浏览器中,我们完全可以在从磁盘中读取和写入内容。web应用程序即便在不借助于webStorage 、indexDB等也可以在计算机持久化存储大量的数据。但是实际上提到的三个唤出文件句柄的API的兼容性是需要考量的。

他们无一例外的都在firefox 和safari中无法使用,在移动端所有的主流浏览器中都无法使用,因此使用过程中应该酌情考虑。
下面的内容是我们在其他浏览器中应该如何处理磁盘IO的一部分探索。
三、兼容性
那么怎么解决showOpenFilePicker 、showSaveFilePicker 、showDirectoryPicker 这几个API在fireFox和safrai中不支持的问题呢?
读
如果仅仅是读取文件,这个比较简单,我们可以使用html标签来完成;
js
<input type="file" id="input"/>
const input = document.getElementById("input");
input.addEventListener("input" , (e) => e.target.files )
这个标签所有浏览器都是支持的,点击它同样可以唤起一个文件选择框,我们可以使用它来获取一个文件内容,得到File类型的文件,然后就可以想怎么读取文件内容都可以。
写
关键是写不太好弄,但通过上面的分析我们可以知道,写文件的关键在于获取文件句柄。
但是很遗憾根据我目前搜集到的资料,firefox 并没有提供类似chrome 中showOpenFilePicker的API来获取一个文件句柄,但是你不可能跟老板说做不了这个功能吧,如果我们一定写入磁盘中,我们可以选择优雅的降级实现:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Firefox补丁实现写入磁盘</title>
</head>
<body>
<button onclick="writeToFile()">Write to File</button>
<script>
function writeToFile() {
const content = 'Hello, Firefox!';
const blob = new Blob([content], { type: 'text/plain' });
const a = document.createElement('a');
const url = URL.createObjectURL(blob);
a.href = url;
a.download = 'example.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
</body>
</html>
思路就是我们可以选择在程序中将内容封装中,然后将他下载下来,和写入磁盘不同的是,下载是创建了一个新的文件写入,而获得一个文件句柄可以在已经存在的文件写入。
案例
有一款知名的web应用程序,也许大家都用过,它就是draw.io,它提供了非常强大的绘图能力,其中就有读写本地磁盘的功能,我分析了它在各种浏览器上的表现,发现其实它和我上面提到的思路是一样的。
在chrome、edge、opera中它可以在已存在的文件中写入变动的内容。

只要你保存过后,它就自动写入了已有的文件中。但是在firefox、safrai中当你改动文件的时候,它是通过下载一个新的文件来保存的。每一次保存都会有一个新的文件被下载下来,通过这样的方式来实现持久化存储的。

全球知名的web应用程序都是使用的这个方案,当你跟产品做不了的时候,他也无话可说的。
四、参考资料
developer.mozilla.org/zh-CN/docs/... developer.mozilla.org/zh-CN/docs/...