TypeScript V5 前瞻
通过这篇博客得知,TypeScript V5 即将发布,那么我们就来看看 TypeScript V5 带来了哪些新特性吧。
1. 装饰器
装饰器即将成为 ES 标准,所以 TypeScript V5 也落地了符合规范的装饰器。
快速预览类方法装饰器
function loggedMethod<
This,
Args extends any[],
Return,
Fn extends (this: This, args: Args) => Return,
>(target: Fn, context: ClassMethodDecoratorContext<This, Fn>) {
const methodName = String(context.name)
return function (this: This, ...args: Args): Return {
console.log(`Into ${methodName}`)
const result = target.apply(this, args)
// ^======================^
// 调用原始方法 <Person.greet>
console.log(`Out ${methodName}`)
return result
}
}
class Person {
name: string
constructor(name: string) {
this.name = name
}
// 直接在 ClassFunction 上使用装饰器
// 或者也可以 @loggedMethod greet() { 这样
@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}.`)
}
}
const p = new Person('Alex')
p.greet()
// 打印如下:
// Into greet
// Hello, my name is Alex
// Out greet
TypeScript 提供了一个叫做 ClassMethodDecoratorContext 的类型,它为方法装饰器的上下文对象建模。
通过 context 属性,我们能拿到一个方法的元数据 name, args...
const context = {
kind: 'method',
name: 'greet',
static: false,
private: false,
addInitializer: Function,
}
addInitializer
在 context 中,存在一个方法 addInitializer,它会挂在类的构造函数开始。(当我们在 static class function 中使用的时候,则会挂在类本身的初始化上)
举个简单的例子,当我们为了保证类方法在调用时 this 为当前类实例时,我们会这样做:
在构造函数中绑定 this:
class Person {
constructor(public name: string) {
this.greet = this.greet.bind(this)
}
greet() {
console.log(`Hello, my name is ${this.name}`)
}
}
const person = new Person('John')
const greet = person.greet
greet()
或者将某个方法使用箭头函数作为属性:
class Person {
greet = () => {
// 保证调用时不会丢失 this
console.log(this.name)
}
}
那么使用 addInitializer 就可以来解决这个问题:
假设我们定义一个装饰器,它可以将类方法的 this 绑定到当前类实例上:
function bound<
This, Args extends any[], Return,
Fn extends (this: This, ...args: Args) => Return,
>(originalMethod: Fn, context: ClassMethodDecoratorContext<This, Fn>) {
const methodName = context.name.toString()
// 注册钩子,可以挂在构造函数开始执行
// 注意不要使用箭头函数,否则会丢失 this
context.addInitializer(function () {
this[methodName] = this[methodName].bind(this)
})
return originalMethod
}
class Person {
constructor(public name: string) {
this.greet = this.greet.bind(this)
}
@bound
greet() {
console.log(`Hello, my name is ${this.name}`)
}
}
const person = new Person('John')
const greet = person.greet
greet() // 这样就不会丢失 this 了
那么这段代码通过 tsc 会编译为啥样呢?我简化了一下:
function bound(originalMethod, context) {
const methodName = context.name.toString()
context.addInitializer(function () {
this[methodName] = this[methodName].bind(this)
})
return originalMethod
}
const __esDecorate = function (ctor, descriptorIn, decorators, contextIn, extraInitializers) {
const target = ctor.prototype
const descriptor = Object.getOwnPropertyDescriptor(target, contextIn.name)
for (let i = decorators.length - 1; i >= 0; i--) {
const context = {}
for (const k in contextIn) context[k] = contextIn[k]
context.addInitializer = function (f) {
extraInitializers.push(f)
}
decorators[i](descriptor, context)
}
}
function __runInitializers(thisArg, initializers) {
for (let i = 0; i < initializers.length; i++)
initializers[i].call(thisArg)
}
const Person = (function () {
const _instanceExtraInitializers = []
function Person(name) {
this.name = (__runInitializers(this, _instanceExtraInitializers), name)
}
Person.prototype.greet = function () {
console.log('Hello, my name is '.concat(this.name))
}
const _a = Person
const _greet_decorators = [bound]
__esDecorate(_a, null, _greet_decorators, { name: 'greet' }, _instanceExtraInitializers)
return _a
}())
const person = new Person('John')
const greet = person.greet
greet()
根据上述代码可以看出:
- 在构造 Person 之前,就已经执行了每个装饰器,将新注册的
addInitializer方法挂在了_instanceExtraInitializers上 (__esDecorate中) - 然后在构造函数中依次调用了这些方法 (
__runInitializers中)
与旧装饰器的差异
--experimentalDecorators 在未来继续存在,但是如果不开启这个选项,默认 tsc 编译的就是新装饰器的语法。
新装饰器与 --emitDecoratorMetadata 同样不兼容,不允许存在装饰器参数,也许未来 ECMA 会弥补这一缺陷。
新装饰器要求类的装饰器需要放在 export 关键词后,也就是:
export @register class Foo {
// ...
}
export
@Component({
// ...
})
class Bar {
// ...
}
一些应用场景
1. Log
function log(level: 'WARN' | 'INFO') {
return function<
This,
Args extends any[],
Return,
>(originalMethod: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext<This, any>) {
return function (this: This, ...args: Args): Return {
console.log(`${level}: log from ${context.name.toString()}`)
return originalMethod.apply(this, args)
}
}
}
class Foo {
@log('WARN')
bar() {
console.log('I\'m in bar')
}
}
const f = new Foo()
f.bar()
虽然不允许有装饰器参数,但是可以通过闭包来传递参数。
2. 泛型参数可以使用 const
当推断一个对象的类型时,TypeScript 倾向于选择更通用的类型:
interface HasNames { readonly names: string[] }
function getNamesExactly<T extends HasNames>(arg: T): T['names'] {
return arg.names
}
// 推断出类型 string,而不是 ['Alice', 'Bob', 'Eve'] 的元组
const names = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] })
如果想要推断出更具体的类型,TypeScript 4.x 只能是向给定的参数增加 as const
const names = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] } as const)
// ^^^^^^^^
但是在 TypeScript V5 中,我们可以给泛型参数增加 const
interface HasNames { names: readonly string[] }
function getNamesExactly<const T extends HasNames>(arg: T): T['names'] {
// ^^^^^
return arg.names
}
// 推断出来类型 readonly ['Alice', 'Bob', 'Eve']
// 无需使用 as const
const names = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] })
值得注意的是:const 修饰符无法推断可变的值:
declare function fnBad<const T extends string[]>(args: T): void
// T 依然是 string[],因为这里的 T 不是 readonly
fnBad(['a', 'b', 'c'])
但是如果改成 readonly 了呢?
declare function fnBad<const T extends readonly string[]>(args: T): void
// T 就会变为 readonly ['a', 'b', 'c']
fnBad(['a', 'b', 'c'])
3. 支持继承多个配置文件
{
"compilerOptions": {
"extends": ["./tsconfig1.json", "./tsconfig2.json"]
}
}
如果配置发生冲突,则会根据先后顺序覆盖,后面的配置会覆盖前面的配置。
4. 优化枚举
这有一个例子:
const BaseValue = 10
const Prefix = '/data'
const enum Values {
First = BaseValue, // 10
Second, // 11
Third, // 12
}
const enum Routes {
Parts = `${Prefix}/parts`, // "/data/parts"
Invoices = `${Prefix}/invoices`, // "/data/invoices"
}
TypeScript V5 中枚举值将支持表达式,但是表达式必须是常量之间进行计算,并且常量必须声明在枚举前
5. --moduleResolution 增加配置 bundler
在 TypeScript 4.7 中,为 --module 和 --moduleResolution 增加了 node16 和 nodenext 选项。主要是为了更好的模拟 ESM 快速查找文件的规则。但是这个规则存在很多限制,以至于其他工具没有真正的强制执行。
例如,在 Node 中执行一个 ESM 模块,必须指定文件扩展名:
// entry.mjs
import * as utils from './utils' // 错误,找不到文件
import * as utils from './utils.mjs' // 正确
对于 Node.js 和浏览器来说,这样的行为有助于更快找到文件。但是对于大部分使用打包器的开发者来说,它存在了一定的限制。
所以可以配置 --moduleResolution 为 bundler,来模拟诸如 Webpack、Vite、Rollup 等打包器的行为
6. customConditions
通过配置 customConditions,TypeScript 可以从 package.json -> exports/imports 中读取自定义的条件。
{
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"customConditions": ["production", "development"]
}
}
// package.json
{
"exports": {
".": {
"development": "./index.js",
"production": "./index.min.js"
}
}
}
7. --verbatimModuleSyntax
默认情况下,tsc 会检测你的导入,并检测是否需要省略,例如:
import { Type } from 'xx'
export function foo(type: Type) {}
当 tsc 检测出来导入了一个类型时,将会自动将该条导入省略:
// 编译为
export function foo(type) {}
大多数情况下,这个行为是没问题的。但是如果 Type 并不是一个类型,而是一个值的时候,我们可能会得到一个运行时错误。
所以 tsc 会考虑导入值的声明方式,如果 Car 被声明为类似于类的东西,那么它可以被保留在结果的 JavaScript 文件中。但如果 Car 只是被声明为类型别名或接口,那么 JavaScript 文件根本就不应该导出 Car。
虽然 tsc 可以跨文件获取信息,但是并不是所有的 TypeScript 编译器都能做到这件事情。所以 type 标识符是存在一定意义的:
// 可以完全丢弃
import type * as car from './car'
// 可以完全丢弃
import { type Car } from './car'
export { type Car } from './car'
不过在加上 type 标识符的默认情况下,tsc 的模块精简仍然可能会出现上述的问题,所以可以启用 --importsNotUsedAsValues 和 --preserveValueImports 来避免这种情况,启用 --isolatedModules 来在不同的编译器中正常工作。
TypeScript V5 引入了一个新的选项 --verbatimModuleSyntax,这个配置就很简单粗暴了:
// 整个丢弃
import type { A } from 'a'
// 重写为 'import { b } from "bcd";'
import { b, type c, type d } from 'bcd'
// 重写为 'import {} from "xyz";'
import { type xyz } from 'xyz'
任何不存在 type 的导入都会完全保留下来。由于 --verbatimModuleSyntax 的行为更加明确,--importsNotUsedAsValues 和 --preserveValueImports 将会被废弃。
8. 支持 export type *
// models/vehicles.ts
// main.ts
import { vehicles } from './models'
export class Spaceship {
// ...
}
// models/index.ts
export type * as vehicles from './spaceship'
function takeASpaceship(s: vehicles.Spaceship) {
// vehicles 可以被用作为类型
}
function makeASpaceship() {
return new vehicles.Spaceship()
// ^^^^^^^^
// vehicles 不能被用作为一个值
}
9. JSDoc 优化
支持 @satisfies
// @ts-check
/**
* @typedef CompilerOptions
* @prop {boolean} [strict]
* @prop {string} [outDir]
* @prop {string | string[]} [extends]
*/
/**
* @satisfies {CompilerOptions}
*/
const myCompilerOptions = {
outdir: '../lib',
// Error: outdir 与 outDir 不兼容
}
支持 @overload
/**
* @overload
* @param {string} value
* @return {void}
*/
/**
* @overload
* @param {number} value
* @param {number} [maximumFractionDigits]
* @return {void}
*/
/**
* @param {string | number} value
* @param {number} [maximumFractionDigits]
*/
function printValue(value, maximumFractionDigits) {
if (typeof value === 'number') {
const formatter = Intl.NumberFormat('en-US', {
maximumFractionDigits,
})
value = formatter.format(value)
}
console.log(value)
}
10. CLI 配置增加
现在可以使用 tsc --build 可以传递如下配置:
- declaration
- emitDeclarationOnly
- declarationMap
- soureMap
- inlineSourceMap
例如:
tsc --build -p ./my-project-dir --declaration
11. switch-case 优化
如果 switch case 的分支是字面量时,会检测每个字面量是否覆盖完毕,并提供一个快捷指令补全未覆盖的分支:
12. 破坏性变化和废弃的特性
Node.js 10.0.0 以下版本将不再支持。
lib.d.ts 更改
有关 DOM 的相关代码可能会产生问题,某些属性已经从数字转换为数字字面类型。
API 破坏性变化
详情看 API 破坏性变化
禁止关系操作符的隐式转换
如果你的代码中存在从字符串到数字的隐式转换,现在 TypeScript 会出现警告:
function func(ns: number | string) {
return ns * 4 // 错误:可能会出现隐式转换
}
在 V5 中,同样会检测 >、<、<=、和 >=:
function func(ns: number | string) {
return ns > 4 // 报错
// return +ns > 4 // 这个没问题
}
更好的枚举
在 TypeScript V5 中,修复了一些关于枚举的问题:
例如:
enum SomeEvenDigit {
Zero = 0,
Two = 2,
Four = 4
}
// 在 V5 会直接报错
const m: SomeEvenDigit = 1
对原有装饰器的更细致的类型检查
提升了 --experimentalDecorators 下的装饰器的类型检查,主要是对于构造函数参数上使用装饰器的类型。
具体可以看 这个 PR
废弃配置
在 V5 中,会逐渐废弃以下配置/配置值
- target: ES3
- out
- noImplicitUseStrict
- keyofStringsOnly
- suppressExcessPropertyErrors
- suppressImplicitAnyIndexErrors
- noStrictGenericChecks
- charset
- importsNotUsedAsValues
- preserveValueImports
- prepend in project references