Node 脚本自动化:把重复操作从人脑里拿出来

我们这个中后台项目的 scripts 目录这半年膨胀得很快。生成路由的、生成图标索引的、发布前检查环境变量的、同步接口文档生成 TS 类型的,加起来快十个文件了。上周整理的时候我数了一下,有三个脚本已经没人知道具体做什么,注释也没写,只能靠猜文件名和读代码。这促使我把团队里写 Node 脚本的一套判断标准认真过了一遍——什么样的脚本算写得对,什么样的算埋雷。

Node 16 是我们现在的生产基线,4 月发布的 Node 18 团队还在观望,没往 CI 环境上推——它要到 10 月才转 LTS,这时候上生产环境风险和收益不成比例。写脚本用的语法倒是可以往前一点,ES2022 的类字段和静态块已经进了标准,顶层 await 配合 ESM 脚本也能用,等下会展开讲。

先解决具体的小事,别急着做平台

写自动化脚本最容易犯的错误是一上来就想搭一个"脚本框架":统一的插件机制、配置 schema、脚手架命令。这类设计如果没有先攒够几个真实脚本打底,大概率会做成一个没人用的空中楼阁。

比较务实的路径是先解决一件具体的烦心事。我们最早写的一个脚本,是根据 pages 目录结构生成路由表——项目里页面按目录组织,新增一个页面要同步改路由配置、菜单配置、权限点位,三个地方漏一个,上线就出问题。脚本要做的事很简单:遍历目录,找出所有页面文件,生成一份路由声明。

1// scripts/generate-routes.mjs
2import fs from 'node:fs'
3import path from 'node:path'
4
5function walk(dir) {
6  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
7    const fullPath = path.join(dir, entry.name)
8    return entry.isDirectory() ? walk(fullPath) : [fullPath]
9  })
10}
11
12const pageFiles = walk('src/pages').filter((file) => file.endsWith('.vue'))
13
14const routes = pageFiles.map((file) => {
15  const routePath = file
16    .replace('src/pages', '')
17    .replace(/\.vue$/, '')
18    .replace(/\/index$/, '') || '/'
19  const componentPath = './' + path.relative('src', file)
20  return { path: routePath, component: componentPath }
21})

这类脚本逻辑不复杂,但每次新增页面能省掉一次"记得去改三个地方"的心智负担,这个价值是实打实的。团队里有位资深前端后来照着这个思路又写了一个生成菜单配置的脚本,复用了同一份 walk 函数,两个脚本合起来基本堵住了"页面加了、路由和菜单忘了同步"这类低级失误。

顶层 await 这一年已经写进 ES2022 标准,在 ESM 脚本里可以直接用,不需要再套一层立即执行的 async 函数——比如上面这类脚本如果要读取一个异步配置源,现在可以直接在模块顶层 await 一下,少写一层嵌套:

1// scripts/generate-routes.mjs (ESM, package.json 里 "type": "module")
2const config = await loadRouteConfig()
3const pageFiles = walk(config.pagesDir).filter((file) => file.endsWith('.vue'))

我们把这类脚本的文件后缀都统一改成了 .mjs,一是明确表达"这是 ESM 模块",二是能顺手用上顶层 await,不用再纠结要不要在文件顶部包一个 (async () => { ... })()

文件读写要经得起误触

写脚本最怕的不是逻辑写错,是脚本误改了不该动的文件。批量写入类的脚本,我们要求必须支持 dry run,先打印会做什么,不直接落地:

1const dryRun = process.argv.includes('--dry-run')
2
3function writeFile(targetFile, content) {
4  if (dryRun) {
5    console.log(`[dry-run] 将写入 ${targetFile},内容长度 ${content.length}`)
6    return
7  }
8  fs.writeFileSync(targetFile, content)
9  console.log(`已写入 ${targetFile}`)
10}

