目录
类型断言
类型断言(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;
}
// Property 'swim' does not exist on type 'Cat | Fish'.
// 类型'Cat | Fish'不存在属性'swim'。
// Property 'swim' does not exist on type 'Cat'.
// 类型'Cat'不存在属性'swim'。
上面的例子中,获取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;
}
这样就可以解决访问animal.swim
时报错的问题了。
需要注意的是,类型断言只能够「欺骗」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);
// animal.swim is not a function.
// animal.swim不是一个函数。
上面的例子编译时不会报错,但在运行时会报错:
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;
}
// 'ApiError' only refers to a type, but is being used as a value here.
// 'ApiError'只指向一个类型,但在这里被用作一个值。
此时就只能用类型断言,通过判断是否存在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;
// Property 'length' does not exist on type 'number'.
// 属性'length'在类型'number'上不存在。
上面的例子中,数字类型的变量foo
上是没有length
属性的,故TypeScript给出了相应的错误提示。
这种错误提示显然是非常有用的。
但有的时候,我们非常确定这段代码不会出错,比如下面这个例子:
window.foo = 1;
// Property 'foo' does not exist on type 'Window & typeof globalThis'.
// 属性'foo'不存在类型'Window & typeof globalThis'。
上面的例子中,我们需要将window
上添加一个属性foo
,但TypeScript编译时会报错,提示我们window
上不存在foo
属性。
此时我们可以使用as any
临时将window
断言为any
类型:
(window as any).foo = 1;
在any
类型的变量上,访问任何属性都是允许的。
需要注意的是,将一个变量断言为any
可以说是解决TypeScript中类型问题的最后一个手段。
它极有可能掩盖了真正的类型错误,所以如果不是非常确定,就不要使用as any
。
上面的例子中,我们也可以通过扩展window的类型(TODO)解决这个错误,不过如果只是临时的增加foo
属性,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
的访问时就有了代码补全,提高了代码的可维护性。
类型断言的限制
从上面的例子中,我们可以总结出:
• 联合类型可以被断言为其中一个类型;
• 父类可以被断言为子类;
• 任何类型都可以被断言为 any;
• any 可以被断言为任何类型。
那么类型断言有没有什么限制呢?是不是任何一个类型都可以被断言为任何另一个类型呢?
答案是否定的——并不是任何一个类型都可以被断言为任何另一个类型。
具体来说,若A
兼容B
,那么A
能够被断言为B
,B
也能被断言为A
。
下面我们通过一个简化的例子,来理解类型断言的限制:
interface Animal {
name: string;
}
interface Cat {
name: string;
run(): void;
}
let tom: Cat = {
name: 'Tom',
run: () => { console.log('run') }
};
let animal: Animal = tom;
我们知道,TypeScript 是结构类型系统,类型之间的对比只会比较它们最终的结构,而会忽略它们定义时的关系。
在上面的例子中,Cat
包含了Animal
中的所有属性,除此之外,它还有一个额外的方法run。
TypeScript 并不关心Cat
和Animal
之间定义时是什么关系,而只会看它们最终的结构有什么关系——所以它与Cat extends Animal
是等价的:
interface Animal {
name: string;
}
interface Cat extends Animal {
run(): void;
}
那么也不难理解为什么Cat
类型的tom
可以赋值给Animal
类型的animal
了——就像面向对象编程中我们可以将子类的实例赋值给类型为父类的变量。
我们把它换成TypeScript中更专业的说法,即Animal
兼容Cat
。
当Animal
兼容Cat
时,它们就可以互相进行类型断言了:
interface Animal {
name: string;
}
interface Cat {
name: string;
run(): void;
}
function testAnimal(animal: Animal) {
return (animal as Cat);
}
function testCat(cat: Cat) {
return (cat as Animal);
}
这样的设计其实也很容易就能理解:
• 允许animal as Cat
是因为「父类可以被断言为子类」,这个前面已经学习过了;
• 允许cat as Animal
是因为既然子类拥有父类的属性和方法,那么被断言为父类,获取父类的属性、调用父类的方法,就不会有任何问题,故「子类可以被断言为父类」。
总之,若A
兼容B
,那么A
能够被断言为B
,B
也能被断言为A
。
同理,若B
兼容A
,那么A
能够被断言为B
,B
也能被断言为A
。
所以这也可以换一种说法:
要使得A
能够被断言为B
,只需要A
兼容B
或B
兼容A
即可,这也是为了在类型断言时的安全考虑,毕竟毫无根据的断言是非常危险的。
综上所述:
• 联合类型可以被断言为其中一个类型;
• 父类可以被断言为子类;
• 任何类型都可以被断言为any;
• any可以被断言为任何类型;
• 要使得A
能够被断言为B
,只需要A
兼容B
或B
兼容A
即可。
其实前四种情况都是最后一个的特例。
双重断言
既然:
• 任何类型都可以被断言为any;
• any可以被断言为任何类型。
那么我们是不是可以使用双重断言as any as Foo
来将任何一个类型断言为任何另一个类型呢?
interface Cat {
run(): void;
}
interface Fish {
swim(): void;
}
function testCat(cat: Cat) {
return (cat as any as Fish);
}
在上面的例子中,若直接使用cat as Fish
肯定会报错,因为Cat
和Fish
互相都不兼容。
但是若使用双重断言,则可以打破「要使得A
能够被断言为B
,只需要A
兼容B
或B
兼容A
即可」的限制,将任何一个类型断言为任何另一个类型。
若你使用了这种双重断言,那么十有八九是非常错误的,它很可能会导致运行时错误。
除非迫不得已,千万别用双重断言。
类型断言 vs 类型转换
类型断言只会影响TypeScript编译时的类型,类型断言语句在编译结果中会被删除:
function toBoolean(something: any): boolean {
return something as boolean;
}
toBoolean(1);
// 返回值为 1
在上面的例子中,将something
断言为boolean
虽然可以通过编译,但是并没有什么用,代码在编译后会变成:
function toBoolean(something) {
return something;
}
toBoolean(1);
// 返回值为 1
所以类型断言不是类型转换,它不会真的影响到变量的类型。
若要进行类型转换,需要直接调用类型转换的方法:
function toBoolean(something: any): boolean {
return Boolean(something);
}
toBoolean(1);
// 返回值为 true
类型断言 vs 类型声明
在这个例子中:
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();
我们使用as Cat
将any
类型断言为了Cat
类型。
但实际上还有其他方式可以解决这个问题:
function getCacheData(key: string): any {
return (window as any).cache[key];
}
interface Cat {
name: string;
run(): void;
}
const tom: Cat = getCacheData('tom');
tom.run();
上面的例子中,我们通过类型声明的方式,将tom
声明为Cat
,然后再将any
类型的getCacheData('tom')
赋值给Cat
类型的tom
。
这和类型断言是非常相似的,而且产生的结果也几乎是一样的——tom
在接下来的代码中都变成了Cat
类型。
它们的区别,可以通过这个例子来理解:
interface Animal {
name: string;
}
interface Cat {
name: string;
run(): void;
}
const animal: Animal = {
name: 'tom'
};
let tom = animal as Cat;
在上面的例子中,由于Animal
兼容Cat
,故可以将animal
断言为Cat
赋值给tom
。
但是若直接声明tom
为Cat
类型:
interface Animal {
name: string;
}
interface Cat {
name: string;
run(): void;
}
const animal: Animal = {
name: 'tom'
};
let tom: Cat = animal;
// Property 'run' is missing in type 'Animal' but required in type 'Cat'.
// 属性'run'在类型'Animal'中缺失,但在类型'Cat'中需要。
则会报错,不允许将animal
赋值为Cat
类型的tom。
这很容易理解,Animal
可以看作是Cat
的父类,当然不能将父类的实例赋值给类型为子类的变量。
深入的讲,它们的核心区别就在于:
• animal
断言为Cat
,只需要满足Animal
兼容Cat
或Cat
兼容Animal
即可;
• animal
赋值给tom
,需要满足Cat
兼容Animal
才行。
但是Cat
并不兼容Animal
。
而在前一个例子中,由于getCacheData('tom')
是any
类型,any
兼容Cat
,Cat
也兼容any
,故
const tom = getCacheData('tom') as Cat;
等价于
const tom: Cat = getCacheData('tom');
知道了它们的核心区别,就知道了类型声明是比类型断言更加严格的。
所以为了增加代码的质量,我们最好优先使用类型声明,这也比类型断言的as
语法更加优雅。
类型断言 vs 泛型
还是这个例子:
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();
我们还有第三种方式可以解决这个问题,那就是泛型:
function getCacheData<T>(key: string): T {
return (window as any).cache[key];
}
interface Cat {
name: string;
run(): void;
}
const tom = getCacheData<Cat>('tom');
通过给getCacheData
函数添加了一个泛型<T>
,我们可以更加规范的实现对getCacheData
返回值的约束,这也同时去除掉了代码中的any
,是最优的一个解决方案。
声明文件
当使用第三方库时,我们需要引用它的声明文件,才能获得对应的代码补全、接口提示等功能。
新语法索引
由于本章涉及大量新语法,故在本章开头列出新语法的索引,方便大家在使用这些新语法时能快速查找到对应的讲解:
• declare var
声明全局变量;
• declare function
声明全局方法;
• declare class
声明全局类;
• declare enum
声明全局枚举类型;
• declare namespace
声明(含有子属性的)全局对象;
• interface
和type
声明全局类型;
• export
导出变量;
• export namespace
导出(含有子属性的)对象;
• export default
ES6 默认导出;
• export =
commonjs 导出模块;
• export as namespace
UMD 库声明全局变量;
• declare global
扩展全局变量;
• declare module
扩展模块;
• /// <refernce />
三斜线指令。
什么是声明语句
假如我们想使用第三方库jQuery,一种常见的方式是在html中通过<script>
标签引入jQuery,然后就可以使用全局变量$
或jQuery
了。
我们通常这样获取一个id
是foo
的元素:
$('#foo');
// or
jQuery('#foo');
但是在ts中,编译器并不知道$
或jQuery
是什么东西:
jQuery('#foo');
// Cannot find name 'jQuery'.
// 无法找到名称'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
的类型定义了。
/path/to/project
├── src
| ├── index.ts
| └── jQuery.d.ts
└── tsconfig.json
假如仍然无法解析,那么可以检查下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 function
声明全局方法;
• declare class
声明全局类;
• declare enum
声明全局枚举类型;
• declare namespace
声明(含有子属性的)全局对象;
• interface
和type
声明全局类型。
declare var
在所有的声明语句中,declare var
是最简单的,如之前所学,它能够用来定义一个全局变量的类型。与其类似的,还有declare let
和declare const
,使用let
与使用const
没有什么区别:
// 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);
};
// Cannot assign to 'jQuery' because it is a constant or a read-only property.
// 不能赋值给'jQuery',因为它是常量或只读属性。
一般来说,全局变量都是禁止修改的常量,所以大部分情况都应该使用const
而不是var
或let
。
需要注意的是,声明语句中只能定义类型,切勿在声明语句中定义具体的实现:
declare const jQuery = function(selector) {
return document.querySelector(selector);
};
// An implementation cannot be declared in ambient contexts.
// 实现不能在环境上下文中声明。
declare function
declare function
用来定义全局函数的类型。jQuery其实就是一个函数,所以也可以用function
来定义:
// src/jQuery.d.ts
declare function jQuery(selector: string): any;
// src/index.ts
jQuery('#foo');
在函数类型的声明语句中,函数重载也是支持的:
// src/jQuery.d.ts
declare function jQuery(selector: string): any;
declare function jQuery(domReadyCallback: () => any): any;
// src/index.ts
jQuery('#foo');
jQuery(function() {
alert('Dom Ready!');
});
declare class
当全局变量是一个类的时候,我们用declare class
来定义它的类型:
// src/Animal.d.ts
declare class Animal {
name: string;
constructor(name: string);
sayHi(): string;
}
// src/index.ts
let cat = new Animal('Tom');
同样的,declare class
语句也只能用来定义类型,不能用来定义具体的实现,比如定义sayHi
方法的具体实现则会报错:
// src/Animal.d.ts
declare class Animal {
name: string;
constructor(name: string);
sayHi() {
return `My name is ${this.name}`;
};
// An implementation cannot be declared in ambient contexts.
// 实现不能在环境上下文中声明。
}
declare enum
使用declare enum
定义的枚举类型也称作外部枚举(Ambient Enums),举例如下:
// src/Directions.d.ts
declare enum Directions {
Up,
Down,
Left,
Right
}
// src/index.ts
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
是由第三方库定义好的全局变量。
declare namespace
namespace
是ts早期时为了解决模块化而创造的关键字,中文称为命名空间。
由于历史遗留原因,在早期还没有 ES6 的时候,ts提供了一种模块化方案,使用module
关键字表示内部模块。但由于后来ES6也使用了module
关键字,ts为了兼容ES6,使用namespace
替代了自己的module
,更名为命名空间。
随着ES6的广泛应用,现在已经不建议再使用ts中的namespace
,而推荐使用ES6的模块化方案了,故我们不再需要学习namespace
的使用了。
namespace
被淘汰了,但是在声明文件中,declare namespace
还是比较常用的,它用来表示全局变量是一个对象,包含很多子属性。
比如jQuery
是一个全局变量,它是一个对象,提供了一个jQuery.ajax
方法可以调用,那么我们就应该使用declare namespace jQuery
来声明这个拥有多个子属性的全局变量。
// src/jQuery.d.ts
declare namespace jQuery {
function ajax(url: string, settings?: any): void;
}
// src/index.ts
jQuery.ajax('/api/get_something');
注意,在declare namespace
内部,我们直接使用function ajax
来声明函数,而不是使用declare function ajax
。类似的,也可以使用const
, class
, enum
等语句:
// src/jQuery.d.ts
declare namespace jQuery {
function ajax(url: string, settings?: any): void;
const version: number;
class Event {
blur(eventType: EventType): void
}
enum EventType {
CustomClick
}
}
// src/index.ts
jQuery.ajax('/api/get_something');
console.log(jQuery.version);
const e = new jQuery.Event();
e.blur(jQuery.EventType.CustomClick);
嵌套的命名空间
如果对象拥有深层的层级,则需要用嵌套的namespace
来声明深层的属性的类型:
// src/jQuery.d.ts
declare namespace jQuery {
function ajax(url: string, settings?: any): void;
namespace fn {
function extend(object: any): void;
}
}
// src/index.ts
jQuery.ajax('/api/get_something');
jQuery.fn.extend({
check: function() {
return this.each(function() {
this.checked = true;
});
}
});
假如jQuery
下仅有fn
这一个属性(没有ajax
等其他属性或方法),则可以不需要嵌套namespace
:
// src/jQuery.d.ts
declare namespace jQuery.fn {
function extend(object: any): void;
}
// src/index.ts
jQuery.fn.extend({
check: function() {
return this.each(function() {
this.checked = true;
});
}
});
interface和type
除了全局变量之外,可能有一些类型我们也希望能暴露出来。在类型声明文件中,我们可以直接使用interface
或type
来声明一个全局的接口或类型:
// src/jQuery.d.ts
interface AjaxSettings {
method?: 'GET' | 'POST'
data?: any;
}
declare namespace jQuery {
function ajax(url: string, settings?: AjaxSettings): void;
}
这样的话,在其他文件中也可以使用这个接口或类型了:
// src/index.ts
let settings: AjaxSettings = {
method: 'POST',
data: {
name: 'foo'
}
};
jQuery.ajax('/api/post_something', settings);
type
与interface
类似,不再赘述。
防止命名冲突
暴露在最外层的interface
或type
会作为全局类型作用于整个项目中,我们应该尽可能的减少全局变量或全局类型的数量。故最好将他们放到namespace
下:
// src/jQuery.d.ts
declare namespace jQuery {
interface AjaxSettings {
method?: 'GET' | 'POST'
data?: any;
}
function ajax(url: string, settings?: AjaxSettings): void;
}
注意,在使用这个interface
的时候,也应该加上jQuery
前缀:
// src/index.ts
let settings: jQuery.AjaxSettings = {
method: 'POST',
data: {
name: 'foo'
}
};
jQuery.ajax('/api/post_something', settings);
声明合并
假如jQuery既是一个函数,可以直接被调用jQuery('#foo')
,又是一个对象,拥有子属性jQuery.ajax()
(事实确实如此),那么我们可以组合多个声明语句,它们会不冲突的合并起来:
// src/jQuery.d.ts
declare function jQuery(selector: string): any;
declare namespace jQuery {
function ajax(url: string, settings?: any): void;
}
// src/index.ts
jQuery('#foo');
jQuery.ajax('/api/get_something');
npm包
一般我们通过import foo from 'foo'
导入一个npm包,这是符合ES6模块规范的。
在我们尝试给一个npm包创建声明文件之前,需要先看看它的声明文件是否已经存在。一般来说,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包的声明文件主要有以下几种语法:
• export
导出变量;
• export namespace
导出(含有子属性的)对象;
• export default
ES6 默认导出;
• export =
commonjs 导出模块。
export
npm包的声明文件与全局变量的声明文件有很大区别。在npm包的声明文件中,使用declare不再会声明一个全局变量,而只会在当前文件中声明一个局部变量。只有在声明文件中使用export
导出,然后在使用方import
导入后,才会应用到这些类型声明。
export
的语法与普通的ts中的语法类似,区别仅在于声明文件中禁止定义具体的实现:
// types/foo/index.d.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;
}
对应的导入和使用模块应该是这样:
// src/index.ts
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
我们也可以使用declare
先声明多个变量,最后再用export
一次性导出。上例的声明文件可以等价的改写为:
// types/foo/index.d.ts
declare 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
的。
export namespace
与declare namespace
类似,export namespace
用来导出一个拥有子属性的对象:
// types/foo/index.d.ts
export namespace foo {
const name: string;
namespace bar {
function baz(): string;
}
}
// src/index.ts
import { foo } from 'foo';
console.log(foo.name);
foo.bar.baz();
export default
在ES6模块系统中,使用export default
可以导出一个默认值,使用方可以用import foo from 'foo'
而不是import { foo } from 'foo'
来导入这个默认值。
在类型声明文件中,export default
用来导出默认值的类型:
// types/foo/index.d.ts
export default function foo(): string;
// src/index.ts
import foo from 'foo';
foo();
注意,只有function
、class
和interface
可以直接默认导出,其他的变量需要先定义出来,再默认导出:
// types/foo/index.d.ts
export default enum Directions {
// ERROR: Expression expected.
Up,
Down,
Left,
Right
}
上例中export default enum
是错误的语法,需要使用declare enum
定义出来,然后使用export default
导出:
// types/foo/index.d.ts
declare enum Directions {
Up,
Down,
Left,
Right
}
export default Directions;
针对这种默认导出,我们一般会将导出语句放在整个声明文件的最前面:
// types/foo/index.d.ts
export default Directions;
declare enum Directions {
Up,
Down,
Left,
Right
}
export =
在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 =
这种语法了:
// 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
。
UMD库
既可以通过<script>
标签引入,又可以通过import
导入的库,称为UMD库。相比于npm包的类型声明文件,我们需要额外声明一个全局变量,为了实现这种方式,ts提供了一个新语法export as namespace
。
export as namespace
一般使用export as namespace
时,都是先有了npm包的声明文件,再基于它添加一条export as namespace
语句,即可将声明好的一个变量声明为全局变量,举例如下:
// types/foo/index.d.ts
export as namespace foo;
export = foo;
declare function foo(): string;
declare namespace foo {
const bar: number;
}
当然它也可以与export default
一起使用:
// types/foo/index.d.ts
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
给已有的命名空间添加类型声明:
// 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库中扩展全局变量
如之前所说,对于一个npm包或者UMD库的声明文件,只有export
导出的类型声明才能被导入。所以对于npm包或UMD库,如果导入此库之后会扩展全局变量,则需要使用另一种语法在声明文件中扩展全局变量的类型,那就是declare global
。
declare global
使用declare global
可以在npm包或者UMD库的声明文件中扩展全局变量的类型:
// types/foo/index.d.ts
declare global {
interface String {
prependHello(): string;
}
}
export {};
// src/index.ts
'bar'.prependHello();
注意即使此声明文件不需要导出任何东西,仍然需要导出一个空对象,用来告诉编译器这是一个模块的声明文件,而不是一个全局变量的声明文件。
模块插件
有时通过import
导入一个模块插件,可以改变另一个原有模块的结构。此时如果原有模块已经有了类型声明文件,而插件模块没有类型声明文件,就会导致类型不完整,缺少插件部分的类型。ts提供了一个语法declare module
,它可以用来扩展原有模块的类型。
declare module
如果是需要扩展原有模块的话,需要在类型声明文件中先引用原有模块,再使用declare module
扩展原有模块:
// types/moment-plugin/index.d.ts
import * as moment from 'moment';
declare module 'moment' {
export function foo(): moment.CalendarKey;
}
// src/index.ts
import * as moment from 'moment';
import 'moment-plugin';
moment.foo();
declare module
也可用于在一个文件中一次性声明多个模块的类型:
// types/foo-bar.d.ts
declare module 'foo' {
export interface Foo {
foo: string;
}
}
declare module 'bar' {
export function bar(): string;
}
// src/index.ts
import { Foo } from 'foo';
import * as bar from 'bar';
let f: Foo;
bar.bar();
声明文件中的依赖
一个声明文件有时会依赖另一个声明文件中的类型,比如在前面的declare module
的例子中,我们就在声明文件中导入了moment
,并且使用了moment.CalendarKey
这个类型:
// types/moment-plugin/index.d.ts
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库,就不再是全局变量的声明文件了。故当我们在书写一个全局变量的声明文件时,如果需要引用另一个库的类型,那么就必须用三斜线指令了:
// types/jquery-plugin/index.d.ts
/// <reference types="jquery" />
declare function foo(options: JQuery.AjaxSettings): string;
// src/index.ts
foo({});
三斜线指令的语法如上,///
后面使用xml的格式添加了对jquery
类型的依赖,这样就可以在声明文件中使用JQuery.AjaxSettings
类型了。
注意,三斜线指令必须放在文件的最顶端,三斜线指令的前面只允许出现单行或多行注释。
依赖一个全局变量的声明文件
在另一个场景下,当我们需要依赖一个全局变量的声明文件时,由于全局变量不支持通过import
导入,当然也就必须使用三斜线指令来引入了:
// types/node-plugin/index.d.ts
/// <reference types="node" />
export function foo(p: NodeJS.Process): string;
// src/index.ts
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"
将它们一一引入。
其他三斜线指令
除了这两种三斜线指令之外,还有其他的三斜线指令,比如/// <reference no-default-lib="true"/>
, /// <amd-module />
等,但它们都是废弃的语法,故这里就不介绍了,详情可见官网。
自动生成声明文件
如果库的源码本身就是由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
之后,目录结构如下:
/path/to/project
├── 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
,这与自动生成声明文件类似,一个库中同时包含了多个类型声明文件。
将声明文件发布到@types下
如果我们是在给别人的仓库添加类型声明文件,但原作者不愿意合并pull request,那么就需要将声明文件发布到@types
下。
与普通的 npm 模块不同,@types
是统一由 DefinitelyTyped 管理的。要将声明文件发布到@types
下,就需要给 DefinitelyTyped 创建一个 pull-request,其中包含了类型声明文件,测试代码,以及tsconfig.json
等。
pull-request 需要符合它们的规范,并且通过测试,才能被合并,稍后就会被自动发布到@types
下。
在 DefinitelyTyped 中创建一个新的类型声明,需要用到一些工具,DefinitelyTyped 的文档中已经有了详细的介绍,这里就不赘述了,以官方文档为准。
如果大家有此类需求,可以参考下笔者提交的 pull-request 。
内置对象
JavaScript 中有很多内置对象,它们可以直接在TypeScript中当做定义好了的类型。
内置对象是指根据标准在全局作用域(Global)上存在的对象。这里的标准是指ECMAScript和其他环境(比如DOM)的标准。
ECMAScript的内置对象
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]/;
更多的内置对象,可以查看MDN的文档。
而他们的定义文件,则在TypeScript核心库的定义文件中。
DOM和BOM的内置对象
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核心库的定义文件中定义了所有浏览器环境需要用到的类型,并且是预置在TypeScript中的。
当你在使用一些常用的方法的时候,TypeScript实际上已经帮你做了很多类型判断的工作了,比如:
Math.pow(10, '2');
// Argument of type 'string' is not assignable to parameter of type 'number'.
// 'string'类型的实参不能赋值给'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);
});
// Property 'targetCurrent' does not exist on type 'MouseEvent'.
// 属性'targetCurrent'在类型'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部分。
用TypeScript写Node.js
Node.js不是内置对象的一部分,如果想用TypeScript写Node.js,则需要引入第三方声明文件:
npm install @types/node --save-dev