深入探索浏览器File API与本地文件操作实战

シ梓熙 框架 阅读 1,145
赞 17 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

上周接了个需求:让用户在网页里直接操作本地文件夹——增删改查、读写文本、甚至监控变化。你没听错,浏览器里操作本地文件系统。我第一反应是“这也能行?”但自从有了 File System Access API,还真能干。

深入探索浏览器File API与本地文件操作实战

别整那些虚的,先上一段核心代码,亲测有效:

async function openDirectory() {
  const dirHandle = await window.showDirectoryPicker();
  for await (const entry of dirHandle.values()) {
    console.log(entry.name, entry.kind); // 文件名 + 类型(file 或 directory)
  }
}

就这么几行,就能弹出系统选择框,选中一个文件夹后遍历里面所有文件和子目录。注意,for await...of 是必须的,因为 dirHandle.values() 返回的是异步迭代器。这里我踩过坑:一开始用 for...of 同步遍历,结果只拿到第一个就停了,折腾了半天才发现是异步问题。

这个场景最好用

我们有个内部工具,要批量处理 Markdown 文件生成配置。以前得手动拖拽上传,现在直接打开项目根目录,递归扫描所有 .md 文件:

async function scanMarkdownFiles(dirHandle) {
  const files = [];
  async function walk(handle) {
    if (handle.kind === 'file') {
      if (handle.name.endsWith('.md')) {
        const file = await handle.getFile();
        files.push({
          name: handle.name,
          path: handle.name, // 简化版路径,实际可拼接完整路径
          content: await file.text()
        });
      }
    } else if (handle.kind === 'directory') {
      for await (const entry of handle.values()) {
        await walk(entry);
      }
    }
  }
  await walk(dirHandle);
  return files;
}

调用方式也很简单:

const dirHandle = await window.showDirectoryPicker();
const mdFiles = await scanMarkdownFiles(dirHandle);
console.log(mdFiles); // 所有 Markdown 内容都在这儿了

重点来了:用户一旦授权访问某个目录,下次刷新页面还能继续操作吗?答案是:不能直接访问。但你可以用 permission API 缓存句柄:

async function rememberPermission(dirHandle) {
  const permission = await dirHandle.queryPermission({ mode: 'readwrite' });
  if (permission === 'granted') {
    // 可以考虑把 dirHandle 存到 IndexedDB,带上时间戳做缓存管理
    await saveHandleToStorage(dirHandle); // 自定义存储逻辑
  }
}

不过要注意,浏览器出于安全考虑,不会让你无限期保留权限。用户关闭页面或重启后,可能需要重新授权。目前 Chrome 和 Edge 支持较好,Firefox 还没跟上,Safari 基本别指望。

写入文件?小心权限问题

读是基础,写才是关键。我们的编辑器需要保存修改后的文件:

async function saveFile(handle, content) {
  const writable = await handle.createWritable();
  await writable.write(content);
  await writable.close();
}

看着挺简单,但我在这儿栽了好几次。第一次运行时报错:Uncaught DOMException: The user aborted a request。排查半天发现是因为没有显式请求写权限。正确姿势是:

async function ensureWritable(handle) {
  const permission = await handle.queryPermission({ mode: 'readwrite' });
  if (permission !== 'granted') {
    const newPerm = await handle.requestPermission({ mode: 'readwrite' });
    if (newPerm !== 'granted') {
      throw new Error('用户拒绝写入权限');
    }
  }
}

所以完整的保存流程应该是:

async function safeSaveFile(fileHandle, content) {
  await ensureWritable(fileHandle);
  const writable = await fileHandle.createWritable();
  await writable.write(content);
  await writable.close();
}

还有个细节:如果你要创建新文件而不是覆盖旧的,得先从目录句柄下手:

async function createNewFile(dirHandle, fileName, content) {
  const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
  await safeSaveFile(fileHandle, content);
  return fileHandle;
}

{ create: true } 很关键,否则找不到文件会报错。但如果文件已存在,它也不会报错,而是直接返回现有句柄——相当于自动复用。

踩坑提醒:这三点一定注意

  • HTTPS 是硬性要求:本地开发可以用 http://localhost,但部署到线上必须走 HTTPS,否则 API 根本不会暴露出来。别问我怎么知道的,线上环境突然挂掉的时候差点背锅。
  • 移动端基本不用想:手机浏览器基本不支持这些 API,尤其是 iOS。这个功能目前只适合桌面端内部工具或 Electron 类应用。
  • 文件句柄不是永久的:虽然可以存到 IndexedDB,但重启浏览器后再次使用时仍需重新验证权限。别指望一次授权永久生效。

还有一个容易被忽略的问题:大文件读写。我试过处理一个 200MB 的日志文件,直接 file.text() 导致页面卡死。后来改成流式读取:

async function readLargeFile(fileHandle) {
  const file = await fileHandle.getFile();
  const stream = file.stream();
  const reader = stream.getReader();
  let result = '';
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    result += new TextDecoder().decode(value, { stream: true });
    // 可在此处加进度反馈
  }
  return result;
}

虽然麻烦点,但至少不会把内存撑爆。

高级技巧:监听文件变化?其实不行

你以为能像 Node.js 那样用 fs.watch 监听文件变动?醒醒,浏览器没这功能。File System Access API 完全被动,没有事件机制。

但我们有个 workaround:轮询检测最后修改时间。虽然 low,但有效:

async function getFileLastModified(handle) {
  const file = await handle.getFile();
  return file.lastModified;
}

// 轮询检查
let lastTime = null;
async function startPolling(handle, interval = 3000) {
  while (true) {
    await new Promise(r => setTimeout(r, interval));
    const current = await getFileLastModified(handle);
    if (lastTime && current > lastTime) {
      console.log('文件被修改了!');
      // 触发重新加载或其他逻辑
    }
    lastTime = current;
  }
}

当然,频繁轮询会影响性能,建议只在用户处于编辑状态时开启,并提供开关选项。

拓展玩法:配合 Worker 处理密集任务

既然能读大文件,那不如把解析工作丢给 Web Worker。比如我们要分析一堆 JSON 日志:

// main thread
async function processLogsInWorker(handles) {
  const worker = new Worker('/log-processor.js');
  const files = await Promise.all(handles.map(h => h.getFile()));
  const data = files.map(f => ({
    name: f.name,
    lastModified: f.lastModified
  }));
  worker.postMessage({ type: 'start', files: data }, files.map(f => f.stream()));
}
// log-processor.js (Worker)
self.onmessage = async function(e) {
  if (e.data.type === 'start') {
    for (const stream of e.ports) {
      const reader = stream.getReader();
      let buffer = '';
      while (true) {
        const { done, value } = await reader.read();
        if (value) buffer += new TextDecoder().decode(value);
        // 按行解析等逻辑…
        if (done) break;
      }
    }
  }
};

利用 Transferable Streams,可以把文件流直接传给 Worker,避免主线程阻塞。这个组合拳在处理大型项目时特别有用。

结语:这玩意儿还没成熟,但真香

File System Access API 现在还是实验性功能,兼容性和稳定性都有限。但在特定场景下,比如内部管理系统、文档编辑器、代码片段工具,它的价值非常明显。

我的建议是:不要拿来搞通用产品,但如果你做的工具主要跑在 Chromium 内核浏览器上(比如公司统一配的电脑),完全可以大胆用。毕竟比起传统上传下载那一套,这种原生般的体验提升太大了。

以上是我踩坑后的总结,希望对你有帮助。这个技术的拓展用法还有很多,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论