自己写小工具时,技术选型应该先想清楚什么
很多内部小工具一开始都不是「项目」。
它可能只是一个发布脚本、一次数据修复、一个本地调试入口、一个把表格内容写进浏览器缓存的 Chrome Extension,也可能只是一个今天临时要跑的迁移脚本。
这种东西最容易被低估。代码不多,看起来随手写几行就能跑;但它一旦接上真实副作用,比如上传 OSS、写线上缓存、改数据库、调用接口、批量处理文件,技术选型就不再只是「怎么快点写完」。
小工具的技术选型要先解决一件事:用尽量低的复杂度,把执行环境、可读性、副作用、后续维护和验证方式都压在可控范围里。
小工具不是越轻越好
「越轻越好」经常会把人带到两个极端。
一端是所有东西都手写:用原生 fs.readdir 手动递归,用字符串替换处理路径,用事件式库的 callback 拼异步流程。代码看起来没有多加依赖,但读者要自己理解很多底层细节。
另一端是直接上一个完整工程化方案:开 Vite、引框架、搞组件目录、写一堆抽象。工具还没解决问题,工程先膨胀起来。
小工具更合适的判断标准不是「依赖最少」或「架构最完整」,而是「复杂度放在最该放的位置」。
如果问题本身很简单,标准库就够。
如果问题本身有成熟领域模型,例如 glob、CSV 解析、命令行参数、HTTP mock、浏览器自动化,就应该优先考虑成熟小库。
如果工具会长期运行、多人维护、需要 UI 和状态管理,再考虑完整框架。
先看它到底是哪种工具
小工具还要先分清身份。
一种是「整个项目就是工具」。比如 rimraf 这种包,它自己的核心价值就是提供一个 CLI 或 API,供很多项目调用。这类工具要按正式 npm package 的方式思考:入口、参数、跨平台行为、语义版本、README、测试、兼容范围、依赖体积和发布产物都是产品的一部分。
另一种是「依附在正式项目里的脚本」。比如博客项目里的 build-scripts/publish.mts,它只是服务当前 Hexo 项目的发布流程。这类脚本不需要把自己包装成通用包,但要尊重宿主项目:不要轻易改根目录 "type",不要污染运行时 scripts/,不要为了一个脚本引入大型框架,也不要把真实上传这类副作用藏进普通验证命令里。
这两类工具都可以很小,但处理方式不一样。
独立工具优先考虑「别人怎么安装、怎么调用、怎么升级」。
附属脚本优先考虑「当前项目怎么读、怎么跑、怎么不影响主项目」。
选型前先问这几个问题
写小工具前,可以先问这几个问题。
它是独立工具,还是依附项目的小脚本?
独立工具要像一个包一样设计。附属脚本要像当前项目的一部分一样设计。这个问题会决定后面要不要单独 package、要不要公开 API、要不要跨平台测试、要不要写完整 README。
它是一次性的,还是会留下来?
一次性脚本可以线性写,少拆函数,读者从上往下看就能知道发生了什么。长期工具则要考虑目录、类型、测试、README 和入口命令。
它会不会有真实副作用?
只读扫描脚本和上传发布脚本完全不是一个级别。只读脚本失败了最多重跑;上传脚本失败可能产生半发布、缓存不一致、覆盖远端对象。副作用越大,越需要 dry-run、日志、错误退出和可重复验证。
它依赖当前项目环境,还是要跨项目复用?
如果只服务当前项目,可以读取当前项目的配置、路径和依赖。
如果要跨项目复用,路径、端口、tag、接口、缓存 key 都应该做成参数或配置,不要把一次现场值写死。
它有没有成熟的领域工具?
文件遍历可以是 fs.readdir,也可以是 fs.glob、tinyglobby。CSV 可以手写拆 tab,也可以用解析库。浏览器控制可以是 DOM API,也可以是 Playwright、CDP 或 Chrome Extension API。
成熟工具不一定要用,但要先知道它解决了什么问题。
它的失败会不会很难排查?
如果失败只能靠猜,工具就应该多打印关键状态。比如「即将写入哪些对象」「目标浏览器 tab 是哪个」「实际匹配了多少文件」「上传 key 是什么」「跳过了哪些文件」。这些日志不是花活,而是小工具的可维护性。
TypeScript 脚本的几种运行方式
Node 现在已经能直接运行一部分 TypeScript。
Node.js TypeScript 文档 把这类能力叫 type stripping。它会把可擦除的 TypeScript 类型语法替换掉再执行,不做类型检查,也不会读取
tsconfig.json里的 paths、降级编译等配置。需要完整 TypeScript 支持时,Node 官方文档也会建议使用第三方包,并以tsx作为示例。
这让「是不是还要装 tsx」变成一个真实问题。
以这个博客发布脚本为例,第一步自然会先看 Node 原生能力。原因很简单:如果 node build-scripts/publish.ts 就能直接跑,就少一个运行器,脚本也更贴近平台本身。
但 Node 原生 TypeScript 支持的定位是轻量 type stripping。它稳定了,但边界也很明确:不做类型检查,不读取 tsconfig.json,不支持依赖 tsconfig 的 paths,也不负责把需要代码生成的 TypeScript 语法转成 JavaScript。对一个可能继续演进的内部发布脚本来说,这些限制会慢慢把代码写法推向「为了运行环境而收缩」。
ts-node 也是一个自然候选。它出现得早,生态熟,能力完整,也可以配 SWC 加速。但如果脚本想用 ESM、顶层 await,它的配置会更重:要在 CommonJS 和原生 ESM 两条路之间选,ESM 模式还要走 loader 或 ts-node-esm,官方文档也提醒这一路依赖 Node 的 ESM loader hooks。
tsx 刚好落在中间:比 Node 原生 type stripping 更完整,比 ts-node 的 ESM 配置更轻,官方 Node TypeScript 文档也直接拿它作为第三方完整支持的示例。对这种依附项目的发布脚本,tsx + .mts + tsc --noEmit 的组合比较合适:执行交给 tsx,模块语义用 .mts 表达,类型检查单独交给 tsc。
比较实用的判断是:
| 方式 | 适合场景 | 注意点 |
|---|---|---|
node script.js |
纯 JS、没有 TS 类型、没有构建需求 | 最少依赖,但代码规模变大后类型保护会变弱 |
node script.ts |
新 Node 版本、只用了可擦除 TS 语法、没有 tsconfig 依赖 | 不做类型检查,不处理 paths,不支持需要转换的 TS 语法 |
ts-node script.ts |
已有项目已经使用 ts-node,或者需要它的 REPL / register 能力 | ESM 和性能配置更重,通常还要单独考虑 transpileOnly / SWC |
tsx script.ts |
本地脚本、构建脚本、迁移脚本,需要完整 TS / ESM / CJS 兼容 | 仍然要配合 tsc --noEmit 做类型检查 |
tsx script.mts |
明确要 ESM 语义、顶层 await、不想把整个项目改成 ESM |
.mts 是轻量但清晰的意图表达 |
tsc 或 esbuild 先构建再 node |
要发布给别人、要部署到固定运行环境、要避免运行时转译依赖 | 多一个构建步骤,但产物更稳定 |
tsx 的定位很适合内部工具。
tsx 官方文档 把它描述成增强版 Node.js:可以直接运行 TypeScript,处理 CJS 和 ESM 互操作,支持 watch mode,也支持
tsconfig.jsonpaths。它不是类型检查器,类型检查仍然应该交给tsc。
小工具里使用 tsx 的好处是:不用把脚本先编译成 JS,也不用为了一个发布脚本改整个项目的 "type": "module"。
如果脚本需要顶层 await,并且当前项目不是 ESM 项目,.mts 是很合适的选择。
// build-scripts/publish.mts
import { readFile } from 'node:fs/promises';
const content = await readFile('./public/index.html', 'utf8');
console.log(content.length);这个后缀能让读者直接知道:这是一个 ESM TypeScript 脚本。认知成本很低,但表达的信息很明确。
不要为了环境兼容牺牲源码可读性
小工具经常会遇到一个诱惑:为了让当前环境少装一个东西,把源码写得很绕。
例如,明明顶层 await 能把脚本写成从上到下的线性流程,却为了兼容 CommonJS 包一层 async main();明明可以用 import,却为了老环境改成动态 await import();明明一个 .mts 后缀就能说明 ESM 语义,却把整个项目的 "type": "module" 改掉。
这类取舍要看项目。
自己的小工具、新脚本、内部仓库,可以优先让源码更容易读。环境能升级就升级,运行命令能调整就调整。
已经跑很久的生产项目、被很多人依赖的包、公共 CLI,就要慎重一点。它们的环境兼容也是契约的一部分,不能为了代码看起来舒服就随便破坏。
判断顺序可以写成这样:
- 能不能用更准确的文件后缀表达语义,例如
.mts/.cts。 - 能不能只给脚本目录加局部配置,例如
build-scripts/tsconfig.json。 - 能不能用运行器解决,例如
tsx build-scripts/publish.mts。 - 是否真的需要改根目录
"type"、根 tsconfig 或全项目构建方式。
越靠后的选择,影响面越大。
依赖不是越少越好,而是语义要对
依赖选择也容易走极端。
一个很小的库,如果正好覆盖领域复杂度,往往比手写更清楚。比如 glob 文件匹配,tinyglobby 的 cwd、onlyFiles、ignore、dot、expandDirectories 都是领域语言;读者一眼能看到脚本是在「匹配文件」,而不是在「读目录、判断文件、拼路径、转斜杠」。
反过来,如果只是为了一个 normalizePath(),把整个 Vite 作为依赖引进来,就不合适。Vite 的 normalizePath 是好设计,但它属于 Vite 工具链的一部分,不应该为了一个 helper 拉一个大型构建工具。
可以按这几条判断:
- 标准库能直接表达业务意图时,用标准库。
- 标准库只能给底层原料,主流程会被细节淹没时,考虑小型领域库。
- 依赖如果引入大量传递依赖、构建副作用或运行时约束,要提高门槛。
- 不要为了一个 helper 引入完整框架。
- 如果依赖会进入长期维护路径,要加 README、脚本说明和版本边界。
线性脚本不一定要拆很多函数
工具脚本还有一个常见误区:代码刚写出来就开始拆函数。
如果逻辑本身是线性的,例如「读取 public 下所有文件 -> 生成 object key -> 上传 OSS -> 打印结果」,过度拆分反而会让读者跳来跳去。
这类脚本可以先保持线性。
const files = await glob('**/*', { cwd: publicDir, onlyFiles: true });
for (const objectPath of files) {
const filePath = posix.join(publicDir, objectPath);
const objectKey = posix.join('/', objectPath);
const result = await client.put(objectKey, filePath, uploadOptions);
console.log(`${result.name} -> ${result.url}`);
}适合拆出去的是这些内容:
- 会被复用的转换逻辑。
- 需要单测覆盖的纯函数。
- 错误处理或重试策略。
- 和主流程无关的配置读取。
- 过长、过深、让主流程看不清的分支。
小工具里的函数拆分也要服务阅读,不是为了显得像正式项目。
副作用脚本要先设计验证方式
有副作用的脚本,验证方式要先于重构。
发布脚本尤其如此。pnpm run build 如果会真的上传 OSS,就不能把它当普通构建命令随手跑。本地验证应该拆成几层:
pnpm run typecheck:build-scripts
pnpm exec hexo generate再加一个不会上传的路径映射 smoke test:
import { glob } from 'tinyglobby';
import { posix } from 'node:path';
const publicDir = './public';
const files = await glob('**/*', { cwd: publicDir, onlyFiles: true });
console.log(files.slice(0, 5).map((objectPath) => ({
filePath: posix.join(publicDir, objectPath),
objectKey: posix.join('/', objectPath),
})));这里的 filePath 也使用 posix.join(),是因为旧脚本传给 ali-oss 的本地路径本来就是 slash 风格。当前工作环境是 macOS,又没有方便验证 Windows 的条件,重构时优先保持旧行为,不在「换遍历工具」的同时改变上传 SDK 收到的路径形态。
确认路径映射没问题后,再决定是否跑真正上传。
脚本一旦会改远端状态,最好补这些能力:
- 输出即将操作的目标。
- 支持 dry-run。
- 失败时设置非 0 exit code。
- 日志里不要打印密钥。
- 密钥从环境变量或 CI secret 读取,不要写进源码。
- 上传、删除、覆盖这类动作要区分命令名,不要把危险动作藏在普通
build里。
最后看的还是维护成本
小工具的技术选型没有固定答案。
有时 node script.js 最合适,因为它只有五行。
有时 tsx script.mts 最合适,因为它能让 TypeScript、顶层 await 和 ESM 语义都自然落下来。
有时应该上 tinyglobby,因为文件匹配就是它的领域。
有时应该坚持标准库,因为问题只是复制目录或读一个文件。
要避免的是两件事。
一件是为了「轻」把复杂度留给读者。
另一件是为了「正规」把小工具做成一个小项目。
小工具最好的状态是:打开文件能看懂,运行命令能预测,失败日志能定位,后续有人接手时不会先问「为什么要这么写」。