浏览器文件系统 API

27 阅读2分钟

浏览器有三个访问文件系统的 API

  • showOpenFilePicker:打开文件选择器,可以选择一个或多个文件
  • showDictoryPicker:打开目录选择器,选择一个文件夹
  • showSaveFilePicker:打开保存文件对话框

此功能仅在安全上下文(HTTPS)或 localhost 中可用,且必须由用户手势(如点击)触发。

另仅在部分浏览器中支持,Firefox 和 Safari 中暂不可用

showOpenFilePicker

showOpenFilePicker 语法如下

const fileHandles = await window.showOpenFilePicker(options);

入参 options 包含如下配置项:

  • multipleboolean,是否支持选择多个文件,默认为 false

  • types:数组,允许选择的文件类型数组

    const options = {
      types: [
        {
          description: '图片文件',
          accept: {
            'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp']
          }
        },
        {
          description: '文本文件',
          accept: {
            'text/plain': ['.txt', '.md']
          }
        }
      ]
    };
    
  • excludeAcceprAllOptionboolean,是否排除「所有文件」的选项,默认是 false,即可以选择所有文件,如果设置为 true,那么就不能选择任意文件,但是就必须得设置 types 属性,否则就会抛出 TypeError 异常

    // 只允许用户选择图片
    const fileHandlers = await showOpenFilePicker({
      types: [{
        description: '图片',
        accept: { 'image/*': ['.png', '.jpg'] }
      }],
      excludeAcceptAllOption: true
    });
    
  • startIn:起始目录,可选值有 desktopdocumentsdownloadsmusicpicturesvideos,如果没有设置此属性,分为两种情况

    • 首次使用:通常打开用户的「文档」文件夹或系统默认位置
    • 后续使用:浏览器会记住上次选择的目录,自动从那里开始

返回值是 FileSystemFileHandle 对象数组(即使选择一个文件,返回的也是一个数组),可通过 FileSystemFileHandle 对象读取文件、写入文件、查询文件权限等。

FileSystemFileHandle 包含两个属性:

  • kind:始终为 "file"
  • name:文件名,包含扩展名
const [fileHandle] = await showOpenFilePicker();

// File 对象属性
console.log(fileHandle.kind); // file
console.log(fileHandle.name); // 文件名,如 demo.txt

包含如下方法可以用来操作文件:

  • getFile:获取到相应的 File 对象,可用来读取文件内容

    const [fileHandle] = await showOpenFilePicker();
    
    // 获取 File 对象
    const file = await fileHandle.getFile();
    
    // File 对象属性
    console.log(file.name);         // 文件名
    console.log(file.size);         // 文件大小(字节)
    console.log(file.type);         // MIME 类型
    console.log(file.lastModified); // 最后修改时间戳
    
    // 读取文件内容的几种方式
    const text = await file.text();               // 读取为文本
    const arrayBuffer = await file.arrayBuffer(); // 读取为 ArrayBuffer
    const blob = await file.slice(0, 100);        // 读取部分内容
    

    读取多张图片并显示

    const displayImages = (files) => {
      gallery.innerHTML = '';
    
      files.forEach(file => {
        const url = URL.createObjectURL(file);
    
        const card = document.createElement('div');
        card.className = 'image-card';
        card.innerHTML = `
          <img src="${url}" alt="${file.name}" loading="lazy">
          <div class="image-info">
            <div class="image-name" title="${file.name}">${file.name}</div>
            <div class="image-meta">${formatSize(file.size)}</div>
          </div>
        `;
    
        gallery.appendChild(card);
      });
    }
    
    const handles = await window.showOpenFilePicker({
      multiple: true,
      types: [{
        description: '图片文件',
        accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'] }
      }],
      excludeAcceptAllOption: true
    });
    const files = await Promise.all(handles.map(h => h.getFile()));
    displayImages(files);
    
  • createWritable:创建一个 FileSystemWritableFileStream,用于写入文件内容

    它不会直接实时修改原文件。当你创建流时,浏览器通常会创建一个临时文件,write 操作都是写入到这个临时文件中,只有当你调用 close() 方法时,浏览器才会将临时文件移动/重命名以替换原始文件。

    const [fileHandle] = await showOpenFilePicker();
    
    // 创建可写流
    const writable = await fileHandle.createWritable();
    
    // 写入方式一:直接写入字符串
    await writable.write('Hello, World!');
    
    // 写入方式二:写入 Blob
    await writable.write(new Blob(['Hello'], { type: 'text/plain' }));
    
    // 写入方式三:使用配置对象
    await writable.write({
      type: 'write',
      position: 0,      // 写入位置
      data: 'Hello'     // 写入内容
    });
    
    // 定位到指定位置
    await writable.write({
      type: 'seek',
      position: 10
    });
    
    // 截断文件
    await writable.write({
      type: 'truncate',
      size: 100
    });
    
    // 必须关闭流才能保存更改
    await writable.close();
    
    // 或者放弃写入,删除临时文件,不修改原始文件。
    await writable.abort()
    
  • queryPermission/requestPermission

    查询或请求文件的权限

    const [fileHandle] = await showOpenFilePicker();
    
    // 查询当前权限状态
    const readPermission = await fileHandle.queryPermission({ mode: 'read' });
    const writePermission = await fileHandle.queryPermission({ mode: 'readwrite' });
    
    // 权限状态值:'granted' | 'denied' | 'prompt'
    console.log(readPermission);  // 'granted'
    console.log(writePermission); // 'prompt'
    
    // 请求写入权限
    if (writePermission !== 'granted') {
      const result = await fileHandle.requestPermission({ mode: 'readwrite' });
      if (result === 'granted') {
        // 用户授予了权限,可以写入文件
        const writable = await fileHandle.createWritable();
        // ...
      }
    }
    

