JavaScript/TypeScript生态系统中的大多数项目都会发布新版本,版本号遵循语义版本控制(Semver)。Semver 是一个规范,描述了如何在每次新版本发布时可预测地增加包的版本号。TypeScript 因其版本没有严格遵循 semver 而闻名。本文将深入探讨:
-
semver是什么,为什么它对许多软件包有用
-
为什么严格遵循 semver 的解释对于 TypeScript 来说是不切实际的
-
TypeScript 的版本是如何根据 semver 的解释来进行版本控制的
虽然 TypeScript 偏离了公共社区规范,这可能会让开发人员感到恼火,但它选择偏离的真正原因是:
-
TypeScript 的类型检查在几乎每个版本中都会发生细微的变化。
-
为每个类型检查更改增加 TypeScript 的主版本是不切实际的。
-
如果我们把这些类型检查细微差别看作是细节而不是公共 API,那么 TypeScript 实际上对语义版本控制有很好的遵循。
本文将更深入地解释这些要点。
什么是语义版本控制
Semver 规定版本遵循 Major.Minor.Patch 格式:
-
主要版本:当您做出不兼容的 API 更改时增加
-
次要版本:当你以向后兼容的方式添加功能时增加
-
补丁:当你进行向后兼容的错误修复时增加
-
例如,假设一个包的版本是
1.2.3
。它可能会将其版本增加到: -
1.2.4
:在发布不改变其 API 或典型行为的小错误修复时 -
1.3.0
:在发布不改变其 API 或典型行为的新特性时 -
2.0.0
:在对其公共API或典型行为的破坏性更改发布时
Semver 是 JavaScript/TypeScript 生态系统中被广泛接受的规范,包括在 package.json
依赖项列表中。
语义版本控制的好处
采用版本号的标准策略,使得业界能够分享关于版本号变更时会发生什么的预期。
语义版本控制对于开发者的意义
Semver 作为开发人员考虑是否更新依赖项的“营销”数字。如果您看到一个包,比如说,只改变了它的补丁版本号,那么您可以预期更新可能不会在您这边花费太多精力。然而,如果包更新了它的主版本号,那么您可以预期更新可能需要一些手动工作。
提示:
不管语义版本号发生了什么变化,在尝试更新一个包之前阅读它的发布说明总是个好主意。你永远不知道什么时候对依赖项的一个小更改会以有意义的方式影响你的项目。
语义版本控制对于工具的意义
除了向开发人员建议更改严重性外,Web生态系统中的许多常用工具都依赖于语义版本控制。最值得注意的是,Node.js package.json
的包依赖性遵循 node-semver
版本,该版本直接引用 semver.org。在 package.json
文件中常见的 ^
和 ~
等符号是构建在 semver 上的,并由 node-semver
的文档解释。
另一个基于 semver 构建的工具的常见示例是自动化的发布管理。诸如 Changesets、release-it 和 semantic-release 之类的工具可以根据对包所做的更改的类型自动发布语义版本化的包。诸如 conventional commits 之类的提交标准可以通知工具每个提交包含哪些更改。
TypeScript 版本控制
考虑到语义版本控制的种种优点,我们可能会惊讶地发现 TypeScript 并没有遵循严格的语义版本控制。到目前为止,TypeScript 的版本号是按照以下模式递增的:
-
TypeScript 的 minor 版本(例如 4.9.0)每三个月左右发布一次,并带有新功能。
-
如果minor版本超过9(例如4.10.0),则发布新的TypeScript major版本(例如5.0.0)。
换句话说,TypeScript 使用主版本号更多地是为了市场营销和公众可见性,而不是语义版本控制。这是一个有意的工程决策:发布一个新的 TypeScript 版本几乎总是需要编译器更改,这会“破坏”一些现有的 TypeScript 程序。
“Breaking”类型检查更改
许多用户认为 TypeScript 的类型检查是其 API 的一部分。当一个 minor 或 patch 版本引入了对类型检查的更改时,一些人可能会认为这是一个“破坏性更改”。
但是 - 类型检查器是 TypeScript 的核心部分,也是每个版本中最频繁修改的部分之一。严格遵循 semver 意味着对类型检查的任何更改都将被视为“破坏”。
对类型系统的改变可以有多种形式。即使是这些形式中看似很小的改变,也可能导致 TypeScript 的一些使用者崩溃。
这个著名的xkcd漫画很幽默,但对于许多项目来说,它离现实并不远。
大多数变化可以归为以下几类之一:
新增类型错误
使用 TypeScript 的一个主要好处是,当它检测到代码中的问题时,它会报告类型错误。新版本的 TypeScript 通常在发现更多类型错误方面更强大。但是,有些人会认为这是类型检查中的 bug 修复或新功能,而另一些人则认为这是引入的破坏。
更新到新版本的 TypeScript 时,看到新的红色编辑器涂鸦或构建失败可能被认为是“破坏” - 但这是 TypeScript 随着时间的推移而改进的自然结果。
示例:TypeScript 4.8 不再允许无约束泛型类型参数可赋值给 {}
。这在技术上是正确的,因为 {}
不应该可赋值给 null
或 undefined
。但是现有的代码可能已经在假设类型参数总是提供对象类型的情况下编写。在 TypeScript >=4.8 中,该代码将新接收类型错误。
移除类型错误
相反,TypeScript有时会停止报告曾经被认为是错误的类型错误。这有时是为了使TypeScript更宽容,与现有的JavaScript代码更兼容。但对于依赖TypeScript更严格的类型检查来捕获问题的消费者来说,这些变化也可能被认为是“破坏性的”。
更新到新版本的 TypeScript 并不再被阻止发布项目想要避免的代码模式可能是一个问题 - 即使 TypeScript 认为这个变化是一个改进。
例如:TypeScript 5.1 允许 getter 和 setter 访问器对指定两种不同的类型。这是正确的,因为许多内置的 JavaScript 对象都有访问器对的不同类型 API。然而,那些期望 TypeScript 防止他们编写不匹配的访问器对的团队可能会惊讶地发现 TypeScript >=5.1 不再会这样做。
内置类型定义的变化
TypeScript 包含一组内置类型定义,这些定义在大多数项目中都被使用。这些定义包括 JavaScript 内置对象的类型,如 Array
和 String
,以及 DOM 类型,如 Document
和 HTMLElement
。
对这些定义的更改可能会导致现有代码中的类型错误,即使这些更改纯粹是附加的。许多项目增强内置类型定义,以填补空白或根据项目的需求使其更严格。对增强的内置类型的修改可能会为以前允许的声明合并引入类型错误。
TypeScript 4.9 提高了内置 Promise.resolve
类型的准确性。这种改进的类型可以使现有的依赖于 Promise.resolve(...)
调用的代码被类型为返回 any
或 unknown
(<4.9) 而不是 Promise<...>
(>=4.9)。
严格版本控制选项
我们已经看到许多常见的类型检查更改是多么“破坏性”。由此可见,如果 TypeScript 遵循严格的 semver 解释,那么每个版本都将是一个 major。TypeScript 的版本策略选项因此是:
-
允许在非主版本中更改类型系统(目前使用的策略)
-
只在主要版本中发布这些更改
不幸的是,不管主版本发布得多频繁,只在主版本中发布类型系统更改都是不切实际的。
频繁的主版本
理论上,TypeScript 可以每三个月左右发布一个新的 major 版本,而不是像现在这样发布一个新的 minor 版本。这将更直接地遵循传统的 semver,通过将类型系统更改作为 break 来指示。
然而,这样做会否定semver为消费者带来的许多好处。每3个月发布一个主要版本,消费者将不得不非常频繁地更新到那个主要版本。主要版本和次要版本之间的区别将失去意义。
这种区别的丧失对使用者是有害的,因为 TypeScript 确实偶尔需要发布一个 API 级别的破坏性更改。如果每个版本都被贴上“major”的标签,那么开发人员和工具都无法轻松识别“真正的”重大破坏。
此外,频繁的主版本更新会导致社区中的大部分人被甩在后面。大多数项目不会经常更新依赖项的主版本。特别是企业软件,因为坚持使用旧的主版本而臭名昭著。一个软件存在的主要版本越多,许多用户仍然使用旧的、不受支持的版本的可能性就越大。
错误和错误修复版本
当一个无意的更改,例如类型检查错误,在 TypeScript 发行版中发布时会发生什么?如果 TypeScript 将类型系统更改视为永久破坏,那么错误修复也必须作为主要版本发布。在发布后不可避免地需要修复错误,这将使每个预期发布都需要添加大量主要版本。
不频繁的主版本
另一方面,TypeScript 可以不经常发布主要版本 —— 比如每六个月甚至每年一次。这将最大限度地减少用户需要更新主要版本和体验中断的频率。
但是限制所有“破坏性”的变更到一个偶尔的新的主要版本将意味着一次封装数月里几乎所有的变更。新的不频繁的主要版本将一次包含大量的类型系统变更。适应新的不频繁的主要版本将比今天的小版本困难得多。
更糟糕的是,因为新的变化在发布之前在稳定版本中只有很少的公开用户测试,它们很可能会包含更多的 bug。TypeScript 是一种足够复杂的语言,拥有足够大的社区,直到稳定版本发布之前,许多类型系统边缘案例都不会被捕获。即使对于新的不常见的主要版本,有很长的 beta 测试期,bug 也可能会悄悄出现 - 而它们的修复通常会被认为是“破坏性”的更改,必须等待另一个新的主要版本。
TypeScript 确实遵循(松散的) 语义版本控制
尽管前面提到了在 TypeScript 版本中进行语义版本控制的困难,但该语言实际上遵循了更宽松的 semver 版本。它认为 API 更改是破坏性的:
-
对 Node.js API 的向后不兼容的修改
-
移除TSConfig编译器选项
-
修改TSConfig编译器选项的默认值(除了catch-all
strict
,它被明确地排除在外)
换句话说,你调用 TypeScript 的方式,比如 API 输入和输出的一般格式,都被认为是它的稳定的公共 API。TypeScript 生成输出的细微差别,比如类型检查行为,可能会在非主要版本中发生变化。
使用 TypeScript 版本
考虑到 TypeScript 的 minor 版本通常会改变类型检查行为,升级到新的 TypeScript 版本有时可能不是小事,甚至实际上很痛苦。TypeScript 的破坏性变化在网上有文档记录:
-
发布说明总是可以在 TypeScript 手册中找到:例如 What's New > TypeScript 5.1
-
github.com/microsoft/TypeScript/wiki/Breaking-Changes:终端用户的变化,最显著的是关于 emitting 和 type checking 行为的改变。
-
github.com/microsoft/TypeScript/wiki/API-Breaking-Changes:影响 TypeScript 作为库或 Node.js API 运行的更改
此外,还有一些策略可以让你减少这种痛苦。
固定的 TypeScript 版本
如今,JavaScript生态系统中的大多数软件项目都使用“锁文件”,例如 package-lock.json
(npm), pnpm-lock.yaml
(pnpm),以及 yarn.lock
(Yarn)来保持包版本的一致性。锁文件将确保在你的项目中安装的依赖项总是使用相同的版本。使用锁文件通常是一个好的做法 - 特别是对于像TypeScript这样的工具,它可以在不同版
本之间实质性地改变行为。
注意,当使用锁文件时,你不必在你的package.json中指定 "typescript" 的major.minor.patch版本。你的锁文件会为你指定每个依赖项的确切版本 - 通常默认为 package.json 中所有依赖项的最新稳定版本,包括 "devDependencies" 。
有意的 TypeScript 版本升级
当你确实想更新 TypeScript 版本时,请考虑以下策略:
-
创建一个 pull request 来隔离新的 TypeScript 版本的更改
-
在 pull request 的主体中,链接到 TypeScript 发行说明(例如 TypeScript 5.1)并指出对你的项目有影响的更改。
-
在PR的文件视图中,对于你必须做出的每种改变,在改变的第一个实例中添加注释,解释为什么它是必要的。
破损处理
如果一个新的 TypeScript 版本在你的代码中导致了类型错误,考虑依次尝试以下策略,直到问题被修复:
-
修复了与新的 TypeScript 行为一起工作的类型的小问题
-
重构类型以减少复杂性并使用新的 TypeScript 行为
-
在你无法确定的类型上使用
any
,并使用// TODO
注释链接到一个跟踪问题/票据,以便稍后进行清理。 -
在你无法解决的问题上使用
// @ts-expect-error
,并使用// TODO
注释链接到跟踪问题/票据,以便稍后清理它。
让我们更详细地看看这些技巧。
简单明了的类型
TypeScript 的类型系统非常强大,可以表示一些疯狂古怪的类型操作。然而,你的类型越复杂,随着时间的推移,它们的读取、编写和更新就越困难。TypeScript 类型遵循保持代码简洁的一般软件开发原则:
-
尽可能使用简单易读的代码(类型)
-
选择名称好、能够解释其作用的类型
-
当代码变得复杂到难以阅读的程度时,考虑使用注释来解释棘手的细节
更简单的类型不太可能被新的 TypeScript 版本在类型系统细微差别上修修补补而破坏。当某些东西被破坏时,它们也更容易调试。
因此,如果你无法修复新破坏类型的小问题,你可能会从尝试重构它们中受益,使其更简单,更容易使用。
回落到 any
另一个通用的类型系统最佳实践是避免使用 any 类型。 any 类型表示一个值可以是任何东西,TypeScript 应该允许以几乎任何方式使用它。这听起来很危险:any 会阻止 TypeScript 报告潜在的有用类型错误,并阻止其他 TypeScript 开发人员工具的工作。
不过,如果你正努力让一个类型工作, any 可以作为有效的备份创可贴来降低 TypeScript 的类型检查的严格性。用 any 替换一个类型可以让你免于深入研究该类型的复杂性。尝试总是添加一个 // TODO 注释来解释你为什么使用 any ,以帮助通知将来要删除 any 的努力。
提示:
@typescript-eslint/no-explicit-any lint 规则可以强制禁止在代码中显式地编写 any 类型。
回到 TypeScript 注释指令
比 any
更危险的是TypeScript注释指令,它会完全关闭一行代码的类型检查:
// @ts-ignore:对一行隐藏任何类型检查错误
// @ts-expect-error:与 // @ts-ignore 类似,但如果相应的行没有导致类型错误,TypeScript 会报告注释指令是不必要的。
这些都是所有策略都失败后的最后手段。完全忽略 TypeScript 类型错误带来的缺点与 any 相同,但甚至更多 —— 跨越整行代码。
如果你必须使用注释指令来静音 TypeScript,最好使用 // @ts-expect-error 和一个 // TODO 注释来解释你为什么需要它。如果将来对代码库的更改消除了对注释指令的需求, // @ts-expect-error 将指示 TypeScript 让你知道要删除注释。
提示:
@typescript-eslint/ban-ts-comment lint 规则可以强制执行注释指令,或者强制它们附带一个解释性的注释。
@typescript-eslint/prefer-ts-expect-error lint 规则可以强制使用 // @ts-expect-error 而不是 // @ts-ignore 。
总结
语义版本控制是一个很棒的规范,它帮助许多工具标准化了发布新版本和/或基于可预测版本控制构建的方法。TypeScript 项目尽其所能地与其公共 API 保持一致。
不幸的是,严格遵守 semver 对于 TypeScript 的大部分来说是不可能的 —— 特别是类型检查器 —— 不管 TypeScript 如何尝试安排新版本。TypeScript 的目标是在它的公共 API 中尽可能地与 semver 兼容,同时仍然以合理的速度在它的类型系统上进行迭代。
当将项目的 TypeScript 依赖升级到新版本时,可以采取几种策略来最小化中断。这些策略主要围绕着拥有干净、规范的 TypeScript 代码,并将 TypeScript 的“逃生舱口”作为最后的手段。
更多公开讨论,请参见 TypeScript 问题 #14116,讨论了开始使用语义版本控制的请求:特别是 @RyanCavanaugh 的第二个评论。
欢迎关注公众号:文本魔术,了解更多