Vue 3 与 File System Access API

概述

File System Access API 是现代浏览器提供的一组 API,允许 web 应用以安全的方式访问用户设备上的文件系统。与传统的文件上传相比,它提供了更强大的功能,包括:

  • 直接读取和写入本地文件
  • 浏览目录结构
  • 监听文件变化
  • 在沙盒环境中操作文件

本教程将介绍如何在 Vue 3 应用中集成 File System Access API,构建一个功能完整的文件管理器应用。

核心知识点

1. API 基本概念

File System Access API 主要包含以下核心接口:

  • FileSystemHandle:表示文件系统中的一个条目(文件或目录)
  • FileSystemFileHandle:表示单个文件的句柄
  • FileSystemDirectoryHandle:表示目录的句柄
  • FileSystemWritableFileStream:用于写入文件的流

2. 文件操作基础

打开文件

async function openFile() {
  try {
    const [fileHandle] = await window.showOpenFilePicker();
    const file = await fileHandle.getFile();
    const content = await file.text();
    return { fileHandle, content };
  } catch (error) {
    console.error('Error opening file:', error);
  }
}

保存文件

async function saveFile(fileHandle, content) {
  try {
    const writable = await fileHandle.createWritable();
    await writable.write(content);
    await writable.close();
    return true;
  } catch (error) {
    console.error('Error saving file:', error);
    return false;
  }
}

创建文件

async function createFile() {
  try {
    const fileHandle = await window.showSaveFilePicker({
      suggestedName: 'new-file.txt',
      types: [{ accept: { 'text/plain': ['.txt'] } }]
    });
    return fileHandle;
  } catch (error) {
    console.error('Error creating file:', error);
  }
}

3. 目录操作

打开目录

async function openDirectory() {
  try {
    const dirHandle = await window.showDirectoryPicker();
    return dirHandle;
  } catch (error) {
    console.error('Error opening directory:', error);
  }
}

遍历目录

async function traverseDirectory(dirHandle, path = '') {
  const entries = [];
  for await (const [name, handle] of dirHandle.entries()) {
    const fullPath = `${path}/${name}`;
    if (handle.kind === 'file') {
      entries.push({ path: fullPath, type: 'file', handle });
    } else {
      entries.push({ path: fullPath, type: 'directory', handle });
      entries.push(...await traverseDirectory(handle, fullPath));
    }
  }
  return entries;
}

4. Vue 3 组合式 API 封装

创建一个 useFileSystem 组合式函数来封装 File System Access API:

import { ref, computed } from 'vue';

export function useFileSystem() {
  const currentFile = ref(null);
  const currentDir = ref(null);
  const fileContent = ref('');
  const directoryEntries = ref([]);
  const isLoading = ref(false);

  // 打开文件
  const openFile = async (options = {}) => {
    try {
      isLoading.value = true;
      const [fileHandle] = await window.showOpenFilePicker(options);
      const file = await fileHandle.getFile();
      const content = await file.text();
      
      currentFile.value = fileHandle;
      fileContent.value = content;
      
      return { fileHandle, content };
    } catch (error) {
      console.error('Error opening file:', error);
      throw error;
    } finally {
      isLoading.value = false;
    }
  };

  // 保存文件
  const saveFile = async (handle, content) => {
    try {
      isLoading.value = true;
      const writable = await handle.createWritable();
      await writable.write(content);
      await writable.close();
      return true;
    } catch (error) {
      console.error('Error saving file:', error);
      throw error;
    } finally {
      isLoading.value = false;
    }
  };

  // 创建新文件
  const createFile = async (options = {}) => {
    try {
      isLoading.value = true;
      const fileHandle = await window.showSaveFilePicker(options);
      currentFile.value = fileHandle;
      fileContent.value = '';
      return fileHandle;
    } catch (error) {
      console.error('Error creating file:', error);
      throw error;
    } finally {
      isLoading.value = false;
    }
  };

  // 打开目录
  const openDirectory = async () => {
    try {
      isLoading.value = true;
      const dirHandle = await window.showDirectoryPicker();
      currentDir.value = dirHandle;
      await loadDirectoryEntries(dirHandle);
      return dirHandle;
    } catch (error) {
      console.error('Error opening directory:', error);
      throw error;
    } finally {
      isLoading.value = false;
    }
  };

  // 加载目录条目
  const loadDirectoryEntries = async (dirHandle, path = '') => {
    const entries = [];
    for await (const [name, handle] of dirHandle.entries()) {
      const fullPath = `${path}/${name}`;
      entries.push({
        name,
        path: fullPath,
        type: handle.kind,
        handle
      });
    }
    directoryEntries.value = entries;
    return entries;
  };

  // 读取文件内容
  const readFile = async (fileHandle) => {
    try {
      isLoading.value = true;
      const file = await fileHandle.getFile();
      const content = await file.text();
      return content;
    } catch (error) {
      console.error('Error reading file:', error);
      throw error;
    } finally {
      isLoading.value = false;
    }
  };

  return {
    currentFile,
    currentDir,
    fileContent,
    directoryEntries,
    isLoading,
    openFile,
    saveFile,
    createFile,
    openDirectory,
    loadDirectoryEntries,
    readFile
  };
}

