惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
B
Blog RSS Feed
宝玉的分享
宝玉的分享
腾讯CDC
博客园_首页
T
Tailwind CSS Blog
月光博客
月光博客
博客园 - 司徒正美
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
M
MIT News - Artificial intelligence
A
About on SuperTechFans
云风的 BLOG
云风的 BLOG
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
有赞技术团队
有赞技术团队
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
大猫的无限游戏
大猫的无限游戏
MongoDB | Blog
MongoDB | Blog
博客园 - 聂微东
V
Visual Studio Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
SecWiki News
SecWiki News
美团技术团队
P
Privacy International News Feed
H
Help Net Security
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Microsoft Security Blog
Microsoft Security Blog
Know Your Adversary
Know Your Adversary
Y
Y Combinator Blog
D
DataBreaches.Net
Project Zero
Project Zero
T
The Blog of Author Tim Ferriss
Cyberwarzone
Cyberwarzone
C
Cybersecurity and Infrastructure Security Agency CISA
C
Cisco Blogs
S
Schneier on Security
G
GRAHAM CLULEY
博客园 - 三生石上(FineUI控件)
Cisco Talos Blog
Cisco Talos Blog
小众软件
小众软件
Forbes - Security
Forbes - Security
D
Docker
T
Tenable Blog
S
Secure Thoughts
雷峰网
雷峰网
S
Security @ Cisco Blogs
T
The Exploit Database - CXSecurity.com
The Cloudflare Blog
博客园 - 【当耐特】
Spread Privacy
Spread Privacy
阮一峰的网络日志
阮一峰的网络日志

暗无天日

