TypeScript声明文件的语法与场景详解

简介

声明文件是以.d.ts为后缀的文件,开发者在声明文件中编写类型声明,TypeScript根据声明文件的内容进行类型检查。(注意同目录下最好不要有同名的.ts文件和.d.ts,例如lib.ts和lib.d.ts,否则模块系统无法只根据文件名加载模块)

为什么需要声明文件呢?我们知道TypeScript根据类型声明进行类型检查,但有些情况可能没有类型声明:

  • 第三方包,因为第三方包打包后都是JavaScript语法,而非TypeScript,没有类型。
  • 宿主环境扩展,如一些hybrid环境,在window变量下有一些bridge接口,这些接口没有类型声明。

如果没有类型声明,在使用变量、调用函数、实例化类的时候就没法通过TypeScript的类型检查。

声明文件就是针对这些情况,开发者在声明文件中编写第三方模块的类型声明/宿主环境的类型声明。让TypeScript可以正常地进行类型检查。

除此之外,声明文件也可以被导入,使用其中暴露的类型定义。

总之,声明文件有两种用法:

  • 被通过import导入,使用其中暴露的类型定义和变量声明。
  • 和相关模块关联,为模块进行类型声明。

对于第二种用法,声明文件如何同相关模块关联呢?

比如有个第三方包名字叫"foo",那么TypeScript会在node_modules/foo中根据其package.json的types和typing字段查找声明文件查找到的声明文件被作为该模块的声明文件;TypeScript也会在node_modules/@types/foo/目录中查找声明文件,如果能找到就被作为foo模块的声明文件;TypeScript还会在我们的项目中查找.d.ts文件,如果遇到declare module 'foo'语句,则该声明被用作foo模块的声明。

总结一下,TypeScript会在特定的目录读取指定的声明文件。

  • 在内部项目中,TypeScript会读取tsconfig.json中的文件集合,在其中的声明文件才会被处理。
  • 读取node_modules中各第三方包的package.json的types或者typing指定的文件。
  • 读取@types目录下同名包的声明文件。

声明文件中的代码不会出现在最终的编译结果中,编译后会把转换后的JavaScript代码输出到"outDir"选项指定的目录中,并且把 .ts模块中使用到的值的声明都输出到"declarationDir"指定的目录中。

而在.ts文件中的声明语句,编译后会被去掉,如

declare let a: number;

export default a;

会被编译为

"use strict";
exports.__esModule = true;
exports["default"] = a;

TypeScript编译过程不仅将TypeScript语法转译为ES6/ES5,还会将代码中.ts文件中用到的值的类型输出到指定的声明文件中。如果你需要实现一个库项目,这个功能很有用,因为用到你的库的项目可以直接使用这些声明文件,而不需要你再为你的库写声明文件。

语法

内容

TypeScript中的声明会创建以下三种实体之一:命名空间,类型或值。

命名空间最终被编译为全局变量,因此我们也可以认为声明文件中其实创建了类型和值两种实体。即定义类型或者声明值。

// 类型 接口
interface Person {name: string;}

// 类型 类型别名
type Fruit = {size: number};

// 值 变量
declare let a: number;

// 值 函数
declare function log(message: string): void;

// 值 类
declare class Person {name: string;}

// 值 枚举
declare enum Color {Red, Green}

// 值 命名空间
declare namespace person {let name: string;}

我们注意到类型可以直接定义,但是值的声明需要借助declare关键字,这是因为如果不用declare关键字,值的声明和初始化是一起的,如

let a: number;

// 编译为
var a;

但是编译结果是会去掉所有的声明语句,保留初始化的部分,而声明文件中的内容只是起声明作用,因此需要通过declare来标识,这只是声明语句,编译时候直接去掉即可。

TypeScript也约束声明文件中声明一个值必须要用declare,否则会被认为存在初始化的内容,从而报错。

// foo.d.ts
let a: number = 1; // error TS1039: Initializers are not allowed in ambient contexts.

declare也允许出现在.ts文件中,但一般不会这么做,.ts文件中直接用let/const/function/class就可以声明并初始化一个变量。并且.ts文件编译后也会去掉declare的语句,所以不需要declare语句。

注意,declare多个同名的变量是会冲突的

declare let foo: number; // error TS2451: Cannot redeclare block-scoped variable 'a'.

declare let foo: number; // error TS2451: Cannot redeclare block-scoped variable 'a'.

除了使用declare声明一个值,declare还可以用来声明一个模块和全局的插件,这两种用法都是在特定场景用来给第三方包做声明。

declare module用来给一个第三方模进行类型声明,比如有一个第三方包foo,没有类型声明。我们可以在我们项目中实现一个声明文件来让TypeScript可以识别模块类型:foo.d.ts

// foo.d.ts
declare module 'foo' {
    export let size: number;
}

然后我们就可以使用了:

import foo from 'foo';

console.log(foo.size);

declare module除了可以用来给一个模块声明类型,还可以用来实现模块插件的声明。后面小节中会做介绍。

declare global用来给扩展全局的第三方包进行声明,后面小节介绍。

模块化

