《TypeScript编程入门》17.3 部署、发布与版本演进

本节把写好的 TypeScript 代码真正送上生产:先讲清构建产物与运行时的差异,对比静态托管、长驻 Node 进程、Serverless、npm 包四种部署形态,再给出把类型检查放在第一道门禁的 CI 流水线。随后讲环境变量的启动期校验、语义化版本号在 TS 项目里的特殊含义、全量与灰度、金丝雀、蓝绿三种发布策略的取舍,以及回滚时最该先想清楚的数据库迁移问题。

本节目标:读完这一节,你能说清「你部署的不是 TypeScript,而是编译后的 JavaScript」这句话对发布流程意味着什么;能在静态托管、长驻 Node 进程、Serverless 和 npm 包四种形态里为项目选对一种;能写出一条把类型检查放在第一步的 CI 流水线;能用 schema 在进程启动时一次性校验环境变量;能解释语义化版本号三个数字在 TypeScript 项目里分别对应什么改动,并说出金丝雀、蓝绿、功能开关三种发布策略各自的回滚代价。

17.3 部署、发布与版本演进

前两节我们把项目分好了层、把前后端的契约统一了。这一节走完最后一公里:把代码真正送上生产,并让它在之后的每一次改动里都能安全地演进。

「部署」和「发布」是两个不同的动作,值得先分清:部署是把产物放到服务器上,发布是让用户开始用新版本。它们可以分离——金丝雀发布就是「部署了但只让 1% 的用户用」,功能开关则是「部署并发布了,但功能是关着的」。把这两个动作分开,是后面所有发布策略的基础。

你部署的不是 TypeScript

第一个必须建立的认知:TypeScript 在构建阶段就被完全擦除了,生产环境跑的是普通 JavaScript。第 13 章讲过 类型擦除带来的运行时盲区 ,到了部署环节,它的后果变得更具体:

认知后果
类型在运行时不存在「类型都对了」不代表线上不会崩,测试不能省
产物是 JS部署的是 dist/,不是 src/
需要 source map否则错误堆栈只能看到压缩后的行号
编译配置影响产物target、module 决定生成什么语法,见 编译目标与严格模式配置

有人会问:能不能直接把 .ts 交给 Node 跑?新版本 Node 的类型剥离能力确实在进步,但生产环境通常还是走「编译成 JS」这条路——因为它同时完成了类型检查、语法降级和打包三件事。打包器怎么选、产物长什么样,esbuild/swc/tsup 与打包产物 已经讲透了,这里只补一句实践建议:用同一个 tsconfig 跑类型检查、用打包器的配置产出代码,两件事都要在 CI 里做(后面会看到原因)。

构建产物里该有什么

在写 CI 之前,先确认产物是完整的。发布一个 Node 包时,dist/ 里必须同时有编译后的 JS 和类型声明文件——后者是别人能用上你类型的关键。package.json 里的字段配置大致长这样:

{
  "name": "my-lib",
  "version": "1.2.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

三个字段容易被写错:

字段作用写错的后果
files白名单,只有列出的目录会进包把 src/、测试、配置一起发出去
types / exports.types告诉使用者的编辑器去哪找类型使用者只能看到 any
exports 里的条件顺序types 必须排在最前面条件匹配失败,类型解析不到

exports 的条件顺序是硬性要求:解析器按顺序匹配,第一个命中的就返回。如果 import 写在 types 前面,TypeScript 会先匹配到 JS 文件,于是类型丢失——这类问题在本地很难发现,只有在别人装包之后才暴露。这也是为什么产物要在发布前用 npm pack --dry-run 看一眼清单。

构建产物与 source map

线上报错时,堆栈里全是压缩过的变量名和偏移量,看不出是哪一行。source map 就是用来把产物位置映射回原始 TypeScript 源码的:

{
  "compilerOptions": {
    "sourceMap": true,
    "inlineSources": true
  }
}

实践上有两条经验:

  • 映射文件上传到错误收集服务,不对外公开。source map 能还原你的源码结构,直接放在静态目录里等于公开源码。生产环境常用 hidden 模式生成——文件仍然产出,但不加指向它的注释。
  • 构建产物要保留原始模块结构或至少可读的 chunk 名,否则即使有 source map,堆栈里的文件名也帮不上忙。

四种部署形态

选部署形态,本质上是在选「代码运行在什么样的进程里」:

形态产物典型平台适合要额外处理什么
静态托管HTML / CSS / JSCloudflare Pages、Vercel前端 SPA、SSG 站点没有服务端环境变量,密钥不能进前端
长驻 Node 进程bundle + nodeDocker + K8s、VPS连接池、WebSocket、定时任务健康检查、优雅退出、多实例状态
Serverless / Edge单个函数Vercel Functions、Workers流量波峰明显、事件驱动冷启动、执行时限、不能保持长连接
npm 包 / CLIdist/ + 类型声明npm registry工具库、SDK版本兼容、类型声明要一起发

前端项目的静态托管可以看 Vercel 部署实践 ;需要长驻进程时,Docker CI/CD 流水线 给出了从构建镜像到推送仓库的完整链路,而进程退出这件事比想象中麻烦,优雅退出与健康检查 值得单独读一遍——被 SIGTERM 打断时还在处理中的请求,处理不当会直接变成用户可见的 5xx。

类型检查必须是第一道门禁

CI 流水线的设计原则只有一条:把最快的检查放在最前面。类型检查是秒级的,构建和端到端测试是分钟级的,让一个类型错误在 3 分钟内才被发现,是纯粹的浪费。

name: ci
on:
  push:
    branches: [main]
  pull_request:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npm run lint
      - run: npm test -- --run
      - run: npm run build

几个容易被忽略的细节:

  • npm ci 而不是 npm install。前者严格按 package-lock.json 安装,保证 CI 和本地环境一致;后者可能悄悄升级依赖,制造「本地能跑 CI 不能跑」的经典问题。
  • tsc --noEmit 与 npm run build 都要跑。它们可能用不同的配置(比如构建用的是 tsconfig.build.json,排除了测试文件),只跑一个会漏掉另一边的错误。缓存与提速技巧见 CI/CD 性能与缓存优化 。
  • 顺序即优先级。类型检查、lint、单元测试、构建,任何一步失败立刻中断,不浪费后续的计算资源。
  • 门禁的完整设计(覆盖率阈值、lint 规则、分支保护)在 覆盖率、lint 与 CI 门禁 里已经展开过,这里只强调类型检查的位置最靠前。

环境变量的启动期校验

线上事故里有一类特别冤:服务部署成功了,但第一次有请求进来时才炸,原因是某个环境变量没配。这类问题可以用「启动期校验」彻底消灭。

问题的根源在于 process.env 的类型。它的值是 string | undefined,很多人用 ! 断言糊过去:

// 危险写法:断言骗过了编译器,错误被推迟到运行时
const dbUrl = process.env.DATABASE_URL!;

这样写的后果是:进程正常启动,直到第一次访问数据库才抛 TypeError: Invalid URL,而此时流量已经打进来了。正确做法是在进程启动的最开始,用 schema 一次性校验所有变量:

// src/config/env.ts
import { z } from "zod";

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().int().positive().default(3000),
});

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  // 启动期失败,好过运行到一半才失败
  console.error("环境变量校验失败:", parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data; // 类型是精确推导出来的,且保证有值

这段代码值得逐行理解:

  • z.coerce.number():环境变量永远是字符串,PORT 需要转成数字。coerce 让 schema 负责转换,业务代码拿到就是 number。
  • .default(3000):可选变量有兜底,不写就是默认值,类型推导出来也不含 undefined。
  • process.exit(1):让部署流程立刻失败。K8s 会看到容器退出,不会把流量导进一个配置不全的实例。
  • 导出的是 parsed.data:整个应用只从这里读配置,任何地方都不该再碰 process.env。这样「配置有哪些、什么类型」只有一处定义,和上一节讲的契约是同一个思路。

这就是所谓的 fail fast:宁可启动失败,不要带着错误配置运行。它的收益在容器编排环境里尤其明显——启动失败会被编排器直接拦下,而运行时失败会变成用户看到的报错。

语义化版本号在 TypeScript 项目里的特殊含义

发布 npm 包或给 API 打版本时,都会用到语义化版本(MAJOR.MINOR.PATCH)。规则本身很简单,但 TypeScript 项目有一条额外的坑:

段位何时递增在 TS 项目里对应什么
MAJOR破坏性变更导出的类型签名不兼容、删除导出、收紧类型
MINOR向后兼容的新功能新增导出、新增可选字段
PATCH向后兼容的修复修 bug、文档、不影响签名的实现调整

关键的那一行是「收紧类型」。假设你把一个导出函数的参数从 string 收紧成 "a" | "b",运行时的行为完全没变,但对使用者来说,原本能编译的代码现在编译失败了——这就是破坏性变更,必须升 MAJOR。反过来,把类型放宽("a" | "b" → string)是兼容的,但使用者的 switch 会失去穷尽性检查,值得在 CHANGELOG 里提醒一句。

版本号怎么影响依赖解析、^ 与 ~ 的行为差异,语义化版本与依赖解析 有完整说明;如果项目已经进入定期发布节奏,发布列车与版本管理 讨论的是组织层面的节奏问题。要把包发到 npm,TypeScript SDK 打包与发布 给出了类型声明一起发布的配置细节。

预发布版本与 dist-tag

大版本改造往往需要使用者提前试用,这时用「预发布版本」比直接发正式版安全得多。语义化版本允许在版本号后面加连字符和标识符:

2.0.0-beta.1
2.0.0-rc.1

这些版本不会覆盖默认的 latest 标签,而是挂在另一个 dist-tag 上:

npm publish --tag next
npm dist-tag ls my-lib
latest: 1.2.0
next: 2.0.0-beta.1

使用者要试用时显式指定标签即可:

npm install my-lib@next

这样做的好处是:npm install my-lib 仍然装到稳定的 1.2.0,不会有人被半成品影响;同时早期使用者能提前反馈兼容性问题。等 beta 稳定后,用 npm dist-tag add my-lib@2.0.0 latest 把它提升为正式版——注意这时不需要重新发布,只是改了一个指针。

多包项目里的发布顺序

如果项目是 monorepo,发布就多了一层约束:被依赖的包必须先发。比如 contracts 被 web 和 server 依赖,那么顺序必须是 contracts → web / server,否则后两者安装时会拿不到新版本的契约包。

这件事手工做极易出错,通常交给工具链处理(changesets、lerna、pnpm publish -r 都能按依赖拓扑排序)。上一节讲的契约包尤其要注意:契约变更和消费方变更最好在同一次发布里完成,跨次发布就意味着中间会有一段时间「契约已经是新的,代码还是旧的」——这正是我们强调「加字段安全、删字段危险」的原因。Monorepo 与 Project References 讲了怎么用工具保证类型引用正确,发布顺序则是它在流水线上的延伸。

三种发布策略的取舍

发布策略要比较的不是「先进程度」,而是回滚速度——线上出问题时,你能多快让用户回到正常状态:

策略做法回滚速度代价
全量替换直接部署新版本慢,要重新部署旧版最简单
灰度(按比例)先 5% 流量,观察后逐步放开中,把流量切回旧版需要流量路由能力
金丝雀先起 1 个新实例,指标正常再扩快,销毁新实例即可需要新旧版本共存
蓝绿两套完整环境,切负载均衡最快,切回去就行资源翻倍
功能开关代码已上线,开关控制是否生效最快,关开关即可代码里留下分支,需定期清理

金丝雀和蓝绿的细节可以看 金丝雀发布策略 与 部署策略总览 ;如果项目希望「部署」与「发布」彻底解耦,功能开关与发布 是更彻底的方案。预览环境(每个 PR 一个独立地址)则是把「发布」提前到评审阶段,见 预览环境 。

一个小建议:小项目从全量替换开始就够了。上表里越往下的策略,运维复杂度越高,在没有足够流量和监控之前,它们带来的收益不如「把监控做好」。

回滚:最难的不是代码

上面所有策略都假设「回滚就是把旧代码放回去」。真正的难点在数据:代码可以回滚,数据库结构往往不可以。

假设你在这次发布里把 users.name 拆成了 first_name 和 last_name,代码回滚很容易,但已经迁移过去的数据怎么办?所以数据库变更要拆成每一步都能独立回滚的序列:

第 1 步:新增 first_name、last_name 两列(可空)—— 旧代码不受影响
第 2 步:代码双写,两个旧字段和新字段都写 —— 可以回滚
第 3 步:迁移历史数据 —— 可以回滚
第 4 步:读切到新字段 —— 可以回滚
第 5 步:确认稳定后,删除旧列 —— 这一步不可逆

这套「扩展—迁移—收缩」的流程,是让不可逆操作尽量往后推的唯一办法。迁移怎么进 CI 流水线,可以看 数据库迁移流水线 ;架构层面还有一套 蓝绿与金丝雀发布策略 的完整讨论。

发布后该看什么指标

没有监控的发布等于闭着眼睛开车。四个最基本的信号:

信号看什么报警阈值示例
错误率5xx 响应占比持续 5 分钟超过 1%
延迟p95 / p99 响应时间p95 超过 500ms
饱和度CPU、内存、连接池占用连接池使用率超过 80%
业务注册、下单等关键动作成功率环比下跌超过 20%

前三个是通用的,第四个最容易被忽略却最有价值:技术指标全绿但业务指标腰斩,通常意味着一次静默的逻辑错误。TypeScript 项目接入链路追踪可以看 OpenTelemetry 可观测性 ;「错误预算」这套把可靠性和发布节奏绑定的做法,见 SLO 与错误预算 。

破坏性变更的弃用流程

最后是「演进」这个词的另一半:怎么安全地删掉一个不再需要的 API。标准流程有四步:

  1. 标记弃用。用 JSDoc 的 @deprecated 标签,编辑器会给使用者划上删除线:
/**
 * @deprecated 自 v2.3.0 起改用 createUserV2,v3.0.0 将移除。
 */
export function createUser(input: OldInput): User {
  return createUserV2(convert(input));
}
  1. 运行时警告。被调用时打一条日志,统计还有多少流量在走老路径。
  2. 观察调用量。调用量降到零(或只剩自己可控的调用方)之后,才排期删除。
  3. 在下一个 MAJOR 版本移除。删除也是破坏性变更,必须走大版本。

这套流程和上一节的契约兼容性表是同一件事的两面:先加新的,让新的用起来,再删旧的。顺序反了,就是线上事故。

一次发布的检查清单

把前面的内容压缩成一张清单,发版前逐条打勾:

检查项为什么
CI 全绿(类型检查、lint、测试、构建)任何一项没过都不该进入发布
环境变量在目标环境已配置且能通过校验启动期失败好过运行期失败
数据库迁移是「可回滚的下一步」不可逆操作必须单独排期
版本号已按改动性质递增类型收紧也要升 MAJOR
CHANGELOG 写清了破坏性变更使用者据此决定是否升级
监控与告警在看新版本没有观测的发布是赌博
回滚路径已确认(旧产物还在、开关可用)出事时没有时间现想
发布后用真实账号走一遍关键路径冒烟测试能挡掉大部分低级错误

这份清单的价值不在于条目本身,而在于它把「发布」从一次凭感觉的操作变成了一个可重复的流程。当流程稳定之后,就可以交给 CI 自动执行——真正的自动化不是「脚本能跑」,而是「步骤明确到可以写成脚本」。

常见坑

  • 把 dist/ 提交进 git。产物应该由 CI 生成,dist/ 要进 .gitignore。
  • CI 里只跑 tsc 不跑 build(或反过来)。两者配置不同时,会出现「CI 全绿但产物是坏的」。
  • 环境变量在构建期被内联。Vite 的 import.meta.env.VITE_* 是编译时替换,改了环境变量必须重新构建,不是重启就能生效。
  • 忘记设置 NODE_ENV=production。很多依赖会据此切换分支,漏掉可能导致体积翻倍或调试代码泄漏到线上。
  • source map 直接公开。它能还原你的源码结构,通常上传到错误收集服务后就不再对外提供。

小结

这一节我们走完了从代码到线上的全流程,可以压缩成五条结论:

  1. 部署的是编译产物,不是 TypeScript。类型擦除意味着「编译通过」不能替代测试,也意味着 source map 是必需品。
  2. 先选运行形态,再谈发布策略。静态托管、长驻进程、Serverless、npm 包,各自的运维负担完全不同。
  3. CI 的第一道门禁是类型检查,因为它最快;npm ci、tsc --noEmit 与 build 三者缺一不可。
  4. 配置在启动期校验,失败就退出。process.env 的 ! 断言是把错误推迟到最坏的时刻。
  5. 回滚的难点在数据。代码能回滚,schema 变更要拆成可独立回滚的步骤,把不可逆操作尽量往后推。

到这里,第 17 章「全栈项目实战」就结束了。我们有了结构(17.1)、有了契约(17.2)、有了发布流程(17.3)——一个 TypeScript 项目的完整骨架已经立起来。

但绝大多数人接手的不是新项目,而是一个已经跑了几年的 JavaScript 老项目。下一章我们回到这个最现实的场景:怎么在不推倒重来的前提下,把一个 JavaScript 项目渐进式迁移到 TypeScript——从哪里开始、哪些文件先改、allowJs 与 checkJs 怎么配合,以及怎么说服团队接受这套流程。

阅读导航:上一节:17.2 前后端共享类型与 API 契约 · 下一节:18.1 JavaScript 项目渐进式迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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