showDictoryPicker

showDirectoryPicker 语法如下

const directoryHandle = await window.showDirectoryPicker(options);

入参 options 包含如下属性:

  • id:一个可选的字符串,它的核心作用是帮助浏览器「记住」用户上次选择的目录位置

    当你指定一个 id(例如 "projects")时,浏览器会将用户选择的目录路径与这个 id 绑定。下次你再次调用 showDirectoryPicker 并传入相同的 id: "projects" 时,浏览器会尝试直接在文件选择对话框中打开上次那个目录。

    不同的 id 拥有独立的记忆空间,互不干扰。

  • mode:有 read(要求只读访问权限) 和 readwrite(要求读写访问权限) 两种取值,默认值为 read

  • startIn:起始目录,可选值有 desktopdocumentsdownloadsmusicpicturesvideos

返回值是一个 FileSystemDirectoryHandle 对象,同样包含两个属性:

  • kind:始终为 "directory"
  • name:目录名称

对象上存在如下方法:

  • entries,返回一个迭代器,AsyncIterableIterator<[string, FileSystemHandle]>

    另外还存在 keysvalues 两个方法,分别返回的文件(夹)名称和文件(夹)句柄

    const dirHandle = await showDirectoryPicker();
    
    for await (const [name, handle] of dirHandle.entries()) {
      console.log(`名称: ${name}`);
      console.log(`类型: ${handle.kind}`);  // 'file' 或 'directory'
    }
    
  • getFileHandle(name, options?)

    获取目录中指定名称的文件句柄

    options 可以配置 create 属性,是一个布尔值,默认值为 false,当 create 设置为 true 时,如果文件不存在则可以创建该文件(需要有写入权限,否则会抛出 NotAllowedError),如果设置为 false 且文件不存在,那么会抛出 NotFoundError

    如果指定的文件名对应的是目录而不是文件,那么会抛出 TypeMismatchError

    const dirHandle = await showDirectoryPicker({ mode: 'readwrite' });
    
    // 获取已存在的文件
    try {
      const fileHandle = await dirHandle.getFileHandle('config.json');
      const file = await fileHandle.getFile();
      const content = JSON.parse(await file.text());
      console.log(content);
    } catch (err) {
      if (err.name === 'NotFoundError') {
        console.log('文件不存在');
      } else if (err.name === 'TypeMismatchError') {
        console.log('这是一个目录,不是文件');
      }
    }
    
    // 创建新文件(如果不存在)
    const newFileHandle = await dirHandle.getFileHandle('data.txt', {
      create: true
    });
    
    // 写入内容
    const writable = await newFileHandle.createWritable();
    await writable.write('Hello, World!');
    await writable.close();
    
  • getDirectoryHandle(name, options?)

    获取目录中指定名称的目录句柄,options 参数也同 getFileHandle,可以指定一个 create 参数,当设置为 true 时,如果目录不存在则创建新目录

    const dirHandle = await showDirectoryPicker({ mode: 'readwrite' });
    
    // 获取已存在的子目录
    try {
      const srcDir = await dirHandle.getDirectoryHandle('src');
      console.log('src 目录存在');
    } catch (err) {
      if (err.name === 'NotFoundError') {
        console.log('src 目录不存在');
      }
    }
    
    // 创建新子目录
    const distDir = await dirHandle.getDirectoryHandle('dist', {
      create: true
    });
    
    // 递归创建嵌套目录结构
    async function mkdirp(rootHandle, path) {
      const parts = path.split('/').filter(Boolean);
      let current = rootHandle;
      for (const part of parts) {
        current = await current.getDirectoryHandle(part, { create: true });
      }
      return current;
    }
    
    // 创建 src/components/ui 目录
    const uiDir = await mkdirp(dirHandle, 'src/components/ui');
    
  • removeEntry(name, options?)

    删除目录中指定名称的文件或子目录

    options 中可指定一个 recursive,是一个布尔值,如果设置为 true,则会递归删除子目录及其内容,如果设置为 false,则只能删除空目录,否则会抛出 InvalidModificationError

    const dirHandle = await showDirectoryPicker({ mode: 'readwrite' });
    
    // 删除单个文件
    try {
      await dirHandle.removeEntry('temp.txt');
    } catch (err) {
      if (err.name === 'NotFoundError') {
        // 要删除的文件不存在
      }
    }
    
    // 删除空目录
    await dirHandle.removeEntry('empty-folder');
    
    // 递归删除非空目录
    await dirHandle.removeEntry('node_modules', {
      recursive: true
    });
    
  • resolve(possibleDescendant)

    计算从当前目录到指定句柄的相对路径。用于确定一个文件或目录是否在当前目录内,返回一个 Promise

    • string[] - 如果 possibleDescendant 是当前目录的后代,返回路径数组(从当前目录到目标的各级名称)
    • null - 如果 possibleDescendant 不是当前目录的后代
    const dirHandle = await showDirectoryPicker();
    
    // 获取子目录和文件
    const srcDir = await dirHandle.getDirectoryHandle('src');
    const componentsDir = await srcDir.getDirectoryHandle('components');
    const fileHandle = await componentsDir.getFileHandle('Button.tsx');
    
    // 解析相对路径
    const pathToFile = await dirHandle.resolve(fileHandle);
    console.log(pathToFile);  // ['src', 'components', 'Button.tsx']
    
    const pathToDir = await dirHandle.resolve(componentsDir);
    console.log(pathToDir);   // ['src', 'components']
    
    // 检查是否为后代
    const otherDir = await showDirectoryPicker();
    const isDescendant = await dirHandle.resolve(otherDir);
    if (isDescendant === null) {
      console.log('不是当前目录的后代');
    }
    
    // 获取完整路径字符串
    async function getRelativePath(rootHandle, handle) {
      const parts = await rootHandle.resolve(handle);
      return parts ? parts.join('/') : null;
    }
    
    const relativePath = await getRelativePath(dirHandle, fileHandle);
    console.log(relativePath) // 'src/components/Button.tsx'
    
  • isSameEntry(other)

    比较两个句柄是否指向同一个文件系统条目

    const dirHandle1 = await showDirectoryPicker();
    const dirHandle2 = await showDirectoryPicker();
    
    // 检查用户是否选择了同一个目录
    const isSame = await dirHandle1.isSameEntry(dirHandle2);
    if (isSame) {
      console.log('选择了相同的目录');
    } else {
      console.log('选择了不同的目录');
    }
    

