# 声明相关

# 什么是声明语句

假如,想使用第三方库 jQuery,一种常见的方式是在 html 中通过 <script> 标签引入 jQuery,然后就可以使用全局变量 $jQuery 了。

通常这样获取一个 idfoo 的元素:

$('#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 的类型定义了。

/path/to/project
├── src
|  ├── index.ts
|  └── jQuery.d.ts
└── tsconfig.json

假如仍然无法解析,那么可以检查下 tsconfig.json 中的 filesincludeexclude 配置,确保其包含了 jQuery.d.ts 文件。

这里只演示了全局变量这种模式的声明文件,假如是通过模块导入的方式使用第三方库,怎么办呢?见下面

# 第三方声明文件

当然,jQuery 的声明文件不需要定义了,社区已经帮定义好了:jQuery in DefinitelyTyped (opens new window)

可以直接下载下来使用,但是更推荐的是使用 @types 统一管理第三方库的声明文件。

@types 的使用方式很简单,直接用 npm 安装对应的声明模块即可,以 jQuery 举例:

npm install @types/jquery --save-dev

可以在这个页面 (opens new window)找到官方的工具,还可以找到tsconfig配置文件的参考文档

image-20210721161058920

Type Search (opens new window)搜索声明文件:

image-20210721160952061

小知识:既可以通过 <script> 标签引入,又可以通过 import 导入的库,称为 UMD 库。e.g: JQuery其实就是一个UMD的库!

相比于 npm 包的类型声明文件,需要额外声明一个全局变量,为了实现这种方式,ts 提供了一个新语法 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;
}

对于一个 npm 包或者 UMD 库的声明文件,只有 export 导出的类型声明才能被导入。

所以对于 npm 包或 UMD 库,如果导入此库之后会扩展全局变量,则需要使用另一种语法在声明文件中扩展全局变量的类型,那就是 declare global

# 书写声明文件

全局变量是最简单的一种场景,之前举的例子就是通过 <script> 标签引入 jQuery,注入全局变量 $jQuery

使用全局变量的声明文件时,如果是以 npm install @types/xxx --save-dev 安装的,则不需要任何配置。

如果是将声明文件直接存放于当前项目中,则建议和其他源码一起放到 src 目录下(或者对应的源码目录下):

/path/to/project
├── src
|  ├── index.ts
|  └── jQuery.d.ts
└── tsconfig.json

如果没有生效,可以检查下 tsconfig.json 中的 filesincludeexclude 配置,确保其包含了 jQuery.d.ts 文件。

全局变量的声明文件主要有以下几种语法:

  • declare var 声明全局变量,传送门
  • declare function 声明全局方法,传送门
  • declare class 声明全局类,传送门
  • declare enum 声明全局枚举类型,传送门
  • declare namespace 声明(含有子属性的)全局对象,传送门

# declare var

在所有的声明语句中,declare var 是最简单的,如之前所学,它能够用来定义一个全局变量的类型。与其类似的,还有 declare letdeclare const,使用 let 与使用 var 没有什么区别:

// src/jQuery.d.ts

declare let jQuery: (selector: string) => any;

# 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 语句也只能用来定义类型,不能用来定义具体的实现
// src/Animal.d.ts
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 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 等语句9 (opens new window)

// 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;
        });
    }
});

# declare module

有时通过 import 导入一个模块插件,可以改变另一个原有模块的结构。

此时如果原有模块已经有了类型声明文件,而插件模块没有类型声明文件,就会导致类型不完整,缺少插件部分的类型。

ts 提供了一个语法 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 global

使用 declare global 可以在 npm 包或者 UMD 库的声明文件中扩展全局变量的类型:

// types/foo/index.d.ts

declare global {
    interface String {
        prependHello(): string;
    }
}

export {};
// src/index.ts

'bar'.prependHello();

注意即使此声明文件不需要导出任何东西,仍然需要导出一个空对象,用来告诉编译器这是一个模块的声明文件,而不是一个全局变量的声明文件。

# 发布声明文件

