typescript 声明文件加载机制以及在不同场景下的正确使用方式

typescript 声明文件加载机制以及在不同场景下的正确使用方式

.d.ts 文件是 typescript 的声明文件,主要用来给编辑器做代码提示用,具体的书写位置和方式根据你的具体需求而定。

(嫌弃太长可以直接跳到使用方式的 1.1 和 2.3)

很多初学者(比如我)刚开始接触 ts 时,一直分不清 .ts 和 .d.ts 的区别,不知道 .d.ts 存在的意义是什么。刚开始跟着各种教程搭建好了 ts 开发环境,写好了 hello world 时,发现就算没有写 .d.ts 文件,编辑器(这里以及之后的编辑器均指宇宙第一编辑器 vscode )也可以获得代码提示,甚至就算写的不是 ts 而是 js,只要 import 的依赖关系明确,对于一些简单的函数依然能获得最基本的入参、返回值的形参提示。从而就想知道 .d.ts 文件存在的意义是什么。

其实这个问题稍微思考一下就能知道。假设我们用 ts 开发了一个 npm 库,经过编译打包之后发布到了 npm 上,其他用户下载了我们这个库,下载到他本地的一般是一个 dist/index.js ,package.json 里的 main 指向这个 dist/index.js 文件。这时候不管这个用户开发使用的是 ts 还是 js,当他 import 我们这个库的时候都无法获得代码提示。

.d.ts 文件主要是 for 第三方库,让第三方库的使用者可以获得良好的代码和接口提示 。本文主要介绍 .d.ts 文件的加载机制以及在 纯 js 开发环境中如何使用 .d.ts 声明文件,获得代码提示和接口声明。

加载机制

一些定义:

  • 三斜线指令
    • 定义: /// <reference path="xxx.d.ts"/> 或者 /// <reference types="xxx"/>
    • 特点:
      • 在 .ts 中已经不再使用,但是在 .d.ts 中还是有一定用处
      • 只能出现在文件的最开头,并且前面只能有注释或者别的三斜线指令
      • 有点类似 C++ 的 #include,但tsc 不会把 xxx 的代码插入替换到三斜线指令的位置
  • 声明文件:
    • 定义:.d.ts 后缀的文件
    • 特点:
      • 里面不允许有任何函数的实现
      • 顶层作用域里只能出现 declare import export interface 三斜线指令
  • 全局类声明文件:
    • 定义:如果一个声明文件的 顶层作用域 中没有 import && export ,那么这个声明文件就是一个全局类声明文件
    • 特点: 如果一个全局类声明文件在 ts 处理范围内, 那么全局类声明文件中的 declare 会在全局生效
  • 模块类声明文件:
    • 定义:如果一个声明文件的 顶层作用域 中有 import || export ,那么这个声明文件就是一个模块类声明文件
    • 特点:里面的 declare 不会在全局生效,需要按模块的方式导出来才能生效

一些行为:

  • ts 编译器会包含下面的所有 .d.ts 文件:
    • tsconfig.json 的 file (并集) include (差集) exclude
    • 对于 node_modules/@types 下的每个 npm 包,ts 会按照 node 解析包的那一套流程(如果有 package.json并且里面有 main 字段,将 main 字段的文件作为入口文件。如果上面流程失败,将 index.d.ts 作为入口文件。否则抛出错误)。这个入口文件里面的三斜线指令、import 所引入的其他文件,会按照 其他文件自己的规则生效


// node_modules/@types/my/index.d.ts
/// <reference path="a.d.ts"/>
import "./b"; 
import c from "./c";  
declare const index = 1; 
export default index;


// node_modules/@types/my/a.d.ts
declare const a = 1;


// node_modules/@types/my/b.d.ts
declare const b = 1;


// node_modules/@types/my/c.d.ts
declare const c = 1;
export default c;

上面的代码的效果为:

    • 全局:a、b
    • 非全局: index、c

index 文件中,由于含有 import export,所以为模块类声明文件,里面使用 declare 声明的 index 为非全局,在被使用时只有 import index from 'my'; 时才能拿到 index。而引入的 a、b 文件,这两个文件虽然是被 index 这个模块类声明文件引入的,但是这两个文件自己本身是全局类声明文件(既没有 import 也没有 export),所以这两个文件里面 declare 的变量都可以在全局访问到。而引入的 c 文件里面含有 export,所以为模块文件,里面的 c 无法在全局被访问。

FAQ | Tips | 注意事项:

1. 既然 a、b 文件为全局类声明文件,那么为什么还要在 index 中引入?

因为一般情况下 tsconfig.json 的 exclude 会加入 node_modules,所以理论上 node_modules 里面的所有文件都不会被 ts 编译。而 node_modules/@types 比较特别,里面的每个包会被作为一个模块,这个模块只会有一个入口文件(比如默认的 index.d.ts)。这个入口文件中没有引入的,都不会被 ts 处理。所以上面的代码在 index 文件中去掉【import "./b";】 之后,b 文件中的【declare const b = 1;】不会被 ts 看到(a 同理)。

2. import 和 /// <reference /> 的区别是什么?

主要还是历史遗留问题,三斜线指令出现的时候 ES6 还没出来。三斜线指令不会将一个全局文件变成模块文件,而 import 会。如果你需要一个在一个全局文件 b 里用另一个文件 c 里的变量,就可以用三斜线指令,因为用 import 会把 b 变成一个模块文件。

3. 我想在一个模块文件里导出全局变量怎么办?

这种情况只能导出一个 namespace:

export const d = 1;     
export as namespace whateverthisis; // 全局 namespace 名,whateverthisis.d 就可以访问到

(export as namespace 只能在模块文件里面使用)