模块语法

声明文件的模块化语法和.ts模块的类似,在一些细节上稍有不同。.ts导出的是模块(typescript会根据导出的模块判断类型),.d.ts导出的是类型的定义和声明的值。

声明文件可以导出类型,也可以导出值的声明

// index.d.ts

// 导出值声明
export let a: number;

// 导出类型
export interface Person {
    name: string;
};

声明文件可以引入其他的声明文件,甚至可以引入其他的.ts文件(因为.ts文件也可能导出类型)

// Person.d.ts
export default interface Person {name: string}

// index.d.ts
import Person from './person';

export let p: Person;

如果声明文件不导出,默认是全局可以访问的

// person.d.ts
interface Person {name: string}
declare let p: Person;

// index.ts
let p1: Person = {name: 'Sam'};
console.log(p);

如果使用模块导出语法(ESM/CommJS/UMD),则不解析为全局(当然UMD还是可以全局访问)。

// ESM

interface Person {name: string}

export let p: Person;

export default Person;
// CommonJS
interface Person {name: string}

declare let p: Person;

export = p;
// UMD
interface Person {name: string}

declare let p: Person;

export = p;
export as namespace p;

注意:UMD包export as namespace语法只能在声明文件中出现。

三斜线指令

声明文件中的三斜线指令,用于控制编译过程。

三斜线指令仅可放在包含它的文件的最顶端。

如果指定--noResove编译选项,预编译过程会忽略三斜线指令。

reference

reference指令用来表明声明文件的依赖情况。

/// <reference path="..." />用来告诉编译器依赖的其他声明文件。编译器预处理时候会将path指定的声明文件加入进来。路径是相对于文件自身的。引用不存在的文件或者引用自身,会报错。

/// <reference types="node" />用来告诉编译器它依赖node_modules/@types/node/index.d.ts。如果你的项目里面依赖了@types中的某些声明文件,那么编译后输出的声明文件中会自动加上这个指令,用以说明你的项目中的声明文件依赖了@types中相关的声明文件。

/// <reference no-default-lib="true"/>,

这涉及两个编译选项,--noLib,设置了这个编译选项后,编译器会忽略默认库,默认库是在安装TypeScript时候自动引入的,这个文件包含 JavaScript 运行时(如window)以及 DOM 中存在各种常见的环境声明。但是如果你的项目运行环境和基于标准浏览器运行时环境有很大不同,可能需要排除默认库,一旦你排除了默认的 lib.d.ts 文件,你就可以在编译上下文中包含一个命名相似的文件,TypeScript 将提取该文件进行类型检查。

另一个编译选项是--skipDefaultLibCheck这个选项会让编译器忽略包含了/// <reference no-default-lib="true"/>指令的声明文件。你会注意到在默认库的顶端都会有这个三斜线指令,因此如果采用了--skipDefaultLibCheck编译选项,也同样会忽略默认库。

amd-module

amd-module相关指令用于控制打包到amd模块的编译过程

///<amd-module name='NamedModule'/>这个指令用于告诉编译器给打包为AMD的模块传入模块名(默认情况是匿名的)

///<amd-module name='NamedModule'/>
export class C {
}

编译结果为

define("NamedModule", ["require", "exports"], function (require, exports) {
    var C = (function () {
        function C() {
        }
        return C;
    })();
    exports.C = C;
});

场景

这里我们将自己的项目代码称为“内部项目”,引入的第三方模块,包括npm引入的和script引入的,称为“外部模块”。

1. 在内部项目中给内部项目写声明文件

自己项目中,给自己的模块写声明文件,例如多个模块共享的类型,就可以写一个声明文件。这种场景通常不必要,一般是某个.ts文件导出声明,其他模块引用声明。

2. 给第三方包写声明文件

给第三方包写声明文件又分为在内部项目中给第三方包写声明文件和在外部模块中给外部模块写声明文件。

在内部项目中给第三方包写声明文件: 如果第三方包没有TS声明文件,则为了保证使用第三方包时候能够通过类型检查,也为了安全地使用第三方包,需要在内部项目中写第三方包的声明文件。

在外部模块中给外部模块写声明文件: 如果你是第三方库的作者,无论你是否使用TypeScript开发库,都应该提供声明文件以便用TypeScript开发的项目能够更好地使用你的库,那么你就需要写好你的声明文件。

这两种情况的声明文件的语法类似,只在个别声明语法和文件的处理上有区别:

  • 内部项目给第三方包写声明文件时候,以.d.ts命名即可,然后在tsconfig.json中的files和include中配置能够包含到文件即可,外部模块的声明文件需要打包到输出目录,并且在package.json中的type字段指定声明文件位置;或者上传到@types/<moduleName>中,使用者通过npm install @types/<moduleName>安装声明文件。redux就在tsconfig.json中指定了declarationDir为./types,TypeScript会将项目的声明都打包到这个目录下,目录结构和源码一样,然后redux源码入口处导出了所有的模块,因此types目录下也有一个入口的声明文件index.d.ts,并且包含了所有的导出模块声明,redux在package.json中指定types字段(或者typings字段)为入口的声明文件:./types/index.d.ts。这样就实现了自动生成接口的声明文件。
  • 内部项目给第三方写声明文件时候,如果是通过npm模块引入方式,如import moduleName from 'path';则需要通过declare module '<moduleName>'语法来声明模块。而外部模块的声明文件都是正常的类型导出语法(如export default export =等),如果声明文件在@types中,会将与模块同名的声明文件作为模块的类型声明;如果声明文件在第三方包中,那么就TypeScript模块就将它作为这个第三方包模块的模块声明,当使用者导入并使用这个模块时候,TypeScript就根据相应地声明文件进行类型提示和类型检查。

