2023/10/04
posted in
TypeScript
#typescript
2023/10/04
posted in
TypeScript
#typescript
TypeScript 是 JavaScript 的一个超集,主要提供了类型系统和对 ES6 的支持
TypeScript 的命令行工具安装方法如下:
npm install -g typescript
以上命令会在全局环境下安装 tsc
命令,安装完成之后,我们就可以在任何地方执行 tsc
命令了。
编译一个 TypeScript 文件很简单:
tsc hello.ts
我们约定使用 TypeScript 编写的文件以 .ts
为后缀,用 TypeScript 编写 React 时,以 .tsx
为后缀。
TypeScript | JavaScript |
---|---|
JavaScript 的超集用于解决大型项目的代码复杂性 | 一种脚本语言,用于创建动态网页 |
可以在编译期间发现并纠正错误 | 作为一种解释型语言,只能在运行时发现错误 |
支持静态和动态类型 | 没有静态类型选项 |
最终被编译成 JavaScript 代码,使浏览器可以理解 | 可以直接在浏览器中使用 |
支持模块、泛型和接口 | 不支持模块,泛型或接口 |
社区的支持仍在增长,而且还不是很大 | 大量的社区支持以及大量文档和解决问题的支持 |
原始数据类型包括:布尔值、数值、字符串、null
、undefined
以及 ES6 中的新类型 Symbol
。
布尔值是最基础的数据类型,在 TypeScript 中,使用 boolean
定义布尔值类型:
let isDone: boolean = false
注意,使用构造函数 Boolean
创造的对象不是布尔值,返回的是一个Boolean对象
let createdByNewBoolean: boolean = new Boolean(1);
// Type 'Boolean' is not assignable to type 'boolean'.
// 'boolean' is a primitive, but 'Boolean' is a wrapper object. Prefer using 'boolean' when possible.
使用number定义数值类型:
let count: number = 6
/ ES6 中的二进制表示法
let binaryLiteral: number = 0b1010;
// ES6 中的八进制表示法
let octalLiteral: number = 0o744;
使用string定义字符串类型:
let myName: string = 'Tom';
let myAge: number = 25;
// 模板字符串
let sentence: string = `Hello, my name is ${myName}.
I'll be ${myAge + 1} years old next month.`;
JavaScript 没有空值(Void)的概念,在 TypeScript 中,可以用 void
表示没有任何返回值的函数:
function alertName: void {
alert('My name is Tom')
}
声明一个 void
类型的变量没有什么用,因为你只能将它赋值为 undefined
和 null
:
let unusable: void = undefined;
在 TypeScript 中,可以使用 null
和 undefined
来定义这两个原始数据类型:
let u: undefined = undefined;
let n: null = null;
与 void
的区别是,undefined
和 null
是所有类型的子类型。也就是说 undefined
类型的变量,可以赋值给 number
类型的变量:
// 这样不会报错
let num: number = undefined;
never
类型表示的是那些永不存在的值的类型。 例如,never
类型是那些总是会抛出异常或根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型。
// 返回never的函数必须存在无法达到的终点
function error(message: string): never {
throw new Error(message);
}
function infiniteLoop(): never {
while (true) {}
}
任意值(Any)用来表示允许赋值任意类型
如果是一个普通类型,在赋值过程中改变类型是不被允许的:
let myFavoriteNumber: string = 'seven';
myFavoriteNumber = 7;
// error Type 'number' is not assignable to type 'string'.
但如果是any类型,则允许被赋值位任意类型
let myFavoriteNumber: any = 'seven';
myFavoriteNumber = 7
// it’s ok
在任意值上访问的任何属性都是被允许的:
let anyThing: any = 'hello';
console.log(anyThing.myName);
console.log(anyThing.myName.firstName);
也允许调用任何方法:
let anyThing: any = 'Tom';
anyThing.setName('Jerry');
anyThing.setName('Jerry').sayHello();
anyThing.myName.setFirstName('Cat');
可以认为,声明以一个变量为任意值之后,对它的任何操作,返回的内容的类型都是任意值
变量如果在声明的时候,未指定其类型,那么它会被识别为任意值类型:
let something;
something = 'seven';
something = 7;
something.setName('Tom');
等价于:
let something: any;
something = 'seven';
something = 7;
something.setName('Tom');
TypeScript 3.0 引入了新的unknown
类型,它是 any
类型对应的安全类型。
当我们在写应用的时候可能会需要描述一个我们还不知道其类型的变量。这些值可以来自动态内容,例如从用户获得,或者我们想在我们的 API 中接收所有可能类型的值。在这些情况下,我们想要让编译器以及未来的用户知道这个变量可以是任意类型。这个时候我们会对它使用 unknown
类型。
就像所有类型都可以赋值给 any
,所有类型也都可以赋值给 unknown
。这使得 unknown
成为 TypeScript 类型系统的另一种顶级类型(另一种是 any
)。
let value: unknown;
value = true; // OK
value = 42; // OK
value = "Hello World"; // OK
value = []; // OK
value = {}; // OK
value = Math.random; // OK
value = null; // OK
value = undefined; // OK
value = new TypeError(); // OK
value = Symbol("type"); // OK
对 value
变量的所有赋值都被认为是类型正确的。但是,当我们尝试将类型为 unknown
的值赋值给其他类型的变量时会发生什么?
let value: unknown;
let value1: unknown = value; // OK
let value2: any = value; // OK
let value3: boolean = value; // Error
let value4: number = value; // Error
let value5: string = value; // Error
let value6: object = value; // Error
let value7: any[] = value; // Error
let value8: Function = value; // Error
unknown
类型只能被赋值给 any
类型和 unknown
类型本身。直观地说,这是有道理的:只有能够保存任意类型值的容器才能保存 unknown
类型的值。毕竟我们不知道变量 value
中存储了什么类型的值。
如果没有明确的指定类型,那么 TypeScript 会依照类型推论(Type Inference)的规则推断出一个类型。
以下代码虽然没有指定类型,但是会在编译的时候报错:
let myFavoriteNumber = 'seven';
myFavoriteNumber = 7;
// error TS2322: Type 'number' is not assignable to type 'string'.
等价于:
let myFavoriteNumber: string = 'seven';
myFavoriteNumber = 7;
// error TS2322: Type 'number' is not assignable to type 'string'.
TypeScript 会在没有明确的指定类型的时候推测出一个类型,这就是类型推论。
let a = 1
let b = '2'
function add(x: number, y: string) {
return a + b
}
type Adder = (a: number, b: number) => number;
let foo: Adder = (a, b) => a + b;
const foo = {
a: 123,
b: 234
}
foo.a = '123' // Error:不能把 'string' 类型赋值给 'number' 类型
const foo = {
a: 123,
b: 456
};
let { a } = foo;
a = 'hello'; // Error:不能把 'string' 类型赋值给 'number' 类型
如果定义的时候没有赋值,不管之后有没有赋值,都会被推断成 any
类型而完全不被类型检查:
let myFavoriteNumber;
myFavoriteNumber = 'seven';
myFavoriteNumber = 7;
联合类型(Union Types)表示取值可以为多种类型中的一种。
let count : string | number
count = 7
count = '7'
// it's ok
let count : string | number
count = 7
count = true
// error Type 'boolean' is not assignable to type 'string | number'.
联合类型使用 |
分隔每个类型。
当 TypeScript 不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型里共有的属性或方法:
function getLength(something: string | number): number {
return something.length;
}
// index.ts(2,22): error TS2339: Property 'length' does not exist on type 'string | number'.
// Property 'length' does not exist on type 'number'.
上例中,length
不是 string
和 number
的共有属性,所以会报错。
访问 string
和 number
的共有属性是没问题的:
function getString(something: string | number): string {
return something.toString();
}
联合类型的变量在被赋值的时候,会根据类型推论的规则推断出一个类型:
let myFavoriteNumber: string | number;
myFavoriteNumber = 'seven';
console.log(myFavoriteNumber.length); // 5
myFavoriteNumber = 7;
console.log(myFavoriteNumber.length); // 编译时报错
// index.ts(5,30): error TS2339: Property 'length' does not exist on type 'number'.
例中,第二行的 myFavoriteNumber
被推断成了 string
,访问它的 length
属性不会报错。
而第四行的 myFavoriteNumber
被推断成了 number
,访问它的 length
属性时就报错了。
在 TypeScript 中,我们使用接口(Interfaces)来定义对象的类型。
在面向对象语言中,接口(Interfaces)是一个很重要的概念,它是对行为的抽象,而具体如何行动需要由类(classes)去实现(implement)。
TypeScript 中的接口是一个非常灵活的概念,除了可用于对类的一部分行为进行抽象以外,也常用于对「对象的形状(Shape)」进行描述。
interface Person {
name: string;
age: number;
}
let tom: Person = {
name: 'Tom',
age: 25
};
上面的例子中,我们定义了一个接口 Person
,接着定义了一个变量 tom
,它的类型是 Person
。这样,我们就约束了 tom
的形状必须和接口 Person
一致。
定义的变量比接口少了一些属性是不允许的:
interface Person {
name: string;
age: number;
}
let tom: Person = {
name: 'Tom'
};
// error TS2322: Type '{ name: string; }' is not assignable to type 'Person'.
// Property 'age' is missing in type '{ name: string; }'.
多一些属性也是不允许的:
interface Person {
name: string;
age: number;
}
let tom: Person = {
name: 'Tom',
age: 25,
gender: 'male'
};
// error TS2322: Type '{ name: string; age: number; gender: string; }' is not assignable to type 'Person'.
// Object literal may only specify known properties, and 'gender' does not exist in type 'Person'.
可见,赋值的时候,变量的形状必须和接口的形状保持一致。
有时我们希望不要完全匹配一个形状,那么可以用可选属性:
interface Person {
name: string;
age?: number;
}
let tom: Person = {
name: 'Tom'
};
interface Person {
name: string;
age?: number;
}
let tom: Person = {
name: 'Tom',
age: 25
};
可选属性的含义是该属性可以不存在。
这时仍然不允许添加未定义的属性:
interface Person {
name: string;
age?: number;
}
let tom: Person = {
name: 'Tom',
age: 25,
gender: 'male'
};
有时候我们希望一个接口有任意属性,可以使用如下方式:
interface Person {
name: string;
age?: number;
[propName: string]: any;
}
let tom: Person = {
name: 'Tom',
age: 20,
gender: 'male'
}
需要注意的是,一旦定义了任意属性,那么确定属性和可选属性的类型都必须是它的类型的子集:
interface Person {
name: string;
age?: number;
[propName: string]: string
}
let tom: Person = {
name: 'tom',
age: 24,
gender: 'male'
}
// error Property 'age' of type 'number' is not assignable to string index type 'string'.
上例中,任意属性的值允许是 string
,但是可选属性 age
的值却是 number
,number
不是 string
的子属性,所以报错了。
一个接口中只能定义一个任意属性。如果接口中有多个类型的属性,则可以在任意属性中使用联合类型:
interface Person {
name: string;
age?: number;
[propName: string]: number | string
}
let tom: Person = {
name: 'tom',
age: 24,
gender: 'male'
}
有时候我们希望对象中的一些字段只能在创建的时候被赋值,那么可以用 readonly
定义只读属性:
interface Person {
readonly id: number;
name: string;
age?: number;
[propName: string]: number | string
}
let tom: Person = {
id: 1,
name: 'tom'
}
tom.id = 2 // error Cannot assign to 'id' because it is a constant or a read-only property.
注意,只读的约束存在于第一次给对象赋值的时候,而不是第一次给只读属性赋值的时候:
interface Person = {
readonly id: number;
name: string;
age?: number;
[propName: string]: string | number
}
let tom: Person = {
name: 'tom',
age: 23
}
tom.id = 1
// error Type '{ name: string; gender: string; }' is not assignable to type 'Person'.
Property 'id' is missing in type '{ name: string; gender: string; }'.
Cannot assign to 'id' because it is a constant or a read-only property.
上例中,报错信息有两处,第一处是在对 tom
进行赋值的时候,没有给 id
赋值。
第二处是在给 tom.id
赋值的时候,由于它是只读属性,所以报错了。
在TypeScript中,数组的类型有多种定义方式,比较灵活
最简单的方法是使用【类型+方括号】来表示数组:
let countArray : number[] = [1,2,3,4,5]
数组的项中不允许出现其他的类型:
let countArray: number[] = [1,2,3,'4']
// error Type 'string' is not assignable to type 'number'.
数组的一些方法的参数也会根据数组在定义时约定的类型进行限制:
let countArray: number[] = [1,2,3,4]
countArray.push('2')
// error Argument of type '"2"' is not assignable to parameter of type 'number'.
我们也可以使用数组泛型(Array Generic) Array<elemType>
来表示数组:
let countArray: Array<number> = [1,2,3,4,5]
注:关于泛型,后续会介绍
接口也可以用来描述数组:
interface NumberArray {
[index: number]: number
}
let countArray: NumberArray = [1,2,3,4,5]
NumberArray
表示:只要索引的类型是数字时,那么值的类型必须是数字。
虽然接口也可以用来描述数组,但是我们一般不会这么做,因为这种方式比前两种方式复杂多了。
不过有一种情况例外,那就是它常用来表示类数组。
类数组(Array-like Object)不是数组类型
function sum() {
let args: number[] = arguments;
}
// Type 'IArguments' is missing the following properties from type 'number[]': pop, push, concat, join, and 24 more.
上例中,arguments
实际上是一个类数组,不能用普通的数组的方式来描述,而应该用接口:
function sum() {
let args: {
[index: number]: number;
length: number;
callee: Function;
} = arguments;
}
在这个例子中,我们除了约束当索引的类型是数字时,值的类型必须是数字之外,也约束了它还有 length
和 callee
两个属性。
事实上常用的类数组都有自己的接口定义,如 IArguments
, NodeList
, HTMLCollection
等:
function sum() {
let args: IArguments = arguments;
}
其中 IArguments
是 TypeScript 中定义好了的类型,它实际上就是:
interface IArguments {
[index: number]: any;
length: number;
callee: Function;
}
关于内置对象,可以参考内置对象一章。
一个比较常见的作法是,用any
表示数组允许出现任何类型
let countArray:any[] = ['1',2,{name:'12', age: 24}]
在 JavaScript 中,有两种常见的定义函数的方式——函数声明(Function Declaration)和函数表达式(Function Expression):
// 函数声命式(Function Declaration)
function sum(x,y) {
return x + y
}
// 函数表达式
let sum = function(x,y) {
return x + y
}
一个函数有输入和输出,要在 TypeScript 中对其进行约束,需要把输入和输出都考虑到,其中函数声明的类型定义较简单:
function sum(x:number,y:number): number {
return x + y
}
注意,输入多余的(或者少于要求的)参数,是不被允许的
如果要我们现在写一个对函数表达式(Function Expression)的定义,可能会写成这样:
let mySum = function (x: number, y: number): number {
return x + y;
};
这是可以通过编译的,不过事实上,上面的代码只对等号右侧的匿名函数进行了类型定义,而等号左边的 mySum
,是通过赋值操作进行类型推论而推断出来的。如果需要我们手动给 mySum
添加类型,则应该是这样:
let mySum: (x: number, y: number) => number = function (x: number, y: number): number {
return x + y;
};
注意不要混淆了 TypeScript 中的 =>
和 ES6 中的 =>
。
在 TypeScript 的类型定义中,=>
用来表示函数的定义,左边是输入类型,需要用括号括起来,右边是输出类型。
我们也可以使用接口的方式来定义一个函数需要符合的形状:
interface SearchFun {
(source: string, subString: string): boolean
}
let mySearch: SearchFun
mySearch = function(source: string, subString: string) {
return source.search(subString) !== -1
}
采用函数表达式|接口定义函数的方式时,对等号左侧进行类型限制,可以保证以后对函数名赋值时保证参数个数、参数类型、返回值类型不变。
前面提到,输入多余的(或者少于要求的)参数,是不允许的。那么如何定义可选的参数呢?
与接口中的可选属性类似,我们用 ?
表示可选的参数:
function buildName(firstName: string, lastName?: string) {
if (lastName) {
return firstName + ' ' + lastName;
} else {
return firstName;
}
}
let tomcat = buildName('Tom', 'Cat');
let tom = buildName('Tom');
需要注意的是,可选参数必须接在必需参数后面。换句话说,可选参数后面不允许再出现必需参数了:
function buildName(firstName?: string, lastName: string) {
if (firstName) {
return firstName + ' ' + lastName;
} else {
return lastName;
}
}
let tomcat = buildName('Tom', 'Cat');
let tom = buildName(undefined, 'Tom');
//error TS1016: A required parameter cannot follow an optional parameter.
在 ES6 中,我们允许给函数的参数添加默认值,TypeScript 会将添加了默认值的参数识别为可选参数:
function buildName(firstName: string, lastName: string = 'Cat') {
return firstName + ' ' + lastName;
}
let tomcat = buildName('Tom', 'Cat');
let tom = buildName('Tom');
此时就不受「可选参数必须接在必需参数后面」的限制了:
function buildName(firstName: string = 'Tom', lastName: string) {
return firstName + ' ' + lastName;
}
let tomcat = buildName('Tom', 'Cat');
let cat = buildName(undefined, 'Cat');
ES6 中,可以使用 ...rest
的方式获取函数中的剩余参数(rest 参数):
function push(array, ...items) {
items.forEach(function(item) {
array.push(item);
});
}
let a: any[] = [];
push(a, 1, 2, 3);
事实上,items
是一个数组。所以我们可以用数组的类型来定义它:
function push(array: any[], ...items: any[]) {
items.forEach(function(item) {
array.push(item);
});
}
let a = [];
push(a, 1, 2, 3);
注意,rest 参数只能是最后一个参数,关于 rest 参数,可以参考 ES6 中的 rest 参数。
载允许一个函数接受不同数量或类型的参数时,作出不同的处理。
比如,我们需要实现一个函数 reverse
,输入数字 123
的时候,输出反转的数字 321
,输入字符串 'hello'
的时候,输出反转的字符串 'olleh'
。
利用联合类型,我们可以这么实现:
function reverse(x: number | string): number | string {
if (typeof x === 'number') {
return Number(x.toString().split('').reverse().join(''));
} else if (typeof x === 'string') {
return x.split('').reverse().join('');
}
}
然而这样有一个缺点,就是不能够精确的表达,输入为数字的时候,输出也应该为数字,输入为字符串的时候,输出也应该为字符串。
这时,我们可以使用重载定义多个 reverse
的函数类型:
function reverse(x: number): number;
function reverse(x: string): string;
function reverse(x: number | string): number | string {
if (typeof x === 'number') {
return Number(x.toString().split('').reverse().join(''));
} else if (typeof x === 'string') {
return x.split('').reverse().join('');
}
}
上例中,我们重复定义了多次函数 reverse
,前几次都是函数定义,最后一次是函数实现。在编辑器的代码提示中,可以正确的看到前两个提示。
注意,TypeScript 会优先从最前面的函数定义开始匹配,所以多个函数定义如果有包含关系,需要优先把精确的定义写在前面。
类型断言(Type Assertion)可以用来手动指定一个值的类型。
值 as 类型
或
<类型>值
在 tsx 语法(React 的 jsx 语法的 ts 版)中必须使用前者,即 值 as 类型
。
形如 <Foo>
的语法在 tsx 中表示的是一个 ReactNode
,在 ts 中除了表示类型断言之外,也可能是表示一个泛型。
故建议大家在使用类型断言时,统一使用 值 as 类型
这样的语法。
当 TypeScript 不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型中共有的属性或方法:
interface Cat {
name: string;
run(): void;
}
interface Fish {
name: string;
swim(): void;
}
function getName(animal: Cat | Fish) {
return animal.name;
}
而有时候,我们确实需要在还不确定类型的时候就访问其中一个类型特有的属性或方法,比如:
interface Cat {
name: string;
run(): void;
}
interface Fish {
name: string;
swim(): void;
}
function isFish(animal: Cat | Fish) {
if (typeof animal.swim === 'function') {
return true;
}
return false;
}
// error TS2339: Property 'swim' does not exist on type 'Cat | Fish'.
// Property 'swim' does not exist on type 'Cat'.
上面的例子中,获取 animal.swim
的时候会报错。
此时可以使用类型断言,将 animal
断言成 Fish
:
interface Cat {
name: string;
run(): void;
}
interface Fish {
name: string;
swim(): void;
}
function isFish(animal: Cat | Fish) {
if (typeof (animal as Fish).swim === 'function') {
return true;
}
return false;
}
需要注意的是,类型断言只能够「欺骗」TypeScript 编译器,无法避免运行时的错误,反而滥用类型断言可能会导致运行时错误:
interface Cat {
name: string;
run(): void;
}
interface Fish {
name: string;
swim(): void;
}
function swim(animal: Cat | Fish) {
(animal as Fish).swim();
}
const tom: Cat = {
name: 'Tom',
run() { console.log('run') }
};
swim(tom);
// Uncaught TypeError: animal.swim is not a function`
上面的例子编译时不会报错,但在运行时会报错:
原因是 (animal as Fish).swim()
这段代码隐藏了 animal
可能为 Cat
的情况,将 animal
直接断言为 Fish
了,而 TypeScript 编译器信任了我们的断言,故在调用 swim()
时没有编译错误。
可是 swim
函数接受的参数是 Cat | Fish
,一旦传入的参数是 Cat
类型的变量,由于 Cat
上没有 swim
方法,就会导致运行时错误了。
总之,使用类型断言时一定要格外小心,尽量避免断言后调用方法或引用深层属性,以减少不必要的运行时错误。
当类之间有继承关系时,类型断言也是很常见的:
class ApiError extends Error {
code: number = 0;
}
class HttpError extends Error {
statusCode: number = 200;
}
function isApiError(error: Error) {
if (typeof (error as ApiError).code === 'number') {
return true;
}
return false;
}
上面的例子中,我们声明了函数 isApiError
,它用来判断传入的参数是不是 ApiError
类型,为了实现这样一个函数,它的参数的类型肯定得是比较抽象的父类 Error
,这样的话这个函数就能接受 Error
或它的子类作为参数了。
但是由于父类 Error
中没有 code
属性,故直接获取 error.code
会报错,需要使用类型断言获取 (error as ApiError).code
。
大家可能会注意到,在这个例子中有一个更合适的方式来判断是不是 ApiError
,那就是使用 instanceof
:
class ApiError extends Error {
code: number = 0;
}
class HttpError extends Error {
statusCode: number = 200;
}
function isApiError(error: Error) {
if (error instanceof ApiError) {
return true;
}
return false;
}
上面的例子中,确实使用 instanceof
更加合适,因为 ApiError
是一个 JavaScript 的类,能够通过 instanceof
来判断 error
是否是它的实例。
但是有的情况下 ApiError
和 HttpError
不是一个真正的类,而只是一个 TypeScript 的接口(interface
),接口是一个类型,不是一个真正的值,它在编译结果中会被删除,当然就无法使用 instanceof
来做运行时判断了:
interface ApiError extends Error {
code: number;
}
interface HttpError extends Error {
statusCode: number;
}
function isApiError(error: Error) {
if (error instanceof ApiError) {
return true;
}
return false;
}
//error TS2693: 'ApiError' only refers to a type, but is being used as a value here.
此时就只能用类型断言,通过判断是否存在 code
属性,来判断传入的参数是不是 ApiError
了:
interface ApiError extends Error {
code: number;
}
interface HttpError extends Error {
statusCode: number;
}
function isApiError(error: Error) {
if (typeof (error as ApiError).code === 'number') {
return true;
}
return false;
}
any
理想情况下,TypeScript 的类型系统运转良好,每个值的类型都具体而精确。
当我们引用一个在此类型上不存在的属性或方法时,就会报错:
const foo:number = 1
foo.length = 1
//error TS2339: Property 'length' does not exist on type 'number'.
上面的例子中,数字类型的变量 foo
上是没有 length
属性的,故 TypeScript 给出了相应的错误提示。
这种错误提示显然是非常有用的。
但有的时候,我们非常确定这段代码不会出错,比如下面这个例子:
window.foo = 1
// error TS2339: Property 'foo' does not exist on type 'Window & typeof globalThis'.
面的例子中,我们需要将 window
上添加一个属性 foo
,但 TypeScript 编译时会报错,提示我们 window
上不存在 foo
属性。
此时我们可以使用 as any
临时将 window
断言为 any
类型:
(window as any).foo = 1
在 any
类型的变量上,访问任何属性都是允许的。
需要注意的是,将一个变量断言为 any
可以说是解决 TypeScript 中类型问题的最后一个手段。
它极有可能掩盖了真正的类型错误,所以如果不是非常确定,就不要使用 as any
。
总之,一方面不能滥用 as any
,另一方面也不要完全否定它的作用,我们需要在类型的严格性和开发的便利性之间掌握平衡(这也是 TypeScript 的设计理念之一),才能发挥出 TypeScript 最大的价值
any
断言为一个具体的类型在日常的开发中,我们不可避免的需要处理 any
类型的变量,它们可能是由于第三方库未能定义好自己的类型,也有可能是历史遗留的或其他人编写的烂代码,还可能是受到 TypeScript 类型系统的限制而无法精确定义类型的场景。
遇到 any
类型的变量时,我们可以选择无视它,任由它滋生更多的 any
。
我们也可以选择改进它,通过类型断言及时的把 any
断言为精确的类型,亡羊补牢,使我们的代码向着高可维护性的目标发展。
举例来说,历史遗留的代码中有个 getCacheData
,它的返回值是 any
:
function getCacheData(key: string): any {
return (window as any).cache[key];
}
那么我们在使用它时,最好能够将调用了它之后的返回值断言成一个精确的类型,这样就方便了后续的操作:
function getCacheData(key: string): any {
return (window as any).cache[key];
}
interface Cat {
name: string;
run(): void;
}
const tom = getCacheData('tom') as Cat;
tom.run();
上面的例子中,我们调用完 getCacheData
之后,立即将它断言为 Cat
类型。这样的话明确了 tom
的类型,后续对 tom
的访问时就有了代码补全,提高了代码的可维护性。
在上下文中当类型检查器无法断定类型时,一个新的后缀表达式操作符 !
可以用于断言操作对象是非 null 和非 undefined 类型。具体而言,x! 将从 x 值域中排除 null 和 undefined 。
function myFunc(maybeString: string | undefined | null) {
const onlyString: string = maybeString; // Error
const ignoreUndefinedAndNull: string = maybeString!; // Ok
}
type NumGenerator = () => number;
function myFunc(numGenerator: NumGenerator | undefined) {
// Object is possibly 'undefined'.(2532)
// Cannot invoke an object which is possibly 'undefined'.(2722)
const num1 = numGenerator(); // Error
const num2 = numGenerator!(); //OK
}
在 TypeScript 2.7 版本中引入了确定赋值断言,即允许在实例属性和变量声明后面放置一个 !
号,从而告诉 TypeScript 该属性会被明确地赋值。为了更好地理解它的作用,我们来看个具体的例子:
let x: number;
initialize();
// Variable 'x' is used before being assigned.(2454)
console.log(2 * x); // Error
function initialize() {
x = 10;
}
很明显该异常信息是说变量 x 在赋值前被使用了,要解决该问题,我们可以使用确定赋值断言:
let x!: number;
initialize();
console.log(2 * x); // Ok
function initialize() {
x = 10;
}
从上面的例子中,我们可以总结出:
当使用第三方库时,我们需要引用它的声明文件,才能获得对应的代码补全、接口提示等功能。
由于本章涉及大量新语法,故在本章开头列出新语法的索引,方便大家在使用这些新语法时能快速查找到对应的讲解:
假如我们想使用第三方库 jQuery,一种常见的方式是在 html 中通过 <script>
标签引入 jQuery,然后就可以使用全局变量 $
或 jQuery
了。
我们通常这样获取一个id是foo的元素:
$('#foo')
// or
jQuery('#foo')
但是在ts种,编译器并不知道$
或者jQuery
是什么东西:
jQuery('#foo');
// ERROR: Cannot find name 'jQuery'.
这时我们需要使用declare var
来定义它的类型:
declare var jQuery:(selector:string) => any;
jQuery('#foo')
上例中,declare var
并没有真的定义一个变量,只是定义了全局变量 jQuery
的类型,仅仅会用于编译时的检查,在编译结果中会被删除。它编译结果是:
jQuery('#foo')
除了 declare var
之外,还有其他很多种声明语句,将会在后面详细介绍。
通常我们会把声明语句放到一个单独的文件(jQuery.d.ts
)中,这就是声明文件:
// src/jQuery.d.ts
declare var jQuery: (selector: string) => any;
// src/index.ts
jQuery('#foo');
声明文件必需以 .d.ts
为后缀。
一般来说,ts 会解析项目中所有的 *.ts
文件,当然也包含以 .d.ts
结尾的文件。所以当我们将 jQuery.d.ts
放到项目中时,其他所有 *.ts
文件就都可以获得 jQuery
的类型定义了。
假如仍然无法解析,那么可以检查下 tsconfig.json
中的 files
、include
和 exclude
配置,确保其包含了 jQuery.d.ts
文件。
当然,jQuery 的声明文件不需要我们定义了,社区已经帮我们定义好了:jQuery in DefinitelyTyped。
我们可以直接下载下来使用,但是更推荐的是使用 @types
统一管理第三方库的声明文件。
@types
的使用方式很简单,直接用 npm 安装对应的声明模块即可,以 jQuery 举例:
npm install @types/jquery --save-dev
可以在这个页面搜索你需要的声明文件。
在不同的场景下,声明文件的内容和使用方式会有所区别。
库的使用场景主要有以下几种:
全局变量: 通过 <script>
标签引入第三方库,注入全局变量
npm包:通过 import foo from 'foo'
导入,符合 ES6 模块规范
UMD:既可以通过 <script>
标签引入,又可以通过 import
导入
直接扩展全局变量:通过 <script>
标签引入后,改变一个全局变量的结构
在npm包或UMD库中扩展全局变量: 引用 npm 包或 UMD 库后,改变一个全局变量的结构
模块插件:通过 <script>
或 import
导入后,改变另一个模块的结构
全局变量是最简单的一种场景,之前举的例子就是通过 <script>
标签引入 jQuery,注入全局变量 $
和 jQuery
。
使用全局变量的声明文件时,如果是以 npm install @types/xxx --save-dev
安装的,则不需要任何配置。如果是将声明文件直接存放于当前项目中,则建议和其他源码一起放到 src
目录下(或者对应的源码目录下):
/path/to/project
├── src
| ├── index.ts
| └── jQuery.d.ts
└── tsconfig.json
如果没有生效,可以检查下 tsconfig.json
中的 files
、include
和 exclude
配置,确保其包含了 jQuery.d.ts
文件。
全局变量的声明文件主要有以下几种语法:
在所有的声明语句中,declare var
是最简单的,如之前所学,它能够用来定义一个全局变量的类型。与其类似的,还有 declare let
和 declare const
,使用 let
与使用 var
没有什么区别:
// src/jQuery.d.ts
declare let jQuery: (selector: string) => any;
/ src/index.ts
jQuery('#foo');
// 使用 declare let 定义的 jQuery 类型,允许修改这个全局变量
jQuery = function(selector) {
return document.querySelector(selector);
};
而当我们使用const定义时,标识此时的全局变量是一个常量,不允许再去修改它的值了
// src/jQuery.d.ts
declare const jQuery: (selector: string) => any;
jQuery('#foo');
// 使用 declare const 定义的 jQuery 类型,禁止修改这个全局变量
jQuery = function(selector) {
return document.querySelector(selector);
};
// ERROR: Cannot assign to 'jQuery' because it is a constant or a read-only property.
一般来说,全局变量都是禁止修改的常量,所以大部分情况都应该使用 const
而不是 var
或 let
。
declare function
用来定义全局函数的类型。jQuery 其实就是一个函数,所以也可以用 function
来定义:
定义:declare function jQuery(selector: string): any;
使用:jQuery('#foo');
在函数类型的声明语句中,函数重载也是支持的6:
declare function jQuery(selector: string): any;
declare function jQuery(domReadyCallback: () => any): any;
jQuery('#foo');
jQuery(function() {
alert('Dom Ready!');
});
需要注意的是,声明语句中只能定义类型,切勿在声明语句中定义具体的实现5:
declare const jQuery = function(selector) {
return document.querySelector(selector);
};
// ERROR: An implementation cannot be declared in ambient contexts.
当全局变量是一个类的时候,我们用 declare class
来定义它的类型
// src/Animal.d.ts
declare class Animal {
name: string;
constructor(name: string);
sayHi(): string;
}
// index.ts
let cat = new Animal('tom')
同样的,declare class
语句也只能用来定义类型,不能用来定义具体的实现,比如定义 sayHi
方法的具体实现则会报错:
declare class Animal {
name: string;
constructor(name: string);
sayHi() {
return `My name is ${this.name}`;
};
// ERROR: An implementation cannot be declared in ambient contexts.
}
使用 declare enum
定义的枚举类型也称作外部枚举(Ambient Enums)
declare enum Directions {
Up,
Down,
Left,
Right
}
let directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right];
与其他全局变量的类型声明一致,declare enum
仅用来定义类型,而不是具体的值。
Directions.d.ts
仅仅会用于编译时的检查,声明文件里的内容在编译结果中会被删除。它编译结果是:
var directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right];
其中 Directions
是由第三方库定义好的全局变量。
ES6中使用模块化,很少使用namespace
namespace
被淘汰了,但是在声明文件中,declare namespace
还是比较常用的,它用来表示全局变量是一个对象,包含很多子属性。
比如 jQuery
是一个全局变量,它是一个对象,提供了一个 jQuery.ajax
方法可以调用,那么我们就应该使用 declare namespace jQuery
来声明这个拥有多个子属性的全局变量。
declare namespace jQuery {
function ajax(url: string, settings?: any): void;
}
jQuery.ajax('/api/get_something');
注意,在 declare namespace
内部,我们直接使用 function ajax
来声明函数,而不是使用 declare function ajax
。类似的,也可以使用 const
, class
, enum
等语句9:
declare namespace jQuery {
function ajax(url: string, settings?: any): void;
const version: number;
class Event {
blur(eventType: EventType): void
}
enum EventType {
CustomClick
}
}
jQuery.ajax('/api/get_something');
console.log(jQuery.version);
const e = new jQuery.Event();
e.blur(jQuery.EventType.CustomClick);
除了全局变量之外,可能有一些类型我们也希望能暴露出来。在类型声明文件中,我们可以直接使用 interface
或 type
来声明一个全局的接口或类型:
interface AjaxSettings {
meyhod?:'GET'|'POST'
data?: any
}
declare namespace jQuery {
function ajax(url: string, settings?: AjaxSettings): void;
}
这样的话,在其他文件中也可以使用这个接口或类型了:
let settings: AjaxSettings = {
method: 'POST',
data: {
name: 'foo'
}
};
jQuery.ajax('/api/post_something', settings);
暴露在最外层的 interface
或 type
会作为全局类型作用于整个项目中,我们应该尽可能的减少全局变量或全局类型的数量。故最好将他们放到 namespace
下
declare namespace jQuery {
interface AjaxSettings {
method?: 'GET' | 'POST'
data?: any;
}
function ajax(url: string, settings?: AjaxSettings): void;
}
注意,在使用这个 interface
的时候,也应该加上 jQuery
前缀:
let settings: jQuery.AjaxSettings = {
method: 'POST',
data: {
name: 'foo'
}
};
jQuery.ajax('/api/post_something', settings);
假如 jQuery 既是一个函数,可以直接被调用 jQuery('#foo')
,又是一个对象,拥有子属性 jQuery.ajax()
(事实确实如此),那么我们可以组合多个声明语句,它们会不冲突的合并起来:
declare function jQuery(selector: string): any;
declare namespace jQuery {
function ajax(url: string, settings?: any): void;
}
jQuery('#foo');
jQuery.ajax('/api/get_something');
一般我们通过 import foo from 'foo'
导入一个 npm 包,这是符合 ES6 模块规范的。
在我们尝试给一个 npm 包创建声明文件之前,需要先看看它的声明文件是否已经存在。一般来说,npm 包的声明文件可能存在于两个地方:
package.json
中有 types
字段,或者有一个 index.d.ts
声明文件。这种模式不需要额外安装其他包,是最为推荐的,所以以后我们自己创建 npm 包的时候,最好也将声明文件与 npm 包绑定在一起。@types
里。我们只需要尝试安装一下对应的 @types
包就知道是否存在该声明文件,安装命令是 npm install @types/foo --save-dev
。这种模式一般是由于 npm 包的维护者没有提供声明文件,所以只能由其他人将声明文件发布到 @types
里了。假如以上两种方式都没有找到对应的声明文件,那么我们就需要自己为它写声明文件了。由于是通过 import
语句导入的模块,所以声明文件存放的位置也有所约束,一般有两种方案:
node_modules/@types/foo/index.d.ts
文件,存放 foo
模块的声明文件。这种方式不需要额外的配置,但是 node_modules
目录不稳定,代码也没有被保存到仓库中,无法回溯版本,有不小心被删除的风险,故不太建议用这种方案,一般只用作临时测试。types
目录,专门用来管理自己写的声明文件,将 foo
的声明文件放到 types/foo/index.d.ts
中。这种方式需要配置下 tsconfig.json
中的 paths
和 baseUrl
字段。目录结构:
/path/to/project
├── src
| └── index.ts
├── types
| └── foo
| └── index.d.ts
└── tsconfig.json
tsconfig.json
内容:
{
"compilerOptions": {
"module": "commonjs",
"baseUrl": "./",
"paths": {
"*": ["types/*"]
}
}
}
如此配置之后,通过 import
导入 foo
的时候,也会去 types
目录下寻找对应的模块的声明文件了。
注意 module
配置可以有很多种选项,不同的选项会影响模块的导入导出模式。这里我们使用了 commonjs
这个最常用的选项,后面的教程也都默认使用的这个选项。
不管采用了以上两种方式中的哪一种,我都强烈建议大家将书写好的声明文件(通过给第三方库发 pull request,或者直接提交到 @types
里)发布到开源社区中,享受了这么多社区的优秀的资源,就应该在力所能及的时候给出一些回馈。只有所有人都参与进来,才能让 ts 社区更加繁荣。
npm 包的声明文件主要有以下几种语法:
npm 包的声明文件与全局变量的声明文件有很大区别。在 npm 包的声明文件中,使用 declare
不再会声明一个全局变量,而只会在当前文件中声明一个局部变量。只有在声明文件中使用 export
导出,然后在使用方 import
导入后,才会应用到这些类型声明。
export
的语法与普通的 ts 中的语法类似,区别仅在于声明文件中禁止定义具体的实现:
export const name: string;
export function getName(): string;
export class Animal {
constructor(name: string);
sayHi(): string;
}
export enum Directions {
Up,
Down,
Left,
Right
}
export interface Options {
data: any;
}
对应的导入和使用模块应该是这样:
import { name, getName, Animal, Directions, Options } from 'foo';
console.log(name);
let myName = getName();
let cat = new Animal('Tom');
let directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right];
let options: Options = {
data: {
name: 'foo'
}
};
我们也可以使用 declare
先声明多个变量,最后再用 export
一次性导出。上例的声明文件可以等价的改写为:
eclare const name: string;
declare function getName(): string;
declare class Animal {
constructor(name: string);
sayHi(): string;
}
declare enum Directions {
Up,
Down,
Left,
Right
}
interface Options {
data: any;
}
export { name, getName, Animal, Directions, Options };
注意,与全局变量的声明文件类似,interface
前是不需要 declare
的。
与 declare namespace
类似,export namespace
用来导出一个拥有子属性的对象
export namespace foo {
const name: string;
namespace bar {
function baz(): string;
}
}
import { foo } from 'foo';
console.log(foo.name);
foo.bar.baz();
在 ES6 模块系统中,使用 export default
可以导出一个默认值,使用方可以用 import foo from 'foo'
而不是 import { foo } from 'foo'
来导入这个默认值。
在类型声明文件中,export default
用来导出默认值的类型:
export default function foo(): string;
import foo from 'foo';
foo();
注意,只有 function
、class
和 interface
可以直接默认导出,其他的变量需要先定义出来,再默认导出:
export default enum Directions {
// ERROR: Expression expected.
Up,
Down,
Left,
Right
}
// error
上例中 export default enum
是错误的语法,需要使用 declare enum
定义出来,然后使用 export default
导出
declare enum Directions {
Up,
Down,
Left,
Right
}
export default Directions;
针对这种默认导出,我们一般会将导出语句放在整个声明文件的最前面:
export default Directions;
declare enum Directions {
Up,
Down,
Left,
Right
}
在 commonjs 规范中,我们用以下方式来导出一个模块:
// 整体导出
module.exports = foo;
// 单个导出
exports.bar = bar;
在 ts 中,针对这种模块导出,有多种方式可以导入,第一种方式是 const ... = require
:
// 整体导入
const foo = require('foo');
// 单个导入
const bar = require('foo').bar;
第二种方式是 import ... from
,注意针对整体导出,需要使用 import * as
来导入:
// 整体导入
import * as foo from 'foo';
// 单个导入
import { bar } from 'foo';
第三种方式是 import ... require
,这也是 ts 官方推荐的方式:
// 整体导入
import foo = require('foo');
// 单个导入
import bar = foo.bar;
对于这种使用 commonjs 规范的库,假如要为它写类型声明文件的话,就需要使用到 export =
这种语法了21:
// types/foo/index.d.ts
export = foo;
declare function foo(): string;
declare namespace foo {
const bar: number;
}
需要注意的是,上例中使用了 export =
之后,就不能再单个导出 export { bar }
了。所以我们通过声明合并,使用 declare namespace foo
来将 bar
合并到 foo
里。
准确地讲,export =
不仅可以用在声明文件中,也可以用在普通的 ts 文件中。实际上,import ... require
和 export =
都是 ts 为了兼容 AMD 规范和 commonjs 规范而创立的新语法,由于并不常用也不推荐使用,所以这里就不详细介绍了,感兴趣的可以看官方文档。
由于很多第三方库是 commonjs 规范的,所以声明文件也就不得不用到 export =
这种语法了。但是还是需要再强调下,相比与 export =
,我们更推荐使用 ES6 标准的 export default
和 export
。
既可以通过 <script>
标签引入,又可以通过 import
导入的库,称为 UMD 库。相比于 npm 包的类型声明文件,我们需要额外声明一个全局变量,为了实现这种方式,ts 提供了一个新语法 export as namespace
。
一般使用 export as namespace
时,都是先有了 npm 包的声明文件,再基于它添加一条 export as namespace
语句,即可将声明好的一个变量声明为全局变量,举例如下:
export as namespace foo;
export = foo;
declare function foo(): string;
declare namespace foo {
const bar: number;
}
当然它也可以与 export default
一起使用:
export as namespace foo;
export default foo;
declare function foo(): string;
declare namespace foo {
const bar: number;
}
有的第三方库扩展了一个全局变量,可是此全局变量的类型却没有相应的更新过来,就会导致 ts 编译错误,此时就需要扩展全局变量的类型。比如扩展 String
类型
interface String {
prependHello(): string;
}
'foo'.prependHello();
通过声明合并,使用 interface String
即可给 String
添加属性或方法。
也可以使用 declare namespace
给已有的命名空间添加类型声明24:
// types/jquery-plugin/index.d.ts
declare namespace JQuery {
interface CustomOptions {
bar: string;
}
}
interface JQueryStatic {
foo(options: JQuery.CustomOptions): string;
}
// src/index.ts
jQuery.foo({
bar: ''
});
如之前所说,对于一个 npm 包或者 UMD 库的声明文件,只有 export
导出的类型声明才能被导入。所以对于 npm 包或 UMD 库,如果导入此库之后会扩展全局变量,则需要使用另一种语法在声明文件中扩展全局变量的类型,那就是 declare global
declare global {
interface String {
prependHello(): string;
}
}
export {};
'bar'.prependHello();
注意即使此声明文件不需要导出任何东西,仍然需要导出一个空对象,用来告诉编译器这是一个模块的声明文件,而不是一个全局变量的声明文件。
有时通过 import
导入一个模块插件,可以改变另一个原有模块的结构。此时如果原有模块已经有了类型声明文件,而插件模块没有类型声明文件,就会导致类型不完整,缺少插件部分的类型。ts 提供了一个语法 declare module
,它可以用来扩展原有模块的类型。
如果是需要扩展原有模块的话,需要在类型声明文件中先引用原有模块,再使用 declare module
扩展原有模块
import * as moment from 'moment';
declare module 'moment' {
export function foo(): moment.CalendarKey;
}
import * as moment from 'moment';
import 'moment-plugin';
moment.foo();
declare module
也可用于在一个文件中一次性声明多个模块的类型
declare module 'foo' {
export interface Foo {
foo: string;
}
}
declare module 'bar' {
export function bar(): string;
}
import { Foo } from 'foo';
import * as bar from 'bar';
let f: Foo;
bar.bar();
一个声明文件有时会依赖另一个声明文件中的类型,比如在前面的 declare module
的例子中,我们就在声明文件中导入了 moment
,并且使用了 moment.CalendarKey
这个类型:
import * as moment from 'moment';
declare module 'moment' {
export function foo(): moment.CalendarKey;
}
除了可以在声明文件中通过 import
导入另一个声明文件中的类型之外,还有一个语法也可以用来导入另一个声明文件,那就是三斜线指令。
与 namespace
类似,三斜线指令也是 ts 在早期版本中为了描述模块之间的依赖关系而创造的语法。随着 ES6 的广泛应用,现在已经不建议再使用 ts 中的三斜线指令来声明模块之间的依赖关系了。
但是在声明文件中,它还是有一定的用武之地。
类似于声明文件中的 import
,它可以用来导入另一个声明文件。与 import
的区别是,当且仅当在以下几个场景下,我们才需要使用三斜线指令替代 import
:
这些场景听上去很拗口,但实际上很好理解——在全局变量的声明文件中,是不允许出现 import
, export
关键字的。一旦出现了,那么他就会被视为一个 npm 包或 UMD 库,就不再是全局变量的声明文件了。故当我们在书写一个全局变量的声明文件时,如果需要引用另一个库的类型,那么就必须用三斜线指令了
/// <reference types="jquery" />
declare function foo(options: JQuery.AjaxSettings): string;
foo({});
三斜线指令的语法如上,///
后面使用 xml 的格式添加了对 jquery
类型的依赖,这样就可以在声明文件中使用 JQuery.AjaxSettings
类型了。
注意,三斜线指令必须放在文件的最顶端,三斜线指令的前面只允许出现单行或多行注释。
在另一个场景下,当我们需要依赖一个全局变量的声明文件时,由于全局变量不支持通过 import
导入,当然也就必须使用三斜线指令来引入了
/// <reference types="node" />
export function foo(p: NodeJS.Process): string;
import { foo } from 'node-plugin';
foo(global.process);
在上面的例子中,我们通过三斜线指引入了 node
的类型,然后在声明文件中使用了 NodeJS.Process
这个类型。最后在使用到 foo
的时候,传入了 node
中的全局变量 process
。
由于引入的 node
中的类型都是全局变量的类型,它们是没有办法通过 import
来导入的,所以这种场景下也只能通过三斜线指令来引入了。
以上两种使用场景下,都是由于需要书写或需要依赖全局变量的声明文件,所以必须使用三斜线指令。在其他的一些不是必要使用三斜线指令的情况下,就都需要使用 import
来导入。
当我们的全局变量的声明文件太大时,可以通过拆分为多个文件,然后在一个入口文件中将它们一一引入,来提高代码的可维护性。比如 jQuery
的声明文件就是这样的:
// node_modules/@types/jquery/index.d.ts
/// <reference types="sizzle" />
/// <reference path="JQueryStatic.d.ts" />
/// <reference path="JQuery.d.ts" />
/// <reference path="misc.d.ts" />
/// <reference path="legacy.d.ts" />
export = jQuery;
其中用到了 types
和 path
两种不同的指令。它们的区别是:types
用于声明对另一个库的依赖,而 path
用于声明对另一个文件的依赖。
上例中,sizzle
是与 jquery
平行的另一个库,所以需要使用 types="sizzle"
来声明对它的依赖。而其他的三斜线指令就是将 jquery
的声明拆分到不同的文件中了,然后在这个入口文件中使用 path="foo"
将它们一一引入。
如果库的源码本身就是由 ts 写的,那么在使用 tsc
脚本将 ts 编译为 js 的时候,添加 declaration
选项,就可以同时也生成 .d.ts
声明文件了。
我们可以在命令行中添加 --declaration
(简写 -d
),或者在 tsconfig.json
中添加 declaration
选项。这里以 tsconfig.json
为例:
{
"compilerOptions": {
"module": "commonjs",
"outDir": "lib",
"declaration": true,
}
}
上例中我们添加了 outDir
选项,将 ts 文件的编译结果输出到 lib
目录下,然后添加了 declaration
选项,设置为 true
,表示将会由 ts 文件自动生成 .d.ts
声明文件,也会输出到 lib
目录下。
运行 tsc
之后,目录结构如下:
├── lib
| ├── bar
| | ├── index.d.ts
| | └── index.js
| ├── index.d.ts
| └── index.js
├── src
| ├── bar
| | └── index.ts
| └── index.ts
├── package.json
└── tsconfig.json
在这个例子中,src
目录下有两个 ts 文件,分别是 src/index.ts
和 src/bar/index.ts
,它们被编译到 lib
目录下的同时,也会生成对应的两个声明文件 lib/index.d.ts
和 lib/bar/index.d.ts
。它们的内容分别是:
// src/index.ts
export * from './bar';
export default function foo() {
return 'foo';
}
// src/bar/index.ts
export function bar() {
return 'bar';
}
// lib/index.d.ts
export * from './bar';
export default function foo(): string;
// lib/bar/index.d.ts
export declare function bar(): string;
可见,自动生成的声明文件基本保持了源码的结构,而将具体实现去掉了,生成了对应的类型声明。
使用 tsc
自动生成声明文件时,每个 ts 文件都会对应一个 .d.ts
声明文件。这样的好处是,使用方不仅可以在使用 import foo from 'foo'
导入默认的模块时获得类型提示,还可以在使用 import bar from 'foo/lib/bar'
导入一个子模块时,也获得对应的类型提示。
除了 declaration
选项之外,还有几个选项也与自动生成声明文件有关,这里只简单列举出来,不做详细演示了:
declarationDir
设置生成 .d.ts
文件的目录declarationMap
对每个 .d.ts
文件,都生成对应的 .d.ts.map
(sourcemap)文件emitDeclarationOnly
仅生成 .d.ts
文件,不生成 .js
文件当我们为一个库写好了声明文件之后,下一步就是将它发布出去了。
此时有两种方案:
@types
下这两种方案中优先选择第一种方案。保持声明文件与源码在一起,使用时就不需要额外增加单独的声明文件库的依赖了,而且也能保证声明文件的版本与源码的版本保持一致。
仅当我们在给别人的仓库添加类型声明文件,但原作者不愿意合并 pull request 时,才需要使用第二种方案,将声明文件发布到 @types
下。
如果声明文件是通过 tsc
自动生成的,那么无需做任何其他配置,只需要把编译好的文件也发布到 npm 上,使用方就可以获取到类型提示了。
如果是手动写的声明文件,那么需要满足以下条件之一,才能被正确的识别:
package.json
中的 types
或 typings
字段指定一个类型声明文件地址index.d.ts
文件package.json
中的 main
字段指定的入口文件),编写一个同名不同后缀的 .d.ts
文件第一种方式是给 package.json
中的 types
或 typings
字段指定一个类型声明文件地址。比如:
{
"name": "foo",
"version": "1.0.0",
"main": "lib/index.js",
"types": "foo.d.ts",
}
指定了 types
为 foo.d.ts
之后,导入此库的时候,就会去找 foo.d.ts
作为此库的类型声明文件了。
typings
与 types
一样,只是另一种写法。
如果没有指定 types
或 typings
,那么就会在根目录下寻找 index.d.ts
文件,将它视为此库的类型声明文件。
如果没有找到 index.d.ts
文件,那么就会寻找入口文件(package.json
中的 main
字段指定的入口文件)是否存在对应同名不同后缀的 .d.ts
文件。
比如 package.json
是这样时:
{
"name": "foo",
"version": "1.0.0",
"main": "lib/index.js"
}
就会先识别 package.json
中是否存在 types
或 typings
字段。发现不存在,那么就会寻找是否存在 index.d.ts
文件。如果还是不存在,那么就会寻找是否存在 lib/index.d.ts
文件。假如说连 lib/index.d.ts
都不存在的话,就会被认为是一个没有提供类型声明文件的库了。
有的库为了支持导入子模块,比如 import bar from 'foo/lib/bar'
,就需要额外再编写一个类型声明文件 lib/bar.d.ts
或者 lib/bar/index.d.ts
,这与自动生成声明文件类似,一个库中同时包含了多个类型声明文件。
如果我们是在给别人的仓库添加类型声明文件,但原作者不愿意合并 pull request,那么就需要将声明文件发布到 @types
下。
与普通的 npm 模块不同,@types
是统一由 DefinitelyTyped 管理的。要将声明文件发布到 @types
下,就需要给 DefinitelyTyped 创建一个 pull-request,其中包含了类型声明文件,测试代码,以及 tsconfig.json
等。
pull-request 需要符合它们的规范,并且通过测试,才能被合并,稍后就会被自动发布到 @types
下。
JavaScript有很多内置对象,它们可以直接在TypeScript中当做定义好了的类型
内置对象是指根据标准在全局作用域(Global)上存在的对象。这里的标准是指 ECMAScript 和其他环境(比如 DOM)的标准。
ECMAScript 标准提供的内置对象有:
Boolean
、Error
、Date
、RegExp
等。
我们可以在 TypeScript 中将变量定义为这些类型:
let b: Boolean = new Boolean(1);
let e: Error = new Error('Error occurred');
let d: Date = new Date();
let r: RegExp = /[a-z]/;
DOM 和 BOM 提供的内置对象有:
Document
、HTMLElement
、Event
、NodeList
等。
TypeScript 中会经常用到这些类型:
let body: HTMLElement = document.body;
let allDiv: NodeList = document.querySelectorAll('div');
document.addEventListener('click', function(e: MouseEvent) {
// Do something
});
TypeScript 核心库的定义文件中定义了所有浏览器环境需要用到的类型,并且是预置在 TypeScript 中的。
当你在使用一些常用的方法的时候,TypeScript 实际上已经帮你做了很多类型判断的工作了,比如:
Math.pow(10, '2');
// index.ts(1,14): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
上面的例子中,Math.pow
必须接受两个 number
类型的参数。事实上 Math.pow
的类型定义如下:
interface Math {
/**
* Returns the value of a base expression taken to a specified power.
* @param x The base value of the expression.
* @param y The exponent value of the expression.
*/
pow(x: number, y: number): number;
}
再举一个 DOM 中的例子:
document.addEventListener('click', function(e) {
console.log(e.targetCurrent);
});
// index.ts(2,17): error TS2339: Property 'targetCurrent' does not exist on type 'MouseEvent'.
上面的例子中,addEventListener
方法是在 TypeScript 核心库中定义的:
interface Document extends Node, GlobalEventHandlers, NodeSelector, DocumentEvent {
addEventListener(type: string, listener: (ev: MouseEvent) => any, useCapture?: boolean): void;
}
所以 e
被推断成了 MouseEvent
,而 MouseEvent
是没有 targetCurrent
属性的,所以报错了。
注意,TypeScript 核心库的定义中不包含 Node.js 部分。
Node.js 不是内置对象的一部分,如果想用 TypeScript 写 Node.js,则需要引入第三方声明文件:
npm install @types/node --save-dev