Web File System API是一种用于在Web浏览器中访问和操作本地文件系统的API。它允许Web应用程序以安全的方式读取和写入用户的文件,而无需将文件上传到服务器。允许程序与用户本地设备上的或是用户能够访问的网络文件系统上的文件进行交互。核心功能包括读取文件、写入或保存文件以及访问目录结构。

实验性功能,只有在Chromiun内核PC浏览器支持,也就是Chrome和MicroSoft Edge。

image-20231212212334121

功能比较强大,但是处于实验性阶段,并且大部分浏览器不支持,仅在HTTPS安全上下文可用,需要用户手势触发(Buff拉满)。

API 概览

基本上所有的文件操作都基于句柄完成,父类 FileSystemHandle 派生出两个子类:FileSystemFileHandleFileSystemDirectoryHandle,分别表示文件句柄和目录句柄。

句柄可以通过showOpenFilePicker/showSaveFilePicker和showDirectoryPicker选择文件获取,还可以通过DataTransferItem.getAsFileSystemHandle(拖拽)和File Handling API获取。

句柄

FileSystemHandle

代表一个条目,多个句柄可以指代同一个条目(文件或目录)。

  • kind:返回条目的类型。如果关联的条目是一个文件,则此值为 'file',否则为 'directory'
  • name:关联的条目名称;
  • isSameEntry(fileSystemHandle)->boolean:对比两个句柄关联的条目是否相同;
  • queryPermission(fileSystemHandlePermissionDescriptor)->’granted’ | ‘denied’ | ‘prompt’:查询当前句柄的权限状态,fileSystemHandlePermissionDescriptor是一个对象{mode: 'read' | 'readWrite'}。“prompt”:站点必须在对句柄进行任何操作前调用 requestPermission() 请求授权;“denied”:任何操作都会被拒绝;初始时对只读权限状态返回“granted”,从 IndexedDB 获取的句柄也有可能会返回“prompt”;
  • remove(options)->undefined:直接移除一个文件或一个目录,通过{recursive: true}控制递归删除;
  • requestPermission(fileSystemHandlePermissionDescriptor)->’granted’ | ‘denied’ | ‘prompt’:为句柄请求权限,参数和返回值同queryPermission。

但是基本上不会直接用到,而是使用子类。

FileSystemFileHandle

通过showOpenFilePicker访问此接口,读写操作所依赖的文件访问权限在刷新或关闭页面并且页面所属的源没有其他标签页保持打开的情况下不会继续保有。

  • getFile():获取File对象;
  • createWritable():创建一个新的可写流 FileSystemWritableFileStream 对象,可用于写入文件;
  • createSyncAccessHandle():获取 FileSystemSyncAccessHandle 对象,用于同步读写文件(仅在web worker中可用)。

FileSystemDirectoryHandle

目录句柄, 可以通过 window.showDirectoryPicker()StorageManager.getDirectory()DataTransferItem.getAsFileSystemHandle()FileSystemDirectoryHandle.getDirectoryHandle() 获取。

下面所有方法均返回Promise

  • getDirectoryHandle(name, options):获取子目录句柄(FileSystemDirectoryHandle),通过name(FileSystemHandle.name)读取子目录的文件句柄,options.write设置为true时如果没有找到对应子目录则创建;

  • getFileHandle(name, options):获取目录句柄内指定的文件句柄(FileSystemFileHandle),参数同getDirectoryHandle;

  • removeEntry(name, options):移除目录句柄内的文件或目录,通过name指定要移除的条目,options.recursive设置为true可对目录进行地柜删除;

  • resolve(possibleDescendant):解析给定的possibleDescendant(文件或目录句柄)的相对路径,返回一个路径数组,指定的条目不在当前目录下返回null;

  • 另外还有几个异步迭代器,entries,keys,values,用法和Object上的方法类似,只不过是异步的。

文件选择器

showOpenFilePicker

选择文件句柄,返回由文件句柄组成的数组,因为文件可以多选。

selectBtn.addEventListener('click', async () => {
  const handles = await showOpenFilePicker({
    types: [
      {
        description: 'Text',
        accept: {
          'text/plain': ['.txt'],
        },
      }
    ],
    multiple: true,
    startIn: 'documents',
  });
  console.log(handles);
})

image-20231215200904287

返回的句柄如下

image-20231215200952708

配置项都在代码示例中写了,属于一眼就能看明白的那种,另外有一个excludeAcceptAllOption配置用于控制是否隐藏所有文件的过滤器(上面文件选择器截图左下角的【显示选项】)。

showSaveFilePicker

打开文件保存窗口,配置同showOpenFilePicker差不多,多了suggesedName用于默认文件名,少了multiple选项。

selectBtn.addEventListener('click', async () => {
  const handle = await showSaveFilePicker({
    suggestedName: 'test.txt',
    startIn: 'downloads',
    types: [
      {
        description: 'Text',
        accept: {
          'text/plain': ['.txt'],
        },
      }
    ],
  })
})

image-20231215202909434

showDirectoryPicker

打开目录选择器,获取目录句柄

  • id:浏览器会记住id对应的目录,再次使用id打开选择器会直接定位到选择的目录;
  • mode:默认read,只能访问,修改为readwrite可对目录进行读写操作。
selectBtn.addEventListener('click', async () => {
  const handle = await showDirectoryPicker({
    id: 'test1',
    startIn: 'documents',
    mode: 'readwrite',
  })
  console.log(handle);
})

image-20231215204532739

