Node.js 工程化的三个常见问题:Corepack 报错、版本号符号与 ESM 下运行 TypeScript

这篇把三个用 Node.js 时反复遇到、每次都得重新查一遍的小问题整理到一起:Corepack 启用 pnpm 后的签名报错、package.json 里版本号符号的含义,以及在原生 ESM 项目里怎么直接跑 .ts 文件。它们没什么关联,按需查阅即可。

一、Corepack 启用 pnpm 后报 keyid 错误

corepack enable pnpm 启用 pnpm 之后,执行任何 pnpm 命令都报这个错:

Error: Cannot find matching keyid: {"signatures":[{"sig":"MEQCIHGq...","keyid":"SHA256:DhQ8wR5..."}],"keys":[{"keyid":"SHA256:jl3bwswu..."}]}

原因

Corepack 在下载 pnpm 包之后会校验它的签名:它内置了一份 npm 官方的公钥列表,用来验证下载到的包确实是 npm 签发的。报这个错,是因为本地 Corepack 里存的公钥列表过期了——pnpm 发布方更换或新增了签名密钥,而你这个版本的 Corepack 还不知道新密钥,于是「下载包用的 keyid」在「本地已知的 keys」里找不到匹配项,校验失败。

这本质上是 Corepack 版本太旧导致的,和 pnpm 本身、和你的项目都没关系。

解决方法

更新 Corepack 到最新版,让它带上最新的密钥列表:

npm i -g corepack@latest

更新完再跑 pnpm,它会提示要下载最新版本,确认即可:

pnpm -v
# ! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-10.1.0.tgz
# ? Do you want to continue? [Y/n] y
# 10.1.0

能正常打印版本号,就说明签名校验通过、问题解决了。

二、package.json 里的版本号符号

每次看 package.json 里的依赖版本,^~ 总要想一下才能确认区别。一次性记清楚。

先看 SemVer 格式

版本号格式是 主版本.次版本.补丁版本,比如 18.2.0

  • 主版本(major):有破坏性变更(不向后兼容)时递增
  • 次版本(minor):新增功能但向后兼容时递增
  • 补丁版本(patch):只做 bug 修复时递增

理解了这三段各代表什么,下面的符号就好记了——它们本质上是在说「允许自动升级到哪一段为止」。

常用符号

写法含义例子实际范围
18.2.0精确版本"react": "18.2.0"只装 18.2.0
^18.2.0锁主版本"react": "^18.2.0">=18.2.0 <19.0.0
~18.2.0锁次版本"react": "~18.2.0">=18.2.0 <18.3.0
*任意版本"react": "*"最新版
>=18.2.0大于等于18.2.0 及以上

^ 是最常见的,pnpm add / npm install 默认加的就是它。它允许次版本和补丁版本自动升级,但不会跨主版本(不会升到有破坏性变更的新大版本),适合绝大多数依赖。

~ 更保守,只允许补丁版本升级,适合对稳定性要求很高、不想随便引入新功能代码的场景。

一个细节:^0.x.y 的行为和直觉不同。当主版本是 0 时,^0.2.3 只允许升到 <0.3.0——因为 SemVer 规定 0.x 阶段每个次版本都可能有破坏性变更,所以 ^ 此时退化成类似 ~ 的行为。

范围写法

可以用空格组合多个条件:

"react": ">=16.0.0 <18.0.0"

这样只会安装 16.x.x 和 17.x.x,不会装到 18。

锁文件才是最终决定者

这里有个常被误解的点:^~ 只是声明「允许的范围」,实际装哪个版本由锁文件决定pnpm-lock.yamlpackage-lock.json)。锁文件记录的是精确版本,保证团队里每个人 install 出来的依赖完全一致。

所以:

  • 日常 pnpm install 是严格按锁文件来的,^ / ~ 不会让你今天装 18.2.0、明天就变成 18.3.0。
  • package.json 里的版本符号,主要影响 pnpm update 时能把依赖升到哪个版本,以及锁文件不存在时首次安装解析出的版本。

三、在 ESM 项目里直接运行 TypeScript 文件

Node.js 原生不认识 .ts 文件。在原生 ESM 项目(package.json"type": "module")里这个问题更明显,因为连 require 那套 hack 也用不了。常见有三种方式。

方法一:tsx(推荐)

tsx 是目前最省事的方案,直接运行 .ts 文件,ESM 和 CJS 都支持,几乎零配置。

pnpm add -D tsx

package.json

{
    "scripts": {
        "dev": "tsx src/index.ts"
    }
}
pnpm dev

就这样,没有其他配置。它底层用 esbuild 做即时转译,启动速度也很快,开发阶段首选。

方法二:ts-node + ESM loader

ts-node 是老牌工具,但在 ESM 项目里需要额外配置:

pnpm add -D ts-node

package.json

{
    "scripts": {
        "dev": "node --loader ts-node/esm src/index.ts"
    }
}

同时 tsconfig.jsonmodule 要设成 ESNextNodeNext。需要注意的是,在较新的 Node.js 版本里 --loader 标志会有 deprecation 警告(官方改推 --import + register),所以新项目不太推荐再用这个组合。

方法三:先编译再运行

最稳妥的方式,也是生产环境部署该用的方式——先用 tsc 编译成 JS,再用 Node 跑编译产物:

pnpm add -D typescript
npx tsc          # 编译到 dist/
node dist/index.js

package.json

{
    "scripts": {
        "build": "tsc",
        "start": "node dist/index.js"
    }
}

缺点是开发阶段每改一次都要重新编译,一般配合 tsc --watch 用。

怎么选

  • 开发阶段tsx,启动快、零配置。
  • 生产部署tsc 编译后跑纯 JS,运行时不带任何转译开销,也最接近线上真实产物。

这套「开发 tsx、部署 tsc」的组合是目前比较主流的搭配。