光有 dry run 还不够,输出也要清楚——读了哪些文件、改了哪些文件、跳过了哪些文件,三类都要打印出来,不能只在出错时才说话。脚本不是黑盒,排查的时候第一反应应该是看日志,而不是翻脚本源码猜它做了什么。

处理结构化数据时,能用结构化 API 就别用正则硬改。JSON 配置文件用 JSON.parse/JSON.stringify 读写,不要指望用字符串替换去改一个 JSON 文件里的字段——一旦格式稍微有点出入(尾随逗号、字段顺序、缩进风格),正则替换很容易改出一个语法错误的文件,而且这种错误往往要等到构建阶段才会暴露。

如果脚本要修改的是已有的 JS/TS 源码,情况会更麻烦一些。单纯生成新文件,字符串模板完全够用;但如果要往一个已有文件里插入代码——比如自动往路由声明文件里追加一条 import,或者给某个数组末尾插入一项——最好用 AST 工具去做,而不是找一行字符串然后往后面拼。我们试过用 @babel/parser 解析目标文件,找到对应的 AST 节点后插入新节点,再用 @babel/generator 生成回字符串。这样做的好处是不会因为字符串定位错一位就破坏语法,缺点是要多写一层解析逻辑,对小脚本来说未必划算——我们的判断标准是,如果这个"插入"动作只发生一次两次、以后大概率不会再触发,就不值得上 AST,手写字符串定位加一条断言校验(比如插入后校验一下文件还能不能被 requireimport 成功)就够用。

环境变量检查,救过我们两次线上事故

这半年至少有两次线上问题,根源是环境变量缺失或者配错了值,页面表现是接口请求打到了 undefined/api 这种地址上,排查起来第一反应是看接口报错,很容易绕远路。

后来加了一个启动前检查脚本,在 npm run build 之前跑:

1// scripts/check-env.mjs
2const required = ['VITE_API_BASE_URL', 'VITE_APP_ENV', 'VITE_SENTRY_DSN']
3
4const missing = required.filter((key) => !process.env[key])
5
6if (missing.length > 0) {
7  console.error(`缺少环境变量:${missing.join('、')}`)
8  process.exit(1)
9}
10
11console.log('环境变量检查通过')

这个脚本本身没什么技术含量,但把"检查提前到构建之前"这件事本身,比"页面跑起来之后再排查"效率高得多。构建阶段失败,流水线直接红,谁都能看到;运行时才暴露,往往要等用户反馈或者监控告警才会发现。

有一点容易忽略:检查脚本只应该提示缺了哪个 key,不应该把 value 打印到日志里。我们最早的版本图省事把 process.env 整个打印出来方便排查,后来意识到 CI 日志是团队内部可见的,如果里面混进了数据库密码或者第三方服务的密钥,相当于把敏感信息摊在了日志系统里。现在的原则是:检查脚本只报告"缺失"和"存在",不报告"值是什么"。

代码生成要追求可复现的输出

生成类脚本有一个容易被忽视的要求:相同输入应该产生相同输出。如果同一份源数据跑两次生成出的文件有细微差异——哪怕只是数组顺序不一样——每次跑生成脚本都会在 git diff 里留下一堆和本次改动无关的噪音,评审的时候很难看清楚真正的改动在哪。

排序是最容易漏掉的一步。比如生成导出索引:

1const files = walk('src/icons')
2  .filter((file) => file.endsWith('.svg'))
3  .sort()
4
5const content = files
6  .map((file) => {
7    const name = path.basename(file, '.svg')
8    return `export { default as ${name} } from './${path.relative('src/icons', file)}'`
9  })
10  .join('\n')

fs.readdirSync 返回的文件顺序在不同操作系统、不同文件系统上不一定一致,不显式排序的话,同一份源文件在你机器上生成的结果和在 CI 机器上生成的结果可能顺序不同——如果 CI 里有"生成结果和仓库里的文件做 diff、不一致就报错"这类校验(我们确实加了这一步),排序缺失会导致这个校验在本地和 CI 上表现不一致,排查起来很容易走偏方向。