根据第三方包类型可以分成几种

全局变量的第三方库

我们知道如果不使用模块导出语法,声明文件默认的声明都是全局的。

declare namespace person {
    let name: string
}

或者

interface Person {
    name: string;
}

declare let person: Person;

使用:

console.log(person.name);

修改全局变量的模块的第三方库的声明

如果有第三方包修改了一个全局模块(这个第三方包是这个全局模块的插件),这个第三方包的声明文件根据全局模块的声明,有不同的声明方式

如果全局模块使用命名空间声明

declare namespace person {
    let name: string
}

根据命名空间的声明合并原理,插件模块可以这样声明

declare namespace person {
  	// 扩展了age属性
    let age: number;
}

如果全局模块使用全局变量声明

interface Person {
    name: string;
}

declare let person: Person;

根据接口的声明合并原理,插件模块可以这样声明

interface Person {
  	// 扩展了age属性
    age: number;
}

上面的全局模块的插件模块的声明方式可以应用于下面的场景:

  • 内部项目使用了插件,但插件没有声明文件,我们可以在内部项目中自己实现声明文件。
  • 给插件模块写声明文件并发布到@types。

如果是插件模块的作者,希望在项目中引用全局模块并且将扩展的类型输出到声明文件,以便其他项目使用。可以这样实现

// plugin/index.ts

// 注意这样声明才会让TypeScript将类型输出声明文件
declare global {
  	// 假设全局模块使用全局变量的方式声明
    interface Person {
        age: number
    }
}

console.log(person.age);

export {};

注意,declare global写在声明文件中也可以,但是要在尾部加上export {}或者其他的模块导出语句,否则会报错。另外declare global在声明文件中写的话,编译后不会输出到声明文件中。

修改window

window的类型是interface Window {...},在默认库中声明,如果要扩展window变量(如一些hybrid环境)可以这样实现

// window.d.ts

// 声明合并	
interface Window {
		bridge: {log(): void} 
}

// 或者
declare global {
    interface Window {
        bridge: {log(): void} 
    }
}

或者

// index.ts

declare global {
    interface Window {
        bridge: {log(): void} 
    }
}

window.bridge = {log() {}}

export {};

ESM和CommonJS

给第三方的ESM或者CommonJS模块写声明文件,使用ESM导出或者CommonJS模块语法导出都可以,不管第三方包是哪种模块形式。

看下面示例

interface Person {
    name: string;
}

declare let person: Person;
 
export = person;
// 也可以使用export default person;
import person from 'person';

console.log(person.name);

上面的声明文件是放在node_modules/@types/person/index.d.ts中,或者放在node_modules/person/package.json的types或者typings字段指定的位置。

如果在自己项目中声明,应该使用declare module实现

declare module 'person' {
    export let name: string;
}

UMD

UMD模块,在CommonJS声明的基础上加上export as namespace ModuleName;语句即可。

看下面的ESM的例子

// node_modules/@types/person/index.d.ts
interface Person {
    name: string;
}

declare let person: Person;

export default person;

export as namespace person;

可以通过import导入来访问

// src/index.ts
import person from 'person';

console.log(person.name);

也可以通过全局访问

// src/index.ts

// 注意如果用ESM导出,全局使用时候先访问defalut属性。
console.log(person.default.name);

下面是CommonJS的例子

// node_modules/@types/person/index.d.ts

interface Person {
    name: string;
}

declare let person: Person;

export default person;

export as namespace person;

可以通过import引入访问

// src/index.ts

import person from 'person';

console.log(person.name);

也可以全局访问

// src/index.ts

console.log(person.name);

模块插件

上面我们提到,declare module不仅可以用于给一个第三方模块声明类型,还可以用来给第三方模块的插件模块声明类型。

// types/moment-plugin/index.d.ts

// 如果moment定义为UMD,就不需要引入,直接能够使用
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();

比如作为redux的插件的redux-thunk的声明文件extend-redux.d.ts,就是这样声明的

// node_modules/redux-thunk/extend-redux.d.ts

declare module 'redux' {
		// declaration code......
}

总结

到此这篇关于TypeScript声明文件的语法与场景详解的文章就介绍到这了,更多相关TS声明文件内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!

若文章对您有帮助,帮忙点个赞!

0
-5
发布时间 2022-05-14 18:00:26
0 条回复(回复会通过微信通知作者)
点击加载更多评论
登录 后再进行评论
(微信扫码即可登录,无需注册)