示例:递归生成目录树

async function walkDirectory(dirHandle, path = '', depth = 0, maxDepth = 10) {
  const entries = [];

  if (depth > maxDepth) {
    return [{ type: 'info', name: '... (层级过深,已省略)' }];
  }

  for await (const entry of dirHandle.values()) {
    const entryPath = path ? `${path}/${entry.name}` : entry.name;

    if (entry.kind === 'file') {
      globalStats.files++;
      try {
        const file = await entry.getFile();
        globalStats.totalSize += file.size;
        entries.push({
          type: 'file',
          name: entry.name,
          path: entryPath,
          size: file.size,
          lastModified: file.lastModified,
          mimeType: file.type
        });
      } catch {
        entries.push({ type: 'file', name: entry.name, path: entryPath, size: 0 });
      }
    } else {
      globalStats.folders++;
      entries.push({
        type: 'directory',
        name: entry.name,
        path: entryPath,
        children: await walkDirectory(entry, entryPath, depth + 1, maxDepth)
      });
    }
  }

  // 排序:文件夹在前,文件在后,按名称排序
  entries.sort((a, b) => {
    if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
    return a.name.localeCompare(b.name);
  });

  return entries;
}

示例:文件搜索

async function findFiles(dirHandle, pattern, path) {
  for await (const entry of dirHandle.values()) {
    const entryPath = path ? `${path}/${entry.name}` : entry.name;

    if (entry.kind === 'file') {
      if (pattern.test(entry.name)) {
        try {
          const file = await entry.getFile();
          searchResults.push({
            handle: entry,
            name: entry.name,
            path: entryPath,
            size: file.size,
            type: file.type,
            lastModified: file.lastModified
          });
        } catch {
          searchResults.push({ handle: entry, name: entry.name, path: entryPath, size: 0 });
        }
      }
    } else {
      await findFiles(entry, pattern, entryPath);
    }
  }
}

