我入坑前端的时候,Typescript 已经成为了事实 npm 包的事实标准,很多 npm 包都带有 .d.ts
的类型,让我能享受即时的类型提示和自动补全。
但是,在自己写工程的时候,又不常会接触到 .d.ts
文件,一般都是为某个缺失的 module 手动声明一个类型,或者直接使用脚手架生成的类型文件,几乎没有其他了解。因此,在遇到一些问题时,比如「为什么 import
后就不能为全局声明类型」,就没有办法解决了。
为什么我们需要 dts
浏览器和 NodeJS 都只能运行 Javascript 代码(本质上是 V8 只认 JS),但 JS 不携带类型等元信息, d.ts
就是一种「附加元信息」,可以理解为 JS 代码的一种「特殊注释」,Typescript 的语言服务器会读取这些信息来给 IDE 提供类型提示和自动补全,而这些类型信息并不会被 runtime 读取来影响运行进程
npm 包
dts
的出现让类型声明和实际运行的代码可以分离开,使类型的引入变得无痛,很快就成为了 npm 包的事实标准
但是,早期的 npm 包都是纯 JS 编写的,不可能全部用 TS 重写一次,只能用一些类似注释的方法为其添加类型说明,为此 DefinitelyTyped 被引入了。
DefinitelyTyped 是一个专门存储类型定义的仓库,专门用于那些没有自带类型声明文件的 npm 包。
- 在 Typescript 2.0 之前,需要引入第三方 CLI 工具如 tsd 和 Typings 来手动安装类型声明,输出特定的文件或目录,配置
tsconfig.json
读取该文件 在 Typescript 2.0 之后,在 npm 上DefinitelyTyped 会把类型声明发布到
@types
这个域下,在本地 TS 语言服务器会默认读取node_modules/@types
文件夹下的类型声明,因此不再需要安装独立的包管理器就可以安装类型声明;使用tsconfig.json
可以定义类型加载行为:typeRoots
默认包含了node_modules/@types
这个目录,手动定义会覆盖此默认设置- 默认情况下,所有
@types
下的包都会被加载,但如果定义了types
字段,则只会加载指定的类型
d.ts
文件的仓库;@types 是 DefinitelyTyped 在 npm 上发包的根组织;
d.ts
是独立单位,是真正存储类型信息的底层机制,上面的都只是一种分发模式。后面许多 npm 包原生使用 TS 编写,在发布前会运行一次转译过程,将 ts
源文件编译为用于运行的 js
文件和用于提供类型信息的 d.ts
文件。
额外类型声明
对于一些本地的、需要补充类型或者添加全局类型的场景,如:
- 直接从 CDN 引入了某些资源,调用时为了避免报错手动声明其类型
- 使用 webpack loader 加载了某些非 JS/TS 的资源,需要定义这些 module 的类型
- 在全局范围内添加某些常用类型,为避免反复引用写在全局 dts 中(一般脚手架都会替我们完成这个工作)
- 在全局变量(如
window
和global
)里添加了某些成员,为了避免 TS 报错手动添加成员类型
语法简介
d.ts
文件本质上是一种特殊的 ts
文件,专注于「类型声明」这一领域,支持以下场景和用例:
- 声明局部或全局的类型(直接定义
type
或interface
) - 给已有的全局变量或模块添加类型定义
- 不能编写实际代码实现,只能提供类型定义
定义类型
类型定义部分与普通 .ts
文件表现一致,只是在模块化策略上有区别:
ts
文件都被视为模块d.ts
文件默认都是全局的,除非使用了import
和export
才会被视为模块
有两种定义类型(变量形状)的方法: type
和 interface
,它们在大部分情况下没有区别。
只有 type
定义能做的事
- 定义类型别名:
type MyType = string
- 对类型进行并集(union type)或交集运算
只有 interface
定义能做的事
- 进行 OOP 继承和组合:
implements
和extends
- 多次声明同一个
interface
以合并类型
如何选择 interface
和 type
?
无关优劣,只看语义,我的个人习惯如下:
- 当你需要直接定义一个对象的形状时,选
interface
- 当你很明确地知道自己是在玩弄类型时,选
type
declare
语法
decalre
是 Typescript 中定义类型的一种方法,一般用于给已有的 JS 变量或函数添加类型。
需要注意的是:
- d.ts 中只能使用
decalre
声明变量类型,而不能定义具体变量;但declare
可以在所有 ts 源文件中使用,不限制在 d.ts 中使用 - 声明
type
和interface
不需要使用decalre
,但是declare type
是合法的,可以用于重新声明某个类型或添加全局
常用的 decalre
语句的作用和应用场景
declare namespace Obj {}
声明一个全局命名空间- 用于传统命名空场景,即避免命名冲突,如声明接口类型等
- 声明全局变量类型(点分法类型),如 jQuery 的类型
// jquery.d.ts
declare namespace $ {
interface JQuery {
// 定义 jQuery 方法
html(content: string): this; // 设置或获取 HTML 内容
css(property: string, value?: string): this; // 设置或获取 CSS 属性
on(event: string, handler: (event: Event) => void): this; // 绑定事件处理程序
// 其他 jQuery 方法...
}
// 定义全局 jQuery 函数
function (selector: string): JQuery;
}
declare function fn(): Type
声明或重载函数类型- 声明全局函数类型
- 一种另类的函数重载方法:多次声明同名函数的类型签名
declare var foo: number
声明全局变量类型declare class MyClass {}
声明全局class
的类型- 与
class
的区别:declare class
只提供类型定义,不提供具体实现
- 与
declare module 'example-package' {}
在全局范围内补充声明某个模块的类型定义declare interface
和declare type
重新声明已有的类型或接口,表明其存在或修改定义与直接使用
type
和interface
的区别:declare
通常用于声明已有的接口或类型,直接使用往往会定义一个新的- 在 d.ts 中是否使用
declare
往往没有区别
全局与模块化
Typescript 和 Javascript 处理模块化的逻辑是一致的,没有任何顶层 export
和 import
或顶层 await
语句的代码文件都会被视为全局脚本而非模块。
d.ts
只是一种特殊的、只包含类型定义的 ts
文件,模块判定方式与普通 ts
文件一致,因此默认情况下, d.ts
都是全局的,但是出现了模块化关键字(如 import
和 exports
等)后,就会被视为模块。
关于如何为模块添加类型定义,通常有以下两种方式:
Typescript 如何寻找类型定义?
查找模块类型定义 import a from "a"
的来源,合并所有定义
- 全局模块定义:
declare module "a"
- 相对/绝对路径:依次寻找
a.ts
,a.d.ts
中的类型定义 包引用:
- 入口文件(包名 alias):查找包
package.json
中的types
定义 - 同名
@types
包下的类型声明 - 直接引用内部文件:查找对应文件的
.d.ts
文件
- 入口文件(包名 alias):查找包
- 对于模块本身不包含类型声明,需要在全局范围内添加时,应该使用
declare module
语法,在全局类型声明中添加; 对于为模块创建类型声明(包内携带类型或发布
@types
包)的场景,应该使用export
或exports
语法,创建模块化的类型声明文件;假设我们处于一个编写 npm 包的场景中,我们使用 Typescript 编写源代码,使用
tsc
将源代码转译为 js 和 d.ts 文件,根据输出的模块类型不同,我们使用不同的类型声明语法:- CommonJS 规范:d.ts 使用
export =
语法,等价于module.exports =
- ESM 规范:d.ts 使用
export
和export default
语法,与正常 TS 文件无异
- CommonJS 规范:d.ts 使用
TS 中的模块引用与 esModuleInterop
属性
import * as
)以获得最佳兼容性;对于任何 TS 项目,都应该开启 esModuleInterop
属性以无害地获得 CJS 互操作性(如果不使用 tsc 编译,请额外留意构建工具行为)导入 CJS 模块时,Typescript 提供了多种语法支持,具体由 tsconfig.json
中的 esModuleInterop
属性开关决定
关闭时:严格检验 ESM 模块规范,对于 CommonJS 模块必须使用以下语法
import a = require('a')
:NodeJS CJS 引用语法(不推荐)import * as fs from "fs"
:命名空间导出语法,将整个模块作为一个对象引入(对于 ESM 模块也使用此语法)
开启后:允许使用 ESM 格式导入 CommonJS 模块,开启后 TS 编译器会对 CJS 模块做一些预处理以支持 ESM 导入
- 默认导出模式:
import React from "react"
- 默认导出模式:
那么, esModuleInterop
做了什么工作呢?其实非常简单。
下面的例子中 tsc 的 target 都是 commonjs
- ESM 模块和 CJS 模块在引用时的区别在于是从
default
中取值还是直接从对象中取值,参考下面的代码(未开启esModuleInterop
)
// 编译前
import fs from 'fs';
import * as fsBak from 'fs';
fs.copyFile('', '', () => {});
fsBak.copyFile('', '', () => {});
// 编译后:CJS
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = require("fs");
const fsBak = require("fs");
fs_1.default.copyFile('', '', () => { });
fsBak.copyFile('', '', () => { });
问题在于 CJS 中没有
default
导出,那么就在运行时加上一段代码来手动添加即可,这就是esModuleInterop
属性开启后 tsc 完成的工作。开启后,对 CJS 的命名空间导入和默认导入行为都有改变,即将整个导出对象赋值给default
属性- 对于命名空间导入
import * as React from 'react'
,会生成__importStar
辅助函数,为 CJS 引入对象添加default
属性,ESM 对象则不做处理,具体示例如下:
// 编译前 import * as fsBak from 'fs'; fsBak.copyFile('', '', () => {}); // 编译后 "use strict"; var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function (o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function (o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); const fsBak = __importStar(require("fs")); fsBak.copyFile('', '', () => { });
- 对于默认导入
import React from 'react
,会生成__importDefault
辅助函数,将 CJS 导出整个赋值给default
属性,ESM 则不做处理,与上面的区别在于返回对象是仅包含default
属性的新对象
// 编译前 import fs from 'fs'; fs.copyFile('', '', () => {}); // 编译后 "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const fs_1 = __importDefault(require("fs")); fs_1.default.copyFile('', '', () => { });
- 对于命名空间导入
三斜杠注释(指令)
三斜线指令是包含单个XML标签的单行注释。 注释的内容会做为编译器指令使用。这些指令只有在文件顶部时才有效,作用是告诉在编译器为该文件编译时要引入的额外的文件,主要应用场景是在 d.ts (特别是全局)中声明文件依赖关系(毕竟在 ts 里我为什么不直接 import
呢)
这一预处理过程类似于 C 语言里的宏展开,在编译的输入过程中按顺序将指令引用的文件额外添加到编译过程中,使用 DFS 顺序遍历文件依赖。
下面是一些常用的三斜杠指令:
/// <reference path="..." />
最常用的指令,在编译或语法分析时将指定文件添加到上下文中/// <reference types="..." />
:与path
引用类似,不过仅用于引入@types
下的包类型,一般只在手写 d.ts 时才会用到,日常开发时可以直接修改 tsconfig 中的types
属性/// <reference no-default-lib="true"/
:我不知道,没用过
需要注意的是,只有在编译时才会展开,IDE 类型推断并不会展开。
FAQ
Q: 我想在全局类型定义中引用某个外部定义如何实现
和 /// <reference>
指令无关,不要使用顶级 import
即可不变成模块,保留全局属性,如下:
// src/interface/a.ts
interface ITest {
a: string;
}
// src/types/global.d.ts, 需要`typeRoots`包含`src/types`目录
type GlobalITest = import('src/interface/a').ITest;
// src/index.ts
const obj: GlobalITest = { a: "test" }; // 无需引入类型