当我们为一个库写好了声明文件之后,下一步就是将它发布出去了。

此时有两种方案:

  1. 将声明文件和源码放在一起

    package.json 中的 typestypings 字段指定一个类型声明文件地址。比如:

    {
        "name": "foo",
        "version": "1.0.0",
        "main": "lib/index.js",
        "types": "foo.d.ts",
    }
    
  2. 将声明文件发布到 @types

    如果我们是在给别人的仓库添加类型声明文件,但原作者不愿意合并 pull request,那么就需要将声明文件发布到 @types 下。

    与普通的 npm 模块不同,@types 是统一由 DefinitelyTyped (opens new window) 管理的。要将声明文件发布到 @types 下,就需要给 DefinitelyTyped (opens new window) 创建一个 pull-request,其中包含了类型声明文件,测试代码,以及 tsconfig.json 等。

    pull-request 需要符合它们的规范,并且通过测试,才能被合并,稍后就会被自动发布到 @types 下。

    DefinitelyTyped (opens new window) 中创建一个新的类型声明,需要用到一些工具,DefinitelyTyped (opens new window) 的文档中已经有了详细的介绍 (opens new window),这里就不赘述了,以官方文档为准。

这两种方案中优先选择第一种方案。

保持声明文件与源码在一起,使用时就不需要额外增加单独的声明文件库的依赖了,而且也能保证声明文件的版本与源码的版本保持一致。

仅当我们在给别人的仓库添加类型声明文件,但原作者不愿意合并 pull request 时,才需要使用第二种方案,将声明文件发布到 @types 下。

# 生成声明文件

方案有三:

  • 使用tsc生成

    可以在命令行中添加 --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
    

    除了 declaration 选项之外,还有几个选项也与自动生成声明文件有关,这里只简单列举出来,不做详细演示了:

    • declarationDir 设置生成 .d.ts 文件的目录
    • declarationMap 对每个 .d.ts 文件,都生成对应的 .d.ts.map(sourcemap)文件
    • emitDeclarationOnly 仅生成 .d.ts 文件,不生成 .js 文件
  • 使用npm工具:dts-gen (opens new window)

# 声明合并

声明合并是指 TypeScript 编译器会将名字相同的多个声明合并为一个声明,合并后的声明同时拥有多个声明的特性。

知道在 JavaScrip 中,使用var关键字定义变量时,定义相同名字的变量,后面的会覆盖前面的值。

使用let 定义变量和使用 const 定义常量时,不允许名字重复。

在 TypeScript 中,接口、命名空间是可以多次声明的,最后 TypeScript 会将多个同名声明合并为一个。

下来看个简单的例子:

interface Info {
    name: string
}
interface Info {
    age: number
}
let info: Info

info = { // error 类型“{ name: string; }”中缺少属性“age”
    name: 'lison'
}
info = { // right
    name: 'lison',
    age: 18
}

可以看到,定义了两个同名接口Info,每个接口里都定义了一个必备属性,最后定义info类型为Info时,info的定义要求同时包含name和age属性。

这就是声明合并的简单示例。

TypeScript的所有声明概括起来,会创建这三种实体之一:命名空间、类型

  • 命名空间的创建实际是创建一个对象,对象的属性是在命名空间里export导出的内容;
  • 类型的声明是创建一个类型并赋给一个名字;
  • 值的声明就是创建一个在JavaScript中可以使用的值。

下面这个表格会清晰的告诉你,每一种声明类型会创建这三种实体中的哪种。

先来说明一下,第一列是指声明的内容,每一行包含4列,表明这一行中,第一列的声明类型创建了后面三列哪种实体,打钩即表示创建了该实体:

声明类型 创建了命名空间 创建了类型 创建了值
Namespace
Class
Enum
Interface
Type Alias类型别名
Function
Variable

可以看到:

  • 只要命名空间创建了命名空间这种实体。

  • Class、Enum两个,Class即是实际的值也作为类使用,Enum编译为JavaScript后也是实际值,而且一定条件下,它的成员可以作为类型使用;

  • Interface和类型别名是纯粹的类型;

  • 而Funciton和Variable只是创建了JavaScript中可用的值,不能作为类型使用,注意这里Variable是变量,不是常量,常量是可以作为类型使用的。

# 合并函数

可以使用重载定义多个函数类型:

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('');
    }
}

# 合并接口(最常用)

多个同名接口,定义的非函数的成员命名应该是不重复的,如果重复了,类型应该是相同的,否则将会报错。

interface Info {
    name: string
}
interface Info {
    age: number
}
interface Info {
    age: boolean // error 后续属性声明必须属于同一类型。属性“age”的类型必须为“number”,但此处却为类型“boolean”
}

对于函数成员,每个同名函数成员都会被当成这个函数的重载,且合并时后面的接口具有更高的优先级。来看下多个同名函数成员的例子:

interface Res {
    getRes(input: string): number
}
interface Res {
    getRes(input: number): string
}
const res: Res = {
    getRes: (input: any): any => {
        if (typeof input === 'string') return input.length
        else return String(input)
    }
}
res.getRes('123').length // error 类型“number”上不存在属性“length”

# 合并命名空间

同名命名空间最后会将多个命名空间导出的内容进行合并,如下面两个命名空间:

namespace Validation {
    export const checkNumber = () => {}
}
namespace Validation {
    export const checkString = () => {}
}

上面定义两个同名命名空间,效果相当于:

namespace Validation {
    export const checkNumber = () => {}
    export const checkString = () => {}
}

在命名空间里,有时并不是把所有内容都对外部可见,对于没有导出的内容,在其它同名命名空间内是无法访问的:

namespace Validation {
    const numberReg = /^[0-9]+$/
    export const stringReg = /^[A-Za-z]+$/
    export const checkString = () => {}
}
namespace Validation {
    export const checkNumber = (value: any) => {
        return numberReg.test(value) // error 找不到名称“numberReg”
    }
}

上面定义的两个命名空间,numberReg没有使用export导出,所以在第二个同名命名空间内是无法使用的,如果给 const numberReg 前面加上 export,就可以在第二个命名空间使用了。

命名空间分别和类、函数、枚举都可以合并,下面来一一说明:

# 命名空间和类

这里要求同名的类和命名空间在定义的时候,类的定义必须在命名空间前面,最后合并之后的效果,一个包含一些以命名空间导出内容为静态属性的类,来看例子:

class Validation {
    checkType() { }
}
namespace Validation {
    export const numberReg = /^[0-9]+$/
    export const stringReg = /^[A-Za-z]+$/
    export const checkString = () => { }
}
namespace Validation {
    export const checkNumber = (value: any) => {
        return numberReg.test(value)
    }
}
console.log(Validation.prototype) // { checkType: fun () {} }
console.log(Validation.prototype.constructor) 
/**
{
    checkNumber: ...
    checkString: ...
    numberReg: ...
    stringReg: ...
}
*/

# 命名空间和函数

在JavaScript中,函数也是对象,所以可以给一个函数设置属性,在TypeScript中,就可以通过声明合并实现。

但同样要求,函数的定义要在同名命名空间前面,再拿之前讲过的计数器的实现来看下,如何利用声明合并实现计数器的定义:

function countUp () {
    countUp.count++
}
namespace countUp {
    export let count = 0
}
  
countUp()
countUp()
console.log(countUp.count) // 2

# 命名空间和枚举

可以通过命名空间和枚举的合并,为枚举拓展内容,枚举和同名命名空间的先后顺序是没有要求的,来看例子:

enum Colors {
    red,
    green,
    blue
}
namespace Colors {
    export const yellow = 3
}
console.log(Colors)
/*
{
    0: "red",
    1: "green",
    2: "blue",
    red: 0,
    green: 1,
    blue: 2,
    yellow: 3 
}
*/

通过打印结果你可以发现,虽然使用命名空间增加了枚举的成员,但是最后输出的值只有key到index的映射,没有index到key的映射。