在前端的世界里,除了通过input[file]上传文件外,我们是无法处理文件内容的,处理文件的逻辑都需要依赖于后端完成,现在html5提供了showOpenFilePicker(), showDirectoryPicker(), showSaveFilePicker()等API可以轻松地让我们在浏览器世界里来管理本地文件。所以现在我们来学习一下这个强大的功能。
API介绍
注意:我们访问一个文件或者目录的读写操作所依赖的文件访问权限在刷新或关闭页面并且页面所属的源没有其他标签页保持打开的情况下不会继续保有。
showDirectoryPicker(options)  
用于显示一个允许用户选择一个目录的目录选择器。
- options- id可选,通过指定 ID,浏览器可以记住不同 ID 所对应的目录。如果在另一个选择器中使用了相同的 ID,则选择器将在同一目录中打开。
- mode可选,默认为- "read",用于只读访问,或- "readwrite",用于读写访问。
- startIn可选,一个- FileSystemHandle对象或者代表某个众所周知的目录的字符串(如:- "desktop"、- "documents"、- "downloads"、- "music"、- "pictures"、- "videos"),用于指定选择器的起始目录。
 

如果选中的文件夹包含系统文件,将无法打开,并提示选择其他文件夹。

成功选择后,将返回FileSystemDirectoryHandle对象,如果取消(关闭系统弹框或者点击取消)时,将返回一个失败的Promise。

