commit 111aa6ef94663d4cd31cd2f776649283f61dc9e6 Author: ruanyf Date: Sun Jul 9 12:07:22 2023 +0800 docs: first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cdde9e --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +TypeScript 开源教程,介绍基本概念和用法,面向初学者。 diff --git a/chapters.yml b/chapters.yml new file mode 100644 index 0000000..a0a47b9 --- /dev/null +++ b/chapters.yml @@ -0,0 +1 @@ +- intro.md: 简介 diff --git a/docs/any.md b/docs/any.md new file mode 100644 index 0000000..c87acf3 --- /dev/null +++ b/docs/any.md @@ -0,0 +1,218 @@ +# any 类型,unknown 类型,never 类型 + +本章介绍 TypeScript 的三种特殊类型,它们可以作为学习 TypeScript 类型系统的起点。 + +## any 类型 + +### 基本含义 + +any 类型表示该位置不限制类型,任意类型的值都可以使用。 + +```typescript +let x:any; + +x = 1; // 正确 +x = 'foo'; // 正确 +x = true; // 正确 +``` + +上面示例中,变量`x`的类型是`any`,就可以被赋值为任意类型的值。 + +变量类型一旦设为`any`,TypeScript 实际上会关闭它的类型检查,即使有明显的类型错误,只要句法正确,都不会报错。 + +```typescript +let x:any = 'hello'; +x(1) // 正确 +x.foo = 100; // 正确 +``` + +上面示例中,变量`x`的值是一个字符串,但是把它当作函数调用,或者当作对象读取任意属性,TypeScript 编译时都不报错。原因就是`x`的类型是`any`,TypeScript 不对其进行类型检查。 + +实际项目中,`any`类型往往用于关闭某些变量的类型检查。由于这个原因,应该尽量避免使用`any`类型,否则就失去了使用 TypeScript 的意义。 + +这个类型的主要设计目的,是为了适配以前老的 JavaScript 项目的迁移。有些年代很久的大型 JavaScript 项目,尤其是别人的代码,很难为每一行适配正确的类型,这时你为那些类型复杂的变量加上`any`,TypeScript 编译时就不会报错。不过,这大概是`any`唯一的适用场合。 + +总之,TypeScript 认为,只要开发者使用了`any`类型,就表示开发者想要自己来处理这些代码,所以就不对`any`类型进行任何限制,怎么使用都可以。 + +### 类型推断问题 + +`any`类型的另一个出现场景是,对于那些开发者没有指定类型、TypeScript 必须自己推断类型的变量,如果这时无法推断出类型,TypeScript 就会认为该变量的类型是`any`。 + +```typescript +function add(x, y) { + return x + y; +} + +add(1, [1, 2, 3]) // 正确 +``` + +上面示例中,函数`add()`的参数变量`x`和`y`,都没有足够的信息,TypeScript 无法推断出它们的类型,就会认为这些变量的类型是`any`。以至于后面就不再对函数`add()`进行类型检查了,怎么用都可以。 + +这显然是很糟糕的情况,所以对于那些类型不明显的变量,一定要明确声明类型,防止推断为`any`。 + +TypeScript 提供了一个编译选项`--noImplicitAny`,只要打开这个选项,推断不出类型就会报错。 + +```bash +$ tsc --noImplicitAny app.ts +``` + +上面命令就使用了`--noImplicitAny`编译选项进行编译,这时上面的函数`add()`就会报错。 + +### 污染问题 + +`any`类型除了关闭类型检查,还有一个很大的问题,就是它会“污染”其他变量。它可以赋值给其他任何类型的变量(因为没有类型检查),导致其他变量出错。 + +```typescript +let x:any = 'hello'; +let y:number; + +y = x; // 正确 + +y * 123 // 正确 +y.toFixed() // 正确 +``` + +上面示例中,变量`x`的类型是`any`,实际的值是一个字符串。数值类型的变量`y`被赋值为`x`,也不会报错。然后,变量`y`继续进行各种数值运算,TypeScript 也检查不出错误,问题就这样留到运行时才会暴露。 + +污染其他具有正确类型的变量,把错误留到运行时,这就是不宜使用`any`类型的另一个主要原因。 + +### 顶端类型 + +前面说过,`any`类型可以被赋值为任何类型的值。在 TypeScript 语言中,如果类型`A`可以被赋值为类型`B`,那么类型`A`称为父类型,类型`B`称为子类型。TypeScript 的一个规则是,凡是可以使用父类型的地方,都可以使用子类型。 + +由于任何值都可以赋值给`any`类型,所以`any`类型是 TypeScript 所有其他类型的父类型,或者说,所有其他类型都是`any`的子类型。 + +所以,`any`类型是 TypeScript 的一个基础类型,包含了一切可能的值。所有其他类型都可以看成是它的衍生类型,它又被称为顶端类型(top type)。 + +## unknown 类型 + +为了解决`any`类型“污染”其他变量的问题,TypeScript 3.0 引入了[`unknown`类型](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html#new-unknown-top-type)。它与`any`含义相同,表示类型不确定,但是使用上有一些限制,可以视为严格版的`any`。 + +`unknown`跟`any`的相似之处,在于所有类型的值都可以分配给`unknown`类型。 + +```typescript +let x:unknown; + +x = true; // 正确 +x = 42; // 正确 +x = 'Hello World'; // 正确 +``` + +上面示例中,变量`value`的类型是`unknown`,可以赋值为各种类型的值。这与`any`的行为一致。 + +`unknown`类型跟`any`类型的不同之处在于,它不能直接使用。主要有以下几个限制。 + +首先,`unknown`类型的变量,不能直接赋值给其他类型的变量(除了`any`类型和`unknown`类型)。 + +```typescript +let v:unknown = 123; + +let v1:boolean = v; // 报错 +let v2:number = v; // 报错 +``` + +上面示例中,变量`v`是`unknown`类型,赋值给`any`和`unknown`以外类型的变量都会报错,这就避免了污染问题,从而克服了`any`类型的一大缺点。 + +另外,也不能直接调用`unknown`类型变量的方法和属性。 + +```typescript +let v1:unknown = { foo: 123 }; +v1.foo // 报错 + +let v2:unknown = 'hello'; +v2.trim() // 报错 + +let v3:unknown = (n = 0) => n + 1; +v3() // 报错 +``` + +上面示例中,直接调用`unknown`类型变量的属性和方法,或者直接当作函数执行,都会报错。 + +再次,`unknown`类型变量能够进行的运算是有限的,只能进行比较运算(运算符`==`、`===`、`!=`、`!==`、`||`、`&&`、`?`)、取反运算(运算符`!`)、`typeof`运算符和`instanceof`运算符这几种,其他运算都会报错。 + +```typescript +let a:unknown = 1; + +a + 1 // 报错 +a === 1 // 正确 +``` + +上面示例中,`unknown`类型的变量`a`进行加法运算会报错,因为这是不允许的运算。但是,进行比较运算就是可以的。 + +那么,怎么才能使用`unknown`类型变量呢? + +答案是只有经过“类型细化”(refine),`unknown`类型变量才可以使用。所谓“类型细化”,就是缩小`unknown`变量的类型范围,确保不会出错。 + +```typescript +let a:unknown = 1; + +if (typeof a === 'number') { + let r = a + 10; // 正确 +} +``` + +上面示例中,`unknown`类型的变量`a`经过`typeof`运算以后,能够确定实际类型是`number`,就能用于加法运算了。这就是“类型细化”,即将一个不确定的类型细化为更明确的类型。 + +下面是另一个例子。 + +```typescript +let s:unknown = 'hello'; + +if (typeof s === 'string') { + s.length; // 正确 +} +``` + +上面示例中,确定变量`s`的类型为字符串以后,才能调用它的`length`属性。 + +这样设计的目的是,只有明确`unknown`变量的实际类型,才允许使用它,防止像`any`那样可以随意乱用,“污染”其他变量。类型细化以后再使用,就不会报错。 + +总之,`unknown`可以看作是更安全的`any`,凡是需要设为`any`的地方,通常都应该优先考虑设为`unknown`。 + +由于`unknown`类型的变量也可以被赋值为任意其他类型,所以其他类型(除了`any`)都可以视为它的子类型。所以,它和`any`一样都属于 TypeScript 的顶端类型。 + +## never 类型 + +类型也可能是空集,即不包含任何类型。为了逻辑的完整性,TypeScript 把这种情况也当作一种类型,叫做`never`类型。 + +`never`类型表示不可能的类型,也就是不可能有任何值属于这个类型。 + +```typescript +let x: never; +``` + +上面示例中,变量`x`的类型是`never`,就不可能赋给它任何值,都会报错。 + +`never`类型的使用场景,主要是在一些类型运算之中,保证类型运算的完整性,详见后面章节。另外,不可能返回值的函数,返回值的类型就可以写成`never`,详见《函数》一章。 + +如果一个变量可能有多种类型(即联合类型),通常需要使用分支处理每一种类型。这时,处理所有可能的类型之后,剩余的情况就属于`never`类型。 + +```typescript +function fn(x:string|number) { + if (typeof x === 'string') { + // ... + } else if (typeof x === 'number') { + // ... + } else { + x; // never 类型 + } +} +``` + +上面示例中,参数变量`x`可能是字符串,也可能是数组,判断了这两种情况后,剩下的`else`分支里面,`x`就是`never`类型了。 + +任何类型的变量都可以赋值为`never`类型。 + +```typescript +function f():never { + throw new Error('Error'); +} + +let v1:number = f(); // 正确 +let v2:string = f(); // 正确 +let v3:string = f(); // 正确 +``` + +上面示例中,函数`f()`会抛错,所以返回值类型可以写成`never`,即不可能返回任何值。各种其他类型的变量都可以赋值为`f()`的运行结果(`never`类型)。 + +前面说过,在 TypeScript 中,如果类型`A`可以被赋值为类型`B`,那么类型`B`就称为类型`A`的子类型。所以,`never`类型可以视为所有其他类型的子类型,表示不包含任何可能的值,这种情况叫做“尾端类型”(bottom type),`never`是 TypeScript 唯一的尾端类型。 diff --git a/docs/array.md b/docs/array.md new file mode 100644 index 0000000..984d743 --- /dev/null +++ b/docs/array.md @@ -0,0 +1,234 @@ +# TypeScript 的数组类型 + +JavaScript 数组在 TypeScript 里面分成两种类型,分别是数组(array)和元组(tuple)。 + +本章介绍数组,下一章介绍元组。 + +## 简介 + +TypeScript 数组有一个根本特征:所有成员的类型必须相同,但是成员数量是不确定的,可以是无限数量的成员,也可以是零成员。 + +数组的类型有两种写法。第一种写法是在数组成员的类型后面,加上一对方括号。 + +```typescript +let arr:number[] = [1, 2, 3]; +``` + +上面示例中,数组`arr`的类型是`number[]`,其中`number`表示成员类型是`number`。 + +如果数组成员的类型比较复杂,可以写在圆括号里面。 + +```typescript +let arr:(number|string)[]; +``` + +上面示例中,数组`arr`的成员类型是`number|string`。 + +这个例子里面的圆括号是必须的,否则因为竖杠(`|`)的优先级低于`[]`,TypeScript 会把`number|string[]`理解成`number`和`string[]`的联合类型。 + +如果数组成员可以是任意类型,则写成`any[]`。当然,这种写法是应该避免的。 + +```typescript +let arr:any[]; +``` + +数组类型的第二种写法是使用 TypeScipt 内置的 Array 接口。 + +```typescript +let arr:Array = [1, 2, 3]; +``` + +上面示例中,数组`arr`的类型是`Array`,其中`number`表示成员类型是`number`。 + +这种写法对于成员类型比较复杂的数组,代码可读性会稍微好一些。 + +```typescript +let arr:Array; +``` + +这种写法本质上属于泛型,这里只要知道怎么写就可以了,详细解释参见《泛型》一章。另外,数组类型还有第三种写法,因为很少用到,本章就省略了,详见《interface 接口》一章。 + +数组类型声明了以后,成员数量是不限制的,任意数量的成员都可以,也可以是空数组。 + +```typescript +let arr:number[]; +arr = []; +arr = [1]; +arr = [1, 2]; +arr = [1, 2, 3]; +``` + +上面示例中,数组`arr`无论有多少个成员,都是正确的。 + +这种规定的隐藏含义就是,数组的成员是可以动态变化的。 + +```typescript +let arr:number[] = [1, 2, 3]; + +arr[3] = 4; +arr.length = 2; + +arr // [1, 2] +``` + +上面示例中,数组增加成员或减少成员,都是可以的。 + +正是由于成员数量可以动态变化,所以 TypeScript 不会对数组边界进行检查,如果越界访问数组并不会报错。 + +```typescript +let arr:number[] = [1, 2, 3]; +let foo = arr[3]; // 正确 +``` + +上面示例中,变量`foo`的值是一个不存在的数组成员,TypeScript 并不会报错。 + +TypeScript 允许使用方括号读取数组成员的类型。 + +```typescript +type Names = string[]; +type Name = Names[0]; // string +``` + +上面示例中,类型`Names`是字符串数组,那么`Names[0]`返回的类型就是`string`。 + +由于数组成员的索引类型都是`number`,所以读取成员类型也可以写成下面这样。 + +```typescript +type Names = string[]; +type Name = Names[number]; // string +``` + +上面示例中,`Names[number]`表示元组`Names`所有数值索引的成员类型,所以返回`string`。 + +## 数组的类型推断 + +如果数组变量没有声明类型,TypeScript 就会推断数组成员的类型。 + +这时,推断行为会因为值的不同,而有所不同。 + +如果变量的初始值是空数组,那么 TypeScript 会推断数组类型是`any[]`。 + +```typescript +// 推断为 any[] +const arr = []; +``` + +后面,为这个数组赋值时,TypeScript 会自动更新类型推断。 + +```typescript +// 推断为 any[] +const arr = []; + +// 推断类型为 number[] +arr.push(123); + +// 推断类型为 (string | number)[] +arr.push('abc'); +``` + +上面示例中,数组变量`arr`的初始值是空数值,然后随着新成员的加入,TypeScript 会自动修改推断的数组类型。 + +但是,类型推断的自动更新只发生初始值为空数组的情况。如果初始值不是空数组,类型推断就不会更新。 + +```typescript +// 推断类型为 number[] +const arr = [123]; + +arr.push('abc'); // 报错 +``` + +上面示例中,数组变量`arr`的初始值是`[123]`,TypeScript 就推断成员类型为`number`。新成员如果不是这个类型,TypeScript 就会报错,而不会更新类型推断。 + +## 只读数组,const 断言 + +JavaScript 规定,`const`命令声明的数组变量是可以改变成员的。 + +```typescript +const arr = [0, 1]; +arr[0] = 2; +``` + +上面示例中,修改`const`命令声明的数组的成员是允许的。 + +但是,很多时候确实有声明只读数组的需求,即不允许变动数组成员。 + +TypeScript 允许声明只读数组,方法是在在数组类型前面加上`readonly`关键字。 + +```typescript +const arr:readonly number[] = [0, 1]; + +arr[1] = 2; // 报错 +arr.push(3); // 报错 +delete arr[0]; // 报错 +``` + +上面示例中,`arr`是一个只读数组,删除、修改、新增数组成员都会报错。 + +TypeScript 将`readonly number[]`与`number[]`视为两种不一样的类型,后者是前者的子类型。 + +这是因为只读数组没有`pop()`、`push()`之类会改变原数组的方法,所以`number[]`的方法数量要多于`readonly number[]`,这意味着`number[]`其实是`readonly number[]`的子类型。 + +我们知道,子类型继承了父类型的所有特征,并加上了自己的特征,所以子类型`number[]`可以用于所有使用父类型的场合,反过来就不行。 + +```typescript +let a1:number[] = [0, 1]; +let a2:readonly number[] = a1; // 正确 + +a1 = a2; // 报错 +``` + +上面示例中,子类型`number[]`可以赋值给父类型`readonly number[]`,但是反过来就会报错。 + +由于只读数组是数组的父类型,所以它不能代替数组。这一点很容易产生令人困惑的报错。 + +```typescript +function getSum(s:number[]) { + // ... +} + +const arr:readonly number[] = [1, 2, 3]; + +getSum(arr) // 报错 +``` + +上面示例中,函数`getSum()`的参数`s`是一个数组,传入只读数组就会报错。原因就是只读数组是数组的父类型,父类型不能替代子类型。这个问题的解决方法是使用类型断言`getSum(arr as number[])`,详见《类型断言》一章。 + +注意,`readonly`关键字不能与数组的泛型写法一起使用。 + +```typescript +// 报错 +const arr:readonly Array = [0, 1]; +``` + +上面示例中,`readonly`与数组的泛型写法一起使用,就会报错。 + +实际上,TypeScript 提供了两个专门的泛型,用来生成只读数组的类型。 + +```typescript +const a1:ReadonlyArray = [0, 1]; + +const a2:Readonly = [0, 1]; +``` + +上面示例中,泛型`ReadonlyArray`和`Readonly`都可以用来生成只读数组类型。两者尖括号里面的写法不一样,`Readonly`的尖括号里面是整个数组(`number[]`),而`ReadonlyArray`的尖括号里面是数组成员(`number`)。 + +只读数组还有一种声明方法,就是使用“const 断言”。 + +```typescript +const arr = [0, 1] as const; + +arr[0] = [2]; // 报错 +``` + +上面示例中,`as const`告诉 TypeScript,推断类型时要把变量`arr`推断为只读数组,从而使得数组成员无法改变。 + +## 多维数组 + +TypeScript 使用`T[][]`的形式,表示二维数组,`T`是最底层数组成员的类型。 + +```typescript +var multi:number[][] = + [[1,2,3], [23,24,25]]; +``` + +上面示例中,变量`multi`的类型是`number[][]`,表示它是一个二维数组,最底层的数组成员类型是`number`。 diff --git a/docs/assert.md b/docs/assert.md new file mode 100644 index 0000000..dca9dc7 --- /dev/null +++ b/docs/assert.md @@ -0,0 +1,587 @@ +# TypeScript 的类型断言 + +## 简介 + +对于没有类型声明的值,TypeScript 会进行类型推断,很多时候得到的结果,未必是开发者想要的。 + +```typescript +type T = 'a'|'b'|'c'; +let foo = 'a'; + +let bar:T = foo; // 报错 +``` + +上面示例中,最后一行报错,原因是 TypeScript 推断变量`foo`的类型是`string`,而变量`bar`的类型是`'a'|'b'|'c'`,前者是后者的父类型。父类型不能赋值给子类型,所以就报错了。 + +这时,TypeScript 提供了“类型断言”这样一种手段,允许开发者在代码中“断言”某个值的类型,提示编译器此处的值是什么类型。TypeScript 一旦发现存在类型断言,就不再对该值进行类型推断,而是直接采用断言给出的类型。 + +这种做法的实质是,允许开发者在某个位置“绕过”编译器的类型推断,使其能够通过类型检查,避免编译器报错。这样虽然削弱了 TypeScript 类型系统的严格性,但是为开发者带来了方便,毕竟开发者比编译器更了解自己的代码。 + +回到上面的例子,解决方法就是进行类型断言,在赋值时断言变量`foo`的类型。 + +```typescript +type T = 'a'|'b'|'c'; + +let foo = 'a'; +let bar:T = foo as T; // 正确 +``` + +上面示例中,最后一行的`foo as T`表示告诉编译器,变量`foo`的类型断言为`T`,所以这一行不再需要类型推断了,编译器直接把`foo`的类型当作`T`,就不会报错了。 + +总之,类型断言并不是真的改变一个值的类型,而是提示编译器,应该如何处理这个值。 + +类型断言有两种语法。 + +```typescript +// 语法一:<类型>值 +value + +// 语法二:值 as 类型 +value as Type +``` + +上面两种语法是等价的,`value`表示值,`Type`表示类型。早期只有语法一,后来因为 TypeScript 开始支持 React 的 JSX 语法(尖括号表示 HTML 元素),为了避免两者冲突,就引入了语法二。目前,推荐使用语法二。 + +```typescript +// 语法一 +let bar:T = foo; + +// 语法二 +let bar:T = foo as T; +``` + +上面示例是两种类型断言的语法,其中的语法一因为跟 JSX 语法冲突,使用时必须关闭 TypeScript 的 React 支持,否则会无法识别。由于这个原因,现在一般都使用语法二。 + +下面看一个例子。《对象》一章提到过,对象类型有严格字面量检查,如果存在额外的属性会报错。 + +```typescript +// 报错 +const p:{ x: number } = { x: 0, y: 0 }; +``` + +上面示例中,等号右侧是一个对象字面量,多出了属性`y`,导致报错。解决方法就是使用类型断言,可以用两种不同的断言。 + +```typescript +// 正确 +const p0:{ x: number } = + { x: 0, y: 0 } as { x: number }; + +// 正确 +const p1:{ x: number } = + { x: 0, y: 0 } as { x: number; y: number }; +``` + +上面示例中,两种类型断言都是正确的。第一种断言将类型改成与等号左边一致,第二种断言使得等号右边的类型是左边类型的子类型,子类型可以赋值给父类型,同时因为存在类型断言,就没有严格字面量检查了,所以不报错。 + +下面是一个网页编程的实际例子。 + +```typescript +const username = document.getElementById('username'); + +if (username) { + (username as HTMLInputElement).value; // 正确 +} +``` + +上面示例中,变量`username`的类型是`HTMLElement|null`,排除了`null`的情况以后,HTMLElement 类型是没有`value`属性的。如果`username`是一个输入框,那么就可以通过类型断言,将它的类型改成`HTMLInputElement`,就可以读取`value`属性。 + +注意,上例的类型断言的圆括号是必需的,否则`username`会被断言成`HTMLInputElement.value`,从而报错。 + +类型断言不应滥用,因为它改变了 TypeScript 的类型检查,很可能埋下错误的隐患。 + +```typescript +const data:object = { + a: 1, + b: 2, + c: 3 +}; + +data.length; // 报错 + +(data as Array).length; // 正确 +``` + +上面示例中,变量`data`是一个对象,没有`length`属性。但是通过类型断言,可以将它的类型断言为数组,这样使用`length`属性就能通过类型检查。但是,编译后的代码在运行时依然会报错,所以类型断言可以让错误的代码通过编译。 + +类型断言的一大用处是,指定 unknown 类型的变量的具体类型。 + +```typescript +const value:unknown = 'Hello World'; + +const s1:string = value; // 报错 +const s2:string = value as string; // 正确 +``` + +上面示例中,unknown 类型的变量`value`不能直接赋值给其他类型的变量,但是可以将它断言为其他类型,这样就可以赋值给别的变量了。 + +另外,类型断言也适合指定联合类型的值的具体类型。 + +```typescript +const s1:number|string = 'hello'; +const s2:number = s1 as number; +``` + +上面示例中,变量`s1`是联合类型,可以断言其为联合类型里面的一种具体类型,再将其赋值给变量`s2`。 + +## 类型断言的前提 + +类型断言并不意味着,可以把某个值断言为任意类型。 + +```typescript +const n = 1; +const m:string = n as string; // 报错 +``` + +上面示例中,变量`n`是数值,无法把它断言成字符串,TypeScript 会报错。 + +类型断言的使用前提是,值的实际类型与断言的类型必须满足一个条件。 + +```typescript +expr as T +``` + +上面代码中,`expr`是实际的值,`T`是类型断言,它们必须满足下面的条件:`expr`是`T`的子类型,或者`T`是`expr`的子类型。 + +也就是说,类型断言要求实际的类型与断言的类型兼容,实际类型可以断言为一个更加宽泛的类型(父类型),也可以断言为一个更加精确的类型(子类型),但不能断言为一个完全无关的类型。 + +但是,如果真的要断言成一个完全无关的类型,也是可以做到的。那就是连续进行两次类型断言,先断言成 unknown 类型或 any 类型,然后再断言为目标类型。因为`any`类型和`unknown`类型是所有其他类型的父类型,所以可以作为两种完全无关的类型的中介。 + +```typescript +// 或者写成 expr +expr as unknown as T +``` + +上面代码中,`expr`连续进行了两次类型断言,第一次断言为`unknown`类型,第二次断言为`T`类型。这样的话,`expr`就可以断言成任意类型`T`,而不报错。 + +下面是本小节开头那个例子的改写。 + +```typescript +const n = 1; +const m:string = n as unknown as string; // 正确 +``` + +上面示例中,通过两次类型断言,变量`n`的类型就从数值,变成了完全无关的字符串,从而赋值时不会报错。 + +## as const 断言 + +如果没有声明变量类型,let 命令声明的变量,会被类型推断为 TypeScript 内置的基本类型之一;const 命令声明的变量,则被推断为值类型常量。 + +```typescript +// 类型推断为 string +let s1 = 'JavaScript'; + +// 类型推断为 JavaScript +const s2 = 'JavaScript'; +``` + +上面示例中,变量`s1`的类型被推断为`string`,变量`s2`的类型推断为值类型`JavaScript`。后者是前者的子类型,相当于 const 命令有更强的限定作用,可以缩小变量的类型范围。 + +有些时候,let 变量会出现一些意想不到的报错,变更成 const 变量就能消除报错。 + +```typescript +let s = 'JavaScript'; + +type Lang = + |'JavaScript' + |'TypeScript' + |'Python'; + +function setLang(language:Lang) { + /* ... */ +} + +setLang(s); // 报错 +``` + +上面示例中,最后一行报错,原因是函数`setLang()`的参数`language`类型是`Lang`,这是一个联合类型。但是,传入的字符串`s`的类型被推断为`string`,属于`Lang`的父类型。父类型不能替代子类型,导致报错。 + +一种解决方法就是把 let 命令改成 const 命令。 + +```typescript +const s = 'JavaScript'; +``` + +这样的话,变量`s`的类型就是值类型`JavaScript`,它是联合类型`Lang`的子类型,传入函数`setLang()`就不会报错。 + +另一种解决方法是使用类型断言。TypeScript 提供了一种特殊的类型断言`as const`,用于告诉编译器,推断类型时,可以将这个值推断为常量,即把 let 变量断言为 const 变量,从而把内置的基本类型变更为值类型。 + +```typescript +let s = 'JavaScript' as const; +setLang(s); // 正确 +``` + +上面示例中,变量`s`虽然是用 let 命令声明的,但是使用了`as const`断言以后,就等同于是用 const 命令声明的,变量`s`的类型会被推断为值类型`JavaScript`。 + +使用了`as const`断言以后,let 变量就不能再改变值了。 + +```typescript +let s = 'JavaScript' as const; +s = 'Python'; // 报错 +``` + +上面示例中,let 命令声明的变量`s`,使用`as const`断言以后,就不能改变值了,否则报错。 + +注意,`as const`断言只能用于字面量,不能用于变量。 + +```typescript +let s = 'JavaScript'; +setLang(s as const); // 报错 +``` + +上面示例中,`as const`断言用于变量`s`,就报错了。 + +另外,`as const`也不能用于表达式。 + +```typescript +let s = ('Java' + 'Script') as const; // 报错 +``` + +上面示例中,`as const`用于表达式,导致报错。 + +`as const`也可以写成前置的形式。 + +```typescript +// 后置形式 +expr as const + +// 前置形式 +expr +``` + +`as const`断言可以用于整个对象,也可以用于对象的单个属性,这时它的类型缩小效果是不一样的。 + +```typescript +const v1 = { + x: 1, + y: 2, +}; // 类型是 { x: number; y: number; } + +const v2 = { + x: 1 as const, + y: 2, +}; // 类型是 { x: 1; y: number; } + +const v3 = { + x: 1, + y: 2, +} as const; // 类型是 { readonly x: 1; readonly y: 2; } +``` + +上面示例中,第二种写法是对属性`x`缩小类型,第三种写法是对整个对象缩小类型。 + +总之,`as const`会将字面量的类型断言为不可变类型,缩小成 TypeScript 允许的最小类型。 + +下面是数组的例子。 + +```typescript +// a1 的类型推断为 number[] +const a1 = [1, 2, 3]; + +// a2 的类型推断为 readonly [1, 2, 3] +const a2 = [1, 2, 3] as const; +``` + +上面示例中,数组字面量使用`as const`断言后,类型推断就变成了只读元组。 + +由于`as const`会将数组变成只读元组,所以很适合用于函数的 rest 参数。 + +```typescript +function add(x: number, y: number) { + return x + y; +} + +const nums = [1, 2]; +const total = add(...nums); // 报错 +``` + +上面示例中,变量`nums`的类型推断为`number[]`,导致使用扩展运算符`...`传入函数`add()`会报错,因为`add()`只能接受两个参数,而`...nums`并不能保证参数的个数。 + +事实上,对于固定参数个数的函数,如果传入的参数包含扩展运算符,那么扩展运算符只能用于元组。只有当函数定义使用了 rest 参数,扩展运算符才能用于数组。 + +解决方法就是使用`as const`断言,将数组变成元组。 + +```typescript +const nums = [1, 2] as const; +const total = add(...nums); // 正确 +``` + +上面示例中,使用`as const`断言后,变量`nums`的类型会被推断为`readonly [1, 2]`,使用扩展运算符展开后,正好符合函数`add()`的参数类型。 + +Enum 成员也可以使用`as const`断言。 + +```typescript +enum Foo { + X, + Y, +} +let e1 = Foo.X; // Foo +let e2 = Foo.X as const; // Foo.X +``` + +上面示例中,如果不使用`as const`断言,变量`e1`的类型被推断为整个 Enum 类型;使用了`as const`断言以后,变量`e2`的类型被推断为 Enum 的某个成员,这意味着它不能变更为其他成员。 + +## 非空断言 + +对于那些可能为空的变量(即可能等于`undefined`或`null`),TypeScript 提供了非空断言,保证这些变量不会为空,写法是在变量名后面加上感叹号`!`。 + +```typescript +function f(x?:number|null) { + validateNumber(x); + console.log(x!.toFixed()); +} + +function validateNumber(e?:number|null) { + // 如果 e 不是数值,就抛出错误 +} +``` + +上面示例中,变量`x`的类型是`number|null`,即可能为空。如果为空,就不存在`.toFixed()`方法,编译时会报错。但是,开发者有时可以确认,变量`x`不会为空,这时就可以使用非空断言,为函数体内部的变量`x`加上后缀`!`,编译就不会报错了。 + +非空断言在实际编程中很有用,有时可以省去一些额外的判断。 + +```typescript +const root = document.getElementById('root'); + +// 报错 +root.addEventListener('click', e => { + /* ... */ +}); +``` + +上面示例中,`getElementById()`有可能返回空值`null`,即变量`root`可能为空,这时对它调用`addEventListener()`方法就会报错,通不过编译。但是一般来说,开发者可以确认`root`元素肯定会在网页中存在,这时就可以使用非空断言。 + +```typescript +const root = document.getElementById('root')!; +``` + +上面示例中,`getElementById()`方法加上后缀`!`,表示这个方法肯定返回非空结果。 + +非空断言会造成安全隐患,只有在确定一个表达式的值不为空时才能使用。比较保险的做法还是手动检查一下是否为空。 + +```typescript +const root = document.getElementById('root'); + +if (root === null) { + throw Error('Unable to find DOM element #root'); +} + +root.addEventListener('click', e => { + /* ... */ +}); +``` + +上面示例中,如果`root`为空会抛错,比非空断言更保险一点。 + +非空断言还可以用于赋值断言。TypeScript 有一个编译设置,要求类的属性必须初始化(即有初始值),如果不对属性赋值就会报错。 + +```typescript +class Point { + x:number; // 报错 + y:number; // 报错 + + constructor(x:number, y:number) { + // ... + } +} +``` + +上面示例中,属性`x`和`y`会报错,因为 TypeScript 认为它们没有初始化。 + +这时就可以使用非空断言,表示这两个属性肯定有值,这样就不会报错了。 + +```typescript +class Point { + x!:number; // 正确 + y!:number; // 正确 + + constructor(x:number, y:number) { + // ... + } +} +``` + +另外,非空断言只有在打开编译选项`strictNullChecks`时才有意义。如果不打开这个选项,编译器就不会检查某个变量是否可能为`undefined`或`null`。 + +## 断言函数 + +断言函数是一种特殊函数,用于保证函数参数符合某种类型。如果函数参数达不到要求,就会抛出错误,中断程序执行;如果达到要求,就不进行任何操作,让代码按照正常流程运行。 + +```typescript +function isString(value) { + if (typeof value !== 'string') + throw new Error('Not a string'); +} +``` + +上面示例中,函数`isString()`就是一个断言函数,用来保证参数`value`是一个字符串。 + +下面是它的用法。 + +```typescript +const aValue:string|number = 'Hello'; +isString(aValue); +``` + +上面示例中,变量`aValue`可能是字符串,也可能是数组。但是,通过调用`isString()`,后面的代码就可以确定,变量`aValue`一定是字符串。 + +断言函数的类型可以写成下面这样。 + +```typescript +function isString(value:unknown):void { + if (typeof value !== 'string') + throw new Error('Not a string'); +} +``` + +上面代码中,函数参数`value`的类型是`unknown`,返回值类型是`void`,即没有返回值。可以看到,单单从这样的类型声明,很难看出`isString()`是一个断言函数。 + +为了更清晰地表达断言函数,TypeScript 3.7 引入了新的类型写法。 + +```typescript +function isString(value:unknown):asserts value is string { + if (typeof value !== 'string') + throw new Error('Not a string'); +} +``` + +上面示例中,函数`isString()`的返回值类型写成`asserts value is string`,其中`asserts`和`is`都是关键词,`value`是函数的参数名,`string`是函数参数的预期类型。它的意思是,该函数用来断言参数`value`的类型是`string`,如果达不到要求,程序就会在这里中断。 + +使用了断言函数的新写法以后,TypeScript 就会自动识别,只要执行了该函数,对应的变量都为断言的类型。 + +注意,函数返回值的断言写法,只是用来更清晰地表达函数意图,真正的检查是需要开发者自己部署的。而且,如果内部的检查与断言不一致,TypeScript 也不会报错。 + +```typescript +function isString(value:unknown):asserts value is string { + if (typeof value !== 'number') + throw new Error('Not a number'); +} +``` + +上面示例中,函数的断言是参数`value`类型为字符串,但是实际上,内部检查的却是它是否为数值,如果不是就抛错。这段代码能够正常通过编译,表示 TypeScript 并不会检查断言与实际的类型检查是否一致。 + +另外,断言函数的`asserts`语句等同于`void`类型,所以如果返回除了`undefined`和`null`以外的值,都会报错。 + +```typescript +function isString(value:unknown):asserts value is string { + if (typeof value !== 'string') + throw new Error('Not a string'); + return true; // 报错 +} +``` + +上面示例中,断言函数返回了`true`,导致报错。 + +下面是另一个例子。 + +```typescript +type AccessLevel = 'r' | 'w' | 'rw'; + +function allowsReadAccess( + level:AccessLevel +):asserts level is 'r' | 'rw' { + if (!level.includes('r')) + throw new Error('Read not allowed'); +} +``` + +上面示例中,函数`allowsReadAccess()`用来断言参数`level`一定等于`r`或`rw`。 + +如果要断言参数非空,可以使用工具类型`NonNullable`。 + +```typescript +function assertIsDefined( + value:T +):asserts value is NonNullable { + if (value === undefined || value === null) { + throw new Error(`${value} is not defined`) + } +} +``` + +上面示例中,工具类型`NonNullable`对应类型`T`去除空类型后的剩余类型。 + +如果要将断言函数用于函数表达式,可以采用下面的写法。 + +```typescript +// 写法一 +const assertIsNumber = ( + value:unknown +):asserts value is number => { + if (typeof value !== 'number') + throw Error('Not a number'); +}; + +// 写法二 +type AssertIsNumber = + (value:unknown) => asserts value is number; + +const assertIsNumber:AssertIsNumber = (value) => { + if (typeof value !== 'number') + throw Error('Not a number'); +}; +``` + +注意,断言函数与类型保护函数(type guard)是两种不同的函数。它们的区别是,断言函数不返回值,而类型保护函数总是返回一个布尔值。 + +```typescript +function isString( + value:unknown +):value is string { + return typeof value === 'string'; +} +``` + +上面示例就是一个类型保护函数`isString()`,作用是检查参数`value`是否为字符串。如果是的,返回`true`,否则返回`false`。该函数的返回值类型是`value is string`,其中的`is`是一个类型运算符,如果左侧的值符合右侧的类型,则返回`true`,否则返回`false`。 + +如果要断言某个参数保证为真(即不等于`false`、`undefined`和`null`),TypeScript 提供了断言函数的一种简写形式。 + +```typescript +function assert(x:unknown):asserts x { + // ... +} +``` + +上面示例中,函数`assert()`的断言部分,`asserts x`省略了谓语和宾语,表示参数`x`保证为真(`true`)。 + +同样的,参数为真的实际检查需要开发者自己实现。 + +```typescript +function assert(x:unknown):asserts x { + if (!x) { + throw new Error(`${x} should be a truthy value.`); + } +} +``` + +这种断言函数的简写形式,通常用来检查某个操作是否成功。 + +```typescript +type Person = { + name: string; + email?: string; +}; + +function loadPerson(): Person | null { + return null; +} + +let person = loadPerson(); + +function assert( + condition:unknown, message:string +):asserts condition { + if (!condition) throw new Error(message); +} + +// Error: Person is not defined +assert(person, 'Person is not defined'); +console.log(person.name); +``` + +上面示例中,只有`loadPerson()`返回结果为真(即操作成功),`assert()`才不会报错。 + +## 参考链接 + +- [Const Assertions in Literal Expressions in TypeScript](https://mariusschulz.com/blog/const-assertions-in-literal-expressions-in-typescript), Marius Schulz +- [Assertion Functions in TypeScript](https://mariusschulz.com/blog/assertion-functions-in-typescript), Marius Schulz +- [Assertion functions in TypeScript](https://blog.logrocket.com/assertion-functions-typescript/), Matteo Di Pirro diff --git a/docs/basic.md b/docs/basic.md new file mode 100644 index 0000000..2ba6c35 --- /dev/null +++ b/docs/basic.md @@ -0,0 +1,335 @@ +# TypeScript 基本用法 + +本章介绍 TypeScript 的一些最基本的语法和用法。 + +## 类型声明 + +TypeScript 代码最明显的特征,就是为 JavaScript 变量加上了类型声明。 + +```typescript +let foo:string; +``` + +上面示例中,变量`foo`的后面使用冒号,声明了它的类型为`string`。 + +类型声明的写法,一律为在标识符后面添加“冒号 + 类型”。函数参数和返回值,也是这样来声明类型。 + +```typescript +function toString(num:number):string { + return String(num); +} +``` + +上面示例中,函数`toString()`的参数`num`的类型是`number`。参数列表的圆括号后面,声明了返回值的类型是`string`。更详细的介绍,参见《函数》一章。 + +注意,变量的值应该与声明的类型一致,如果不一致,TypeScript 就会报错。 + +```typescript +// 报错 +let foo:string = 123; +``` + +上面示例中,变量`foo`的类型是字符串,但是赋值为数值`123`,TypeScript 就报错了。 + +另外,TypeScript 规定,变量只有赋值后才能使用,否则就会报错。 + +```typescript +let x:number; +console.log(x) // 报错 +``` + +上面示例中,变量`x`没有赋值就被读取,导致报错。而 JavaScript 允许这种行为,不会报错,没有赋值的变量会返回`undefined`。 + +## 类型推断 + +类型声明并不是必需的,如果没有,TypeScript 会自己推断类型。 + +```typescript +let foo = 123; +``` + +上面示例中,变量`foo`并没有类型声明,TypeScript 就会推断它的类型。由于它被赋值为一个数值,因此 TypeScript 推断它的类型为`number`。 + +后面,如果变量`foo`更改为其他类型的值,跟推断的类型不一致,TypeScript 就会报错。 + +```typescript +let foo = 123; +foo = 'hello'; // 报错 +``` + +上面示例中,变量`foo`的类型推断为`number`,后面赋值为字符串,TypeScript 就报错了。 + +TypeScript 也可以推断函数的返回值。 + +```typescript +function toString(num:number) { + return String(num); +} +``` + +上面示例中,函数`toString()`没有声明返回值的类型,但是 TypeScript 推断返回的是字符串。正是因为 TypeScript 的类型推断,所以函数返回值的类型通常是省略不写的。 + +从这里可以看到,TypeScript 的设计思想是,类型声明是可选的,你可以加,也可以不加。即使不加类型声明,依然是有效的 TypeScript 代码,只是这时不能保证 TypeScript 会正确推断出类型。由于这个原因。所有 JavaScript 代码都是合法的 TypeScript 代码。 + +这样设计还有一个好处,将以前的 JavaScript 项目改为 TypeScript 项目时,你可以逐步地为老代码添加类型,即使有些代码没有添加,也不会无法运行。 + +## TypeScript 的编译 + +JavaScript 的运行环境(浏览器和 Node.js)不认识 TypeScript 代码。所以,TypeScript 项目要想运行,必须先转为 JavaScript 代码,这个代码转换的过程就叫做“编译”(compile)。 + +TypeScript 官方没有做运行环境,只提供编译器。编译时,会将类型声明和类型相关的代码全部删除,只留下能运行的 JavaScript 代码,并且不会改变 JavaScript 的运行结果。 + +因此,TypeScript 的类型检查只是编译时的类型检查,而不是运行时的类型检查。一旦代码编译为 JavaScript,运行时就不再检查类型了。 + +## 值与类型 + +学习 TypeScript 需要分清楚“值”(value)和“类型”(type)。 + +“类型”是针对“值”的,可以视为是后者的一个元属性。每一个值在 TypeScript 里面都是有类型的。比如,`3`是一个值,它的类型是`number`。 + +TypeScript 代码只涉及类型,不涉及值。所有跟“值”相关的处理,都由 JavaScript 完成。 + +这一点务必牢记。TypeScript 项目里面,其实存在两种代码,一种是底层的“值代码”,另一种是上层的“类型代码”。前者使用 JavaScript 语法,后者使用 TypeScript 的类型语法。 + +它们是可以分离的,TypeScript 的编译过程,实际上就是把“类型代码”全部拿掉,只保留“值代码”。 + +编写 TypeScript 项目时,不要混淆哪些是值代码,哪些是类型代码。 + +## TypeScript Playground + +最简单的 TypeScript 使用方法,就是使用官网的在线编译页面,叫做 [TypeScript Playground](http://www.typescriptlang.org/play/)。 + +只要打开这个网页,把 TypeScript 代码贴进文本框,它就会在当前页面自动编译出 JavaScript 代码,还可以在浏览器执行编译产物。如果编译报错,它也会给出详细的报错信息。 + +这个页面还具有支持完整的 IDE 支持,可以自动语法提示。此外,它支持把代码片段和编译器设置保存成 URL,分享给他人。 + +本书的示例都建议放到这个页面,进行查看和编译。 + +## tsc 编译器 + +TypeScript 官方提供的编译器叫做 tsc,可以将 TypeScript 脚本编译成 JavaScript 脚本。本机想要编译 TypeScript 代码,必须安装 tsc。 + +根据约定,TypeScript 脚本文件使用`.ts`后缀名,JavaScript 脚本文件使用`.js`后缀名。tsc 的作用就是把`.ts`脚本转变成`.js`脚本。 + +### 安装 + +tsc 是一个 npm 模块,使用下面的命令安装(必须先安装 npm)。 + +```bash +$ npm install -g typescript +``` + +上面命令是全局安装 tsc,也可以在项目中将 tsc 安装为一个依赖模块。 + +安装完成后,检查一下是否安装成功。 + +```bash +# 或者 tsc --version +$ tsc -v +Version 5.1.6 +``` + +上面命令中,`-v`或`--version`参数可以输出当前安装的 tsc 版本。 + +### 帮助信息 + +`-h`或`--help`参数输出帮助信息。 + +```bash +$ tsc -h +``` + +默认情况下,“--help”参数仅显示基本的可用选项。我们可以使用“--all”参数,查看完整的帮助信息。 + +```bash +$ tsc --all +``` + +### 编译脚本 + +安装 tsc 之后,就可以编译 TypeScript 脚本了。 + +`tsc`命令后面,加上 TypeScript 脚本文件,就可以将其编译成 JavaScript 脚本。 + +```bash +$ tsc app.ts +``` + +上面命令会在当前目录下,生成一个`app.js`脚本文件,这个脚本就完全是编译后生成的 JavaScript 代码。 + +`tsc`命令也可以一次编译多个 TypeScript 脚本。 + +```bash +$ tsc file1.ts file2.ts file3.ts +``` + +上面命令会在当前目录生成三个 JavaScript 脚本文件`file1.js`、`file2.js`、`file3.js`。 + +tsc 有很多参数,可以调整编译行为。 + +**(1)--outFile** + +如果想将多个 TypeScript 脚本编译成一个 JavaScript 文件,使用`--outFile`参数。 + +```bash +$ tsc file1.ts file2.ts --outFile app.js +``` + +上面命令将`file1.ts`和`file2.ts`两个脚本编译成一个 JavaScript 文件`app.ts`。 + +**(2)--outDir** + +编译结果默认都保存在当前目录,`--outDir`参数可以指定保存到其他目录。 + +```bash +$ tsc app.ts --outDir dist +``` + +上面命令会在`dist`子目录下生成`app.js`。 + +注意,`--outDir`与`--outFile`不能同时使用。 + +**(3)--target** + +为了保证编译结果能在各种 JavaScript 引擎运行,tsc 默认会将 TypeScript 代码编译成很低版本的 JavaScript,即3.0版本(以`es3`表示)。这通常不是我们想要的结果。 + +这时可以使用`--target`参数,指定编译后的 JavaScript 版本。建议使用`es2015`,或者更新版本。 + +```bash +$ tsc --target es2015 app.ts +``` + +### 编译错误的处理 + +编译过程中,如果没有报错,`tsc`命令不会有任何显示。所以,如果你没有看到任何提示,就表示编译成功了。 + +如果编译报错,`tsc`命令就会显示报错信息,但是这种情况下,依然会编译生成 JavaScript 脚本。 + +举例来说,下面是一个错误的 TypeScript 脚本`app.ts`。 + +```typescript +// app.ts +let foo:number = 123; +foo = 'abc'; // 报错 +``` + +上面示例中,变量`foo`是数值类型,赋值为字符串,`tsc`命令编译这个脚本就会报错。 + +```bash +$ tsc app.ts + +app.ts:2:1 - error TS2322: Type 'string' is not assignable to type 'number'. + +2 foo = 'abc'; + ~~~ + +Found 1 error in app.ts:2 +``` + +上面示例中,`tsc`命令输出报错信息,表示变量`foo`被错误地赋值为字符串。 + +这种情况下,编译产物`app.js`还是会照样生成,下面就是编译后的结果。 + +```javascript +// app.js +var foo = 123; +foo = 'abc'; +``` + +可以看到,尽管有错,tsc 依然原样将 TypeScript 编译成 JavaScript 脚本。 + +这是因为 TypeScript 团队认为,编译器的作用只是给出编译错误,至于怎么处理这些错误,那就是开发者自己的判断了。开发者更了解自己的代码,所以不管怎样,编译产物都会生成,让开发者决定下一步怎么处理。 + +如果希望一旦报错就停止编译,不生成编译产物,可以使用`--noEmitOnError`参数。 + +```bash +$ tsc --noEmitOnError app.ts +``` + +上面命令在报错后,就不会生成`app.js`。 + +tsc 还有一个`--noEmit`参数,只检查类型是否正确,不生成 JavaScript 文件。 + +```bash +$ tsc --noEmit app.ts +``` + +上面命令只检查是否有编译错误,不会生成`app.js`。 + +tsc 命令的更多参数,详见《tsc 编译器》一章。 + +### tsconfig.json + +TypeScript 允许将`tsc`的编译参数,写在配置文件`tsconfig.json`。只要当前目录有这个文件,`tsc`就会自动读取,所以运行时可以不写参数。 + +```bash +$ tsc file1.js file2.js --outFile dist/app.js +``` + +上面这个命令写成`tsconfig.json`,就是下面这样。 + +```json +{ + "files": ["file1.ts", "file2.ts"], + "compilerOptions": { + "outFile": "dist/app.js" + } +} +``` + +有了这个配置文件,编译时直接调用`tsc`命令就可以了。 + +```bash +$ tsc +``` + +`tsconfig.json`的详细介绍,参见《tsconfig.json 配置文件》一章。 + +## ts-node 模块 + +[ts-node](https://github.com/TypeStrong/ts-node) 是一个非官方的 npm 模块,可以直接运行 TypeScript 代码。 + +使用时,可以先全局安装它。 + +```bash +$ npm install -g ts-node +``` + +安装后,就可以直接运行 TypeScript 脚本。 + +```bash +$ ts-node script.ts +``` + +上面命令运行了 TypeScript 脚本`script.ts`,给出运行结果。 + +如果不安装 ts-node,也可以通过 npx 调用它来运行 TypeScript 脚本。 + +```bash +$ npx ts-node script.ts +``` + +上面命令中,`npx`会在线调用 ts-node,从而在不安装的情况下,运行`script.ts`。 + +如果执行 ts-node 命令不带有任何参数,它会提供一个 TypeScript 的命令行 REPL 运行环境,你可以在这个环境中输入 TypeScript 代码,逐行执行。 + +```bash +$ ts-node +> +``` + +上面示例中,单独运行`ts-node`命令,会给出一个大于号,这就是 TypeScript 的 REPL 运行环境,可以逐行输入代码运行。 + +```bash +$ ts-node +> const twice = (x:string) => x + x; +> twice('abc') +'abcabc' +> +``` + +上面示例中,在 TypeScript 命令行 REPL 环境中,先输入一个函数`twice`,然后调用该函数,就会得到结果。 + +要退出这个 REPL 环境,可以按下 Ctrl + d,或者输入`.exit`。 + +如果只是想简单运行 TypeScript 代码看看结果,ts-node 不失为一个简单的方法。 diff --git a/docs/class.md b/docs/class.md new file mode 100644 index 0000000..c636631 --- /dev/null +++ b/docs/class.md @@ -0,0 +1,1555 @@ +# TypeScript 的 class 类型 + +## 简介 + +类(class)是面向对象编程的基本构件,封装了属性和方法,TypeScript 给予了全面支持。 + +### 属性的类型 + +类的属性可以在顶层声明,也可以在构造方法内部声明。 + +对于顶层声明的属性,可以在声明时同时给出类型。 + +```typescript +class Point { + x:number; + y:number; +} +``` + +上面声明时,属性`x`和`y`的类型都是`number`。 + +如果不给出类型,TypeScript 会认为`x`和`y`的类型都是`any`。 + +```typescript +class Point { + x; + y; +} +``` + +上面示例中,`x`和`y`的类型都是`any`。 + +如果声明时给出初值,可以不写类型,TypeScript 会自行推断属性的类型。 + +```typescript +class Point { + x = 0; + y = 0; +} +``` + +上面示例中,属性`x`和`y`的类型都会被推断为 number。 + +TypeScript 有一个配置项`strictPropertyInitialization`,只要打开,就会检查属性是否设置了初值,如果没有就报错。 + +如果你打开了这个设置,但是某些情况下,不是在声明时赋值或在构造函数里面赋值,为了防止这个设置报错,可以使用非空断言。 + +```typescript +class Point { + x!:number; + y!:number; +} +``` + +上面示例中,属性`x`和`y`没有初值,但是属性名后面添加了感叹号,表示这两个属性肯定不会为空,所以 TypeScript 就不报错了,详见《类型断言》一章。 + +### readonly 修饰符 + +属性名前面加上 readonly 修饰符,就表示该属性是只读的。实例对象不能修改这个属性。 + +```typescript +class A { + readonly id = 'foo'; +} + +const a = new A(); +a.id = 'bar'; // 报错 +``` + +上面示例中,`id`属性前面有 readonly 修饰符,实例对象修改这个属性就会报错。 + +readonly 属性的初始值,可以写在顶层属性,也可以写在构造函数里面。 + +```typescript +class A { + readonly id:string; + + constructor() { + this.id = 'bar'; // 正确 + } +} +``` + +上面示例中,构造方法内部设置只读属性的初值,这是可以的。 + +```typescript +class A { + readonly id:string = 'foo'; + + constructor() { + this.id = 'bar'; // 正确 + } +} +``` + +上面示例中,构造方法修改只读属性的值也是可以的。或者说,如果两个地方都设置了只读属性的值,以构造方法为准。在其他方法修改只读属性都会报错。 + +### 方法的类型 + +类的方法就是普通函数,类型声明方式与函数一致。 + +```typescript +class Point { + x:number; + y:number; + + constructor(x:number, y:number) { + this.x = x; + this.y = y; + } + + add(point:Point) { + return new Point( + this.x + point.x, + this.y + point.y + ); + } +} +``` + +上面示例中,构造方法`constructor()`和普通方法`add()`都注明了参数类型,但是省略了返回值类型,因为 TypeScript 可以自己推断出来。 + +类的方法跟普通函数一样,可以使用参数默认值,以及函数重载。 + +下面是参数默认值的例子。 + +```typescript +class Point { + x: number; + y: number; + + constructor(x = 0, y = 0) { + this.x = x; + this.y = y; + } +} +``` + +上面示例中,如果新建实例时,不提供属性`x`和`y`的值,它们都等于默认值`0`。 + +下面是函数重载的例子。 + +```typescript +class Point { + constructor(x:number, y:string); + constructor(s:string); + constructor(xs:number|string, y?:string) { + // ... + } +} +``` + +上面示例中,构造方法可以接受一个参数,也可以接受两个参数,采用函数重载进行类型声明。 + +另外,构造方法不能声明返回值类型,否则报错,因为它总是返回实例对象。 + +```typescript +class B { + constructor():object { // 报错 + // ... + } +} +``` + +上面示例中,构造方法声明了返回值类型`object`,导致报错。 + +### 存取器方法 + +存取器(accessor)是特殊的类方法,包括取值器(getter)和存值器(setter)两种方法。 + +它们用于读写某个属性,取值器用来读取属性,存值器用来写入属性。 + +```typescript +class C { + _name = ''; + get name() { + return this._name; + } + set name(value) { + this._name = value; + } +} +``` + +上面示例中,`get name()`是取值器,其中`get`是关键词,`name`是属性名。外部读取`name`属性时,实例对象会自动调用这个方法,该方法的返回值就是`name`属性的值。 + +`set name()`是存值器,其中`set`是关键词,`name`是属性名。外部写入`name`属性时,实例对象会自动调用这个方法,并将所赋的值作为函数参数传入。 + +TypeScript 对存取器有以下规则。 + +(1)如果某个属性只有`get`方法,没有`set`方法,那么该属性自动成为只读属性。 + +```typescript +class C { + _name = 'foo'; + + get name() { + return this._name; + } +} + +const c = new C(); +c.name = 'bar'; // 报错 +``` + +上面示例中,`name`属性没有`set`方法,对该属性赋值就会报错。 + +(2)`set`方法的参数类型,必须兼容`get`方法的返回值类型,否则报错。 + +```typescript +class C { + _name = ''; + get name():string { + return this._name; + } + set name(value:number) { + this._name = value; // 报错 + } +} +``` + +上面示例中,`get`方法的返回值类型是字符串,与`set`方法参数类型不兼容,导致报错。 + +```typescript +class C { + _name = ''; + get name():string { + return this._name; + } + set name(value:number|string) { + this._name = String(value); // 正确 + } +} +``` + +上面示例中,`set`方法的参数类型(`number|return`)兼容`get`方法的返回值类型(`string`),这是允许的。但是,最终赋值的时候,还是必须保证与`get`方法的返回值类型一致。 + +另外,如果`set`方法的参数没有指定类型,那么会推断为与`get`方法返回值类型一致。 + +(3)`get`方法与`set`方法的类型必须一致,要么都为公开方法,要么都为私有方法。 + +### 属性索引 + +类允许定义属性索引。 + +```typescript +class MyClass { + [s:string]: boolean | + ((s:string) => boolean); + + get(s:string) { + return this[s] as boolean; + } +} +``` + +上面示例中,`[s:string]`表示所有属性名类型为字符串的属性,它们的属性值要么是布尔值,要么是返回布尔值的函数。 + +注意,由于类的方法是一种特殊属性(属性值为函数的属性),所以属性索引必须同时给出属性和方法两种类型。 + +```typescript +class MyClass { + [s:string]: boolean; + + get(s:string) { // 报错 + return this[s] as boolean; + } +} +``` + +上面示例中,属性索引没有给出方法的类型,导致`get()`方法报错。 + +## 类的 interface 接口 + +### implements 关键字 + +interface 接口或 type 别名,可以用对象的形式,为 class 指定一组检查条件。然后,类使用 implements 关键字,表示当前类能够通过这些外部类型条件。 + +```typescript +interface Country { + name:string; + capital:string; +} +// 或者 +type Country = { + name:string; + capital:string; +} + +class MyCountry implements Country { + name = ''; + capital = ''; +} +``` + +上面示例中,`interface`或`type`都可以定义一个对象类型。类`MyCountry`使用`implements`关键字,表示该类的实例对象满足这个外部类型。 + +interface 只是指定检查条件,如果不满足这些条件就会报错。它并不能代替 class 自身的类型声明。 + +```typescript +interface A { + get(name:string): boolean; +} + +class B implements A { + get(s) { // s 的类型是 any + return true; + } +} +``` + +上面示例中,类`B`实现了接口`A`,但是后者并不能代替`B`的类型声明。因此,`B`的`get()`方法的参数`s`的类型是`any`,而不是`string`。`B`类依然需要声明参数`s`的类型。 + +```typescript +class B implements A { + get(s:string) { + return true; + } +} +``` + +下面是另一个例子。 + +```typescript +interface A { + x: number; + y?: number; +} + +class B implements A { + x = 0; +} + +const b = new B(); +b.y = 10; // 报错 +``` + +上面示例中,接口`A`有一个可选属性`y`,类`B`没有声明这个属性,所以可以通过类型检查。但是,如果给`B`的实例对象的属性`y`赋值,就会报错。所以,`B`类还是需要声明可选属性`y`。 + +```typescript +class B implements A { + x = 0; + y?: number; +} +``` + +同理,类可以定义接口没有声明的方法和属性。 + +```typescript +interface Point { + x: number; + y: number; +} + +class MyPoint implements Point { + x = 1; + y = 1; + z:number = 1; +} +``` + +上面示例中,`MyPoint`类实现了`Point`接口,但是内部还定义了一个额外的属性`z`,这是允许的,表示除了满足接口给出的条件,类还有额外的条件。 + +`implements`关键字后面,不仅可以是接口,也可以是另一个类。这时,后面的类将被当作接口。 + +```typescript +class Car { + id:number = 1; + move():void {}; +} + +class MyCar implements Car { + id = 2; // 不可省略 + move():void {}; // 不可省略 +} +``` + +上面示例中,`implements`后面是类`Car`,这时 TypeScript 就把`Car`视为一个接口,要求`MyCar`实现`Car`里面的每一个属性和方法,否则就会报错。所以,这时不能因为`Car`类已经实现过一次,而在`MyCar`类省略属性或方法。 + +注意,interface 描述的是类的对外接口,也就是实例的公开属性和公开方法,不能定义私有的属性和方法。这是因为 TypeScript 设计者认为,私有属性是类的内部实现,接口作为模板,不应该涉及类的内部代码写法。 + +```typescript +interface Foo { + private member:{}; // 报错 +} +``` + +上面示例中,接口`Foo`有一个私有属性,结果就报错了。 + +### 实现多个接口 + +类可以实现多个接口,每个接口之间使用逗号分隔。 + +```typescript +class Car implements MotorVehicle, Flyable, Swimmable { + // ... +} +``` + +上面示例中,`Car`类同时实现了`MotorVehicle`、`Flyable`、`Swimmable`三个接口。这意味着,它必须部署这三个接口声明的所有属性和方法。 + +但是,同时实现多个接口并不是一个好的写法,容易使得代码难以管理,可以使用两种方法替代。 + +第一种方法是类的继承。 + +```typescript +class Car implements MotorVehicle { +} + +class SecretCar extends Car implements Flyable, Swimmable { +} +``` + +上面示例中,`Car`类实现了`MotorVehicle`,而`SecretCar`类继承了`Car`类,然后再实现`Flyable`和`Swimmable`两个接口,相当于`SecretCar`类同时实现了三个接口。 + +第二种方法是接口的继承。 + +```typescript +interface A { + a:number; +} + +interface B extends A { + b:number; +} +``` + +上面示例中,接口`B`继承了接口`A`,类只要实现接口`B`,就相当于实现`A`和`B`两个接口。 + +前一个例子可以用接口继承改写。 + +```typescript +interface MotorVehicle { + // ... +} +interface Flyable { + // ... +} +interface Swimmable { + // ... +} + +interface SuperCar extends MotoVehicle,Flyable, Swimmable { + // ... +} + +class SecretCar implements SuperCar { + // ... +} +``` + +上面示例中,接口`SuperCar`通过`SuperCar`接口,就间接实现了多个接口。 + +注意,发生多重实现时(即一个接口同时实现多个接口),不同接口不能有互相冲突的属性。 + +```typescript +interface Flyable { + foo:number; +} + +interface Swimmable { + foo:string; +} +``` + +上面示例中,属性`foo`在两个接口里面的类型不同,如果同时实现这两个接口,就会报错。 + +### 类与接口的合并 + +TypeScript 不允许两个同名的类,但是如果一个类和一个接口同名,那么接口会被合并进类。 + +```typescript +class A { + x:number = 1; +} + +interface A { + y:number; +} + +let a = new A(); +a.y = 10; + +a.x // 1 +a.y // 10 +``` + +上面示例中,类`A`与接口`A`同名,后者会被合并进前者的类型定义。 + +## Class 类型 + +### 实例类型 + +TypeScript 的类本身就是一种类型,但是它代表该类的实例类型,而不是 class 的自身类型。 + +```typescript +class Color { + name:string; + + constructor(name:string) { + this.name = name; + } +} + +const green:Color = new Color('green'); +``` + +上面示例中,定义了一个类`Color`。它的类名就代表一种类型,实例对象`green`就属于该类型。 + +对于引用实例对象的变量来说,既可以声明类型为 Class,也可以声明类型为 Interface,因为两者都代表实例类型。 + +```typescript +interface MotorVehicle { +} + +class Car implements MotorVehicle { +} + +// 写法一 +const c:Car = new Car(); +// 写法二 +const c:MotorVehicle = new Car(); +``` + +上面示例中,变量`c`的类型可以写成类`Car`,也可以写成接口`MotorVehicle`。它们的区别是,如果类`Car`有接口`MotoVehicle`没有的属性和方法,那么只有变量`c1`可以调用这些属性和方法。 + +作为类型使用时,类名只能表示实例的类型,不能表示类的自身类型。 + +```typescript +class Point { + x:number; + y:number; + + constructor(x:number, y:number) { + this.x = x; + this.y = y; + } +} + +// 错误 +function createPoint( + PointClass:Point, + x: number, + y: number +) { + return new PointClass(x, y); +} +``` + +上面示例中,函数`createPoint()`的第一个参数`PointClass`,需要传入 Point 这个类,但是如果把参数的类型写成`Point`就会报错,因为`Point`描述的是实例类型,而不是 Class 的自身类型。 + +由于类名作为类型使用,实际上代表一个对象,因此可以把类看作为对象类型起名。事实上,TypeScript 有三种方法可以为对象类型起名:type、interface 和 class。 + +### 类的自身类型 + +要获得一个类的自身类型,一个简便的方法就是使用 typeof 运算符。 + +```typescript +function createPoint( + PointClass:typeof Point, + x:number, + y:number +):Point { + return new PointClass(x, y); +} +``` + +上面示例中,`createPoint()`的第一个参数`PointClass`是`Point`类自身,要声明这个参数的类型,简便的方法就是使用`typeof Point`。因为`Point`类是一个值,`typeof Point`返回这个值的类型。注意,`createPoint()`的返回值类型是`Point`,代表实例类型。 + +JavaScript 语言中,类只是构造函数的一种语法糖,本质上是构造函数的另一种写法。所以,类的自身类型可以写成构造函数的形式。 + +```typescript +function createPoint( + PointClass: new (x:number, y:number) => Point, + x: number, + y: number +):Point { + return new PointClass(x, y); +} +``` + +上面示例中,参数`PointClass`的类型写成了一个构造函数,这时就可以把`Point`类传入。 + +构造函数也可以写成对象形式,所以参数`PointClass`的类型还有另一种写法。 + +```typescript +function createPoint( + PointClass: { + new (x:number, y:number): Point + }, + x: number, + y: number +):Point { + return new PointClass(x, y); +} +``` + +根据上面的写法,可以把构造函数提取出来,单独定义一个接口(interface),这样可以大大提高代码的通用性。 + +```typescript +interface PointConstructor { + new(x:number, y:number):Point; +} + +function createPoint( + PointClass: PointConstructor, + x: number, + y: number +):Point { + return new PointClass(x, y); +} +``` + +总结一下,类的自身类型就是一个构造函数,可以单独定义一个接口来表示。 + +### 结构类型原则 + +Class 也遵循“结构类型原则”。一个对象只要满足 Class 的实例结构,就跟该 Class 属于同一个类型。 + +```typescript +class Foo { + id!:number; +} + +function fn(arg:Foo) { + // ... +} + +const bar = { + id: 10, + amount: 100, +}; + +fn(bar); // 正确 +``` + +上面示例中,对象`bar`满足类`Foo`的实例结构,只是多了一个属性`amount`。所以,它可以当作参数,传入函数`fn()`。 + +如果两个类的实例结构相同,那么这两个类就是兼容的,可以用在对方的使用场合。 + +```typescript +class Person { + name: string; +} + +class Customer { + name: string; +} + +// 正确 +const cust:Customer = new Person(); +``` + +上面示例中,`Person`和`Customer`是两个结构相同的类,TypeScript 将它们视为相同类型,因此`Person`可以用在类型为`Customer`的场合。 + +现在修改一下代码,`Person`类添加一个属性。 + +```typescript +class Person { + name: string; + age: number; +} + +class Customer { + name: string; +} + +// 正确 +const cust:Customer = new Person(); +``` + +上面示例中,`Person`类添加了一个属性`age`,跟`Customer`类的结构不再相同。但是这种情况下,TypeScript 依然认为,`Person`属于`Customer`类型。 + +这是因为根据“结构类型原则”,只要`Person`类具有`name`属性,就满足`Customer`类型的实例结构,所以代替它。反过来就不行,如果`Customer`类多出一个属性,就会报错。 + +```typescript +class Person { + name: string; +} + +class Customer { + name: string; + age: number; +} + +// 报错 +const cust:Customer = new Person(); +``` + +上面示例中,`Person`类比`Customer`类少一个属性`age`,它就不满足`Customer`类型的实例结构,就报错了。因为在使用`Customer`类型的情况下,可能会用到它的`age`属性,而`Person`类就没有这个属性。 + +总之,只要 A 类具有 B 类的结构,哪怕还有额外的属性和方法,TypeScript 也认为 A 兼容 B 的类型。 + +不仅是类,如果某个对象跟某个 class 的实例结构相同,TypeScript 也认为两者的类型相同。 + +```typescript +class Person { + name: string; +} + +const obj = { name: 'John' }; +const p:Person = obj; // 正确 +``` + +上面示例中,对象`obj`并不是`Person`的实例,但是赋值给变量`p`不会报错,TypeScript 认为`obj`也属于`Person`类型,因为它们的属性相同。 + +由于这种情况,运算符`instanceof`不适用于判断某个对象是否跟某个 class 属于同一类型。 + +```typescript +obj instanceof Person // false +``` + +上面示例中,运算符`instanceof`确认变量`obj`不是 Person 的实例,但是两者的类型是相同的。 + +空类不包含任何成员,任何其他类都可以看作与空类结构相同。因此,凡是类型为空类的地方,所有类(包括对象)都可以使用。 + +```typescript +class Empty {} + +function fn(x:Empty) { + // ... +} + +fn({}); +fn(window); +fn(fn); +``` + +上面示例中,函数`fn()`的参数是一个空类,这意味着任何对象都可以用作`fn()`的参数。 + +注意,确定两个类的兼容关系时,只检查实例成员,不考虑静态成员和构造方法。 + +```typescript +class Point { + x: number; + y: number; + static t: number; + constructor(x:number) {} +} + +class Position { + x: number; + y: number; + z: number; + constructor(x:string) {} +} + +const point:Point = new Position(''); +``` + +上面示例中,`Point`与`Position`的静态属性和构造方法都不一样,但因为`Point`的实例成员与`Position`相同,所以`Position`兼容`Point`。 + +如果类中存在私有成员(private)或保护成员(protected),那么确定兼容关系时,TypeScript 要求私有成员和保护成员来自同一个类,这意味着两个类需要存在继承关系。 + +```typescript +// 情况一 +class A { + private name = 'a'; +} + +class B extends A { +} + +const a:A = new B(); + +// 情况二 +class A { + protected name = 'a'; +} + +class B extends A { + protected name = 'b'; +} + +const a:A = new B(); +``` + +上面示例中,`A`和`B`都有私有成员(或保护成员)`name`,这时只有在`B`继承`A`的情况下,`B`才兼容`A`。 + +## 类的继承 + +类(这里又称“子类”)可以使用 extends 关键字继承另一个类(这里又称“基类”)的所有属性和方法。 + +```typescript +class A { + greet() { + console.log('Hello, world!'); + } +} + +class B extends A { +} + +const b = new B(); +b.greet() // "Hello, world!" +``` + +上面示例中,子类`B`继承了基类`A`,因此就拥有了`greet()`方法,不需要再次在类的内部定义这个方法了。 + +根据结构类型原则,子类也可以用于类型为基类的场合。 + +```typescript +const a:A = b; +a.greet() +``` + +上面示例中,变量`a`的类型是基类,但是可以赋值为子类的实例。 + +子类可以覆盖基类的同名方法。 + +```typescript +class B extends A { + greet(name?: string) { + if (name === undefined) { + super.greet(); + } else { + console.log(`Hello, ${name}`); + } + } +} +``` + +上面示例中,子类`B`定义了一个方法`greet()`,覆盖了基类`A`的同名方法。 + +其中,参数`name`省略时,就调用基类`A`的`greet()`方法,这里可以写成`super.greet()`。使用`super`关键字指代基类是常见做法。 + +但是,子类的同名方法不能与基类的类型定义相冲突。 + +```typescript +class A { + greet() { + console.log('Hello, world!'); + } +} + +class B extends A { + // 报错 + greet(name:string) { + console.log(`Hello, ${name}`); + } +} +``` + +上面示例中,子类`B`的`greet()`有一个`name`参数,跟基类`A`的`greet()`定义不兼容,因此就报错了。 + +如果基类包括保护成员(`protected`修饰符),子类可以将该成员的可访问性设置为公开(`public`修饰符),也可以保持保护成员不变,但是不能改用私有成员(`private`修饰符),详见后文。 + +```typescript +class A { + protected x: string = ''; + protected y: string = ''; + protected z: string = ''; +} + +class B extends A { + // 正确 + public x:string = ''; + + // 正确 + protected y:string = ''; + + // 报错 + private z: string = ''; +} +``` + +上面示例中,子类`B`将基类`A`的受保护成员改成私有成员,就会报错。 + +注意,`extends`关键字后面不一定是类名,可以是一个表达式,只要它的类型是构造函数就可以了。 + +```typescript +// 例一 +class MyArray extends Array {} + +// 例二 +class MyError extends Error {} + +// 例三 +class A { + greeting() { + return 'Hello from A'; + } +} +class B { + greeting() { + return 'Hello from B'; + } +} + +interface Greeter { + greeting(): string; +} + +interface GreeterConstructor { + new (): Greeter; +} + +function getGreeterBase(): GreeterConstructor { + return Math.random() >= 0.5 ? A : B; +} + +class Test extends getGreeterBase() { + sayHello() { + console.log(this.greeting()); + } +} +``` + +上面示例中,例一和例二的`extends`关键字后面都是构造函数,例三的`extends`关键字后面是一个表达式,执行后得到的也是一个构造函数。 + +对于那些只设置了类型、没有初值的顶层属性,有一个细节需要注意。 + +```typescript +interface Animal { + animalStuff: any; +} + +interface Dog extends Animal { + dogStuff: any; +} + +class AnimalHouse { + resident: Animal; + + constructor(animal:Animal) { + this.resident = animal; + } +} + +class DogHouse extends AnimalHouse { + resident: Dog; + + constructor(dog:Dog) { + super(dog); + } +} +``` + +上面示例中,类`DogHouse`的顶层成员`resident`只设置了类型(`Dog`),没有设置初值。这段代码在不同的编译设置下,编译结果不一样。 + +如果编译设置的`target`设成大于等于`ES2022`,或者`useDefineForClassFields`设成`true`,那么下面代码的执行结果是不一样的。 + +```typescript +const dog = { + animalStuff: 'animal', + dogStuff: 'dog' +}; + +const dogHouse = new DogHouse(dog); + +console.log(dogHouse.resident) // undefined +``` + +上面示例中,`DogHouse`实例的属性`resident`输出的是`undefined`,而不是预料的`dog`。原因在于 ES2022 标准的 Class Fields 部分,与早期的 TypeScript 实现不一致,导致子类的那些只设置类型、没有设置初值的顶层成员在基类中被赋值后,会在子类被重置为`undefined`,详细的解释参见《tsconfig.json》一章,以及官方 3.7 版本的[发布说明](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#the-usedefineforclassfields-flag-and-the-declare-property-modifier)。 + +解决方法就是使用`declare`命令,去声明顶层成员的类型,告诉 TypeScript 这些成员的赋值由基类实现。 + +```typescript +class DogHouse extends AnimalHouse { + declare resident: Dog; + + constructor(dog:Dog) { + super(dog); + } +} +``` + +上面示例中,`resident`属性的类型声明前面用了`declare`命令,这样就能确保在编译目标大于等于`ES2022`时(或者打开`useDefineForClassFields`时),代码行为正确。 + +## 可访问性修饰符 + +类的内部成员的外部可访问性,由三个可访问性修饰符(access modifiers)控制:`public`、`private`和`protected`。 + +这三个修饰符的位置,都写在属性或方法的最前面。 + +### public + +`public`修饰符表示这是公开成员,外部可以自由访问。 + +```typescript +class Greeter { + public greet() { + console.log("hi!"); + } +} + +const g = new Greeter(); +g.greet(); +``` + +上面示例中,`greet()`方法前面的`public`修饰符,表示该方法可以在类的外部调用,即外部实例可以调用。 + +`public`修饰符是默认修饰符,如果省略不写,实际上就带有该修饰符。因此,类的属性和方法默认都是外部可访问的。 + +正常情况下,除非为了醒目和代码可读性,`public`都是省略不写的。 + +### private + +`private`修饰符表示私有成员,只能用在当前类的内部,类的实例和子类都不能使用该成员。 + +```typescript +class A { + private x:number = 0; +} + +const a = new A(); +a.x // 报错 + +class B extends A { + showX() { + console.log(this.x); // 报错 + } +} +``` + +上面示例中,属性`x`前面有`private`修饰符,表示这是私有成员。因此,实例对象和子类使用该成员,都会报错。 + +注意,子类不能定义父类私有成员的同名成员。 + +```typescript +class A { + private x = 0; +} + +class B extends A { + x = 1; // 报错 +} +``` + +上面示例中,`A`类有一个私有属性`x`,子类`B`就不能定义自己的属性`x`了。 + +如果在类的内部,当前类的实例可以获取私有成员。 + +```typescript +class A { + private x = 10; + + f(obj:A) { + console.log(obj.x); + } +} + +const a = new A(); +a.f(a) // 10 +``` + +上面示例中,在类`A`内部,`A`的实例对象可以获取私有成员`x`。 + +严格地说,`private`定义的私有成员,并不是真正意义的私有成员。一方面,编译成 JavaScript 后,`private`关键字就被剥离了,这时外部访问该成员就不会报错。另一方面,由于前一个原因,TypeScript 对于访问`private`成员没有严格禁止,使用方括号写法(`[]`)或者`in`运算符,实例对象就能访问该成员。 + +```typescript +class A { + private x = 1; +} + +const a = new A(); +a['x'] // 1 + +if ('x' in a) { // 正确 + // ... +} +``` + +上面示例中,`A`类的属性`x`是私有属性,但是实例使用方括号,就可以读取这个属性,或者使用`in`运算符检查这个属性是否存在,都可以正确执行。 + +由于`private`存在这些问题,加上它是 ES6 标准发布前出台的,而 ES6 引入了自己的私有成员写法`#propName`。因此建议不使用`private`,改用 ES6 的写法,获得真正意义的私有成员。 + +```typescript +class A { + #x = 1; +} + +const a = new A(); +a['x'] // 报错 +``` + +上面示例中,采用了 ES6 的私有成员写法(属性名前加`#`),TypeScript 就正确识别了实例对象没有属性`x`,从而报错。 + +构造方法也可以是私有的,这就直接防止了使用`new`命令生成实例对象,只能在类的内部创建实例对象。 + +这时一般会有一个静态方法,充当工厂函数,强制所有实例都通过该方法生成。 + +```typescript +class Singleton { + private static instance?: Singleton; + + private constructor() {} + + static getInstance() { + if (!Singleton.instance) { + Singleton.instance = new Singleton(); + } + return Singleton.instance; + } +} + +const s = Singleton.getInstance(); +``` + +上面示例使用私有构造方法,实现了单例模式。想要获得 Singleton 的实例,不能使用`new`命令,只能使用`getInstance()`方法。 + +### protected + +`protected`修饰符表示该成员是保护成员,只能在类的内部使用该成员,实例无法使用该成员,但是子类可以使用。 + +```typescript +class A { + protected x = 1; +} + +class B extends A { + getX() { + return this.x; + } +} + +const a = new A(); +const b = new B(); + +a.x // 报错 +b.getX() // 1 +``` + +上面示例中,类`A`的属性`x`是保护成员,直接从实例读取该属性(`a.x`)会报错,但是子类`B`内部可以读取该属性。 + +子类不仅可以拿到父类的保护成员,还可以定义同名成员。 + +```typescript +class A { + protected x = 1; +} + +class B extends A { + x = 2; +} +``` + +上面示例中,子类`B`定义了父类`A`的同名成员`x`,并且父类的`x`是保护成员,子类将其改成了公开成员。`B`类的`x`属性前面没有修饰符,等同于修饰符是`public`,外界可以读取这个属性。 + +在类的外部,实例对象不能读取保护成员,但是在类的内部可以。 + +```typescript +class A { + protected x = 1; + + f(obj:A) { + console.log(obj.x); + } +} + +const a = new A(); + +a.x // 报错 +a.f(a) // 1 +``` + +上面示例中,属性`x`是类`A`的保护成员,在类的外部,实例对象`a`拿不到这个属性。但是,实例对象`a`传入类`A`的内部,就可以从`a`拿到`x`。 + +### 实例属性的简写形式 + +实际开发中,很多实例属性的值,是通过构造方法传入的。 + +```typescript +class Point { + x:number; + y:number; + + constructor(x:number, y:number) { + this.x = x; + this.y = y; + } +} +``` + +上面实例中,属性`x`和`y`的值是通过构造方法的参数传入的。 + +这样的写法等于对同一个属性要声明两次类型,一次在类的头部,另一次在构造方法的参数里面。这有些累赘,TypeScript 就提供了一种简写形式。 + +```typescript +class Point { + constructor( + public x:number, + public y:number + ) {} +} + +const p = new Point(10, 10); +p.x // 10 +p.y // 10 +``` + +上面示例中,构造方法的参数`x`前面有`public`修饰符,这时 TypeScript 就会自动声明一个公开属性`x`,不必在构造方法里面写任何代码,同时还会设置`x`的值为构造方法的参数值。注意,这里的`public`不能省略。 + +除了`public`修饰符,构造方法的参数名只要有`private`、`protected`、`readonly`修饰符,都会自动声明对应修饰符的实例属性。 + +```typescript +class A { + constructor( + public a: number, + protected b: number, + private c: number, + readonly d: number + ) {} +} + +// 编译结果 +class A { + a; + b; + c; + d; + constructor(a, b, c, d) { + this.a = a; + this.b = b; + this.c = c; + this.d = d; + } +} +``` + +上面示例中,从编译结果可以看到,构造方法的`a`、`b`、`c`、`d`会生成对应的实例属性。 + +`readonly`还可以与其他三个可访问性修饰符,一起使用。 + +```typescript +class A { + constructor( + public readonly x:number, + protected readonly y:number, + private readonly z:number + ) {} +} +``` + +## 静态成员 + +类的内部可以使用`staic`关键字,定义静态成员。 + +静态成员是只能通过类本身使用的成员,不能通过实例对象使用。 + +```typescript +class MyClass { + static x = 0; + static printX() { + console.log(MyClass.x); + } +} + +MyClass.x // 0 +MyClass.printX() // 0 +``` + +上面示例中,`x`是静态属性,`printX()`是静态方法。它们都必须通过`MyClass`获取,而不能通过实例对象调用。 + +`static`关键字前面可以使用 public、private、protected 修饰符。 + +```typescript +class MyClass { + private static x = 0; +} + +MyClass.x // 报错 +``` + +上面示例中,静态属性`x`前面有`private`修饰符,表示只能在`MyClass`内部使用,如果在外部调用这个属性就会报错。 + +静态私有属性也可以用 ES6 语法的`#`前缀表示,上面示例可以改写如下。 + +```typescript +class MyClass { + static #x = 0; +} +``` + +`public`和`protected`静态成员可以被继承。 + +```typescript +class A { + public static x = 1; + protected static y = 1; +} + +class B extends A { + static getY() { + return B.y; + } +} + +B.x // 1 +B.getY() // 1 +``` + +上面示例中,类`A`的静态属性`x`和`y`都被`B`继承,公开成员`x`可以在`B`的外部获取,保护成员`y`只能在`B`的内部获取。 + +## 泛型类 + +类也可以写成泛型,使用类型参数。 + +```typescript +class Box { + contents: Type; + + constructor(value:Type) { + this.contents = value; + } +} + +const b:Box = new Box('hello!'); +``` + +上面示例中,类`Box`有类型参数`Type`,因此属于泛型类。新建实例时,变量的类型声明需要带有类型参数的值,不过本例的`Box`可以省略不写,因为可以从等号右边推断得到。 + +注意,静态成员不能使用泛型的类型参数。 + +```typescript +class Box { + static defaultContents: Type; // 报错 +} +``` + +上面示例中,静态属性`defaultContents`的类型写成类型参数`Type`会报错。因为这意味着调用时必须给出类型参数`Box.defaultContents`,并且类型参数发生变化,这个属性也会跟着变,这并不是好的做法。 + +## 抽象类,抽象成员 + +TypeScript 允许在类的定义前面,加上关键字`abstract`,表示该类不能被实例化,只能当作其他类的模板。这种类就叫做“抽象类”(abastract class)。 + +```typescript +abstract class A { + id = 1; +} + +const a = new A(); // 报错 +``` + +上面示例中,直接新建抽象类的实例,会报错。 + +抽象类只能当作基类使用,用来在它的基础上定义子类。 + +```typescript +abstract class A { + id = 1; +} + +class B extends A { + amount = 100; +} + +const b = new B(); + +b.id // 1 +b.amount // 100 +``` + +上面示例中,`A`是一个抽象类,`B`是`A`的子类,继承了`A`的所有成员,并且可以定义自己的成员和实例化。 + +抽象类的子类也可以是抽象类,也就是说,抽象类可以继承其他抽象类。 + +```typescript +abstract class A { + foo:number; +} + +abstract class B extends A { + bar:string; +} +``` + +抽象类的内部可以有已经实现好的属性和方法,也可以有还未实现的属性和方法。后者就叫做“抽象成员”(abstract member),即属性名和方法名有`abstract`关键字,表示该方法需要子类实现。如果子类没有实现抽象成员,就会报错。 + +```typescript +abstract class A { + abstract foo:string; + bar:string = ''; +} + +class B extends A { + foo = 'b'; +} +``` + +上面示例中,抽象类`A`定义了抽象属性`foo`,子类`B`必须实现这个属性,否则会报错。 + +下面是抽象方法的例子。 + +如果抽象类的属性前面加上`abstract`,就表明子类必须给出该方法的实现。 + +```typescript +abstract class A { + abstract execute():string; +} + +class B extends A { + execute() { + return `B executed`; + } +} +``` + +这里有几个注意点。 + +(1)抽象成员只能存在于抽象类,不能存在于普通类。 + +(2)抽象成员不能有具体实现的代码。也就是说,已经实现好的成员前面不能加`abstract`关键字。 + +(3)抽象成员前也不能有`private`修饰符,否则无法在子类中实现该成员。 + +(4)一个子类最多只能继承一个抽象类。 + +总之,抽象类的作用是,确保各种相关的子类都拥有跟基类相同的接口,可以看作是模板。其中的抽象成员都是必须由子类实现的成员,非抽象成员则表示基类已经实现的、由所有子类共享的成员。 + +## this 问题 + +类的方法经常用到`this`关键字,它表示该方法当前所在的对象。 + +```typescript +class A { + name = 'A'; + + getName() { + return this.name; + } +} + +const a = new A(); +a.getName() // 'A' + +const b = { + name: 'b', + getName: a.getName +}; +b.getName() // 'b' +``` + +上面示例中,变量`a`和`b`的`getName()`是同一个方法,但是执行结果不一样,原因就是它们内部的`this`指向不一样的对象。 + +如果`getName()`在变量`a`上运行,`this`指向`a`;如果在`b`上运行,`this`指向`b`。 + +有些场合需要给出`this`类型,但是 JavaScript 函数通常不带有`this`参数,这时 TypeScript 允许函数增加一个名为`this`的参数,放在参数列表的第一位,用来描述函数内部的`this`关键字的类型。 + +```typescript +// 编译前 +function fn( + this: SomeType, + x: number +) { + /* ... */ +} + +// 编译后 +function fn(x) { + /* ... */ +} +``` + +上面示例中,函数`fn()`的第一个参数是`this`,用来声明函数内部的`this`的类型。编译时,TypeScript 一旦发现函数的第一个参数名为`this`,则会去除这个参数,即编译结果不会带有该参数。 + +```typescript +class A { + name = 'A'; + + getName(this: A) { + return this.name; + } +} + +const a = new A(); +const b = a.getName; + +b() // 报错 +``` + +上面示例中,类`A`的`getName()`添加了`this`参数,如果直接调用这个方法,`this`的类型就会跟声明的类型不一致,从而报错。 + +`this`参数的类型可以声明为各种对象。 + +```typescript +function foo( + this: { name: string } +) { + this.name = 'Jack'; + this.name = 0; // 报错 +} + +foo.call({ name: 123 }); // 报错 +``` + +上面示例中,参数`this`的类型是一个带有`name`属性的对象,不符合这个条件的`this`都会报错。 + +TypeScript 提供了一个`noImplicitThis`编译选项。如果打开了这个设置项,如果`this`的值推断为`any`类型,就会报错。 + +```typescript +// noImplicitThis 打开 + +class Rectangle { + constructor( + public width:number, + public height:number + ) {} + + getAreaFunction() { + return function () { + return this.width * this.height; // 报错 + }; + } +} +``` + +上面示例中,`getAreaFunction()`方法返回一个函数,这个函数里面用到了`this`,但是这个`this`跟`Rectangle`这个类没关系,它的类型推断为`any`,所以就报错了。 + +在类的内部,`this`本身也可以当作类型使用,表示当前类的实例对象。 + +```typescript +class Box { + contents:string = ''; + + set(value:string):this { + this.contents = value; + return this; + } +} +``` + +上面示例中,`set()`方法的返回值类型就是`this`,表示当前的实例对象。 + +注意,`this`类型不允许应用于静态成员。 + +```typescript +class A { + static a:this; // 报错 +} +``` + +上面示例中,静态属性`a`的返回值类型是`this`,就报错了。原因是`this`类型表示实例对象,但是静态成员拿不到实例对象。 + +有些方法返回一个布尔值,表示当前的`this`是否属于某种类型。这时,这些方法的返回值类型可以写成`this is Type`的形式,其中用到了`is`运算符。 + +```typescript +class FileSystemObject { + isFile(): this is FileRep { + return this instanceof FileRep; + } + + isDirectory(): this is Directory { + return this instanceof Directory; + } + + // ... +} +``` + +上面示例中,两个方法的返回值类型都是布尔值,写成`this is Type`的形式,可以精确表示返回值。 + +## 参考链接 + +- [TypeScript Constructor in Interface](http://fritzthecat-blog.blogspot.com/2018/06/typescript-constructor-in-interface.html) diff --git a/docs/comment.md b/docs/comment.md new file mode 100644 index 0000000..c3b2d7e --- /dev/null +++ b/docs/comment.md @@ -0,0 +1,212 @@ +# TypeScript 的注释指令 + +TypeScript 接受一些注释指令。 + +所谓“注释指令”,指的是采用 JS 双斜杠注释的形式,向编译器发出的命令。 + +## `// @ts-nocheck` + +`// @ts-nocheck`告诉编译器不对当前脚本进行类型检查,可以用于 TypeScript 脚本,也可以用于 JavaScript 脚本。 + +```javascript +// @ts-nocheck + +const element = document.getElementById(123); +``` + +上面示例中,`document.getElementById(123)`存在类型错误,但是编译器不对该脚本进行类型检查,所以不会报错。 + +## `// @ts-check` + +如果一个 JavaScript 脚本顶部添加了`// @ts-check`,那么编译器将对该脚本进行类型检查,不论是否启用了`checkJs`编译选项。 + +```javascript +// @ts-check +let isChecked = true; + +console.log(isChceked); // 报错 +``` + +上面示例是一个 JavaScript 脚本,`// @ts-check`告诉 TypeScript 编译器对其进行类型检查,所以最后一行会报错。 + +## `// @ts-ignore` + +`// @ts-ignore`或`// @ts-expect-error`,告诉编译器不对下一行代码进行类型检查,可以用于 TypeScript 脚本,也可以用于 JavaScript 脚本。 + +```typescript +let x:number; + +x = 0; + +// @ts-expect-error +x = false; // 不报错 +``` + +上面示例中,最后一行是类型错误,变量`x`的类型是`number`,不能等于布尔值。但是因为前面加上了`// @ts-expect-error`,编译器会跳过这一行的类型检查,所以不会报错。 + +## JSDoc + +TypeScript 直接处理 JS 文件时,如果无法推断出类型,会使用 JS 脚本里面的 JSDoc 注释。 + +使用 JSDoc 时,有两个基本要求。 + +(1)JSDoc 注释必须以`/**`开始,其中星号(`*`)的数量必须为两个。若使用其他形式的多行注释,则 JSDoc 会忽略该条注释。 + +(2)JSDoc 注释必须与它描述的代码处于相邻的位置,并且注释在上,代码在下。 + +下面是 JSDoc 的一个简单例子。 + +```javascript +/** + * @param {string} somebody + */ +function sayHello(somebody) { + console.log('Hello ' + somebody); +} +``` + +上面示例中,注释里面的`@param`是一个 JSDoc 命令,表示下面的函数`sayHello()`的参数`somebody`类型为`string`。 + +TypeScript 编译器支持的大部分的 JSDoc 命令,下面介绍其中的一些。 + +### @typedef + +`@typedef`命令创建自定义类型,等同于 TypeScript 里面的类型别名。 + +```javascript +/** + * @typedef {(number | string)} NumberLike + */ +``` + +上面示例中,定义了一个名为`NumberLike`的新类型,它是由`number`和`string`构成的联合类型,等同于 TypeScript 的如下语句。 + +```typescript +type NumberLike = string | number; +``` + +### @type + +`@type`命令定义变量的类型。 + +```javascript +/** + * @type {string} + */ +let a; +``` + +上面示例中,`@type`定义了变量`a`的类型为`string`。 + +在`@type`命令中可以使用由`@typedef`命令创建的类型。 + +```javascript +/** + * @typedef {(number | string)} NumberLike + */ + +/** + * @type {NumberLike} + */ +let a = 0; +``` + +在`@type`命令中允许使用 TypeScript 类型及其语法。 + +```javascript +/**@type {true | false} */ +let a; + +/** @type {number[]} */ +let b; + +/** @type {Array} */ +let c; + +/** @type {{ readonly x: number, y?: string }} */ +let d; + +/** @type {(s: string, b: boolean) => number} */ +let e; +``` + +### @param + +`@param`命令用于定义函数参数的类型。 + +```javascript +/** + * @param {string} x + */ +function foo(x) {} +``` + +如果是可选参数,需要将参数名放在方括号`[]`里面。 + +```javascript +/** + * @param {string} [x] + */ +function foo(x) {} +``` + +方括号里面,还可以指定参数默认值。 + +```javascript +/** + * @param {string} [x="bar"] + */ +function foo(x) {} +``` + +上面示例中,参数`x`的默认值是字符串`bar`。 + +### @return,@returns + +`@return`和`@returns`命令的作用相同,指定函数返回值的类型。 + +```javascript +/** + * @return {boolean} + */ +function foo() { + return true; +} + +/** + * @returns {number} + */ +function bar() { + return 0; +} +``` + +### @extends 和类型修饰符 + +`@extends`命令用于定义继承的基类。 + +/** + * @extends {Base} + */ +class Derived extends Base { +} +``` + +`@public`、`@protected`、`@private`分别指定类的公开成员、保护成员和私有成员。 + +`@readonly`指定只读成员。 + +```javascript +class Base { + /** + * @public + * @readonly + */ + x = 0; + + /** + * @protected + */ + y = 0; +} +``` diff --git a/docs/conditional-types.md b/docs/conditional-types.md new file mode 100644 index 0000000..606b928 --- /dev/null +++ b/docs/conditional-types.md @@ -0,0 +1,148 @@ +# 条件类型 + +```typescript +T extends U ? X : Y +``` + +上面定义中,T、U、X、Y 代表任意类型。`T extends U`表示类型的测试条件,如果满足此条件,返回类型`X`,否则返回类型`Y`。 + +下面是一个例子。 + +```typescript +type NonNullable = T extends null | undefined ? never : T; +``` + +上面式子定义了一个范型`NonNullable`,用来检测某个类型是否非空。 + +```typescript +type EmailAddress = string | string[] | null | undefined; + +// 等同于 type NonNullableEmailAddress = string | string[]; +type NonNullableEmailAddress = NonNullable; +``` + +TypeScript 提供了一些预定义的条件类型,下面逐一介绍。 + +## `NonNullable` + +`NonNullable`从类型`T`里面过滤掉`null`和`undefined`。 + +```typescript +type NonNullable = T extends null | undefined ? never : T; +``` + +```typescript +type A = NonNullable; // boolean +type B = NonNullable; // number +type C = NonNullable; // string +type D = NonNullable; // never +``` + +## `Extract` + +```typescript +type Extract = T extends U ? T : never; +``` + +`Extract`类型表达式相当于提取功能,只要`T`符合`U`就返回`T`,否则就过滤掉。 + +```typescript +type A = Extract; // string[] +type B = Extract<(() => void) | null, Function>; // () => void +type C = Extract<200 | 400, 200 | 201>; // 200 +type D = Extract; // never +``` + +## `Exclude` + +`Exclude`相当于排除功能,只要`T`符合`U`就过滤掉,否则返回`T`。 + +```typescript +type Exclude = T extends U ? never : T; +``` + +```typescript +type A = Exclude; // string +type B = Exclude<(() => void) | null, Function>; // null +type C = Exclude<200 | 400, 200 | 201>; // 400 +type D = Exclude; // number +``` + +## `ReturnType` + +`ReturnType`提取函数的返回类型。 + +```typescript +type ReturnType any> = T extends ( + ...args: any[] +) => infer R + ? R + : any; +``` + +```typescript +type A = ReturnType<() => string>; // string +type B = ReturnType<() => () => any[]>; // () => any[] +type C = ReturnType; // number +type D = ReturnType; // boolean +``` + +## `Parameters` + +`Parameters`提供函数`T`的所有参数类型,它的返回值是一个`tuple`类型,或者`never`(如果 T 不是函数)。 + +```typescript +type Parameters any> = T extends ( + ...args: infer P +) => any + ? P + : never; +``` + +```typescript +type A = Parameters<() => void>; // [] +type B = Parameters; // [any] +type C = Parameters; // [string, (number | undefined)?] +type D = Parameters; // number[] +``` + +`Array.isArray()`只有一个参数,所以返回的类型是`[any]`,而不是`any[]`。`Math.max()`的参数是任意多个数值,而不是一个数值数组,所以返回的类型是`number[]`,而不是`[number[]]`。 + +## `ConstructorParameters` + +`ConstructorParameters`提取一个构造函数的所有参数类型。它的返回值是一个 tuple 类型,成员是所有参数的类型,如果 T 不是函数,则返回 never。 + +```typescript +type ConstructorParameters< + T extends new (...args: any[]) => any +> = T extends new (...args: infer P) => any ? P : never; +``` + +```typescript +type A = ConstructorParameters; +// [(string | undefined)?] + +type B = ConstructorParameters; +// string[] + +type C = ConstructorParameters; +// [string, (string | undefined)?] +``` + +## `InstanceType` + +`InstanceType`提取构造函数的返回值的类型,等同于构造函数的`ReturnType`。 + +```typescript +type InstanceType any> = T extends new ( + ...args: any[] +) => infer R + ? R + : any; +``` + +```typescript +type A = InstanceType; // Error +type B = InstanceType; // Function +type C = InstanceType; // RegExp +``` diff --git a/docs/d.ts.md b/docs/d.ts.md new file mode 100644 index 0000000..578140f --- /dev/null +++ b/docs/d.ts.md @@ -0,0 +1,609 @@ +# d.ts 类型声明文件 + +## 简介 + +模块需要提供一个类型声明文件(declaration file),让模块使用者了解它的接口类型。 + +类型声明文件就是接口的类型描述,写在一个单独的文件里面。它里面只有类型代码,没有具体的代码实现。 + +它的文件名一般为`[模块名].d.ts`的形式,其中的`d`表示 declaration(声明)。 + +举例来说,有一个模块的代码如下。 + +```typescript +const maxInterval = 12; +function getArrayLength(arr) { + return arr.length; +} +module.exports = { + getArrayLength, + maxInterval, +}; +``` + +它的类型声明文件可以写成下面这样。 + +```typescript +export function getArrayLength(arr: any[]): number; +export const maxInterval: 12; +``` + +如果输出的是一个值,那么类型声明文件需要使用`export default`或`export=`。 + +```typescript +// 模块输出 +module.exports = 3.142; + +// 类型输出文件 +// 写法一 +declare const pi: number; +export default pi; + +// 写法二 +declare const pi: number; +export= pi; +``` + +下面是一个简单例子,有一个类型声明文件`types.d.ts`。 + +```typescript +// types.d.ts +export interface Character { + catchphrase?: string; + name: string; +} +``` + +然后,就可以在 TypeScript 脚本里面导入该文件声明的类型。 + +```typescript +// index.ts +import { Character } from "./types.d.ts"; + +export const character:Character = { + catchphrase: "Yee-haw!", + name: "Sandy Cheeks", +}; +``` + +定义了类型声明文件以后,可以将其包括在项目的 tsconfig.json 文件里面,方便打包和其他脚本加载。比如,moment 模块的类型声明文件是`moment.d.ts`,将其加入 tsconfig.json。 + +```typescript +{ + "compilerOptions": {}, + "files": [ + "src/index.ts", + "typings/moment.d.ts" + ] +} +``` + +有些模块是 CommonJS 格式,采用`module.exports`输出接口。它的类型描述文件可以写成下面的形式。 + +```typescript +declare module 'moment' { + function moment(): any; + export = moment; +} +``` + +上面示例中,模块`moment`是 CommonJS 格式,它的内部有一个函数`moment()`,而`export =`表示`module.exports`输出的就是这个函数。 + +类型声明文件主要有以下三种来源。 + +- TypeScript 语言内置的类型声明文件。 +- 第三方类型声明文件,需要自己安装。 +- 自己编写的类型声明文件。 + +### 内置声明文件 + +安装 TypeScript 语言时,会同时安装一些内置声明文件,主要是 JavaScript 语言接口和运行环境 API 的类型声明。 + +这些内置声明文件位于 TypeScript 语言安装目录的`lib`文件夹内,数量大概有几十个,下面是其中一些主要文件。 + +- lib.d.ts +- lib.dom.d.ts +- lib.es2015.d.ts +- lib.es2016.d.ts +- lib.es2017.d.ts +- lib.es2018.d.ts +- lib.es2019.d.ts +- lib.es2020.d.ts +- lib.es5.d.ts +- lib.es6.d.ts + +这些内置声明文件的文件名统一为“lib.[description].d.ts”的形式,其中`description`部分描述了文件内容。比如,`lib.dom.d.ts`这个文件就描述了 DOM 结构的类型。 + +TypeScript 编译器会自动加载这些内置声明文件,所以不需要特别的配置。 + +### 第三方声明文件 + +如果项目中使用了外部的某个第三方代码库,那么就需要这个库的类型声明文件。 + +这时又分成三种情况。 + +(1)这个库自带了类型声明文件。 + +(2)这个库没有自带,但是可以找到社区制作的类型声明文件。 + +(3)找不到类型声明文件,需要自己写。 + +一般来说,如果这个库的源码包含了`[vendor].d.ts`文件,那么就自带了类型声明文件。其中的`vendor`表示这个库的名字,比如`moment`这个库就自带`moment.d.ts`。 + +### DefinitelyTyped 社区 + +第三方库如果没有提供类型声明文件,社区往往会提供。TypeScript 社区主要使用 [DefinitelyTyped 仓库](https://github.com/DefinitelyTyped/DefinitelyTyped),各种类型声明文件都会提交到那里,已经包含了几千个第三方库。 + +TypeScript 官网有 DefinitelyTyped 的搜索入口,需要第三方声明文件的时候,就可以去 [www.typescriptlang.org/dt/search](https://www.typescriptlang.org/dt/search) 搜搜看。 + +这些声明文件都会发布到 npm 的`@types`名称空间之下。比如,jQuery 的类型声明文件就放在`@types/jquery`这个库,安装这个库就可以了。 + +```bash +$ npm install @types/jquery --save-dev +``` + +执行上面的命令,`@types/jquery`这个库就安装到项目的`node_modules/@types/jquery`目录,里面的`index.d.ts`文件就是 jQuery 的类型声明文件。 + +然后,在`tsconfig.json`文件里面加上类型声明文件的位置。 + +```javascript +{ + "compilerOptions": { + "types" : ["jquery"] + } +} +``` + +上面设置中,`types`属性是一个数组,成员是所要加载的类型声明文件,要加载几个文件,就有几个成员,每个成员在子目录`node_modules/@types`下面都有一个自己的目录。 + +这样的话,你的项目加载 jQuery 时,编译器就会正确加载它的类型声明文件。 + +## declare 关键字 + +类型声明文件只包含类型描述,不包含具体实现,所以非常适合使用 declare 语句来描述类型。 + +declare 字的具体用法,详见《declare 关键字》一章,这里讲解如何在类型声明文件里面使用它。 + +类型声明文件里面,变量的类型描述必须使用`declare`命令,否则会报错。 + +```typescript +declare let foo:string; +``` + +interface 类型有没有`declare`都可以。 + +```typescript +interface Foo {} // 正确 +declare interface Foo {} // 正确 +``` + +类型声明文件里面,可以使用`export`命令。 + +```typescript +export interface Data { + version: string; +} +``` + +下面是 moment 模块的类型描述文件`moment.d.ts`的例子。 + +```typescript +declare module 'moment' { + export interface Moment { + format(format:string):string; + add( + amount:number, + unit:'days' | 'months' | 'years' + ):Moment; + subtract( + amount:number, + unit:'days' | 'months' | 'years' + ): Moment; + } + + function moment( + input?:string | Date + ):Moment; + + export default moment; +} +``` + +下面是 D3 库的`D3.d.ts`文件。 + +```typescript +declare namespace D3 { + export interface Selectors { + select: { + (selector: string): Selection; + (element: EventTarget): Selection; + }; + } + export interface Event { + x: number; + y: number; + } + export interface Base extends Selectors { + event: Event; + } +} +declare var d3: D3.Base; +``` + +## 模块发布 + +类型声明文件写好后,如果要在 npm 上面发布,可以在 package.json 文件添加一个`types`字段,指明类型声明文件的位置。 + +```typescript +{ + "name": "awesome", + "author": "Vandelay Industries", + "version": "1.0.0", + "main": "./lib/main.js", + "types": "./lib/main.d.ts" +} +``` + +上面示例中,`types`字段给出了类型声明文件的位置。 + +注意,`types`字段也可以写成`typings`。 + +另外,如果类型声明文件为`index.d.ts`,且在项目的根目录(与`index.js`在一起),那么不需要注明`types`字段。 + +有时,类型声明文件会单独发布成一个 npm 模块,这时用户就必须同时加载该模块。 + +```typescript +{ + "name": "browserify-typescript-extension", + "author": "Vandelay Industries", + "version": "1.0.0", + "main": "./lib/main.js", + "types": "./lib/main.d.ts", + "dependencies": { + "browserify": "latest", + "@types/browserify": "latest", + "typescript": "next" + } +} +``` + +上面示例是一个模块的 package.json 文件,该文件需要 browserify 模块。由于后者的类型声明文件放在另一个模块`@types/browserify`,所以还必需加载那个模块。 + +## 三斜杠命令 + +其他脚本可以使用三斜杠命令,加载类型声明文件。 + +三斜杠命令(`///`)是一个编译器命令,用来指定编译器行为。它只能用在文件的头部,如果用在其他地方,会被当作普通的注释。 + +若一个文件中使用了三斜线命令,那么在三斜线命令之前只允许使用单行注释、多行注释和其他三斜线命令,否则三斜杠命令会被当作普通的注释。 + +三斜杠命令主要包含三个参数,代表三种不同的命令。 + +- path +- types +- lib + +下面依次进行讲解。 + +### `/// ` + +`/// `是最常见的三斜杠命令,告诉编译器在编译时需要包括的文件,常用来声明当前脚本依赖的类型文件。 + +```typescript +/// + +let count = add(1, 2); +``` + +上面示例中,编译当前脚本时,还会同时编译`lib.ts`。编译产物会有两个 JS 文件,一个当前脚本,另一个就是`lib.js`。 + +编译器会在预处理阶段,找出所有三斜杠引用的文件,将其添加到编译列表中,然后一起编译。 + +`path`参数指定了所引入文件的路径。如果该路径是一个相对路径,则基于当前脚本的路径进行计算。 + +使用该命令时,有以下两个注意事项。 + +- `path`参数必须指向一个存在的文件,若文件不存在会报错。 +- `path`参数不允许指向当前文件。 + +默认情况下,每个三斜杠命令引入的脚本,都会编译成单独的 JS 文件。如果希望编译后只产出一个合并文件,可以使用编译参数`outFile`。但是,`outFile`编译参数不支持合并 CommonJS 模块和 ES 模块,只有当编译参数`module`的值设为 None、System 或 AMD 时,才能编译成一个文件。 + +如果打开了编译参数`noResolve`,则忽略三斜杠指令。将其当作一般的注释,原样保留在编译产物中。 + +### `/// ` + +types 参数用来告诉编译器当前脚本依赖某个 DefinitelyTyped 类型库,通常安装在`node_modules/@types`目录。 + +types 参数的值是类型库的名称,也就是安装到`node_modules/@types`目录中的子目录的名字。 + +```typescript +/// +``` + +上面示例中,这个三斜杠命令表示编译时添加 Node.js 的类型库,实际添加的脚本是`node_modules`目录里面的`@types/node/index.d.ts`。 + +可以看到,这个命令的作用类似于`import`命令。 + +注意,这个命令只在你自己手写`.d.ts`文件时,才有必要用到,也就是说,只应该用在`.d.ts`文件中,普通的`.ts`脚本文件不需要写这个命令。 + +我们应该只在类型声明文件(.d.ts)中使用“/// ”三斜线命令,而不应该在普通的`.ts`脚本文件中使用该命令。如果是普通的`.ts`脚本,可以使用`tsconfig.json`文件的`types`属性指定依赖的类型库。 + +### `/// ` + +`/// `命令允许脚本文件显式包含内置 lib 库,等同于在`tsconfig.json`文件里面使用`lib`属性指定 lib 库。 + +前文说过,安装 TypeScript 软件包时,会同时安装一些内置的类型声明文件,即内置的 lib 库。这些库文件位于 TypeScript 安装目录的`lib`文件夹中,它们描述了 JavaScript 语言的标准 API。 + +库文件并不是固定的,会随着 TypeScript 版本的升级而更新。库文件统一使用“lib.[description].d.ts”的命名方式,而`/// `里面的`lib`属性的值就是库文件名的`description`部分,比如`lib="es2015"`就表示加载库文件`lib.es2015.d.ts`。 + +```typescript +/// +``` + +上面示例中,`es2017.string`对应的库文件就是`lib.es2017.string.d.ts`。 + +## 自定义类型声明文件 + +有时实在没有第三方库的类型声明文件,你可以告诉 TypeScript 相关对象的类型是`any`。比如,使用 jQuery 的脚本可以写成下面这样。 + +```typescript +declare var $: any +``` + +上面代码表示,jQuery 的`$`对象是外部引入的,类型是`any`,也就是 TypeScript 不用对它进行类型检查。 + + + +为了描述不是用 TypeScript 编写的库的形状,我们需要声明库公开的 API。通常,这些是在.d.ts文件中定义的。如果您熟悉 C/C++,您可以将这些视为.h文件。 + +在 Node.js 中,大多数任务都是通过加载一个或多个模块来完成的。我们可以为每个模块,定义一个自己的 .d.ts 文件,但是把所有模块的类型定义放在一个大的 .d.ts 文件更方便。 + + + +开源库 [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped) 提供大部分常用第三方库的类型,在写自己的`[vendor.d.ts]`之前,可以先到这个库查看,它有没有提供。下面是 node.d.ts 的简化的样子。 + +```typescript +declare module "url" { + export interface Url { + protocol?: string; + hostname?: string; + pathname?: string; + } + export function parse( + urlStr: string, + parseQueryString?, + slashesDenoteHost? + ): Url; +} +declare module "path" { + export function normalize(p: string): string; + export function join(...paths: any[]): string; + export var sep: string; +} +``` + +后面就可以在脚本里面,使用`/// node.d.ts`给出类型定义。 + +```typescript +/// +``` + +```typescript +/// +import * as URL from "url"; +let myUrl = URL.parse("https://www.typescriptlang.org"); +``` + +有时候,很难为别人的模块写出完整的 .d.ts 类型定义文件。TypeScript 这时允许在 .d.ts 里面只写模块名,不写具体的类型定义。 + +```typescript +declare module "hot-new-module"; +``` + +脚本加载这个模块以后,所有引入的接口都是 any 类型。 + +```typescript +import x, { y } from "hot-new-module"; +x(y); +``` + +d.ts 文件里面,需要声明引入变量的类型。比如,jQuery 可以这样声明。 + +```typescript +declare var $: any; +``` + +上面示例表示变量`$`可以是任意类型。 + +也可以像下面这样,自定义一个 JQuery 类型。 + +```typescript +declare type JQuery = any; +declare var $: JQuery; +``` + +另一个方法是声明一个模块 jquery。 + +```typescript +declare module "jquery"; +``` + +然后在脚本里面加载这个模块。 + +```typescript +import * as $ from "jquery"; +``` + +下面是 Node.js 的 Process 模块的例子。 + +```typescript +interface Process { + exit(code?: number): void; +} +declare var process: Process; +``` + +如果你要自己为 Process 对象添加一个方法`exitWithLogging()`,就需要自己补上该方法的类型注释。 + +```typescript +interface Process { + exitWithLogging(code?: number): void; +} +process.exitWithLogging = function() { + console.log("exiting"); + process.exit.apply(process, arguments); +}; +``` + +## package.json + +TypeScript扩展了“package.json”文件,增加了typings属性和types属性。虽然两者的名字不同,但是作用相同,它们都用于指定当前npm包提供的声明文件。 + +```typescript +{ + "name": "my-package", + "version": "1.0.0", + "main": "index.js", + "typings": "index.d.ts" +} +``` + +此例中,使用typings属性定义了“my-package”包的声明文件为“index.d.ts”文件。当TypeScript编译器进行模块解析时,将会读取该属性的值并使用指定的“index.d.ts”文件作为声明文件。这里我们也可以将typings属性替换为types属性,两者是等效的。 + +如果一个npm包的声明文件为“index.d.ts”且位于npm包的根目录下,那么在“package.json”文件中也可以省略typings属性和types属性,因为编译器在进行模块解析时,若在“package.json”文件中没有找到typings属性和types属性,则将默认使用名为“index.d.ts”的文件作为声明文件。 + +在TypeScript 3.1版本中,编译器能够根据当前安装的TypeScript版本来决定使用的声明文件,该功能是通过“package.json”文件中的typesVersions属性来实现的。 + +```javascript +{ + "name": "my-package", + "version": "1.0.0", + "main": "index.js", + "typings": "index.d.ts", + "typesVersions": { + ">=3.7": { + "*": ["ts3.7/*"] + }, + ">=3.1": { + "*": ["ts3.1/*"] + } + } +} +``` + +此例中,我们定义了两个声明文件匹配规则: + +▪第7行,当安装了TypeScript 3.7及以上版本时,将使用“ts3.7”目录下的声明文件。 + +▪第10行,当安装了TypeScript 3.1及以上版本时,将使用“ts3.1”目录下的声明文件。 + +需要注意的是,typesVersions中的声明顺序很关键,编译器将从第一个声明(此例中为">=3.7")开始尝试匹配,若匹配成功,则应用匹配到的值并退出。因此,若将此例中的两个声明调换位置,则会产生不同的结果。 + +此外,如果typesVersions中不存在匹配的版本,如当前安装的是TypeScript 2.0版本,那么编译器将使用typings属性和types属性中定义的声明文件。 + +## @types + +TypeScript 官方提供了加载许多常用模块的类型注释,都放在 NPM 仓库的 @types 名称空间下面。 + +你可以像安装 npm 模块一样,安装外部库的类型注释。 + +```bash +$ npm install @types/jquery --save-dev +``` + +`@types/jquery`里面包括了模块类型和全局变量的类型。 + +你可以直接使用下面的语句。 + +```typescript +import * as $ from "jquery"; +``` + +## 自定义声明文件 + +如果使用的第三方代码库没有提供内置的声明文件,而且在DefinitelyTyped仓库中也没有对应的声明文件,那么就需要开发者自己编写一个声明文件。 + +如果我们不想编写一个详尽的声明文件,而只是想要跳过对某个第三方代码库的类型检查,则可以使用下面介绍的方法。 + +如果为 jQuery 创建一个“.d.ts”声明文件,例如“jquery.d.ts”。“jquery.d.ts”声明文件的内容如下: + +```typescript +declare module 'jquery'; +``` + +此例中的代码是外部模块声明,该声明会将jquery模块的类型设置为any类型。jquery模块中所有成员的类型都成了any类型,这等同于不对jQuery进行类型检查。 + +“typings.d.ts”文件的内容如下: + +```typescript +declare module 'mod' { + export function add(x: number, y: number): number; +} +``` + +在“a.ts”文件中,可以使用非相对模块导入语句来导入外部模块“mod”。示例如下: + +```typescript +import * as Mod from 'mod'; + +Mod.add(1, 2); +``` + +## es6-shim.d.ts + +如果代码编译成 ES5(`tsconfig.json`设成`"target": ES5`),但是代码会用到 ES6 的 API,并且希望 IDE 能够正确识别,可以引入`es6-shim.d.ts`。 + +```bash +$ npm install @types/es6-shim -D +``` + +`tsconfig.json`加入下面的设置。 + +```javascript +"types" : ["jquery", "es6-shim"] +``` + +另外一个新的垫片库是`core-js`。 + +## reference 命令 + +自己以前的项目可以自定义一个类型声明文件,比如`typings.d.ts`。 + +比如,你以前写过一个函数。 + +```typescript +function greeting(name) { + console.log("hello " + name); +} +``` + +新项目要用到这个函数,你可以为这个函数单独写一个类型文件`src/typings.d.ts`。 + +```typescript +export function greeting(name: string): void; +``` + +然后,需要在用到这个库的脚本头部加上一行,用三斜杠语法告诉 TypeScript 类型声明文件的位置。 + +```typescript +/// +``` + +如果类型声明文件是随 NPM 安装的,那么`reference`语句的属性需要从`path`改成`type`。 + +```typescript +/// +``` + +## JavaScript 项目加入 TypeScript + +如果现有的 JavaScript 项目需要加入 TypeScript,可以在`tsconfig.json`文件加入`"allowJs": true`设置,表示将 JS 文件一起复制到编译产物目录。 + +这时,TypeScript 不会对 JavaScript 脚本进行类型检查。如果你希望也进行类型检查,可以设置`"checkJs": true`。 + +另一种方法是在 JavaScript 脚本的第一行,加上注释`//@ts-check`,这时 TypeScript 也会对这个脚本进行检查。 + +打开`"checkJs": true`以后,如果不希望对有的 JavaScript 脚本进行类型检查,可以在该脚本头部加上`//@ts-ignore`。 + +You can also help tsc with type inference by adding the JSDoc annotations (such as +@param and @return) to your JavaScript code. diff --git a/docs/declare.md b/docs/declare.md new file mode 100644 index 0000000..ed60d69 --- /dev/null +++ b/docs/declare.md @@ -0,0 +1,382 @@ +# declare 关键字 + +## 简介 + +declare 关键字用来告诉编译器,某个类型是存在的,可以在当前文件中使用。 + +它的直接作用,就是让当前文件可以使用其他文件声明的类型。因此,编译单个脚本时,不会因为使用了外部类型而报错。 + +declare 关键字可以描述以下类型。 + +- 变量(const、let、var 命令声明) +- type 或者 interface 命令声明的类型 +- class +- enum +- 函数(function) +- 模块(module) +- 命名空间(namespace) + +declare 关键字的重要特点是,它只是通知编译器某个类型是存在的,不用给出具体实现。 + +declare 只能用来描述已经存在的变量和数据结构,不能用来声明新的变量和数据结构。另外,所有 declare 语句都不会出现在编译后的文件里面。 + +## declare variable + +declare 关键字可以给出外部变量的类型描述。 + +举例来说,当前脚本使用了其他脚本定义的全局变量`x`。 + +```typescript +x = 123; // 报错 +``` + +上面示例中,变量`x`是其他脚本定义的,当前脚本不知道它的类型,编译器就会报错。 + +这时使用 declare 命令给出它的类型,就不会报错了。 + +```typescript +declare let x:number; +x = 1; +``` + +如果 declare 关键字没有给出变量的具体类型,那么变量类型就是`any`。 + +```typescript +declare let x; +x = 1; +``` + +上面示例中,变量`x`的类型为`any`。 + +下面的例子是脚本使用浏览器全局对象`document`。 + +```typescript +declare var document; +document.title = "Hello"; +``` + +上面示例中,declare 告诉编译器,变量`document`的类型是外部定义的(具体定义在 TypeScript 内置文件`lib.d.ts`)。 + +如果 TypeScript 没有找到`document`的外部定义,这里就会假定它的类型是`any`。 + +注意,declare 关键字只用来给出类型描述,是纯的类型代码,不允许设置变量的初始值,即不涉及值。 + +```typescript +// 报错 +declare let x:number = 1; +``` + +上面示例中,declare 设置了变量的初始值,结果就报错了。 + +## declare function + +declare 关键字可以给出外部函数的类型描述。 + +下面是一个例子。 + +```typescript +declare function sayHello( + name:string +):void; + +sayHello('张三'); +``` + +上面示例中,declare 命令给出了`sayHello()`的类型描述,因此可以直接使用它。 + +注意,这种单独的函数类型声明语句,只能用于`declare`命令后面。一方面,TypeScript 不支持单独的函数类型声明语句;另一方面, declare 关键字后面也不能带有函数的具体实现。 + +```typescript +// 报错 +function sayHello( + name:string +):void; +function sayHello(name) { + return '你好,' + name; +} +``` + +上面示例中,单独写函数的类型声明就会报错。 + +## declare class + +declare 给出 class 的描述描述写法如下。 + +```typescript +declare class Animal { + constructor(name:string); + eat():void; + sleep():void; +} +``` + +下面是一个复杂一点的例子。 + +```typescript +declare class C { + // 静态成员 + public static s0():string; + private static s1:string; + + // 属性 + public a:number; + private b:number; + + // 构造函数 + constructor(arg:number); + + // 方法 + m(x:number, y:number):number; + + // 存取器 + get c():number; + set c(value:number); + + // 索引签名 + [index:string]:any; +} +``` + +同样的,declare 后面不能给出 Class 的具体实现或初始值。 + +## declare module,declare namespace + +如果想把变量、函数、类组织在一起,可以将 declare 与 module 或 namespace 一起使用。 + +```typescript +declare namespace AnimalLib { + class Animal { + constructor(name:string); + eat():void; + sleep():void; + } + + type Animals = 'Fish' | 'Dog'; +} + +// 或者 +declare module AnimalLib { + class Animal { + constructor(name:string); + eat(): void; + sleep(): void; + } + + type Animals = 'Fish' | 'Dog'; +} +``` + +上面示例中,declare 关键字给出了 module 或 namespace 的类型描述。 + +declare module 和 declare namespace 里面,加不加 export 关键字都可以。 + +```typescript +declare namespace Foo { + export var a: boolean; +} + +declare module 'io' { + export function readFile(filename:string):string; +} +``` + +上面示例中,namespace 和 module 里面使用了 export 关键字。 + +下面的例子是当前脚本使用了`myLib`这个外部库,它有方法`makeGreeting()`和属性`numberOfGreetings`。 + +```typescript +let result = myLib.makeGreeting('你好'); +console.log('欢迎词:' + result); +let count = myLib.numberOfGreetings; +``` + +`myLib`的类型描述就可以这样写。 + +```typescript +declare namespace myLib { + function makeGreeting(s:string):string; + let numberOfGreetings:number; +} +``` + +declare 关键字的另一个用途,是为外部模块添加属性和方法时,给出新增部分的类型描述。 + +```typescript +import { Foo as Bar } from 'moduleA'; + +declare module 'moduleA' { + interface Bar extends Foo { + custom: { + prop1:string; + } + } +} +``` + +上面示例中,从模块`moduleA`导入了一个`Foo`,将其重命名为`Bar`,并用 declare 关键字增加一个属性`custom`。 + +declare module 后面的模块名可以使用通配符。 + +```typescript +declare module 'my-plugin-*' { + interface PluginOptions { + enabled: boolean; + priority: number; + } + + function initialize(options: PluginOptions): void; + export = initialize; +} +``` + +上面示例中,模块名`my-plugin-*`表示适配所有以`my-plugin-`开头的模块名(比如`my-plugin-logger`)。 + +## declare global + +如果要为 JavaScript 引擎的原生对象添加属性和方法,可以使用`declare global {}`语法。 + +```typescript +export {}; + +declare global { + interface String { + toSmallString(): string; + } +} + +String.prototype.toSmallString = ():string => { + // 具体实现 + return ''; +}; +``` + +上面示例中,为 JavaScript 原生的`String`对象添加了`toSmallString()`方法。declare global 给出这个新增方法的类型描述。 + +这个示例第一行的空导出语句`export {}`,表示当前脚本是一个模块。这是因为 declare global必须用在模块里面。 + +下面的示例是为 window 对象添加一个属性`myAppConfig`。 + +```typescript +export {}; + +declare global { + interface window { + myAppConfig:object; + } +} + +const config = window.myAppConfig; +``` + +declare global 只能扩充现有对象的类型描述,不能增加新的顶层类型。 + +## declare enum + +declare 关键字给出 enum 类型描述的例子如下,后面的写法都是允许的。 + +```typescript +declare enum E1 { + A, + B, +} + +declare enum E2 { + A = 0, + B = 1, +} + +declare const enum E3 { + A, + B, +} + +declare const enum E3 { + A = 0, + B = 1, +} +``` + +## declare module 命令 + +我们可以为每个模块文件,定义一个`.d.ts`文件。但是,更方便的做法是为整个项目,定义一个大的`.d.ts`文件,在这个文件里面使用`declare module`定义每个模块文件的类型。 + +下面的示例是`node.d.ts`文件的一部分。 + +```typescript +declare module "url" { + export interface Url { + protocol?: string; + hostname?: string; + pathname?: string; + } + + export function parse( + urlStr: string, + parseQueryString?, + slashesDenoteHost? + ): Url; +} + +declare module "path" { + export function normalize(p: string): string; + export function join(...paths: any[]): string; + export var sep: string; +} +``` + +使用时,脚本使用三斜杠命令,加载这个类型声明文件。 + +```typescript +/// +``` + +如果不加载上面的`reference`标签,脚本文件使用外部模块时,就需要在脚本里面使用 declare 命令单独给出外部模块的类型。 + +但是,对于某些第三方模块,原始作者没有提供接口类型,这时可以在脚本顶部加上下面一行命令。 + +```typescript +declare module "模块名"; + +// 例子 +declare module "hot-new-module"; +``` + +加上上面的命令以后,外部模块即使没有类型,也可以通过编译。但是,从该模块输入的所有接口都将为`any`类型。 + +## 扩展模块类型 + +declare 可以用来对一个现有模块进行类型扩充,主要适合需要扩充外部模块的情况。 + +如果一个项目有多个模块文件,可以对一个文件中声明的类型,在另一个文件中进行扩展。 + +```typescript +// a.ts +export interface A { + x: number; +} + +// b.ts +import { A } from './a'; + +declare module './a' { + interface A { + y: number; + } +} + +const a:A = { x: 0, y: 0 }; +``` + +上面示例中,脚本`a.ts`定义了一个接口`A`,脚本`b.ts`为这个接口添加了属性`y`。`declare module './a' {}`表示对`a.ts`里面的模块,进行类型声明,而同名 interface 会自动合并,所以等同于扩展类型。 + +使用这种语法进行模块的类型扩展时,有两点需要注意: + +(1)`declare module NAME`语法里面的模块名`NAME`,跟 import 和 export 的模块名规则是一样的,且必须跟当前文件加载该模块的语句写法(上例`import { A } from './a'`)保持一致。 + +(2)不能创建新的顶层类型。也就是说,只能对`a.ts`模块中已经存在的类型进行扩展,不允许增加新的顶层类型,比如新定义一个接口`B`。 + +(3)不能对默认的`default`接口进行扩展,只能对 export 命令输出的命名接口进行扩充。这是因为在进行类型扩展时,需要依赖输出的接口名。 + +## 参考链接 + +- [How Does The Declare Keyword Work In TypeScript?](https://timmousk.com/blog/typescript-declare/), Tim Mouskhelichvili \ No newline at end of file diff --git a/docs/decorator-legacy.md b/docs/decorator-legacy.md new file mode 100644 index 0000000..9017bd1 --- /dev/null +++ b/docs/decorator-legacy.md @@ -0,0 +1,826 @@ +# 装饰器的传统语法 + +本章介绍装饰器的传统语法。 + +## --experimentalDecorators 编译参数 + +使用装饰器的传统语法,需要打开`--experimentalDecorators`编译参数。 + +```bash +$ tsc --target ES5 --experimentalDecorators +``` + +除了`--experimentalDecorators`这个配置项目用来打开装饰器支持,还有另外一个配置项`--emitDecoratorMetadata`,用来产生一些元数据,供其他工具(比如 reflect-metadata )使用。 + +这两个配置项可以在命令行设置,也可以在`tsconfig.json`文件里面进行设置。 + +```javascript +{ + "compilerOptions": { + "target": "ES6", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + } +} +``` + +## 装饰器的种类 + +按照所装饰的不同对象,装饰器可以分成五类。 + +> - 类装饰器(Class Decorators):用于类。 +> - 属性装饰器(Property Decorators):用于属性。 +> - 方法装饰器(Method Decorators):用于方法。 +> - 存取器装饰器(Accessor Decorators):用于类的 set 或 get 方法。 +> - 参数装饰器(Parameter Decorators):用于方法的参数。 + +下面是这五种装饰器一起使用的一个示例。 + +```typescript +@ClassDecorator() // (A) +class A { + + @PropertyDecorator() // (B) + name: string; + + @MethodDecorator() //(C) + fly( + @ParameterDecorator() // (D) + meters: number + ) { + // code + } + + @AccessorDecorator() // (E) + get egg() { + // code + } + set egg(e) { + // code + } +} +``` + +上面示例中,A 是类装饰器,B 是属性装饰器,C 是方法装饰器,D 是参数装饰器,E 是存取器装饰器。 + +注意,构造方法没有方法装饰器,只有参数装饰器。类装饰器其实就是用来装饰构造方法。 + +另外,装饰器只能用于类,要么应用于一个类,要么应用于一个类的内部成员,不能用于独立的函数。 + +```typescript +function Decorator() { + console.log('In Decorator'); +} + +@Decorator // 报错 +function decorated() { + console.log('in decorated'); +} +``` + +上面示例中,装饰器用于一个普通函数,这是无效的,结果报错。 + +## 类装饰器 + +类装饰器应用于类(class),但实际上是应用于类的构造方法。 + +类装饰器有唯一参数,就是构造方法,可以在装饰器内部,对构造方法进行各种改造。如果类装饰器有返回值,就会替换掉原来的构造方法。 + +类装饰器的类型定义如下。 + +```typescript +type ClassDecorator = + (target: TFunction) => TFunction | void; +``` + +上面定义中,类型参数`TFunction`必须是函数,实际上就是构造方法。类装饰器的返回值,要么是返回处理后的原始构造方法,要么返回一个新的构造方法。 + +下面就是一个示例。 + +```typescript +function f(target:any) { + console.log('apply decorator') + return target; +} + +@f +class A {} +// 输出:apply decorator +``` + +上面示例中,使用了装饰器`@f`,因此类`A`的构造方法会自动传入`f`。 + +类`A`不需要新建实例,装饰器也会执行。装饰器会在代码加载阶段执行,而不是在运行时执行,而且只会执行一次。 + +由于 TypeScript 存在编译阶段,所以装饰器对类的行为的改变,实际上发生在编译阶段。这意味着,TypeScript 装饰器能在编译阶段运行代码,也就是说,它本质就是编译时执行的函数。 + +下面再看一个示例。 + +```typescript +@sealed +class BugReport { + type = "report"; + title: string; + + constructor(t:string) { + this.title = t; + } +} + +function sealed(constructor: Function) { + Object.seal(constructor); + Object.seal(constructor.prototype); +} +``` + +上面示例中,装饰器`@sealed()`会锁定`BugReport`这个类,使得它无法新增或删除静态成员和实例成员。 + +如果除了构造方法,类装饰器还需要其他参数,可以采取“工厂模式”,即把装饰器写在一个函数里面,该函数可以接受其他参数,执行后返回装饰器。但是,这样就需要调用装饰器的时候,先执行一次工厂函数。 + +```typescript +function factory(info:string) { + console.log('received: ', info); + return function (target:any) { + console.log('apply decorator'); + return target; + } +} + +@factory('log something') +class A {} +``` + +上面示例中,函数`factory()`的返回值才是装饰器,所以加载装饰器的时候,要先执行一次`@factory('log something')`,才能得到装饰器。这样做的好处是,可以加入额外的参数,本例是参数`info`。 + +总之,`@`后面要么是一个函数名,要么是函数表达式,甚至可以写出下面这样的代码。 + +```typescript +@((constructor: Function) => { + console.log('log something'); +}) +class InlineDecoratorExample { + // ... +} +``` + +上面示例中,`@`后面是一个箭头函数,这也是合法的。 + +类装饰器可以没有返回值,如果有返回值,就会替代所装饰的类的构造函数。由于 JavaScript 的类等同于构造函数的语法糖,所以装饰器通常返回一个新的类,对原有的类进行修改或扩展。 + +```typescript +function decorator(target:any) { + return class extends target { + value = 123; + }; +} + +@decorator +class Foo { + value = 456; +} + +const foo = new Foo(); +console.log(foo.value); // 123 +``` + +上面示例中,装饰器`decorator`返回一个新的类,替代了原来的类。 + +上例的装饰器参数`target`类型是`any`,可以改成构造方法,这样就更准确了。 + +```typescript +type Constructor = { + new(...args: any[]): {} +}; + +function decorator ( + target: T +) { + return class extends target { + value = 123; + }; +} +``` + +这时,装饰器的行为就是下面这样。 + +```javascript +@decorator +class A {} + +// 等同于 +class A {} +A = decorator(A) || A; +``` + +上面代码中,装饰器要么返回一个新的类`A`,要么不返回任何值,`A`保持装饰器处理后的状态。 + +## 方法装饰器 + +方法装饰器用来装饰类的方法,它的类型定义如下。 + +```typescript +type MethodDecorator = ( + target: Object, + propertyKey: string|symbol, + descriptor: TypedPropertyDescriptor +) => TypedPropertyDescriptor | void; +``` + +方法装饰器一共可以接受三个参数。 + +- target:(对于类的静态方法)类的构造函数,或者(对于类的实例方法)类的原型。 +- propertyKey:所装饰方法的方法名,类型为`string|symbol`。 +- descriptor:所装饰方法的描述对象。 + +方法装饰器的返回值(如果有的话),就是修改后的该方法的描述对象,可以覆盖原始方法的描述对象。 + +下面是一个示例。 + +```typescript +function enumerable(value: boolean) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor + ) { + descriptor.enumerable = value; + }; +} + +class Greeter { + greeting: string; + + constructor(message:string) { + this.greeting = message; + } + + @enumerable(false) + greet() { + return 'Hello, ' + this.greeting; + } +} +``` + +上面示例中,方法装饰器`@enumerable()`装饰 Greeter 类的`greet()`方法,作用是修改该方法的描述对象的可遍历性属性`enumerable`。`@enumerable(false)`表示将该方法修改成不可遍历。 + +下面再看一个例子。 + +```typescript +function logger( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor +) { + const original = descriptor.value; + + descriptor.value = function (...args) { + console.log('params: ', ...args); + const result = original.call(this, ...args); + console.log('result: ', result); + return result; + } +} + +class C { + @logger + add(x: number, y:number ) { + return x + y; + } +} +``` + +上面示例中,方法装饰器`@logger`用来装饰`add()`方法,它的作用是将该方法的结果输出一条日志。每当`add()`调用一次,控制台就会打印出一条日志“result: ...”。 + +## 属性装饰器 + +属性装饰器用来装饰属性,类型定义如下。 + +```typescript +type PropertyDecorator = + ( + target: Object, + propertyKey: string|symbol + ) => void; +``` + +属性装饰器函数接受两个参数。 + +- target:(对于实例属性)类的原型对象(prototype),或者(对于静态属性)类的构造函数。 +- propertyKey:所装饰属性的属性名,注意类型有可能是字符串,也有可能是 Symbol 值。 + +属性装饰器不需要返回值,如果有的话,也会被忽略。 + +下面是一个示例。 + +```typescript +function ValidRange(min:number, max:number) { + return (target:Object, key:string) => { + Object.defineProperty(target, key, { + set: function(v:number) { + if (v < min || v > max) { + throw new Error(`Not allowed value ${v}`); + } + } + }); + } +} + +// 输出 Installing ValidRange on year +class Student { + @ValidRange(1920, 2020) + year!: number; +} + +const stud = new Student(); + +// 报错 Not allowed value 2022 +stud.year = 2022; +``` + +上面示例中,装饰器`ValidRange`对属性`year`设立了一个上下限检查器,只要该属性赋值时,超过了上下限,就会报错。 + +注意,属性装饰器的第一个参数,对于实例属性是类的原型对象,而不是实例对象(即不是`this`对象)。这是因为装饰器执行时,类还没有新建实例,所以实例对象不存在。 + +由于拿不到`this`,所以属性装饰器无法获得实例属性的值。这也是它没有在参数里面提供属性描述对象的原因。 + +```typescript +function logProperty(target: Object, member: string) { + const prop = Object.getOwnPropertyDescriptor(target, member); + console.log(`Property ${member} ${prop}`); +} + +class PropertyExample { + @logProperty + name:string = 'Foo'; +} +// 输出 Property name undefined +``` + +上面示例中,属性装饰器`@logProperty`内部想要获取实例属性`name`的属性描述对象,结果拿到的是`undefined`。 + +因为上例的`target`是类的原型对象,不是实例对象,所以拿不到`name`属性,也就是说`target.name`是不存在的,所以拿到的是`undefined`。只有通过`this.name`才能拿到`name`属性,但是这时`this`还不存在。 + +属性装饰器不仅无法获得实例属性的值,也不能初始化或修改实例属性,而且它的返回值也会被忽略。因此,它的作用很有限。 + +不过,如果属性装饰器设置了当前属性的取值器(setter),然后在构造函数里面为实例属性赋值,这时可以拿到属性的值。 + +```typescript +function Min(limit:number) { + return function( + target: Object, + propertyKey: string + ) { + let value: string; + + const getter = function() { + return value; + }; + + const setter = function(newVal:string) { + if(newVal.length < limit) { + throw new Error(`Your password should be bigger than ${limit}`); + } + else { + value = newVal; + } + }; + Object.defineProperty(target, propertyKey, { + get: getter, + set: setter + }); + } +} + +class User { + username: string; + + @Min(8) + password: string; + + constructor(username: string, password: string){ + this.username = username; + this.password = password; + } +} + +const u = new User('Foo', 'pass'); +// 报错 Your password should be bigger than 8 +``` + +上面示例中,属性装饰器`@Min`通过设置存取器,拿到了实例属性的值。 + +## 存取器装饰器 + +存取器装饰器用来装饰类的存取器(accessor)。所谓“存取器”指的是某个属性的取值器(getter)和存值器(setter)。 + +存取器装饰器的类型定义,与方法装饰器一致。 + +```typescript +type AccessorDecorator = ( + target: Object, + propertyKey: string|symbol, + descriptor: TypedPropertyDescriptor +) => TypedPropertyDescriptor | void; +``` + +存取器装饰器有三个参数。 + +- target:(对于静态属性的存取器)类的构造函数,或者(对于实例属性的存取器)类的原型。 +- propertyKey:存取器的属性名。 +- descriptor:存取器的属性描述对象。 + +存取器装饰器的返回值(如果有的话),会作为该属性新的描述对象。 + +下面是一个示例。 + +```typescript +function configurable(value: boolean) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor + ) { + descriptor.configurable = value; + }; +} + +class Point { + private _x: number; + private _y: number; + constructor(x:number, y:number) { + this._x = x; + this._y = y; + } + + @configurable(false) + get x() { + return this._x; + } + + @configurable(false) + get y() { + return this._y; + } +} +``` + +上面示例中,装饰器`@configurable(false)`关闭了所装饰属性(`x`和`y`)的属性描述对象的`configurable`键(即关闭了属性的可配置性)。 + +下面的示例是将装饰器用来验证属性,如果赋值不满足条件就报错。 + +```typescript +function validator( + target: Object, + propertyKey: string, + descriptor: PropertyDescriptor +){ + const originalGet = descriptor.get; + const originalSet = descriptor.set; + + if (originalSet) { + descriptor.set = function (val) { + if (val > 100) { + throw new Error(`Invalid value for ${propertyKey}`); + } + originalSet.call(this, val); + }; + } +} + +class C { + #foo!: number; + + @validator + set foo(v) { + this.#foo = v; + } + + get foo() { + return this.#foo; + } +} + +const c = new C(); +c.foo = 150; +// 报错 +``` + +上面示例中,装饰器用自己定义的存值器,取代了原来的存值器,加入了验证条件。 + +TypeScript 不允许对同一个属性的存取器(getter 和 setter)使用同一个装饰器,也就是说只能装饰两个存取器里面的一个,且必须排在前面的那一个,否则报错。 + +```typescript +// 报错 +class Person { + #name:string; + + @Decorator + set name(n:string) { + this.#name = n; + } + + @Decorator // 报错 + get name() { + return this.#name; + } +} +``` + +上面示例中,`@Decorator`同时装饰`name`属性的存值器和取值器,所以报错。 + +但是,下面的写法不会报错。 + +```typescript +class Person { + #name:string; + + @Decorator + set name(n:string) { + this.#name = n; + } + get name() { + return this.#name; + } +} +``` + +上面示例中,`@Decorator`只装饰它后面第一个出现的存值器(`set name()`),并不装饰取值器(`get name()`),所以不报错。 + +装饰器之所以不能同时用于同一个属性的存值器和取值器,原因是装饰器可以从属性描述对象上面,同时拿到取值器和存值器,因此只调用一次就够了。 + +## 参数装饰器 + +参数装饰器用来装饰构造方法或者其他方法的参数。它的类型定义如下。 + +```typescript +type ParameterDecorator = ( + target: Object, + propertyKey: string|symbol, + parameterIndex: number +) => void; +``` + +参数装饰器接受三个参数。 + +- target:(对于静态方法)类的构造函数,或者(对于类的实例方法)类的原型对象。 +- propertyKey:所装饰的方法的名字,类型为`string|symbol`。 +- parameterIndex:当前参数在方法的参数序列的位置(从0开始)。 + +该装饰器不需要返回值,如果有的话会被忽略。 + +下面是一个示例。 + +```typescript +function log( + target: Object, + propertyKey: string|symbol, + parameterIndex: number +) { + console.log(`${String(propertyKey)} NO.${parameterIndex} Parameter`); +} + +class C { + member( + @log x:number, + @log y:number + ) { + console.log(`member Paremeters: ${x} ${y}`); + } +} + +const c = new C(); +c.member(5, 5); +// member NO.1 Parameter +// member NO.0 Parameter +// member Paremeters: 5 5 +``` + +上面示例中,参数装饰器会输出参数的位置序号。注意,后面的参数会先输出。 + +跟其他装饰器不同,参数装饰器主要用于输出信息,没有办法修改类的行为。 + +## 装饰器的执行顺序 + +前面说过,装饰器只会执行一次,就是在代码解析时执行,哪怕根本没有调用类新建实例,也会执行,而且从此就不再执行了。 + +执行装饰器时,按照如下顺序执行。 + +1. 实例相关的装饰器。 +1. 静态相关的装饰器。 +1. 构造方法的参数装饰器。 +1. 类装饰器。 + +请看下面的示例。 + +```typescript +function f(key:string):any { + return function () { + console.log('执行:', key); + }; +} + +@f('类装饰器') +class C { + @f('静态方法') + static method() {} + + @f('实例方法') + method() {} + + constructor(@f('构造方法参数') foo:any) {} +} +``` + +加载上面的示例,输出如下。 + +```typescript +执行: 实例方法 +执行: 静态方法 +执行: 构造方法参数 +执行: 类装饰器 +``` + +同一级装饰器的执行顺序,是按照它们的代码顺序。但是,参数装饰器的执行总是早于方法装饰器。 + +```typescript +function f(key:string):any { + return function () { + console.log('执行:', key); + }; +} + +class C { + @f('方法1') + m1(@f('参数1') foo:any) {} + + @f('属性1') + p1: number; + + @f('方法2') + m2(@f('参数2') foo:any) {} + + @f('属性2') + p2: number; +} +``` + +加载上面的示例,输出如下。 + +```typescript +执行: 参数1 +执行: 方法1 +执行: 属性1 +执行: 参数2 +执行: 方法2 +执行: 属性2 +``` + +上面示例中,实例装饰器的执行顺序,完全是按照代码顺序的。但是,同一个方法的参数装饰器,总是早于该方法的方法装饰器执行。 + +如果同一个方法或属性有多个装饰器,那么装饰器将顺序加载、逆序执行。 + +```typescript +function f(key:string):any { + console.log('加载:', key); + return function () { + console.log('执行:', key); + }; +} + +class C { + @f('A') + @f('B') + @f('C') + m1() {} +} +// 加载: A +// 加载: B +// 加载: C +// 执行: C +// 执行: B +// 执行: A +``` + +如果同一个方法有多个参数,那么参数也是顺序加载、逆序执行。 + +```typescript +function f(key:string):any { + console.log('加载:', key); + return function () { + console.log('执行:', key); + }; +} + +class C { + method( + @f('A') a:any, + @f('B') b:any, + @f('C') c:any, + ) {} +} +// 加载: A +// 加载: B +// 加载: C +// 执行: C +// 执行: B +// 执行: A +``` + +## 为什么装饰器不能用于函数? + +装饰器只能用于类和类的方法,不能用于函数,主要原因是存在函数提升。 + +JavaScript 的函数不管在代码的什么位置,都会提升到代码顶部。 + +```typescript +addOne(1); +function addOne(n:number) { + return n + 1; +} +``` + +上面示例中,函数`addOne()`不会因为在定义之前执行而报错,原因就是函数存在提升,会自动提升到代码顶部。 + +如果允许装饰器可以用于普通函数,那么就有可能导致意想不到的情况。 + +```typescript +let counter = 0; + +let add = function (target:any) { + counter++; +}; + +@add +function foo() { + //... +} +``` + +上面示例中,本来的意图是装饰器`@add`每使用一次,变量`counter`就加`1`,但是实际上会报错,因为函数提升的存在,使得实际执行的代码是下面这样。 + +```javascript +@add // 报错 +function foo() { + //... +} + +let counter = 0; +let add = function (target:any) { + counter++; +}; +``` + +上面示例中,`@add`还没有定义就调用了,从而报错。 + +总之,由于存在函数提升,使得装饰器不能用于函数。类是不会提升的,所以就没有这方面的问题。 + +另一方面,如果一定要装饰函数,可以采用高阶函数的形式直接执行,没必要写成装饰器。 + +```javascript +function doSomething(name) { + console.log('Hello, ' + name); +} + +function loggingDecorator(wrapped) { + return function() { + console.log('Starting'); + const result = wrapped.apply(this, arguments); + console.log('Finished'); + return result; + } +} + +const wrapped = loggingDecorator(doSomething); +``` + +上面示例中,`loggingDecorator()`是一个装饰器,只要把原始函数传入它执行,就能起到装饰器的效果。 + +## 多个装饰器的合成 + +多个装饰器可以应用于同一个目标对象,可以写在一行。 + +```typescript +@f @g x +``` + +上面示例中,装饰器`@f`和`@g`同时装饰目标对象`x`。 + +多个装饰器也可以写成多行。 + +```typescript +@f +@g +x +``` + +多个装饰器的效果,类似于函数的合成,按照从里到外的顺序执行。对于上例来说,就是执行`f(g(x))`。 + +前面也说过,如果`f`和`g`是表达式,那么需要先从外到里求值。 + +## 参考链接 + +- [A Complete Guide to TypeScript Decorators](https://saul-mirone.github.io/a-complete-guide-to-typescript-decorator/), by Saul Mirone +- [Deep introduction to using and implementing TypeScript decorators](https://techsparx.com/nodejs/typescript/decorators/introduction.html), by David Herron +- [Deep introduction to property decorators in TypeScript](https://techsparx.com/nodejs/typescript/decorators/properties.html), by David Herron +- [Deep introduction to accessor decorators in TypeScript](https://techsparx.com/nodejs/typescript/decorators/accessors.html), by David Herron +- [Using Property Decorators in Typescript with a real example](https://dev.to/danywalls/using-property-decorators-in-typescript-with-a-real-example-44e), by Dany Paredes \ No newline at end of file diff --git a/docs/decorator.md b/docs/decorator.md new file mode 100644 index 0000000..f1305ce --- /dev/null +++ b/docs/decorator.md @@ -0,0 +1,672 @@ +# TypeScript 装饰器 + +## 简介 + +装饰器(Decorator)是一种语法结构,用来修改类(class)的行为。 + +在语法上,装饰器有如下几个特征。 + +(1)第一个字符(或者说前缀)是`@`,后面是一个表达式。 + +(2)`@`后面的表达式,必须是一个函数(或者执行后可以得到一个函数)。 + +(3)这个函数接受所修饰对象的一些相关值作为参数。 + +(4)这个函数要么不返回值,要么返回一个新对象取代所修饰的目标对象。 + +举例来说,有一个函数`Injectable()`当作装饰器使用,那么需要写成`@Injectable`,然后放在某个类的前面。 + +```typescript +@Injectable class A { + // ... +} +``` + +上面示例中,由于有了装饰器`@Injectable`,类`A`的行为在运行时就会发生改变。 + +下面就是一个最简单的装饰器。 + +```typescript +function simpleDecorator() { + console.log('hi'); +} + +@simpleDecorator +class A {} // "hi" +``` + +上面示例中,函数`simpleDecorator()`用作装饰器,附加在类`A`之上,后者在代码解析时就会打印一行日志。 + +编译上面的代码会报错,提示没有用到装饰器的参数。现在就为装饰器加上参数,让它更像正式运行的代码。 + +```typescript +function simpleDecorator( + target:any, + context:any +) { + console.log('hi, this is ' + target); + return target; +} + +@simpleDecorator +class A {} // "hi, this is class A {}" +``` + +上面的代码就可以顺利通过编译了,代码含义这里先不解释。大家只要理解,类`A`在执行前会先执行装饰器`simpleDecorator()`,并且会向装饰器自动传入参数就可以了。 + +装饰器有多种形式,基本上只要在`@`符号后面添加表达式都是可以。下面都是合法的装饰器。 + +```typescript +@myFunc +@myFuncFactory(arg1, arg2) + +@libraryModule.prop +@someObj.method(123) + +@(wrap(dict['prop'])) +``` + +注意,`@`后面的表达式,最终执行后得到的应该是一个函数。 + +相比使用子类改变父类,装饰器更加简洁优雅,缺点是不那么直观,功能也受到一些限制。所以,装饰器一般只用来为类添加某种特定行为。 + +```javascript +@frozen class Foo { + + @configurable(false) + @enumerable(true) + method() {} + + @throttle(500) + expensiveMethod() {} +} +``` + +上面示例中,一共有四个装饰器,一个用在类本身(`@frozen`),另外三个用在类的方法(`@configurable`、`@enumerable`、`@throttle`)。它们不仅增加了代码的可读性,清晰地表达了意图,而且提供一种方便的手段,增加或修改类的功能。 + +## 装饰器的版本 + +TypeScript 从早期开始,就支持装饰器。但是,装饰器的语法后来发生了变化。ECMAScript 标准委员会最终通过的语法标准,与 TypeScript 早期使用的语法有很大差异。 + +目前,TypeScript 5.0 同时支持两种装饰器语法。标准语法可以直接使用,传统语法需要打开`--experimentalDecorators`编译参数。 + +```bash +$ tsc --target ES5 --experimentalDecorators +``` + +本章介绍装饰器的标准语法,下一章介绍传统语法。 + +## 装饰器的结构 + +装饰器函数的类型定义如下。 + +```typescript +type Decorator = ( + value: DecoratedValue, + context: { + kind: string; + name: string | symbol; + addInitializer(initializer: () => void): void; + + // Don’t always exist: + static: boolean; + private: boolean; + access: {get: () => unknown, set: (value: unknown) => void}; + } +) => void | ReplacementValue; // only fields differ +``` + +上面代码中,`Decorator`是装饰器的类型定义。它是一个函数,接受`value`和`context`两个参数。 + +其中,`value`参数是所装饰的对象,`context`是装饰器的上下文对象,TypeScript 提供一个原生接口`ClassMethodDecoratorContext`,描述这个对象。 + +```typescript +function decorator( + value:any, + context:ClassMethodDecoratorContext +) { + // ... +} +``` + +`context`对象有以下属性。 + +(1)`kind`:字符串,表示装饰器类型,可能取以下的值。 + +- 'class' +- 'method' +- 'getter' +- 'setter' +- 'accessor' +- 'field' + +这表示一共有六种类型的装饰器。 + +(2)`name`:字符串或者 Symbol 值,所装饰对象的名字,比如类名、属性名等。 + +(3)`addInitializer()`:函数,用来在类的初始化阶段,对方法进行一些处理。以前,这些处理通常放在构造函数里面,早于方法本身执行,现在改为放在装饰器的`context`对象里面,具体例子请参阅《方法装饰器》一节。 + +注意,`addInitializer()`函数没有返回值。 + +(4)`private`:布尔值,表示所装饰的方法或属性,是否为私有。 + +## 类装饰器 + +类装饰器的类型描述如下。 + +```typescript +type ClassDecorator = ( + value: Function, + context: { + kind: 'class'; + name: string | undefined; + addInitializer(initializer: () => void): void; + } +) => Function | void; +``` + +请看下面的例子。 + +```typescript +class InstanceCollector { + instances = new Set(); + install = (value:any, {kind}:any) => { + if (kind === 'class') { + const _this = this; + return function (...args:any[]) { + const inst = new value(...args); + _this.instances.add(inst); + return value; + } as unknown as typeof MyClass; + } + return; + }; +} + +const collector = new InstanceCollector(); + +@collector.install +class MyClass {} + +const inst1 = new MyClass(); +const inst2 = new MyClass(); +const inst3 = new MyClass(); + +collector.instances // new Set([inst1, inst2, inst3]) +``` + +上面示例中,类装饰器`@collector.install`将所有实例加入一个集合变量`collector.instances`。 + +类装饰器返回的函数,会作为新的构造函数。 + +```typescript +function countInstances(value:any, context:any) { + let instanceCount = 0; + const wrapper = function (...args:any[]) { + instanceCount++; + const instance = new value(...args); + instance.count = instanceCount; + return instance; + } as unknown as typeof MyClass; + wrapper.prototype = value.prototype; // A + return wrapper; +} + +@countInstances +class MyClass {} + +const inst1 = new MyClass(); +inst1 instanceof MyClass // true +inst1.count // 1 +``` + +上面示例实现了实例的计数。为了确保`wrapper()`的返回值是`MyClass`的示例,特别加入`A`行,确保两者的原型对象是一致的。否则,新的构造函数`wrapper`的原型对象,与`MyClass`不同,通不过`instanceof`运算符。 + +类装饰器也可以直接返回一个新的类。 + +```typescript +function countInstances(value:any, context:any) { + let instanceCount = 0; + return class extends value { + constructor(...args:any[]) { + super(...args); + instanceCount++; + this.count = instanceCount; + } + }; +} + +@countInstances +class MyClass {} + +const inst1 = new MyClass(); +inst1 instanceof MyClass // true +inst1.count // 1 +``` + +上面示例中,`@countInstances`返回一个`MyClass`的子类。 + +下面的例子是通过类装饰器,禁止使用`new`命令调用类。 + +```typescript +function functionCallable( + value as any, {kind} as any +) { + if (kind === 'class') { + return function (...args) { + if (new.target !== undefined) { + throw new TypeError('This function can’t be new-invoked'); + } + return new value(...args); + } + } +} + +@functionCallable +class Person { + constructor(name) { + this.name = name; + } +} +const robin = Person('Robin'); +robin.name // 'Robin' +``` + +上面示例中,类装饰器`@functionCallable`返回一个新的构造方法,里面判断`new.target`是否不为空,如果是的,就表示通过`new`命令调用,从而报错。 + +## 方法装饰器 + +方法装饰器用来装饰类的方法(method)。它的类型描述如下。 + +```typescript +type ClassMethodDecorator = ( + value: Function, + context: { + kind: 'method'; + name: string | symbol; + static: boolean; + private: boolean; + access: { get: () => unknown }; + addInitializer(initializer: () => void): void; + } +) => Function | void; +``` + +它的上下文对象`context`有以下属性。 + +- static:布尔值,表示是否为静态方法。 +- private:布尔值,表示是否为私有方法。 +- access:函数,表示方法的存取器,但是只能用来取值(只有`get()`方法),不能用来赋值(不能定义`set()`方法)。 + +```typescript +class C { + @trace + toString() { + return 'C'; + } +} + +function trace(decoratedMethod) { + // 此处略 +} +``` + +方法装饰器的实质是执行下面的操作。 + +```typescript +class C { + toString() { + return 'C'; + } +} + +C.prototype.toString = trace(C.prototype.toString); +``` + +如果装饰器返回一个新的函数,就会替代所装饰的对象。 + +```typescript +function replaceMethod() { + return function () { + return `How are you, ${this.name}?`; + } +} + +class Person { + constructor(name) { + this.name = name; + } + + @replaceMethod + hello() { + return `Hi ${this.name}!`; + } +} + +const robin = new Person('Robin'); + +robin.hello() // 'How are you, Robin?' +``` + +上面示例中,装饰器`@replaceMethod`返回的函数,就成为了新的`hello()`方法。 + +下面是另一个例子。 + +```typescript +class Person { + name: string; + constructor(name: string) { + this.name = name; + } + + @log + greet() { + console.log(`Hello, my name is ${this.name}.`); + } +} + +function log(originalMethod:any, context:ClassMethodDecoratorContext) { + const methodName = String(context.name); + + function replacementMethod(this: any, ...args: any[]) { + console.log(`LOG: Entering method '${methodName}'.`) + const result = originalMethod.call(this, ...args); + console.log(`LOG: Exiting method '${methodName}'.`) + return result; + } + + return replacementMethod; +} + +const person = new Person('张三'); +person.greet() +// "LOG: Entering method 'greet'." +// "Hello, my name is 张三." +// "LOG: Exiting method 'greet'." +``` + +下面是装饰器上下文对象的`addInitializer()`方法的例子。类的方法往往会在构造方法里面,进行`this`的绑定。 + +```typescript +class Person { + name: string; + constructor(name: string) { + this.name = name; + + // greet() 绑定 this + this.greet = this.greet.bind(this); + } + + greet() { + console.log(`Hello, my name is ${this.name}.`); + } +} +``` + +上面例子中,构造方法将`greet()`方法绑定了`this`,这行代码必须放在构造方法里面。现在,它可以移到`addInitializer()`。 + +```typescript +function bound( + originalMethod:any, context:ClassMethodDecoratorContext +) { + const methodName = context.name; + if (context.private) { + throw new Error(`不能绑定私有方法 ${methodName as string}`); + } + context.addInitializer(function () { + this[methodName] = this[methodName].bind(this); + }); +} +``` + +上面示例中,绑定`this`转移到了`addInitializer()`方法里面。 + +```typescript +function collect( + value, + {name, addInitializer} +) { + addInitializer(function () { + if (!this.collectedMethodKeys) { + this.collectedMethodKeys = new Set(); + } + this.collectedMethodKeys.add(name); + }); +} + +class C { + @collect + toString() {} + + @collect + [Symbol.iterator]() {} +} + +const inst = new C(); +inst.@collect // new Set(['toString', Symbol.iterator]) +``` + +上面示例中,装饰器`@collect`会将所装饰的成员名字,加入一个 Set 集合`collectedMethodKeys`。 + +## 属性装饰器 + +属性装饰器用来装饰定义在类顶部的属性(field)。它的类型描述如下。 + +```typescript +type ClassFieldDecorator = ( + value: undefined, + context: { + kind: 'field'; + name: string | symbol; + static: boolean; + private: boolean; + access: { get: () => unknown, set: (value: unknown) => void }; + addInitializer(initializer: () => void): void; + } +) => (initialValue: unknown) => unknown | void; +``` + +注意,装饰器的第一个参数`value`的类型是`undefined`,这意味着这个参数实际上是不存在的,即不能从`value`获取目标属性的值。 + +如果要获取属性的值,必须使用存取器,请看下面的例子。 + +```typescript +let acc; + +function exposeAccess( + value, {access} +) { + acc = access; +} + +class Color { + @exposeAccess + name = 'green' +} + +const green = new Color(); +green.name // 'green' + +acc.get.call(green) // 'green' + +acc.set.call(green, 'red'); +green.name // 'red' +``` + +上面示例中,`@exposeAccess`是`name`属性的装饰器,它的第二个参数就是`name`的上下文对象,其中`access`属性包含了取值器(`get`)和存值器(`set`),可以对`name`属性进行取值和赋值。 + +下面的例子是更改属性的初始值。 + +```typescript +function twice() { + return initialValue => initialValue * 2; +} + +class C { + @twice + field = 3; +} + +const inst = new C(); +inst.field // 6 +``` + +## getter 装饰器,setter 装饰器 + +getter 装饰器和 setter 装饰器的类型描述如下。 + +```typescript +type ClassGetterDecorator = ( + value: Function, + context: { + kind: 'getter'; + name: string | symbol; + static: boolean; + private: boolean; + access: { get: () => unknown }; + addInitializer(initializer: () => void): void; + } +) => Function | void; + +type ClassSetterDecorator = ( + value: Function, + context: { + kind: 'setter'; + name: string | symbol; + static: boolean; + private: boolean; + access: { set: (value: unknown) => void }; + addInitializer(initializer: () => void): void; + } +) => Function | void; +``` + +下面的例子是将取值器的结果,保存为一个属性,加快后面的读取。 + +```typescript +class C { + @lazy + get value() { + console.log('正在计算……'); + return '开销大的计算结果'; + } +} + +function lazy( + value:any, + {kind, name}:any +) { + if (kind === 'getter') { + return function (this:any) { + const result = value.call(this); + Object.defineProperty( + this, name, + { + value: result, + writable: false, + } + ); + return result; + }; + } + return; +} + +const inst = new C(); +inst.value +// 正在计算…… +// '开销大的计算结果' +inst.value +// '开销大的计算结果' +``` + +上面示例中,第一次读取`inst.value`,会进行计算,然后装饰器`@lazy`将结果存入只读属性`value`,后面再读取这个属性,就不会进行计算了。 + +## 装饰器的执行顺序 + +装饰器的执行分为两三个阶段。 + +(1)评估(evaluation):计算`@`符号后面的表达式的值,得到的应该是函数。 + +(2)应用(application):将调用装饰器后得到的结果,应用于类的定义。其中,类装饰器在所有方法装饰器和属性装饰器之后应用。 + +请看下面的例子。 + +```typescript +function d(str:string) { + console.log(`评估 @d(): ${str}`); + return ( + value:any, context:any + ) => console.log(`应用 @d(): ${str}`); +} + +function log(str:string) { + console.log(str); + return str; +} + +@d('类装饰器') +class T { + @d('静态属性装饰器') + static staticField = log('静态属性值'); + + @d('原型方法') + [log('计算方法名')]() {} + + @d('实例属性') + instanceField = log('实例属性值'); +} +``` + +上面示例中,类`T`有四种装饰器:类装饰器、静态属性装饰器、方法装饰器、属性装饰器。 + +它的运行结果如下。 + +```typescript +// "评估 @d(): 类装饰器" +// "评估 @d(): 静态属性装饰器" +// "评估 @d(): 原型方法" +// "计算方法名" +// "评估 @d(): 实例属性" +// "应用 @d(): 原型方法" +// "应用 @d(): 静态属性装饰器" +// "应用 @d(): 实例属性" +// "应用 @d(): 类装饰器" +// "静态属性值" +``` + +可以看到,类载入的时候,代码按照以下顺序执行。 + +(1)装饰器评估:这一步计算装饰器的值,首先是类装饰器,然后是类内部的装饰器,按照它们出现的顺序。 + +注意,如果属性名或方法名是计算值(本例是“计算方法名”),则它们在对应的装饰器评估之后,也会进行自身的评估。 + +(2)装饰器应用:实际执行装饰器函数,将它们与对应的方法和属性进行结合。 + +原型方法的装饰器首先应用,然后是静态属性和静态方法装饰器,接下来是实例属性装饰器,最后是类装饰器。 + +注意,“实例属性值”在类初始化的阶段并不执行,直到类实例化时才会执行。 + +如果一个方法或属性有多个装饰器,则内层的装饰器先执行,外层的装饰器后执行。 + +```typescript +class Person { + name: string; + constructor(name: string) { + this.name = name; + } + + @bound + @log + greet() { + console.log(`Hello, my name is ${this.name}.`); + } +} +``` + +上面示例中,`greet()`有两个装饰器,内层的`@log`先执行,外层的`@bound`针对得到的结果再执行。 + +## 参考链接 + +- [JavaScript metaprogramming with the 2022-03 decorators API](https://2ality.com/2022/10/javascript-decorators.html) + diff --git a/docs/enum.md b/docs/enum.md new file mode 100644 index 0000000..71a1455 --- /dev/null +++ b/docs/enum.md @@ -0,0 +1,605 @@ +# TypeScript 的 Enum 类型 + +Enum 是 TypeScript 新增的一种数据结构和类型,中文译为“枚举”。 + +## 简介 + +实际开发中,经常需要定义一组相关的常量。 + +```typescript +const RED = 1; +const GREEN = 2; +const BLUE = 3; + +let color = userInput(); + +if (color === RED) {/* */} +if (color === GREEN) {/* */} +if (color === BLUE) {/* */} + +throw new Error('wrong color'); +``` + +上面示例中,常量`RED`、`GREEN`、`BLUE`是相关的,而且它们具体等于什么值并不重要,只要不相等就可以了。 + +TypeScript 就设计了 Enum 结构,用来将相关常量放在一个容器里面,方便使用。 + +```typescript +enum Color { + Red, // 0 + Green, // 1 + Blue // 2 +} +``` + +上面示例声明了一个 Enum 结构`Color`,里面包含三个成员`Red`、`Green`和`Blue`。第一个成员的值默认为整数`0`,第二个为`1`,第二个为`2`,以此类推。 + +使用时,调用 Enum 的某个成员,与调用对象属性的写法一样,可以使用点运算符,也可以使用方括号运算符。 + +```typescript +let c = Color.Green; // 1 +// 等同于 +let c = Color['Green']; // 1 +``` + +Enum 结构本身也是一种类型。比如,上例的变量`c`等于`1`,它的类型可以是 Color,也可以是`number`。 + +```typescript +let c:Color = Color.Green; // 正确 +let c:number = Color.Green; // 正确 +``` + +上面示例中,变量`c`的类型写成`Color`或`number`都可以。但是,`Color`类型的语义更好。 + +Enum 结构的特别之处在于,它既是一种类型,也是一个值。绝大多数 TypeScript 语法都是类型语法,编译后会全部去除,但是 Enum 结构是一个值,编译后会变成 JavaScript 对象,留在代码中。 + +```typescript +// 编译前 +enum Color { + Red, // 0 + Green, // 1 + Blue // 2 +} + +// 编译后 +let Color = { + Red: 0, + Green: 1, + Blue: 2 +}; +``` + +上面示例是 Enum 结构编译前后的对比。 + +由于 TypeScript 的定位是 JavaScript 语言的类型增强,所以官方建议谨慎使用 Enum 结构,因为它不仅仅是类型,还会为编译后的代码加入一个对象。 + +Enum 结构比较适合的场景是,成员的值不重要,名字更重要,从而增加代码的可读性和可维护性。 + +```typescript +enum Operator { + ADD, + DIV, + MUL, + SUB +} + +function compute( + op:Operator, + a:number, + b:number +) { + switch (op) { + case Operator.ADD: + return a + b; + case Operator.DIV: + return a / b; + case Operator.MUL: + return a * b; + case Operator.SUB: + return a - b; + default: + throw new Error('wrong operator'); + } +} + +compute(Operator.ADD, 1, 3) // 4 +``` + +上面示例中,Enum 结构`Operator`的四个成员表示四则运算“加减乘除”。代码根本不需要用到这四个成员的值,只用成员名就够了。 + +Enum 作为类型有一个缺点,就是输入任何数值都不报错。 + +```typescript +enum Bool { + No, + Yes +} + +function foo(noYes:Bool) { + // ... +} + +func(33); // 不报错 +``` + +上面代码中,函数`foo`的参数`noYes`只有两个可用的值,但是输入任意数值,编译都不会报错。 + +另外,由于 Enum 结构编译后是一个对象,所以不能有与它同名的变量(包括对象、函数、类等)。 + +```typescript +enum Color { + Red, + Green, + Blue +} + +const Color = 'red'; // 报错 +``` + +上面示例,Enum 结构与变量同名,导致报错。 + +很大程度上,Enum 结构可以被对象的`as const`断言替代。 + +```typescript +enum Foo { + A, + B, + C, +} + +const Bar = { + A: 0, + B: 1, + C: 2, +} as const; + +if (x === Foo.A){} +// 等同于 +if (x === Bar.A) {} +``` + +上面示例中,对象`Bar`使用了`as const`断言,作用就是使得它的属性无法修改。这样的话,`Foo`和`Bar`的行为就很类似了,前者完全可以用后者替代,而且后者还是 JavaScript 的原生数据结构。 + +## Enum 成员的值 + +Enum 成员默认不必赋值,系统会从零开始逐一递增,按照顺序为每个成员赋值,比如0、1、2…… + +但是,也可以为 Enum 成员显式赋值。 + +```typescript +enum Color { + Red, + Green, + Blue +} + +// 等同于 +enum Color { + Red = 0, + Green = 1, + Blue = 2 +} +``` + +上面示例中,Enum 每个成员的值都是显式赋值。 + +成员的值可以是任意数值,但不能是大整数(Bigint)。 + +```typescript +enum Color { + Red = 90, + Green = 0.5, + Blue = 7n // 报错 +} +``` + +上面示例中,Enum 成员的值可以是小数,但不能是 Bigint。 + +成员的值甚至可以相同。 + +```typescript +enum Color { + Red = 0, + Green = 0, + Blue = 0 +} +``` + +如果只设定第一个成员的值,后面成员的值就会从这个值开始递增。 + +```typescript +enum Color { + Red = 7, + Green, // 8 + Blue // 9 +} + +// 或者 +enum Color { + Red, // 0 + Green = 7, + Blue // 8 +} +``` + +Enum 成员的值也可以使用计算式。 + +```typescript +enum Permission { + UserRead = 1 << 8, + UserWrite = 1 << 7, + UserExecute = 1 << 6, + GroupRead = 1 << 5, + GroupWrite = 1 << 4, + GroupExecute = 1 << 3, + AllRead = 1 << 2, + AllWrite = 1 << 1, + AllExecute = 1 << 0, +} + +enum Bool { + No = 123, + Yes = Math.random(), +} +``` + +上面示例中,Enum 成员的值等于一个计算式,或者等于函数的返回值,都是正确的。 + +Enum 成员值都是只读的,不能重新赋值。 + +```typescript +enum Color { + Red, + Green, + Blue +} + +Color.Red = 4; // 报错 +``` + +上面示例中,重新为 Enum 成员赋值就会报错。 + +为了让这一点更醒目,通常会在 enum 关键字前面加上`const`修饰,表示这是常量,不能再次赋值。 + +```typescript +const enum Color { + Red, + Green, + Blue +} +``` + +加上`const`还有一个好处,就是编译为 JavaScript 代码后,代码中 Enum 成员会被替换成对应的值,这样能提高性能表现。 + +```typescript +const enum Color { + Red, + Green, + Blue +} + +const x = Color.Red; +const y = Color.Green; +const z = Color.Blue; + +// 编译后 +const x = 0 /* Color.Red */; +const y = 1 /* Color.Green */; +const z = 2 /* Color.Blue */; +``` + +上面示例中,由于 Enum 结构前面加了`const`关键字,所以编译产物里面就没有生成对应的对象,而是把所有 Enum 成员出现的场合,都替换成对应的常量。 + +如果希望加上`const`关键词后,运行时还能访问 Enum 结构(即编译后依然将 Enum 转成对象),需要在编译时打开`preserveConstEnums`参数。 + +## 同名 Enum 的合并 + +多个同名的 Enum 结构合并成一个 Enum 结构。 + +```typescript +enum Foo { + A, +} + +enum Foo { + B = 1, +} + +enum Foo { + C = 2, +} + +// 等同于 +enum Foo { + A, + B = 1, + C = 2 +} +``` + +上面示例中,`Foo`分成三段定义,系统会自动把它们合并。 + +Enum 结构合并时,只允许其中一个的首成员省略初始值,否则报错。 + +```typescript +enum Foo { + A, +} + +enum Foo { + B, // 报错 +} +``` + +上面示例中,`Foo`的两段定义的第一个成员,都没有设置初始值,导致报错。 + +同名 Enum 合并时,不能有同名成员,否则报错。 + +```typescript +enum Foo { + A, + B +} + +enum Foo { + B = 1, // 报错 + C +} +``` + +上面示例中,`Foo`的两段定义有一个同名成员`B`,导致报错。 + +同名 Enum 合并的另一个限制是,所有定义必须同为 const 枚举或者非 const 枚举,不允许混合使用。 + +```typescript +// 正确 +enum E { + A, +} +enum E { + B = 1, +} + +// 正确 +const enum E { + A, +} +const enum E { + B = 1, +} + +// 报错 +enum E { + A, +} +const enum E2 { + B = 1, +} +``` + +同名 Enum 的合并,最大用处就是补充外部定义的 Enum 结构。 + +## 字符串 Enum + +Enum 成员的值除了设为数值,还可以设为字符串。也就是说,Enum 也可以用作一组相关字符串的集合。 + +```typescript +enum Direction { + Up = 'UP', + Down = 'DOWN', + Left = 'LEFT', + Right = 'RIGHT', +} +``` + +上面示例中,`Direction`就是字符串枚举,每个成员的值都是字符串。 + +注意,字符串枚举的所有成员值,都必须显式设置。如果没有设置,成员值默认为数值,且位置必须在字符串成员之前。 + +```typescript +enum Foo { + A, // 0 + B = 'hello', + C // 报错 +} +``` + +上面示例中,`A`之前没有其他成员,所以可以不设置初始值,默认等于`0`;`C`之前有一个字符串成员,必须有初始值,不赋值就报错了。 + +Enum 成员可以是字符串和数值混合赋值。 + +```typescript +enum Enum { + One = 'One', + Two = 'Two', + Three = 3, + Four = 4, +} +``` + +除了数值和字符串,Enum 成员不允许使用其他值(比如 Symbol 值)。 + +变量类型如果是字符串 Enum,就不能再赋值为字符串,这跟数值 Enum 不一样。 + +```typescript +enum MyEnum { + One = 'One', + Two = 'Two', +} + +let s = MyEnum.One; +s = 'One'; // 报错 +``` + +上面示例中,变量`s`的类型是`MyEnum`,再赋值为字符串就报错。 + +由于这个原因,如果函数的参数类型是字符串 Enum,传参时就不能直接传入字符串,而要传入 Enum 成员。 + +```typescript +enum MyEnum { + One = 'One', + Two = 'Two', +} + +function f(arg:MyEnum) { + return 'arg is ' + arg; +} + +f('One') // 报错 +``` + +上面示例中,参数类型是`MyEnum`,直接传入字符串会报错。 + +所以,字符串 Enum 作为一种类型,有限定函数参数的作用。 + +前面说过,数值 Enum 的成员值往往不重要。但是有些场合,开发者可能希望 Enum 成员值可以保存一些有用的信息,所以 TypeScript 才设计了字符串 Enum. + +```typescript +const enum MediaTypes { + JSON = 'application/json', + XML = 'application/xml', +} + +const url = 'localhost'; + +fetch(url, { + headers: { + Accept: MediaTypes.JSON, + }, +}).then(response => { + // ... +}); +``` + +上面示例中,函数`fetch()`的参数对象的属性`Accept`,只能接受一些指定的字符串。这时就很适合把字符串放进一个 Enum 结构,通过成员值来引用这些字符串。 + +字符串 Enum 可以使用联合类型(union)代替。 + +```typescript +function move( + where:'Up'|'Down'|'Left'|'Right' +) { + // ... + } +``` + +上面示例中,函数参数`where`属于联合类型,效果跟指定为字符串 Enum 是一样的。 + +注意,字符串 Enum 的成员值,不能使用表达式赋值。 + +```typescript +enum MyEnum { + A = 'one', + B = ['T', 'w', 'o'].join('') // 报错 +} +``` + +上面示例中,成员`B`的值是一个字符串表达式,导致报错。 + +## keyof 运算符 + +keyof 运算符可以取出 Enum 结构的所有成员名,作为联合类型返回。 + +```typescript +enum MyEnum { + A = 'a', + B = 'b' +} + +// 'A'|'B' +type Foo = keyof typeof MyEnum; +``` + +上面示例中,`keyof typeof MyEnum`可以取出`MyEnum`的所有成员名,所以类型`Foo`等同于联合类型`'A'|'B'`。 + +注意,这里的`typeof`是必需的,否则`keyof MyEnum`相当于`keyof number`。 + +```typescript +// "toString" | "toFixed" | "toExponential" | +// "toPrecision" | "valueOf" | "toLocaleString" +type Foo = keyof MyEnum; +``` + +上面示例中,类型`Foo`等于类型`number`的所有原生属性名组成的联合类型。 + +这是因为 Enum 作为类型,本质上属于`number`或`string`的一种变体,而`typeof MyEnum`会将`MyEnum`当作一个值处理,从而先其转为对象类型,就可以再用`keyof`运算符返回该对象的所有属性名。 + +如果要返回 Enum 所有的成员值,可以使用`in`运算符。 + +```typescript +enum MyEnum { + A = 'a', + B = 'b' +} + +// { a:any, b: any } +type Foo = { [key in MyEnum]: any }; +``` + +上面示例中,采用属性索引可以取出`MyEnum`的所有成员值。 + +## 反向映射 + +数值 Enum 存在反向映射,即可以通过成员值获得成员名。 + +```typescript +enum Weekdays { + Monday = 1, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday +} + +console.log(Weekdays[3]) // Wednesday +``` + +上面示例中,Enum 成员`Wednesday`的值等于3,从而可以从成员值`3`取到对应的成员名`Wednesday`,这就叫反向映射。 + +这是因为 TypeScript 会将上面的 Enum 结构,编译成下面的 JavaScript 代码。 + +```javascript +var Weekdays; +(function (Weekdays) { + Weekdays[Weekdays["Monday"] = 1] = "Monday"; + Weekdays[Weekdays["Tuesday"] = 2] = "Tuesday"; + Weekdays[Weekdays["Wednesday"] = 3] = "Wednesday"; + Weekdays[Weekdays["Thursday"] = 4] = "Thursday"; + Weekdays[Weekdays["Friday"] = 5] = "Friday"; + Weekdays[Weekdays["Saturday"] = 6] = "Saturday"; + Weekdays[Weekdays["Sunday"] = 7] = "Sunday"; +})(Weekdays || (Weekdays = {})); +``` + +上面代码中,实际进行了两组赋值,以第一个成员为例。 + +```javascript +Weekdays[ + Weekdays["Monday"] = 1 +] = "Monday"; +``` + +上面代码有两个赋值运算符(`=`),实际上等同于下面的代码。 + +```javascript +Weekdays["Monday"] = 1; +Weekdays[1] = "Monday"; +``` + +注意,这种情况只发生在数值 Enum,对于字符串 Enum,不存在反向映射。这是因为字符串 Enum 编译后只有一组赋值。 + +```typescript +enum MyEnum { + A = 'a', + B = 'b' +} + +// 编译后 +var MyEnum; +(function (MyEnum) { + MyEnum["A"] = "a"; + MyEnum["B"] = "b"; +})(MyEnum || (MyEnum = {})); +``` diff --git a/docs/es6.md b/docs/es6.md new file mode 100644 index 0000000..1229e84 --- /dev/null +++ b/docs/es6.md @@ -0,0 +1,154 @@ +# TypeScript 的 ES6 类型 + +## `Map` + +```typescript +let map2 = new Map(); // Key any, value any +let map3 = new Map(); // Key string, value number +``` + +TypeScript 使用 Map 类型,描述 Map 结构。 + +```typescript +const myMap: Map = new Map([ + [false, 'no'], + [true, 'yes'], +]); +``` + +Map 是一个泛型,使用时,比如给出类型变量。 + +由于存在类型推断,也可以省略类型参数。 + +```typescript +const myMap = new Map([ + [false, 'no'], + [true, 'yes'], +]); +``` + +## `Set` + +## `Promise` + +## async 函数 + +async 函数的的返回值是一个 Promise 对象。 + +```typescript +const p:Promise = /* ... */; + +async function fn(): Promise { + var i = await p; + return i + 1; +} +``` + +## `Iterable<>` + +对象只要部署了 Iterator 接口,就可以用`for...of`循环遍历。Generator 函数(生成器)返回的就是一个具有 Iterator 接口的对象。 + +TypeScript 使用泛型`Iterable`表示具有 Iterator 接口的对象,其中`T`表示 Iterator 接口包含的值类型(每一轮遍历获得的值)。 + +```typescript +interface Iterable { + [Symbol.iterator](): Iterator; +} +``` + +上面是`Iterable`接口的定义,表示一个具有`Symbol.iterator`属性的对象,该属性是一个函数,调用后返回的是一个 Iterator 对象。 + +Iterator 对象必须具有`next()`方法,另外还具有两个可选方法`return()`和`throw()`,类型表述如下。 + +```typescript +interface Iterator { + next(value?: any): IteratorResult; + return?(value?: any): IteratorResult; + throw?(e?: any): IteratorResult; +} +``` + +上面的类型定义中,可以看到`next()`、`return()`、`throw()`这三个方法的返回值是一个部署了`IteratorResult`接口的对象。 + +`IteratorResult`接口的定义如下。 + +```typescript +interface IteratorResult { + done: boolean; //表示遍历是否结束 + value: T; // 当前遍历得到的值 +} +``` + +上面的类型定义表示,Iterator 对象的`next()`等方法的返回值,具有`done`和`value`两个属性。 + +下面的例子是 Generator 函数返回一个具有 Iterator 接口的对象。 + +```typescript +function* g():Iterable { + for (var i = 0; i < 100; i++) { + yield ''; + } + yield* otherStringGenerator(); +} +``` + +上面示例中,生成器`g()`返回的类型是`Iterable`,其中`string`表示 Iterator 接口包含的是字符串。 + +这个例子的类型声明可以省略,因为 TypeScript 可以自己推断出来 Iterator 接口的类型。 + +```typescript +function* g() { + for (var i = 0; i < 100; i++) { + yield ""; // infer string + } + yield* otherStringGenerator(); +} +``` + +另外,扩展运算符(`...`)后面的值必须具有 Iterator 接口,下面是一个例子。 + +```typescript +function toArray(xs: Iterable):X[] { + return [...xs] +} +``` + +## Generator 函数 + +Generator 函数返回一个同时具有 Iterable 接口(具有`[Symbol.iterator]`属性)和 Iterator 接口(具有`next()`方法)的对象,因此 TypeScript 提供了一个泛型`IterableIterator`,表示同时满足`Iterable`和`Iterator`两个接口。 + +```typescript +interface IterableIterator extends Iterator { + [Symbol.iterator](): IterableIterator; +} +``` + +上面类型定义中,`IterableIterator`接口就是在`Iterator`接口的基础上,加上`[Symbol.iterator]`属性。 + +下面是一个例子。 + +```typescript +function* createNumbers(): IterableIterator { + let n = 0; + while (1) { + yield n++; + } +} + +let numbers = createNumbers() + +// {value: 0, done: false} +numbers.next() + +// {value: 1, done: false} +numbers.next() + +// {value: 2, done: false} +numbers.next() +``` + +上面示例中,`createNumbers()`返回的对象`numbers`即具有`next()`方法,也具有`[Symbol.iterator]`属性,所以满足`IterableIterator`接口。 + +## 参考链接 + +- [Typing Iterables and Iterators with TypeScript](https://www.geekabyte.io/2019/06/typing-iterables-and-iterators-with.html) \ No newline at end of file diff --git a/docs/function.md b/docs/function.md new file mode 100644 index 0000000..ee047d3 --- /dev/null +++ b/docs/function.md @@ -0,0 +1,931 @@ +# TypeScript 的函数类型 + +## 简介 + +函数的类型声明,需要在声明函数时,给出参数的类型和返回值的类型。 + +```typescript +function hello( + txt:string +):void { + console.log('hello ' + txt); +} +``` + +上面示例中,函数`hello()`在声明时,需要给出参数`txt`的类型(string),以及返回值的类型(`void`),后者写在参数列表的圆括号后面。`void`类型表示没有返回值,详见后文。 + +如果不指定参数类型(比如上例不写`txt`的类型),TypeScript 就会推断参数类型,如果缺乏足够信息,就会推断该参数的类型为`any`。 + +返回值的类型通常可以不写,因为 TypeScript 自己会推断出来。 + +```typescript +function hello(txt:string) { + console.log('hello ' + txt); +} +``` + +上面示例中,由于没有`return`语句,TypeScript 会推断出函数`hello()`没有返回值。 + +不过,有时候出于文档目的,或者为了防止不小心改掉返回值,还是会写返回值的类型。 + +如果变量被赋值为一个函数,变量的类型有两种写法。 + +```typescript +// 写法一 +const hello = function (txt:string) { + console.log('hello ' + txt); +} + +// 写法二 +const hello: + (txt:string) => void += function (txt) { + console.log('hello ' + txt); +}; +``` + +上面示例中,变量`hello`被赋值为一个函数,它的类型有两种写法。写法一是通过等号右边的函数类型,推断出变量`hello`的类型;写法二则是使用箭头函数的形式,为变量`hello`指定类型,参数的类型写在箭头左侧,返回值的类型写在箭头右侧。 + +写法二有两个地方需要注意。 + +首先,函数的参数要放在圆括号里面,不放会报错。 + +其次,类型里面的参数名(本例是`txt`)是必须的。有的语言的函数类型可以不写参数名(比如 C 语言),但是 TypeScript 不行。如果写成`(string) => void`,TypeScript 会理解成函数有一个名叫 string 的参数,并且这个`string`参数的类型是`any`。 + +```typescript +type MyFunc = (string, number) => number; +// (string: any, number: any) => number +``` + +上面示例中,函数类型没写参数名,导致 TypeScript 认为参数类型都是`any`。 + +函数类型里面的参数名与实际参数名,可以不一致。 + +```typescript +let f:(x:number) => number; + +f = function (y:number) { + return y; +}; +``` + +上面示例中,函数类型里面的参数名为`x`,实际的函数定义里面,参数名为`y`,两者并不相同。 + +如果有多个变量被赋值为同一种类型的函数,写法二用起来就很麻烦。因此,往往用`type`命令为函数类型定义一个别名,便于指定给其他变量。 + +```typescript +type MyFunc = (txt:string) => void; + +const hello:MyFunc = function (txt) { + console.log('hello ' + txt); +}; +``` + +上面示例中,`type`命令为函数类型定义了一个别名`MyFunc`,后面使用就很方便,变量可以指定为这个类型。 + +变量所赋值的函数的参数个数,可以少于指定类型,但是不能多于指定类型,即这种情况下,TypeScript 允许省略参数。 + +```typescript +let myFunc: + (a:number, b:number) => number; + +myFunc = (a:number) => a; // 正确 + +myFunc = ( + a:number, b:number, c:number +) => a + b + c; // 报错 +``` + +上面示例中,变量`myFunc`的类型只能接受两个参数,如果被赋值只有一个参数的函数,并不报错。但是,被赋值为有三个参数的函数,就会报错。 + +这是因为 JavaScript 函数在声明时往往有多余的参数,实际使用时可以只传入一部分参数。比如,数组的`forEach()`方法的参数是一个函数,该函数默认有三个参数`(item, index, array) => void`,实际上往往只使用第一个参数`(item) => any`。因此,TypeScript 允许参数较少的函数,兼容于参数较多的函数。 + +```typescript +let x = (a:number) => 0; +let y = (b:number, s:string) => 0; + +y = x; // 正确 +x = y; // 报错 +``` + +上面示例中,函数`x`只有一个参数,函数`y`有两个参数,`x`可以赋值给`y`,反过来就不行。 + +如果一个变量要套用另一个函数的类型,有一个小技巧,就是使用`typeof`运算符。 + +```typescript +function add( + x:number, + y:number +) { + return x + y; +} + +const myAdd:typeof add = function (x, y) { + return x + y; +} +``` + +上面示例中,函数`myAdd()`的类型与函数`add()`是一样的,那么就可以定义成`typeof add`。因为函数名`add`本身不是类型,而是一个值,所以要用`typeof`运算符返回它的类型。 + +这是一个很有用的技巧,任何需要类型的地方,都可以使用`typeof`运算符从一个值获取类型。 + +函数类型还可以采用对象的写法。 + +```typescript +let add:{ + (x:number, y:number):number +}; + +add = function (x, y) { + return x + y; +}; +``` + +上面示例中,变量`add`的类型就写成了一个对象。 + +函数类型的对象写法如下。 + +```typescript +{ + (参数列表): 返回值 +} +``` + +注意,这种写法的函数参数与返回值之间,间隔符是冒号`:`,而不是正常写法的箭头`=>`,因为这里采用的是对象类型的写法,对象的属性名与属性值之间使用的是冒号。 + +这种写法平时很少用,但是非常合适用在一个场合:函数本身存在属性。 + +```typescript +function f(x:number) { + console.log(x); +} + +f.version = '1.0'; +``` + +上面示例中,函数`f()`本身还有一个属性`foo`。这时,`f`完全就是一个对象,类型就要使用对象的写法。 + +```typescript +let foo: { + (x:number): void; + version: string +} = f; +``` + +函数类型也可以使用 Interface 来声明,这种写法就是对象写法的翻版,详见《Interface》一章。 + +```typescript +interface myfn { + (a:number, b:number): number; +} + +var add:myfn = (a, b) => a + b; +``` + +上面示例中,interface 命令定义了接口`myfn`,这个接口的类型就是一个用对象表示的函数。 + +## Function 类型 + +TypeScript 提供 Function 类型表示函数,任何函数都属于这个类型。 + +```typescript +function doSomething(f:Function) { + return f(1, 2, 3); +} +``` + +上面示例中,参数`f`的类型就是`Function`,代表这是一个函数。 + +Function 类型的值都可以直接执行。 + +Function 类型的函数可以接受任意数量的参数,每个参数的类型都是`any`,返回值的类型也是`any`,代表没有任何约束,所以不建议使用这个类型,给出函数详细的类型声明会更好。 + +## 箭头函数 + +箭头函数是普通函数的一种简化写法,它的类型写法与普通函数类似。 + +```typescript +const repeat = ( + str:string, + times:number +):string => str.repeat(times); +``` + +上面示例中,变量`repeat`被赋值为一个箭头函数,类型声明写在箭头函数的定义里面。其中,参数的类型写在参数名后面,返回值类型写在参数列表的圆括号后面。 + +注意,类型写在箭头函数的定义里面,与使用箭头函数表示函数类型,写法有所不同。 + +```typescript +function greet( + fn:(a:string) => void +):void { + fn('world'); +} +``` + +上面示例中,函数`greet()`的参数`fn`是一个函数,类型就用箭头函数表示。这时,`fn`的返回值类型要写在箭头右侧,而不是写在参数列表的圆括号后面。 + +下面再看一个例子。 + +```typescript +type Person = { name: string }; + +const people = ['alice', 'bob', 'jan'].map( + (name):Person => ({name}) +); +``` + +上面示例中,`Person`是一个类型别名,代表一个对象,该对象有属性`name`。变量`people`是数组的`map()`方法的返回值。 + +`map()`方法的参数是一个箭头函数`(name):Person => ({name})`,该箭头函数的参数`name`的类型省略了,因为可以从`map()`的类型定义推断出来,箭头函数的返回值类型为`Person`。相应地,变量`people`的类型是`Person[]`。 + +至于箭头后面的`({name})`,表示返回一个对象,该对象有一个属性`name`,它的属性值为变量`name`的值。这里的圆括号是必须的,否则`(name):Person => {name}`的大括号表示函数体,即函数体内有一行语句`name`,同时由于没有`return`语句,这个函数不会返回任何值。 + +注意,下面两种写法都是不对的。 + +```typescript +// 错误 +(name:Person) => ({name}) + +// 错误 +name:Person => ({name}) +``` + +上面的两种写法在本例中都是错的。第一种写法表示,箭头函数的参数`name`的类型是`Person`,同时没写函数返回值的类型,让 TypeScript 自己去推断。第二种写法中,函数参数缺少圆括号。 + +## 可选参数 + +如果函数的某个参数可以省略,则在参数名后面加问号表示。 + +```typescript +function f(x?:number) { + // ... +} + +f(); // OK +f(10); // OK +``` + +上面示例中,虽然参数`x`后面有问号,表示该参数可以省略,不一定需要给出。 + +参数名带有问号,表示该参数的类型实际上是`原始类型|undefined`,它有可能为`undefined`。比如,上例的`x`虽然类型声明为`number`,但是实际上是`number|undefined`。 + +```typescript +function f(x?:number) { + return x; +} + +f(undefined) // 正确 +``` + +上面示例中,参数`x`是可选的,等同于说`x`可以赋值为`undefined`。 + +但是,反过来就不成立,类型显式设为`undefined`的参数,就不能省略。 + +```typescript +function f(x:number|undefined) { + return x; +} + +f() // 报错 +``` + +上面示例中,参数`x`的类型是`number|undefined`,表示要么传入一个数值,要么传入`undefined`,如果省略这个参数,就会报错。 + +函数的可选参数只能在参数列表的尾部,跟在必选参数的后面。 + +```typescript +let myFunc: + (a?:number, b:number) => number; // 报错 +``` + +上面示例中,可选参数在必选参数前面,就报错了。 + +如果前部参数有可能为空,这时只能显示注明该参数类型可能为`undefined`。 + +```typescript +let myFunc: + ( + a:number|undefined, + b:number + ) => number; +``` + +上面示例中,参数`a`有可能为空,就只能显式注明类型包括`undefined`,传参时也要显式传入`undefined`。 + +函数体内部用到可选参数时,需要判断该参数是否为`undefined`。 + +```typescript +let myFunc: + (a:number, b?:number) => number; + +myFunc = function (x, y) { + if (y === undefined) { + return x; + } + return x + y; +} +``` + +上面示例中,由于函数的第二个参数为可选参数,所以函数体内部需要判断一下,该参数是否为空。 + +## 参数默认值 + +TypeScript 函数的参数默认值写法,与 JavaScript 一致。 + +设置了默认值的参数,就是可选的。如果不传入该参数,它就会等于默认值。 + +```typescript +function createPoint( + x:number = 0, + y:number = 0 +):[number, number] { + return [x, y]; +} + +createPoint() // [0, 0] +``` + +上面示例中,参数`x`和`y`的默认值都是`0`,调用`createPoint()`时,这两个参数都是可以省略的。这里其实可以省略`x`和`y`的类型声明,因为可以从默认值推断出来。 + +```typescript +function createPoint( + x = 0, y = 0 +) { + return [x, y]; +} +``` + +可选参数与默认值不能同时使用。 + +```typescript +// 报错 +function f(x?: number = 0) { + // ... +} +``` + +上面示例中,`x`是可选参数,还设置了默认值,结果就报错了。 + +设有默认值的参数,如果传入`undefined`,也会触发默认值。 + +```typescript +function f(x = 456) { + return x; +} + +f2(undefined) // 456 +``` + +具有默认值的参数如果不位于参数列表的末尾,调用时不能省略,如果要触发默认值,必须显式传入`undefined`。 + +```typescript +function add( + x:number = 0, + y:number +) { + return x + y; +} + +add(1) // 报错 +add(undefined, 1) // 正确 +``` + +## 参数解构 + +函数参数如果存在变量解构,类型写法如下。 + +```typescript +function f( + [x, y]: [number, number] +) { + // ... +} + +function sum( + { a, b, c }: { + a: number; + b: number; + c: number + } +) { + console.log(a + b + c); +} +``` + +参数结构可以结合类型别名(type 命令)一起使用,代码会看起来简洁一些。 + +```typescript +type ABC = { a:number; b:number; c:number }; + +function sum({ a, b, c }:ABC) { + console.log(a + b + c); +} +``` + +## rest 参数 + +rest 参数表示函数剩余的所有参数,它可以是数组(剩余参数类型相同),也可能是元组(剩余参数类型不同)。 + +```typescript +// rest 参数为数组 +function joinNumbers(...nums:number[]) { + // ... +} + +// rest 参数为元组 +function f(...args:[boolean, number]) { + // ... +} +``` + +注意,元组需要声明每一个剩余参数的类型。如果元组里面的参数是可选的,则要使用可选参数。 + +```typescript +function f( + ...args: [boolean, string?] +) {} +``` + +下面是一个 rest 参数的例子。 + +```typescript +function multiply(n:number, ...m:number[]) { + return m.map((x) => n * x); +} +``` + +上面示例中,参数`m`就是 rest 类型,它的类型是一个数组。 + +rest 参数甚至可以嵌套。 + +```typescript +function f(...args:[boolean, ...string[]]) { + // ... +} +``` + +rest 参数可以与变量解构结合使用。 + +```typescript +function repeat( + ...[str, times]: [string, number] +):string { + return str.repeat(times); +} + +// 等同于 +function repeat( + str: string, + times: number +):string { + return str.repeat(times); +} +``` + +## readonly 只读参数 + +如果函数内部不能修改某个参数,可以在函数定义时,在参数类型前面加上`readonly`关键字,表示这是只读参数。 + +```typescript +function arraySum( + arr:readonly number[] +) { + // ... + arr[0] = 0; // 报错 +} +``` + +上面示例中,参数`arr`的类型是`readonly number[]`,表示为只读参数。如果函数体内部修改这个数组,就会报错。 + +## void 类型 + +void 类型表示函数没有返回值。 + +```typescript +function f():void { + console.log('hello'); +} +``` + +上面示例中,函数`f`没有返回值,类型就要写成`void`。 + +如果返回其他值,就会报错。 + +```typescript +function f():void { + return 123; // 报错 +} +``` + +上面示例中,函数`f()`的返回值类型是`void`,但是实际返回了一个数值,编译时就报错了。 + +void 类型允许返回`undefined`或`null`。 + +```typescript +function f():void { + return undefined; // 正确 +} + +function f():void { + return null; // 正确 +} +``` + +如果打开了`--strictNullChecks`编译选项,那么 void 类型只允许返回`undefined`。如果返回`null`,就会报错。这是因为 JavaScript 规定,如果函数没有返回值,就等同于返回`undefined`。 + +```typescript +// --strictNullChecks=true + +function f():void { + return undefined; // 正确 +} + +function f():void { + return null; // 报错 +} +``` + +需要特别注意的是,如果变量、对象方法、函数参数的类型是 void 类型的函数,那么并不代表不能赋值为有返回值的函数。恰恰相反,该变量、对象方法和函数参数可以接受返回任意值的函数,这时并不会报错。 + +```typescript +type voidFunc = () => void; + +const f:voidFunc = () => { + return 123; +}; +``` + +上面示例中,变量`f`的类型是`voidFunc`,是一个没有返回值的函数类型。但是实际上,`f`的值是一个有返回值的函数(返回`123`),编译时不会报错。 + +这是因为,这时 TypeScript 认为,这里的 void 类型只是表示该函数的返回值没有利用价值,或者说不应该使用该函数的返回值。只要不用到这里的返回值,就不会报错。 + +这样设计是有现实意义的。举例来说,数组方法`Array.prototype.forEach(fn)`的参数`fn`是一个函数,而且这个函数应该没有返回值,即返回值类型是`void`。 + +但是,实际应用中,很多时候传入的函数是有返回值,但是它的返回值不重要,或者不产生作用。 + +```typescript +const src = [1, 2, 3]; +const ret = []; + +src.forEach(el => ret.push(el)); +``` + +上面示例中,`push()`有返回值,表示新插入的元素在数组里面的位置。但是,对于`forEach()`方法来说,这个返回值是没有作用的,根本用不到,所以 TypeScript 不会报错。 + +如果后面使用了这个函数的返回值,就违反了约定,则会报错。 + +```typescript +type voidFunc = () => void; + +const f:voidFunc = () => { + return 123; +}; + +f() * 2 // 报错 +``` + +上面示例中,最后一行报错了,因为根据类型声明,`f()`没有返回值,但是却用到了它的返回值,因此报错了。 + +注意,这种情况仅限于变量、对象方法和函数参数,函数字面量如果声明了返回值是 void 类型,还是不能有返回值。 + +```typescript +function f():void { + return true; // 报错 +} + +const f3 = function ():void { + return true; // 报错 +}; +``` + +上面示例中,函数字面量声明了返回`void`类型,这时只要有返回值(除了`undefined`和`null`)就会报错。 + +除了函数,其他变量声明为`void`类型没有多大意义,因为这时只能赋值为`undefined`或者`null`(假定没有打开`strictNullChecks`) 。 + +```typescript +let foo:void = undefined; + +// 没有打开 --strictNullChecks 的情况下 +let bar:void = null; +``` + +## never 类型 + +`never`类型表示肯定不会出现的值。它用在函数的返回值,就表示某个函数肯定不会返回值,即函数不会正常执行结束。 + +它主要有以下两种情况。 + +(1)抛出错误的函数。 + +```typescript +function fail(msg:string):never { + throw new Error(msg); +} +``` + +上面示例中,函数`fail()`会抛错,不会正常退出,所以返回值类型是`never`。 + +注意,只有抛出错误,才是 never 类型。如果显式用`return`语句返回一个 Error 对象,返回值就不是 never 类型。 + +```typescript +function fail():Error { + return new Error("Something failed"); +} +``` + +上面示例中,函数`fail()`返回一个 Error 对象,所以返回值类型是 Error。 + +(2)无限执行的函数。 + +```typescript +const sing = function():never { + while (true) { + console.log('sing'); +}; +``` + +上面示例中,函数`sing()`会永远执行,不会返回,所以返回值类型是`never`。 + +注意,`never`类型不同于`void`类型。前者表示函数没有执行结束,不可能有返回值;后者表示函数正常执行结束,但是不返回值,或者说返回`undefined`。 + +```typescript +// 正确 +function sing():void { + console.log('sing'); +} + +// 报错 +function sing():never { + console.log('sing'); +} +``` + +上面示例中,函数`sing()`虽然没有`return`语句,但实际上是省略了`return undefined`这行语句,真实的返回值是`undefined`。所以,它的返回值类型要写成`void`,而不是`never`,写成`never`会报错。 + +如果一个函数抛出了异常或者陷入了死循环,那么该函数无法正常返回一个值,因此该函数的返回值类型就是`never`。如果程序中调用了一个返回值类型为`never`的函数,那么就意味着程序会在该函数的调用位置终止,永远不会继续执行后续的代码。 + +```typescript +function neverReturns():never { + throw new Error(); +} + +function f( + x:string|undefined +) { + if (x === undefined) { + neverReturns(); + } + + x; // 推断为 string +} +``` + +上面示例中,函数`f()`的参数`x`的类型为`string|undefined`。但是,`x`类型为`undefined`时,调用了`neverReturns()`。这个函数不会返回,因此 TypeScript 可以推断出,判断语句后面的那个`x`,类型一定是`string`。 + +## 局部类型 + +函数内部允许声明其他类型,该类型只在函数内部有效,称为局部类型。 + +```typescript +function hello(txt:string) { + type message = string; + let newTxt:message = 'hello ' + txt; + return newTxt; +} + +const newTxt:message = hello('world'); // 报错 +``` + +上面示例中,类型`message`是在函数`hello()`内部定义的,只能在函数内部使用。在函数外部使用,就会报错。 + +## 高阶函数 + +一个函数的返回值还是一个函数,那么前一个函数就称为高阶函数(higher-order function)。 + +下面就是一个例子,箭头函数返回的还是一个箭头函数。 + +```typescript +(someValue: number) => (multiplier: number) => someValue * multiplier; +``` + +## 函数重载 + +有些函数可以接受不同类型或不同个数的参数,并且根据参数的不同,会有不同的函数行为。这种根据参数类型不同,执行不同逻辑的行为,称为函数重载(function overload)。 + +```javascript +reverse('abc') // 'cba' +reverse([1, 2, 3]) // [3, 2, 1] +``` + +上面示例中,函数`reverse()`可以将参数颠倒输出。参数可以是字符串,也可以是数组。 + +这意味着,该函数内部有处理字符串和数组的两套逻辑,根据参数类型的不同,分别执行对应的逻辑。这就叫“函数重载”。 + +TypeScript 对于“函数重载”的类型声明方法是,逐一定义每一种情况的类型。 + +```typescript +function reverse(str:string):string; +function reverse(arr:any[]):any[]; +``` + +上面示例中,分别对函数`reverse()`的两种参数情况,给予了类型声明。但是,到这里还没有结束,后面还必须对函数`reverse()`给予完整的类型声明。 + +```typescript +function reverse(str:string):string; +function reverse(arr:any[]):any[]; +function reverse( + stringOrArray:string|any[] +):string|any[] { + if (typeof stringOrArray === 'string') + return stringOrArray.split('').reverse().join(''); + else + return stringOrArray.slice().reverse(); +} +``` + +上面示例中,前两行类型声明列举了重载的各种情况。第三行是函数本身的类型声明,它必须与前面已有的重载声明兼容。 + +有一些编程语言允许不同的函数参数,对应不同的函数实现。但是,JavaScript 函数只能有一个实现,必须在这个实现当中,处理不同的参数。因此,函数体内部就需要判断参数的类型及个数,并根据判断结果执行不同的操作。 + +```typescript +function add( + x:number, + y:number +):number; +function add( + x:any[], + y:any[] +):any[]; +function add( + x:number|any[], + y:number|any[] +):number|any[] { + if (typeof x === 'number' && typeof y === 'number') { + return x + y; + } else + + + if (Array.isArray(x) && Array.isArray(y)) { + return [...x, ...y]; + } + + throw new Error('wrong parameters'); +} +``` + +上面示例中,函数`add()`内部使用`if`代码块,分别处理参数的两种情况。 + +注意,重载的个别类型描述与函数的具体实现之间,不能有其他代码,否则报错。 + +另外,虽然函数的具体实现里面,有完整的类型声明。但是,函数实际调用的类型,以前面的类型声明为准。比如,上例的函数实现,参数类型和返回值类型都是`number|any[]`,但不意味着参数类型为`number`时返回值类型为`any[]`。 + +函数重载的每个类型声明之间,以及类型声明与函数实现的类型之间,不能有冲突。 + +```typescript +// 报错 +function fn(x:boolean):void; +function fn(x:string):void; +function fn(x:number|string) { + console.log(x); +} +``` + +上面示例中,函数重载的类型声明与函数实现是冲突的,导致报错。 + +重载声明的排序很重要,因为 TypeScript 是按照顺序进行检查的,一旦发现符合某个类型声明,就不再往下检查了,所以类型最宽的声明应该放在最后面,防止覆盖其他类型声明。 + +```typescript +function f(x:any):number; +function f(x:string): 0|1; +function f(x:any):any { + // ... +} + +const a:0|1 = f('hi'); // 报错 +``` + +上面声明中,第一行类型声明`x:any`范围最宽,导致函数`f()`的调用都会匹配这行声明,无法匹配第二行类型声明,所以最后一行调用就报错了,因为等号两侧类型不匹配,左侧类型是`0|1`,右侧类型是`number`。这个函数重载的正确顺序是,第二行类型声明放到第一行的位置。 + +对象的方法也可以使用重载。 + +```typescript +class StringBuilder { + #data = ''; + + add(num:number): this; + add(bool:boolean): this; + add(str:string): this; + add(value:any): this { + this.#data += String(value); + return this; + } + + toString() { + return this.#data; + } +} +``` + +上面示例中,方法`add()`也使用了函数重载。 + +函数重载也可以用来精确描述函数参数与返回值之间的对应关系。 + +```typescript +function createElement( + tag:'a' +):HTMLAnchorElement; +function createElement( + tag:'canvas' +):HTMLCanvasElement; +function createElement( + tag:'table' +):HTMLTableElement; +function createElement( + tag:string +):HTMLElement { + // ... +} +``` + +上面示例中,函数重载精确描述了参数`tag`的三个值,所对应的不同的函数返回值。 + +这个示例的函数重载,也可以用对象表示。 + +```typescript +type CreateElement = { + (tag:'a'): HTMLAnchorElement; + (tag:'canvas'): HTMLCanvasElement; + (tag:'table'): HTMLTableElement; + (tag:string): HTMLElement; +} +``` + +由于重载是一种比较复杂的类型声明方法,为了降低复杂性,一般来说,如果可以的话,应该优先使用联合类型替代函数重载。 + +```typescript +// 写法一 +function len(s:string):number; +function len(arr:any[]):number; +function len(x:any):number { + return x.length; +} + +// 写法二 +function len(x:any[]|string):number { + return x.length; +} +``` + +上面示例中,写法二使用联合类型,要比写法一的函数重载简单很多。 + +## 构造函数 + +JavaScript 语言使用构造函数,生成对象的实例。 + +构造函数的最大特点,就是必须使用`new`命令调用。 + +```typescript +const d = new Date(); +``` + +上面示例中,`date()`就是一个构造函数,使用`new`命令调用,返回 Date 对象的实例。 + +构造函数的类型声明,采用对象形式。 + +```typescript +type F = { + new (s:string): object; +}; +``` + +上面示例中,类型 F 就是一个构造函数。类型写成一个可执行对象的形式,并且在参数列表前面要加上`new`命令。 + +某些函数既是构造函数,又可以当作普通函数使用,比如`Date()`。这时,类型声明可以写成下面这样。 + +```typescript +type F = { + new (s:string): object; + (n?:number): number; +} +``` + +上面示例中,F 既可以当作普通函数执行,也可以当作构造函数使用。 + +下面是构造函数的一个例子。 + +```typescript +class Animal { + numLegs: number = 4; +} + +function create(c:new () => Animal):Animal { + return new c(); +} + +const a = create(Animal); +``` + +上面示例中,函数`create()`的参数`c`是一个构造函数。在 JavaScript 中,类(class)本质上是构造函数,所以可以传入`create()`。 + +不过,构造函数在 TypeScript 里面实际上只能用类(class)的形式来实现,详见《Class》一章。 diff --git a/docs/generics.md b/docs/generics.md new file mode 100644 index 0000000..fe2c2cd --- /dev/null +++ b/docs/generics.md @@ -0,0 +1,548 @@ +# TypeScript 泛型 + +## 简介 + +有些时候,函数返回值的类型与参数类型是相关的。 + +```javascript +function getFirst(arr) { + return arr[0]; +} +``` + +上面示例中,函数`getFirst()`总是返回参数数组的第一个成员。参数数组是什么类型,返回值就是什么类型。 + +这个函数的类型声明只能写成下面这样。 + +```typescript +function f(arr:any[]):any { + return arr[0]; +} +``` + +上面的类型声明,就反映不出参数与返回值之间的类型关系。 + +为了解决这个问题,TypeScript 就引入了“泛型”(generics)。泛型的特点就是带有“类型参数”(type parameter)。 + +```typescript +function getFirst(arr:T[]):T { + return arr[0]; +} +``` + +上面示例中,函数`getFirst()`的函数名后面尖括号的部分``,就是类型参数放在一对尖括号(`<>`)里面。本例只有一个类型参数`T`,可以将其视为类型声明需要的变量,具体的类型由调用时输入的参数类型决定。 + +参数类型是`T[]`,返回值类型是`T`,就清楚地表示了两者之间的关系。比如,输入的参数类型是`number[]`,那么 T 的值就是`number`,因此返回值类型也是`number`。 + +函数调用时,需要提供类型参数。 + +```typescript +getFirst([1, 2, 3]) +``` + +上面示例中,调用函数`getFirst()`时,需要在函数名后面使用尖括号,给出类型参数`T`的值,本例是``。 + +不过为了方便,函数调用时,往往省略不写类型参数的值,让 TypeScript 自己推断。 + +```typescript +getFirst([1, 2, 3]) +``` + +上面示例中,TypeScript 会从实际参数`[1, 2, 3]`,推断出类型参数 T 的值为`number`。 + +有些复杂的使用场景,TypeScript 可能推断不出类型参数的值,这时就必须显式给出了。 + +```typescript +function comb(arr1:T[], arr2:T[]):T[] { + return arr1.concat(arr2); +} +``` + +上面示例中,两个参数`arr1`、`arr2`和返回值都是同一个类型。如果不给出类型参数的值,下面的调用会报错。 + +```typescript +comb([1, 2], ['a', 'b']) // 报错 +``` + +上面示例会报错,TypeScript 认为两个参数不是同一个类型。但是,如果类型参数是一个联合类型,就不会报错。 + +```typescript +comb([1, 2], ['a', 'b']) // 正确 +``` + +上面示例中,类型参数是一个联合类型,使得两个参数都符合类型参数,就不报错了。这种情况下,类型参数是不能省略不写的。 + +类型参数的名字,可以随便取,但是必须为合法的标识符。习惯上,类型参数的第一个字符往往采用大写字母。 + +一般会使用`T`(type 的第一个字母)作为类型参数的名字。如果有多个类型参数,则使用 T 后面的 U、V 等字母命名,各个参数之间使用逗号“,”分隔。 + +下面是多个类型参数的例子。 + +```typescript +function map( + arr:T[], + f:(arg:T) => U +):U[] { + return arr.map(f); +} + +// 用法实例 +map( + ['1', '2', '3'], + (n) => parseInt(n) +); // 返回 [1, 2, 3] +``` + +上面示例将数组的实例方法`map()`改写成全局函数,它有两个类型参数`T`和`U`。含义是,原始数组的类型为`T[]`,对该数组的每个成员执行一个处理函数`f`,将类型`T`转成类型`U`,那么就会得到一个类型为`U[]`的数组。 + +总之,泛型可以理解成一段类型逻辑,需要类型参数来表达。有了类型参数以后,可以在输入类型与输出类型之间,建立一一对应关系。 + +## 泛型的写法 + +泛型主要用在四个场合:函数、接口、类和别名。 + +### 函数的泛型写法 + +上一节提到,`function`关键字定义的泛型函数,类型参数放在尖括号中,写在函数名后面。 + +```typescript +function id(arg:T):T { + return arg; +} +``` + +那么对于变量形式定义的函数,泛型有下面两种写法。 + +```typescript +// 写法一 +let myId:(arg:T) => T = id; + +// 写法二 +let myId:{ (arg:T):T } = id; +``` + +### 接口的泛型写法 + +泛型函数也可以采用 inteface 的写法。 + +```typescript +interface Box { + contents: Type; +} + +let box:Box; +``` + +上面示例中,使用泛型接口时,需要给出类型参数的值(本例是`string`)。 + +下面是另一个例子。 + +```typescript +interface Comparator { + compareTo(value:T):number; +} + +class Rectangle implements Comparator { + + compareTo(value:Rectangle): number { + // ... + } +} +``` + +上面示例中,先定义了一个泛型接口,然后将这个接口用于一个类。 + +泛型接口还有第二种写法。 + +```typescript +interface Fn { + (arg:Type):Type; +} + +function id(arg:Type):Type { + return arg; +} + +let myId:Fn = id; +``` + +上面示例中,类型参数定义在接口内部,所以使用这个接口时(最后一行),不需要给出类型参数的值。 + +除了声明时不需要给出加类型参数,第二种写法还有一个区别。那就是它的类型参数定义在某个方法之上,其他属性和方法不能使用该类型参数。前面的第一种写法,类型参数定义在整个接口,接口内部的所有属性和方法都可以使用该类型参数。 + +### 类的泛型写法 + +泛型类的类型参数写在类名后面。 + +```typescript +class Pair { + key: K; + value: V; +} +``` + +下面是继承泛型类的例子。 + +```typescript +class A { + value: T; +} + +class B extends A { +} +``` + +上面示例中,类`A`有一个类型参数`T`,使用时必须给出`T`的类型,所以类`B`继承时要写成`A`。 + +泛型也可以用在类表达式。 + +```typescript +const Container = class { + constructor(private readonly data:T) {} +}; + +const a = new Container(true); +const b = new Container(0); +``` + +上面示例中,新建实例时,需要同时给出类型参数`T`和类参数`data`的值。 + +下面是另一个例子。 + +```typescript +class C { + value!:NumType; + add!:(x: NumType, y: NumType) => NumType; +} + +let foo = new C(); + +foo.value = 0; +foo.add = function (x, y) { + return x + y; +}; +``` + +上面示例中,先新建类`C`的实例`foo`,然后再定义示例的`value`属性和`add()`方法。类的定义中,属性和方法后面的感叹号是非空断言,告诉 TypeScript 它们都是非空的,后面会赋值。 + +JavaScript 的类本质上是一个构造函数,因此也可以把泛型类写成构造函数。 + +```typescript +type Class = new (...args: any[]) => T; + +// 或者 +interface Class { + new(...args: any[]):T; +} + +// 用法实例 +function createInstance( + AnyClass:Class, + ...args:any[] +):T { + return new AnyClass(...args); +} +``` + +泛型类描述的是类的实例,不包括静态属性,因为静态属性定义在类的本身。因此,类的静态属性不能引用类型参数。 + +```typescript +class C { + static data:T; // 报错 + constructor(public value:T) {} +} +``` + +上面示例中,静态属性`data`引用了类型参数`T`,这是不可以的,因为类型参数只能用于实例属性和实例方法,所以报错了。 + +### 类型别名的泛型写法 + +type 命令定义的类型别名,也可以使用泛型。 + +```typescript +type Nullable = T | undefined | null; +``` + +上面示例中,`Nullable`是一个泛型,只要传入一个类型,就可以得到这个类型与`undefined`和`null`的一个联合类型。 + +下面是另一个例子。 + +```typescript +type Container = { value: T }; + +const a: Container = { value: 0 }; + +const b: Container = { value: 'b' }; +``` + +下面是定义树形结构的例子。 + +```typescript +type Tree = { + value: T; + left: Tree | null; + right: Tree | null; +}; +``` + +上面示例中,类型别名`Tree`内部递归引用了`Tree`自身。 + +## 类型参数的默认值 + +类型参数可以设置默认值。使用时,如果没有给出类型参数的值,就会使用默认值。 + +```typescript +function getFirst( + arr:T[] +):T { + return arr[0]; +} +``` + +上面示例中,`T = string`表示类型参数的默认值是`string`。调用`getFirst()`时,如果不给出`T`的值,TypeScript 就认为`T`等于`string`。 + +但是,因为 TypeScript 会从实际参数推断出`T`的值,从而覆盖掉默认值,所以下面的代码不会报错。 + +```typescript +getFirst([1, 2, 3]) // 正确 +``` + +上面示例中,实际参数是`[1, 2, 3]`,TypeScript 推断 T 等于`number`,从而覆盖掉默认值`string`。 + +类型参数的默认值,往往用在类中。 + +```typescript +class Generic { + list:T[] = [] + + add(t:T) { + this.list.push(t) + } +} +``` + +上面示例中,类`Generic`有一个类型参数`T`,默认值为`string`。这意味着,实例方法`add()`的参数`t`的类型,默认是`string`。 + +```typescript +const g = new Generic(); + +g.add(4) // 报错 +g.add('hello') // 正确 +``` + +上面示例中,新建`Generic`的实例`g`时,没有给出类型参数`T`的值,所以`T`就等于`string`。因此,向`add()`方法传入一个数值会报错,传入字符串就不会。 + +```typescript +const g = new Generic(); + +g.add(4) // 正确 +g.add('hello') // 报错 +``` + +上面示例中,新建实例`g`时,给出了类型参数`T`的值是`number`,因此`add()`方法传入数值不会报错,传入字符串会报错。 + +一旦类型参数有默认值,就表示它是可选参数。如果有多个类型参数,可选参数必须在必选参数之后。 + +```typescript + // 错误 + + // 正确 +``` + +上面示例中,依次有两个类型参数`T`和`U`。如果`T`是可选参数,`U`不是,就会报错。 + +## 数组的泛型表示 + +《数组》一章提到过,数组类型有一种表示方法是`Array`。这就是泛型的写法,`Array`是 TypeScript 原生的一个类型接口,`T`是它的类型参数。声明数组时,需要提供`T`的值。 + +```typescript +let arr:Array = [1, 2, 3]; +``` + +上面的示例中,`Array`就是一个泛型,类型参数的值是`number`,表示该数组的全部成员都是数值。 + +同样的,如果数组成员都是字符串,那么类型就写成`Array`。事实上,在 TypeScript 内部,数组类型的另一种写法`number[]`、`string[]`,只是`Array`、`Array`的简写形式。 + +在 TypeScript 内部,`Array`是一个泛型接口,类型定义基本是下面的样子。 + +```typescript +interface Array { + + length:number; + + pop():Type | undefined; + + push(...items:Type[]): number; + + // ... +} +``` + +上面代码中,`push()`方法的参数`item`的类型是`Type[]`,跟`Array()`的参数类型`Type`保持一致,表示只能添加同类型的成员。调用`push()`的时候,TypeScript 就会检查两者是否一致。 + +其他的 TypeScript 内部数据结构,比如`Map`、`Set`和`Promise`,其实也是泛型接口,完整的写法是`Map`、`Set`和`Promise`。 + +TypeScript 默认还提供一个`ReadonlyArray`接口,表示只读数组。 + +```typescript +function doStuff( + values: ReadonlyArray +) { + values.push('hello!'); // 报错 +} +``` + +上面示例中,参数`values`的类型是`ReadonlyArray`,表示不能修改这个数组,所以函数体内部新增数组成员就会报错。因此,如果不希望函数内部改动参数数组,就可以将该参数数组声明为`ReadonlyArray`类型。 + +## 类型参数的约束条件 + +很多类型参数并不是无限制的,对于传入的类型存在约束条件。 + +```typescript +function comp(a:Type, b:Type) { + if (a.length >= b.length) { + return a; + } + return b; +} +``` + +上面示例中,类型参数 Type 有一个隐藏的约束条件:Type 必须是对象,且存在`length`属性。如果不满足这个条件,就会报错。 + +TypeScript 提供了一种语法,允许在类型参数上面写明约束条件,如果不满足条件,编译时就会报错。这样也可以有良好的语义,对类型参数进行了说明。 + +```typescript +function comp( + a:T, b:T +) { + if (a.length >= b.length) { + return a; + } + return b; +} +``` + +上面示例中,`T extends { length: number }`就是约束条件,表示类型参数 T 必须满足`{ length: number }`,否则就会报错。 + +```typescript +comp([1, 2], [1, 2, 3]) // 正确 +comp('ab', 'abc') // 正确 +comp(1, 2) // 报错 +``` + +上面示例中,只要传入的参数类型不满足约束条件,就会报错。 + +类型参数的约束条件采用下面的形式。 + +```typescript + +``` + +上面语法中,`TypeParameter`表示类型参数,`extends`是关键字,这是必须的,`ConstraintType`表示类型参数要满足的条件,即类型参数应该是`ConstraintType`的子类型。 + +类型参数可以同时设置约束条件和默认值,前提是默认值必须满足约束条件。 + +```typescript +type Fn + = [A, B]; + +type Result = Fn<'hello'> // ["hello", "world"] +``` + +上面示例中,类型参数`A`和`B`都有约束条件,并且`B`还有默认值。所以,调用`Fn`的时候,可以只给出`A`的值,不给出`B`的值。 + +另外,上例也可以看出,泛型本质上是一个类型函数,通过输入参数,获得结果,两者是一一对应关系。 + +如果有多个类型参数,一个类型参数的约束条件,可以引用其他参数。 + +```typescript + +// 或者 + +``` + +上面示例中,`U`的约束条件引用`T`,或者`T`的约束条件引用`U`,都是正确的。 + +但是,约束条件不能引用类型参数自身。 + +```typescript + // 报错 + // 报错 +``` + +上面示例中,`T`的约束条件不能是`T`自身,因此多个类型参数也不能互相约束(即`T`的约束条件是`U`、`U`的约束条件是`T`),因为互相约束就意味着约束条件就是类型参数自身。 + +## 使用注意点 + +泛型有一些使用注意点。 + +**(1)尽量少用泛型。** + +泛型虽然灵活,但是会加大代码的复杂性,使其变得难读难写。一般来说,只要使用了泛型,类型声明通常都不太易读,容易写得很复杂。因此,可以不用泛型就不要用。 + +**(2)类型参数越少越好。** + +多一个类型参数,多一道替换步骤,加大复杂性。因此,类型参数越少越好。 + +```typescript +function filter< + T, + Fn extends (arg:T) => boolean +>( + arr:T[], + func:Fn +):T[] { + return arr.filter(func); +} +``` + +上面示例有两个类型参数,但是第二个类型参数`Fn`是不必要的,完全可以直接写在函数参数的类型声明里面。 + +```typescript +function filter( + arr:T[], + func:(arg:T) => boolean +):T[] { + return arr.filter(func); +} +``` + +上面示例中,类型参数简化成了一个,效果与前一个示例是一样的。 + +**(3)类型参数需要出现两次。** + +如果类型参数只出现一次,那么很可能是不必要的。 + +```typescript +function greet( + s:Str +) { + console.log('Hello, ' + s); +} +``` + +上面示例中,类型参数`Str`只在函数声明中出现一次(除了它的定义部分),这往往表明这个类型参数是不必要。 + +```typescript +function greet(s:string) { + console.log('Hello, ' + s); +} +``` + +上面示例把前面的类型参数省略了,效果与前一个示例是一样的。 + +也就是说,只有当类型参数用到两次或两次以上,才是泛型的适用场合。 + +**(4)泛型可以嵌套。** + +类型参数可以是另一个类型参数。 + +```typescript +type OrNull = Type|null; + +type OneOrMany = Type|Type[]; + +type OneOrManyOrNull = OrNull>; +``` + +上面示例中,最后一行的泛型`OrNull`的类型参数,就是另一个泛型`OneOrMany`。 diff --git a/docs/global.d.ts.md b/docs/global.d.ts.md new file mode 100644 index 0000000..319ef71 --- /dev/null +++ b/docs/global.d.ts.md @@ -0,0 +1,30 @@ +# global.d.ts + +`global.d.ts`文件用来放置全局的 interfaces 和类型。一旦设置,该项目的所有文件,都可以读取这些类型。 + + +在这个文件里面可以全局申明模块。 + +```typescript +// global.d.ts +declare module 'foo' { + // Some variable declarations + export var bar: number; /*sample*/ +} +``` + +然后可以用模块名输入。 + +```typescript +// anyOtherTsFileInYourProject.ts +import * as foo from 'foo'; +// TypeScript assumes (without doing any lookup) that +// foo is {bar:number} +``` + +该文件的另一个用途是声明一些编译时常量。 + +```typescript +declare const BUILD_MODE_PRODUCTION: boolean; // can be used for conditional compiling +declare const BUILD_VERSION: string; +``` \ No newline at end of file diff --git a/docs/interface.md b/docs/interface.md new file mode 100644 index 0000000..0633f1c --- /dev/null +++ b/docs/interface.md @@ -0,0 +1,695 @@ +# TypeScript 的 interface 接口 + +## 简介 + +interface 是对象的模板,可以看作是一种类型约定,中文译为“接口”。使用了这个模板的对象,就拥有了指定的类型结构。 + +```typescript +interface Person { + firstName: string; + lastName: string; + age: number; +} +``` + +上面示例中,定义了一个接口`Person`,它指定一个对象模板,拥有三个属性`firstName`、`lastName`和`age`。任何实现这个接口的对象,都必须部署这三个属性,并且必须符合规定的类型。 + +实现该接口很简单,只要指定它作为对象的类型即可。 + +```typescript +const p:Person = { + firstName: 'John', + lastName: 'Smith', + age: 25 +}; +``` + +上面示例中,变量`p`的类型就是接口`Person`,所以必须符合`Person`指定的结构。 + +方括号运算符可以取出 interface 某个属性的类型。 + +```typescript +interface Foo { + a: string; +} + +type A = Foo['a']; // string +``` + +上面示例中,`Foo['a']`返回属性`a`的类型,所以类型`A`就是`string`。 + +interface 可以表示对象的各种语法,它的成员有5种形式。 + +- 对象属性 +- 对象的属性索引 +- 对象方法 +- 函数 +- 构造函数 + +(1)对象属性 + +```typescript +interface Point { + x: number; + y: number; +} +``` + +上面示例中,`x`和`y`都是对象的属性,分别使用冒号指定每个属性的类型。 + +属性之间使用分号或逗号分隔,最后一个属性结尾的分号或逗号可以省略。 + +如果属性是可选的,就在属性名后面加一个问号。 + +```typescript +interface Foo { + x?: string; +} +``` + +如果属性是只读的,需要加上`readonly`修饰符。 + +```typescript +interface A { + readonly a: string; +} +``` + +(2)对象的属性索引 + +```typescript +interface A { + [prop: string]: number; +} +``` + +上面示例中,`[prop: string]`就是属性的字符串索引,表示属性名只要是字符串,都符合类型要求。 + +属性索引共有`string`、`number`和`symbol`三种类型。 + +一个接口中,最多只能定义一个字符串索引。字符串索引会约束该类型中所有名字为字符串的属性。 + +```typescript +interface MyObj { + [prop: string]: number; + + a: boolean; // 编译错误 +} +``` + +上面示例中,属性索引指定所有名称为字符串的属性,它们的属性值必须是数值(`number`)。属性`a`的值为布尔值就报错了。 + +属性的数值索引,其实是指定数组的类型。 + +```typescript +interface A { + [prop: number]: string; +} + +const obj:A = ['a', 'b', 'c']; +``` + +上面示例中,`[prop: number]`表示属性名的类型是数值,所以可以用数组对变量`obj`赋值。 + +同样的,一个接口中最多只能定义一个数值索引。数值索引会约束所有名称为数值的属性。 + +如果一个 interface 同时定义了字符串索引和数值索引,那么数值索性必须服从于字符串索引。因为在 JavaScript 中,数值属性名最终是自动转换成字符串属性名。 + +```typescript +interface A { + [prop: string]: number; + [prop: number]: string; // 报错 +} + +interface B { + [prop: string]: number; + [prop: number]: number; // 正确 +} +``` + +上面示例中,数值索引的属性值类型与字符串索引不一致,就会报错。数值索引必须兼容字符串索引的类型声明。 + +(3)对象的方法 + +对象的方法共有三种写法。 + +```typescript +// 写法一 +interface A { + f(x: boolean): string; +} + +// 写法二 +interface B { + f: (x: boolean) => string; +} + +// 写法三 +interface C { + f: { (x: boolean): string }; +} +``` + +属性名可以采用表达式,所以下面的写法也是可以的。 + +```typescript +const f = 'f'; + +interface A { + [f](x: boolean): string; +} +``` + +类型方法可以重载。 + +```typescript +interface A { + f(): number; + f(x: boolean): boolean; + f(x: string, y: string): string; +} +``` + +interface 里面的函数重载,不需要给出实现。但是,由于对象内部定义方法时,无法使用函数重载的语法,所以需要额外在对象外部给出函数方法的实现。 + +```typescript +interface A { + f(): number; + f(x: boolean): boolean; + f(x: string, y: string): string; +} + +function MyFunc(): number; +function MyFunc(x: boolean): boolean; +function MyFunc(x: string, y: string): string; +function MyFunc( + x?:boolean|string, y?:string +):number|boolean|string { + if (x === undefined && y === undefined) return 1; + if (typeof x === 'boolean' && y === undefined) return true; + if (typeof x === 'string' && typeof y === 'string') return 'hello'; + throw new Error('wrong parameters'); +} + +const a:A = { + f: MyFunc +} +``` + +上面示例中,接口`A`的方法`f()`有函数重载,需要额外定义一个函数`MyFunc()`实现这个重载,然后部署接口`A`的对象`a`的属性`f`等于函数`MyFunc()`就可以了。 + +(4)函数 + +interface 也可以用来声明独立的函数。 + +```typescript +interface Add { + (x:number, y:number): number; +} + +const myAdd:Add = (x,y) => x + y; +``` + +上面示例中,接口`Add`声明了一个函数类型。 + +(5)构造函数 + +interface 内部可以使用`new`关键字,表示构造函数。 + +```typescript +interface ErrorConstructor { + new (message?: string): Error; +} +``` + +上面示例中,接口`ErrorConstructor`内部有`new`命令,表示它是一个构造函数。 + +TypeScript 里面,构造函数特指具有`constructor`属性的类,详见《Class》一章。 + +## interface 的继承 + +interface 可以继承其他类型,主要有下面几种情况。 + +### interface 继承 interface + +interface 可以使用`extends`关键字,继承其他 interface。 + +```typescript +interface Shape { + name: string; +} + +interface Circle extends Shape { + radius: number; +} +``` + +上面示例中,`Circle`继承了`Shape`,所以`Circle`其实有两个属性`name`和`radius`。这时,`Circle`是子接口,`Shape`是父接口。 + +`extends`关键字会从继承的接口里面拷贝属性类型,这样就不必书写重复的属性。 + +interface 允许多重继承。 + +```typescript +interface Style { + color: string; +} + +interface Shape { + name: string; +} + +interface Circle extends Style, Shape { + radius: number; +} +``` + +上面示例中,`Circle`同时继承了`Style`和`Shape`,所以拥有三个属性`color`、`name`和`radius`。 + +多重接口继承,实际上相当于多个父接口的合并。 + +如果子接口与父接口存在同名属性,那么子接口的属性会覆盖父接口的属性。注意,子接口与父接口的同名属性必须是类型兼容的,不能有冲突,否则会报错。 + +```typescript +interface Foo { + id: string; +} + +interface Bar extends Foo { + id: number; // 报错 +} +``` + +上面示例中,`Bar`继承了`Foo`,但是两者的同名属性`id`的类型不兼容,导致报错。 + +多重继承时,如果多个父接口存在同名属性,那么这些同名属性不能有类型冲突,否则会报错。 + +```typescript +interface Foo { + id: string; +} + +interface Bar { + id: number; +} + +// 报错 +interface Baz extends Foo, Bar { + type: string; +} +``` + +上面示例中,`Baz`同时继承了`Foo`和`Bar`,但是后两者的同名属性`id`有类型冲突,导致报错。 + +### interface 继承 type + +interface 可以继承`type`命令定义的对象类型。 + +```typescript +type Country = { + name: string; + capital: string; +} + +interface CountryWithPop extends Country { + population: number; +} +``` + +上面示例中,`CountryWithPop`继承了`type`命令定义的`Country`对象,并且新增了一个`population`属性。 + +注意,如果`type`命令定义的类型不是对象,interface 就无法继承。 + +### interface 继承 class + +inteface 还可以继承 class,即继承该类的所有成员。关于 class 的详细解释,参见下一章。 + +```typescript +class A { + x:string = ''; + + y():boolean { + return true; + } +} + +interface B extends A { + z: number +} +``` + +上面示例中,`B`继承了`A`,因此`B`就具有属性`x`、`y()`和`z`。 + +实现`B`接口的对象就需要实现这些属性。 + +```typescript +const b:B = { + x: '', + y: function(){ return true }, + z: 123 +} +``` + +上面示例中,对象`b`就实现了接口`B`,而接口`B`又继承了类`A`。 + +某些类拥有私有成员和保护成员,interface 可以继承这样的类,但是意义不大。 + +```typescript +class A { + private x: string = ''; + protected y: string = ''; +} + +interface B extends A { + z: number +} + +// 报错 +const b:B = { /* ... */ } + +// 报错 +class C implements B { + // ... +} +``` + +上面示例中,`A`有私有成员和保护成员,`B`继承了`A`,但无法用于对象,因为对象不能实现这些成员。这导致`B`只能用于其他 class,而这时其他 class 与`A`之间不构成父类和子类的关系,使得`x`与`y`无法部署。 + +## 接口合并 + +多个同名接口会合并成一个接口。 + +```typescript +interface Box { + height: number; + width: number; +} + +interface Box { + length: number; +} +``` + +上面示例中,两个`Box`接口会合并成一个接口,同时有`height`、`width`和`length`三个属性。 + +这样的设计主要是为了兼容 JavaScript 的行为。JavaScript 开发者常常对全局对象或者外部库,添加自己的属性和方法。那么,只要使用 interface 给出这些自定义属性和方法的类型,就能自动跟原始的 interface 合并,使得扩展外部类型非常方便。 + +举例来说,Web 网页开发经常会对`windows`对象和`document`对象添加自定义属性,但是 TypeScript 会报错,因为原始定义没有这些属性。解决方法就是把自定义属性写成 interface,合并进原始定义。 + +```typescript +interface Document { + foo: string; +} + +document.foo = 'hello'; +``` + +上面示例中,接口`Document`增加了一个自定义属性`foo`,从而就可以在`document`对象上使用自定义属性。 + +同名接口合并时,同一个属性如果有多个类型声明,彼此不能有类型冲突。 + +```typescript +interface A { + a: number; +} + +interface A { + a: string; // 报错 +} +``` + +上面示例中,接口`A`的属性`a`有两个类型声明,彼此是冲突的,导致报错。 + +同名接口合并时,如果同名方法有不同的类型声明,那么会发生函数重载。而且,后面的定义比前面的定义具有更高的优先级。 + +```typescript +interface Cloner { + clone(animal: Animal): Animal; +} + +interface Cloner { + clone(animal: Sheep): Sheep; +} + +interface Cloner { + clone(animal: Dog): Dog; + clone(animal: Cat): Cat; +} + +// 等同于 +interface Cloner { + clone(animal: Dog): Dog; + clone(animal: Cat): Cat; + clone(animal: Sheep): Sheep; + clone(animal: Animal): Animal; +} +``` + +上面示例中,`clone()`方法有不同的类型声明,会发生函数重载。这时,越靠后的定义,优先级越高,排在函数重载的越前面。比如,`clone(animal: Animal)`是最先出现的类型声明,就排在函数重载的最后,属于`clone()`函数最后匹配的类型。 + +这个规则有一个例外。同名方法之中,如果有一个参数是字面量类型,字面量类型有更高的优先级。 + +```typescript +interface A { + f(x:'foo'): boolean; +} + +interface A { + f(x:any): void; +} + +// 等同于 +interface A { + f(x:'foo'): boolean; + f(x:any): void; +} +``` + +上面示例中,`f()`方法有一个类型声明是,参数`x`是字面量类型,这个类型声明的优先级最高,会排在函数重载的最前面。 + +一个实际的例子是 Document 对象的`createElement()`方法,它会根据参数的不同,而生成不同的 HTML 节点对象。 + +```typescript +interface Document { + createElement(tagName: any): Element; +} +interface Document { + createElement(tagName: "div"): HTMLDivElement; + createElement(tagName: "span"): HTMLSpanElement; +} +interface Document { + createElement(tagName: string): HTMLElement; + createElement(tagName: "canvas"): HTMLCanvasElement; +} + +// 等同于 +interface Document { + createElement(tagName: "canvas"): HTMLCanvasElement; + createElement(tagName: "div"): HTMLDivElement; + createElement(tagName: "span"): HTMLSpanElement; + createElement(tagName: string): HTMLElement; + createElement(tagName: any): Element; +} +``` + +上面示例中,`createElement()`方法的函数重载,参数为字面量的类型声明会排到最前面,返回具体的 HTML 节点对象。类型越不具体的参数,排在越后面,返回通用的 HTML 节点对象。 + +如果两个 interface 组成的联合类型存在同名属性,那么该属性的类型也是联合类型。 + +```typescript +interface Circle { + area: bigint; +} + +interface Rectangle { + area: number; +} + +declare const s: Circle | Rectangle; + +s.area; // bigint | number +``` + +上面示例中,接口`Circle`和`Rectangle`组成一个联合类型`Circle | Rectangle`。因此,这个联合类型的同名属性`area`,也是一个联合类型。本例中的`declare`命令表示变量`s`的具体定义,由其他脚本文件给出,详见《d.ts 文件》一章。 + +## interface 与 type 的异同 + +`interface`命令与`type`命令作用类似,都可以表示对象类型。 + +很多对象类型即可以用 interface 表示,也可以用 type 表示。而且,两者往往可以换用,几乎所有的 interface 命令都可以改写为 type 命令。 + +它们的相似之处,首先表示在都能为对象类型起名。 + +```typescript +type Country = { + name: string; + capital: string; +} + +interface Coutry { + name: string; + capital: string; +} +``` + +上面示例是`type`命令和`interface`命令,分别定义同一个类型。 + +`class`命令也有类似作用,通过定义一个类,同时定义一个对象类型。但是,它会创造一个值,编译后依然存在。如果只是单纯想要一个类型,应该使用`type`或`interface`。 + +interface 与 type 的区别有下面几点。 + +(1)`type`能够表示非对象类型,而`interface`只能表示对象类型(包括数组、函数等)。 + +(2)`interface`可以继承其他类型,`type`不支持继承。 + +继承的主要作用是添加属性,`type`定义的对象类型如果想要添加属性,只能使用`&`运算符,重新定义一个类型。 + +```typescript +type Animal = { + name: string +} + +type Bear = Animal & { + honey: boolean +} +``` + +上面示例中,类型`Bear`在`Animal`的基础上添加了一个属性`honey`。 + +上例的`&`运算符,表示同时具备两个类型的特征,因此可以起到两个对象类型合并的作用。 + +作为比较,`interface`添加属性,采用的是继承的写法。 + +```typescript +interface Animal { + name: string +} + +interface Bear extends Animal { + honey: boolean +} +``` + +继承时,type 和 interface 是可以换用的。interface 可以继承 type。 + +```typescript +type Foo = { x: number; }; + +interface Bar extends Foo { + y: number; +} +``` + +type 也可以继承 interface。 + +```typescript +interface Foo { + x: number; +} + +type Bar = Foo & { y: number; }; +``` + +(3)同名`interface`会自动合并,同名`type`则会报错。也就是说,TypeScript 不允许使用`type`多次定义同一个类型。 + +```typescript +type A = { foo:number }; // 报错 +type A = { bar:number }; // 报错 +``` + +上面示例中,`type`两次定义了类型`A`,导致两行都会报错。 + +作为比较,`interface`则会自动合并。 + +```typescript +interface A { foo:number }; +interface A { bar:number }; + +const obj:A = { + foo: 1, + bar: 1 +}; +``` + +上面示例中,`interface`把类型`A`的两个定义合并在一起。 + +这表明,inteface 是开放的,可以添加属性,type 是封闭的,不能添加属性,只能定义新的 type。 + +(4)`interface`不能包含属性映射(mapping),`type`可以,详见《映射》一章。 + +```typescript +interface Point { + x: number; + y: number; +} + +// 正确 +type PointCopy1 = { + [Key in keyof Point]: Point[Key]; +}; + +// 报错 +interface PointCopy2 { + [Key in keyof Point]: Point[Key]; +}; +``` + +(5)`this`关键字只能用于`interface`。 + +```typescript +// 正确 +interface Foo { + add(num:number): this; +}; + +// 报错 +type Foo = { + add(num:number): this; +}; +``` + +上面示例中,type 命令声明的方法`add()`,返回`this`就报错了。interface 命令没有这个问题。 + +下面是返回`this`的实际对象的例子。 + +```typescript +class Calculator implements Foo { + result = 0; + add(num:number) { + this.result += num; + return this; + } +} +``` + +(6)type 可以扩展原始数据类型,interface 不行。 + +```typescript +// 正确 +type MyStr = string & { + type: 'new' +}; + +// 报错 +interface MyStr extends string { + type: 'new' +} +``` + +上面示例中,type 可以扩展原始数据类型 string,interface 就不行。 + +(7)`interface`无法表达某些复杂类型(比如交叉类型和联合类型),但是`type`可以。 + +```typescript +type A = { /* ... */ }; +type B = { /* ... */ }; + +type AorB = A | B; +type AorBwithName = AorB & { + name: string +}; +``` + +上面示例中,类型`AorB`是一个联合类型,`AorBwithName`则是为`AorB`添加一个属性。这两种运算,`interface`都没法表达。 + +综上所述,如果有复杂的类型运算,那么没有选择只有使用`type`;一般情况下,`interface`灵活性比较高,便于扩充类型或自动合并,建议优先使用。 diff --git a/docs/intro.md b/docs/intro.md new file mode 100644 index 0000000..ceacc09 --- /dev/null +++ b/docs/intro.md @@ -0,0 +1,201 @@ +# TypeScript 语言简介 + +## 概述 + +TypeScript(简称 TS)是微软公司开发的一种基于 JavaScript (简称 JS)语言的编程语言。 + +它的目的并不是创造一种全新语言,而是增强 JavaScript 的功能,使其更适合多人合作的企业级项目。 + +TypeScript 可以看成是 JavaScript 的超集(superset),即它继承了后者的全部语法,所有 JavaScript 脚本都可以当作 TypeScript 脚本(但是可能会报错),此外它再增加了一些自己的语法。 + +TypeScript 对 JavaScript 添加的最主要部分,就是一个独立的类型系统。 + +## 类型的概念 + +类型(type)指的是一组具有相同特征的值。如果两个值具有某种共同的特征,就可以说,它们属于同一种类型。 + +举例来说,`123`和`456`这两个值,共同特征是都能进行数值运算,所以都属于“数值”(number)这个类型。 + +一旦确定某个值的类型,就意味着,这个值具有该类型的所有特征,可以进行该类型的所有运算。凡是适用该类型的地方,都可以使用这个值;凡是不适用该类型的地方,使用这个值都会报错。 + +可以这样理解,**类型是人为添加的一种编程约束和用法提示。** 主要目的是在软件开发过程中,为编译器和开发工具提供更多的验证和帮助,帮助提高代码质量,减少错误。 + +下面是一段简单的 TypeScript 代码,演示一下类型系统的作用。 + +```typescript +function addOne(n:number) { + return n + 1; +} +``` + +上面示例中,函数`addOne()`有一个参数`n`,类型为数值(number),表示这个位置只能使用数值,传入其他类型的值就会报错。 + +```typescript +addOne('hello') // 报错 +``` + +上面示例中,函数`addOne()`传入了一个字符串`hello`,TypeScript 发现类型不对,就报错了,指出这个位置只能传入数值,不能传入字符串。 + +JavaScript 语言就没有这个功能,不会检查类型对不对。开发阶段很可能发现不了这个问题,代码也许就会原样发布,导致用户在使用时遇到错误。 + +作为比较,TypeScript 是在开发阶段报错,这样有利于提早发现错误,避免使用时报错。另一方面,函数定义里面加入类型,具有提示作用,可以告诉开发者这个函数怎么用。 + +## 动态类型与静态类型 + +前面说了,TypeScript 的主要功能是为 JavaScript 添加类型系统。大家可能知道,JavaScript 语言本身就有一套自己的类型系统,比如数值`123`和字符串`Hello`。 + +但是,JavaScript 的类型系统非常弱,而且没有使用限制,运算符可以接受各种类型的值。在语法上,JavaScript 属于动态类型语言。 + +请看下面的 JavaScript 代码。 + +```javascript +// 例一 +let x = 1; +x = 'hello'; + +// 例二 +let y = { foo: 1 }; +delete y.foo; +y.bar = 2; +``` + +上面的例一,变量`x`声明时,值的类型是数值,但是后面可以改成字符串。所以,无法提前知道变量的类型是什么,也就是说,变量的类型是动态的。 + +上面的例二,变量`y`是一个对象,有一个属性`foo`,但是这个属性是可以删掉的,并且还可以新增其他属性。所以,对象有什么属性,这个属性还在不在,也是动态的,没法提前知道。 + +正是因为存在这些动态变化,所以 JavaScript 的类型系统是动态的,不具有很强的约束性。这对于提前发现代码错误,非常不利。 + +TypeScript 引入了一个更强大、更严格的类型系统,属于静态类型语言。 + +上面的代码在 TypeScript 里面都会报错。 + +```javascript +// 例一 +let x = 1; +x = 'hello'; // 报错 + +// 例二 +let y = { foo: 1 }; +delete y.foo; // 报错 +y.bar = 2; // 报错 +``` + +上面示例中,例一的报错是因为变量一旦赋值了,就不允许再改变类型,即变量的类型是静态的。例二的报错是因为对象的属性也是静态的,不允许随意增删。 + +TypeScript 的作用,就是为 JavaScript 引入这种静态类型特征。 + +## 静态类型的优点 + +静态类型有很多好处,这也是 TypeScript 想要达到的目的。 + +(1)有利于代码的静态分析。 + +有了静态类型,不必运行代码,就可以确定变量的类型,从而推断代码有没有错误。这就叫做代码的静态分析。 + +这对于大型项目非常重要,单单在开发阶段运行静态检查,就可以发现很多问题,避免交付有问题的代码,大大降低了线上风险。 + +(2)有利于发现错误。 + +由于每个值、每个变量、每个运算符都有严格的类型约束,TypeScript 就能轻松发现拼写错误、语义错误和方法调用错误,节省程序员的时间。 + +```typescript +let obj = { message: '' }; +console.log(obj.messege); // 报错 +``` + +上面示例中,不小心把`message`拼错了,写成`messege`。TypeScript 就会报错,指出没有定义过这个属性。JavaScript 遇到这种情况是不报错的。 + +```typescript +const a = 0; +const b = true; +const result = a + b; // 报错 +``` + +上面示例是合法的 JavaScript 代码,但是没有意义,不应该将数值`a`与布尔值`b`相加。TypeScript 就会直接报错,提示运算符`+`不能用于数值和布尔值的相加。 + +```typescript +function hello() { + return 'hello world'; +} + +hello().find('hello'); // 报错 +``` + +上面示例中,`hello()`返回的是一个字符串,TypeScript 发现字符串没有`find()`方法,所以报错了。如果是 JavaScript,只有到运行阶段才会报错。 + +(3)更好的 IDE 支持,做到语法提示和自动补全。 + +IDE(集成开发环境,比如 VSCode)一般都会利用类型信息,提供语法提示功能(编辑器自动提示函数用法、参数等)和自动补全功能(只键入一部分的变量名或函数名,编辑器补全后面的部分)。 + +(4)提供了代码文档。 + +类型信息可以部分替代代码文档,解释应该如何使用这些代码,熟练的开发者往往只看类型,就能大致推断代码的作用。借助类型信息,很多工具能够直接生成文档。 + +(5)有助于代码重构。 + +修改他人的 JavaScript 代码,往往非常痛苦,项目越大越痛苦,因为不确定修改后是否会影响到其他部分的代码。 + +类型信息大大减轻了重构的成本。一般来说,只要函数或对象的参数和返回值保持类型不变,就能基本确定,重构后的代码也能正常运行。如果还有配套的单元测试,就完全可以放心重构。越是大型的、多人合作的项目,类型信息能够提供的帮助越大。 + +综上所述,TypeScript 有助于提高代码质量,保证代码安全,更适合用在大型的企业级项目。这就是为什么大量 JavaScript 项目转成 TypeScript 的原因。 + +## 静态类型的缺点 + +静态类型也存在一些缺点。 + +(1)丧失了动态类型的代码灵活性。 + +动态类型有非常高的灵活性,给予程序员很大的自由,静态类型将这些灵活性都剥夺了。 + +(2)增加了编程工作量。 + +有了类型之后,程序员不仅需要编写功能,还需要编写类型声明,确保类型正确。这增加了不少工作量,有时会显著拖长项目的开发时间。 + +(3)更高的学习成本。 + +类型系统通常比较复杂,要学习的东西更多,要求开发者付出更高的学习成本。 + +(4)引入了独立的编译步骤。 + +原生的 JavaScript 代码,可以直接在 JavaScript 引擎运行。添加类型系统以后,就多出了一个单独的编译步骤,检查类型是否正确,并将 TypeScript 代码转成 JavaScript 代码,这样才能运行。 + +(5)兼容性问题。 + +TypeScript 依赖 JavaScript 生态,需要用到很多外部模块。但是,过去大部分 JavaScript 项目都没有做 TypeScript 适配,虽然可以自己动手做适配,不过使用时难免还是会有一些兼容性问题。 + +总的来说,这些缺点使得 TypeScript 不一定适合那些小型的、短期的个人项目。 + +## TypeScript 的历史 + +下面简要介绍 TypeScript 的发展历史。 + +2012年,微软公司宣布推出 TypeScript 语言,设计者是著名的编程语言设计大师 Anders Hejlsberg,他也是 C# 和 .Net 的设计师。 + +微软推出这门语言的主要目的,是让 JavaScript 程序员可以参与 Windows 8 应用程序的开发。 + +当时,Windows 8 即将发布,它的应用程序开发除了使用 C# 和 Visual Basic,还可以使用 HTML + JavaScript。微软希望,TypeScript 既能让 JavaScript 程序员快速上手,也能让 .Net 程序员感到熟悉。 + +这就是说,TypeScript 的最初动机是减少 .Net 程序员的转移和学习成本。所以,它的很多语法概念跟 .Net 很类似。 + +另外,TypeScript 是一个开源项目,接受社区的参与,核心的编译器采用 Apache 2.0 许可证。微软希望通过这种做法,迅速提高这门语言在社区的接受度。 + +2013年,微软的 Visual Studio 2013 开始内置支持 TypeScript 语言。 + +2014年,TypeScript 1.0 版本发布。同年,代码仓库搬到了 GitHub。 + +2016年,TypeScript 2.0 版本发布,引入了很多重大的语法功能。 + +2018年,TypeScript 3.0 版本发布。 + +2020年,TypeScript 4.0 版本发布。 + +2023年,TypeScript 5.0 版本发布。 + +## 如何学习 + +学习 TypeScript,必须先了解 JavaScript 的语法。因为真正的实际功能都是 JavaScript 引擎完成的,TypeScript 只是添加了一个类型系统。 + +本书假定读者已经了解 JavaScript 语言,就不再介绍它的语法了,只介绍 TypeScript 引入的新语法,主要是类型系统。 + +如果你对 JavaScript 还不熟悉,建议先阅读[《JavaScript 教程》](https://wangdoc.com/javascript)和[《ES6 教程》](https://wangdoc.com/es6),再来阅读本书。 + diff --git a/docs/mapping.md b/docs/mapping.md new file mode 100644 index 0000000..e4350af --- /dev/null +++ b/docs/mapping.md @@ -0,0 +1,359 @@ +# TypeScript 的类型映射 + +## 简介 + +映射(mapping)指的是,将一种类型按照映射规则,转换成另一种类型,通常用于对象类型。 + +举例来说,现有一个类型`A`和另一个类型`B`。 + +```typescript +type A = { + foo: number; + bar: number; +}; + +type B = { + foo: string; + bar: string; +}; +``` + +上面示例中,这两个类型的属性结构是一样的,但是属性的类型不一样。如果属性数量多的话,写起来就很麻烦。 + +使用类型映射,就可以从类型`A`得到类型`B`。 + +```typescript +type A = { + foo: number; + bar: number; +}; + +type B = { + [prop in keyof A]: string; +}; +``` + +上面示例中,类型`B`采用了属性名索引的写法,`[prop in keyof A]`表示依次得到类型`A`的所有属性名,然后将每个属性的类型改成`string`。 + +在语法上,`[prop in keyof A]`是一个属性名表达式,表示这里的属性名需要计算得到。具体的计算规则如下: + +- `prop`:属性名变量,名字可以随便起。 +- `in`:运算符,用来取出右侧的联合类型的每一个成员。 +- `Keyof A`:返回类型`A`的每一个属性名,组成一个联合类型。 + +下面是复制原始类型的例子。 + +```typescript +type A = { + foo: number; + bar: string; +}; + +type B = { + [prop in keyof A]: A[prop]; +}; +``` + +上面示例中,类型`B`原样复制了类型`A`。 + +为了增加代码复用性,可以把常用的映射写成泛型。 + +```typescript +type ToBoolean = { + [Property in keyof Type]: boolean; +}; +``` + +上面示例中,定义了一个泛型,可以将其他对象的所有属性值都改成 boolean 类型。 + +由于对象的属性名,只有 string、number、symbol 三种可能,所以 keyof 运算符返回的联合类型,应该是`string | number | symbol`的子类型。 + +下面是另一个例子。 + +```typescript +type MyObj = { + [P in 0|1|2]: string; +}; + +// 等同于 +type MyObj = { + 0: string; + 1: string; + 2: string; +}; +``` + +上面示例中,联合类型`0|1|2`映射成了三个属性名。 + +不使用联合类型,直接使用某种具体类型进行属性名映射,也是可以的。 + +```typescript +type MyObj = { + [p in 'foo']: number; +}; + +// 等同于 +type MyObj = { + foo: number; +}; +``` + +上面示例中,`p in 'foo'`可以看成只有一个成员的联合类型,因此得到了只有这一个属性的对象类型。 + +甚至还可以写成`p in string`。 + +```typescript +type MyObj = { + [p in string]: boolean; +}; + +// 等同于 +type MyObj = { + [p: string]: boolean; +}; +``` + +上面示例中,`[p in string]`就是属性名索引形式`[p: string]`的映射写法。 + +通过映射,可以某个对象的所有属性改成可选属性。 + +```typescript +type A = { + a: string; + b: number; +}; + +type B = { + [Prop in keyof A]?: A[Prop]; +}; +``` + +上面示例中,类型`B`在类型`A`的所有属性名后面添加问号,使得这些属性都变成了可选属性。 + +事实上,TypeScript 的内置工具类型`Partial`,就是这样实现的。 + +TypeScript内置的工具类型`Readonly`可以将所有属性改为只读属性,实现也是通过映射。 + +```typescript +// 将 T 的所有属性改为只读属性 +type Readonly = { + readonly [P in keyof T]: T[P]; +}; +``` + +它的用法如下。 + +```typescript +type T = { a: string; b: number }; + +// { +// readonly a: string; +// readonly b: number; +// } +type ReadonlyT = Readonly; +``` + +## 映射修饰符 + +映射会原样复制原始对象的可选属性和只读属性。 + +```typescript +type A = { + a?: string; + readonly b: number; +}; + +type B = { + [Prop in keyof A]: A[Prop]; +}; + +// 等同于 +type B = { + a?: string; + readonly b: number; +}; +``` + +上面示例中,类型`B`是类型`A`的映射,把`A`的可选属性和只读属性都保留下来。 + +如果要删改可选和只读这两个特性,并不是很方便。为了解决这个问题,TypeScript 引入了两个映射修饰符,用来在映射时添加或移除某个属性的`?`修饰符和`readonly`修饰符。 + +- `+`修饰符:写成`+?`或`+readonly`,为映射属性添加`?`修饰符或`readonly`修饰符。 +- `–`修饰符:写成`-?`或`-readonly`,为映射属性移除`?`修饰符或`readonly`修饰符。 + +下面是添加或移除可选属性的例子。 + +```typescript +// 添加可选属性 +type Optional = { + [Prop in keyof Type]+?: Type[Prop]; +}; + +// 移除可选属性 +type Concrete = { + [Prop in keyof Type]-?: Type[Prop]; +}; +``` + +注意,`+?`或`-?`要写在属性名的后面。 + +下面是添加或移除只读属性的例子。 + +```typescript +// 添加 readonly +type CreateImmutable = { + +readonly [Prop in keyof Type]: Type[Prop]; +}; + +// 移除 readonly +type CreateMutable = { + -readonly [Prop in keyof Type]: Type[Prop]; +}; +``` + +注意,`+readonly`和`-readonly`要写在属性名的前面。 + +如果同时增删`?`和`readonly`这两个修饰符,写成下面这样。 + +```typescript +// 增加 +type MyObj = { + +readonly [P in keyof T]+?: T[P]; +}; + +// 移除 +type MyObj = { + -readonly [P in keyof T]-?: T[P]; +} +``` + +TypeScript 原生的工具类型`Required`专门移除可选属性,就是使用`-?`修饰符实现的。 + +注意,`–?`修饰符移除了可选属性以后,该属性就不能等于`undefined`了,实际变成必选属性了。但是,这个修饰符不会移除`null`类型。 + +另外,`+?`修饰符可以简写成`?`,`+readonly`修饰符可以简写成`readonly`。 + +```typescript +type A = { + +readonly [P in keyof T]+?: T[P]; +}; + +// 等同于 +type B = { + readonly [P in keyof T]?: T[P]; +}; +``` + +## 键名重映射 + +### 语法 + +TypeScript 4.1 引入了键名重映射(key remapping),允许将键名指定为其他类型。 + +```typescript +type A = { + foo: number; + bar: number; +}; + +type B = { + [p in keyof A as `${p}ID`]: number; +}; + +// 等同于 +type B = { + fooID: number; + barID: number; +}; +``` + +上面示例中,类型`B`是类型`A`的映射,但在映射时把属性名改掉了,在原始属性名后面加上了字符串`ID`。 + +可以看到,键名重映射的语法是在键名映射的后面加上`as + 新类型`子句。这里的“新类型”通常是一个模板字符串,里面可以对原始键名进行各种操作。 + +下面是另一个例子。 + +```typescript +interface Person { + name: string; + age: number; + location: string; +} + +type Getters = { + [P in keyof T + as `get${Capitalize}`]: () => T[P]; +}; + +type LazyPerson = Getters; +// 等同于 +type LazyPerson = { + getName: () => string; + getAge: () => number; + getLocation: () => string; +} +``` + +上面示例中,类型`LazyPerson`是类型`Person`的映射,并且把键名改掉了。 + +它的修改键名的代码是一个模板字符串`get${Capitalize}`,下面是各个部分的解释。 + +- `get`:为键名添加的前缀。 +- `Capitalize`:一个原生的工具泛型,用来将`T`的首字母变成大写。 +- `string & P`:一个交叉类型,其中的`P`是 keyof 运算符返回的键名联合类型`string|number|symbol`,但是`Capitalize`只能接受字符串作为类型参数,因此`string & P`只返回`P`的字符串属性名。 + +### 属性过滤 + +键名重映射还可以过滤掉某些属性。下面的例子是只保留字符串属性。 + +```typescript +type User = { + name: string, + age: number +} + +type Filter = { + [K in keyof T + as T[K] extends string ? K : never]: string +} + +type FilteredUser = Filter // { name: string } +``` + +上面示例中,映射`K in keyof T`获取类型`T`的每一个属性以后,然后使用`as Type`修改键名的类型。 + +它的键盘重映射`as T[K] extends string ? K : never]`,使用了条件运算符。如果属性值`T[K]`的类型是字符串,那么属性名不变,否则属性名类型改为`never`,即这个属性名不存在。这样就等于过滤了不符合条件的属性,只保留属性值为字符串的属性。 + +### 联合类型的映射 + +由于键名重映射可以修改键名类型,所以原始键名的类型不必是`string|number|symbol`,任意的联合类型都可以。 + +```typescript +type S = { + kind: 'square', + x: number, + y: number, +}; + +type C = { + kind: 'circle', + radius: number, +}; + +type MyEvents = { + [E in Events as E['kind']]: (event: E) => void; +} + +type Config = MyEvent; +// 等同于 +type Config = { + square: (event:S) => void; + circle: (event:C) => void; +} +``` + +上面示例中,原始键名的映射是`E in Events`,这里的`Events`是两个对象组成的联合类型`S|C`。所以,`E`是一个对象,然后再通过键名重映射,得到字符串键名`E['kind']`。 + +## 参考链接 + +- [Mapped Type Modifiers in TypeScript](https://mariusschulz.com/blog/mapped-type-modifiers-in-typescript), Marius Schulz diff --git a/docs/module.md b/docs/module.md new file mode 100644 index 0000000..a9424fc --- /dev/null +++ b/docs/module.md @@ -0,0 +1,375 @@ +# TypeScript 模块 + +## 简介 + +任何包含 import 或 export 语句的文件,就是一个模块(module)。相应地,如果文件不包含 export 语句,就是一个全局的脚本文件。 + +模块本身就是一个作用域,不属于全局作用域。模块内部的变量、函数、类只在内部可见,对于模块外部是不可见的。暴露给外部的接口,必须用 export 命令声明;如果其他文件要使用模块的接口,必须用 import 命令来输入。 + +如果一个文件不包含 export 语句,但是希望把它当作一个模块(即内部变量对外不可见),可以在文件中添加一行语句。 + +```typescript +export {}; +``` + +上面这行语句不产生任何实际作用,但会让当前文件被当作模块处理,所有它的代码都变成了内部代码。 + +ES 模块的详细介绍,请参考 ES6 教程,这里就不重复了。本章主要介绍 TypeScript 的模块处理。 + +TypeScript 模块除了支持所有 ES 模块的语法,特别之处在于允许输出和输入类型。 + +```typescript +export type Bool = true | false; +``` + +上面示例中,当前脚本输出一个类型别名`Bool`。这行语句把类型定义和接口输出写在一行,也可以写成两行。 + +```typescript +type Bool = true | false; + +export { Bool }; +``` + +假定上面的模块文件为`a.ts`,另一个文件`b.ts`就可以使用 import 语句,输入这个类型。 + +```typescript +import { Bool } from './a.js'; + +let foo:Bool = true; +``` + +上面示例中,import 语句加载的是一个类型。注意,它是从文件`a.js`加载,而不是从`a.ts`加载,因为在代码运行环境是 JS 环境,所以要写成从 JS 文件加载,否则报错。 + +TypeScript 允许加载模块时,省略模块文件的后缀名,它会自动定位。 + +```typescript +import { Bool } from './a'; +``` + +上面示例中,模块名写成`./a`,TypeScript 会自动定位到`./a.ts`。 + +编译时,可以两个脚本同时编译。 + +```bash +$ tsc a.ts b.ts +``` + +上面命令会将`a.ts`和`b.ts`分别编译成`a.js`和`b.js`。 + +也可以只编译`b.ts`,因为它是入口脚本,tsc 会自动编译它依赖的所有脚本。 + +```bash +$ tsc b.ts +``` + +上面命令发现`b.ts`依赖`a.js`,就会自动寻找`a.ts`,也将其同时编译,因此编译产物还是`a.js`和`b.js`两个文件。 + +如果想将`a.ts`和`b.ts`编译成一个文件,可以使用`--outFile`参数。 + +```typescript +$ tsc --outFile result.js b.ts +``` + +上面示例将`a.ts`和`b.ts`合并编译为`result.js`。 + +## import type 语句 + +import 在一条语句中,可以同时输入类型和正常接口。 + +```typescript +// a.ts +export interface A { + foo: string; +} + +export let a = 123; + +// b.ts +import { A, a } from './a.js'; +``` + +上面示例中,文件`a.ts`的 export 语句输出了一个类型`A`和一个正常接口`a`,另一个文件`b.ts`则在同一条语句中输入了类型和正常接口。 + +这样很不利于区分类型和正常接口,容易造成混淆。为了解决这个问题,TypeScript 引入了两个解决方法。 + +第一个方法是在 import 语句输入的类型前面加上`type`关键字。 + +```typescript +import { type A, a } from './a.js'; +``` + +上面示例中,import 语句输入的类型`A`前面有`type`关键字,表示这是一个类型。 + +第二个方法是使用 import type 语句,这个语句只能输入类型,不能输入正常接口。 + +```typescript +// 正确 +import type { A } from './a.js'; + +// 报错 +import type { a } from './a.js'; +``` + +上面示例中,import type 输入类型`A`是正确的,但是输入正常接口`a`就会报错。 + +import type 语句也可以输入默认类型。 + +```typescript +import type DefaultType from 'moduleA'; +``` + +import type 在一个名称空间下,输入所有类型的写法如下。 + +```typescript +import type * as TypeNS from 'moduleA'; +``` + +同样的,export 语句也有两种方法,表示输出的是类型。 + +```typescript +type A = 'a'; +type B = 'b'; + +// 方法一 +export {type A, type B}; + +// 方法二 +export type {A, B}; +``` + +上面示例中,方法一是使用`type`关键字作为前缀,表示输出的是类型;方法二是使用 export type 语句,表示整行输出的都是类型。 + +下面是 export type 将一个类作为类型输出的例子。 + +```typescript +class Point { + x: number; + y: number; +} + +export type { Point }; +``` + +上面示例中,由于使用了 export type 语句,输出的并不是 Point 这个类,而是 Point 代表的实例类型。输入时,只能作为类型输入。 + +```typescript +import type { Point } from './module.js'; + +const p:Point = { x: 0, y: 0 }; +``` + +上面示例中,`Point`只能作为类型输入,不能当作正常接口使用。 + +## importsNotUsedAsValues + +输入类型的 import 语句,编译时怎么处理? + +TypeScript 提供了`importsNotUsedAsValues`编译设置项,有三个可能的值。 + +(1)`remove`:这是默认值,自动删除输入类型的 import 语句。 + +(2)`preserve`:保留输入类型的 import 语句。 + +(3)`error`:保留输入类型的 import 语句(与`preserve`相同),但是必须写成 import type 的形式,否则报错。 + +请看示例,下面是一个输入类型的 import 语句。 + +```typescript +import { TypeA } from './a.js'; +``` + +上面示例中,`TypeA`是一个类型。 + +`remove`的编译结果会将该语句删掉。 + +`preserve`的编译结果会保留该语句,但会把删掉类型的部分。 + +```typescript +import './a.js'; +``` + +上面示例中,编译后的 import 语句不从`a.js`输入任何接口,但是会引发`a.js`的执行,因此会保留`a.js`里面的副作用。 + +`error`的结果与`preserve`相同,但是编译过程会报错,因为输入类型的 import 语句必须写成 import type 的形式。原始语句改成下面的形式,就不会报错。 + +```typescript +import type { TypeA } from './a.js'; +``` + +## CommonJS 模块 + +CommonJS 是 Node.js 的专用模块格式,与 ES 模块格式不兼容。 + +### import = 语句 + +TypeScript 使用 import = 语句输入 CommonJS 模块。 + +```typescript +import fs = require('fs'); +const code = fs.readFileSync('hello.ts', 'utf8'); +``` + +上面示例中,使用 import = 语句和`require()`命令输入了一个 CommonJS 模块。模块本身的用法跟 Node.js 是一样的。 + +除了使用`import =`语句,TypeScript 还允许使用`import * as [接口名] from "模块文件"`输入 CommonJS 模块。 + +```typescript +import * as fs from 'fs'; +// 等同于 +import fs = require('fs'); +``` + +### export = 语句 + +TypeScript 使用 export = 语句,输出 CommonJS 模块的对象,等同于 CommonJS 的`module.exports`对象。 + +```typescript +let obj = { foo: 123 }; + +export = obj; +``` + +export = 语句输出的对象,只能使用 import = 语句加载。 + +```typescript +import obj = require('./a'); + +console.log(obj.foo); // 123 +``` + +## 模块定位 + +模块定位(module resolution)指的是确定 import 语句和 export 语句里面的模块文件位置。 + +```typescript +import { TypeA } from './a'; +``` + +上面示例中,TypeScript 怎么确定`./a`到底是指哪一个模块,这就叫做“模块定位”。 + +模块定位有两种方法,一种称为 Classic 方法,另一种称为 Node 方法。可以使用编译参数`moduleResolution`,指定使用哪一种方法。 + +没有指定定位方法时,就看原始脚本采用什么模块格式。如果模块格式是 CommonJS,即编译时指定`--module commonjs`,那么模块定位采用 Node 方法,否则采用 Classic 方法(模块格式为 es2015、 esnext、amd, system, umd 等等)。 + +### 相对模块,非相对模块 + +加载模块时,目标模块分为相对模块(relative import)和非相对模块两种(non-relative import)。 + +相对模块指的是路径以`/`、`./`、`../`开头的模块。下面 import 语句加载的模块,都是相对模块。 + +- `import Entry from "./components/Entry";` +- `import { DefaultHeaders } from "../constants/http";` +- `import "/mod";` + +非相对模块指的是不带有路径信息的模块。下面 import 语句加载的模块,都是非相对模块。 + +- `import * as $ from "jquery";` +- `import { Component } from "@angular/core";` + +### Classic 方法 + +Classic 方法以当前脚本的路径作为“基准路径”,计算相对模块的位置。比如,脚本`a.ts`里面有一行代码`import { b } from "./b"`,那么 TypeScript 就会在`a.ts`所在的目录,查找`b.ts`和`b.d.ts`。 + +至于非相对模块,也是以当前脚本的路径作为起点,一层层查找上级目录。比如,脚本`a.ts`里面有一行代码`import { b } from "b"`,那么就会查找`b.ts`和`b.d.ts`。 + +### Node 方法 + +Node 方法就是模拟 Node.js 的模块加载方法。 + +相对模块依然是以当前脚本的路径作为“基准路径”。比如,脚本文件`a.ts`里面有一行代码`let x = require("./b");`,TypeScript 按照以下顺序查找。 + +1. 当前目录是否包含`b.ts`、`b.tsx`、`b.d.ts`。 +1. 当前目录是否有子目录`b`,该子目录是否存在文件`package.json`,该文件的`types`字段是否指定了入口文件,如果是的就加载该文件。 +1. 当前目录的子目录`b`是否包含`index.ts`、`index.tsx`、`index.d.ts`。 + +非相对模块则是以当前脚本的路径作为起点,逐级向上层目录查找是否存在子目录`node_modules`。比如,脚本文件`a.js`有一行`let x = require("b");`,TypeScript 按照以下顺序进行查找。 + +1. 当前目录的子目录`node_modules`是否包含`b.ts`、`b.tsx`、`b.d.ts`。 +2. 当前目录的子目录`node_modules`,是否存在文件`package.json`,该文件的`types`字段是否指定了入口文件,如果是的就加载该文件。 +3. 当前目录的子目录`node_modules`里面,是否包含子目录`@types`,在该目录中查找文件`b.d.ts`。 +4. 当前目录的子目录`node_modules`里面,是否包含子目录`b`,在该目录中查找`index.ts`、`index.tsx`、`index.d.ts`。 +5. 进入上一层目录,重复上面4步,直到找到为止。 + +### 路径映射 + +TypeScript 允许开发者在`tsconfig.json`文件里面,手动指定模块的路径。 + +(1)baseUrl + +`baseUrl`字段可以手动指定模块的基准目录。 + +```typescript +{ + "compilerOptions": { + "baseUrl": "." + } +} +``` + +上面示例中,`baseUrl`是一个点,表示基准目录就是`tsconfig.json`所在的目录。 + +(2)paths + +`paths`字段指定非相对模块与实际脚本的映射。 + +```typescript +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "jquery": ["node_modules/jquery/dist/jquery"] + } + } +} +``` + +上面示例中,加载模块`jquery`时,实际加载的脚本是`node_modules/jquery/dist/jquery`,它的位置要根据`baseUrl`字段计算得到。 + +注意,上例的`jquery`属性的值是一个数组,可以指定多个路径。如果第一个脚本路径不存在,那么就加载第二个路径,以此类推。 + +(3)rootDirs + +`rootDirs`字段指定模块定位时必须查找的其他目录。 + +```typescript +{ + "compilerOptions": { + "rootDirs": ["src/zh", "src/de", "src/#{locale}"] + } +} +``` + +上面示例中,`rootDirs`指定了模块定位时,需要查找的不同的国际化目录。 + +### tsc 的`--traceResolution`参数 + +由于模块定位的过程很复杂,tsc 命令有一个`--traceResolution`参数,能够在编译时在命令行显示模块定位的每一步。 + +```bash +$ tsc --traceResolution +``` + +上面示例中,`traceResolution`会输出模块定位的判断过程。 + +### tsc 的`--noResolve`参数 + +tsc 命令的`--noResolve`参数,表示模块定位时,只考虑在命令行传入的模块。 + +举例来说,`app.ts`包含如下两行代码。 + +```typescript +import * as A from "moduleA"; +import * as B from "moduleB"; +``` + +使用下面的命令进行编译。 + +```bash +$ tsc app.ts moduleA.ts --noResolve +``` + +上面命令使用`--noResolve`参数,因此可以定位到`moduleA.ts`,因为它从命令行传入了;无法定位到`moduleB`,因为它没有传入,因此会报错。 + +## 参考链接 + +- [tsconfig 之 importsNotUsedAsValues 属性](https://blog.51cto.com/u_13028258/5754309) diff --git a/docs/namespace.md b/docs/namespace.md new file mode 100644 index 0000000..f8ab82b --- /dev/null +++ b/docs/namespace.md @@ -0,0 +1,302 @@ +# TypeScript namespace + +namespace 是一种将相关代码组织在一起的方式,中文译为“命名空间”。 + +它出现在 ES 模块诞生之前,作为 TypeScript 自己的模块格式而发明的。但是,自从有了 ES 模块,官方已经不推荐使用 namespace 了。 + +## 基本用法 + +namespace 用来建立一个容器,内部的所有变量和函数,都必须在这个容器里面使用。 + +```typescript +namespace Utils { + function isString(value:any) { + return typeof value === 'string'; + } + + // 正确 + isString('yes'); +} + +Utils.isString('no'); // 报错 +``` + +上面示例中,命名空间`Utils`里面定义了一个函数`isString()`,它只能在`Utils`里面使用,如果用于外部就会报错。 + +如果要在命名空间以外使用内部成员,就必须为该成员加上`export`前缀,表示对外输出该成员。 + +```typescript +namespace Utility { + export function log(msg:string) { + console.log(msg); + } + export function error(msg:string) { + console.error(msg); + } +} + +Utility.log('Call me'); +Utility.error('maybe!'); +``` + +上面示例中,只要加上`export`前缀,就可以在命名空间外部使用内部成员。 + +编译出来的 JavaScript 代码如下。 + +```typescript +var Utility; + +(function (Utility) { + function log(msg) { + console.log(msg); + } + Utility.log = log; + function error(msg) { + console.error(msg); + } + Utility.error = error; +})(Utility || (Utility = {})); +``` + +上面代码中,命名空间`Utility`变成了 JavaScript 的一个对象,凡是`export`的内部成员,都成了该对象的属性。 + +这就是说,namespace 会变成一个值,保留在编译后的代码中。这一点要小心,它不是纯的类型代码。 + +namespace 内部还可以使用`import`命令输入外部成员,相当于为外部成员起别名。当外部成员的名字比较长时,别名能够简化代码。 + +```typescript +namespace Utils { + export function isString(value:any) { + return typeof value === 'string'; + } +} + +namespace App { + import isString = Utils.isString; + + isString('yes'); + // 等同于 + Utils.isString('yes'); +} +``` + +上面示例中,`import`命令指定在命名空间`App`里面,外部成员`Utils.isString`的别名为`isString`。 + +`import`命令也可以在 namespace 外部,指定别名。 + +```typescript +namespace Shapes { + export namespace Polygons { + export class Triangle {} + export class Square {} + } +} + +import polygons = Shapes.Polygons; + +// 等同于 new Shapes.Polygons.Square() +let sq = new polygons.Square(); +``` + +上面示例中,`import`命令在命名空间`Shapes`的外部,指定` Shapes.Polygons`的别名为`polygons`。 + +namespace 可以嵌套。 + +```typescript +namespace Utils { + export namespace Messaging { + export function log(msg:string) { + console.log(msg); + } + } +} + +Utils.Messaging.log('hello') // "hello" +``` + +上面示例中,命名空间`Utils`内部还有一个命名空间`Messaging`。注意,如果要在外部使用`Messaging`,必须在它前面加上`export`命令。 + +使用嵌套的命名空间,必须从最外层开始引用,比如`Utils.Messaging.log()`。 + +namespace 不仅可以包含实义代码,还可以包括类型代码。 + +```typescript +namespace N { + export interface MyInterface{} + export class MyClass{} +} +``` + +上面代码中,命令空间`N`不仅对外输出类,还对外输出一个接口,它们都可以用作类型。 + +namespace 与模块的作用是一致的,都是把相关代码组织在一起,对外输出接口。区别是一个文件只能有一个模块,但可以有多个 namespace。由于模块可以取代 namespace,而且是 JavaScript 的标准语法,还不需要编译转换,所以建议总是使用模块,替代 namespace。 + +如果 namespace 代码放在一个单独的文件里,那么引入这个文件需要使用三斜杠的语法。 + +```typescript +/// +``` + +## namespace 的输出 + +namespace 本身也可以使用`export`命令输出,供其他文件使用。 + +```typescript +// shapes.ts +export namespace Shapes { + export class Triangle { + // ... + } + export class Square { + // ... + } +} +``` + +上面示例是一个文件`shapes.ts`,里面使用`export`命令,输出了一个命名空间`Shapes`。 + +其他脚本文件使用`import`命令,加载这个命名空间。 + +```typescript +// 写法一 +import { Shapes } from './shapes'; +let t = new Shapes.Triangle(); + +// 写法二 +import * as shapes from "./shapes"; +let t = new shapes.Shapes.Triangle(); // shapes.Shapes? +``` + +不过,更好的方法还是建议使用模块,采用模块的输出和输入。 + +```typescript +// shapes.ts +export class Triangle { + /* ... */ +} +export class Square { + /* ... */ +} + +// shapeConsumer.ts +import * as shapes from "./shapes"; +let t = new shapes.Triangle(); +``` + +上面示例中,使用模块的输出和输入,改写了前面的例子。 + +## namespace 的合并 + +多个同名的 namespace 会自动合并,这一点跟 interface 一样。 + +```typescript +namespace Animals { + export class Cat {} +} +namespace Animals { + export interface Legged { + numberOfLegs: number; + } + export class Dog {} +} + +// 等同于 +namespace Animals { + export interface Legged { + numberOfLegs: number; + } + export class Cat {} + export class Dog {} +} +``` + +这样做的目的是,如果同名的命名空间分布在不同的文件中,TypeScript 最终会将它们合并在一起。这样就比较方便扩展别人的代码。 + +合并命名空间时,命名空间中的非`export`的成员不会被合并,但是它们只能在各自的命名空间中使用。 + +```typescript +namespace N { + const a = 0; + + export function foo() { + console.log(a); // 正确 + } +} + +namespace N { + export function bar() { + foo(); // 正确 + console.log(a); // 报错 + } +} +``` + +上面示例中,变量`a`是第一个名称空间`N`的非对外成员,它只在第一个名称空间可用。 + +命名空间还可以跟同名函数合并,但是要求同名函数必须在命名空间之前声明。这样做是为了确保先创建出一个函数对象,然后同名的命名空间就相当于给这个函数对象添加额外的属性。 + +```typescript +function f() { + return f.version; +} + +namespace f { + export const version = '1.0'; +} + +f() // '1.0' +f.version // '1.0' +``` + +上面示例中,函数`f()`与命名空间`f`合并,相当于命名空间为函数对象`f`添加属性。 + +命名空间也能与同名 class 合并,同样要求class 必须在命名空间之前声明,原因同上。 + +```typescript +class C { + foo = 1; +} + +namespace C { + export const bar = 2; +} + +C.bar // 2 +``` + +上面示例中,名称空间`C`为类`C`添加了一个静态属性`bar`。 + +命名空间还能于同名 Enum 合并。 + +```typescript +enum E { + A, + B, + C, +} + +namespace E { + export function foo() { + console.log(E.C); + } +} + +E.foo() // 2 +``` + +上面示例中,命名空间`E`为枚举`E`添加了一个`foo()`方法。 + +注意,Enum 成员与命名空间导出成员不允许同名。 + +```typescript +enum E { + A, // 报错 + B, +} + +namespace E { + export function A() {} // 报错 +} +``` + +上面示例中,同名 Enum 与命名空间有同名成员,结果报错。 diff --git a/docs/narrowing.md b/docs/narrowing.md new file mode 100644 index 0000000..1c4e479 --- /dev/null +++ b/docs/narrowing.md @@ -0,0 +1,303 @@ +# TypeScript 类型缩小 + +TypeScript 变量的值可以变,但是类型通常是不变的。唯一允许的改变,就是类型缩小,就是将变量值的范围缩得更小。 + +## 手动类型缩小 + +如果一个变量属于联合类型,所以使用时一般需要缩小类型。 + +第一种方法是使用`if`判断。 + +```typescript +function getScore(value: number|string): number { + if (typeof value === 'number') { // (A) + // %inferred-type: number + value; + return value; + } + if (typeof value === 'string') { // (B) + // %inferred-type: string + value; + return value.length; + } + throw new Error('Unsupported value: ' + value); +} +``` + + +如果一个值是`any`或`unknown`,你又想对它进行处理,就必须先缩小类型。 + +```typescript +function parseStringLiteral(stringLiteral: string): string { + const result: unknown = JSON.parse(stringLiteral); + if (typeof result === 'string') { // (A) + return result; + } + throw new Error('Not a string literal: ' + stringLiteral); +} +``` + +下面是另一个例子。 + +```typescript +interface Book { + title: null | string; + isbn: string; +} + +function getTitle(book: Book) { + if (book.title === null) { + // %inferred-type: null + book.title; + return '(Untitled)'; + } else { + // %inferred-type: string + book.title; + return book.title; + } +} +``` + +缩小类型的前提是,需要先获取类型。获取类型的几种方法如下。 + +```typescript +function func(value: Function|Date|number[]) { + if (typeof value === 'function') { + // %inferred-type: Function + value; + } + + if (value instanceof Date) { + // %inferred-type: Date + value; + } + + if (Array.isArray(value)) { + // %inferred-type: number[] + value; + } +} +``` + +### typeof 运算符 + +第二种方法是使用`switch`缩小类型。 + +```typescript +function getScore(value: number|string): number { + switch (typeof value) { + case 'number': + // %inferred-type: number + value; + return value; + case 'string': + // %inferred-type: string + value; + return value.length; + default: + throw new Error('Unsupported value: ' + value); + } +} +``` + +### instanceof 运算符 + +第三种方法是instanceof运算符。它能够检测实例对象与构造函数之间的关系。instanceof运算符的左操作数为实例对象,右操作数为构造函数,若构造函数的prototype属性值存在于实例对象的原型链上,则返回true;否则,返回false。 + +```typescript +function f(x: Date | RegExp) { + if (x instanceof Date) { + x; // Date + } + + if (x instanceof RegExp) { + x; // RegExp + } +} +``` + +instanceof类型守卫同样适用于自定义构造函数,并对其实例对象进行类型细化。 + +```typescript +class A {} +class B {} + +function f(x: A | B) { + if (x instanceof A) { + x; // A + } + + if (x instanceof B) { + x; // B + } +} +``` + +### in 运算符 + +第四种方法是使用in运算符。 + +in运算符是JavaScript中的关系运算符之一,用来判断对象自身或其原型链中是否存在给定的属性,若存在则返回true,否则返回false。in运算符有两个操作数,左操作数为待测试的属性名,右操作数为测试对象。 + +in类型守卫根据in运算符的测试结果,将右操作数的类型细化为具体的对象类型。 + +```typescript +interface A { + x: number; +} +interface B { + y: string; +} + +function f(x: A | B) { + if ('x' in x) { + x; // A + } else { + x; // B + } +} +``` + +```typescript +interface A { a: number } +interface B { b: number } +function pickAB(ab: A | B) { + if ('a' in ab) { + ab // Type is A + } else { + ab // Type is B + } + ab // Type is A | B +} +``` + +缩小对象的属性,要用`in`运算符。 + +```typescript +type FirstOrSecond = + | {first: string} + | {second: string}; + +function func(firstOrSecond: FirstOrSecond) { + if ('second' in firstOrSecond) { + // %inferred-type: { second: string; } + firstOrSecond; + } +} + +// 错误 +function func(firstOrSecond: FirstOrSecond) { + // @ts-expect-error: Property 'second' does not exist on + // type 'FirstOrSecond'. [...] + if (firstOrSecond.second !== undefined) { + // ··· + } +} +``` + +`in`运算符只能用于联合类型,不能用于检查一个属性是否存在。 + +```typescript +function func(obj: object) { + if ('name' in obj) { + // %inferred-type: object + obj; + + // 报错 + obj.name; + } +} +``` + +### 特征属性 + +对于不同对象之间的区分,还可以人为地为每一类对象设置一个特征属性。 + +```typescript +interface UploadEvent { + type: 'upload'; + filename: string; + contents: string +} +interface DownloadEvent { type: 'download'; filename: string; } +type AppEvent = UploadEvent | DownloadEvent; + +function handleEvent(e: AppEvent) { + switch (e.type) { + case 'download': + e // Type is DownloadEvent + break; + case 'upload': + e; // Type is UploadEvent + break; + } +} +``` + +## any 类型的细化 + +TypeScript 推断变量类型时,会根据获知的信息,不断改变推断出来的类型,越来越细化。这种现象在`any`身上特别明显。 + +```typescript +function range( + start:number, + limit:number +) { + const out = []; // 类型为 any[] + for (let i = start; i < limit; i++) { + out.push(i); + } + return out; // 类型为 number[] +} +``` + +上面示例中,变量`out`的类型一开始推断为`any[]`,后来在里面放入数值,类型就变为`number[]`。 + +再看下面的例子。 + +```typescript +const result = []; // 类型为 any[] +result.push('a'); +result // 类型为 string[] +result.push(1); +result // 类型为 (string | number)[] +``` + +上面示例中,数组`result`随着成员类型的不同,而不断改变自己的类型。 + +注意,这种`any`类型的细化,只在打开了编译选项`noImplicitAny`时发生。 + +这时,如果在变量的推断类型还为`any`时(即没有任何写操作),就去输出(或读取)该变量,则会报错,因为这时推断还没有完成,无法满足`noImplicitAny`的要求。 + +```typescript +const result = []; // 类型为 any[] +console.log(typeof result); // 报错 +result.push('a'); // 类型为 string[] +``` + +上面示例中,只有运行完第三行,`result`的类型才能完成第一次推断,所以第二行读取`result`就会报错。 + +## is 运算符 + +`is`运算符返回一个布尔值,用来判断左侧的值是否属于右侧的类型。 + +```typescript +function isInputElement(el: HTMLElement): el is HTMLInputElement { + return 'value' in el; +} + +function getElementContent(el: HTMLElement) { + if (isInputElement(el)) { + el; // Type is HTMLInputElement + return el.value; + } + el; // Type is HTMLElement + return el.textContent; +} +``` + +```typescript +function isDefined(x: T | undefined): x is T { + return x !== undefined; +} +``` \ No newline at end of file diff --git a/docs/npm.md b/docs/npm.md new file mode 100644 index 0000000..b2e443d --- /dev/null +++ b/docs/npm.md @@ -0,0 +1,87 @@ +# TypeScript 项目使用 npm 模块 + +## 简介 + +npm 模块都是 JavaScript 代码。即使模块是用 TypeScript 写的,还是必须编译成 JavaScript 再发布,保证模块可以在没有 TypeScript 的环境运行。 + +问题就来了,TypeScript 项目开发时,加载外部 npm 模块,如果拿不到该模块的类型信息,就会导致无法开发。所以,必须有一个方法,可以拿到模块的类型信息。 + +有些 npm 模块本身可能包含`.d.ts`文件甚至完整的 TypeScript 代码。它的`package.json`文件里面有一个`types`字段,指向一个`.d.ts`文件,这就是它的类型声明文件。 + +```javascript +{ + "name": "left-pad", + "version": "1.3.0", + "description": "String left pad", + "main": "index.js", + "types": "index.d.ts", + // ... +} +``` + +如果某个模块没有`.d.ts`文件,TypeScript 官方和社区就自发为常用模块添加类型描述,可以去[官方网站](https://www.typescriptlang.org/dt/search)搜索,然后安装网站给出的 npm 类型模块,通常是`@types/[模块名]`。 + +```bash +$ npm install --save lodash +$ npm install --save @types/lodash +``` + +lodash 的类型描述就是`@types/lodash`的文件`index.d.ts`。 + +## TS 模块转 npm 模块 + +TS 代码放在`ts`子目录,编译出来的 CommonJS 代码放在`dist`子目录。 + +## 如何写 TypeScript 模块 + +首先,创建模块目录,然后在该目录里面新建一个`tsconfig.json`。 + +```json +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2015", + "declaration": true, + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ] +} +``` + +- `"declaration": true`:生成 .d.ts 文件,方便其他使用 TypeScript 的开发者加载你的库。 +- `"module": "commonjs"`:编译后的模块格式为`commonjs`,表示该模块供 Node.js 使用。如果供浏览器使用,则要写成`"module": "esnext"`。 +- `"target": "es2015"`:生成的 JavaScript 代码版本为 ES2015,需要 Node.js 8 以上版本。 +- `"outDir": "./dist"`:编译后的文件放在`./dist`目录。 +- `include`:指定需要编译的文件。 + +然后,使用 TypeScript 编写仓库代码。可以在`src`子目录里面,编写一个入口文件`index.ts`。 + +最后,编写`package.json`。 + +```typescript +{ + "name": "hwrld", + "version": "1.0.0", + "description": "Can log \"hello world\" and \"goodbye world\" to the console!", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "/dist" + ] +} +``` + +里面的`"types": "dist/index.d.ts"`字段指定类型声明文件,否则使用这个库的 TypeScript 开发者找不到类型声明文件。`files`属性指定打包进入 npm 模块的文件。 + +然后,就是编译和发布。 + +```bash +$ tsc +$ npm publish +``` + +## 参考链接 + +- [How to Write a TypeScript Library](https://www.tsmean.com/articles/how-to-write-a-typescript-library/), by tsmean \ No newline at end of file diff --git a/docs/object.md b/docs/object.md new file mode 100644 index 0000000..4d4ce7c --- /dev/null +++ b/docs/object.md @@ -0,0 +1,771 @@ +# TypeScript 的对象类型 + +## 简介 + +除了原始类型,对象是 JavaScript 最基本的数据结构。TypeScript 对于对象类型有很多规则。 + +对象类型的最简单声明方法,就是使用大括号表示对象,在大括号内部声明每个属性和方法的类型。 + +```typescript +const obj:{ + x:number; + y:number; +} = { x: 1, y: 1 }; +``` + +上面示例中,对象`obj`的类型就写在变量名后面,使用大括号描述,内部声明每个属性的属性名和类型。 + +属性的类型可以用分号结尾,也可以用逗号结尾。 + +```typescript +// 属性类型以分号结尾 +type MyObj = { + x:number; + y:number; +}; + +// 属性类型以逗号结尾 +type MyObj = { + x:number, + y:number, +}; +``` + +最后一个属性后面,可以写分号或逗号,也可以不写。 + +一旦声明了类型,对象赋值时,就不能缺少指定的属性,也不能有多余的属性。 + +```typescript +type MyObj = { + x:number; + y:number; +}; + +const o1:MyObj = { x: 1 }; // 报错 +const o2:MyObj = { x: 1, y: 1, z: 1 }; // 报错 +``` + +上面示例中,变量`o1`缺少了属性`y`,变量`o2`多出了属性`z`,都会报错。 + +读写不存在的属性也会报错。 + +```typescript +const obj:{ + x:number; + y:number; +} = { x: 1, y: 1 }; + +console.log(obj.z); // 报错 +obj.z = 1; // 报错 +``` + +上面示例中,读写不存在的属性`z`都会报错。 + +同样地,也不能删除类型声明中存在的属性,修改属性值是可以的。 + +```typescript +const myUser = { + name: "Sabrina", +}; + +delete myUser.name // 报错 +myUser.name = "Cynthia"; // 正确 +``` + +上面声明中,删除类型声明中存在的属性`name`会报错,但是可以修改它的值。 + +对象的方法使用函数类型描述。 + +```typescript +const obj:{ + x: number; + y: number; + add(x:number, y:number): number; + // 或者写成 + // add: (x:number, y:number) => number; +} = { + x: 1, + y: 1, + add(x, y) { + return x + y; + } +}; +``` + +上面示例中,对象`obj`有一个方法`add()`,需要定义它的参数类型和返回值类型。 + +对象类型可以使用方括号读取属性的类型。 + +```typescript +type User = { + name: string, + age: number +}; +type Name = User['name']; // string +``` + +上面示例中,对象类型`User`使用方括号,读取了属性`name`的类型(`string`)。 + +除了`type`命令可以为对象类型声明一个别名,TypeScript 还提供了`interface`命令,可以把对象类型提炼为一个接口。 + +```typescript +// 写法一 +type MyObj = { + x:number; + y:number; +}; + +const obj:MyObj = { x: 1, y: 1 }; + +// 写法二 +interface MyObj { + x: number; + y: number; +} + +const obj:MyObj = { x: 1, y: 1 }; +``` + +上面示例中,写法一是`type`命令的用法,写法二是`interface`命令的用法。`interface`命令的详细解释,以及与`type`命令的区别,详见《Interface》一章。 + +注意,TypeScript 不区分对象自身的属性和继承的属性,一律视为对象的属性。 + +```typescript +interface MyInterface { + toString(): string; // 继承的属性 + prop: number; // 自身的属性 +} + +const obj:MyInterface = { // 正确 + prop: 123, +}; +``` + +上面示例中,`obj`只写了`prop`属性,但是不报错。因为它可以继承原型上面的`toString()`方法。 + +## 可选属性 + +如果某个属性是可选的(即可以忽略),需要在属性名后面加一个问号。 + +```typescript +const obj: { + x: number; + y?: number; +} = { x: 1 }; +``` + +上面示例中,属性`y`是可选的。 + +可选属性等同于允许赋值为`undefined`,下面两种写法是等效的。 + +```typescript +type User = { + firstName: string; + lastName?: string; +}; + +// 等同于 +type User = { + firstName: string; + lastName: string|undefined; +}; +``` + +上面示例中,类型`User`的属性`lastName`可以是字符串,也可以是`undefined`,就表示该属性可以省略不写。 + +同理,读取一个可选属性时,有可能返回`undefined`。 + +```typescript +type MyObj = { + x: string, + y?: string +}; + +const obj:MyObj = { x: 'hello' }; +obj.y.toLowerCase() // 报错 +``` + +上面示例中,最后一行会报错,因为`obj.y`有可能是`undefined`,无法对其调用`toLowerCase()`。 + +所以,读取可选属性之前,必须检查一下是否为`undefined`。 + +```typescript +const user:{ + firstName: string; + lastName?: string; +} = { firstName: 'Foo'}; + +if (user.lastName !== undefined) { + console.log(`hello ${user.firstName} ${user.lastName}`) +} +``` + +上面示例中,`lastName`是可选属性,需要判断是否为`undefined`以后,才能使用。建议可以使用下面的写法。 + +```typescript +// 写法一 +let firstName = (user.firstName === undefined) + ? 'Foo' : user.firstName; +let lastName = (user.lastName === undefined) + ? 'Bar' : user.lastName; + +// 写法二 +let firstName = user.firstName ?? 'Foo'; +let lastName = user.lastName ?? 'Bar'; +``` + +上面示例中,写法一使用三元运算符`?:`,判断是否为`undefined`,并设置默认值。写法二使用 Null 判断运算符`??`,与写法一的作用完全相同。 + +## 只读属性 + +属性名前面加上`readonly`关键字,表示这个属性是只读属性,不能修改。 + +```typescript +interface MyInterface { + readonly prop: number; +} +``` + +上面示例中,`prop`属性是只读属性,不能修改它的值。 + +```typescript +const person:{ + readonly age: number +} = { age: 20 }; + +person.age = 21; // 报错 +``` + +上面示例中,最后一行修改了只读属性`age`,就报错了。 + +只读属性只能在对象初始化期间赋值,此后就不能修改该属性。 + +```typescript +type Point = { + readonly x: number; + readonly y: number; +}; + +const p:Point = { x: 0, y: 0 }; + +p.x = 100; // 报错 +``` + +上面示例中,类型`Point`的属性`x`和`y`都带有修饰符`readonly`,表示这两个属性只能在初始化期间赋值,后面再修改就会报错。 + +注意,如果属性值是一个对象,`readonly`修饰符并不禁止修改该对象的属性,只是禁止完全替换掉该对象。 + +```typescript +interface Home { + readonly resident: { + name: string; + age: number + }; +} + +const h:Home = { + resident: { + name: 'Vicky', + age: 42 + } +}; + +h.resident.age = 32; // 正确 +h.resident = { + name: 'Kate', + age: 23 +} // 报错 +``` + +上面示例中,`h.resident`是只读属性,它的值是一个对象。修改这个对象的`age`属性是可以的,但是整个替换掉`h.resident`属性会报错。 + +另一个需要注意的地方是,如果一个对象有两个引用,即两个变量对应同一个对象,其中一个变量是可写的,另一个变量是只读的,那么从可写变量修改属性,会影响到只读变量。 + +```typescript +interface Person { + name: string; + age: number; +} + +interface ReadonlyPerson { + readonly name: string; + readonly age: number; +} + +let w:Person = { + name: 'Vicky', + age: 42, +}; + +let r:ReadonlyPerson = w; + +w.age += 1; +r.age // 43 +``` + +上面示例中,变量`w`和`r`指向同一个对象,其中`w`是可写的,`r`的只读的。那么,对`w`的属性修改,会影响到`r`。 + +如果希望属性值是只读的,除了声明时加上`readonly`关键字,还有一种方法,就是在赋值时,在对象后面加上只读断言`as const`。 + +```typescript +const myUser = { + name: "Sabrina", +} as const; + +myUser.name = "Cynthia"; // 报错 +``` + +上面示例中,对象后面加了只读断言`as const`,就变成只读对象了,不能修改属性了。 + +注意,上面的`as const`属于 TypeScript 的类型推断,如果变量明确地声明了类型,那么 TypeScript 会以声明的类型为准。 + +```typescript +const myUser:{ name: string } = { + name: "Sabrina", +} as const; + +myUser.name = "Cynthia"; // 正确 +``` + +上面示例中,根据变量`myUser`的类型声明,`name`不是只读属性,但是赋值时又使用只读断言`as const`。这时会以声明的类型为准,因为`name`属性可以修改。 + +## 属性名的索引类型 + +如果对象的属性非常多,一个个声明类型就很麻烦,而且有些时候,无法事前知道对象会有多少属性,比如外部 API 返回的对象。这时 TypeScript 允许采用属性名表达式的写法来描述类型,称为“属性名的索引类型”。 + +索引类型里面,最常见的就是属性名的字符串索引。 + +```typescript +type MyObj = { + [property: string]: string +}; + +const obj:MyObj = { + foo: 'a', + bar: 'b', + baz: 'c', +}; +``` + +上面示例中,类型`MyObj`的属性名类型就采用了表达式形式,写在方括号里面。`[property: string]`的`property`表示属性名,这个是可以随便起的,它的类型是`string`,即属性名类型为`string`。也就是说,不管这个对象有多少属性,只要属性名为字符串,且属性值也是字符串,就符合这个类型声明。 + +属性名(即上例的`property`)的类型有三种可能,除了上例的`string`,还有`number`和`symbol`。 + +```typescript +type T1 = { + [property: number]: string +}; + +type T2 = { + [property: symbol]: string +}; +``` + +上面示例中,对象属性名的类型分别为`number`和`symbol`。 + +```typescript +type MyArr = { + [n:number]: number; +}; + +const arr:MyArr = [1, 2, 3]; +// 或者 +const arr:MyArr = { + 0: 1, + 1: 2, + 2: 3, +}; +``` + +上面示例中,对象类型`MyArr`的属性名是`[n:number]`,就表示它的属性名都是数值,比如`0`、`1`、`2`。 + +对象可以同时有多种类型的属性名索引,比如同时有数值索引和字符串索性。但是,数值索性不能与字符串索引发生冲突,必须服从后者,这是因为在 JavaScript 语言内部,所有的数值属性名都会自动转为字符串属性名。 + +```typescript +type MyType = { + [x: number]: boolean; // 报错 + [x: string]: string; +} +``` + +上面示例中,类型`MyType`同时有两种属性名索引,但是数值索引与字符串索引冲突了,所以报错了。由于字符属性名的值类型是`string`,数值属性名的值类型只有同样为`string`,才不会报错。 + +同样地,可以既声明属性名索引,也声明具体的单个属性名。如果单个属性名符合属性名索引的范围,两者不能有冲突,否则报错。 + +```typescript +type MyType = { + foo: boolean; // 报错 + [x: string]: string; +} +``` + +上面示例中,属性名`foo`符合属性名的字符串索引,但是两者的属性值类型不一样,所以报错了。 + +属性的索引类型写法,建议谨慎使用,因为属性名的声明太宽泛,约束太少。另外,属性名的数值索引不宜用来声明数组,因为采用这种方式声明数组,就不能使用各种数组方法以及`length`属性,因为类型里面没有定义这些东西。 + +```typescript +type MyArr = { + [n:number]: number; +}; + +const arr:MyArr = [1, 2, 3]; +arr.length // 报错 +``` + +上面示例中,读取`arr.length`属性会报错,因为类型`MyArr`没有这个属性。 + +## 解构赋值 + +解构赋值用于直接从对象中提取属性。 + +```typescript +const {id, name, price} = product; +``` + +上面语句从对象`product`提取了三个属性,并声明属性名的同名变量。 + +解构赋值的类型写法,跟为对象声明类型是一样的。 + +```typescript +const {id, name, price}:{ + id: string; + name: string; + price: number +} = product; +``` + +注意,目前没法为解构变量指定类型,因为对象解构里面的冒号,JavaScript 指定了其他用途。 + +```typescript +let { x: foo, y: bar } = obj; + +// 等同于 +let foo = obj.x; +let bar = obj.y; +``` + +上面示例中,冒号不是表示属性`x`和`y`的类型,而是为这两个属性指定新的变量名。如果要为`x`和`y`指定类型,不得不写成下面这样。 + +```typescript +let { x: foo, y: bar } + : { x: string; y: number } = obj; +``` + +这一点要特别小心,TypeScript 里面很容易搞糊涂。 + +```typescript +function draw({ + shape: Shape, + xPos: number = 100, + yPos: number = 100 +}) { + let myShape = shape; // 报错 + let x = xPos; // 报错 +} +``` + +上面示例中,函数`draw()`的参数是一个对象解构,里面的冒号很像是为变量指定类型,其实是为对应的属性指定新的变量名。所以,TypeScript 就会解读成,函数体内不存在变量`shape`,而是属性`shape`的值被赋值给了变量`Shape`。 + +## 结构类型原则 + +只要对象 B 满足 对象 A 的结构特征,TypeScript 就认为对象 B 兼容对象 A 的类型,这称为“结构类型”原则(structual typing)。 + +```typescript +const A = { + x: number; +}; + +const B = { + x: number; + y: number; +}; +``` + +上面示例中,对象`A`只有一个属性`x`,类型为`number`。对象`B`满足这个特征,因此兼容对象`A`,只要可以使用`A`的地方,就可以使用`B`。 + +```typescript +const B = { + x: 1, + y: 1 +}; + +const A:{ x: number } = B; // 正确 +``` + +上面示例中,`A`和`B`并不是同一个类型,但是`B`可以赋值给`A`,因为`B`满足`A`的结构特征。 + +根据“结构类型”原则,TypeScript 检查某个值是否符合指定类型时,并不是检查这个值的类型名(即“名义类型”),而是检查这个值的结构是否符合要求(即“结构类型”)。 + +TypeScript 之所以这样设计,是为了符合 JavaScript 的行为。JavaScript 并不关心对象是否严格相似,只要某个对象具有所要求的属性,就可以正确运行。 + +如果类型 B 可以赋值给类型 A,TypeScript 就认为 B 是 A 的子类型(subtyping),A 是 B 的父类型。子类型满足父类型的所有结构特征,同时还具有自己的特征。凡是可以使用父类型的地方,都可以使用子类型,即子类型兼容父类型。 + +这种设计有时会导致令人惊讶的结果。 + +```typescript +type myObj = { + x: number, + y: number, +}; + +function getSum(obj:myObj) { + let sum = 0; + + for (const n of Object.keys(obj)) { + const v = obj[n]; // 报错 + sum += Math.abs(v); + } + + return sum; +} +``` + +上面示例中,函数`getSum()`要求传入参数的类型是`myObj`,但是实际上所有与`myObj`兼容的对象都可以传入。这会导致`const v = obj[n]`这一行会报错,原因是`obj[n]`取出的属性值不一定是数值(`number`),使得变量`v`的类型是`any`。如果不允许变量类型推断为`any`,代码就会报错。如果写成下面这样,就不会报错。 + +```typescript +type MyObj = { + x: number, + y: number, +}; + +function getSum(obj:MyObj) { + return Math.abs(obj.x) + Math.abs(obj.y); +} +``` + +上面示例就不会报错,因为函数体内部只使用了属性`x`和`y`,这两个属性有明确的类型声明,保证`obj.x`和`obj.y`肯定是数值。虽然与`MyObj`兼容的任何对象都可以传入函数`getSum()`,但是只要不使用其他属性,就不会有类型报错。 + +## 严格字面量检查 + +如果对象使用字面量表示,会触发 TypeScript 的严格字面量检查(strict object literal checking)。如果字面量的结构跟类型定义的不一样(比如多出了未定义的属性),就会报错。 + +```typescript +const point:{ + x:number; + y:number; +} = { + x: 1, + y: 1, + z: 1 // 报错 +}; +``` + +上面示例中,等号右边是一个对象的字面量,这时会触发严格字面量检查。只要有类型声明中不存在的属性(本例是`z`),就会导致报错。 + +如果等号右边不是字面量,而是一个变量,根据结构类型原则,是不会报错的。 + +```typescript +const myPoint = { + x: 1, + y: 1, + z: 1 +}; + +const point:{ + x:number; + y:number; +} = myPoint; // 正确 +``` + +上面示例中,等号右边是一个变量,就不会触发严格字面量检查,从而不报错。 + +TypeScript 对字面量进行严格检查的目的,主要是防止拼写错误。一般来说,字面量大多数来自手写,容易出现拼写错误,或者误用了 API。 + +```typescript +type Options = { + title:string; + darkMode?:boolean; +}; + +const Obj:Options = { + title: '我的网页', + darkmode: true, // 报错 +}; +``` + +上面示例中,属性`darkMode`拼写错了,成了`darkmode`。如果没有严格字面量规则,就不会报错,因为`darkMode`是可选属性,根据结构类型原则,任何对象只要有`title`属性,都认为符合`Options`类型。 + +规避严格字面量检查,可以使用中间变量。 + +```typescript +let myOptions = { + title: '我的网页', + darkmode: true, +}; + +const Obj:Options = myOptions; +``` + +上面示例中,创建了一个中间变量`myOptions`,就不会触发严格字面量规则,因为这时变量`obj`的赋值,不属于直接字面量赋值。 + +如果你确认字面量没有错误,也可以使用类型断言规避严格字面量检查。 + +```typescript +const Obj:Options = { + title: '我的网页', + darkmode: true, +} as Options; +``` + +上面示例使用类型断言`as Options`,告诉编译器,字面量符合 Options 类型,就能规避这条规则。 + +如果允许字面量有多余属性,可以像下面这样在类型里面定义一个通用属性。 + +```typescript +let x: { + foo: number, + [x: string]: any +}; + +x = { foo: 1, baz: 2 }; // Ok +``` + +上面示例中,变量`x`的类型声明里面,有一个属性的字符串索引(`[x: string]`),导致任何字符串属性名都是合法的。 + +由于严格字面量检查,字面量对象传入函数必须很小心,不能有多余的属性。 + +```typescript +interface Point { + x: number; + y: number; +} + +function computeDistance(point: Point) { /*...*/ } + +computeDistance({ x: 1, y: 2, z: 3 }); // 报错 +computeDistance({x: 1, y: 2}); // 正确 +``` + +上面示例中,对象字面量传入函数`computeDistance()`时,不能有多余的属性,否则就通不过严格字面量检查。 + +编译器选项`suppressExcessPropertyErrors`,可以在`tsconfig.json`文件里面关闭多余属性检查。 + +```typescript +{ + "compilerOptions": { + "suppressExcessPropertyErrors": true + } +} +``` + +## 最小可选属性规则 + +如果一个对象的所有属性都是可选的,会触发最小可选属性规则。 + +```typescript +type Options = { + a?:number; + b?:number; + c?:number; +}; + +const obj:Options = { + d: 123 // 报错 +}; +``` + +上面示例中,类型`Options`是一个对象,它的所有属性都是可选的,这导致任何对象实际都符合`Options`类型。 + +为了避免这种情况,TypeScript 添加了最小可选属性规则,规定这时属于`Options`类型的对象,必须至少存在一个可选属性,不能所有可选属性都不存在。这就是为什么上例的`myObj`对象会报错的原因。 + +这条规则无法通过中间变量规避。 + +```typescript +const myOptions = { d: 123 }; + +const obj:Options = myOptions; // 报错 +``` + +上面示例中,即使使用了中间变量`myOptions`,由于存在最小可选属性规则,依然会报错。 + +## 空对象 + +空对象是 TypeScript 的一种特殊值,也是一种特殊类型。 + +```typescript +const obj = {}; +obj.prop = 123; // 报错 +``` + +上面示例中,变量`obj`的值是一个空对象,然后对`obj.prop`赋值就会报错。 + +原因是这时 TypeScript 会推断变量`obj`的类型为空对象,实际执行的是下面的代码。 + +```typescript +const obj:{} = {}; +``` + +空对象没有自定义属性,所以对自定义属性赋值就会报错。空对象只能使用继承的属性,即继承自原型对象`Object.prototype`的属性。 + +```typescript +obj.toString() // 正确 +``` + +上面示例中,`toString()`方法是一个继承自原型对象的方法,TypeScript 允许在空对象上使用。 + +回到本节开始的例子,这种写法其实在 JavaScript 很常见:先声明一个空对象,然后向空对象添加属性。但是,TypeScript 不允许动态添加属性,所以对象不能分步生成,必须生成时一次性声明所有属性。 + +```typescript +// 错误 +const pt = {}; +pt.x = 3; +pt.y = 4; + +// 正确 +const pt = { + x: 3, + y: 4 +}; +``` + +如果确实需要分步声明,一个比较好的方法是,使用扩展运算符(`...`)合成一个新对象。 + +```typescript +const pt0 = {}; +const pt1 = { x: 3 }; +const pt2 = { y: 4 }; + +const pt = { + ...pt0, ...pt1, ...pt2 +}; +``` + +上面示例中,对象`pt`是三个部分合成的,这样既可以分步声明,也符合 TypeScript 静态声明的要求。 + +空对象作为类型,其实是`Object`类型的简写形式。 + +```typescript +let d:{}; +// 等同于 +// let d:Object; + +d = {}; +d = { x: 1 }; +d = 'hello'; +d = 2; +``` + +上面示例中,各类类型的值(除了`null`和`undefined`)都可以赋值给空对象类型,跟`Object`类型的行为是一样的。 + +由于空对象是`Object`类型的简写,所以它不会有严格字面量检查,赋值时总是允许多余的属性,只是不能读取这些属性。 + +```typescript +interface Empty { } +const b:Empty = {myProp: 1, anotherProp: 2}; // 正确 +b.myProp // 报错 +``` + +上面示例中,变量`b`的类型是空对象,视同`Object`类型,不会有严格字面量检查,但是读取多余的属性会报错。 + +如果想强制使用没有任何属性的对象,可以采用下面的写法。 + +```typescript +interface WithoutProperties { + [key: string]: never; +} + +// 报错 +const a:WithoutProperties = { prop: 1 }; +``` + +上面的示例中,`[key: string]: never`表示字符串属性名是不存在的,因此其他对象进行赋值时就会报错。 diff --git a/docs/operator.md b/docs/operator.md new file mode 100644 index 0000000..053101b --- /dev/null +++ b/docs/operator.md @@ -0,0 +1,627 @@ +# TypeScript 类型运算符 + +TypeScript 提供强大的类型运算能力,可以使用各种类型运算符,对已有的类型进行计算,得到新类型。 + +## keyof 运算符 + +### 简介 + +keyof 是一个单目运算符,接受一个对象类型作为参数,返回该对象的所有键名组成的联合类型。 + +```typescript +type MyObj = { + foo: number, + bar: string, +}; + +type Keys = keyof MyObj; // 'foo'|'bar' +``` + +上面示例中,`keyof MyObj`返回`MyObj`的所有键名组成的联合类型,即`'foo'|'bar'`。 + +下面是另一个例子。 + +```typescript +interface T { + 0: boolean; + a: string; + b(): void; +} + +type KeyT = keyof T; // 0 | 'a' | 'b' +``` + +由于 JavaScript 对象的键名只有三种类型,所以对于任意键名的联合类型就是`string|number|symbol`。 + +```typescript +// string | number | symbol +type KeyT = keyof any; +``` + +对于上面三种类型以外的类型使用 keyof 运算符,返回`never`类型,表示不可能有这样类型的键名。 + +```typescript +type KeyT = keyof object; // never +``` + +上面示例中,由于不可能有`object`类型的键名,所以`keyof object`返回`never`类型。 + +由于 keyof 返回的类型是`string|number|symbol`,如果有些场合只需要其中的一种类型,那么可以采用交叉类型的写法。 + +```typescript +type Capital = Capitalize; + +type MyKeys = Capital; // 报错 +``` + +上面示例中,类型`Capital`只接受字符串作为类型参数,传入`keyof Obj`会报错,原因是这时的类型参数是`string|number|symbol`,跟字符串不兼容。 + +采用下面的交叉类型写法,就不会报错。 + +```typescript +type MyKeys = Capital; +``` + +上面示例中,`string & keyof Obj`等同于`string & string|number|symbol`进行交集运算,最后返回`string`,因此`Capital`就不会报错了。 + +如果对象属性名采用索引形式,keyof 会返回属性名的索引类型。 + +```typescript +// 示例一 +interface T { + [prop: number]: number; +} + +// number +type KeyT = keyof T; + +// 示例二 +interface T { + [prop: string]: number; +} + +// string|number +type KeyT = keyof T; +``` + +上面的示例二,`keyof T`返回的类型是`string|number`,原因是 JavaScript 属性名为字符串时,包含了属性名为数值的情况,因为数值属性名会自动转为字符串。 + +如果 keyof 运算符用于数组或元组类型,得到的结果可能出人意料。 + +```typescript +// 返回 number | "0" | "1" | "2" +// | "length" | "pop" | "push" | ··· +type Result = keyof ['a', 'b', 'c']; +``` + +上面示例中,keyof 会返回数组的所有属性名,包括字符串属性名和继承的属性名。 + +对于联合类型,keyof 返回成员共有的键名。 + +```typescript +type A = { a: string; z: boolean }; +type B = { b: string; z: boolean }; + +// 'z' +type KeyT = keyof (A | B); +``` + +对于交叉类型,keyof 返回所有键名。 + +```typescript +type A = { a: string; x: boolean }; +type B = { b: string; y: number }; + +// 返回 'a' | 'x' | 'b' | 'y' +type KeyT = keyof (A & B); + +// 相当于 +keyof (A & B) ≡ keyof A | keyof B +``` + +keyof 取出的是键名组成的联合类型,如果想取出键值组成的联合类型,可以像下面这样写。 + +```typescript +type MyObj = { + foo: number, + bar: string, +}; + +type Keys = keyof MyObj; + +type Values = MyObj[Keys]; // number|string +``` + +上面示例中,`Keys`是键名组成的联合类型,而`MyObj[Keys]`会取出每个键名对应的键值类型,组成一个新的联合类型,即`number|string`。 + +### keyof 运算符的用途 + +keyof 运算符往往用于精确表达对象的属性类型。 + +举例来说,取出对象的某个指定属性的值,JavaScript 版本可以写成下面这样。 + +```typescript +function prop(obj, key) { + return obj[key]; +} +``` + +上面这个函数添加类型,只能写成下面这样。 + +```javascript +function prop( + obj:object, key:string +):any { + return obj[key]; +} +``` + +上面的类型声明有两个问题,一是无法表示参数`key`与参数`obj`之间的关系,二是返回值类型只能写成`any`。 + +有了 keyof 以后,就可以解决这两个问题,精确表达返回值类型。 + +```javascript +function prop( + obj:Obj, key:K +):Obj[K] { + return obj[key]; +} +``` + +上面示例中,`K extends keyof Obj`表示`K`是`Obj`的一个属性名,传入其他字符串会报错。返回值类型`Obj[K]`就表示`K`这个属性值的类型。 + +keyof 的另一个用途是用于属性映射,即将一个类型的所有属性逐一映射成其他值。 + +```typescript +type NewProps = { + [Prop in keyof Obj]: boolean; +}; + +// 用法 +type MyObj = { foo: number; }; + +// 等于 { foo: boolean; } +type NewObj = NewProps; +``` + +上面示例中,类型`NewProps`是类型`Obj`的映射类型,前者继承了后者的所有属性,但是把所有属性值类型都改成了`boolean`。 + +下面的例子是去掉 readonly 修饰符。 + +```typescript +type Mutable = { + -readonly [Prop in keyof Obj]: Obj[Prop]; +}; + +// 用法 +type MyObj = { + readonly foo: number; +} + +// 等于 { foo: number; } +type NewObj = Mutable; +``` + +上面示例中,`[Prop in keyof Obj]`是`Obj`类型的所有属性名,`-readonly`表示去除这些属性的只读特性。对应地,还有`+readonly`的写法,表示添加只读属性设置。 + +下面的例子是让可选属性变成必有的属性。 + +```typescript +type Concrete = { + [Prop in keyof Obj]-?: Obj[Prop]; +}; + +// 用法 +type MyObj = { + foo?: number; +} + +// 等于 { foo: number; } +type NewObj = Concrete; +``` + +上面示例中,`[Prop in keyof Obj]`后面的`-?`表示去除可选属性设置。对应地,还有`+?`的写法,表示添加可选属性设置。 + +## in 运算符 + +JavaScript 语言中,`in`运算符用来确定对象是否包含某个属性名。 + +```javascript +const obj = { a: 123 }; + +if ('a' in obj) + console.log('found a'); +``` + +上面示例中,`in`运算符用来判断对象`obj`是否包含属性`a`。 + +`in`运算符的左侧是一个字符串,表示属性名,右侧是一个对象。它的返回值是一个布尔值。 + +TypeScript 语言的类型运算中,`in`运算符有不同的用法,用来取出(遍历)联合类型的每一个成员类型。 + +```typescript +type U = 'a'|'b'|'c'; + +type Foo = { + [Prop in U]: number; +}; +// 等同于 +type Foo = { + a: number, + b: number, + c: number +}; +``` + +上面示例中,`[Prop in U]`表示依次取出联合类型`U`的每一个成员。 + +上一小节的例子也提到,`[Prop in keyof Obj]`表示取出对象`Obj`的每一个键名。 + +## 方括号运算符 + +方括号运算符(`[]`)用于取出对象的键值类型,比如`T[K]`会返回对象`T`的属性`K`的类型。 + +```typescript +type Person = { + age: number; + name: string; + alive: boolean; +}; + +// Age 的类型是 number +type Age = Person['age']; +``` + +上面示例中,`Person['age']`返回属性`age`的类型,本例是`number`。 + +方括号的参数如果是联合类型,那么返回的也是联合类型。 + +```typescript +type Person = { + age: number; + name: string; + alive: boolean; +}; + +// number|string +type T = Person['age'|'name']; + +// number|string|boolean +type A = Person[keyof Obj]; +``` + +上面示例中,方括号里面是属性名的联合类型,所以返回的也是对应的属性值的联合类型。 + +如果访问不存在的属性,会报错。 + +```typescript +type T = Person['notExisted']; // 报错 +``` + +如果对象的属性是索引类型,那么方括号运算符的参数可以是属性名的类型。 + +```typescript +type Obj = { + [key:string]: number, +}; + +// number +type T = Obj[string]; +``` + +上面示例中,`Obj`的属性名是字符串的索引类型,所以可以写成`Obj[string]`,代表所有字符串属性名,返回的就是它们的类型`number`。 + +这个语法对于数组也适用,可以使用`number`作为方括号的参数。 + +```typescript +// MyArray 的类型是 { [key:number]:string } +const MyArray = ['a','b','c']; + +// 等同于 (typeof MyArray)[number] +// 返回 string +type Person = typeof MyArray[number]; +``` + +上面示例中,`MyArray`是一个数组,它的类型实际上是属性名的数值索引,而`typeof MyArray[number]`的`typeof`运算优先级高于方括号,所以返回的是所有数值键名的键值类型`string`。 + +注意,方括号里面不能有值的运算。 + +```typescript +// 示例一 +const key = 'age'; +type Age = Person[key]; // 报错 + +// 示例二 +type Age = Person['a' + 'g' + 'e']; // 报错 +``` + +上面两个示例,方括号里面都涉及值的运算,编译时不会进行这种运算,所以会报错。 + +## extends...?: 条件运算符 + +TypeScript 提供类似 JavaScript 的`?:`运算符这样的三元运算符,但多出了一个`extends`关键字。 + +条件运算符`extends...?:`可以根据当前类型是否符合某种条件,返回不同的类型。 + +```typescript +T extends U ? X : Y +``` + +上面式子中的`extends`用来判断,类型`T`是否可以赋值给类型`U`,即`T`是否为`U`的子类型,这里的`T`和`U`可以是任意类型。 + +如果`T`能够赋值给类型`U`,表达式的结果为类型`X`,否则结果为类型`Y`。 + +```typescript +// true +type T = 1 extends number ? true : false; +``` + +上面示例中,`1`是`number`的子类型,所以返回`true`。 + +下面是另外一个例子。 + +```typescript +interface Animal { + live(): void; +} +interface Dog extends Animal { + woof(): void; +} + +// number +type T1 = Dog extends Animal ? number : string; + +// string +type T2 = RegExp extends Animal ? number : string; +``` + +上面示例中,`Dog`是`Animal`的子类型,所以`T1`的类型是`number`。`RegExp`不是`Animal`的子类型,所以`T2`的类型是`string`。 + +一般来说,调换`extends`两侧类型,会返回相反的结果。举例来说,有两个类`Dog`和`Animal`,前者是后者的子类型,那么`Cat extends Animal`就为真,而`Animal extends Cat`就为伪。 + +如果需要判断的类型是一个联合类型,那么条件运算符会展开这个联合类型。 + +```typescript +(A|B) extends U ? X : Y + +// 等同于 + +(A extends U ? X : Y) | +(B extends U ? X : Y) +``` + +上面示例中,`A|B`是一个联合类型,进行条件运算时,相当于`A`和`B`分别进行运算符,返回结果组成一个联合类型。 + +如果不希望联合类型被条件运算符展开,可以把`extends`两侧的操作数都放在方括号里面。 + +```typescript +// 示例一 +type ToArray = + Type extends any ? Type[] : never; + +// string[]|number[] +type T = ToArray; + +// 示例二 +type ToArray = + [Type] extends [any] ? Type[] : never; + +// (string | number)[] +type T = ToArray; +``` + +上面的示例一,传入的类型参数是联合类型,所以会被展开,返回的也是联合类型。示例二是`extends`两侧的运算数都放在方括号里面,所以传入的联合类型不会展示,返回的是一个数组。 + +条件运算符还可以嵌套使用。 + +```typescript +type LiteralTypeName = + T extends undefined ? "undefined" : + T extends null ? "null" : + T extends boolean ? "boolean" : + T extends number ? "number" : + T extends bigint ? "bigint" : + T extends string ? "string" : + never; +``` + +上面示例是一个多重判断,返回一个字符串的值类型,对应当前类型。下面是它的用法。 + +```typescript +// "bigint" +type Result1 = LiteralTypeName<123n>; + +// "string" | "number" | "boolean" +type Result2 = LiteralTypeName; +``` + +## infer 关键字 + +`infer`关键字用来定义泛型里面推断出来的类型参数,而不是外部传入的类型参数。 + +它通常跟条件运算符一起使用,用在`extends`关键字后面的父类型之中。 + +```typescript +type Flatten = + Type extends Array ? Item : Type; +``` + +上面示例中,`Type`是外部传入的类型参数,如果传入的是一个数组(`Array`),那么可以从该数组推断出它的成员类型,写成`infer Item`,表示`Item`这个类型参数是从当前信息中推断出来的。 + +一旦定义了`Item`,后面的代码就可以使用这个类型参数了。 + +下面是这个泛型`Flatten`的用法。 + +```typescript +// string +type Str = Flatten; + +// number +type Num = Flatten; +``` + +上面示例中,第一个例子`Flatten`传入的类型参数是`string[]`,可以推断出`Item`的类型是`string`,所以返回的是`string`。第二个例子`Flatten`传入的类型参数是`number`,它不是数组,所以直接返回本身。 + +如果不用`infer`定义类型参数,那么就要传入两个类型参数。 + +```typescript +type Flatten = + Type extends Array ? Item : Type; +``` + +上面是不用`infer`的写法,每次使用`Fleatten`的时候,都要传入两个参数,就非常麻烦。 + +下面的例子使用`infer`,推断函数的参数类型和返回值类型。 + +```typescript +type ReturnPromise = + T extends (...args: infer A) => infer R + ? (...args: A) => Promise + : T; +``` + +上面示例中,如果`T`是函数,就返回这个函数的 Promise 版本,否则原样返回。`infer A`表示该函数的参数类型为`A`,`infer R`表示该函数的返回值类型为`R`。 + +如果不使用`infer`,就不得不把`ReturnPromise`写成`ReturnPromise`,这样就很麻烦。 + +下面是`infer`提取对象指定属性的例子。 + +```typescript +type MyType = + T extends { + a: infer M, + b: infer N + } ? [M, N] : never; + +// [string, number] +type T = MyType<{ a: string; b: number }>; +``` + +上面示例中,`infer`可以提取参数对象的属性`a`和属性`b`的值。 + +下面是`infer`通过正则匹配提取类型参数的例子。 + +```typescript +type Str = 'foo-bar'; + +type Bar = Str extends `foo-${infer rest}` ? rest : never // 'bar' +``` + +上面示例中,`rest`是从模板字符串提取的类型参数。 + +## is 运算符 + +函数返回布尔值的时候,可以使用`is`运算符,限定返回值与参数之间的关系。 + +`is`运算符用来描述返回值属于`true`还是`false`。 + +```typescript +function isFish( + pet:Fish|Bird +):pet is Fish { + return (pet as Fish).swim !== undefined; +} +``` + +上面示例中,函数`isFish()`的返回值类型为`pet is Fish`,表示如果参数`pet`类型为`Fish`,则返回`true`,否则返回`false`。 + +`is`运算符总是用于描述函数的返回值类型,写法采用`parameterName is Type`的形式,即左侧为当前函数的参数名,右侧为某一种类型。它返回一个布尔值,表示左侧参数是否属于右侧的类型。 + +```typescript +type A = { a: string }; +type B = { b: string }; + +function isTypeA(x: A|B): x is A { + if ('a' in x) return true; + return false; +} +``` + +上面示例中,返回值类型`x is A`可以准确描述函数体内部的运算逻辑。 + +`is`运算符可以用于类型保护。 + +```typescript +function isCat(a:any): a is Cat { + return a.name === 'kitty'; +} + +let x:Cat|Dog; + +if (isCat(x)) { + x.meow(); // 正确,因为 x 肯定是 Cat 类型 +} +``` + +上面示例中,需要保证`x`有`meow()`方法,`isCat()`的返回值是`a is Cat`与`if`结合,就能起到类型保护的作用,确保`x`是 Cat 类型。 + +`is`运算符还有一种特殊用法,就是用在类(class)的内部,描述类的方法的返回值。 + +```typescript +class Teacher { + isStudent():this is Student { + return false; + } +} + +class Student { + isStudent():this is Student { + return true; + } +} +``` + +上面示例中,`isStudent()`方法的返回值类型,取决于该方法内部的`this`是否为`Student`对象。 + +注意,`this is T`这种写法,只能用来描述方法的返回值类型,而不能用来描述属性的类型。 + +## 模板字符串 + +TypeScript 允许使用模板字符串,构建类型。 + +模板字符串的最大特点,就是内部可以引用其他类型。 + +```typescript +type World = "world"; + +// "hello world" +type Greeting = `hello ${World}`; +``` + +上面示例中,类型`Greeting`是一个模板字符串,里面引用了另一个字符串类型`world`,因此`Greeting`实际上是字符串`hello world`。 + +注意,模板字符串可以引用的类型一共6种,分别是 string、number、bigint、boolean、null、undefined。引用其他类型会报错。 + +```typescript +type N = 123; +type O = { n : 123 }; + +type T1 = `${N} received`; // 正确 +type T2 = `${O} received`; // 报错 +``` + +上面示例中,模板字符串引用数值类型(`N`)是可以的,但是引用对象类型(`O`)就会报错。 + +模板字符串里面引用的类型,如果是一个联合类型,那么它返回的也是一个联合类型,即模板字符串可以展开联合类型。 + +```typescript +type T = 'A'|'B'; + +// "A_id"|"B_id" +type U = `${T}_id`; +``` + +上面示例中,类型`U`是一个模板字符串,里面引用了一个联合类型`T`,导致最后得到的也是一个联合类型。 + +如果模板字符串引用两个联合类型,它会交叉展开这两个类型。 + +```typescript +type T = 'A'|'B'; + +type U = '1'|'2'; + +// 'A1'|'A2'|'B1'|'B2' +type V = `${T}${U}`; +``` + +上面示例中,`T`和`U`都是联合类型,各自有两个成员,模板字符串里面引用了这两个类型,最后得到的就是一个4个成员的联合类型。 diff --git a/docs/react.md b/docs/react.md new file mode 100644 index 0000000..0540034 --- /dev/null +++ b/docs/react.md @@ -0,0 +1,82 @@ +# TypeScript 的 React 支持 + +## JSX 语法 + +JSX 是 React 库引入的一种语法,可以在 JavaScript 脚本中直接书写 HTML 风格的标签。 + +TypeScript 支持 JSX 语法,但是必须将脚本后缀名改成`.tsx`。 + +`.tsx`文件中,类型断言一律使用`as`形式,因为尖括号的写法会与 JSX 冲突。 + +```typescript +// 使用 +var x = foo as any; + +// 不使用 +var x = foo; +``` + +上面示例中,变量`foo`被断言为类型`any`,在`.tsx`文件中只能使用第一种写法,不使用第二种写法。 + +## React 库 + +TypeScript 使用 React 库必须引入 React 的类型定义。 + +```typescript +/// +interface Props { + name: string; +} +class MyComponent extends React.Component { + render() { + return {this.props.name}; + } +} +; // OK +; // error, `name` is not a number +``` + +## 内置元素 + +内置元素使用`JSX.IntrinsicElements`接口。默认情况下,内置元素不进行类型检查。但是,如果给出了接口定义,就会进行类型检查。 + +```typescript +declare namespace JSX { + interface IntrinsicElements { + foo: any; + } +} +; // ok +; // error +``` + +上面示例中,``不符合接口定义,所以报错。 + +一种解决办法就是,在接口中定义一个通用元素。 + +```typescript +declare namespace JSX { + interface IntrinsicElements { + [elemName: string]: any; + } +} +``` + +上面示例中, 元素名可以是任意字符串。 + +## 组件的写法 + +```typescript +interface FooProp { + name: string; + X: number; + Y: number; +} +declare function AnotherComponent(prop: { name: string }); +function ComponentFoo(prop: FooProp) { + return ; +} +const Button = (prop: { value: string }, context: { color: string }) => ( +