读:AI Agent 安全日志——从可见性与隐私的两难说起 - 暗无天日 AI写作的语言指纹——如何让文字不那么像机器 - 暗无天日 读:50 条 Claude Code 技巧——一个工程经理的六个月使用心得 读:AI 辅助开发为什么让 E2E 测试更有价值 - 暗无天日 读:在Emacs中使用Claude Code(Spacemacs适配版) - 暗无天日 Claude Code 背后的工程哲学——读 Agent Harness Engineering 读:Agent Harness Engineering——AI 智能体不只是模型,还有套件 - 暗无天日 browser-harness:让 AI 直接接管你的浏览器 - 暗无天日 读:Security-First CI/CD —— DevSecOps 自动化实践指南 TIL: 数字小键盘的小数点陷阱与行内算术求值 - 暗无天日 读:Immutability 不是万能药,它是一种权衡 - 暗无天日 Conducty:给 Claude Code 加上项目记忆和并行执行能力 - 暗无天日 读 — GitHub Trending 里的 Claude Code 技能包 读 — Prompt Caching 省钱指南 TIL: Emacs 中那些跟鼠标配合的冷门快捷键 - 暗无天日 读:Anvil——把 Emacs 变成 AI 的工具服务器 读:Emacs 代码折叠终极指南 - 暗无天日 读:Clojure 搭车客指南 - 暗无天日 git推送失败后恢复仓库损坏的完整记录 - 暗无天日 多智能体系统的两个有效模式——以及对 Claude Code 用户的启示 - 暗无天日 用 Org Babel 写 Literate 博文:扩展执行 + 定制导出 proced:Emacs 内置的进程查看器 - 暗无天日 从 proced 定制中学到的 Elisp 模式 读:让 Emacs proced 在 macOS 上显示 CPU 和内存 异步编程的函数着色税 - 暗无天日 链式调用的代价:JavaScript 和 Clojure 的共同教训 - 暗无天日 hyperfine:命令行基准测试工具 - 暗无天日 管道中的变量去哪了?——子 shell 作用域陷阱 - 暗无天日 开源包装器的信任陷阱:四个危险信号 - 暗无天日 程序员愿意为 AI 写文档,却不愿为同事写 - 暗无天日 mktemp: Shell 脚本中临时文件的安全陷阱与最佳实践 - 暗无天日 WSL9x —— 在 Windows 9x 里跑 Linux 内核 6.19 用 ox.el 做你想做的事 —— org-export 高级编程指南 读:Hot-wiring the Lisp Machine —— 用纯 Elisp 构建零依赖的 Org 静态站点生成器 Elisp 性能优化的六个实战教训 - 暗无天日 fcitx5 下 Emacs 无法切换输入法的排查 - 暗无天日 ERT 测试交互命令的三种方式 - 暗无天日 SEM Assistant: 当 Elisp 守护进程遇上 LLM 用 dmsg 给 Elisp 加上结构化调试日志 用 org-habit 追踪非每日习惯 - 暗无天日 Clojure X-Men:当编程语言特性变成超能力 - 暗无天日 TIL: 用 diff-hl 在 fringe 中显示 git 变更 读:llm-test —— 用 LLM agent 驱动 Emacs 测试 TIL: AI 时代的橡皮鸭调试 - 暗无天日 fcitx 启动后键盘输入卡顿的排查 - 暗无天日 TIL: 早期网页的图片热区导航 - 暗无天日 读 Seeing the Whole System 用 Emacs 自动生成每周链接推荐 - 暗无天日 读:ASCII control characters in my terminal 读 What to learn - 暗无天日 Lisp 的括号之痛——一个愚人节玩笑揭开的老伤疤 - 暗无天日 一本书该"线性读"还是"并行读" - 暗无天日 读 How to Monetize a Blog:一篇伪装成变现指南的讽刺文 Python Mock 第三方依赖的四种策略 - 暗无天日 Emacs Lisp 热重载实用指南 - 暗无天日 Prot 的 Emacs 配置哲学 - 暗无天日 TIL: 从直播对谈中学到的三个 Emacs 技巧 - 暗无天日 TIL: 自动使用项目虚拟环境的 Python - 暗无天日 TIL: 让 Help buffer 自动获得焦点 一条命令让本地开发用上 HTTPS —— slim 工具介绍 用 fsck 检查和修复 Linux 文件系统 排查Linux进程"卡死"实战:从strace到gdb全流程 - 暗无天日 PostgreSQL 索引:从基础到你可能不知道的高级用法 - 暗无天日 用 .pdbrc 自定义 Python 调试器 ANSI 转义码的标准化现状 - 暗无天日 终端程序的潜规则 - 暗无天日 PARA Org-mode 测试配置 - 暗无天日 AI越强越辣鸡?控制论说这是必然的 - 暗无天日 AI 越强越需要你盯着——反馈循环实操指南 - 暗无天日 你的AI代理正在偷你的密钥——四种你没想到的泄露通道 - 暗无天日 LLM 在 DevOps 中的三种角色 - 暗无天日 写作风格的反建议 - 暗无天日 反驳本质复杂性——Dan Luu 论为什么《没有银弹》错了 - 暗无天日 文件充满了危险——Dan Luu 谈文件系统的可靠性陷阱 - 暗无天日 AI 时代的 PARA 方法:用 Org-mode 和 AI 打造个人知识管理系统 Linux 数据去重学习笔记 - 暗无天日 创建跨平台 ZIP 文件的隐藏陷阱:Extra Field - 暗无天日 X11 Forwarding 排障指南 - 暗无天日 IP欺骗端口扫描:当别人冒充你去扫描别人 - 暗无天日 Linux 输入栈全景解析:从硬件按键到屏幕响应 - 暗无天日 Unix 系统中那些被埋没的配置开关——以 FontConfig 为例 - 暗无天日 在Linux上限制儿童使用电脑 - 暗无天日 GIF不仅仅是一种图片格式——用GIF流做些奇怪的事 - 暗无天日 Leiningen 学习笔记:Clojure 项目构建与管理从入门到实战配置 - 暗无天日 Google SRE Book 读书笔记 - 暗无天日 yes 管道 head 发生了什么 - 暗无天日 为什么 nohup 在 crontab 中不起作用 Bash中的Indirection与Nameref - 暗无天日 Linux PAM 简介 - 暗无天日 从Linux ISO文件启动计算机 - 暗无天日 用 Bash 打造一个Screen Locker 用GitHub Actions自动构建EGO博客 - 暗无天日 blocking I/O 的作用 - 暗无天日 mobileog 手机端同步提示Error:2 No such file 的解决方法 回收 WSL2 VHDX 文件占用空间 使用 org-mode columnview 生成任务列表 - 暗无天日 Emacs 作为 MPD 客户端 - 暗无天日 移动文件路径却不破坏org file link的方法 - 暗无天日 如何合理的导出help link 成HTML - 暗无天日 笑话理解之Biology - 暗无天日
读:File System Access API,浏览器原生读写本地文件 - 暗无天日
lujun9972,Claude Code · 2026-06-26 · via 暗无天日

目录

  • 先拿一个能跑的页面
  • 动手之前得满足两个条件
  • 读文件
    • 读单个文件
    • 读多个文件
  • 写文件
    • 写新文件
    • 改已有文件
  • 目录和删除
    • 列出目录内容
    • 删除文件和目录
  • 2026 年的浏览器支持现状
  • 我的评价

