浏览器有三个访问文件系统的 API
showOpenFilePicker:打开文件选择器,可以选择一个或多个文件showDictoryPicker:打开目录选择器,选择一个文件夹showSaveFilePicker:打开保存文件对话框
此功能仅在安全上下文(HTTPS)或
localhost中可用,且必须由用户手势(如点击)触发。另仅在部分浏览器中支持,Firefox 和 Safari 中暂不可用
showOpenFilePicker
showOpenFilePicker 语法如下
const fileHandles = await window.showOpenFilePicker(options);
入参 options 包含如下配置项:
-
multiple:boolean,是否支持选择多个文件,默认为 false -
types:数组,允许选择的文件类型数组const options = { types: [ { description: '图片文件', accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'] } }, { description: '文本文件', accept: { 'text/plain': ['.txt', '.md'] } } ] }; -
excludeAcceprAllOption:boolean,是否排除「所有文件」的选项,默认是 false,即可以选择所有文件,如果设置为 true,那么就不能选择任意文件,但是就必须得设置 types 属性,否则就会抛出 TypeError 异常// 只允许用户选择图片 const fileHandlers = await showOpenFilePicker({ types: [{ description: '图片', accept: { 'image/*': ['.png', '.jpg'] } }], excludeAcceptAllOption: true }); -
startIn:起始目录,可选值有desktop、documents、downloads、music、pictures、videos,如果没有设置此属性,分为两种情况- 首次使用:通常打开用户的「文档」文件夹或系统默认位置
- 后续使用:浏览器会记住上次选择的目录,自动从那里开始
返回值是 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:起始目录,可选值有desktop、documents、downloads、music、pictures、videos
返回值是一个 FileSystemDirectoryHandle 对象,同样包含两个属性:
kind:始终为"directory"name:目录名称
对象上存在如下方法:
-
entries,返回一个迭代器,AsyncIterableIterator<[string, FileSystemHandle]>另外还存在
keys和values两个方法,分别返回的文件(夹)名称和文件(夹)句柄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如果指定的文件名对应的是目录而不是文件,那么会抛出
TypeMismatchErrorconst 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,则只能删除空目录,否则会抛出InvalidModificationErrorconst 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:起始的文件目录,可选值有desktop、documents、downloads、music、pictures、videos
返回值为 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();
}
参考: