# TypeScript 类型声明文件

# 声明

在 TypeScript 中安全地使用 JavaScript 的库,关键的步骤就是使用 TypeScript 中的一个 declare 关键字。通过使用 declare 关键字,我们可以声明全局的变量、方法、类、对象。

# declare 变量

在运行时,前端代码 <script> 标签会引入一个全局的库,再导入全局变量。此时,如果想安全地使用全局变量,那么就需要对变量的类型进行声明。

声明变量的语法: declare (var|let|const) 变量名称:变量类型

declare let a: string
declare const b: number
a = 'a'
a = 1 // 报错
b = 2 // 报错,const 声明不能再赋值

# declare 函数

声明函数的语法与声明变量的语法相同,不同的是 declare 关键字后需要跟 function 关键字。

/* 报错,只需声明,不需要实现
declare function toString(x: number) {
  return String
} */
// 正确的语法
declare function toString(x: number): string
const x = toString(1)

# declare 类

声明类时,也是只需要声明类的属性、方法的类型即可。

declare class Person {
  public name: string
  private age: number
  constructor(name: string)
  getAge(): number
}

const person = new Person('tom')
const myName = person.name // => string
const myAge = person.getAge() // => number
const age = person.age // 报错,属性“age”为私有属性,只能在类“Person”中访问

注意:使用 declare 关键字时,我们不需要编写声明的变量、函数、类的具体实现(因为变量、函数、类在其他库中已经实现了),只需要声明其类型即可,否则会报错。

# declare 枚举

声明枚举只需要定义枚举的类型,并不需要定义枚举的值,这其实就是枚举中的外部枚举(Ambient Enums)

declare enum Direction {
  Up,
  Down,
  Left,
  Right,
}

const direction = [Direction.Up, Direction.Down]

注意:声明枚举仅用于编译时的检查,编译完成后,声明文件中的内容在编译结果中会被删除。

例如上面的代码,编译完成后,相当于仅剩下了 const direction = [Direction.Up, Direction.Down],这里数组中的 Direction 表示引入的全局变量。

# declare 模块

ES6 之前,TypeScript 提供了通过使用 module 关键字声明一个内部模块的模块化方案,但是由于 ES6 也使用了 module 关键字,为了兼容 ES6,所以 TypeScript 使用 namespace 替代了原来的 module,并更名为命名空间。需要注意:目前,任何使用 module 关键字声明一个内部模块的地方,都应该使用 namespace 关键字进行替换。

TypeScript 与 ES6 一样,任何包含顶级 import 或 export 的文件都会被当作一个模块。我们可以通过声明模块类型,为缺少 TypeScript 类型定义的三方库或者文件补齐类型定义。

声明模块的语法:declare module '模块名' {},在模块声明内部,只需要使用 export 导出对应库的类、函数即可。

// lodash.d.ts
declare module 'lodash' {
  export function first<T extends unknown>(array: T[]): T
}

// index.ts
import { first } from 'lodash'
first([1, 2, 3]) // => number

# declare 文件

在使用 TypeScript 开发前端应用时,可以通过 import 关键字导入文件,比如先使用 import 导入图片文件,再通过 webpack 等工具处理导入的文件。但是,因为 TypeScript 并不知道通过 import 导入的文件是什么类型,所以需要使用 declare 声明导入的文件类型。

// 可以使用模块通配符 *.xxx 匹配一类文件
declare module '*.jpg' {
  const src: string
  export default src
}
declare module '*.png' {
  const src: string
  export default src
}

上面的代码标记的图片文件的默认导出类型是 string,通过 import 使用图片资源时,TypeScript 会将导入的图片识别为 string 类型,因此也就可以把 import 的图片赋值给 src 属性,因为它们的类型都是 string,是匹配的。

# declare namespace

不同于声明模块,命名空间一般用来表示具有很多子属性或者方法的全局对象变量。

一般我们可以将声明命名空间简单看作是声明一个更复杂的变量。

declare namespace $ {
  const version: number
  function ajax(settings?: any): void
}

$.version // => number
$.ajax()

在上面的例子中,因为我们声明了全局导入的 jQuery 变量 $,所以可以直接使用 $ 变量的 version 属性以及 ajax 方法。

# 声明文件

在 TypeScript 中,以 .d.ts 为后缀的文件为声明文件。在声明文件时,我们只需要定义第三方类库所暴露的 API 接口即可。

声明文件中有类型、值、命名空间 3 个核心概念:

  • 类型(以下每一个声明都创建了一个类型名称)
    • 类型别名声明;
    • 接口声明;
    • 类声明;
    • 枚举声明;
    • 导入的类型声明
  • 值(值就是在运行时表达式可以赋予的值)
    • var、let、const 声明;
    • namespace、module 包含值的声明;
    • 枚举声明;
    • 类声明;
    • 导入的值;
    • 函数声明
  • 命名空间 在命名空间中,也可以声明类型。比如 const x: A.B.C 这个声明,这里的类型 C 就是在 A.B 命名空间下的。

# 使用声明文件

安装 TypeScript 依赖后,一般我们会顺带安装一个 lib.d.ts 声明文件,这个文件包含了 JavaScript 运行时以及 DOM 中各种全局变量的声明。lib.d.ts 文件内容如下:

// typescript/lib/lib.d.ts
/// <reference no-default-lib="true"/>
/// <reference lib="es5" />
/// <reference lib="dom" />
/// <reference lib="webworker.importscripts" />
/// <reference lib="scripthost" />

其中,/// 是 TypeScript 中三斜线指令,后面的内容类似于 XML 标签的语法,用来指代引用其他的声明文件。

通过三斜线指令,可以更好地复用和拆分类型声明。no-default-lib="true" 表示这个文件是一个默认库。而最后 4 行的 lib="..." 表示引用内部的库类型声明。

# 使用 @types

Definitely Typed (opens new window) 是最流行的高质量 TypeScript 声明文件类库,正是因为有社区维护的这个声明文件类库,大大简化了 JavaScript 项目迁移 TypeScript 的难度。

目前,社区已经记录了 90% 的 JavaScript 库的类型声明,意味着如果我们想使用的库有社区维护的类型声明,那么就可以通过安装类型声明文件直接使用 JavaScript 编写的库了。

具体操作:首先,通过此链接 (opens new window)搜索你想要导入的库的类型声明,如果有社区维护的声明文件。然后,只需要安装 @types/xxx 就可以在 TypeScript 中直接使用它了。

# 类型合并

因为 Definitely Typed 是由社区人员维护的,如果原来的第三方库升级,那么 Definitely Typed 所导出的第三方库的类型定义想要升级还需要经过 PR、发布的流程,就会导致无法与原库保持完全同步。针对这个问题,在 TypeScript 中,可以通过类型合并、扩充类型定义的技巧临时解决。

# 合并接口

最简单、常见的声明合并是接口合并,需要注意的是接口的非函数成员类型必须完全一样

interface Person {
  name: string
}
interface Person {
  // name: number; 报错,类型不同
  age: number
}

// 相当于
interface Person {
  name: string
  age: number
}

对于函数成员而言,每个同名的函数声明都会被当做这个函数的重载。需要注意的是:接口内部的函数声明优先级按照顺序确定,接口之间的函数声明后面声明的接口具有更高的优先级,如果函数声明指定的参数是字面量类型,优先级最高

interface Obj {
  identity(val: any): any
}
interface Obj {
  identity(val: number): number
}
interface Obj {
  identity(val: boolean): boolean
}

// 相当于
interface Obj {
  identity(val: boolean): boolean
  identity(val: number): number
  identity(val: any): any
}

const obj: Obj = {
  identity(val: any) {
    return val
  },
}
const t1 = obj.identity(1) // => number
const t2 = obj.identity(true) // => boolean
const t3 = obj.identity('a') // => any

# 合并 namespace

合并 namespace 与合并接口类似,命名空间的合并也会合并其导出成员的属性,需要注意的是导出的成员是不能重复的。另外不同的是,非导出成员仅在原命名空间内可见。

namespace Person {
  const age = 18
  export function getAge() {
    return age
  }
}
namespace Person {
  export function getMyAge() {
    return age // TS2304: Cannot find name 'age'
  }
}

在上面的例子,同名的命名空间 Person 中,有一个非导出的属性 age,在第二个命名空间 Person 中没有 age 属性却引用了 age,所以 TypeScript 报错找不到 age。

# 合并 namespace 和 函数

同名的 namespace 和 函数 合并,命名空间中导出的成员相当于给 函数 添加属性。

function Lib() {}
namespace Lib {
  export let version = '1.0'
}

console.log(Lib.version)

# 类不可合并

定义一个类类型,相当于定义了一个类,又定义了一个类的类型。因此,对于类这个既是值又是类型的特殊对象不能合并。

# 扩充模块

除了可以通过接口和命名空间合并的方式扩展原来声明的类型外,还可以通过扩展模块或扩展全局对象来增强类型系统。

JavaScript 是一门动态类型的语言,通过 prototype 可以很容易地扩展原来的对象。

但是,如果直接扩展导入对象的原型链属性,TypeScript 会提示没有该属性的错误,所以还需要扩展原模块的属性。

// person.ts
export class Person {}

// index.ts
import { Person } from './person'

declare module './person' {
  interface Person {
    greet: () => void
  }
}
Person.prototype.greet = () => {
  console.log('Hi!')
}

类似上面的代码,对于导入的第三方模块,同样可以使用这个方法扩充原模块的属性。

# 扩充全局

全局模块指的是不需要通过 import 导入即可使用的模块,如全局的 window、document 等。

declare global {
  interface Array<T extends unknown> {
    getLen(): number
  }
}
Array.prototype.getLen = function () {
  return this.length
}

# 推荐阅读