File System Access API 是一组 Web API,让网页通过 JavaScript 直接读写用户本地文件。以前网页只能用 <input type="file"> 让用户选文件上传,选中后只能读内容,不能写回去。File System Access API 把这个能力补齐了,读和写都能做,还能列目录、删文件。不过目前只有 Chrome 系桌面浏览器支持,Firefox 和 Safari 都没有跟进,详见后文。

CSS-Tricks 上有一篇 入门教程 按读、写、目录、删除四块讲核心 API。本文按原文结构梳理,顺便补上原文没展开的设计细节和 2026 年的浏览器支持现状。

先拿一个能跑的页面

不想一段段看代码的,可以先把下面这个 HTML 存成文件,起个本地服务器直接点按钮试。把代码存成 demo.html=,然后在同目录下执行 =python3 -m http.server 8000=,浏览器打开 =http://localhost:8000/demo.html 就行。

为什么要起服务器?因为 File System Access API 只在安全上下文(HTTPS 或 localhost)里可用,直接双击 .html 文件走的是 file:// 协议,API 会变成 =undefined=。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>File System Access API 试试看</title>
  <style>
    body { font-family: sans-serif; max-width: 700px; margin: 40px auto; }
    button { margin: 4px; padding: 8px 16px; cursor: pointer; font-size: 14px; }
    pre { background: #f5f5f5; padding: 12px; white-space: pre-wrap; word-break: break-all; min-height: 100px; }
  </style>
</head>
<body>
  <h1>File System Access API 试试看</h1>
  <p>需要 Chrome 或 Edge 105+,页面必须跑在 localhost 或 HTTPS 上(不能直接双击打开)。</p>
  <div>
    <button class="pick-file">读单个文件</button>
    <button class="pick-multi">读多个文件</button>
    <button class="pick-image">只选图片</button>
    <button class="save-file">写新文件</button>
    <button class="edit-file">改已有文件(覆盖)</button>
    <button class="read-dir">列出目录</button>
    <button class="del-file">删除文件</button>
    <button class="del-entry">删除目录内条目</button>
  </div>
  <h3>输出</h3>
  <pre id="output">点上面的按钮试试</pre>

  <script>
    const out = document.getElementById("output");
    const log = (msg) => { out.textContent = msg; };

    let fileHandle;

    // 读单个文件
    document.querySelector(".pick-file").onclick = async () => {
      try {
        [fileHandle] = await window.showOpenFilePicker();
      } catch (err) {
        log("取消了选择");
        return;
      }
      const file = await fileHandle.getFile();
      const content = await file.text();
      log(
        "文件名: " + file.name +
        "\n大小: " + file.size + " 字节" +
        "\n类型: " + file.type +
        "\n最后修改: " + new Date(file.lastModified).toLocaleString() +
        "\n\n--- 内容 ---\n" + content
      );
    };

    // 读多个文件
    document.querySelector(".pick-multi").onclick = async () => {
      try {
        const handles = await window.showOpenFilePicker({ multiple: true });
        const results = await Promise.all(
          handles.map(async (h) => {
            const f = await h.getFile();
            return "[" + f.name + "]\n" + await f.text();
          })
        );
        log(results.join("\n\n---\n\n"));
      } catch (err) {
        log("取消了选择");
      }
    };

    // 只选图片(限定文件类型)
    document.querySelector(".pick-image").onclick = async () => {
      try {
        const handles = await window.showOpenFilePicker({
          multiple: true,
          types: [{
            description: "图片",
            accept: { "image/jpeg": [".jpeg"], "image/png": [".png"] },
          }],
          excludeAcceptAllOption: true,
        });
        const names = handles.map(async (h) => (await h.getFile()).name);
        log("选中的图片:\n" + (await Promise.all(names)).join("\n"));
      } catch (err) {
        log("取消了选择");
      }
    };

    // 写新文件
    document.querySelector(".save-file").onclick = async () => {
      try {
        const handle = await window.showSaveFilePicker({
          types: [{ description: "文本文件", accept: { "text/plain": [".txt"] } }],
        });
        const writable = await handle.createWritable();
        await writable.write("Hello World\n这是用 File System Access API 写入的内容。");
        await writable.close();
        log("已保存到: " + handle.name);
      } catch (err) {
        log("取消了保存");
      }
    };

    // 改已有文件(覆盖)
    document.querySelector(".edit-file").onclick = async () => {
      try {
        [fileHandle] = await window.showOpenFilePicker();
        const writable = await fileHandle.createWritable();
        await writable.write("文件内容已被替换。\n" + new Date().toLocaleString());
        await writable.close();
        log("已覆盖写入: " + fileHandle.name);
      } catch (err) {
        log("取消了选择");
      }
    };

    // 列出目录
    document.querySelector(".read-dir").onclick = async () => {
      try {
        const dirHandle = await window.showDirectoryPicker();
        const entries = [];
        for await (const entry of dirHandle.values()) {
          entries.push(entry.kind + "\t" + entry.name);
        }
        log("目录: " + dirHandle.name + "\n\n" + entries.join("\n"));
      } catch (err) {
        log("取消了选择");
      }
    };

    // 删除文件
    document.querySelector(".del-file").onclick = async () => {
      try {
        const [handle] = await window.showOpenFilePicker();
        await handle.remove();
        log("已删除: " + handle.name);
      } catch (err) {
        log("取消了选择");
      }
    };

    // 删除目录内条目
    document.querySelector(".del-entry").onclick = async () => {
      try {
        const dirHandle = await window.showDirectoryPicker();
        const name = prompt("输入要删除的文件或目录名:");
        if (!name) { log("没输入名字"); return; }
        try {
          await dirHandle.removeEntry(name);
          log("已删除: " + name);
        } catch (e) {
          // 可能是目录且有内容,尝试递归删
          if (e.name === "InvalidModificationError") {
            await dirHandle.removeEntry(name, { recursive: true });
            log("已递归删除目录: " + name);
          } else {
            throw e;
          }
        }
      } catch (err) {
        log("取消了选择或文件不存在: " + err.message);
      }
    };
  </script>
</body>
</html>

下面逐个讲每个操作背后的代码。

动手之前得满足两个条件

调用这组 API 有两个硬性前提。一是必须在安全上下文中,也就是 HTTPS 页面。localhost 是例外,浏览器把它当作安全的,方便本地开发调试。二是必须由用户主动触发,比如点击按钮。不能在页面加载时自动弹出文件选择框,也不能在定时器回调里偷偷调。

这个限制是故意的,防止网页在用户不知情的情况下操作本地文件。下文所有代码示例都假设挂在一个按钮的 click 事件上,且需要在 Chrome 或 Edge 105+ 浏览器中执行(其他浏览器不支持,原因见后文)。

读文件

读单个文件

不到十行代码就能读完一个文件。

let fileHandle;

document.querySelector(".pick-file").onclick = async () => {
  try {
    [fileHandle] = await window.showOpenFilePicker();
  } catch (err) {
        return;
  }

  const file = await fileHandle.getFile();
  const content = await file.text();

  console.log(content);
};

window.showOpenFilePicker() 弹出系统的文件选择框。注意等号左边的中括号,这个方法返回的是一个数组(哪怕只选了一个文件),这里用解构语法取第一个元素。

拿到的 fileHandle 是一个 FileSystemFileHandle 对象,长这样。

FileSystemFileHandle {kind: 'file', name: 'data.txt'}

kind 要么是 file 要么是 directory=,=name 是文件名。这个 handle 不直接包含文件内容,只是文件的一个引用。要拿内容,得先调 getFile() 得到 File 对象,再调 text() 读文本。 getFile() 返回的 File 对象还带几个元数据属性: name (文件名)、 size (字节数)、 type (MIME 类型)、 lastModified (最后修改时间的毫秒时间戳)。

这里的设计是把 handle(引用)和 file(内容快照)分开了。 getFile() 每次调用都返回当前最新的文件状态,不会缓存第一次选中时的版本。用户要是在网页打开期间用其他程序改了文件,可以通过再调一次 getFile() 来拿到新内容。

另外,handle 不会随页面刷新自动保留。想跨会话复用同一个文件引用,需要手动把 handle 存进 IndexedDB。

读多个文件

传一个 multiple: true 选项就能选多个文件。

const options = {
  multiple: true,
};

document.querySelector(".pick-file").onclick = async () => {
  const fileHandles = await window.showOpenFilePicker(options);

  const allContent = await Promise.all(
    fileHandles.map(async (handle) => {
      const file = await handle.getFile();
      return file.text();
    })
  );

  console.log(allContent);
};

还可以限定能选的文件类型。比如只接受 JPEG 图片。

const options = {
  types: [
    {
      description: "Images",
      accept: {
        "image/jpeg": [".jpeg"],
      },
    },
  ],
  excludeAcceptAllOption: true,
};

excludeAcceptAllOption: true 会去掉选择框里那个「所有文件」的选项,强制用户只能选指定类型。

写文件

写新文件

document.querySelector(".save-file").onclick = async () => {
  const options = {
    types: [
      {
        description: "Test files",
        accept: {
          "text/plain": [".txt"],
        },
      },
    ],
  };

  const handle = await window.showSaveFilePicker(options);
  const writable = await handle.createWritable();

  await writable.write("Hello World");
  await writable.close();
};

showSaveFilePicker() 弹出的是另存为对话框,让用户选保存位置和文件名。拿到 handle 后,调 createWritable() 创建一个可写流,再用 write() 写内容,最后 close() 关闭流并落盘。

close() 这一步不能漏。忘了调的话,内容可能还留在内存缓冲区里,没真正写到磁盘上。

改已有文件

把读和写拼起来就是改文件。

let fileHandle;

document.querySelector(".pick-file").onclick = async () => {
  [fileHandle] = await window.showOpenFilePicker();
  const writable = await fileHandle.createWritable();

  await writable.write("This is a new line");
  await writable.close();
};

这里有个原文没强调的坑。=write()= 是覆盖整个文件,不追加。上面的代码执行后,原文件的内容会被 "This is a new line" 完全替换掉。想追加的话,得先读出原内容,自己拼接后再写回去。

目录和删除

列出目录内容

document.querySelector(".read-dir").onclick = async () => {
  const directoryHandle = await window.showDirectoryPicker();

  for await (const entry of directoryHandle.values()) {
    console.log(entry.kind, entry.name);
  }
};

showDirectoryPicker() 让用户选一个目录,返回的 directoryHandle 可以用 for await...of 遍历里面的条目。每个 entry 也有 kindname ,输出大致是这样的。

file data.txt
file image.jpeg
directory subdir

注意只列出顶层条目,不会递归到子目录。要递归得自己写遍历逻辑。

删除文件和目录

删选中的文件或目录,拿到 handle 后直接调 remove()=。文件用 =showOpenFilePicker() 拿 handle,目录用 =showDirectoryPicker()=,删除调用一样。

const [fileHandle] = await window.showOpenFilePicker();
await fileHandle.remove();

const directoryHandle = await window.showDirectoryPicker();
await directoryHandle.remove();

删目录里的某个指定条目(而不是整个目录),用 =removeEntry()=。删子目录时如果里面有内容,必须加 ={ recursive: true }=,否则报错。

const directoryHandle = await window.showDirectoryPicker();

await directoryHandle.removeEntry("data.txt");

await directoryHandle.removeEntry('data', { recursive: true });

2026 年的浏览器支持现状

原文的浏览器支持表没标日期。2026 年的真实情况是这样的。

先看桌面端。Chrome 105+ 和 Edge 105+ 支持,Opera 也行(同为 Chromium 内核)。Firefox 从 2 到最新版全不支持,Mozilla 的官方立场把这个 API 标为 harmful,理由是让网页直接操作本地文件对用户安全风险太大。Safari 同样全版本不支持。

移动端更惨,Chrome for Android、Safari on iOS、Samsung Internet、Firefox for Android 全不支持,手机上没有任何浏览器能用这组 API。

数据来源:https://caniuse.com/native-filesystem-api(2026-06-25 查询)

这个支持现状直接决定了 File System Access API 目前不适合用于面向公众的生产环境。网页应用要是依赖这组 API,Firefox 用户、Safari 用户和所有手机用户都用不了。

不过浏览器其实另开了一条路,Origin Private File System(OPFS)。OPFS 让网页在一个沙盒内的虚拟文件系统中读写,不碰用户的真实文件系统。这个能力支持范围广得多(Firefox 111+、Safari 15.2+),因为不涉及访问用户真实文件,安全风险低得多。如果只是想在浏览器里存点数据,OPFS 比 File System Access API 更合适。

我的评价

这组 API 的设计本身是合理的。handle 和 file 分层、强制用户手势、强制 HTTPS、读写权限不随页面刷新自动保留,每个设计都在收紧网页碰本地文件这个危险操作的风险面。 getFile() 每次返回最新快照,不会缓存首次选择时的版本,这个细节也考虑到了文件被外部修改的场景。

支持不全这个现实摆在这里。原文推荐了一个叫 browser-fs-access 的 ponyfill(ponyfill 和 polyfill 的区别在于,polyfill 会往全局对象上补方法,ponyfill 只导出自己用的方法,不改全局环境),在不支持的浏览器里自动降级到传统的 <input type"file">= 上传和 <a download> 下载。真要在项目里用这组 API,建议直接上这个库做兼容层,别裸调原生 API。