本节目标:读完这一节,你能说清「你部署的不是 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 / JS | Cloudflare Pages、Vercel | 前端 SPA、SSG 站点 | 没有服务端环境变量,密钥不能进前端 |
| 长驻 Node 进程 | bundle + node | Docker + K8s、VPS | 连接池、WebSocket、定时任务 | 健康检查、优雅退出、多实例状态 |
| Serverless / Edge | 单个函数 | Vercel Functions、Workers | 流量波峰明显、事件驱动 | 冷启动、执行时限、不能保持长连接 |
| npm 包 / CLI | dist/ + 类型声明 | 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。标准流程有四步:
- 标记弃用。用 JSDoc 的
@deprecated标签,编辑器会给使用者划上删除线:
/**
* @deprecated 自 v2.3.0 起改用 createUserV2,v3.0.0 将移除。
*/
export function createUser(input: OldInput): User {
return createUserV2(convert(input));
}
- 运行时警告。被调用时打一条日志,统计还有多少流量在走老路径。
- 观察调用量。调用量降到零(或只剩自己可控的调用方)之后,才排期删除。
- 在下一个 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 直接公开。它能还原你的源码结构,通常上传到错误收集服务后就不再对外提供。
小结
这一节我们走完了从代码到线上的全流程,可以压缩成五条结论:
- 部署的是编译产物,不是 TypeScript。类型擦除意味着「编译通过」不能替代测试,也意味着 source map 是必需品。
- 先选运行形态,再谈发布策略。静态托管、长驻进程、Serverless、npm 包,各自的运维负担完全不同。
- CI 的第一道门禁是类型检查,因为它最快;
npm ci、tsc --noEmit与build三者缺一不可。 - 配置在启动期校验,失败就退出。
process.env的!断言是把错误推迟到最坏的时刻。 - 回滚的难点在数据。代码能回滚,schema 变更要拆成可独立回滚的步骤,把不可逆操作尽量往后推。
到这里,第 17 章「全栈项目实战」就结束了。我们有了结构(17.1)、有了契约(17.2)、有了发布流程(17.3)——一个 TypeScript 项目的完整骨架已经立起来。
但绝大多数人接手的不是新项目,而是一个已经跑了几年的 JavaScript 老项目。下一章我们回到这个最现实的场景:怎么在不推倒重来的前提下,把一个 JavaScript 项目渐进式迁移到 TypeScript——从哪里开始、哪些文件先改、allowJs 与 checkJs 怎么配合,以及怎么说服团队接受这套流程。
阅读导航:上一节:17.2 前后端共享类型与 API 契约 · 下一节:18.1 JavaScript 项目渐进式迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。