Typescript 类型声明 All-in-one

TypeScript 的类型声明文件 .d.ts 提供了 JavaScript 代码的附加元信息,支持类型提示和自动补全。使用 DefinitelyTyped 仓库可以为没有类型声明的 npm 包提供类型支持,类型定义可通过 type 和 interface 进行,declare 语法用于为已有的 JS 变量或函数添加类型。三斜杠指令用于引入额外的文件依赖。

我入坑前端的时候,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 工具如 tsdTypings 来手动安装类型声明,输出特定的文件或目录,配置 tsconfig.json 读取该文件
  • 在 Typescript 2.0 之后,在 npm 上DefinitelyTyped 会把类型声明发布到 @types 这个域下,在本地 TS 语言服务器会默认读取 node_modules/@types 文件夹下的类型声明,因此不再需要安装独立的包管理器就可以安装类型声明;使用 tsconfig.json 可以定义类型加载行为:

    • typeRoots 默认包含了 node_modules/@types 这个目录,手动定义会覆盖此默认设置
    • 默认情况下,所有 @types 下的包都会被加载,但如果定义了 types 字段,则只会加载指定的类型
💡
DefinitelyTyped 是存放 d.ts 文件的仓库;
@types 是 DefinitelyTyped 在 npm 上发包的根组织;
d.ts 是独立单位,是真正存储类型信息的底层机制,上面的都只是一种分发模式。

后面许多 npm 包原生使用 TS 编写,在发布前会运行一次转译过程,将 ts 源文件编译为用于运行的 js 文件和用于提供类型信息的 d.ts 文件。

额外类型声明

对于一些本地的、需要补充类型或者添加全局类型的场景,如:

  • 直接从 CDN 引入了某些资源,调用时为了避免报错手动声明其类型
  • 使用 webpack loader 加载了某些非 JS/TS 的资源,需要定义这些 module 的类型
  • 在全局范围内添加某些常用类型,为避免反复引用写在全局 dts 中(一般脚手架都会替我们完成这个工作)
  • 在全局变量(如 windowglobal )里添加了某些成员,为了避免 TS 报错手动添加成员类型

语法简介

