欢迎来到 Jax 的专栏「Web 玩转文件操作」,快来了解 Web 端关于文件操作的方方面面!
转载请联系作者 Jax。
先来玩玩这个 Demo ------ 一个网页端的本地文件管理器。
在上面的 Demo 中,点击「打开文件夹」按钮后,你可以选择电脑本地的某个文件夹来打开。然后就能对其中的文件做各种操作了,包括新建、右键打开、编辑、删除等等。你会发现,在网页上的操作会立刻在本地文件上实际生效。
如果你感觉是:"哟?有点儿意思!",那么这篇文章就是专门为你而写的,读下去吧。
正所谓「边牧是边牧、狗是狗」,Web 应用早已不是当初的简陋网页了,它进化出了令人眼花缭乱的新能力,同时也在迅速追齐系统原生能力。如 Demo 所示,我们甚至可以把文件管理系统搬进网页,并且简单的 JavaScript 代码就能实现。Web 应用能有这样的威力,得益于浏览器对文件系统 API 的实现。
文件系统(File System Access)API 目前还处于社区草稿状态,但早在 2016 年,WHATWG 社区就已经开始着手设计这一标准,并且八年来一直在持续不断地打磨,迄今已经被各大主流浏览器所实现。
这套 API 足够简单易用,你很快就能掌握它,并且让它成为你的得力助手。但由于涉及到文件管理这个课题,它有很多方面都可以挖掘得很深很深,比如读写流、OPFS、安全策略等等。咱们这个专栏本着少吃多餐的原则,不会一次性囊括那么多内容。咱们的这第一铲,就先浅浅地挖一个「憨豆」 ------ FileSystemHandle
。
FileSystemHandle
在文件系统中,文件和文件夹是一个个实体,是所有状态和交互的核心。相对应地,在文件系统 API 中,我们用「FileSystemHandle
」这个对象来对实体进行抽象。为了能够简洁地描述,我们在下文中将称之为「憨豆」。我们用对象属性来保存实体的名称、类型,通过执行对象方法来操作实体。
那么 FileSystemHandle
从何而来呢?一般是从用户操作而来。以文件为例,当用户通过文件窗口选择了某个本地文件之后,我们就能从代码层面获取到通向这个文件的入口,从而可以对文件进行管理操作。除此之外,拖拽文件进入也能得到憨豆
属性:name 和 kind
name
:无论是文件还是文件夹,必然都有一个名字。
kind
:实体的类型,值为 'file'
代表文件;值为 'directory'
代表文件夹。
校验方法 isSameEntry()
用于判断两个憨豆是否代表相同的实体。例如用户上传图片时,先后两次选择的是同一张图片,那么两个憨豆指向的其实是同一个文件。
JavaScript
const [handle1] = await showOpenFilePicker() // 第一次上传
const [handle2] = await showOpenFilePicker() // 第二次选择了同一个文件
const isSame = await handle1.isSameEntry(handle2)
console.log(isSame) // true
该方法也同样适用于文件夹校验。
我们可以借此来检测重复性。
删除方法 remove()
用于删除实体。比如,执行下列代码会现场抽取一个幸运文件来删除:
JavaScript
const [handle] = await showOpenFilePicker()
handle.remove()
但如果我们想要选中文件夹来删除,像上面那样直接调用是会报错并删除失败的。我们需要额外传入配置参数:
JavaScript
handle.remove({ recursive: true })
传参后的删除,是对文件夹里面的嵌套结构进行递归删除,这个设计理念旨在让开发者和用户在删除前知悉操作后果。
权限方法 queryPermission() 和 requestPermission()
用于向用户查询和获取读写权限,可以传入一个参数对象来细分权限类型。
JavaScript
const permission = await handle.queryPermission({ mode: 'read' }) // mode 的值还可以是 readwrite
console.log(permission)// 若值为 'granted',则代表有足够权限
我们在对实体进行操作前,最好总是先查询权限。因此一个最佳实践是把这两个方法封装进通用操作逻辑中。
其他特性
除此之外,FileSystemHandle
还具有一些其他特性,比如可以转化为 indexDB 实例、可以通过 postMessage
传输到 Web Workers 里进行操作等等。我们会在后续的专栏文章中继续顺藤摸瓜地了解它们。
两个子类
到目前为止,FileSystemHandle
所具有的属性和方法都在上面了。你可能也意识到了,单靠这三招两式是不可能实现像 Demo 里那么丰富完备的文件操作的。
没错,这个憨豆只是一个抽象父类,它还有两个子类 FileSystemFileHandle
、FileSystemDirectoryHandle
,这两位才是文件和文件夹实体的真正代言人。对实体的常规操作,几乎都是通过这两个紫憨豆来执行的。
除了从父类继承而来的基因,两个紫憨豆都各自具有独特的属性和方法,满足了开发者对文件和文件夹两种不同实体的不同操作需求。
FileSystemFileHandle
在本专栏的上一篇文章《绕过中间商,不用 input 标签也能搞定文件选择》中,我们曾与文件憨豆有过一面之缘。那时候,我们通过 showOpenFilePicker
获取了文件憨豆,并调用它的 getFile
方法拿到了 文件 Blob
。
此外,文件憨豆还具有的方法如下:
createSyncAccessHandle()
:用于同步读写文件,但是仅限于在 Web Workers 中。createWritable
:创建一个写入流对象,用于向文件写入数据。
FileSystemDirectoryHandle
文件夹憨豆的特有方法如下:
getDirectoryHandle()
:按名称查找子文件夹。getFileHandle()
:按名称查找子文件。removeEntry()
:按名称移除子实体。resovle()
:返回指向子实体的路径。
经过上述罗列,我们对两种憨豆的能力有了一个大概的印象。下面,我们将回到 Demo 的交互场景,从具体操作上手,看看文件系统 API 是如何在 JavaScript 中落地的。
操作 & 用法
载入文件夹
我们首先需要载入一个文件夹,然后才能对其中的子实体进行操作。
如果你碰巧读过上一篇文章,知道如何用 showOpenFilePicker()
选择文件,那你一定能举一反三推断出,选择文件夹的方法是 showDirectoryPicker()
:
JavaScript
const dirHandle = await showDirectoryPicker()
showDirectoryPicker
方法也接收一些参数,其中 id
、startIn
这两个参数与 showOpenFilePicker
方法 的同名参数完全对应。另外还支持一个参数 mode
,其值可以是 read
或 readwrite
,用于指定所需的权限。
用户选择文件夹后得到的 dirHandle
,就是一个 FileSystemDirectoryHandle
格式的对象。我们可以遍历出它的子实体:
JavaScript
for await (const sub of dirHandle.values()) {
const { name, kind } = sub
console.log(name, kind)
}
从子实体中取出名称和类别属性值,就可以对文件夹内容一目了然了。
读取文件内容
在上一步中,我们已经读取到了子实体的名字和类型,那么对于文件类型,我们可以先按名称检索到对应的憨豆:
JavaScript
// if sub.kind === 'file'
const fileHandle = await dirHandle.getFileHandle(sub.name)
再从文件憨豆中掏出文件 Blob,进一步读取到文件的内容:
JavaScript
const file = await fileHandle.getFile()
const content = file.text()
如果你用来调试的文件是文本内容的文件,那么打印 content
的值,你就可以看到内容文本了。
同理,获取子文件夹的方式是 dirHandle.getDirectoryHandle(sub.name)
。
新建文件、文件夹
除了指定名称参数,getFileHandle
和 getDirectoryHandle
这两个方法还支持第二个参数,是一个一个配置对象 { create: true/false }
,用于应对指定名称的实体不存在的情况。
例如,我们对一个文件夹实体执行 dirHandle.getFileHandle('fileA')
,但该文件夹中并没有名为 fileA 的文件。此时第二个参数为空,等同于 create
的默认值为 false
,那么此时会抛出一个 NotFoundError
错误,提示我们文件不存在。
而如果我们这样执行:dirHandle.getFileHandle('fileA', { create: true })
,那么就会在当前文件夹中新建一个名为 fileA 的空文件。
同理,我们也可以用 dirHandle.getDirectoryHandle('dirA', { create: true })
新建一个名为 dirA 的空文件夹。
在 Demo 中,我们实现了让用户在新建文件时自定义文件名,这其实是使用了 prompt
方法:
JavaScript
const fileName = prompt('请输入文件名')
await dirHandle.getFileHandle(fileName, { create: true })
在当下这个 AI 时代,Prompt 更多是以「LLM 提示词」被大家初识。在更早时候,它是浏览器实现的一个组件。其实这两种形态的含义是一致的,都是在人机交互中,从一端到另一端的输入输出流程。
编辑文件内容
刚刚我们读取了文件的内容,现在我们来对文件内容进行修改,然后再存回去。
我们已经能够通过 getFile()
方法拿到文本内容,那应该把内容放到哪里进行编辑呢?你有很多种选择:富文本编辑器、给 div 设置 contenteditable
、唤起 VS Code...... 但本着最(能)小(用)原(就)则(行),我们还有更便捷的选项 ------ prompt!
prompt()
方法也支持第二个参数,我们把文本内容传入,弹出弹窗后,你就会看到内容已经填在了输入框中,现在就可以随意编辑里面的字符了。
JavaScript
const file = await fileHandle.getFile()
const fileContent = await file.text()
const newContent = prompt('', fileContent) // newContent 即为修改后的文件内容
但是点击 Prompt 的确认按钮并不会让新内容自动写入文件,这里就需要用到上面提到的 createWritable
了。下面是一个完整的写入流流程:
JavaScript
const writable = await fileHandle.createWritable() // 对待修改文件开启写入流
await writable.write(newContent) // 把新内容写入到文件
await writable.close() // 关闭写入流
至此,新的内容就已经被保存到文件中了。你可以在 Demo 中再次右键、打开文件查看内容,你会发现确实读取到了修改后的内容。
文件重命名
修改文件名也是文件管理中的常规操作,文件系统 API 也提供了对应的方法来实现。可能手快的同学已经在尝试 fileHandle.rename()
方法了。但 API 中还真没有这个方法,我们其实是要用一个 move()
方法。惊不惊喜意不意外?
因为从底层视角看,重命名文件和移动文件的处理过程类型,都是需要先新建一个文件(使用新的命名或者放到新的位置),再把源文件的数据复制过去,最后把源文件删除。目前在 Web 端还没有更高效的处理方式。
我们只需从 Prompt 获取新名称,再传给 move()
方法即可:
JavaScript
const newName = prompt('请输入新的文件名')
await fileHandle.move(newName)
这样,文件重命名就搞定了。
删除文件、文件夹
删除实体就没有那么多幺蛾子了,我们甚至不用对文件和文件夹做逻辑上的区分,简单直接地调用 currentHandle.remove({ recursive: true })
就行了。
但越是方便,就越要谨慎,因为涉及到删除用户的文件,所以如果要用于生产环境,最好给用户提供二次确认的机会。
写在结尾
恭喜你读完了本文,你真棒!
这次我们通过实现一个 Web 版文件管理器 demo,对文件管理 API 进行了较为深入的理解和实践。我再嘱咐两句:
- 涉及到操作用户文件,请务必谨慎。
- 为了保障安全性,文件系统 API 仅支持 https。
我是 Jax,在畅游 Web 技术领域的第 7 年,我仍然是坚定不移的 JavaScript 迷弟,Web 开发带给我太多乐趣。如果你也喜欢 Web 技术,或者想讨论本文内容,欢迎来聊!你可以通过下列方式找到我:
GitHub:github.com/JaxNext
微信:JaxNext