Node 文件遍历工具的迭代:从 walk 到 readdir,再到 tinyglobby

文件遍历看起来是一个很小的问题。

写进工具后,它往往会变成一串细节:要不要递归、要不要只要文件、要不要包含 dotfile、符号链接怎么办、输出路径是系统路径还是 URL 路径、Windows 反斜杠要不要处理、远端对象存储需要什么 key。

这次问题来自一个发布脚本:Hexo 生成 public 目录后,需要把里面所有文件上传到 OSS。ali-oss 提供的是上传单个对象的 API,不能把「上传一个文件夹」这件事完整托管给 SDK,所以脚本必须自己把文件枚举出来,再逐个上传。

OSS PutObject 文档 描述的是上传一个 object。OSS 的核心模型不是本地文件系统里的目录树,而是 bucket 里的 object key。所谓目录通常是通过 key 里的 / 前缀模拟出来的。

问题也就从「怎么上传文件夹」变成了「怎么稳定枚举本地目录里的所有文件,并生成远端 object key」。

第一版:walk 把细节藏在事件里

旧写法使用的是 walk

const OSS = require('ali-oss');
const walk = require('walk');
const slash = require('slash');

const walker = walk.walk('./public');

walker.on('file', async (root, fileStats, next) => {
  root = slash(root);

  const { name } = fileStats;
  const objectKey = root.replace('./public', '') + '/' + name;
  const filePath = `${root}/${name}`;

  const result = await client.put(objectKey, filePath, {
    headers: {
      'Cache-Control': 'max-age=31536000, must-revalidate',
    },
  });

  console.log(`${result.name} -> ${result.url}`);
  next();
});

这段代码有一个好处:主流程里不需要判断 isFile()。因为 walk 已经把遍历结果按事件分好了,只有文件才会进入 file 事件。

但这个好处是用隐藏复杂度换来的。

事件式 API 会让控制流变得分散。async 回调和 next() 放在一起,也容易让人担心错误传播、并发顺序和进程退出时机。再加上 root.replace('./public', '') 这类字符串处理,脚本能跑,但不够直观。

它的问题不是「不能用」,而是这类库的心智已经有点旧。现代 Node 脚本更习惯 async / await、Promise、标准库和小型领域库。

第二版:readdir 更标准,但暴露了底层细节

Node 标准库里有 fs.promises.readdir

配合 recursive: truewithFileTypes: true,可以一次拿到递归目录下的 Dirent 列表。

import { readdir } from 'node:fs/promises';
import { join, relative } from 'node:path';
import slash from 'slash';

const entries = await readdir(publicDir, {
  recursive: true,
  withFileTypes: true,
});

for (const entry of entries) {
  if (!entry.isFile()) {
    continue;
  }

  const filePath = join(entry.parentPath, entry.name);
  const objectKey = `/${slash(relative(publicDir, filePath))}`;

  await client.put(objectKey, slash(filePath), uploadOptions);
}

这版比 walk 现代很多:它是标准库,控制流也是普通 for...of + await

但它也把两个底层问题暴露到主流程里。

第一个问题是文件过滤。

readdir 返回的是目录项,不是「文件列表」。它可以告诉你每一项是不是文件、是不是目录、是不是符号链接,但没有 onlyFiles: true 这种选项。所以主流程必须写:

if (!entry.isFile()) {
  continue;
}

第二个问题是路径语义。

join()relative() 处理的是本机文件系统路径。macOS / Linux 下是 /,Windows 下是 \。但 OSS object key 和 URL 更接近 POSIX 路径,应该稳定使用 /

因此脚本又需要 slash(relative(...)) 这样的转换。

这不算错,但读者会看到很多「文件系统细节」:DirentisFile()parentPathrelative()slash()。这些细节本来不是发布脚本最想表达的事情。

Node.js fsPromises.glob 已经进入标准库,Node 24 / 22.17 起标记为 stable。它返回匹配 pattern 的文件路径,也支持 cwdexcludewithFileTypes 等选项。这说明 Node 标准库也在往 glob 语义补能力,但生态里的成熟 glob 库仍然有更丰富的工程经验。

那为什么没有直接用 fs.glob

不是因为它不能用,而是这个脚本想要的是「拿到 public 下面所有可上传文件的相对路径数组」。fs.glob 在这个场景里还要多处理几件事。

第一,它返回的是 AsyncIterator,如果后面要排序、统计、dry-run 或复用文件列表,通常还要先把它转成数组。

import { glob } from 'node:fs/promises';

