返回文章列表
/ 前端

浏览器读取本地目录:预览与导入

File System Access API 与 webkitdirectory 两种「用户明确授权」路径的取舍与实现边界。

File System Access API浏览器文件导入

场景:前端页面让用户选择一个本地目录,做预览(纯前端读文件渲染)或导入(落盘到服务器)。 适用栈:本仓库 React + esbuild 的前端(src/*/public、services/*),后端 Node fs。

1. 结论:默认不能,只有两种「用户明确授权」的例外

浏览器处于安全沙箱,脚本无文件系统权限,直接 fetch("file:///...") 会被同源策略/CORS 拦截。 仅以下两种需在用户手势触发下可用:

File System Access API(FSA)<input type="file" webkitdirectory>
浏览器Chrome / Edge(Chromium 系)全部(含 Firefox / Safari)
读目录树✅ 真递归 dir.entries()⚠️ 间接:靠 webkitRelativePath 反推
写回✅ createWritable()❌
真实路径✅(相对根)❌ 拿不到
安全上下文必须 localhost / https同

选型:用 FSA 做主路径,检测到不支持时回退 webkitdirectory。两者都只在按钮点击时触发。

2. 读取目录树(FSA 主路径)

type TreeNode = {
  name: string;
  path: string;            // 相对所选根
  kind: "dir" | "file";
  handle?: FileSystemFileHandle;
  children?: TreeNode[];
};

async function readDirectoryTree(
  dir: FileSystemDirectoryHandle,
  base = "",
): Promise<TreeNode> {
  const node: TreeNode = { name: dir.name, path: base, kind: "dir", children: [] };
  for await (const [name, entry] of (dir as any).entries()) {
    const childPath = base ? `${base}/${name}` : name;
    if (entry.kind === "file") {
      node.children!.push({ name, path: childPath, kind: "file", handle: entry });
    } else {
      node.children!.push(await readDirectoryTree(entry, childPath));
    }
  }
  return node;
}

async function collectFiles(node: TreeNode, out: Map<string, File>): Promise<void> {
  if (node.kind === "file" && node.handle) {
    out.set(node.path, await (node.handle as FileSystemFileHandle).getFile());
    return;
  }
  for (const c of node.children ?? []) await collectFiles(c, out);
}

async function pickLocalDirectory(): Promise<{ tree: TreeNode; files: Map<string, File> }> {
  const dir = await (window as any).showDirectoryPicker(); // 用户手势触发
  const tree = await readDirectoryTree(dir);
  const files = new Map<string, File>();
  await collectFiles(tree, files);
  return { tree, files };
}

读内容:const file = await handle.getFile(); const text = await file.text();

回退路径(非 Chromium)

<input type="file" webkitdirectory multiple
       onChange={(e) => { /* 遍历 e.target.files,按 file.webkitRelativePath 反推树 */ }} />

回退路径拿不到真实绝对路径、不能写回,仅用于预览/导入,无法递归列目录(需自行按 webkitRelativePath 拼树)。

3. 两种落地形态

  • 预览(纯前端):把 tree 喂给现有的文件树组件(theme-workbench 已有 FileNode / 树 UI),选中文件用 file.text() 渲染,不经过后端。
  • 导入(落盘):把 files 打包成 FormData / JSON POST 到后端某 /api/...,由 Node fs 写入目标目录 (主题即写入 src/store/public/themes/<theme>/)。浏览器只能选 + 读,落盘必须由后端做—— 与现有 theme-workbench「服务端 fs 读 themesDir」架构一致。

4. 部署约束

  • FSA 要求 secure context:dev 跑在 localhost(如 localhost:8003)✅;线上必须用 https,否则 showDirectoryPicker 为 undefined。
  • 权限会话级,刷新后需重新 showDirectoryPicker(可用 queryPermission 续期)。

5. 在本仓库的建议落点

  • 通用能力:新增 src/common/client/useLocalDirectory.ts(hook)+ <DirectoryPicker> 组件, 与现有 services/* 平级,前端统一复用;内部做 showDirectoryPicker 是否存在的 feature detection, 缺失时渲染回退的 webkitdirectory input。
  • 具体页面(优先):theme-workbench 增加「导入本地主题目录」按钮 —— 补齐它当前只有 「服务端读 themesDir」的短板,支持本地草稿预览 / 导入。
  • 备选:store 设计器(store-design)选本地资源目录预览;或全新的导入弹窗。

6. 待确认

  • 具体落到哪个页面 / 场景(theme-workbench 导入主题目录 / store-design 资源预览 / 新导入弹窗)?
  • 是只要「预览」还是也要「导入落盘」(导入需配套后端接口,见第 3 节)。