《TypeScript编程实战》1.1 从零搭建(pnpm / tsx / tsup)

本节从空目录开始搭起可复现的 TypeScript 工程脚手架:先用 Corepack 锁定 pnpm 版本并说明它比 npm 快在哪里,再设计 src 与 dist 的目录结构和 package.json 关键字段,然后用 tsx 负责开发期直接运行 TS,用 tsup 负责生产构建。读完你能得到 dev、build、typecheck 三条脚本,并知道每个工具为什么被选中、常见报错怎么排查。

本节目标:从空目录开始,搭出一套「今天能跑、半年后同事也能跑」的 TypeScript 工程脚手架。读完后你会拥有一个由 pnpm 管理依赖、由 tsx 负责开发期运行、由 tsup 负责生产构建的最小项目,并且清楚每一个选择背后的取舍。

1.1 从零搭建(pnpm / tsx / tsup)

入门书介绍了 TypeScript 的语言基础:它是 JavaScript 的超集,靠类型系统在编译期拦截错误。但真实项目里,语言只是最里面的一层。同一份 .ts 文件,写的人需要它能被直接执行、需要它报错时能定位、需要它打包后能发布到 npm 或塞进容器——这些都不属于语言范畴,而属于工程脚手架。

这一节我们不谈类型语法,只做一件事:把地基铺好。铺地基的顺序是「运行时 → 包管理器 → 目录结构 → 开发运行器 → 构建器」,每一层都验证一次。

1.1.1 为什么不能只用 tsc

新手最常见的做法是:装好 typescript,然后每次改完代码手动跑一遍 npx tsc,再用 node dist/index.js 执行。这个流程在第一个小时是可行的,在第一个星期就会崩掉。

问题出在反馈速度。tsc 的定位是「把整棵项目编译一遍并做全量类型检查」,它不是为「改一行、立刻看到结果」设计的。项目一大,一次全量编译就要几秒到几十秒,而开发期的编辑动作是每秒都在发生的。

于是社区分化出三类工具,各管一段:

角色工具核心诉求代表
开发期运行器tsx、ts-node改了立刻跑,尽量不做类型检查本节选用 tsx
生产构建器tsup、esbuild、rollup产物小、启动快、可发布本节选用 tsup
类型检查器tsc(--noEmit)只报错,不产出文件始终是 tsc

关键认知是:「运行」和「检查类型」是两件可以拆开的事。开发期由 tsx 负责把 TS 转成 JS 立刻执行,类型正确性交给编辑器与一条独立的 typecheck 脚本;生产构建时再由 tsup 产出产物,构建前跑一次 tsc --noEmit 兜底。这套分工是后面所有章节的基础。

1.1.2 固定 Node 版本与包管理器

第一件事不是装依赖,而是锁死环境。工程脚手架的头号敌人是「我这儿能跑」,而它的根因永远是版本漂移。

先确认 Node 版本,并把它写进 package.json:

{
  "name": "ts-app",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22 <23"
  },
  "packageManager": "pnpm@9.12.0"
}

engines 声明运行时范围,是否强制取决于安装工具的设置。通过 Corepack 的 shim 执行 pnpm 时,packageManager 才会参与版本选择;绕过 shim 直接使用全局 pnpm 不保证一致。

# Node 22 通常附带 Corepack;若发行包没有它,先 npm install -g corepack
# Node 25 起不再随 Node 分发 Corepack
corepack enable

# 进入项目目录后,Corepack 会按 packageManager 字段准备对应版本的 pnpm
corepack prepare pnpm@9.12.0 --activate

# 验证
pnpm -v
# 期望输出:9.12.0

为什么选 pnpm 而不是 npm? 三个实际差别:

  1. 磁盘与安装速度。pnpm 用内容寻址的全局 store,同一版本的依赖全机器只存一份,多个项目通过硬链接共享。重复安装可以复用已有缓存,实际速度仍受网络与安装脚本影响。
  2. 严格的依赖隔离。npm 会把依赖提升到扁平的 node_modules,于是你能 import 到一个从未在 package.json 里声明的「幽灵依赖」。pnpm 默认不做这种提升,没声明就是拿不到——这在写库时是救命的。
  3. monorepo 原生支持。pnpm 的 workspace 不需要额外工具,第 2 章讲路径别名与 monorepo 时会直接受益。

如果你更熟悉 npm 或 yarn,本节所有命令都有等价写法,工程结构完全一致。想了解包管理器在依赖解析上的更多细节,可延伸阅读 semver 依赖解析 。

1.1.3 目录结构与初始化

约定一套目录,是为了让「新文件放哪儿」这个问题永远不需要讨论:

mkdir -p ts-app/src ts-app/scripts
cd ts-app
pnpm init

目标结构如下:

ts-app/
├── src/              # 全部源码,只有这里进类型检查
│   └── index.ts
├── scripts/          # 构建、发布等辅助脚本,也是 TS
├── dist/             # 构建产物,git 忽略
├── package.json
├── tsconfig.json     # 编辑器与类型检查用(下一节详解)
└── .gitignore

.gitignore 至少包含三行:

node_modules/
dist/
*.tsbuildinfo

*.tsbuildinfo 是可重新生成的增量编译缓存,通常应忽略,避免缓存随源码变动进入提交。

1.1.4 安装开发依赖

一次性装齐本节需要的四个包:

pnpm add -D --save-exact typescript@5.9.3 tsx@4.20.5 tsup@8.5.0 @types/node@22.20.5

四个包的分工:

包用途是否进生产依赖
typescript提供 tsc,只做类型检查否
tsx开发期直接运行 .ts否
tsup打包出可发布的 JS 产物否
@types/nodeNode 内置模块(fs、path)的类型否

注意四个全是 -D(开发依赖)。原因是:它们都只在开发机与 CI 上工作,运行时不依赖它们。构建产物是纯 JS,部署时只需要 node dist/index.js,连 node_modules 都可以只装生产依赖。

这一点常被写错。如果把 tsup 放进 dependencies,你的 Docker 镜像里就会白白多出一个打包器,镜像体积涨几十兆,还可能带来供应链风险。

先保存最小 tsconfig.json,否则 typecheck 没有项目配置;下一节再拆分配置层级。本节使用 Node 22、TS 5.9.3,工具版本写入锁文件,跨版本升级单独验证。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

1.1.5 写第一个源文件

// src/index.ts
import { fileURLToPath } from "node:url";

export interface GreetingOptions {
  readonly name: string;
  readonly punctuation?: string;
}

export function greet({ name, punctuation = "!" }: GreetingOptions): string {
  return `Hello, ${name}${punctuation}`;
}

const currentFile = fileURLToPath(import.meta.url);
console.log(greet({ name: "TypeScript" }));
console.log(`running from: ${currentFile}`);

两处值得注意的细节。

第一,import ... from "node:url" 使用了 node: 前缀。在 ESM 下这是推荐写法,它明确告诉读者「这是 Node 内置模块」,也让打包器不会去 node_modules 里找一个同名包。

第二,import.meta.url 只有在 ESM 下才存在。因为我们在 package.json 里写了 "type": "module",所有 .ts/.js 都被当作 ESM 处理。如果漏了这一行,运行时会报:

SyntaxError: Cannot use 'import.meta' outside a module

这是新手最容易踩的坑之一。ESM 与 CommonJS 的互操作细节较多,想系统了解可以延伸阅读 TypeScript 模块解析:ESM 与 CJS 和 Node.js 模块系统与 ESM 。

1.1.6 tsx:开发期直接运行 TS

现在配置脚本。package.json 的 scripts 一节:

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "start": "node dist/index.js",
    "build": "pnpm typecheck && tsup",
    "typecheck": "tsc --noEmit"
  }
}

先跑开发模式:

pnpm dev
# Hello, TypeScript!
# running from: /Users/you/ts-app/src/index.ts

tsx watch 会监听文件变化并自动重启。改一下 greet 里的问候语,保存,终端里的输出立刻更新,全程无需等待类型检查。

tsx 为什么这么快? 它底层用 esbuild 做转译,而 esbuild 是 Go 写的、可以多核并行,转译阶段只做「剥掉类型」,不做类型验证。这正是我们要的分工——类型错误由编辑器即时提示、由 typecheck 脚本兜底。想深入了解 esbuild 的机制,可延伸阅读 esbuild 原理 。

这里必须强调一个纪律:tsx 能跑通,不代表类型是对的。下面的代码 tsx 会照常执行:

// 类型错误,但 tsx 不报错
const count: number = "42";
console.log(count + 1); // 输出 "421"

"421" 这个输出就是「跳过类型检查」的代价。所以第 4 章我们会把 typecheck 接进测试与 CI 门禁,让它不可能被绕过。

1.1.7 tsup:生产构建与产物形态

开发期用 tsx,发布时用 tsup。先建一个最简配置:

