在 Web 应用日益复杂的今天,浏览器端文件处理早已不是「上传到服务器再下载」的单一模式。根据 HTTP Archive 的最新数据,超过 68% 的现代 Web 应用至少有一种本地文件交互需求——从在线 IDE 的项目导入、图片编辑器的批量处理,到开发者工具的 JSON/CSV 分析。File System Access API 正是为此而生的 W3C 标准,它让浏览器首次获得了直接读写用户本地文件系统的能力,且全程无需服务器参与。
本文将从实际开发角度出发,拆解 File System Access API 的核心接口、实战模式、兼容性策略和安全模型,帮助你在 jsjson.com 这类在线工具中实现真正的「本地处理不上传服务器」体验。
🔐 一、核心接口与安全模型
File System Access API 的设计哲学是「权限显式授予、范围严格限定」。与早期 File API 只能读取用户主动选择的文件不同,新 API 允许持久化读写,但代价是更严格的权限模型。
1.1 三大入口函数
File System Access API 提供三个核心入口,分别对应三种最常见的文件操作场景:
| 函数 | 用途 | 返回类型 | 典型场景 |
|---|---|---|---|
showOpenFilePicker() |
选择单个/多个文件 | FileSystemFileHandle[] |
导入文件、打开项目 |
showSaveFilePicker() |
选择保存位置 | FileSystemFileHandle |
导出文件、另存为 |
showDirectoryPicker() |
选择整个目录 | FileSystemDirectoryHandle |
批量处理、项目导入 |
⚠️ 警告:这三个函数都必须在用户手势(click、keydown 等)的事件处理中调用。在
setTimeout、Promise.then或异步回调中调用会抛出SecurityError。
以下是一个完整的文件选择与读取示例:
// === 单文件选择与文本读取 ===
async function openAndReadFile() {
try {
// showOpenFilePicker 返回 FileSystemFileHandle 数组
const [fileHandle] = await window.showOpenFilePicker({
types: [
{
description: 'JSON 文件',
accept: { 'application/json': ['.json'] },
},
{
description: 'CSV 文件',
accept: { 'text/csv': ['.csv'] },
},
],
multiple: false, // 只允许选一个文件
});
// 从 handle 获取 File 对象(只读快照)
const file = await fileHandle.getFile();
const content = await file.text();
console.log(`文件名: ${file.name}`);
console.log(`大小: ${file.size} bytes`);
console.log(`最后修改: ${new Date(file.lastModified)}`);
console.log(`内容: ${content}`);
return { handle: fileHandle, content };
} catch (err) {
// 用户点击取消会抛出 AbortError
if (err.name === 'AbortError') {
console.log('用户取消了文件选择');
return null;
}
throw err;
}
}
1.2 权限模型:Read、ReadWrite 与持久化
每个 FileSystemHandle 都有独立的权限状态,你需要在写入前显式请求权限:
// === 权限检查与请求 ===
async function ensureWritePermission(fileHandle) {
// 第一步:查询当前权限状态
const opts = { mode: 'readwrite' };
let permission = await fileHandle.queryPermission(opts);
if (permission === 'granted') {
return true; // 已有权限
}
// 第二步:如果是 'prompt' 状态,请求权限
// 浏览器会弹出权限对话框,用户可以选择「允许」或「拒绝」
if (permission === 'prompt') {
permission = await fileHandle.requestPermission(opts);
}
if (permission !== 'granted') {
throw new Error('用户拒绝了文件写入权限');
}
return true;
}
💡 提示:
requestPermission()必须在用户手势事件中调用,否则会被浏览器静默拒绝。这是防止恶意网站在后台获取文件系统权限的关键安全机制。
权限状态有三种:
| 状态 | 含义 | 触发条件 |
|---|---|---|
granted |
已授权 | 用户之前同意过,或在同一会话中刚授权 |
prompt |
待确认 | 首次访问,或用户上次选择了「每次询问」 |
denied |
已拒绝 | 用户明确拒绝了权限请求 |
1.3 持久化权限 vs 会话权限
一个容易被忽视的细节:用户可以选择「允许此次访问」或「允许持久访问」。在 Chrome 中,如果用户选择了持久化权限,下次打开页面时 queryPermission 会直接返回 granted,无需再次弹窗。
📌 **记住:**不要假设权限一定是
granted。即使上次用户授权了,也可能因为浏览器清除站点数据、用户手动撤销等原因变为prompt。始终在操作前检查权限状态。
🚀 二、文件读写实战
掌握了入口函数和权限模型后,接下来是真正的文件读写操作。File System Access API 提供了两套读取路径和一套写入路径,各有适用场景。
2.1 读取文件:getFile() vs createReadable()
方案一:getFile() — 获取 File 对象
这是最简单的读取方式,适合中小文件(< 100MB)。File 对象继承自 Blob,支持 text()、arrayBuffer()、stream() 等方法。
// === 使用 getFile() 读取文件 ===
async function readFileWithGetFile(fileHandle) {
const file = await fileHandle.getFile();
// 根据文件大小选择读取策略
if (file.size < 10 * 1024 * 1024) {
// 小于 10MB:一次性读取
return await file.text();
} else {
// 大于 10MB:使用流式读取
const reader = file.stream().getReader();
const chunks = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
totalBytes += value.byteLength;
console.log(`已读取: ${(totalBytes / 1024 / 1024).toFixed(1)} MB`);
}
// 合并所有 chunk
const blob = new Blob(chunks);
return await blob.text();
}
}
方案二:createReadable() — ReadableStream 流式读取
对于超大文件(> 100MB),createReadable() 是更好的选择。它返回一个 ReadableStream,可以在不将整个文件加载到内存的情况下逐块处理。
// === 使用 createReadable() 流式处理大文件 ===
async function processLargeFile(fileHandle) {
const readable = await fileHandle.createReadable();
const reader = readable.getReader();
const decoder = new TextDecoder();
let lineCount = 0;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value 是 Uint8Array,需要解码为文本
buffer += decoder.decode(value, { stream: true });
// 按行处理(适合 CSV、JSON Lines 等格式)
const lines = buffer.split('\n');
buffer = lines.pop(); // 保留不完整的最后一行
for (const line of lines) {
if (line.trim()) {
lineCount++;
// 在这里处理每一行
// 例如:解析 JSON Lines、统计 CSV 行数等
}
}
}
// 处理剩余的 buffer
if (buffer.trim()) {
lineCount++;
}
console.log(`共处理 ${lineCount} 行`);
return lineCount;
}
⚡ **关键结论:**对于 JSON 格式化工具、CSV 分析器等场景,
createReadable()的流式处理可以将内存占用降低 90% 以上。一个 500MB 的 JSON 文件,getFile()方式需要 ~1.2GB 内存(文件 + 解析后的对象),而流式处理只需 ~20MB。
2.2 写入文件:FileSystemWritableFileStream
写入操作通过 createWritable() 获取 FileSystemWritableFileStream,它是一个支持 write()、seek()、truncate() 的 WritableStream。
// === 完整的文件写入流程 ===
async function writeFileContent(fileHandle, content) {
// 1. 检查写入权限
await ensureWritePermission(fileHandle);
// 2. 创建可写流(会创建临时文件)
const writable = await fileHandle.createWritable();
try {
// 3. 写入内容
// write() 支持:string、BufferSource、Blob、WriteParams
await writable.write(content);
// 也可以分块写入
// await writable.write('第一部分\n');
// await writable.write('第二部分\n');
// await writable.write('第三部分\n');
// 4. 关闭流(原子操作:临时文件替换原文件)
await writable.close();
} catch (err) {
// 如果写入失败,临时文件会被自动清理
await writable.abort();
throw err;
}
}
⚠️ 警告:
createWritable()默认会清空文件内容再写入。如果你需要追加内容,必须先 seek 到末尾:const writable = await fileHandle.createWritable({ keepExistingData: true }); const file = await fileHandle.getFile(); await writable.seek(file.size); // 移动到文件末尾 await writable.write('追加的内容'); await writable.close();
2.3 目录操作:遍历、创建、删除
showDirectoryPicker() 返回的 FileSystemDirectoryHandle 支持完整的目录树操作:
// === 目录遍历与批量处理 ===
async function processDirectory(dirHandle) {
const results = [];
// 使用 for-await-of 遍历目录
for await (const [name, handle] of dirHandle) {
if (handle.kind === 'file') {
// 检查文件扩展名
if (name.endsWith('.json') || name.endsWith('.jsonl')) {
const file = await handle.getFile();
const content = await file.text();
results.push({
name,
size: file.size,
content,
});
}
} else if (handle.kind === 'directory') {
// 递归处理子目录
const subResults = await processDirectory(handle);
results.push(...subResults);
}
}
return results;
}
// === 创建子目录和文件 ===
async function createProjectStructure(dirHandle) {
// 创建子目录
const srcDir = await dirHandle.getDirectoryHandle('src', { create: true });
const testDir = await dirHandle.getDirectoryHandle('test', { create: true });
// 在子目录中创建文件
const mainFile = await srcDir.getFileHandle('index.js', { create: true });
const writable = await mainFile.createWritable();
await writable.write('// Generated by File System Access API\n');
await writable.write('console.log("Hello, World!");\n');
await writable.close();
// 删除文件
await srcDir.removeEntry('old-file.js'); // 文件不存在会抛 NotFoundError
// 删除目录(必须为空)
// await dirHandle.removeEntry('empty-dir', { recursive: false });
}
💡 三、兼容性策略与最佳实践
3.1 浏览器支持现状
File System Access API 的浏览器支持并不均匀,这是你必须面对的现实:
| 浏览器 | 版本 | 支持状态 |
|---|---|---|
| Chrome | 86+ | ✅ 完全支持 |
| Edge | 86+ | ✅ 完全支持 |
| Opera | 72+ | ✅ 完全支持 |
| Firefox | — | ❌ 不支持(W3C 争议) |
| Safari | — | ❌ 不支持(Apple 未表态) |
Firefox 的立场值得关注:Mozilla 认为 File System Access API 的权限模型过于宽松,存在安全风险,提出了替代方案(基于 Sandboxed 文件系统)。这意味着短期内不会有 Firefox 支持。
⚠️ **警告:**不要将 File System Access API 作为唯一的文件处理方案。你必须提供降级路径,否则会失去 30%+ 的用户。
3.2 优雅降级方案
以下是一个完整的特性检测与降级策略:
// === 统一的文件操作抽象层 ===
class FileOperations {
static isSupported() {
return (
typeof window.showOpenFilePicker === 'function' &&
typeof window.showSaveFilePicker === 'function'
);
}
// 统一的文件打开接口
static async openFile(options = {}) {
if (this.isSupported()) {
// 方案 A:File System Access API(Chrome/Edge)
return this._openWithFSA(options);
} else {
// 方案 B:传统 <input type="file"> 降级
return this._openWithInput(options);
}
}
static async _openWithFSA(options) {
const pickerOpts = {
types: options.types || [],
multiple: options.multiple || false,
};
const handles = await window.showOpenFilePicker(pickerOpts);
const files = await Promise.all(
handles.map((h) => h.getFile())
);
return { files, handles }; // FSA 特有:返回 handle 用于后续写入
}
static async _openWithInput(options) {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
if (options.accept) {
input.accept = options.accept;
}
if (options.multiple) {
input.multiple = true;
}
input.onchange = () => {
const files = Array.from(input.files);
resolve({ files, handles: null }); // 降级方案没有 handle
};
input.oncancel = () => resolve(null);
input.click();
});
}
// 统一的文件保存接口
static async saveFile(content, suggestedName, mimeType) {
if (this.isSupported()) {
return this._saveWithFSA(content, suggestedName, mimeType);
} else {
return this._saveWithBlob(content, suggestedName, mimeType);
}
}
static async _saveWithFSA(content, suggestedName, mimeType) {
const handle = await window.showSaveFilePicker({
suggestedName,
types: [
{
description: '文件',
accept: { [mimeType]: [suggestedName.split('.').pop()] },
},
],
});
const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
}
static async _saveWithBlob(content, suggestedName, mimeType) {
// 传统方案:创建 Blob 并触发下载
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = suggestedName;
a.click();
URL.revokeObjectURL(url);
}
}
// 使用示例
const result = await FileOperations.openFile({
types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }],
});
if (result) {
const content = await result.files[0].text();
// ... 处理文件 ...
// 保存时,如果有 handle,可以实现「保存到原文件」
if (result.handles) {
const writable = await result.handles[0].createWritable();
await writable.write(JSON.stringify(processedData, null, 2));
await writable.close();
} else {
// 降级方案:弹出「另存为」对话框
await FileOperations.saveFile(
JSON.stringify(processedData, null, 2),
'output.json',
'application/json'
);
}
}
3.3 性能对比与场景选择
| 场景 | File System Access API | <input type="file"> |
OPFS |
|---|---|---|---|
| 读取用户本地文件 | ✅ 有 handle | ✅ 仅 File 对象 | ❌ 不可访问用户文件 |
| 写回原文件 | ✅ 直接写入 | ❌ 只能下载新文件 | ✅ 仅限 origin 内 |
| 目录遍历 | ✅ 递归遍历 | ❌ 不支持 | ✅ 递归遍历 |
| 大文件流式处理 | ✅ ReadableStream | ✅ File.stream() | ✅ ReadableStream |
| 跨浏览器支持 | ❌ Chrome/Edge | ✅ 全部 | ✅ 主流浏览器 |
| 持久化存储 | ✅ 需用户授权 | ❌ 页面刷新后丢失 | ✅ 自动持久化 |
💡 **提示:**如果你的应用不需要读写用户本地文件,而是需要一个浏览器内的私有存储空间(如缓存、数据库文件),应该优先使用 OPFS(Origin Private File System)。OPFS 不需要用户授权,支持所有主流浏览器,且在 Web Worker 中性能更好。
3.4 常见坑点与避坑指南
❌ 错误写法:在 async 函数中延迟调用 picker
// ❌ 这会抛出 SecurityError
async function bad() {
const data = await fetch('/api/config');
const handle = await showOpenFilePicker(); // 失败!不在用户手势上下文中
}
✅ 正确写法:在用户手势事件处理中直接调用
// ✅ 正确的做法
button.addEventListener('click', async () => {
try {
const [handle] = await showOpenFilePicker();
// 后续的异步操作不受限制
const file = await handle.getFile();
const config = await fetch('/api/config');
// ...
} catch (err) {
if (err.name !== 'AbortError') showError(err);
}
});
❌ 错误写法:忘记处理 AbortError
// ❌ 用户点取消时会抛出未捕获的异常
button.onclick = async () => {
const [handle] = await showOpenFilePicker(); // 用户取消 → AbortError
// ...
};
✅ 正确写法:始终捕获 AbortError
// ✅ 优雅处理用户取消
button.onclick = async () => {
try {
const [handle] = await showOpenFilePicker();
// ...
} catch (err) {
if (err.name === 'AbortError') return; // 用户取消,静默返回
throw err; // 其他错误继续抛出
}
};
❌ 错误写法:写入后忘记 close()
// ❌ 数据可能丢失!close() 才会触发原子替换
const writable = await handle.createWritable();
await writable.write('some data');
// 忘记 close(),临时文件永远不会替换原文件
✅ 正确写法:使用 try/finally 确保 close()
// ✅ 确保流被正确关闭
const writable = await handle.createWritable();
try {
await writable.write('some data');
await writable.close(); // 原子操作:临时文件替换原文件
} catch (err) {
await writable.abort(); // 清理临时文件
throw err;
}
🔧 四、实战案例:构建浏览器端 JSON 处理器
结合 jsjson.com 的场景,以下是一个完整的「大 JSON 文件格式化与分析」工具的实现:
// === 浏览器端大 JSON 文件处理器 ===
// 支持流式读取、格式化、分析,内存占用 < 50MB(无论文件多大)
class JsonFileProcessor {
constructor() {
this.supported = FileOperations.isSupported();
}
// 打开并分析 JSON 文件
async analyzeFile() {
const result = await FileOperations.openFile({
types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }],
});
if (!result) return null;
const file = result.files[0];
const startTime = performance.now();
// 根据文件大小选择策略
let analysis;
if (file.size < 50 * 1024 * 1024) {
// < 50MB:一次性读取分析
analysis = this._analyzeSmallFile(file);
} else {
// >= 50MB:流式分析
analysis = await this._analyzeLargeFile(file);
}
const elapsed = performance.now() - startTime;
return {
fileName: file.name,
fileSize: this._formatSize(file.size),
elapsed: `${elapsed.toFixed(0)}ms`,
...analysis,
};
}
_analyzeSmallFile(file) {
// 使用 requestIdleCallback 避免阻塞 UI
return file.text().then((text) => {
const data = JSON.parse(text);
return {
type: Array.isArray(data) ? 'array' : typeof data,
depth: this._getDepth(data),
keyCount: this._countKeys(data),
preview: JSON.stringify(data, null, 2).slice(0, 500) + '...',
};
});
}
async _analyzeLargeFile(file) {
const stream = file.stream();
const reader = stream.getReader();
const decoder = new TextDecoder();
let totalBytes = 0;
let bracketDepth = 0;
let maxDepth = 0;
let keyCount = 0;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
buffer += decoder.decode(value, { stream: true });
// 简单的流式分析:统计括号深度和 key 数量
for (const char of buffer) {
if (char === '{' || char === '[') {
bracketDepth++;
maxDepth = Math.max(maxDepth, bracketDepth);
} else if (char === '}' || char === ']') {
bracketDepth--;
} else if (char === '"') {
keyCount++;
}
}
buffer = ''; // 清空 buffer(流式处理不需要保留完整内容)
}
return {
type: 'json',
depth: maxDepth,
keyCount: Math.floor(keyCount / 2), // 粗略估计(每个 key-value 对有两个引号字符串)
preview: '(文件过大,已跳过预览)',
};
}
_getDepth(obj, current = 0) {
if (typeof obj !== 'object' || obj === null) return current;
return Math.max(
...Object.values(obj).map((v) => this._getDepth(v, current + 1)),
current
);
}
_countKeys(obj) {
if (typeof obj !== 'object' || obj === null) return 0;
let count = Object.keys(obj).length;
for (const val of Object.values(obj)) {
count += this._countKeys(val);
}
return count;
}
_formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
}
// 使用
const processor = new JsonFileProcessor();
document.getElementById('analyze-btn').addEventListener('click', async () => {
const result = await processor.analyzeFile();
if (result) {
console.table(result);
}
});
这个案例展示了 File System Access API 在实际工具开发中的核心优势:无需上传服务器,大文件也能高效处理。与传统的 <input type="file"> + FileReader 方案相比,关键区别在于:
- 写回原文件:处理后的 JSON 可以直接保存回原文件,而不是弹出「另存为」
- 目录批量处理:可以一次选择整个目录,批量格式化所有 JSON 文件
- 流式处理:500MB 的 JSON 文件也能在 50MB 内存下完成分析
📊 五、总结与工具推荐
File System Access API 代表了 Web 平台向「全功能应用」演进的关键一步。虽然目前浏览器支持有限(仅 Chromium 系列),但它解决了一个长期存在的痛点:浏览器无法像桌面应用一样操作本地文件。
⚡ **关键结论:**在生产环境中使用 File System Access API,必须提供 <input type="file"> 或 Blob 下载作为降级方案。好消息是,降级方案的实现成本很低——核心差异只在「获取 handle」这一步。
使用建议:
- ✅ 适用于:在线 IDE、文件编辑器、批量处理工具、数据可视化平台
- ❌ 不适用于:只需要一次性上传的场景(用
<input>就够了) - ⚠️ 注意:Safari 和 Firefox 不支持,必须提供降级方案
- ✅ 搭配 OPFS 使用:本地文件操作用 FSA,浏览器内持久存储用 OPFS
相关工具与资源:
- 🔧 jsjson.com JSON 格式化工具 — 在线 JSON 处理
- 🔧 OPFS Explorer — Chrome DevTools 扩展,可视化 OPFS 内容
- 📖 W3C File System Access API 规范 — 官方规范文档
- 📖 Chrome 团队博客 — 权威教程与最佳实践
- 🔧 browser-fs-access — 兼容性 polyfill 库