生成完的文件建议跑一次项目里现成的格式化工具统一风格,但脚本本身输出的内容也要尽量整洁,不要指望格式化工具兜底所有问题——比如缩进用几个空格、字符串用单引号还是双引号,这些脚本自己就能控制,没必要都推给格式化工具收尾。

生成的文件如果不希望被人工修改,文件头要写清楚:

1// This file is generated by scripts/generate-icons.mjs. Do not edit manually.

这行注释看着简单,但实际拦下过至少一次"有人手改了生成文件,下次跑脚本被覆盖,改动莫名其妙消失"的情况。生成文件被误改这件事,光靠口头约定没用,得让脚本自己在文件里留下证据。

依赖外部接口的脚本,失败要干脆

有一类脚本需要请求远程接口才能完成工作——比如从后端的 Swagger/OpenAPI 文档拉取接口定义、生成对应的 TypeScript 类型;或者从配置中心同步一份字典数据到本地。这类脚本处理错误的方式,和纯本地脚本不一样。

1async function fetchSchema(schemaUrl) {
2  const response = await fetch(schemaUrl)
3
4  if (!response.ok) {
5    throw new Error(`拉取接口文档失败:HTTP ${response.status}`)
6  }
7
8  const schema = await response.json()
9
10  if (!schema.paths || Object.keys(schema.paths).length === 0) {
11    throw new Error('接口文档返回了空的 paths,疑似后端服务异常,拒绝生成')
12  }
13
14  return schema
15}

这里有两层检查:第一层是 HTTP 状态码,第二层是返回内容本身是不是"看起来合理"。第二层容易被忽略——后端服务如果返回了一个 200 状态码但是内容为空或者结构不对的响应体,只检查 response.ok 是拦不住的,脚本会带着一份空的或者错误的 schema 继续往下跑,生成出一堆内容为空的 TS 类型文件。我们踩过这个坑:一次后端网关配置问题导致 Swagger 接口返回了一个空对象,类型生成脚本没做内容校验,直接生成了一份几乎没有类型定义的文件覆盖了仓库里原来正常的版本,直到有同事发现自动补全大面积失效才发现问题。

原则很直接:宁可脚本失败,也不要让一个可疑的产物进仓库。CI 里跑这类脚本时,exit code 要明确对应成功或失败,失败就让流水线红,不要吞掉错误继续往后跑。有同事提过"要不要在拉取失败时退回上一次的缓存结果,这样至少不会中断流程",这个思路在个别场景下(比如纯粹的展示型数据,允许短暂过期)是合理的,但对类型生成、路由生成这类会直接影响代码正确性的脚本,我们的结论是不要退回缓存——用一份过期但"看起来能跑"的类型定义,比直接报错更危险,因为它不会立刻暴露问题,可能要等到运行时才炸。

发布脚本:比生成脚本更该谨慎

除了生成类脚本,发布脚本是另一类值得单独说的场景。我们的发布流程里有一个脚本负责打 tag、更新 changelog、触发部署,这类脚本因为直接牵扯到"东西真的上线了",出错的代价比生成脚本高得多。

这类脚本我们额外加了两条约束。第一条是关键操作前要有二次确认,除非明确传了 --yes 之类的跳过参数:

1import readline from 'node:readline/promises'
2
3async function confirm(question) {
4  if (process.argv.includes('--yes')) return true
5  const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
6  const answer = await rl.question(`${question} (y/N) `)
7  rl.close()
8  return answer.trim().toLowerCase() === 'y'
9}
10
11if (!(await confirm(`确认发布 ${nextVersion} 到生产环境?`))) {
12  console.log('已取消')
13  process.exit(0)
14}

第二条是发布脚本里所有真正有副作用的步骤(打 tag、推送、触发部署 webhook)要集中在脚本末尾,前面的步骤只做校验和准备,不产生任何副作用。这样即使中途某一步校验失败退出,也不会留下一个"发布了一半"的中间状态——比如 tag 打了但部署没触发,或者 changelog 更新了但 tag 没打,这类半吊子状态排查起来比脚本直接失败麻烦得多。