showOpenFilePicker(options)  
用于显示一个允许用户选择一个或多个文件的文件选择器,并返回这些文件的句柄。即使单选也是返回数组。
- options- excludeAcceptAllOption可选,默认为- false。默认情况下,选择器应包含一个不应用任何文件类型过滤器的选项(通过下面的类型选项启动)。将此选项设置为- true意味着该选项不可用。
- id可选,通过指定 ID,浏览器可以记住不同 ID 所对应的目录。如果在另一个选择器中使用了相同的 ID,则选择器将在同一目录中打开。(他会记住上次对应id选择的目录位置,在下次使用相同id打开的弹框将定位到对应的目录)
- multiple可选,默认为- false。当设置为- true时,可以选择多个文件。
- startIn可选, 一个- FileSystemHandle对象或一个众所周知的目录(- "desktop"、- "documents"、- "downloads"、- "music"、- "pictures"或- "videos")以指定打开选择器的起始目录。
- types可选,允许选择的文件类型的数组。每个项目都是一个具有以下选项的对象:- description可选,允许的文件类型类别的可选描述。默认为空字符串。
- accept一个- Object,其键设置为 MIME 类型,值设置为文件扩展名的数组。
 
 
const pickerOpts = {
  types: [
    {
      description: "Images",
      accept: {
        "image/*": [".png", ".gif", ".jpeg", ".jpg"],
      },
    },
  ],
  excludeAcceptAllOption: true,
  multiple: false,
};
showSaveFilePicker(options)  
用于显示允许用户保存一个文件的文件选择器。用户可以选择一个已有文件覆盖保存,也可以输入名字新建一个文件。
- options- excludeAcceptAllOption可选,默认为- false。默认情况下,选择器应包含一个不应用任何文件类型过滤器的选项(通过下面的类型选项启动)。将此选项设置为- true意味着该选项不可用。
- id可选,通过指定 ID,浏览器可以记住不同 ID 所对应的目录。如果在另一个选择器中使用了相同的 ID,则选择器将在同一目录中打开。
- startIn可选,一个- FileSystemHandle对象或一个众所周知的目录(- "desktop"、- "documents"、- "downloads"、- "music"、- "pictures"或- "videos")以指定打开选择器的起始目录。
- suggestedName可选,一个字符串。建议的文件名。
- types可选,允许选择的文件类型的数组。每个项目都是一个具有以下选项的对象:- description可选,允许的文件类型类别的可选描述。默认为空字符串。
- accept,一个- Object,其键设置为 MIME 类型,值设置为文件扩展名的数组。
 
 
async function getNewFileHandle() {
  const opts = {
    suggestedName: "自定义命名.txt",
    types: [
      {
        description: "Text file",
        accept: { "text/plain": [".txt"] },
      },
    ],
  };
  return await window.showSaveFilePicker(opts);
 
 FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemHandle
不管是选择文件还是文件夹, 确认选择后,返回的FileSystemDirectoryHandle,FileSystemFileHandle对象都继承自FileSystemHandle接口,所以我们先来了解下。
FileSystemHandle
代表一个文件或一个目录的对象。
- kind条目的类型。如果关联的条目是一个文件,则此值为- 'file',否则为- 'directory'。
- name关联的条目(文件或者文件夹)的名称。
isSameEntry(fileSystemHandle)  
用于比对两个句柄以查看两者关联的条目(文件或目录)是否相符。
queryPermission(descriptor)  
用于查询当前句柄目前的权限状态。返回值为'granted'、'denied' 或 'prompt'。如果返回prompt,则网站必须先调用 requestPermission(),然后才能对句柄执行任何操作。
- descriptor.mode值为- read'或- 'readwrite', 指定需要查询的权限模式
remove(options) 
允许你用对应的句柄直接移除一个文件或一个目录。
- options.recursive默认为- false。当设为- true并且条目是一个目录时,目录的内容将会被递归移除。如果目录中有内容那么- recursive必须设置为- true否则不能删除。

requestPermission(descriptor)  
用于为文件句柄请求读取或读写权限。返回值为'granted'、'denied' 或 'prompt'。如果返回prompt,则网站必须先调用 requestPermission(),然后才能对句柄执行任何操作。
- descriptor.mode值为- read'或- 'readwrite', 指定需要查询的权限模式
对于requestPermission来说,我如果开始showOpenFilePicker, showDirectoryPickermode未指定readwrite那么我们将可以调用queryPermission来让用户授权。
 const dir = await window.showDirectoryPicker({
    id: "dir",
    mode: "read",
  })
  dir.requestPermission({
    mode: "readwrite"
  }).then(async res => {
      console.log(res)
      console.log(dir, await dir.queryPermission())
      console.log("======")
  })

FileSystemDirectoryHandle
提供一个指向目录条目的句柄。该对象主要提供一些操作目录的方法,比如删除,创建(文件或目录),遍历等等。
entries()
一个异步迭代器,返回当前选中文件夹下所有直接子内容的键值对。键值对是一个 [key, value] 形式的数组。有了这个就可以递归获取当前文件夹下所有文件及文件夹的句柄对象了。
for await (const entry of dir.entries()) {
  console.log(entry)
}

values()
一个异步迭代器,返回当前选中文件夹下所有直接子内容的值(句柄对象)。
for await (const value of dir.values()) {
  console.log(value)
}

keys()
一个异步迭代器,返回当前选中文件夹下所有直接子内容的文件名。
for await (const key of dir.keys()) {
  console.log(key)
}

removeEntry(name, options)  
用于尝试将目录句柄内指定名称的文件或目录移除。
- name文件名
- options.recursive默认为- false。当设为- true时,条目将会被递归移除。
resolve(possibleDescendant)  
一个包含从父目录前往指定子条目中间的目录的名称的数组。数组的最后一项是子条目的名称。
- 要返回其相对路径的 FileSystemHandle对象。
下面这个方法就是一个文件夹中查找一个文件,返回当前文件相对于文件夹的路径。
async function returnPathDirectories(directoryHandle) {
  
  const [handle] = await self.showOpenFilePicker();
  if (!handle) {
    
    return;
  }
  
  const relativePaths = await directoryHandle.resolve(handle);
  return relativePaths
}
getDirectoryHandle(name, options)
返回一个位于调用此方法的目录句柄内带有指定名称的子目录的FileSystemDirectoryHandle。
- name子目录名称
- options.create默认为- false。当设为- true时,如果没有找到对应的目录,将会创建一个指定名称的目录并将其返回。 此时获取句柄对象的- mode必须设置为- readwrite。 如果初始化未设置,我们需要通过- requestPermission请求对应的权限。

const dir = await window.showDirectoryPicker({
    id: "dir",
    mode: "read",
})
const res2 = dir.getDirectoryHandle("zh", {
  create: true
})
console.log("res2", res2)

getFileHandle()
返回一个位于调用此方法的目录句柄内带有指定名称的文件的 FileSystemFileHandle。
- name子文件名称
- options.create默认为- false。当设为- true时,如果没有找到对应的文件,将会创建一个指定名称的文件并将其返回。 此时获取句柄对象的- mode必须设置为- readwrite。 如果初始化未设置,我们需要通过- requestPermission请求对应的权限。
FileSystemFileHandle
提供一个指向文件条目的句柄。该对象主要提供一些操作文件的方法,比如写入,获取等等。
move(newName?, options)
允许你移动或重命名用户本地文件系统中的文件。
- newName新文件名
- options- to: 目标目录的- FileSystemDirectoryHandle(用于跨目录移动)
- overwrite: 默认- false,是否覆盖同名文件
 
createWritable(options)  
用于创建一个FileSystemWritableFileStream 对象,可用于写入文件。
任何通过写入流造成的更改在写入流被关闭前都不会反映到文件句柄所代表的文件上。这通常是将数据写入到一个临时文件来实现的,然后只有在写入文件流被关闭后才会用临时文件替换掉文件句柄所代表的文件。
async function writeFile(fileHandle, contents) {
  
  const writable = await fileHandle.createWritable();
  
  await writable.write(contents);
  
  await writable.close();
}
- keepExistingData默认为- false。当设为- true时,如果文件存在,则先将现有文件的内容复制到临时文件,否则临时文件初始时内容为空。
- mode可选, 指定可写文件流的锁定模式的字符串。默认值为- "siloed"。- "exclusive"只能打开一个- FileSystemWritableFileStream写入器。在第一个写入器关闭之前尝试打开后续写入器会导致抛出- NoModificationAllowedError异常。
- "siloed"可以同时打开多个- FileSystemWritableFileStream写入器,每个写入器都有自己的交换文件,例如在多个标签页中使用同一个文件时。最后打开的写入器会写入其数据,因为每个写入器关闭时都会刷新数据。
 
getFile()  
返回一个File对象,其表示磁盘上句柄所代表的文件。如果磁盘上的文件在调用了此方法后发生了更改或是被移除,那么返回的File 对象可能会不再可读。
const [file] = await window.showOpenFilePicker({
  id: "file"
})
console.log(file)
console.log(await file.getFile())

FileSystemWritableFileStream
获取文件句柄,主要就是为了写入文件的。所以我们再来看看FileSystemWritableFileStream 对象。
- locked表示可写流是否已锁定。如果当前可写流调用了- getWriter()那么他将被锁定,不能进行任何操作。
- mode可写流的锁定模式的字符串。(- "siloed",- "exclusive")
abort(reason)  
用于中止流,表示生产者不能再向流写入数据(会立刻返回一个错误状态),并丢弃所有已入队的数据。一般用于不会流错误时终止。
- reason一个字符串,用于提供人类可读的中止原因。
writer.abort("终止写入")

close()
关闭可写流。
getWriter()
返回一个新的 WritableStreamDefaultWriter 实例并且将流锁定到该实例。当流被锁定时,直到这个流被释放之前,不能操作其他 writer。
seek(position)  
用于更新文件当前指针的偏移到指定的位置(以字节为单位)。主要改变写入内容插入的位置。
- position一个数字,表示从文件开头起的字节位置。 这里我们就可以结合- getFile()获取文件大小,然后设置内容插入位置。来实现追加文件内容的效果。但是需要指定- createWritable({ keepExistingData: true })才会保留以前的文件内容。
const writableStream = await newHandle.createWritable({
  keepExistingData: true
});
const file = await newHandle.getFile()
console.log("file", file.size)
await writableStream.seek(file.size)
await writableStream.write("追加的内容")

truncate(size)  
用于将与流相关联的文件调整为指定字节大小(删除文件内容到对应的字节)。如果指定的大小大于文件当前的大小,文件会被用 0x00(即空格) 字节补充。调用 truncate() 方法同时也会更新文件的指针。和seek()一样
write(data/options)
用于在调用此方法的文件上的当前指针偏移处写入内容。传入一个options对象可以是truncate, seek, write的结合体。
- data用于写入的文件数据,可以是- ArrayBuffer、- TypedArray、- DataView、- Blob或 字符串。
- options- type一个字符串,值为- "write"、- "seek"或- "truncate"之一。
- data用于写入的文件数据,可以是- ArrayBuffer、- TypedArray、- DataView、- Blob或 字符串。这个属性在- type被设为- "write"时是必需的。
- position当- type为- "seek"时,表示文件当前指针应该移动到的位置。当- type被设为- "write"时也可以使用,这种情况下将会在指定的位置开始写入。
- size一个数字,表示流应当包含的字节数。这个属性在- type被设为- "truncate"时是必需的。
 
既然可以调整流的控制权,那我们就来了解下WritableStreamDefaultWriter独有的API
- closed当前流是否被关闭或者释放锁定(调用- releaseLock())
- desiredSize返回填充满流的内部队列需要的大小。如果无法成功写入流(由于流发生错误或者中止入队),则该值为- null,如果流关闭,则该值为 0。
- ready当流填充内部队列的所需大小从非正数变为正数时兑现,表明它不再应用背压。
desiredSize, ready 可用背压控制。
async function writeWithBackpressure(dataChunks) {
  const writer = writableStream.getWriter();
  
  for (const chunk of dataChunks) {
    
    if (writer.desiredSize <= 0) {
      console.log('背压:等待流准备就绪');
      await writer.ready; 
    }
    
    await writer.write(chunk);
    console.log('已写入:', chunk);
  }
  
  await writer.close();
}
const chunks = ['数据A', '数据B', '数据C'];
writeWithBackpressure(chunks);
releaseLock()  
用于释放 writer 对相应流的锁定。释放锁后,writer 将不再处于锁定状态。如果释放锁时关联的流出错,writer 随后也会以同样的方式发生错误;此外,writer 将会关闭。
const [newHandle] = await showOpenFilePicker()
const writableStream = await newHandle.createWritable({
    keepExistingData: true
});
const writer =  writableStream.getWriter() 
writer.write("0000000")
writer.write("111111111111")
writer.releaseLock()
writableStream.write("222222222222")
writableStream.close()

abort(), write(), close()
该方法使用方式同上。
使用WritableStreamDefaultWriter对象操作写入文件的原因
锁机制保证写入安全
如果直接操作流,那么多个页面都可以同时访问一个文件进行操作,导致数据写入混乱。getWriter() 会为流加锁,确保同一时间只有一个写入器活跃。
背压(Backpressure)管理
如果数据生产速度远大于消费速度(如写入大文件),可能导致内存溢出。通过 writer.desiredSize 和 writer.ready 动态控制写入节奏。
如何在开发中使用?
了解了上面的相关API后,我们就来看一下在开发中可以有哪些具体的使用。
分块写入录屏流
前端可以知道的录制浏览器标签页,没有黑魔法
遍历当前选中的目录(及子目录)
async function readDir () {
  const dirHandle = await window.showDirectoryPicker();
  return await recursiveReadDir(dirHandle)
  async function recursiveReadDir(dirHandle) {
    const entries = [];
    for await (const entry of dirHandle.values()) {
      if (entry.kind === 'file') {
        entries.push({
          name: entry.name,
          kind: entry.kind,
        });
      } else if (entry.kind === 'directory') {
        entries.push({
          name: entry.name,
          kind: entry.kind,
          children: await recursiveReadDir(entry)
        });
      }
    }
    return entries
  }
}

递归删除指定目录
async function recursiveRemvoeDir() {
    const dir = await window.showDirectoryPicker()
    dir.remove({
        recursive: true
    })
}
批量处理文件
例如批量修改文件名
async function batchRenameImages() {
  const dirHandle = await window.showDirectoryPicker();
  for await (const entry of dirHandle.values()) {
    if (entry.kind === 'file' && entry.name.endsWith('.jpg')) {
      
      const newName = entry.name.replace('.jpg', '@zh.jpg');
      await entry.move(newName);
    }
  }
}
追加文件内容
默认情况下写入文件都是直接覆盖写入。
async function appendFile(data) {
  const [newHandle] = await showOpenFilePicker()
  const writableStream = await newHandle.createWritable({
    keepExistingData: true 
  });
  const {size} = await newHandle.getFile()
  await writableStream.seek(size)
  await writableStream.write(data)
  await writableStream.close()
}
分片写入大文件
async function saveLargeFile(url) {
  
  const fileHandle = await window.showSaveFilePicker();
  const writableStream = await fileHandle.createWritable();
  const writer = writableStream.getWriter();
  
  const response = await fetch(url);
  const reader = response.body.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    await writer.write(value);
  }
  await writer.close();
  console.log('文件保存完成');
}
saveLargeFile("https://p26-passport.byteacctimg.com/img/user-avatar/b515ec49c88a2c9e23fb5727652cb8f9~90x90.awebp")
转自https://juejin.cn/post/7494870286645346355
该文章在 2025/9/8 14:37:05 编辑过