最佳实践

1. 安全考虑

  • 始终请求用户授权:File System Access API 要求用户明确授权才能访问文件系统
  • 最小权限原则:只请求必要的文件或目录访问权限
  • 验证文件类型:使用 types 选项限制可选择的文件类型
  • 处理用户取消:优雅处理用户取消文件选择对话框的情况

2. 性能优化

  • 使用流处理大文件:对于大文件,使用 createWritable() 和流 API 进行分块读写
  • 缓存文件句柄:在合理范围内缓存文件句柄,避免频繁请求用户授权
  • 异步操作优化:使用 Promise.all() 并行处理多个文件操作
  • 限制目录遍历深度:对于大型目录,限制遍历深度以提高性能

3. 用户体验

  • 提供清晰的反馈:显示加载状态和操作结果
  • 支持键盘快捷键:实现常见的文件操作快捷键(如 Ctrl+S 保存)
  • 自动保存功能:定期自动保存文件,防止数据丢失
  • 文件类型图标:根据文件类型显示相应的图标,提高可读性

4. 兼容性处理

  • 特性检测:在使用 API 前检查浏览器支持
  • 提供降级方案:对于不支持的浏览器,提供传统的文件上传/下载方案
  • 错误处理:全面的错误处理,向用户显示友好的错误信息

常见问题与解决方案

1. 浏览器兼容性问题

问题:File System Access API 在某些浏览器中不被支持

解决方案

if ('showOpenFilePicker' in window) {
  // 使用 File System Access API
} else {
  // 提供传统的文件上传方案
  const input = document.createElement('input');
  input.type = 'file';
  input.onchange = (e) => {
    const file = e.target.files[0];
    // 处理文件
  };
  input.click();
}

2. 跨域安全限制

问题:无法访问来自不同域的文件

解决方案

  • File System Access API 遵循同源策略
  • 只能访问用户明确授权的文件和目录
  • 考虑使用 Web Workers 处理跨域文件操作

3. 大文件处理问题

问题:处理大文件时内存占用过高

解决方案

async function processLargeFile(fileHandle) {
  const file = await fileHandle.getFile();
  const stream = file.stream();
  const reader = stream.getReader();
  
  let result = '';
  let done = false;
  
  while (!done) {
    const { value, done: doneReading } = await reader.read();
    done = doneReading;
    if (value) {
      // 处理数据块
      result += new TextDecoder().decode(value);
    }
  }
  
  return result;
}

4. 文件句柄持久化问题

问题:刷新页面后丢失文件句柄

解决方案

// 保存文件句柄到 IndexedDB
async function saveFileHandle(handle) {
  const db = await openDB('fileSystemDB', 1, {
    upgrade(db) {
      db.createObjectStore('fileHandles');
    }
  });
  await db.put('fileHandles', handle, 'lastFile');
}

// 从 IndexedDB 恢复文件句柄
async function restoreFileHandle() {
  const db = await openDB('fileSystemDB', 1);
  const handle = await db.get('fileHandles', 'lastFile');
  return handle;
}

进阶学习资源

  1. MDN 文档File System Access API
  2. Web.dev 教程The File System Access API: simplifying access to local files
  3. W3C 规范File System Access
  4. GitHub 示例Chrome Samples - File System Access API
  5. Vue 3 组合式 API 文档Composition API

实战练习

练习:构建一个简单的文件管理器

目标:使用 Vue 3 和 File System Access API 构建一个功能完整的文件管理器

功能要求

  1. 文件操作

    • 打开和编辑文本文件
    • 创建新文件
    • 保存文件
    • 删除文件
  2. 目录操作

    • 浏览目录结构
    • 创建新目录
    • 重命名文件/目录
    • 删除目录
  3. 用户体验

    • 显示文件列表和目录结构
    • 提供文件类型图标
    • 显示加载状态
    • 支持键盘快捷键

实现步骤

  1. 创建一个 Vue 3 项目
  2. 实现 useFileSystem 组合式函数
  3. 构建文件管理器界面
  4. 实现文件和目录操作功能
  5. 添加错误处理和用户反馈
  6. 测试不同浏览器兼容性

示例代码结构

<template>
  <div class="file-manager">
    <div class="header">
      <h1>文件管理器</h1>
      <div class="actions">
        <button @click="handleOpenFile" :disabled="isLoading">打开文件</button>
        <button @click="handleCreateFile" :disabled="isLoading">新建文件</button>
        <button @click="handleOpenDirectory" :disabled="isLoading">打开目录</button>
        <button @click="handleSaveFile" :disabled="isLoading || !currentFile">保存</button>
      </div>
    </div>
    
    <div class="main">
      <div class="sidebar" v-if="currentDir">
        <h2>目录结构</h2>
        <div class="directory-tree">
          <div 
            v-for="entry in directoryEntries" 
            :key="entry.path"
            class="entry"
            :class="{ 'entry-file': entry.type === 'file', 'entry-directory': entry.type === 'directory' }"
            @click="handleEntryClick(entry)"
          >
            {{ entry.name }}
          </div>
        </div>
      </div>
      
      <div class="editor" v-if="currentFile">
        <h2>{{ currentFile.name }}</h2>
        <textarea 
          v-model="fileContent" 
          placeholder="文件内容"
          :disabled="isLoading"
        ></textarea>
      </div>
      
      <div class="welcome" v-else-if="!isLoading">
        <h2>欢迎使用文件管理器</h2>
        <p>点击上方按钮打开文件或目录</p>
      </div>
    </div>
    
    <div class="loading" v-if="isLoading">
      加载中...
    </div>
  </div>
</template>

<script setup>
import { useFileSystem } from './composables/useFileSystem';

const {
  currentFile,
  currentDir,
  fileContent,
  directoryEntries,
  isLoading,
  openFile,
  saveFile,
  createFile,
  openDirectory,
  readFile
} = useFileSystem();

const handleOpenFile = async () => {
  try {
    await openFile({
      types: [{ accept: { 'text/plain': ['.txt', '.md', '.js', '.html', '.css'] } }]
    });
  } catch (error) {
    if (error.name !== 'AbortError') {
      alert('打开文件失败:' + error.message);
    }
  }
};

const handleCreateFile = async () => {
  try {
    await createFile({
      suggestedName: 'new-file.txt',
      types: [{ accept: { 'text/plain': ['.txt'] } }]
    });
  } catch (error) {
    if (error.name !== 'AbortError') {
      alert('创建文件失败:' + error.message);
    }
  }
};

const handleOpenDirectory = async () => {
  try {
    await openDirectory();
  } catch (error) {
    if (error.name !== 'AbortError') {
      alert('打开目录失败:' + error.message);
    }
  }
};

const handleSaveFile = async () => {
  if (!currentFile.value) return;
  
  try {
    await saveFile(currentFile.value, fileContent.value);
    alert('文件保存成功!');
  } catch (error) {
    alert('保存文件失败:' + error.message);
  }
};

const handleEntryClick = async (entry) => {
  if (entry.type === 'file') {
    try {
      const content = await readFile(entry.handle);
      fileContent.value = content;
      currentFile.value = entry.handle;
    } catch (error) {
      alert('读取文件失败:' + error.message);
    }
  }
};
</script>

<style scoped>
.file-manager {
  display: flex;
  flex-direction: column;
  height: 100vh;
  font-family: Arial, sans-serif;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  background-color: #f0f0f0;
  border-bottom: 1px solid #ddd;
}

.actions button {
  margin-left: 0.5rem;
  padding: 0.5rem 1rem;
  cursor: pointer;
}

.actions button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.main {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.sidebar {
  width: 300px;
  border-right: 1px solid #ddd;
  overflow-y: auto;
  padding: 1rem;
}

.directory-tree {
  margin-top: 1rem;
}

.entry {
  padding: 0.5rem;
  cursor: pointer;
  border-radius: 4px;
  margin-bottom: 0.25rem;
}

.entry:hover {
  background-color: #f0f0f0;
}

.entry-file {
  color: #333;
}

.entry-directory {
  color: #0066cc;
  font-weight: bold;
}

.editor {
  flex: 1;
  padding: 1rem;
  display: flex;
  flex-direction: column;
}

.editor textarea {
  flex: 1;
  width: 100%;
  padding: 1rem;
  font-family: monospace;
  font-size: 14px;
  border: 1px solid #ddd;
  border-radius: 4px;
  resize: none;
}

.welcome {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  color: #666;
}

.loading {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(255, 255, 255, 0.8);
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 1.2rem;
  font-weight: bold;
}
</style>

进阶学习资源

  1. MDN Web Docs

  2. Web.dev 文章

  3. GitHub 仓库

  4. 视频教程

  5. 规范文档

总结

File System Access API 为 web 应用提供了强大的本地文件系统访问能力,结合 Vue 3 的组合式 API,可以构建出功能丰富、用户体验良好的文件管理应用。在使用过程中,需要注意安全问题、性能优化和浏览器兼容性,始终以用户体验为中心,提供清晰的反馈和友好的错误处理。

通过本教程的学习,你应该能够:

  • 理解 File System Access API 的核心概念和基本用法
  • 实现 Vue 3 组合式函数封装 API
  • 构建功能完整的文件管理器应用
  • 遵循最佳实践,确保应用的安全性和性能
  • 处理常见问题,提供良好的用户体验

File System Access API 是 web 应用向桌面级体验迈进的重要一步,它为开发者提供了更多可能性,可以构建出更加强大和灵活的 web 应用。

« 上一篇 Vue 3 与 IndexedDB 深度集成 下一篇 » Vue 3 与 MediaRecorder API