const files = [];

for await (const objectPath of glob('**/*', { cwd: publicDir })) {
  files.push(objectPath);
}

第二,glob('**/*') 会匹配到目录。这个行为不奇怪,因为 glob 关心的是 pattern 匹配;但上传 OSS 时目录不是要上传的对象,所以还得继续过滤。

import { glob } from 'node:fs/promises';
import { posix } from 'node:path';

const files = [];

for await (const entry of glob('**/*', {
  cwd: publicDir,
  withFileTypes: true,
})) {
  if (!entry.isFile()) {
    continue;
  }

  files.push(posix.relative(publicDir, posix.join(entry.parentPath, entry.name)));
}

这段代码已经比 readdir 接近问题本身,但主流程又重新出现了 withFileTypesisFile()parentPathrelative()。换句话说,它解决了「pattern 匹配」,但没有把「只要文件列表」这件事完整表达出来。

第三,它稳定得比较晚。fs.glob 在 Node 22.0.0 才加入,Node 24.0.0 / 22.17.0 才标记 stable。对明确锁定新 Node 的项目来说这不是问题;但对依附在正式项目里的小脚本来说,如果引入一个很小的成熟库能换来更明确的语义和更少的版本顾虑,这个取舍是合理的。

第三版:tinyglobby 把「要哪些文件」说出来

如果脚本要做的是「匹配 public 下所有文件」,glob 工具会更贴近问题本身。

Vite 的 import.meta.glob 就是这个方向。

Vite 文档 明确写到,import.meta.glob 的匹配由 tinyglobby 完成。Vite 还在插件 API 文档里强调 path normalization:Vite 会把路径规范化为 POSIX / 分隔符,并导出 normalizePath() 帮插件处理跨平台路径比较。

tinyglobby 改写发布脚本后,主流程会更像业务描述。

import { glob } from 'tinyglobby';
import { posix } from 'node:path';

const files = await glob('**/*', {
  cwd: publicDir,
  dot: true,
  onlyFiles: true,
  expandDirectories: false,
});

for (const objectPath of files) {
  const filePath = posix.join(publicDir, objectPath);
  const objectKey = posix.join('/', objectPath);

  const result = await client.put(objectKey, filePath, {
    headers: {
      'Cache-Control': 'max-age=31536000, must-revalidate',
    },
  });

  console.log(`${result.name} -> ${result.url}`);
}

这版代码里,「只要文件」变成了 onlyFiles: true
「从 public 目录算相对路径」变成了 cwd: publicDir
「延续旧脚本的 slash 风格本地上传路径」变成了 posix.join(publicDir, objectPath)
「远端 key 使用 POSIX 路径」变成了 posix.join('/', objectPath)

主流程不再需要知道 Dirent 是什么,也不需要手写 isFile()

这就是领域库的价值:它把底层事实翻译成领域语言。

tinyglobby 文档 把自己定位成 globby / fast-glob 的轻量快速替代。它默认 onlyFiles: true,内部使用 fdirpicomatch,并被 Vite、pnpm、Vitest、Astro、UnoCSS、VitePress 等工具使用。

Vite 不是所有地方都用 glob

这里有一个容易误会的点:既然 Vite 用 tinyglobby,是不是所有文件遍历都应该用 tinyglobby

不是。

Vite 也有手写递归目录复制的地方。它的 publicDir 构建复制逻辑更接近「把一个目录复制到另一个目录」,所以 Vite 源码里可以看到类似 copyDir(srcDir, destDir) 的实现:读目录、判断是不是目录、递归、复制文件。

Vite utils.ts 里能看到 copyDir 这类目录复制工具;Vite importMetaGlob.ts 里能看到 tinyglobby 用于 import.meta.glob 的匹配。两个场景的工具选择不同。

这件事反而说明主流工具链的思路很清楚:按问题选工具。

目录复制问题,用目录复制模型。
文件匹配问题,用 glob 模型。
路径比较问题,先做 path normalization。
远端 object key 问题,构造稳定的 POSIX key。

不要把某个库当成万能答案。

Node 标准库、tinyglobby 和 walk 的取舍

可以把几个选择放在一张表里。