实战演练

文本编辑

通过文件选择器选择txt文件,在页面中编辑后保存,可以做一个网页文本编辑工具(虽然没啥用),实际上我们可以对任何格式文件进行编辑,甚至是我们自定义的文件格式。

const selectBtn = document.getElementById("selectBtn") as HTMLButtonElement;
const saveBtn = document.getElementById("saveBtn") as HTMLButtonElement;
const txt = document.getElementById("txt") as HTMLTextAreaElement;

let handle: FileSystemFileHandle;

selectBtn.addEventListener('click', async () => {
  const [fileHandle] = await showOpenFilePicker({
    types: [
      {
        description: 'Text',
        accept: {
          'text/plain': ['.txt'],
        },
      }
    ],
    multiple: false,
    startIn: 'documents',
  });
  handle = fileHandle;
  const file = await fileHandle.getFile();
  const text = await file.text();
  txt.value = text;
})

saveBtn.addEventListener('click', async () => {
  const value = txt.value;
  if (handle) {
    const writable = await handle.createWritable();
    await writable.write(value);
    await writable.close();
  }
})

打开文件,读取内容

image-20231215214126740

在网页编辑后保存内容,可以看到文本已经更新

image-20231215214258121

流式写入MediaRecorder数据

之前学习媒体录制的时候是将Blob数据保存在内存中,在结束录制的时候一次性写入文件系统,这种方式对内存的压力较大,如果使用流的方式进行写入,可以大大减小内存的压力,提升系统的流畅性。

function getToday() {
  return new Date()
    .toLocaleDateString()
    .split('/')
    .join('-')
}

const recordBtn = document.getElementById("record") as HTMLButtonElement;
const stopBtn = document.getElementById("stop") as HTMLButtonElement;
const shareBtn = document.getElementById("share") as HTMLButtonElement;

let mediaRecorder: MediaRecorder;
let writable: FileSystemWritableFileStream;
let stream: MediaStream;
let timer: NodeJS.Timeout

async function share() {
  stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
  mediaRecorder = new MediaRecorder(stream, {
    mimeType: 'video/webm; codecs=vp9',
    audioBitsPerSecond: 128000,
    videoBitsPerSecond: 2500000,
  });
  mediaRecorder.ondataavailable = (e) => {
    const blob = new Blob([e.data], { type: 'video/webm' });
    if (writable) {
      console.log('write chunk', blob.size);
      
      writable.write(blob);
    }
    if (mediaRecorder.state === 'inactive') {
      writable.close();
    }
  }
}

async function record() {
  const fileHandle = await showSaveFilePicker({
    suggestedName: `录制${getToday()}.webm`,
    startIn: 'downloads',
    types: [
      {
        description: 'Webm',
        accept: {
          'video/webm': ['.webm'],
        },
      }
    ],
  })

  writable = await fileHandle.createWritable();
  mediaRecorder.start();
  
  setInterval(() => {
    mediaRecorder && mediaRecorder.requestData();
  }, 10000)
}

async function stopRecord() {
  clearInterval(timer);
  mediaRecorder && mediaRecorder.stop();
  stream && stream.getTracks().forEach(track => track.stop());
}

shareBtn.addEventListener('click', share);
recordBtn.addEventListener('click', record);
stopBtn.addEventListener('click', stopRecord);
  • 分别对设置屏幕共享、录屏和停止的事件;
  • 通过录制器的dataavailable事件,把录屏数据写入FileSystemFileHandle的writable,并且判断录制器的状态,如果是停止状态则关闭writable(dataavailable事件会在数据达到默认阈值、调用requestData、停止录制时触发);
  • 添加定时器进行requestData的调用,每10秒钟进行一次写入。

image-20231216182041877

可以看到,文件正在逐步写入。

已授权句柄持久化存储

如果你的应用是基于文件的在线编辑,你肯定不会希望用户在每次打开页面的时候都进行一次文件选择器操作,这时候可以使用IndexedDB来存储/读取句柄。

基于之前的文本编辑进行改造下,先读取数据库中是否有缓存,如果有的话需要请求一下授权,然后直接使用句柄;否则让用户选择文件,然后保存句柄到数据库。

const selectBtn = document.getElementById("selectBtn") as HTMLButtonElement;
const saveBtn = document.getElementById("saveBtn") as HTMLButtonElement;
const txt = document.getElementById("txt") as HTMLTextAreaElement;

let handle: FileSystemFileHandle;

selectBtn.addEventListener('click', async () => {
  const cacheHandle = await idbKeyval.get('handle');
  if (cacheHandle) {
    console.log('get handle from cache');
    
    handle = cacheHandle;
    await handle.requestPermission();
  } else {
    console.log('get handle from user pick');
    const [fileHandle] = await showOpenFilePicker({
      types: [
        {
          description: 'Text',
          accept: {
            'text/plain': ['.txt'],
          },
        }
      ],
      multiple: false,
      startIn: 'documents',
    });
    handle = fileHandle;
    await idbKeyval.set('handle', fileHandle);
  }

  
  const file = await handle.getFile();
  const text = await file.text();
  txt.value = text;
})

saveBtn.addEventListener('click', async () => {
  const value = txt.value;
  if (handle) {
    const writable = await handle.createWritable();
    await writable.write(value);
    await writable.close();
  }
})

image-20231216200244556

数据库操作使用了idb-keyval

TS 中没有File System API相关的接口定义,需要安装类型包npm install @types/wicg-file-system-access


前端小白