4. vscode 的 ts 代码提示的缓存机制

vscode 的 ts 代码提示会缓存 node_modules/@types 下的每个 package 的入口文件地址(package.json 的 main 字段)。如果手动去改变了某个包的 main 字段,改成了另一个文件,那么在重启 ts server(vs code自带的一个东西),修改是不会生效的,入口文件依旧是以前的入口文件(但是去修改入口文件里的内容是可以生效的)

5. declare module A 和 declare module 'a' 的区别

前者已经被废弃,使用 declare namespace A代替;后者用于扩展一个已有的模块 a。全局文件下的 declare module 'xx' 会在全局环境生成一个名为 xx 的模块,并且可以在里面定义这个 xx 模块应该有的导出,一般用来添加或补充 node_modules 中的模块的声明文件。同名的 declare module 里面的导出会合并。

使用方式

常用的是下面的 1.1 2.3

1. 添加全局的代码提示(直接输入变量即可获得代码提示)

1.1 添加自定义全局变量

场景:全局注入变量,比如小程序的 Page

正确操作

//global.d.ts (简化版)
interface Opt<D> {
  data: D, 
declare function Page<D>(opt:Opt<D>):void;

1.2 添加第三方库的全局代码提示

场景:添加 jquery、lodash 等全局变量

在简单 web 页面的场景下,经常是直接新建一个 index.html,在里面用 script 标签引入 jquery lodash 的外部 CDN。然后新建一个 index.js,在 index.html 中用 script 标签引入 index.js,这样在 index.js 里是可以直接使用 $ _ 这两个变量的,但是输入 $ _ 时无法获得代码提示。 (image)

如果获得了代码提示,大概率是 ts 的缓存目录生效了:~/Library/Caches/typescript/3.8 里面可能有曾经下载过的 npm 包。不过一个正常的 ts 项目一般根目录会有 tsconfig.json ,里面的 include 里可以规定需要 ts 编译的目录,这个字段填写好了之后 ts 就不会去缓存目录里找了,除非你把缓存目录也写进去。

正确操作 npm i @types/jquery @types/lodash -D

这样会得到 node_modules/@types/jquery 和 node_modules/@types/lodash 两个目录。ts 会检查 node_modules/@types 下面的所有 .d.ts 文件,所以编辑器就获得了 jQuery $ _ 三个全局变量的代码提示。

2. 添加局部的模块代码提示

2.1 扩展挂载到全局的模块的代码提示

场景1:给 Array.prototype 加一个方法

虽然这种行为不被推荐,但是某些情况下你可能的确需要这么做。比如现在给 Array.prototype 加了一个 getSum 方法,获取数组中所有元素的和:

// somewhere_else.js
Array.prototype.getSum = function(){    
  return this.reduce((result, value) => result + value, 0); 

正确操作

// global.d.ts
interface Array<T> {   
  getSum(): T extends number ? number : void; 

场景2:给 jQuery 加一个静态方法 $.getHelloWorld

通过简单分析(opt+click)可以知道,暴露在全局的 jQuery 和 $ 本身是两个全局的 const 常量,类型是 JQueryStatic,所以只需要给这个 JQueryStatic 接口增加 getHelloWorld 方法就可以了。

正确操作

// global.d.ts
interface JQueryStatic {   
  getHelloWorld(): string; 

场景3:给 lodash 加一个静态方法 _.getHelloWorld

通过简单分析(opt+click)可以知道,暴露在全局的 _ 本身是一个 const 变量,类型为 _.LoDashStatic,但是这个 _.LoDashStatic 并没有被暴露到全局,所以需要使用 declare module 的语法来 override lodash 这个模块

正确操作

// module.d.ts (和 global.d.ts 分开,否则会使 global.d.ts 中的 declare 失去全局性)
import _ from 'lodash'; // 注意这个 import 必须写在 declare module 外部 
declare module 'lodash' {
  interface LoDashStatic {
    getHelloWorld(): string;   

2.2 扩展非全局模块,但自带声明文件的模块(esm、commonjs)

场景:node 的 fs 增加一个 getHelloWorld 方法

先通过 npm i @types/node -D 安装 node 的声明文件

正确操作

// global.d.ts
declare module 'fs' {   
  export function getHelloWorld(): string; 

2.3 扩展非全局模块,并且不自带声明文件的模块

场景1:你从 npm 上面下载了一个 ex-module 模块,但是这个作者很懒,没有提供声明文件,@types 社区也没有人提供,你想自己给 ex-module 写声明文件,便于后续开发

假设 ex-module 长这样:

// node_modules/ex-module/index.js
import _ from 'lodash';  
export function add(a, b) { return a + b; } 
export function minus(a, b) { return a - b; } 
export default function getLodash() { return _; }

正确操作

// global.d.ts
declare module 'ex-module' {   
  import { LoDashStatic } from 'lodash'; // 注意这个 import 必须写在 declare module 内部 
  export function add(): number;   
  export function minus(): number;   
  export default function getLodash(): LoDashStatic; 


// index.js
import getLodash,{ add, minus } from 'ex-module';
add(1, 130);


坑点:

  • 由于 js + ts 模块化过于混乱,各自的实现也有冲突,建议全部严格按照 ES6 的方式来写
  • 比如上面的 global.ts 中,如果删掉 export default 宇航,在 index.js 中 import ex from 'ex-module' ,输入 ex 时候, vscode 会提示 ex.add 是一个函数,但实际上 ex 是 undefined

场景2:你用 js 给自己写了一个 util 库,里面有各种各样的工具函数,想给他们加上声明文件

// util/math.js
export function add(a, b) {  
  return a + b; 
export function minus(a, b) {   
  return a - b;