// tsup.config.ts
import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm"],
  target: "node22",
  outDir: "dist",
  clean: true,
  sourcemap: true,
  dts: true,
  splitting: false,
  minify: false,
});

逐项解释:

选项含义为什么这样选
entry入口文件有多个入口时写成数组或对象
format产物模块格式服务端只出 esm;要发布给旧环境才加 cjs
target语法降级目标与 engines.node 保持一致
clean构建前清空 outDir避免旧文件残留造成的假象
sourcemap生成 source map生产报错栈能映射回 TS 源码,第 2.3 节详讲
dts生成 .d.ts发布给他人用时必需;纯应用可关掉省时间
minify压缩服务端不开,压缩后的栈几乎不可读

执行构建:

pnpm build

dist/ 里会出现 index.js、index.js.map,如果开了 dts 还有 index.d.ts。此时 pnpm start 会用纯 Node 跑起来,全程不碰 TypeScript。

一个真实对比。 同一个入口,tsc 与 tsup 的差别:

维度tsctsup
是否做类型检查是JS 转译不检查;生成 dts 时可能检查,仍需独立 typecheck
速度(中等项目)秒级到十几秒百毫秒级
能否打包依赖否,只逐文件转译是,可 bundle
能否输出多格式否,一次一种是,esm + cjs 同时
产物是否含类型声明是需显式 dts: true

结论很清楚:类型检查交给 tsc,产物生成交给 tsup。两者不是替代关系。

如果你的项目要发布成 npm 包,还要处理 exports 字段、版本号与 files 白名单,这些在后续章节展开,可先延伸阅读 TypeScript SDK 包发布 。

1.1.8 常见坑与错误信息

坑一:ERR_MODULE_NOT_FOUND

现象:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/utils'
imported from /app/dist/index.js

原因:ESM 下相对导入必须写完整扩展名。import { a } from "./utils" 在 CJS 下能工作,在 ESM 下不行。

// 错误
import { a } from "./utils";
// 正确
import { a } from "./utils.js";

注意即使源文件是 utils.ts,这里也要写 .js——因为运行时看到的是编译后的文件。这个规则初看反直觉,但它是 ESM 规范的一部分。

坑二:tsx 与 node 行为不一致

现象:pnpm dev 正常,pnpm start 报模块找不到。

原因:tsx 对扩展名做了宽容处理,Node 不做。解决方式是统一按 Node 的严格规则写导入,或者让 tsup 打包时把内部模块合并成一个文件(bundle 默认开启即可)。

坑三:依赖装成了 dependencies

现象:Docker 镜像比预期大很多,或 CI 里 pnpm install --prod 后构建失败。

原因:tsup、typescript 被误装进生产依赖,或反向地,运行时真正需要的包被装成了 -D。判断标准只有一条:这段代码在 dist/ 里会被执行吗?会就是生产依赖。

坑四:忘了 "type": "module"

现象:Cannot use import statement outside a module 或 import.meta 报错。

原因:没有声明模块类型时,Node 默认按 CJS 解析 .js。加上 "type": "module" 即可。

1.1.9 脚手架自检清单

进入下一节前,逐条确认:

检查项命令通过标准
Node 版本node -vv22.x(本节示例基线)
pnpm 版本被锁定pnpm -v与 packageManager 一致
依赖已安装pnpm ls -D含 typescript、tsx、tsup、@types/node
开发模式可跑pnpm dev打印问候语并随改动自动重启
类型检查通过pnpm typecheck无输出即通过
构建可产出pnpm builddist/index.js 存在
产物可执行pnpm start输出与 pnpm dev 一致

小结

本节从空目录搭出了一条完整的工具链:Corepack 与 packageManager 锁住 pnpm 版本,engines 声明 Node 要求,src 与 dist 分离源码与产物,tsx 负责开发期的快速反馈,tsup 负责生产构建,而类型正确性由独立的 tsc --noEmit 保证。核心原则只有一条:运行、构建、类型检查是三条独立的流水线,不要指望一个工具同时做好三件事。

脚手架搭好后,最先要面对的就是 tsconfig.json——它决定了哪些文件进检查、用哪套模块解析规则、严格程度开到多高。这个文件写错,后面每一个报错都会指向错误的方向。下一节 1.2 严格模式与 tsconfig 分层 就来拆开它。如果你对 TypeScript 在服务端的整体实践还想先有个印象,也可以延伸阅读 Node.js 中的 TypeScript 实践 。

阅读导航:下一节:1.2 严格模式与 tsconfig 分层 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes