20.工程层面的类型能力:类型声明、类型指令与命名空间

10/9/2023

我们已经结束了 TypeScript 类型能力的学习,这一节将进入 TypeScript 的实战应用篇。实战篇主要包括了工程能力、框架集成、ECMAScript 语法、TSConfig 解析以及 Node API 开发这五个部分。

在这一节,我们主要介绍 TypeScript 的工程能力基础,包括类型指令、类型声明、命名空间这么几个部分。这些概念不仅可以帮助你了解到 TypeScript 工程能力的核心理念,也是接下来实战篇内容的前置基础。

要开始学习工程能力,其实我们可以从一个很简单的场景开始。如果你已经有一定 TypeScript 的使用经验,那你很有可能遇到过这么一个场景:出现了莫名其妙的类型报错,但你又不知道从何入手解决,想让 TypeScript 直接忽略掉这一行出错的代码?此时,类型指令就是你最需要的工具。

本节代码见:Declaration (opens new window)

# 类型检查指令

在前端世界的许多工具中,其实都提供了 行内注释(Inline Comments) 的能力,用于支持在某一处特定代码使用特殊的配置来覆盖掉全局配置。最常见的即是 ESLint 与 Prettier 提供的禁用检查能力,如 /* eslint-disable-next-lint */<!-- prettier-ignore --> 等。TypeScript 中同样提供了数个行内注释(这里我们称为类型指令),来进行单行代码或单文件级别的配置能力。这些指令均以 // @ts- 开头 ,我们依次来介绍。

# ts-ignore 与 ts-expect-error

ts-ignore 应该是使用最为广泛的一个类型指令了,它的作用就是直接禁用掉对下一行代码的类型检查:

// @ts-ignore
const name: string = 599;
1
2

基本上所有的类型报错都可以通过这个指令来解决,但由于它本质是上 ignore 而不是 disable,也就意味着如果下一行代码并没有问题,那使用 ignore 反而就是一个错误了。因此 TypeScript 随后又引入了一个更严格版本的 ignore,即 ts-expect-error,它只有在下一行代码真的存在错误时才能被使用,否则它会给出一个错误:

// @ts-expect-error
const name: string = 599;

// @ts-expect-error 错误使用此指令,报错
const age: number = 599;
1
2
3
4
5

在这里第二个 expect-error 指令会给出一个报错:无意义的 expect-error 指令

那这两个功能相同的指令应该如何取舍?我的建议是在所有地方都不要使用 ts-ignore,直接把这个指令打入冷宫封存起来。原因在上面我们也说了,对于这类 ignore 指令,本来就应当确保下一行真的存在错误时才去使用。

这两个指令只能对单行代码生效,但如果我们有非常多的类型报错要处理(比如正在将一个 JavaScript 文件迁移到 TypeScript),难道要一个个为所有报错的地方都添加上禁用检查指令?当然不,正如 ESLint 中可以使用 /* eslint-disable-next-line */ 禁用下一行代码检查,也可以使用 /* eslint-disable */ 禁用整个文件检查一样, TypeScript 中也提供了对整个文件生效的类型指令:ts-checkts-nocheck

# ts-check 与 ts-nocheck

我们首先来看 ts-nocheck ,你可以把它理解为一个作用于整个文件的 ignore 指令,使用了 ts-nocheck 指令的 TS 文件将不再接受类型检查:

// @ts-nocheck 以下代码均不会抛出错误
const name: string = 599;
const age: number = 'linbudu';
1
2
3

那么 ts-check 呢?这看起来是一个多余的指令,因为默认情况下 TS 文件不是就会被检查吗?实际上,这两个指令还可以用在 JS 文件中。要明白这一点,首先我们要知道,TypeScript 并不是只能检查 TS 文件,对于 JS 文件它也可以通过类型推导与 JSDoc 的方式进行不完全的类型检查。

// JavaScript 文件
let myAge = 18;

// 使用 JSDoc 标注变量类型
/** @type {string} */
let myName;

class Foo {
  prop = 599;
}
1
2
3
4
5
6
7
8
9
10

在上面的代码中,声明了初始值的 myAge 与 Foo.prop 都能被推导出其类型,而无初始值的 myName 也可以通过 JSDoc 标注的方式来显式地标注类型。

但我们知道 JavaScript 是弱类型语言,表现之一即是变量可以被赋值为与初始值类型不一致的值,比如上面的例子进一步改写:

let myAge = 18;
myAge = "90"; // 与初始值类型不同

/** @type {string} */
let myName;
myName = 599; // 与 JSDoc 标注类型不同
1
2
3
4
5
6

我们的赋值操作在类型层面显然是不成立的,但我们是在 JavaScript 文件中,因此这里并不会有类型报错。如果希望在 JS 文件中也能享受到类型检查,此时 ts-check 指令就可以登场了:

// @ts-check
/** @type {string} */
const myName = 599; // 报错!

let myAge = 18;
myAge = '200'; // 报错!
1
2
3
4
5
6

这里我们的 ts-check 指令为 JavaScript 文件也带来了类型检查,而我们同时还可以使用 ts-expect-error 指令来忽略掉单行的代码检查:

// @ts-check
/** @type {string} */
// @ts-expect-error
const myName = 599; // OK

let myAge = 18;
// @ts-expect-error
myAge = '200'; // OK
1
2
3
4
5
6
7
8

ts-nocheck 在 JS 文件中的作用和 TS 文件其实也一致,即禁用掉对当前文件的检查。如果我们希望开启对所有 JavaScript 文件的检查,只是忽略掉其中少数呢?此时我们在 TSConfig 中启用 checkJs 配置,来开启对所有包含的 JS 文件的类型检查,然后使用 ts-nocheck 来忽略掉其中少数的 JS 文件。

除了类型指令以外,在实际项目开发中还有一个你会经常打交道的概念:类型声明。

# 类型声明

在此前我们其实就已经接触到了类型声明,它实际上就是 declare 语法:

declare var f1: () => void;

declare interface Foo {
  prop: string;
}

declare function foo(input: Foo): Foo;

declare class Foo {}
1
2
3
4
5
6
7
8
9

我们可以直接访问这些声明:

declare let otherProp: Foo['prop'];
1

但不能为这些声明变量赋值:

// × 不允许在环境上下文中使用初始值
declare let result = foo();

// √ Foo
declare let result: ReturnType<typeof foo>;
1
2
3
4
5

这些类型声明就像我们在 TypeScript 中的类型标注一样,会存放着特定的类型信息,同时由于它们并不具有实际逻辑,我们可以很方便地使用类型声明来进行类型兼容性的比较、工具类型的声明与测试等等。

除了手动书写这些声明文件,更常见的情况是你的 TypeScript 代码在编译后生成声明文件:

// 源代码
const handler = (input: string): boolean => {
  return input.length > 5;
}

interface Foo {
  name: string;
  age: number;
}

const foo: Foo = {
  name: "林不渡",
  age: 18
}

class FooCls {
  prop!: string;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

这段代码在编译后会生成一个 .js 文件和一个 .d.ts 文件,而后者即是类型声明文件:

// 生成的类型定义
declare const handler: (input: string) => boolean;

interface Foo {
    name: string;
    age: number;
}

declare const foo: Foo;

declare class FooCls {
    prop: string;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

这样一来,如果别的文件或是别的项目导入了这段代码,它们就能够从这些类型声明获得对应部分的类型,这也是类型声明的核心作用:将类型独立于 .js 文件进行存储。别人在使用你的代码时,就能够获得这些额外的类型信息。同时,如果你在使用别人没有携带类型声明的 .js 文件,也可以通过类型声明进行类型补全,我们在后面还会了解更多。

接下来,我们要学习如何通过 TypeScript 类型声明的能力,让项目中的类型覆盖更加完整。

# 让类型定义全面覆盖你的项目

在开始学习下面的内容前,不妨先想想你是否遇到过这么几个场景?

  • 想要使用一个 npm 包,但它发布的时间太早,根本没有携带类型定义,于是你的项目里就出现了这么一处没有被类型覆盖的地方。
  • 你想要在代码里导入一些非代码文件,反正 Webpack 会帮你处理,但是可恶的 TS 又报错了?
  • 这个项目在运行时动态注入了一些全局变量(如 window.errorReporter),你想要在代码里直接这样访问,却发现类型又报错了...

这些问题都可以通过类型声明来解决,这也是它的核心能力:通过额外的类型声明文件,在核心代码文件以外去提供对类型的进一步补全。类型声明文件,即 .d.ts 结尾的文件,它会自动地被 TS 加载到环境中,实现对应部分代码的类型补全。

声明文件中并不包含实际的代码逻辑,它做的事只有一件:为 TypeScript 类型检查与推导提供额外的类型信息,而使用的语法仍然是 TypeScript 的 declare 关键字,只不过现在我们要进一步学习其它打开方式了。

要详细学习声明文件与 declare 关键字,我们不妨先来看看如何解决上面的问题。首先是无类型定义的 npm 包,我们可以通过 declare module 的方式来提供其类型:

import foo from 'pkg';

const res = foo.handler();
1
2
3

这里的 pkg 是一个没有类型定义的 npm 包(实际并不存在),我们来看如何为它添加类型提示。

declare module 'pkg' {
  const handler: () => boolean;
}
1
2
3

现在我们的 res 就具有了 boolean 类型!declare module 'pkg' 会为默认导入 foo 添加一个具有 handler 的类型,虽然这里的 pkg 根本不存在。我们也可以在 declare module 中使用默认导出:

declare module 'pkg2' {
  const handler: () => boolean;
  export default handler;
}

import bar from 'pkg2';

bar();
1
2
3
4
5
6
7
8

'pkg' 的类型声明中,你也可以使用 export const ,效果是一致的,但由于对 'pkg2' 我们使用了默认导入,因此必须要有一个 export default

除了为缺失类型的模块声明类型以外,使用类型声明我们还可以为非代码文件,如图片、CSS文件等声明类型。

对于非代码文件,比如说 markdown 文件,假设我们希望导入一个 .md 文件,由于其本质和 npm 包一样是一条导入语句,因此我们可以类似地使用 declare module 语法:

// index.ts
import raw from './note.md';

const content = raw.replace('NOTE', `NOTE${new Date().getDay()}`);

// declare.d.ts
declare module '*.md' {
  const raw: string;
  export default raw;
}
1
2
3
4
5
6
7
8
9
10

对于非代码文件的导入,更常见的其实是 .css.module.css.png 这一类,但基本语法都相似,我们在后面还会见到更多。

总结一下,declare module 通常用于为没有提供类型定义的库进行类型的补全,以及为非代码文件提供基本类型定义。但在实际使用中,如果一个库没有内置类型定义,TypeScript 也会提示你,是否要安装 @types/xxx 这样的包。那这个包又是什么?

# DefinitelyTyped

简单来说,@types/ 开头的这一类 npm 包均属于 DefinitelyTyped (opens new window) ,它是 TypeScript 维护的,专用于为社区存在的无类型定义的 JavaScript 库添加类型支持,常见的有 @types/react @types/lodash 等等。通过 DefinitelyTyped 来提供类型定义的包常见的有几种情况,如 Lodash 这样的库仍然有大量 JavaScript 项目使用,将类型定义内置在里面不一定是所有人都需要的,反而会影响包的体积。还有像 React 这种不是用纯 JavaScript / TypeScript 书写的库,需要自己来手写类型声明(React 是用 Flow 写的,这是一门同样为 JavaScript 添加类型的语言,或者说语法)。

举例来说,只要你安装了 @types/react,TypeScript 会自动将其加载到环境中(实际上所有 @types/ 下的包都会自动被加载),并作为 react 模块内部 API 的声明。但这些类型定义并不一定都是通过 declare module,我们下面介绍的命名空间 namespace 其实也可以实现一样的能力。

先来看 @types/node 中与 @types/react 中分别是如何进行类型声明的:

// @types/node
declare module 'fs' { 
    export function readFileSync(/** 省略 */): Buffer;
}

// @types/react
declare namespace React {
    function useState<S>(): [S, Dispatch<SetStateAction<S>>];
}
1
2
3
4
5
6
7
8
9

可以看到,@types/node 中仍然使用 declare module 的方式为 fs 这个内置模块声明了类型,而 @types/react 则使用的是我们没见过的 declare namespace 。别担心,我们会在后面详细介绍。

回到上面的最后一个问题,如果第三方库并不是通过导出来使用,而是直接在全局注入了变量,如 CDN 引入与某些监控埋点 SDK 的引入,我们需要通过 window.xxx 的方式访问,而类型声明很显然并不存在。此时我们仍然可以通过类型声明,但不再是通过 declare module 了。

# 扩展已有的类型定义

对全局变量的声明,还是以 window 为例,实际上我们如果 Ctrl + 点击代码中的 window,会发现它已经有类型声明了:

declare var window: Window & typeof globalThis;

interface Window {
  // ...
}
1
2
3
4
5

这行代码来自于 lib.dom.d.ts 文件,它定义了对浏览器文档对象模型的类型声明,这就是 TypeScript 提供的内置类型,也是“出厂自带”的类型检查能力的依据。类似的,还有内置的 lib.es2021.d.ts 这种文件定义了对 ECMAScript 每个版本的类型声明新增或改动等等。

我们要做的,实际上就是在内置类型声明的基础之上,再新增一部分属性。而别忘了,在 JavaScript 中当你访问全局变量时,是可以直接忽略 window 的:

onerror = () => {};
1

反过来,在类型声明中,如果我们直接声明一个变量,那就相当于将它声明在了全局空间中:

// 类型声明
declare const errorReporter: (err: any) => void;

// 实际使用
errorReporter("err!");
1
2
3
4
5

而如果我们就是想将它显式的添加到已有的 Window 接口中呢?在接口一节中我们其实已经了解到,如果你有多个同名接口,那么这些接口实际上是会被合并的,这一特性在类型声明中也是如此。因此,我们再声明一个 Window 接口即可:

interface Window {
  userTracker: (...args: any[]) => Promise<void>;
}

window.userTracker("click!")
1
2
3
4
5

类似的,我们也可以扩展来自 @types/ 包的类型定义:

declare module 'fs' {
  export function bump(): void;
}

import { bump } from 'fs';
1
2
3
4
5

总结一下这两个部分,TypeScript 通过 DefinitelyTyped ,也就是 @types/ 系列的 npm 包来为无类型定义的 JavaScript npm 包提供类型支持,这些类型定义 的 npm 包内部其实就是数个 .d.ts 这样的声明文件。

而这些声明文件主要通过 declare / namespace 的语法进行类型的描述,我们可以通过项目内额外的声明文件,来实现为非代码文件的导入,或者是全局变量添加上类型声明。而对于多个类型声明文件,如果我们想复用某一个已定义的类型呢?此时三斜线指令就该登场了。

# 三斜线指令

三斜线指令就像是声明文件中的导入语句一样,它的作用就是声明当前的文件依赖的其他类型声明。而这里的“其他类型声明”包括了 TS 内置类型声明(lib.d.ts)、三方库的类型声明以及你自己提供的类型声明文件等。

三斜线指令本质上就是一个自闭合的 XML 标签,其语法大致如下:

/// <reference path="./other.d.ts" />
/// <reference types="node" />
/// <reference lib="dom" />
1
2
3

需要注意的是,三斜线指令必须被放置在文件的顶部才能生效

这里的三条指令作用其实都是声明当前文件依赖的外部类型声明,只不过使用的方式不同:分别使用了 path、types、lib 这三个不同属性,我们来依次解析。

使用 path 的 reference 指令,其 path 属性的值为一个相对路径,指向你项目内的其他声明文件。而在编译时,TS 会沿着 path 指定的路径不断深入寻找,最深的那个没有其他依赖的声明文件会被最先加载。

// @types/node 中的示例
/// <reference path="fs.d.ts" />
1
2

使用 types 的 reference 指令,其 types 的值是一个包名,也就是你想引入的 @types/ 声明,如上面的例子中我们实际上是在声明当前文件对 @types/node 的依赖。而如果你的代码文件(.ts)中声明了对某一个包的类型导入,那么在编译产生的声明文件(.d.ts)中会自动包含引用它的指令。

/// <reference types="node" />
1

使用 lib 的 reference 指令类似于 types,只不过这里 lib 导入的是 TypeScript 内置的类型声明,如下面的例子我们声明了对 lib.dom.d.ts 的依赖:

// vite/client.d.ts
/// <reference lib="dom" />
1
2

而如果我们使用 /// <reference lib="esnext.promise" />,那么将依赖的就是 lib.esnext.promise.d.ts 文件。

这三种指令的目的都是引入当前文件所依赖的其他类型声明,只不过适用场景不同而已。

如果说三斜线指令的作用就像导入语句一样,那么命名空间(namespace)就像一个模块文件一样,将一组强相关的逻辑收拢到一个命名空间内部。

# 命名空间

假设一个场景,我们的项目里需要接入多个平台的支付 SDK,最开始只有微信支付和支付宝:

class WeChatPaySDK {}

class ALiPaySDK {}
1
2
3

然后又多了美团支付、虚拟货币支付(比如 Q 币)、信用卡支付等等:

class WeChatPaySDK {}

class ALiPaySDK {}

class MeiTuanPaySDK {}

class CreditCardPaySDK {}

class QQCoinPaySDK {}
1
2
3
4
5
6
7
8
9

随着业务的不断发展,项目中可能需要引入越来越多的支付 SDK,甚至还有比特币和以太坊,此时将这些所有的支付都放在一个文件内未免过于杂乱了。这些支付方式其实大致可以分成两种:现实货币与虚拟货币。此时我们就可以使用命名空间来区分这两类 SDK:

export namespace RealCurrency {
  export class WeChatPaySDK {}

  export class ALiPaySDK {}

  export class MeiTuanPaySDK {}

  export class CreditCardPaySDK {}
}

export namespace VirtualCurrency {
  export class QQCoinPaySDK {}

  export class BitCoinPaySDK {}

  export class ETHPaySDK {}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

注意,这里的代码是在 .ts 文件中的,此时它是具有实际逻辑意义的,也不能和类型混作一谈。

而命名空间的使用类似于枚举:

const weChatPaySDK = new RealCurrency.WeChatPaySDK();
1

唯一需要注意的是,命名空间内部实际上就像是一个独立的代码文件,因此其中的变量需要导出以后,才能通过 RealCurrency.WeChatPaySDK 这样的形式访问。

如果你开始学习前端的时间较早,一定会觉得命名空间的编译产物很眼熟——它就像是上古时期里使用的伪模块化方案:

export var RealCurrency;
(function (RealCurrency) {
    class WeChatPaySDK {
    }
    RealCurrency.WeChatPaySDK = WeChatPaySDK;
    class ALiPaySDK {
    }
    RealCurrency.ALiPaySDK = ALiPaySDK;
    class MeiTuanPaySDK {
    }
    RealCurrency.MeiTuanPaySDK = MeiTuanPaySDK;
    class CreditCardPaySDK {
    }
    RealCurrency.CreditCardPaySDK = CreditCardPaySDK;
})(RealCurrency || (RealCurrency = {}));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

实际上,命名空间的作用也正是实现简单的模块化功能,在 TypeScript 中引入它时(1.5 版本 (opens new window)),前端的模块化方案还处于混沌时期。

命名空间的内部还可以再嵌套命名空间,比如在虚拟货币中再新增区块链货币一类,此时嵌套的命名空间也需要被导出:

export namespace VirtualCurrency {
  export class QQCoinPaySDK {}

  export namespace BlockChainCurrency {
    export class BitCoinPaySDK {}

    export class ETHPaySDK {}
  }
}

const ethPaySDK = new VirtualCurrency.BlockChainCurrency.ETHPaySDK();
1
2
3
4
5
6
7
8
9
10
11

类似于类型声明中的同名接口合并,命名空间也可以进行合并,但需要通过三斜线指令来声明导入。

// animal.ts
namespace Animal {
  export namespace ProtectedAnimals {}
}

// dog.ts
/// <reference path="animal.ts" />
namespace Animal {
  export namespace Dog {
    export function bark() {}
  }
}

// corgi.ts
/// <reference path="dog.ts" />
namespace Animal {
  export namespace Dog {
    export namespace Corgi {
      export function corgiBark() {}
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

实际使用时需要导入全部的依赖文件:

/// <reference path="animal.ts" />
/// <reference path="dog.ts" />
/// <reference path="corgi.ts" />

Animal.Dog.Corgi.corgiBark();
1
2
3
4
5

除了在 .ts 文件中使用以外,命名空间也可以在声明文件中使用,即 declare namespace

declare namespace Animal {
  export interface Dog {}

  export interface Cat {}
}

declare let dog: Animal.Dog;
declare let cat: Animal.Cat;
1
2
3
4
5
6
7
8

但如果你在 @types/ 系列的包下,想要通过 namespace 进行模块的声明,还需要注意将其导出,然后才会加载到对应的模块下。以 @types/react 为例:

export = React;
export as namespace React;
declare namespace React {
  // 省略了不必要的类型标注
  function useState<S>(initialState): [];
}
1
2
3
4
5
6

首先我们声明了一个命名空间 React,然后使用 export = React 将它导出了,这样我们就能够在从 react 中导入方法时,获得命名空间内部的类型声明,如 useState。

从这一个角度来看,declare namespace 其实就类似于普通的 declare 语法,只是内部的类型我们不再需要使用 declare 关键字(比如我们直接在 namespace 内部 function useState(): [] 即可)。

而还有一行 export as namespace React ,它的作用是在启用了 --allowUmdGlobalAccess 配置的情况下,允许将这个模块作为全局变量使用(也就是不导入直接使用),这一特性同样也适用于通过 CDN 资源导入模块时的变量类型声明。

除了这两处 namespace 使用,React 中还利用 namespace 合并的特性,在全局的命名空间中注入了一些类型:

declare global {
  namespace JSX {
    interface Element extends React.ReactElement<any, any> { }
  }
}
1
2
3
4
5

这也是为什么我们可以在全局使用 JSX.Element 作为类型标注。

除了类型声明中的导入——三斜线指令,以及类型声明中的模块——命名空间以外,TypeScript 还允许你将这些类型去导入到代码文件中。

# 仅类型导入

在 TypeScript 中,当我们导入一个类型时其实并不需要额外的操作,和导入一个实际值是完全一样的:

// foo.ts
export const Foo = () => {};

export type FooType = any;

// index.ts
import { Foo, FooType } from "./foo";
1
2
3
4
5
6
7

虽然类型导入和值导入存在于同一条导入语句中,在编译后的 JS 代码里还是只会有值导入存在,同时在编译的过程中,值与类型所在的内存空间也是分开的。

在这里我们只能通过名称来区分值和类型,但为每一个类型都加一个 Type 后缀也太奇怪了。实际上,我们可以更好地区分值导入和类型导入,只需要通过 import type 语法即可:

import { Foo } from "./foo";
import type { FooType } from "./foo";
1
2

这样会造成导入语句数量激增,如果你想同时保持较少的导入语句数量又想区分值和类型导入,也可以使用同一导入语句内的方式(需要 4.6 版本以后才支持):

import { Foo, type FooType } from "./foo";
1

这实际上是我个人编码习惯的一部分,即对导入语句块的规范整理。在大型项目中一个文件顶部有几十条导入语句是非常常见的,它们可能来自第三方库、UI库、项目内工具方法、样式文件、类型,项目内工具方法可能又分成 constants、hooks、utils、config 等等。如果将这些所有类型的导入都混乱地堆放在一起,对于后续的维护无疑是灾难。因此,我通常会将这些导入按照实际意义进行组织,顺序大致是这样:

  • 一般最上面会是 React;
  • 第三方 UI 组件,然后是项目内封装的其他组件;
  • 第三方工具库,然后是项目内封装的工具方法,具体 hooks 和 utils 等分类的顺序可以按照自己偏好来;
  • 类型导入,包括第三方库的类型导入、项目内的类型导入等;
  • 样式文件,CSS-IN-JS 方案的组件应该被放在第二条中其他组件部分。

示例如下:

import { useEffect } from 'react';

import { Button, Dialog } from 'ui';
import { ChildComp } from './child';

import { store } from '@/store'
import { useCookie } from '@/hooks/useCookie';
import { SOME_CONSTANTS } from '@/utils/constants';

import type { FC } from 'react';
import type { Foo } from '@/typings/foo';
import type { Shared } from '@/typings/shared';

import styles from './index.module.scss';
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 总结与预告

在这一节,我们主要了解了 TypeScript 在工程层面的基础能力,包括类型指令类型声明命名空间三个部分。

类型声明相关的能力几乎是所有规模的工程都会使用到的(你总会遇到没有提供类型定义的库吧),通过大量的额外类型声明我们可以实现更复杂、更准确的类型保护,以及为上古时期的 JavaScript npm 包提供类型定义,即 DefinitelyTyped。但类型指令却相反,它绝对不应该被滥用,无论是相当于后门的 ts-ignore 还是稍显安全的 ts-expect-error 。我们会在后面介绍如何通过 ESLint 规则来进行对应地约束。

而三斜线指令与命名空间这两个概念,虽然已经不再被大量使用,但了解它们诞生与存在的意义同样对理解整个 TypeScript 工程能力很有帮助。在下一节,我们还会与三斜线指令再次碰面。

无论你是在将 TypeScript 集成到什么框架或者工具里,其实你在做的只是一件事,那就是类型,类型,类型!。包括我们在下一节所要学习的 React 与 TypeScript 结合实战,其实本质上也是在学习如何让你的 React 组件也拥有可靠的类型支持。

# 扩展阅读

# 通过 JSDoc 在 JS 文件中获得类型提示

在上面我们提到了可以在 JS 文件中通过 JSDoc 来标注变量类型,而既然有了类型标注,那么自然也能享受到像 TS 文件中一样的类型提示了。但这里我们需要使用更强大一些的 JSDoc 能力:在 @type {} 中使用导入语句!

以拥有海量配置项的 Webpack 为例:

/** @type {import("webpack").Configuration} */
const config = {};

module.exports = config;
1
2
3
4

此时你会发现已经拥有了如臂使指的类型提示:

img

类似的,也可以直接进行导出:

module.exports = /** @type { import('webpack').Configuration } */ ({});
1

当然,Webpack 本身也支持通过 ts 文件进行配置,在使用 TS 进行配置时,一种方式是简单地使用它提供的类型作为一个对象的标注。而目前更常见的一种方式其实是框架内部提供 defineConfig 这样的方法,让你能直接获得类型提示,如 Vite 中的做法:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()]
})
1
2
3
4
5
6
Last Updated: 10/9/2023, 5:43:25 PM