d.ts 文件本质上是一种特殊的 ts 文件,专注于「类型声明」这一领域,支持以下场景和用例:

  • 声明局部或全局的类型(直接定义 typeinterface
  • 给已有的全局变量或模块添加类型定义
  • 不能编写实际代码实现,只能提供类型定义

定义类型

💡
TS 遵循 Duck Typing 原则,即一个变量的实际类型取决于它实际拥有的属性而非其定义时的类型签名,而这些类型推断全部在 IDE 和编译时的 TS LSP 完成,与 JS 运行时没有任何关系。

类型定义部分与普通 .ts 文件表现一致,只是在模块化策略上有区别:

  • ts 文件都被视为模块
  • d.ts 文件默认都是全局的,除非使用了 importexport 才会被视为模块

有两种定义类型(变量形状)的方法: typeinterface ,它们在大部分情况下没有区别。

只有 type 定义能做的事
  • 定义类型别名: type MyType = string
  • 对类型进行并集(union type)或交集运算
只有 interface 定义能做的事
  • 进行 OOP 继承和组合: implementsextends
  • 多次声明同一个 interface 以合并类型
如何选择 interfacetype

无关优劣,只看语义,我的个人习惯如下:

  • 当你需要直接定义一个对象的形状时,选 interface
  • 当你很明确地知道自己是在玩弄类型时,选 type

declare 语法

decalre 是 Typescript 中定义类型的一种方法,一般用于给已有的 JS 变量或函数添加类型。

需要注意的是:

  • d.ts 中只能使用 decalre 声明变量类型,而不能定义具体变量;但 declare 可以在所有 ts 源文件中使用,不限制在 d.ts 中使用
  • 声明 typeinterface 不需要使用 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 interfacedeclare type 重新声明已有的类型或接口,表明其存在或修改定义

    • 与直接使用 typeinterface 的区别:

      • declare 通常用于声明已有的接口或类型,直接使用往往会定义一个新的
      • 在 d.ts 中是否使用 declare 往往没有区别

全局与模块化

Typescript 和 Javascript 处理模块化的逻辑是一致的,没有任何顶层 exportimport 或顶层 await 语句的代码文件都会被视为全局脚本而非模块

d.ts 只是一种特殊的、只包含类型定义的 ts 文件,模块判定方式与普通 ts 文件一致,因此默认情况下, d.ts 都是全局的,但是出现了模块化关键字(如 importexports 等)后,就会被视为模块

关于如何为模块添加类型定义,通常有以下两种方式:

Typescript 如何寻找类型定义?

查找模块类型定义 import a from "a" 的来源,合并所有定义

  • 全局模块定义: declare module "a"
  • 相对/绝对路径:依次寻找 a.ts , a.d.ts 中的类型定义
  • 包引用:

    • 入口文件(包名 alias):查找包 package.json 中的 types 定义
    • 同名 @types 包下的类型声明
    • 直接引用内部文件:查找对应文件的 .d.ts 文件
  • 对于模块本身不包含类型声明,需要在全局范围内添加时,应该使用 declare module 语法,在全局类型声明中添加;
  • 对于为模块创建类型声明(包内携带类型或发布 @types 包)的场景,应该使用 exportexports 语法,创建模块化的类型声明文件;

    假设我们处于一个编写 npm 包的场景中,我们使用 Typescript 编写源代码,使用 tsc 将源代码转译为 js 和 d.ts 文件,根据输出的模块类型不同,我们使用不同的类型声明语法:

    • CommonJS 规范:d.ts 使用 export = 语法,等价于 module.exports =
    • ESM 规范:d.ts 使用 exportexport default 语法,与正常 TS 文件无异
TS 中的模块引用与 esModuleInterop 属性
💡
TL;TR 对于任何 CJS 包,都应该使用命名空间导入语法( 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" }; // 无需引入类型

标题: Typescript 类型声明 All-in-one

作者: ChlorineC

创建于: 2025-01-03 19:12:00

更新于: 2025-01-05 09:03:00

版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。

Share:

Related Posts

View All Posts »

再谈模块化:Node中ESM与CJS的解析策略

本文讨论了Node.js中ESM与CJS的模块解析策略,强调了tsconfig.json中的module和moduleResolution字段对模块解析的影响。通过分析Node对两种模块标准的支持,指出了配置不匹配导致的错误,并提供了解决方案,包括将moduleResolution修改为Node16以确保正确的解析算法。最后,强调了在使用ESM时必须在导入语句中包含文件扩展名。

【译&补】使用ref回调替代useRef吧

在React中,useRef和useEffect常用于管理组件的可变值和副作用,但在响应DOM元素挂载时,使用useCallback作为ref的回调函数更为有效。useCallback确保在元素挂载和卸载时被调用,避免了因useRef导致的生命周期回调未触发的问题。文中还介绍了其他几种优化Node操作的方法,如使用useState、useStateRef和useRefWithCallback,以提高性能和管理复杂性。

你真的需要useMemo和useCallback吗

本文深入探讨了React中的useMemo和useCallback的使用场景和性能影响。useMemo用于缓存计算结果以减少不必要的计算,适合在计算开销较大的情况下使用,而useCallback用于缓存函数定义以避免不必要的组件刷新。作者强调,过度使用这两个Hook可能导致性能下降,建议在实际需要时再使用,并提出了其他优化方案,如使用useReducer和合理组织组件逻辑。

前端包管理器 - yarn 与 PnP

本文介绍了前端包管理器Yarn的不同版本,特别是Yarn v2及其PnP(Plug'n'Play)机制。Yarn v1与npm相比具有离线模式和lock文件等优点,而Yarn v2引入了monorepo支持和零依赖安装。PnP机制替代了传统的node_modules目录,通过优化依赖解析过程,提高了安装速度并解决了依赖版本冲突和幽灵依赖问题。此外,文中还讨论了Yarn的插件化设计、兼容性问题及其解决方案。