工具 / API 适合场景 主要问题
walk 老项目里已经存在、事件式遍历已经稳定运行 控制流偏旧,async 回调和 next() 混在一起,可读性一般
fs.readdir 想少依赖,愿意自己处理文件过滤、递归和路径转换 不直接表达 onlyFiles,主流程容易被 Dirent 细节占据
fs.glob 新 Node 环境,想用官方 glob,需求比较简单 API 比较新,生态经验和复杂 glob 能力还不如成熟库充分
tinyglobby 枚举文件、匹配模式、生成相对路径、需要清楚表达意图 需要新增依赖,但依赖小、语义强,和现代前端工具链心智接近
fast-glob / globby 已有项目已经使用,或需要它们已有生态能力 依赖更重,新项目不一定需要从它们开始

如果只是复制目录,标准库或手写递归可以接受。
如果是「找出所有要处理的文件」,tinyglobby 会更适合。
如果团队运行环境已经固定在新 Node,也可以评估 fs.glob,但要确认它的 pattern、exclude、路径输出和平台行为是否满足需求。

路径要先分清语义,再决定是否保持旧行为

文件遍历脚本里最容易混在一起的是两类路径。

第一类是本地路径,用于读文件、上传文件、判断文件是否存在。新写脚本时,通常可以交给 node:path 的普通 API,例如 join(publicDir, objectPath),让 Node 按当前系统生成路径。

第二类是远端 key,用于 OSS、URL、manifest、缓存 key。它不应该随系统变化,而应该稳定使用 /

const filePath = join(publicDir, objectPath);
const objectKey = posix.join('/', objectPath);

这两行代码比 split(sep).join('/') 更容易读,因为它把意图说出来了:

  • filePath 是本地文件系统路径。
  • objectKey 是远端 POSIX 风格路径。

不过这次发布脚本还有一个额外约束:旧版 walk 脚本会先执行 root = slash(root),再把本地文件路径写成 ${root}/${name}。也就是说,旧版传给 ali-ossfilePath 本来就是 slash 风格。

当前工作环境是 macOS,不方便验证 Windows 下 ali-oss 对原生反斜杠路径的处理。重构这类有副作用的发布脚本时,最好不要在「替换文件遍历工具」的同时改变上传 SDK 收到的路径形态,所以最终代码选择:

const filePath = posix.join(publicDir, objectPath);
const objectKey = posix.join('/', objectPath);

这时两者都是 slash 风格,但语义不同:

  • filePath 是延续旧行为、传给 ali-oss 读取本地文件的路径。
  • objectKey 是 OSS 上的远端 object key。

如果脚本里大量出现 replace('\\', '/')split(sep).join('/')root.replace('./public', ''),通常说明路径语义还没有拆干净。

回到发布脚本,推荐的写法是什么

对 Hexo public 上传 OSS 这种脚本,现在会选 tinyglobby + posix.join

原因不是它最时髦,而是它最贴近当前问题:

  • ali-oss 上传的是单个 object,脚本必须自己枚举文件。
  • 目标是找出 public 下所有文件,不是复制目录。
  • 上传 key 必须是稳定 / 风格,不应该受 Windows 路径影响。
  • 脚本应该让读者先看到「匹配文件 -> 上传对象」,而不是先理解 Dirent 和路径转换。

推荐主流程可以保持这样:

import { glob } from 'tinyglobby';
import { posix } from 'node:path';

const publicDir = './public';

const files = await glob('**/*', {
  cwd: publicDir,
  dot: true,
  onlyFiles: true,
  expandDirectories: false,
});

for (const objectPath of files) {
  const filePath = posix.join(publicDir, objectPath);
  const objectKey = posix.join('/', objectPath);

  const { name, url } = await client.put(objectKey, filePath, uploadOptions);

  console.log(`${name} -> ${url}`);
}

如果后续要加 dry-run、并发上传、失败重试或上传统计,也可以在这个基础上继续加,而不用先拆掉一堆底层遍历细节。

文件遍历也在逼人把问题说准

walkreaddir,再到 tinyglobby,变化不只是 API。

walk 代表的是事件式遍历。它能工作,但控制流藏在事件里。
readdir 代表标准库能力变强。它稳定、少依赖,但会暴露底层目录项细节。
fs.glob 说明 Node 标准库也在吸收生态经验。
tinyglobby 则代表现代工具链常用的领域表达:用 glob 描述要处理的文件集合。

技术选型不是追新,也不是守旧。

会先问:这段代码最想让后面的人看到什么?

如果最想表达的是「复制目录」,那就用目录复制的模型。
如果最想表达的是「匹配文件」,那就用 glob 的模型。
如果最想表达的是「构造远端对象 key」,那就把本地路径和远端 key 分开。

代码越小,这种表达越重要。因为小工具通常没有复杂架构帮读者兜住上下文,主流程本身就是最重要的文档。