npm scripts 的命名和可发现性

脚本写好了,团队里的人得知道怎么跑它。我们把常用脚本统一登记进 package.json:

1{
2  "scripts": {
3    "gen:routes": "node scripts/generate-routes.mjs",
4    "gen:icons": "node scripts/generate-icons.mjs",
5    "gen:api-types": "node scripts/generate-api-types.mjs",
6    "check:env": "node scripts/check-env.mjs",
7    "release": "node scripts/release.mjs"
8  }
9}

命名要让人一看就懂,不应该要求团队成员去翻 scripts 目录、打开文件读代码才知道哪个命令该跑。前缀分组(gen:check:)在脚本数量超过五六个之后价值会体现出来——运行 npm run 不带参数会列出所有可用脚本,分组之后这份列表本身就有导航作用。

复杂一点、带参数的脚本,建议自己实现一个 --help:

1if (process.argv.includes('--help')) {
2  console.log(`
3用法: node scripts/generate-icons.mjs [选项]
4
5选项:
6  --dry-run   只打印将要生成的内容,不实际写入
7  --watch     监听 src/icons 目录变化并自动重新生成
8  --help      显示此帮助信息
9`)
10  process.exit(0)
11}

这几行代码成本很低,但省下的是团队里每次"这个脚本支持哪些参数"的重复提问。

脚本和 CI 用同一套逻辑

我们踩过一个坑:本地跑脚本和 CI 里跑脚本,逻辑一度不是同一份。本地那个是最初写的版本,CI 里为了适配流水线环境,又单独抄了一份改过参数的脚本放进 CI 配置文件里。两边各自维护了一阵子之后就开始不一致——本地脚本加了一个新的校验规则,CI 里那份忘了同步,直到有个不合规的文件在本地跑脚本时被拦下,但因为开发者手动改了一下绕过去,提交上去之后 CI 里那份旧脚本没拦住,才发现两份逻辑早就分叉了。

后来统一成一个原则:CI 只负责调用脚本、传参数、决定失败要不要阻断合并,脚本本身的逻辑必须是本地和 CI 共用同一份,不允许在 CI 配置文件里另外内嵌一份脚本逻辑。GitHub Actions 这一年已经是我们 CI 里很成熟的常规选择,workflow 文件里对应的这一步基本上就是:

1- name: Check API types are up to date
2  run: |
3    npm run gen:api-types
4    git diff --exit-code src/api/types

这里用 git diff --exit-code 校验生成结果和仓库里的文件是否一致——如果本地忘了跑生成脚本就提交,或者生成结果本身不稳定,这一步会直接失败。这也是前面提到"生成结果要可复现"的价值所在:如果生成脚本本身跑两次结果都不一样,这条 CI 检查会变得不可靠,今天过明天不过,排查起来很让人困惑。

写脚本之前该想清楚的几件事

整理下来,我们现在写一个新的 Node 自动化脚本之前,习惯性会过一遍这几个问题:这件事是不是真的重复到值得自动化,而不是图一时新鲜;有没有 dry run 或者足够清楚的执行日志;会不会误改无关文件;处理的是结构化数据的话,有没有用结构化 API 而不是正则硬改;生成结果是不是可复现;依赖的远程接口失败时,脚本是不是会干脆地失败而不是带着错误数据继续跑;涉及副作用的操作是不是集中在流程末尾;npm scripts 的命名是不是别人一看就懂;本地和 CI 跑的是不是同一份逻辑。

这些问题单独看都很朴素,组合在一起基本能把一个脚本从"能跑"变成"团队里的人敢放心用"。开头提到的那三个没人说得清做什么的脚本,对着这份清单挨个过了一遍——两个补上了注释和 --help,剩下一个确认没人在用,直接从 scripts 目录里删掉了。