
你是否想过,为什么在线编辑器(如 Google Docs)能让你存储文件,但 VS Code Web 版本却始终受到沙箱限制?答案就在 File System Access API 这个"破局者"身上。
这个 API 不仅打破了浏览器 30 年来的沙箱隔离,还通过精妙的权限设计在"能力"与"安全"之间找到了平衡。今天我们就从原理到实战,彻底搞透它怎么工作的。
说起浏览器沙箱,它就像一个"监狱"——保护系统安全,但也限制了 Web 应用的能力。想象你有一个在线代码编辑器,用户辛苦写好了 50kb 的代码文件,关掉浏览器一切就烟消云散。或者你想做一个在线设计工具,每次都得从云端同步最新的 PSD 文件……这些都是 Web 应用的痛点。
传统方案很无奈:
File System Access API 的出现改变了这一切。它不仅让 Web 应用获得本地文件读写权限,还通过"用户主动授权"这个机制,在开放能力的同时守住了安全底线。
这种设计思路值得学习:权限来自于用户的每一次主动选择,而不是默认赋予。
在深入代码之前,你需要理解一个核心概念:FileSystemHandle(文件系统句柄)。
这是 API 的灵魂。句柄就像一把"智能钥匙",它代表了用户授予的对某个特定文件或目录的访问权限。有了这把钥匙,你可以:
用户选择文件 → 获得句柄 → 读取/修改/删除 → 写回磁盘
↓ 权限授予 ↓ 有效期内 ↓ 细粒度操作 ↓ 持久化
让我们用伪代码描述这个流程:
┌─────────────────────────────────────────────────────────┐
│ File System Access API 的工作流 │
└─────────────────────────────────────────────────────────┘
用户点击"打开文件"按钮
↓
显示系统文件选择器
↓
用户选择文件 (赋予权限)
↓
获得 FileSystemFileHandle 句柄
↓
┌─────┬──────┬──────────┐
↓ ↓ ↓ ↓
读取 写入 删除 遍历属性
└─────┴──────┴──────────┘
↓
对本地文件操作
关键点是:权限是临时的还是持久的?
在最新的规范中,浏览器可以通过 IndexedDB 存储权限信息,实现"记住我选过的文件夹"这样的功能。但这需要用户明确授权,不会在后台偷偷记录。
// TypeScript 版本
asyncfunction openSingleFile(): Promise<string | null> {
try {
// showOpenFilePicker 返回一个 FileSystemFileHandle 数组
const [fileHandle] = awaitwindow.showOpenFilePicker({
types: [
{
description: 'Text Files',
accept: { 'text/plain': ['.txt', '.md'] }
}
],
multiple: false// 只选择一个文件
});
// 获取 File 对象(标准 Web API)
const file = await fileHandle.getFile();
// 读取文件内容
const content = await file.text();
return content;
} catch (err) {
// 用户取消选择
if (err instanceof DOMException && err.name === 'AbortError') {
console.log('用户取消了文件选择');
}
returnnull;
}
}
// JavaScript 版本
asyncfunction openSingleFile() {
try {
const [fileHandle] = awaitwindow.showOpenFilePicker({
types: [
{
description: 'Text Files',
accept: { 'text/plain': ['.txt', '.md'] }
}
],
multiple: false
});
const file = await fileHandle.getFile();
const content = await file.text();
return content;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
console.log('用户取消了文件选择');
}
returnnull;
}
}
关键细节解析:
showOpenFilePicker() 是异步的,UI 线程不会被阻塞types 参数用 MIME 类型和扩展名双重过滤(体验更好)fileHandle.getFile() 返回标准的 File 对象,可用所有现有方法(.text()、.arrayBuffer() 等)如果你要做一个批量处理工具(比如批量压缩图片),需要同时处理多个文件:
interface FileInfo {
name: string;
size: number;
type: string;
lastModified: number;
handle: FileSystemFileHandle;
}
asyncfunction openMultipleFiles(): Promise<FileInfo[]> {
try {
// 注意:multiple: true
const fileHandles = awaitwindow.showOpenFilePicker({
types: [
{
description: 'Images',
accept: { 'image/*': ['.png', '.jpg', '.webp'] }
}
],
multiple: true
});
const files: FileInfo[] = [];
for (const handle of fileHandles) {
const file = await handle.getFile();
files.push({
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
handle: handle
});
}
return files;
} catch (err) {
console.error('打开文件失败:', err);
return [];
}
}
// 使用示例
asyncfunction processImages() {
const images = await openMultipleFiles();
// 显示进度
for (const img of images) {
console.log(`处理中: ${img.name} (${(img.size / 1024).toFixed(2)}KB)`);
// 这里可以读取图片、压缩、处理等
const imageData = await img.handle.getFile().then(f => f.arrayBuffer());
// ... 处理逻辑
}
}
生产级别的建议:
在中国常见的场景中,比如某个 AI 图片处理平台,用户可能一次上传 100+ 图片。此时应该:
现在到了有趣的部分:写文件。这是 Web 应用实现"编辑→保存"闭环的关键。
写入文件不是一次性操作,而是通过流式接口实现的:
async function saveFile(content: string, suggestedName: string = 'document.txt'): Promise<boolean> {
try {
// showSaveFilePicker 让用户选择保存位置
const fileHandle = awaitwindow.showSaveFilePicker({
suggestedName,
types: [
{
description: 'Text Files',
accept: { 'text/plain': ['.txt'] }
}
]
});
// 关键!获取可写流
const writable = await fileHandle.createWritable();
// 写入内容
// write() 方法接受字符串、Blob、ArrayBuffer 等
await writable.write(content);
// 务必关闭流,否则文件不会真正保存到磁盘
await writable.close();
console.log('✅ 文件已保存');
returntrue;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
console.log('❌ 用户取消了保存');
} else {
console.error('保存失败:', err);
}
returnfalse;
}
}
// JavaScript 版本
asyncfunction saveFile(content, suggestedName = 'document.txt') {
try {
const fileHandle = awaitwindow.showSaveFilePicker({
suggestedName,
types: [
{
description: 'Text Files',
accept: { 'text/plain': ['.txt'] }
}
]
});
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
console.log('✅ 文件已保存');
returntrue;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
console.log('❌ 用户取消了保存');
} else {
console.error('保存失败:', err);
}
returnfalse;
}
}
坑点警告:
close():很多开发者忘记关闭流,导致文件没有真正写入。这不会报错,但文件是空的。write() 默认从头覆盖,如果要追加内容:async function appendToFile(fileHandle: FileSystemFileHandle, newContent: string) {
const writable = await fileHandle.createWritable();
// 获取文件大小,移动到末尾
const file = await fileHandle.getFile();
await writable.seek(file.size);
// 追加内容
await writable.write(newContent);
await writable.close();
}
如果你要做一个"本地文件管理器",需要操作整个目录。API 提供了丰富的目录操作能力:
interface FileTreeNode {
name: string;
kind: 'file' | 'directory';
handle: FileSystemHandle;
children?: FileTreeNode[];
size?: number; // 仅文件有
modifiedTime?: number; // 仅文件有
}
asyncfunction buildFileTree(
directoryHandle: FileSystemDirectoryHandle,
depth: number = 0,
maxDepth: number = 5
): Promise<FileTreeNode[]> {
// 防止无限递归
if (depth > maxDepth) return [];
const tree: FileTreeNode[] = [];
try {
// 遍历目录内容
forawait (const entry of directoryHandle.entries()) {
const [name, handle] = entry;
const node: FileTreeNode = {
name,
kind: handle.kind,
handle
};
// 如果是文件,获取元信息
if (handle.kind === 'file') {
const file = await handle.getFile();
node.size = file.size;
node.modifiedTime = file.lastModified;
}
// 如果是目录,递归遍历
elseif (handle.kind === 'directory') {
node.children = await buildFileTree(
handle as FileSystemDirectoryHandle,
depth + 1,
maxDepth
);
}
tree.push(node);
}
} catch (err) {
console.error(`无法读取目录 ${directoryHandle.name}:`, err);
}
return tree;
}
// JavaScript 版本
asyncfunction buildFileTree(directoryHandle, depth = 0, maxDepth = 5) {
if (depth > maxDepth) return [];
const tree = [];
try {
forawait (const [name, handle] of directoryHandle.entries()) {
const node = {
name,
kind: handle.kind,
handle
};
if (handle.kind === 'file') {
const file = await handle.getFile();
node.size = file.size;
node.modifiedTime = file.lastModified;
} elseif (handle.kind === 'directory') {
node.children = await buildFileTree(handle, depth + 1, maxDepth);
}
tree.push(node);
}
} catch (err) {
console.error(`无法读取目录: ${err.message}`);
}
return tree;
}
// 使用示例
asyncfunction exploreDirectory() {
try {
const dirHandle = awaitwindow.showDirectoryPicker();
const tree = await buildFileTree(dirHandle);
// 打印树形结构
console.log('目录树:');
console.log(JSON.stringify(tree, null, 2));
} catch (err) {
console.error('选择目录失败:', err);
}
}
async function createAndWriteFile(
directoryHandle: FileSystemDirectoryHandle,
fileName: string,
content: string
): Promise<boolean> {
try {
// getFileHandle 的 create: true 选项会自动创建文件
const fileHandle = await directoryHandle.getFileHandle(fileName, {
create: true
});
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
console.log(`✅ 文件 ${fileName} 已创建`);
returntrue;
} catch (err) {
console.error(`创建文件失败: ${err.message}`);
returnfalse;
}
}
// 删除文件(注意:需要权限)
asyncfunction deleteFile(
directoryHandle: FileSystemDirectoryHandle,
fileName: string
): Promise<boolean> {
try {
// 删除需要明确的 remove() 调用
await directoryHandle.removeEntry(fileName);
console.log(`✅ 文件 ${fileName} 已删除`);
returntrue;
} catch (err) {
console.error(`删除文件失败: ${err.message}`);
returnfalse;
}
}
// 删除目录及其内容
asyncfunction deleteDirectory(
parentHandle: FileSystemDirectoryHandle,
dirName: string
): Promise<boolean> {
try {
// recursive: true 会删除目录及其所有内容
await parentHandle.removeEntry(dirName, { recursive: true });
console.log(`✅ 目录 ${dirName} 及其内容已删除`);
returntrue;
} catch (err) {
console.error(`删除目录失败: ${err.message}`);
returnfalse;
}
}
这是 API 设计中最巧妙的部分。File System Access API 的权限模型分三个层级:
┌─────────────────────────────────────┐
│ 权限模型的三个层级 │
├─────────────────────────────────────┤
│ 1️⃣ 瞬时权限(Transient) │
│ │ 只对当前选择有效 │
│ │ 用户关闭选择器即失效 │
│ └─ 最严格,但用户体验一般 │
├─────────────────────────────────────┤
│ 2️⃣ 会话权限(Session) │
│ │ 在浏览器标签页关闭前有效 │
│ │ 刷新页面后失效 │
│ └─ 平衡安全与便利 │
├─────────────────────────────────────┤
│ 3️⃣ 持久权限(Persistent) │
│ │ 保存到 IndexedDB │
│ │ 跨会话有效 │
│ │ 用户可在设置中撤销 │
│ └─ 最便利,但最需谨慎 │
└─────────────────────────────────────┘
在实际代码中,如何实现持久权限呢?
// 权限管理类
class FileSystemPermissionManager {
private dbName = 'FSAccessDB';
private storeName = 'fileHandles';
async saveFileHandle(key: string, handle: FileSystemFileHandle): Promise<void> {
const db = awaitthis.openDB();
const tx = db.transaction(this.storeName, 'readwrite');
await tx.objectStore(this.storeName).put(handle, key);
}
async getFileHandle(key: string): Promise<FileSystemFileHandle | null> {
const db = awaitthis.openDB();
const handle = await db.get(this.storeName, key);
return handle || null;
}
async removeFileHandle(key: string): Promise<void> {
const db = awaitthis.openDB();
await db.delete(this.storeName, key);
}
private openDB(): Promise<IDBDatabase> {
returnnewPromise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
});
}
}
// 使用示例
const permManager = new FileSystemPermissionManager();
asyncfunction openProjectFolder() {
try {
const dirHandle = awaitwindow.showDirectoryPicker();
// 保存权限
await permManager.saveFileHandle('project-root', dirHandle asany);
console.log('✅ 项目文件夹已保存,下次可以直接访问');
} catch (err) {
console.error('打开项目文件夹失败:', err);
}
}
asyncfunction restoreProjectFolder() {
const dirHandle = await permManager.getFileHandle('project-root');
if (dirHandle) {
console.log('✅ 已恢复项目文件夹访问权限');
return dirHandle;
} else {
console.log('⚠️ 没有保存的权限,请重新选择');
returnnull;
}
}
关键问题:用户的权限可以被撤销吗?
是的。在浏览器设置中,用户可以随时查看哪些网站有文件系统权限,并撤销。因此你的代码应该有降级方案:
async function safeReadFile(
fileHandle: FileSystemFileHandle
): Promise<string | null> {
try {
// 先验证权限
const permission = await fileHandle.queryPermission({ mode: 'read' });
if (permission === 'denied') {
// 权限被撤销,请求重新授权
const newPermission = await fileHandle.requestPermission({ mode: 'read' });
if (newPermission !== 'granted') {
thrownewError('用户拒绝了权限请求');
}
}
const file = await fileHandle.getFile();
returnawait file.text();
} catch (err) {
console.error('读取文件失败:', err);
returnnull;
}
}
让我们整合以上知识,构建一个真实可用的代码编辑器:
class SimpleCodeEditor {
private currentFile: FileSystemFileHandle | null = null;
private isDirty = false;
private editor: HTMLTextAreaElement;
private statusDiv: HTMLDivElement;
constructor(editorSelector: string, statusSelector: string) {
this.editor = document.querySelector(editorSelector) as HTMLTextAreaElement;
this.statusDiv = document.querySelector(statusSelector) as HTMLDivElement;
// 监听编辑器变化
this.editor.addEventListener('input', () => {
this.isDirty = true;
this.updateStatus('有未保存的改动 💾');
});
// 快捷键: Ctrl+O 打开,Ctrl+S 保存
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'o') {
e.preventDefault();
this.open();
}
if (e.key === 's') {
e.preventDefault();
this.save();
}
}
});
}
async open(): Promise<void> {
try {
const [fileHandle] = awaitwindow.showOpenFilePicker({
types: [
{
description: 'Code Files',
accept: {
'text/plain': ['.js', '.ts', '.jsx', '.tsx'],
'text/html': ['.html'],
'application/json': ['.json']
}
}
]
});
const file = await fileHandle.getFile();
const content = await file.text();
this.editor.value = content;
this.currentFile = fileHandle;
this.isDirty = false;
this.updateStatus(`📂 已打开: ${file.name}`);
} catch (err) {
console.error('打开文件失败:', err);
}
}
async save(): Promise<void> {
if (!this.isDirty) {
this.updateStatus('✅ 没有改动需要保存');
return;
}
if (!this.currentFile) {
// 第一次保存,需要选择位置
awaitthis.saveAs();
return;
}
try {
const writable = awaitthis.currentFile.createWritable();
await writable.write(this.editor.value);
await writable.close();
this.isDirty = false;
this.updateStatus('✅ 已保存');
} catch (err) {
console.error('保存失败:', err);
this.updateStatus('❌ 保存失败');
}
}
async saveAs(): Promise<void> {
try {
const fileHandle = awaitwindow.showSaveFilePicker({
suggestedName: 'untitled.js',
types: [
{
description: 'JavaScript',
accept: { 'text/javascript': ['.js'] }
},
{
description: 'TypeScript',
accept: { 'text/typescript': ['.ts'] }
}
]
});
const writable = await fileHandle.createWritable();
await writable.write(this.editor.value);
await writable.close();
this.currentFile = fileHandle;
this.isDirty = false;
this.updateStatus(`✅ 已保存为: ${fileHandle.name}`);
} catch (err) {
console.error('另存为失败:', err);
}
}
private updateStatus(message: string): void {
this.statusDiv.textContent = message;
}
}
// 使用示例
const editor = new SimpleCodeEditor('textarea#code-editor', 'div#status');
对应的 HTML:
<!DOCTYPE html>
<html>
<head>
<title>File System API 代码编辑器</title>
<style>
body {
font-family: 'Monaco', 'Menlo', monospace;
margin: 0;
padding: 20px;
background: #1e1e1e;
color: #e0e0e0;
}
textarea {
width: 100%;
height: 500px;
background: #252526;
color: #e0e0e0;
border: none;
padding: 15px;
font-family: inherit;
font-size: 14px;
border-radius: 4px;
}
#status {
margin-top: 10px;
padding: 10px;
background: #333;
border-radius: 4px;
font-size: 12px;
}
</style>
</head>
<body>
<h1>📝 代码编辑器</h1>
<p>快捷键: Ctrl+O 打开 | Ctrl+S 保存</p>
<textarea id="code-editor" placeholder="开始编写代码..."></textarea>
<div id="status">准备就绪</div>
<script type="module" src="editor.ts"></script>
</body>
</html>
截至 2024 年,File System Access API 的支持情况如下:
浏览器 | 支持情况 | 特殊说明 |
|---|---|---|
Chrome | ✅ 完全支持 | 86+ 版本 |
Edge | ✅ 完全支持 | 79+ 版本 |
Firefox | ⚠️ 试验阶段 | 需要标志启用 |
Safari | ❌ 暂无 | 仍在考虑阶段 |
降级方案(支持不支持的浏览器):
async function modernFileAccess(callback: (content: string) => void) {
// 检查 API 支持
if ('showOpenFilePicker'inwindow) {
// 使用 File System API
try {
const [fileHandle] = await (windowasany).showOpenFilePicker();
const file = await fileHandle.getFile();
const content = await file.text();
callback(content);
} catch (err) {
console.error('使用现代 API 失败:', err);
}
} else {
// 降级到传统 FileReader + input[type="file"]
const input = document.createElement('input');
input.type = 'file';
input.onchange = async () => {
const file = (input.files as FileList)[0];
const reader = new FileReader();
reader.onload = (e) => {
callback(e.target?.result asstring);
};
reader.readAsText(file);
};
input.click();
}
}
File System Access API 的安全模型有以下特点:
showXxxPicker() 让用户主动选择queryPermission() 验证权限状态但是,开发者需要注意的"陷阱":
❌ 错误做法:假设权限永远存在
// ❌ 这样很危险
async function dangerousRead() {
// 用户之前授权过,这次不检查,直接读取
const file = await this.fileHandle.getFile();
return await file.text();
}
✅ 正确做法:每次都检查权限
// ✅ 正确的做法
asyncfunction safeRead() {
const permission = awaitthis.fileHandle.queryPermission({ mode: 'read' });
if (permission !== 'granted') {
const newPerm = awaitthis.fileHandle.requestPermission({ mode: 'read' });
if (newPerm !== 'granted') thrownewError('权限被拒绝');
}
const file = awaitthis.fileHandle.getFile();
returnawait file.text();
}
File System Access API 是一个"突破但不失控"的完美例子。它:
对于中国的开发者,这个 API 开启了一类全新的应用可能性:
🎯 立即可用:
🔮 未来前景:
但记住:伟大的能力带来伟大的责任。在使用这个 API 时,始终问自己三个问题:
我是前端达人的内容创作者。专注于深度技术文章、源码解读和前端架构分析。
如果你对现代 JavaScript、React 19、TypeScript 或构建工具感兴趣,欢迎:
👉 关注《前端达人》公众号 - 获取最新技术深度文章
👉 在评论区讨论 - 你对 File System API 有什么实际用途吗?是否遇到过权限问题?
👉 点赞与分享 - 将这篇文章推荐给更多需要它的开发者朋友 💫
看完有收获?点个赞吧! 你的每一个点赞都是我继续深度创作的动力。