showSaveFilePicker

showSaveFilePicker() 方法用于显示一个文件保存对话框,允许用户选择保存文件的位置和文件名

const fileHandle = await window.showSaveFilePicker();
const fileHandle = await window.showSaveFilePicker(options);

options 参数可配置如下参数:

  • id:用以记住选择的目录
  • types:允许保存文件类型数组
  • excludeAcceptAllOption:可否选择
  • suggestedName:建议的文件名,会预填充到保存对话框中
  • startIn:起始的文件目录,可选值有 desktopdocumentsdownloadsmusicpicturesvideos

返回值为 Promise<FileSystemFileHandle>,是用户选择的保存位置的文件句柄,据此创建一个可写流,写入内容并保存。

// 保存文本文件
async function saveTextFile(content) {
  const handle = await window.showSaveFilePicker({
    suggestedName: 'document.txt',
    types: [{
      description: '文本文件',
      accept: {
        'text/plain': ['.txt']
      }
    }]
  });

  const writable = await handle.createWritable();
  await writable.write(content);
  await writable.close();

  return handle.name;  // 返回用户选择的文件名
}

// 保存 JSON 文件
async function saveJsonFile(data) {
  const handle = await window.showSaveFilePicker({
    suggestedName: 'data.json',
    types: [{
      description: 'JSON 文件',
      accept: {
        'application/json': ['.json']
      }
    }]
  });

  const writable = await handle.createWritable();
  await writable.write(JSON.stringify(data, null, 2));
  await writable.close();
}

// 保存图片(Blob)
async function saveImage(blob, suggestedName = 'image.png') {
  const handle = await window.showSaveFilePicker({
    suggestedName,
    types: [{
      description: '图片文件',
      accept: {
        'image/png': ['.png'],
        'image/jpeg': ['.jpg', '.jpeg'],
        'image/webp': ['.webp']
      }
    }]
  });

  const writable = await handle.createWritable();
  await writable.write(blob);
  await writable.close();
}

showOpenSavePicker 可以配合 showOpenFilePicker 选择文件编辑后,然后另存为新文件

async function openEditSave() {
  // 1. 打开文件
  const [openHandle] = await window.showOpenFilePicker({
    types: [{
      description: '文本文件',
      accept: { 'text/plain': ['.txt', '.md'] }
    }]
  });

  const file = await openHandle.getFile();
  let content = await file.text();

  // 2. 编辑内容
  content = content.toUpperCase();

  // 3. 另存为新文件
  const saveHandle = await window.showSaveFilePicker({
    suggestedName: `${file.name.replace(/\.[^.]+$/, '')}_modified.txt`,
    types: [{
      description: '文本文件',
      accept: { 'text/plain': ['.txt'] }
    }]
  });

  const writable = await saveHandle.createWritable();
  await writable.write(content);
  await writable.close();
}

参考: