React 与 浏览器文件系统访问(File System Access API):构建 React 驱动的本地化 IDE 状态同步层

嘿,大家好!把你们的笔记本电脑合上两秒钟,假装你们正在听一场关于“如何在浏览器里搞出一个 VS Code”的讲座。别担心,我不会让你们去写编译器,那是给那些把头发都写秃了的家伙干的。

今天我们要聊的是:React 与 浏览器文件系统访问 API

这听起来很高大上,对吧?但别被那些花哨的名词吓跑了。其实,这就像是你以前用 fs 模块写 Node.js,只不过现在你是在浏览器里写。而且,我们用的是 React,所以 UI 是活的,数据是流动的。

准备好了吗?让我们把“Ctrl+S”这个动作,变成一种 React 状态的自动同步魔法。


第一部分:为什么我们要回到“本地文件”?

你知道那种感觉吗?你写了一下午的代码,突然浏览器崩了,或者你刷新了页面,结果——“哎呀,我的代码呢?”

这就是为什么我们要把文件从服务器拉下来,放到本地。传统的 <input type="file"> 是个渣男,它只给你一次机会,选完就忘。你想要一个 IDE?你需要的是“文件句柄”。你需要那个文件一直粘着你,直到你明确地说“再见”。

这就是 File System Access API 登场的时候。它不是给你一个文件;它给你的是一把钥匙。这把钥匙(FileSystemFileHandle)能让你以后想打开就打开,想写入就写入。


第二部分:核心概念——把浏览器变成你的硬盘

首先,你得明白这个 API 是怎么工作的。它非常简单,简单得让人想哭。

1. 保存文件:showSaveFilePicker

当你想保存文件时,你不会直接写文件(浏览器不让你随便写硬盘,这是为了安全,防止病毒)。你得先问用户:“嘿,我想创建一个文件,你同意吗?”

async function saveFile(data) {
  // 这里的 magic 就是 showSaveFilePicker
  const handle = await window.showSaveFilePicker({
    suggestedName: 'my-awesome-project.json', // 假装你很专业
    types: [{
      description: 'JSON Files',
      accept: {'application/json': ['.json']},
    }],
  });

  // 用户点了保存,现在 handle 就拿到了
  console.log('我拿到了钥匙!', handle);
}

看到了吗?handle 是个对象,它不仅仅是个字符串。它包含文件名、MIME 类型,最重要的是,它包含了一个指向文件的引用。

2. 写入数据:createWritableStream

拿到钥匙后,怎么开锁?用 createWritableStream

async function saveFile(data) {
  const handle = await window.showSaveFilePicker({ ... });

  // 创建一个可写的流
  const writable = await handle.createWritable();

  // 把数据塞进去
  await writable.write(data);

  // 关上流,确保数据写入磁盘
  await writable.close();
}

这就像你把信塞进信封,封好口,然后扔进邮筒。close 就是封口。


第三部分:React Hook——封装你的文件系统

如果你在组件里到处写 await window.showSaveFilePicker,那代码会乱成一锅粥。我们需要一个 Custom Hook。就像 React 的 useState,但专门管文件。

让我们来写一个 useFileSystem Hook。它需要知道:当前文件句柄是什么?文件内容是什么?有没有错误?

import { useState, useEffect } from 'react';

// 定义我们想要的状态结构
const initialState = {
  fileHandle: null,
  content: '',
  isDirty: false, // 内容有没有被修改过?
  error: null,
};

export const useFileSystem = (fileName = 'untitled.txt') => {
  const [state, setState] = useState(initialState);
  const [permissionState, setPermissionState] = useState('prompt');

  // 1. 初始化:尝试从 localStorage 恢复文件句柄
  useEffect(() => {
    const savedHandle = localStorage.getItem('lastFileHandle');
    if (savedHandle) {
      restoreFileHandle(JSON.parse(savedHandle));
    }
  }, []);

  const restoreFileHandle = async (handleObj) => {
    try {
      const handle = await window.showDirectoryPicker(); // 恢复目录上下文
      const fileHandle = await handle.getFileHandle(fileName, { create: true });

      // 检查权限
      const permission = await fileHandle.queryPermission({ mode: 'readwrite' });
      if (permission === 'granted') {
        setPermissionState('granted');
        setState(prev => ({ ...prev, fileHandle }));
        readFile(fileHandle);
      } else if (permission === 'prompt') {
        // 如果需要权限,尝试请求
        const request = await fileHandle.requestPermission({ mode: 'readwrite' });
        if (request === 'granted') {
          setPermissionState('granted');
          setState(prev => ({ ...prev, fileHandle }));
          readFile(fileHandle);
        }
      }
    } catch (err) {
      console.error('恢复文件失败', err);
    }
  };

  // 2. 读取文件:把文件内容变成 React 的 State
  const readFile = async (handle) => {
    try {
      const file = await handle.getFile();
      const text = await file.text();
      setState(prev => ({ ...prev, content: text, isDirty: false, fileHandle: handle }));
    } catch (err) {
      console.error('读取文件失败', err);
    }
  };

  // 3. 保存文件:React State -> 磁盘
  const saveFile = async () => {
    if (!state.fileHandle) return;

    try {
      const writable = await state.fileHandle.createWritable();
      await writable.write(state.content);
      await writable.close();

      setState(prev => ({ ...prev, isDirty: false }));

      // 把文件句柄存起来,下次打开还在
      localStorage.setItem('lastFileHandle', JSON.stringify(state.fileHandle));
    } catch (err) {
      console.error('保存失败', err);
      setState(prev => ({ ...prev, error: '保存失败,可能是权限问题' }));
    }
  };

  // 4. 修改内容:更新 React State,标记为脏数据
  const handleContentChange = (newContent) => {
    setState(prev => ({ ...prev, content: newContent, isDirty: true }));
  };

  return {
    ...state,
    saveFile,
    handleContentChange,
    restoreFileHandle,
  };
};

看,这就是封装的力量。组件里只需要调用 handleContentChange,然后调用 saveFile。逻辑全在 Hook 里,干净利落。


第四部分:同步层——让文件动起来

现在,我们有了 React State(content)和文件系统(fileHandle)。但是,它们之间是怎么同步的?

在传统的 Web 应用里,你写完代码,点击保存,数据传到服务器,服务器存数据库,然后给你个成功提示。

但在本地文件系统里,我们不需要服务器。我们需要的是 实时同步

场景 A:自动保存

想象一下,你在打字。每敲一下键盘,React 的 onChange 触发,isDirty 变成 true。如果你设置了自动保存,你可以在 useEffect 里监听 isDirty

// 在你的组件里
const { content, isDirty, saveFile } = useFileSystem();

useEffect(() => {
  if (isDirty) {
    // 这里有个问题:不能每次敲击都保存,太慢了!
    // 解决方案:防抖
    const timer = setTimeout(() => {
      saveFile();
    }, 1000); // 停止打字1秒后自动保存

    return () => clearTimeout(timer);
  }
}, [content, isDirty, saveFile]);

但是,saveFile 里面调用了 createWritable。如果你每秒都调一次,那性能会炸裂。所以,防抖 是必须的。

场景 B:乐观 UI(Optimistic UI)

这是 React 专家最擅长的事。用户点击“保存”按钮,我们假设保存成功了,立刻更新 UI 状态,然后后台悄悄地去写文件。

const handleSaveClick = async () => {
  // 1. UI 先动:让按钮变绿,状态变干净
  setState(prev => ({ ...prev, isSaving: true, error: null }));

  try {
    // 2. 后台执行:真正的文件操作
    await saveFile();

    // 3. UI 再次确认:保存成功
    setState(prev => ({ ...prev, isSaving: false, isDirty: false }));
  } catch (err) {
    // 4. 出错了:回滚 UI,显示错误
    setState(prev => ({ ...prev, isSaving: false, error: err.message }));
  }
};

这种体验比传统的“Loading… -> 成功”要流畅得多。


第五部分:处理“项目”——目录与持久化

IDE 不止一个文件。一个项目通常有一堆文件:index.js, style.css, package.json

这时候,我们需要 showDirectoryPicker。它和 showFilePicker 有点像,但它是针对文件夹的。

1. 选择项目目录

async function openProject() {
  try {
    const dirHandle = await window.showDirectoryPicker();

    // 把目录句柄存起来
    localStorage.setItem('projectHandle', JSON.stringify(dirHandle));

    // 遍历目录里的文件
    for await (const entry of dirHandle.values()) {
      if (entry.kind === 'file') {
        const file = await entry.getFile();
        console.log('发现文件:', entry.name);
        // 在这里,你可以把文件内容加载到你的 React 状态树里
        // 比如构建一个对象: { 'index.js': content, 'style.css': content }
      }
    }
  } catch (err) {
    console.error('打开项目失败', err);
  }
}

2. 持久化目录句柄

这是最酷的地方。浏览器允许你把 FileSystemDirectoryHandle 存在 localStorage 里。

// 保存项目
const saveProject = async (projectData) => {
  const dirHandle = JSON.parse(localStorage.getItem('projectHandle'));

  for (const [fileName, content] of Object.entries(projectData)) {
    // 检查文件是否存在
    let fileHandle = await dirHandle.getFileHandle(fileName, { create: true });

    // 写入内容
    const writable = await fileHandle.createWritable();
    await writable.write(content);
    await writable.close();
  }
};

当你刷新页面,React 启动,你的 useEffect 检查 localStorage,发现有个 projectHandle。你把它读回来,然后告诉 React:“嘿,这是我的项目目录,加载所有文件。”


第六部分:流——处理大文件的艺术

如果你的文件只有几 KB,直接 file.text() 没问题。但如果你的项目里有 500MB 的日志文件,或者你正在编辑一个巨大的视频转码脚本,直接读入内存会直接把浏览器搞崩。

这时候,我们需要 Streams API

async function streamReadFile(handle) {
  const file = await handle.getFile();
  const reader = file.stream().getReader();

  let result = '';

  while (true) {
    const { done, value } = await reader.read();

    if (done) break;

    // value 是一个 Uint8Array
    result += decoder.decode(value, { stream: true });
  }

  return result;
}

或者,更高级一点,不要把整个文件读进 State

你可以直接把流传给你的编辑器组件(比如 Monaco Editor 或 CodeMirror)。它们通常支持 onDidChangeModelContent 回调。每当内容变化,你就把变化的部分写入文件,而不是重写整个文件。

// 伪代码:流式写入变化的部分
editor.onDidChangeModelContent(async (change) => {
  const range = change.changes[0].range;
  const text = editor.getValue(); // 或者只获取 change.changes[0].text

  // 只写入变化的部分
  const writable = await fileHandle.createWritable();
  // 注意:这里需要复杂的逻辑来定位光标和写入范围
  // await writable.write({ type: 'replace', range, text });
  await writable.close();
});

第七部分:权限与错误处理——现实世界的残酷

你以为一切都很完美?现实是,用户可能拒绝了权限。用户可能点了取消。用户可能拔掉了 U 盘(开玩笑的)。

你需要优雅地处理这些。

1. 权限请求

const requestPermission = async (handle) => {
  const permission = await handle.requestPermission({ mode: 'readwrite' });
  if (permission === 'granted') {
    return handle;
  } else {
    throw new Error('用户拒绝了文件访问权限');
  }
};

2. 用户取消

window.showSaveFilePickershowDirectoryPicker 都会在用户点击取消时抛出异常。

try {
  await saveFile();
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('用户取消了保存');
  } else {
    console.error('未知错误', err);
  }
}

3. 离线支持

一旦你拿到了文件句柄,你就不依赖网络了。这就是为什么这种架构在离线环境下表现极佳。你的 IDE 变成了一个纯粹的本地应用,只有 UI 层是 React。


第八部分:终极实战——构建一个微型 IDE

好了,理论讲得够多了。让我们把这些碎片拼起来。

我们要构建一个 MiniIDE 组件。它包含一个文本框,一个保存按钮,一个状态指示器。

import React, { useState, useEffect } from 'react';
import './App.css';

const MiniIDE = () => {
  const { content, isDirty, isSaving, saveFile } = useFileSystem('main.js');

  return (
    <div className="ide-container">
      <header className="ide-header">
        <h1>React Mini IDE</h1>
        <div className="status-bar">
          <span className={isDirty ? 'dirty' : 'clean'}>
            {isDirty ? '● 未保存' : '● 已保存'}
          </span>
          <span className="saving">
            {isSaving ? ' 正在保存...' : ''}
          </span>
        </div>
      </header>

      <main className="ide-body">
        <textarea
          value={content}
          onChange={(e) => handleContentChange(e.target.value)}
          placeholder="// 开始写代码吧... Ctrl+S 保存"
          spellCheck="false"
        />
      </main>

      <footer className="ide-footer">
        <button onClick={saveFile} disabled={isSaving || !isDirty}>
          {isSaving ? '保存中...' : '保存文件'}
        </button>
      </footer>
    </div>
  );
};

export default MiniIDE;

配合我们的 useFileSystem Hook,这就能跑起来了。

现在,当你修改文本框里的内容,状态会变成“未保存”。当你点击保存按钮(或者等待 1 秒自动保存),文件会真正写入你的硬盘。


第九部分:未来展望——WebAssembly 的结合

这还不够酷。React 处理 UI,File System Access API 处理文件,那谁处理编译和运行呢?

WebAssembly (Wasm)

你可以把 C++ 编写的编译器(比如 Rust 的 wasm-pack)编译成 .wasm 文件。然后在 React 组件里加载这个 .wasm 模块。

  1. 用户在 React 的文本框里写代码。
  2. React 通过 File System Access API 读取文件内容。
  3. React 把内容传给 Wasm 模块。
  4. Wasm 编译代码并返回结果。
  5. React 渲染结果。

这基本上就是 VS Code 在浏览器里的实现原理!React 只是那个漂亮的皮肤,而底层逻辑全是本地文件系统和 WebAssembly。


总结(虽然我不喜欢总结,但规矩就是规矩)

我们今天不仅仅是看了代码,我们实际上是重新定义了浏览器的边界

以前,浏览器是沙盒,是只读的。现在,通过 React 和 File System Access API,浏览器变成了一个全功能的操作系统 shell。

  • React 负责展示和状态管理。
  • File System Access API 负责持久化存储。
  • Custom Hooks 负责连接两者。

当你点击那个“保存”按钮时,你不仅仅是在保存数据,你是在告诉浏览器:“嘿,我相信你,把这个数据存进我的硬盘里。”

这就是现代前端开发最性感的地方:我们不再只是构建网页,我们是在构建应用。

现在,去写一个你的本地 IDE 吧。记得,Ctrl+S 是给胆小鬼用的,真正的大佬都依赖自动保存。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注