1. 模块的编译结果

src/index.ts

1
2
import { show } from "./myModule";
show();

src/myModule.ts

1
2
3
export function show() { 
console.log("show")
}

当没有配置tsconfig的时候,直接使用命令 tsc src/index.ts 也能帮助我们编译模块化的代码,最后的结果大致为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var myModule_1 = require("./myModule");
(0, myModule_1.show)();

// myModule.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.show = void 0;
function show() {
console.log("show:");
}
exports.show = show;

代码很明显是处理了为了commonjs模块化,因为默认targetes3,当targetes3/es5的时候,默认使用的模块化就是commonjs,也就是配置属性就是module:commonjs

使用tsc --init生成tsconfig.json配置文件之后,默认生成的targetes2016modulecommonjs

当然这些大家都能知道,就会以为,在typescript中,模块化是个小case,实际上,他是一个大boss,如果我们基础知识稍微有那么一点不牢固,就会给我们带来很多困扰。因为模块化还涉及到一个很基础,但是非常复杂的基础知识点,模块解析策略,再加上typescript给我们的一些干扰,会导致有时候出现了问题不知道如何排查。

来吧,我们看看会出现哪些问题:

2. module

首先在typescript5.x的版本中,module能取如下的值:

1
2
3
4
5
6
7
8
9
10
11
none
commonjs
amd
umd
system
es6/es2015
es2020
es2022
esnext
node16
nodenext

3. 默认值

target取值为ES3或者ES5的时候,module的值默认为commonjs

target取值为其他的时候,module的值默认为ES6/ES2015 module

在有打包器环境中,也就是有webpack,vite等大家常用的项目中,可能你知道commonjs和ESM的区别,但你并不会怎么留意这两个的一些细节。

4. ES6 模块化所引发的问题

首先通过tsc --init 生成默认的tsconfig.json文件

1
2
3
4
5
6
7
8
9
10
11
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"outDir": "./dist", // 编译后.js文件的位置
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

直接运行tsc,在dist目录下生成了编译之后的index.js文件和myModule.js文件。运行命令 node dist/index.js,是可以执行的。

如果注释"module": "commonjs"之后,生成的.js文件,很明显不再是commonjs模块化的了,而是支持ESM的。因为这时候module取值是默认的ES6,但是,这个时候我们是不能直接通过node运行的。

1
2
3
4
5
6
(node:15598) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
/Users/yingside/Desktop/ts/test4/dist/index.js:1
import { show } from "./myModule";
^^^^^^
......

虽然在 Node.js 中(>=12.20 版本)可以使用原生 ES Module,但必须遵循下面的规则。

  • 文件以 .mjs 结尾;
  • package.json 中声明type: "module"

我们可以根据上面的两个规则,自行更改,这里当然最好是直接更改package.json,修改.mjs结尾的话,还需要将.ts文件修改为.mts,编译的时候才会自动编译为.mjs,这还会引发其他的一些问题,我们暂时不考虑。

但是就算是package.json 中加上了type: "module"了之后,运行还是报错:

1
2
3
4
5
node:internal/errors:496
ErrorCaptureStackTrace(err);
^

Error [ERR_MODULE_NOT_FOUND]:......

当然了,你可以说我干脆不在node中运行还不行吗?我自己写个html页面,拿到浏览器中运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>

</body>
<!--
type="module" 会默认产生跨域请求,而file协议并不支持。
可以使用 vscode 插件 Live Server 来启动一个本地服务器
-->
<script type="module" src="index.js"></script>
</html>

其实还是报错:

1
Failed to load resource: the server responded with a status of 404 (Not Found)

404已经说的很明显了,找不到myModule啊。

4.1 CommonJS模块查找策略

CommonJS,最初用于Node.js,对于require语句引入的模块,采取以下查找策略:

  1. 文件模块: 如果路径是一个相对路径(如./module)或绝对路径(如/path/to/module),Node.js会先尝试按照给出的路径查找文件。如果有相应的文件名直接匹配,则加载该文件;否则,会尝试添加.js.json.node等后缀名。
  2. 目录模块: 如果路径是一个目录,Node.js会查找该目录下的package.json文件,并根据其main字段指定的文件名进行加载。如果没有package.jsonmain字段,Node.js会尝试加载目录下的index.jsindex.node文件。
  3. 内置模块: 如果模块名对应一个Node.js的内置模块(如fshttp),那么就直接返回该模块,不进行文件查找。
  4. node_modules查找: 如果上述步骤都未能解析模块,Node.js会在当前文件夹的父目录中查找node_modules文件夹,并尝试在其中查找模块。这一过程会一直向上递归至文件系统的根目录。

4.2 ESM模块查找策略

ESM(ECMAScript Modules),是ECMAScript的官方模块系统,用importexport语句来导入导出模块。其查找策略与CommonJS相似,但有以下区别:

  1. 文件扩展名: 在ESM中,导入模块时必须指定文件的完整路径和扩展名(如.js.mjs)。ESM不直接支持将一个目录作为导入路径的方式。如果你希望通过ESM导入一个目录下的模块,你需要指定目录中具体文件的路径,包括扩展名(如import { something } from './someDir/index.js';
  2. URL支持: ESM支持直接通过URL导入模块。
  3. node_modules查找: 在nodejs环境中,ESM支持从node_modules目录查找模块,查找逻辑与CommonJS相似,但更严格地遵守文件扩展名和路径的准确性。但是注意在浏览器环境中,并不支持,必须要给上全路径

node_modules第三方库模块的路径导入方式,也被称为bare import

5. moduleResolution

在typescript5.x中,moduleResolution可以取值:

1
2
3
4
5
classic
node10/node
node16
nodenext
bundler

classic只是 tsc自身默认的模块寻找方式,但这个方式已经不常用了。这里就不纠结了

5.1 node10/node

tsc会仿照早期 nodejs 的方式寻找模块。

简单来说,如果是相对路径,如果发现有文件,就会帮你补全.js后缀路径,如果没有,就再此查找有没有这个路径的文件夹,查找这个文件夹下的index.js文件

如果是bare import路径,那么就去查找node_modules路径,并且依次向上层追溯node_modules直到最顶层为止。

不过,需要注意的是,我们现在是typescript的配置,所以,这里又会有一些区别。

比如,在项目中,/src/index.ts相对路径引入./myModule(注意这其实还会受到tsconfig.json的配置include影响,以及package.json中一些其他配置的影响,这里就暂时不考虑了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
依次寻找:

/src/myModule.ts

/src/myModule.tsx

/src/myModule.d.ts

/src/myModule/package.json(访问 "types" 字段)

/src/myModule/index.ts

/src/myModule/index.tsx

/src/myModule/index.d.ts

这仅仅是相对路径的情况,如果是bare import引入第三方包的情况,就更加复杂,

1
npm i axios
1
import axios from "axios"

在module是ES6的情况下,如果没有指定moduleResolution这里就会报错,因为默认只是classic,我们最好指定为moduleResolution:node

1
Cannot find module 'axios'......

比如/src/index.ts引入import axios from 'axios'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/src/node_modules/axios.ts

/src/node_modules/axios.tsx

/src/node_modules/axios.d.ts

/src/node_modules/axios/package.json(访问"types"字段)

/src/node_modules/@types/axios.d.ts

/src/node_modules/axios/index.ts

/src/node_modules/axios/index.tsx

/src/node_modules/axios/index.d.ts

如果当前目录没找到,继续向上再来一次

/node_modules/axios.ts

/node_modules/axios.tsx

/node_modules/axios.d.ts

/node_modules/axios/package.json(访问"types"字段)

/node_modules/@types/axios.d.ts

/node_modules/axios/index.ts

/node_modules/axios/index.tsx

/node_modules/axios/index.d.ts

如果当前目录没找到,继续向上再来一次......
......

当然,你一定要清楚两件事情

1、相对路径在.ts代码中不报错,不一定在.js代码中就能运行

我们现在配置的tsconfig.json是关联的typescript的编译环境,也就是说,确保在typescript的环境中不会报出错误,但是并不是说编译成js文件之后就是一定正确,能在node环境中直接运行的代码。

比如,如果设置为module:ES6moduleResolution:node,这在编写typescript代码的时候确实可能没有什么问题,当模块的后缀名没有写的时候,不会有提示。如果我们是在有打包器的环境中,这不是什么问题。但是现在我们是直接在node环境中,当编译为.js代码之后,会找不到不带具体后缀的路径模块。

这就是我们上面说的typescript不会去处理模块说明符,其实要追根究底原因也很简单,那是因为早期 nodejs 是不支持 esModule 风格的代码,当然tsc在编译的时候,就不会对引用的路径自动做调整

所以,为了减少歧义,如果moduleResolution:node,那么module字段最好要设置为CommonJS

2、我们通过.ts关联的都是.ts或者.d.ts文件

我们可能有时候会习惯性的认为当我按住ctrl(command)+ 左键点击的时候,就应该进入程序的具体实现中,但是,你会发现,大多数我们点击过去,进入的都是类型声明文件中,特别是在第三方包中,一是因为typescript的模块解析就是只找.ts.d.ts文件,另外就是很多第三方包都是.js+.d.ts类型声明文件的方式,所以我们一般关联过去的都是index.d.ts文件,当然甚至有时候我们进入的是DefinitelyTyped的@types文件夹

5.2 Node16 or NodeNext

tsc按照新版本 node 的方式寻找模块。

node 下,esModulecommonJS 都是支持的。

所以,这里解释起来就费劲了…因为需要区分两种不同的module取值的情况,因此,在最新版本的typescript中,就直接做出了限定,如果moduleResolution的值是Node16 或者 NodeNext,那么module也只能在这两个值中选择

而且,由于Node16 or NodeNext是支持ESM的,所以,无论是commonjs还是es module,如果没有跟随后缀,会直接提示错误,(注意:package.json文件中配置了"type": "module"会有如下提示):

1
2
3
import { show } from "./myModule"; // error

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './myModule.js'?

所以,我们必须给上后缀,但是,根据我们理所应当的想法,这里加后缀的话,要加也应该是./myModule.ts,但是加上.ts后缀之后,同样报错:

1
An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.ts(5097)

意思是让你加上allowImportingTsExtensions:true,但是在tsconfig.json中加上这个属性同样报错:

1
Option 'allowImportingTsExtensions' can only be used when either 'noEmit' or 'emitDeclarationOnly' is set.

意思是allowImportingTsExtensions这个属性,只能在noEmit或者emitDeclarationOnly属性为true的时候才能起作用。

"noEmit":true意思是不要生成.js文件

"emitDeclarationOnly":true意思是只生成.d.ts类型声明文件,不生成.js文件,当然这个属性还依赖于"declaration": true要打开

无论怎么样,也就是说,你要在模块引入中加上.ts后缀不是不行,但是不要生成.js文件,因为很容易造成误解。

所以,这里要加上后缀只能按照提示加上./myModule.js

这是因为typescript的模块说明符只会原封不动的转译

Typescript不会处理模块说明符

typescript不是也会编译.ts文件吗?typescript不会自动帮我们加上后缀吗?

Typescript不会处理模块说明符

是的,这个问题非常的关键,无论是Commonjs还是ESM,typescript并不会帮我们去转译任何的模块说明符(ESM模块化import xxx from后面的字符串,Commonjs中require里面的字符串)

import { add } from “./math.mjs”;

import { add } from “./math.js”;

import { add } from “./math.mts”;

import { add } from “./math.ts”;

const math_1 = require(“./math.mjs”);

也就是你写成"./myModule.ts",编译出来的还是"./myModule.ts"这样在node环境中同样运行不了,而写的是 ''./myModule.js" 输出的 还是 "./myModule.js"

虽然在一个ts文件中,突然引入了一个.js的后缀,你会感觉有点突兀,但是其实这正是typescript处于类型安全的一个考虑。毕竟我们还能通过outdir属性去.js配置生成之后的路径。考虑到输出文件中的模块说明符将与输入文件中的模块说明符相同的约束, 正好验证了输出文件和输入文件的类型分配地址是统一的。

5.3 main/module/unpkg/types与exports

其他的模块解析过程Node16 or NodeNextnode的方式基本一致,只不过新支持了在package.json文件的新的字段exports

当然要理解exports,首先要理解**main/module/unpkg/types**

先看看axios中package.json的声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"main": "index.js",
"exports": {
".": {
"types": {
"require": "./index.d.cts",
"default": "./index.d.ts"
},
"browser": {
"require": "./dist/browser/axios.cjs",
"default": "./index.js"
},
"default": {
"require": "./dist/node/axios.cjs",
"default": "./index.js"
}
},
......
},
"type": "module",
"types": "index.d.ts",
"jsdelivr": "dist/axios.min.js",
"unpkg": "dist/axios.min.js",
......
}

main 我们知道是指定程序入口的字段,它是 CommonJS 时代的产物,也是最古老且最常用的入口文件。

随着 ESM 且打包工具的发展,许多 package 会打包 N 份模块化格式进行分发,如 antd 既支持 ESM,也支持 Commonjs,这个时候就出现了**module**字段。上面的axios没有这个字段,可以看看其他的库:

vue的package.json

antd的package.json

如果使用 import 对该库进行导入,则首次寻找 module 字段引入,否则引入 main 字段。

也就是说,module 字段作为 es module 入口,main 字段作为 commonjs 入口。

当然,如果这个第三方包还想提供CDN的引用,还有一些比如**unpkgjsdelivr**这样的字段。主要是指定方便网页直接引用的文件。

这些都是指定可运行的js文件,如果要指定类型文件,那就需要**types**字段

**exports**这个字段对我们来说是相当好用的,一个比较好的形容就是这个字段就像一把瑞士军刀,可以很灵活的来为不同的环境公开你的模块,同时限制对其内部部分的访问。所以这个字段还有一个称呼就是export map,意思是可以把你希望对外暴露的模块像使用map一样,很方便的进行映射。

不过exports里面的语法和细节还有很多,大部分情况下我们不需要去研究里面的细节,除非你现在就想自己去写一个库或者框架。大概看一下这些库的exports字段,大概也能了解,其实就是将不同环境的.js文件和.d.ts文件直接结合起来了,甚至可以指定子目录。

当然,对于我们现在来说,最重要的,其实是exports中指定的类型声明的优先级,是高于types中指定的类型声明的。

所以最后,我想说的是,Node16 or NodeNext对比node的模块解析策略,也就是在解析模块在读取package.json的时候多了exports这一块,而exports读取的优先级高于types,如果我们引入是第三方包,大致模块解析过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/src/node_modules/axios.ts

/src/node_modules/axios.tsx

/src/node_modules/axios.d.ts

/src/node_modules/axios/package.json(优先访问"exports"字段,后访问"types"/"main"/"module"字段)

/src/node_modules/@types/axios.d.ts

/src/node_modules/axios/index.ts

/src/node_modules/axios/index.tsx

/src/node_modules/axios/index.d.ts

如果当前目录没找到,继续向上再来一次

/node_modules/axios.ts

/node_modules/axios.tsx

/node_modules/axios.d.ts

/node_modules/axios/package.json(优先访问"exports"字段,后访问"types"/"main"/"module"字段)

/node_modules/@types/axios.d.ts

/node_modules/axios/index.ts

/node_modules/axios/index.tsx

/node_modules/axios/index.d.ts

如果当前目录没找到,继续向上再来一次......
......

5.4 bundler

bundler 是 TypeScript5.x 新增的一个模块解析策略,这其实是社区倒逼的标准,是对现实的妥协。

比如vite,声称是完全基于ESM的打包工具,但是为了用户方便,声明相对路径模块的时候却不要求写扩展名。这其实是和ESM的标准冲突的,但是这种做法是没有毛病的。本身用户之前已经习惯了在引入模块的时候不写后缀名,毕竟甚至好多程序员都已经把文件夹下写一个index.js当成标准了。

但是问题就出在现有的几个模块解析策略,都不能完美适配 vite + typescript + esm 的开发场景:

如果moduleResolution:node,对于esm支持不好

如果moduleResolution:node16 / nodenext,强制要求使用相对路径模块时必须写扩展名

moduleResolution:node16 / nodenext实际上算是typescript中完美的解决方案,但是在打包器环境中实在是非常尴尬。

所以才有了moduleResolution:bundler,目的就是告诉你,模块解析就交给打包器了,typescript不再负责,而且不负责模块解析的话,由typescript去编译生成js文件就并不安全了

而且选择bundler的话,module就只能是ESM相关的配置

5.5 搭配 module 和 moduleResolution

说了这么多,关键是到底怎么配置这两个东西?

如果打算编写 commonJS 风格的 nodejs 程序,不支持解析exports字段:

1
2
"module": "CommonJS"
"moduleResolution": "Node"

如果打算nodejs 程序支持比较新的内容,无论模块化commonjs还是es module选择:

1
2
"module": "NodeNext"
"moduleResolution": "NodeNext"

当然,ESM需要设置 package.json"type": "module",要么通过后缀区分.cjs, .mjs

如果在vite环境中:

1
2
"module": "ESNext"
"moduleResolution": "Bundler"

如果在webpack相关的打包环境中:

1
2
"module": "ESNext"
"moduleResolution": "node"

6. baseUrl与paths

这两个我们一般在打包器中常见,都是在设置路径别名的时候进行处理。比如:

1
2
3
4
baseUrl:"./",
"paths": {
"@/*": ["src/*"]
}

baseUrl:设置解析非相对路径模块的基础地址,默认是当前目录,

pahts:路径映射

也就是说,在我们的纯node环境的代码中,如果像下面这样的配置modulemoduleResolution

1
2
"module": "NodeNext"
"moduleResolution": "NodeNext"

那我们的导入是肯定要加上后缀的,不然要报错,但是我们可以使用baseUrl+paths来欺骗一下自己

1
2
3
4
5
6
"module": "NodeNext",
"moduleResolution": "NodeNext",
"baseUrl": "./",
"paths":{
"@/*":["src/*.js"]
}

我们在引入的时候,只需要像这样写:

1
import { show } from "@/myModule";

同样也不会报错。

甚至于,你可以写成下面这样:

1
2
3
4
5
6
"module": "NodeNext",
"moduleResolution": "NodeNext",
"baseUrl": "./",
"paths":{
"*":["src/*.js"]
}

意味着,ts将会从当前目录进行查找,并且后续目录会自动映射为src/*.js,这样如果我们在界面上写成这样

1
2
import {view} from "myModule"
import axios from "axios";

注意这样写只是保证TypeScript代码不报错而已,当编译成js文件之后,"@/myModule"还是会原封不动的被转译。 TypeScript并不会处理模块说明符(也就是from “xxxx”)里面的内容 如果想处理别名问题,需要结合paths和打包工具一起进行处理

7. 什么是类型声明文件

在前面的代码中,我们说从 typescript 编译到 Javascript 的过程中,类型消失了,比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const str = "hello";
type User = {
id: number
name: string
show?: (id: number, name: string) => void
}

const u:User = {
id:1,
name:"张三",
show(id,name){
console.log(id,name)
}
}

const users:Array<User> = [
{id:1,name:"jack"},
{id:2,name:"rose"}
]

function addUser(u:User){
// todos...
return true;
}

addUser(u);

编译成javascript之后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"use strict";
const str = "hello";
const u = {
id: 1,
name: "张三",
show(id, name) {
console.log(id, name);
}
};
const users = [
{ id: 1, name: "jack" },
{ id: 2, name: "rose" }
];
function addUser(u) {
// todos...
return true;
}
addUser(u);

但是是真的消失了吗?其实并不是,如果大家留意之前我们在Playground上编写代码,专门有一项就叫做DTS

你会发现,我们写的代码都自动转换成了typescript类型声明。

当然,这在我们的VS Code编辑器中也能生成的。只需要在tsconfig.json文件中加上相关配置即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"compilerOptions": {
"target": "es2016",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
+ "declaration": true,
+ "declarationDir": "./types",
},
"include": ["src/**/*"],
"exclude": ["./node_modules", "./dist", "./types"]
}

运行tsc,最后生成:[文件名].d.ts

1
2
3
4
5
6
7
8
9
declare const str = "hello";
type User = {
id: number;
name: string;
show?: (id: number, name: string) => void;
};
declare const u: User;
declare const users: Array<User>;
declare function addUser(u: User): boolean;

也就是说,类型并不是真的全部消失了,而是被放到了专门的类型声明文件里。

.d.ts结尾的文件,就是类型声明文件。d的含义就是declaration

其实typescript本身就包含两种文件类型

1、.ts文件:既包含类型信息,又包含可执行代码,可以被编译成.js文件后执行,主要是我们编写文件代码的地方

2、.d.ts文件:只包含类型信息的类型声明文件,不会被编译成.js代码,仅仅提供类型信息,所以类型文件的用途就是提供类型信息

8. 类型声明文件的来源

类型声明文件主要有以下三种来源。

  • TypeScript 编译器自动生成。
  • TypeScript 内置类型文件。
  • 外部模块的类型声明文件,需要自己安装。

8.1 自动生成

只要使用编译选项declaration,编译器就会在编译时自动生成单独的类型声明文件。

下面是在tsconfig.json文件里面,打开这个选项。

1
2
3
4
5
{
"compilerOptions": {
"declaration": true
}
}

declaration这个属性还有其他两个属性有强关联:

8.2 内置声明文件

安装 TypeScript 语言时,会同时安装一些内置的类型声明文件,主要是内置的全局对象(JavaScript 语言接口和运行环境 API)的类型声明。这也就是为什么stringnumber等等基础类型,Javascript的api直接就有类型提示的原因

内置声明文件位于 TypeScript 语言安装目录的lib文件夹内

image.png

这些内置声明文件的文件名统一为lib.[description].d.ts的形式,其中description部分描述了文件内容。比如,lib.dom.d.ts这个文件就描述了 DOM 结构的类型。

如果想了解对应的全局对象类型接口,可以去查看这些内置声明文件。

tsconfig.json中的配置targetlib其实就和内置声明文件是有关系的。TypeScript 编译器会自动根据编译目标target的值,加载对应的内置声明文件,默认不需要特别的配置。我们也可以指定加载哪些内置声明文件,自定义配置lib属性即可:

1
"lib":["es2020","dom","dom.iterable"]

为什么我们没有安装typescript之前也有提示?

这是由于我们的VS Code等IDE工具在安装或者更新的时候,已经内置了typescript的lib。一般在你的VS Code安装路径 -> resources -> app -> extensios -> node_modules -> typescript

如果你的VS Code一直没有升级,就有可能导致本地VS Codetypescript版本跟不上的情况,如果你的项目目录下,也安装的的有typescript,我们是可以进行切换的。

VS Code中使用快捷键ctrl(command) + shift + P,输入TypeScript

image.png

选择Select Typescript Version...

image.png

你可以选择使用VS Code版本还是项目工作区的版本

8.3 外部类型声明文件

如果项目中使用了外部的某个第三方库,那么就需要这个库的类型声明文件。这时又分成三种情况了。

1、第三方库自带了类型声明文件

2、社区制作的类型声明文件

3、没有类型声明文件

没有类型声明这个很容易理解,我们现在不纠结这种情况,而且大多数情况下,我们也不应该去纠结他,关键是1,2两点是什么意思?其实我们下载两个常用的第三方库就能很明显的看出问题。

1
npm i axios lodash

注意:引入模块之前,涉及到模块的查找方式,因此在tsconfig.json中需要配置**module**

对于现代 Node.js 项目,我们可以配置nodenext,注意这个配置会影响下面三个配置:

1
2
3
"moduleResolution": "nodenext",
"esModuleInterop": true,
"target": "esnext",

当然,具体模块化的配置,不同的环境要求是不一样的,有一定的区别,比如是nodejs环境,还是webpack的打包环境,或者说是在写一个第三方库的环境,对于模块化的要求是不一样的。而且还涉及到模块化解析方式等问题。这里就先不详细深入讲解了

我们先简单配置为nodenext即可

引入相关模块:

image.png

其实打开这两个库的源代码就能发现问题,axios是有.d.ts文件的,而lodash没有,也就是说根本没有类型声明,那当然就和提示的错误一样,无法找到模块的声明文件。

第三方库如果没有提供类型声明文件,社区往往会提供。TypeScript 社区主要使用 DefinitelyTyped,各种类型声明文件都会提交到那里,已经包含了几千个第三方库。上面代码提示的错误,其实就是让我们到@types名称空间去下载lodash对应的类型声明,如果存在的话。当然,你也可以到npm上进行搜索。几乎你知道的所有较大的库,都会在上面找到,所以一般来说也要下载或者搜索都比较简单,@types开头,/后面加上第三方库原来的名字即可,比如:

@types/lodash@types/jquery@types/node@types/react@types/react-dom等等

1
npm i --save-dev @types/lodash
1
2
3
4
import lodash from 'lodash'

const result = lodash.add(1, 2);
console.log(result)

image.png

默认情况下,typescript会从node_modules/@types文件夹下导入所有类型声明至全局空间,(需要注意只会导入script文件中的声明至全局空间,module文件对全局空间是隐藏的)。

可以通过typeRoots选项设置一系列为文件路径,指示typescript从哪些地方导入类型信息,默认值为node_modules/@types。不用纠结,一般人不会去改动这个配置,只不过如果你希望往里面添加新的配置路径,别忘记把默认的node_modules/@types加上。

其实,nodejs本身也没有TypeScript的类型声明,因此你会发现在.ts文件中直接引入nodejs相关的模块同样会报错

1
import path from "path"; // error 找不到模块"path"或其相应的类型声明

同样,我们直接在DefinitelyTyped下载即可

1
npm i @types/node -D

9. 类型声明文件的用途

我们自己当然也能编写类型声明文件,但是声明文件.d.ts大多数时候是第三方库一起使用的,我们写代码教学阶段在nodejs环境下,单独去声明.d.ts文件没有太大的意义,首先大家要知道这个问题。所以,要使用.d.ts声明文件的场景一般是:

1、自己写了一个主要是Javascript代码的第三方库,需要给这写Javascript代码加上类型声明,以便用户使用的时候可以得到类型声明,方便调用API。

2、自己下载了别人写的第三方库,但是没有typescript类型声明,在社区 DefinitelyTyped中也没有找到对应的类型声明,但是我们一定要用这个库,可以手动为这个库添加一些简单的类型声明,以免我们自己项目在使用这个第三方库没有类型声明报出错误提示。

3、在做应用项目的时候,需要补充一些全局的类型声明的时候,我们可能需要自己动手写.d.ts文件,其实这种情况大多数还是和第2点有关系

所以大家首先要明白类型声明文件使用的场景,我们再来说怎么去使用它。

10. 编写类型声明文件

从之前tsc编译自动生成的.d.ts文件就能看出,大多使用declare关键字进行声明。

declare关键字用于告诉TypeScript编译器:某个变量、常量、函数或类已经存在,即使它在当前文件中没有定义。这是一种类型声明的方式,允许你在不提供具体实现的情况下,定义一个变量的类型。

它只是通知编译器某个类型是存在的,不用给出具体实现。而且,最重要的,declare关键字修饰的只要不在模块文件中都是全局声明,也就是在文件.d.ts文件中声明之后,后续就直接可用了,不用再进行导入等这些操作

注意:只要声明文件中出现顶层的 importexport,那么这个声明文件就会被当做模块,模块中所有的声明都是局部变量或局部类型,必须 export 导出后,才能在其他文件中 import 导入使用。

declare 关键字可以描述以下类型。

  • 变量(const、let、var 命令声明)
  • type 或者 interface 命令声明的类型
  • class
  • enum
  • 函数(function)
  • 模块(module)
  • 命名空间(namespace)

注意1:TS编译器会处理 tsconfig.json 的 fileincludeexclude对应目录下的所有 .d.ts 文件

注意2:如果.d.ts类型声明文件和可执行的.ts文件在同一目录下,文件名不能同名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//types.d.ts
declare var num: number;
declare let str1: string;
declare const str2 = "hello";

declare function power(a: number, b: number): number

type FnAdd = (a: number, b: number) => number

interface User {
id: number
name: string
}

declare module "foo" {
export var bar: number;
export function baz(a: number): string;
}

具体调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { bar, baz } from "foo";

console.log(num);
console.log(str1);
console.log(str2);
const p = power(1, 2);

console.log(bar);
baz(10);

// 上面的代码在TS中并不会报错,因为在类型声明文件中已经声明了
// 但是并不能执行,因为运行时少了具体的实现
// 其实应该还有一个具体实现js文件,比如第三方引入的power函数的具体实现

const add: FnAdd = (a, b) => a + b;

const uu: User = {
id: 1,
name: "张三"
}

10.1 declare module

declare module,应该算类型声明中的语法,它的作用其实在一般的工作场景中我们都是用来对模块声明进行增强的。

如果还不知道上面说的这段是什么意思,我们可以使用第三方库来模拟一下,比如我们导入了lodash库,但是他没有类型声明,我们之前已经通过@types/lodash导入了类型声明,如果没有这个类型声明的话,我们完全也能自己去声明.d.ts文件,简单的屏蔽模块导入错误即可

1
2
3
4
5
//lodash.d.ts
declare module "lodash" {
export function add(a: number, b: number): number;
export function ceil(n: number, precision?: number): number;
}

还比如我们在实际工作中经常可能遇到找不到图片模块的问题:

1
2
3
import notFound from "./assert/404.png";

找不到模块“./assert/404.png”或其相应的类型声明。ts(2307)

这时,我们就可以在声明文件中处理这种模块的声明

1
2
3
4
declare module "*.png" {
const src: string;
export default src;
}

其他后缀名的图片,甚至是css,你想以模块化的方式引入都可以用这个方式。

不过需要注意的是,这种方式用到了通配符*,所以仅仅相当于告诉了ts,遇到了这种后缀的模块化引入就别报错了,但是并不会去验证路径到底是否正确。

10.2 declare namespace

namespace 是 ts 早期时为了解决模块化而创造的关键字,中文称为命名空间。

由于历史遗留原因,在早期还没有 ES6 的时候,ts 提供了一种模块化方案,使用 module 关键字表示内部模块。但由于后来 ES6 也使用了 module 关键字,ts 为了兼容 ES6,使用 namespace 替代了自己的 module,更名为命名空间。

随着 ES6 的广泛应用, namespace其实失去了原本的功能,有一些公司或者项目,可能会使用namespace拿来作为命名的区分。

比如,我们平时在业务系统中,一个比较常用的用法就是用namespace作为命名划分以免出现同名的情况,特别是在封装api类型的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export namespace User {
export interface Address {
province: string,
city: string
}
export interface UserInfo {
_id: string;
address: Address;
age: number;
loginId: string;
loginPwd: string;
loves: string[];
name: string;
}
}

10.3 declare global

如果我们希望定义全局类型,那么就必须放在非模块文件中,简单来说就是文件首位不能出现import或者export,当然,如果当前文件已经是一个模块,但是希望导出一个全局类型,那么可以使用全局声明模块declare global,也就是说一般语法像下面这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// global.d.ts
export {}

declare global{
interface User {
id: number
name: string
}
}

// 使用:index.ts
let u: User = {
id: 1,
name: "typescript"
};

其实这样声明和之前在文件中直接写interface是一个意思,但是,约定俗称的,这样去声明全局模块看起来更加的规范明确,而且也没有必须要在非模块化文件中的这样的硬性要求。

所以,如果我们希望定义一些全局类型,更加推荐的是写在declare global中,特别是对原生代码类型的扩展。

比如我们要给全局类型String的原型上添加类型,就可以使用declare global去进行扩展

1
2
3
4
5
6
7
8
// global.d.ts
export {};

declare global {
interface String {
prependHello(): string;
}
}

interface String在是lib.es5.d.ts中有声明

使用:

1
2
3
4
5
6
7
if (!String.prototype.prependHello) { 
String.prototype.prependHello = function () {
return "Hello, " + this;
};
}

console.log("typescript".prependHello());

比如,我们还可以扩展原生的Array中的方法

1
2
3
4
5
6
7
8
9
10
11
12
// global.d.ts
export {};

declare global {
interface String {
prependHello(): string;
}

interface Array<T> {
removeLast(): T[];
}
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// index.ts
if (!String.prototype.prependHello) {
String.prototype.prependHello = function () {
return "Hello, " + this;
};
}

console.log("typescript".prependHello());

if (!Array.prototype.removeLast) {
Array.prototype.removeLast = function () {
this.pop();
return this;
};
}

const arr = ['a', 'b', 'c'];

console.log(arr.removeLast());

正如前面所说,大家需要留意,如果类型声明文件中出现了import或者export等关键字,那么就会认为这个文件是一个模块声明,那么文件中所有使用declare声明的类型就自动变为了局部声明

1
export {}

11. 声明合并

我们知道接口是可以声明合并的,但其实声明合并其实是TS中一个比较重要的特性,因此并不是接口所独有的。当然,我们一般用的较多的也就仅仅是接口的声明合并。其他的声明合并其实用的很少,滥用反而会引起混乱。这里列举了表格,表明哪些是可以声明合并的。

枚举 函数 类型别名 接口 命名空间 模块
-
- -
枚举 - - -
函数 - - - -
类型别名 - - - - -
接口 - - - - - -
命名空间 - - - - - - -
模块 - - - - - -

12. 三斜线指令

当我们在编写一个全局声明文件但又依赖其他声明文件时,文件内不能出现 import 去导入其他声明文件的声明,此时就可以通过三斜线指令来引用其他的声明文件。

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

1
2
3
/// <reference path="./xxx.d.ts" />
/// <reference types="node" />
/// <reference lib="es2017.string" />

可以看出来,三斜线其实也是一种注释语句,只不过在原有注释两条斜线 // 的基础上多写一条变成了三斜线 /// , 之后通过 <reference /> 来引用另一个声明文件。TS 解析代码时看到这样的注释就知道我们是要引用其他声明文件了。

三个参数,代表三种不同的指令引用。

  • path
  • types
  • lib

它们的区别是:path 用于声明对另一个文件的依赖,types 用于声明对另一个库的依赖,而lib用于声明对内置库的依赖

全局声明文件不是全局都能用吗?为什么还需要引用?

注意我们前面说明.d.ts文件的注意事项TS编译器会处理 tsconfig.json 的 file、include、exclude对应目录下的所有 .d.ts 文件

也就是说,如果在自己的项目中,出现了tsconfig.json声明之外的.d.ts文件是引用不了的。

另外,还有可能是我们需要引用其他第三方库的.d.ts文件

还有一种可能就是在第三方库或者特别是DefinitelyTyped中,.d.ts文件中的内容过多,为了更明显的区分不同的内容,会将文件中的内容拆分,然后再使用三斜线指令进行引用即可

我们可以模拟一下这个场景:

tsconfig.json文件中,配置的"include": ["src/**/*"]。如果新建一个.d.ts文件在另外的目录,就解析不了这个文件。比如我们在根目录下创建新的目录lib/index.d.ts

1
2
3
4
5
6
interface Student { 
id: number
name: string
}

type Level = "plain" | "silver" | "gold" | "platinum" | "diamond"

然后在src/types.d.ts中要使用StudentLevel类型,现在这样是发现不了这两个类型的。

1
2
3
4
5
6
7
8
/// <reference path="../lib/index.d.ts" />

interface User {
id: number;
name: string;
level?: Level;
}
type showStudent = (stu: Student) => void;

使用三斜线指令之后,顺利找到类型。

注意:三斜线指令只能用在文件的头部,如果用在其他地方,会被当作普通的注释。另外,若一个文件中使用了三斜线命令,那么在三斜线指令之前只允许使用单行注释、多行注释和其他三斜线命令,否则三斜杠命令也会被当作普通的注释。

我们也可以引入一下其他第三方库的.d.ts文件,比如我们可以引入vite,看看vite中的声明文件

1
npm i vite

注意: vite版本已经更新到5.0+了,需要nodejs版本18以上,如果nodejs版本跟不上,可以引入vite4.0+,

1
npm i vite@4

我们可以在自己的类型声明文件中,使用三斜线指令,引入一下vite中的类型

1
2
3
4
/// <reference types="vite/client" />

type A = CSSModuleClasses
type B = ImportMeta["env"]

其实,reference types 也是有索引规则的,首先会在node_modules@types下查找,没有的话,然后才到同名项目下查找,和nodejs的模块查找规则很类型,具体关于模块查找解析的内容这里就不展开了

13. 相关TS代码

randomNumber.ts

1
2
3
4
5
const randomNumber = (min: number, max: number): number => {
let num = Math.floor(Math.random() * (min - max) + max);
return num;
}
export default randomNumber;

index.ts

1
2
3
import randomNumber from "./randomNumber.ts";
console.log(randomNumber(1, 100));
export default { randomNumber };

14. webpack依赖与基本设置

1
npm i webpack webpack-cli -D

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const path = require('path')
/**
* @type {import('webpack').Configuration}
*/
module.exports = {
mode:"development",
entry: path.resolve(__dirname, './src/index.ts'), // 入口文件
output: {
path: path.resolve(__dirname, './dist'), // 打包后的目录
filename: '[name].[contenthash:6].js', // 打包后的文件
clean: true, // 清理打包目录 /dist 文件夹
publicPath: "/"
},
resolve: {
// 引入文件时不需要加后缀。
extensions: ['.ts', '.js', '.json'],
}
}

15. tsconfig.json基本配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"compilerOptions": {
"target": "es2016",
"module": "ESNext",
"moduleResolution": "node",
"noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
},
"include": ["src/**/*.ts","src/**/*.d.ts"]
}

webpack运行命令:

1
npx webpack -c webpack.config.js

当然也能直接配置到script脚本中

1
2
3
"scripts": {
"build": "webpack -c webpack.config.js"
},

16. ts-loader安装

webpack本身并不能支持处理ts文件,因此需要ts-loader支持

1
npm i typescript ts-loader -D

配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const path = require('path')
/**
* @type {import('webpack').Configuration}
*/
module.exports = {
mode:"development",
entry: path.resolve(__dirname, './src/index.ts'), // 入口文件
output: {
path: path.resolve(__dirname, './dist'), // 打包后的目录
filename: '[name].[contenthash:6].js', // 打包后的文件
clean: true, // 清理打包目录 /dist 文件夹
publicPath: "/"
},
resolve: {
// 引入文件时不需要加后缀。
// 这里只配置vue,ts,js和json, 其他文件引入都要求带后缀,可以稍微提升构建速度
extensions: ['.ts', '.js', '.json'],
},
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: [
{
loader: 'ts-loader',
options: {
transpileOnly: true, // 关闭类型检查,即只进行转译,不进行类型检查
}
}
],
}
]
}
}

17. babel预设

1
npm i babel-loader @babel/core @babel/preset-env -D

配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const path = require('path')
/**
* @type {import('webpack').Configuration}
*/
module.exports = {
mode:"development",
entry: path.resolve(__dirname, './src/index.ts'), // 入口文件
output: {
path: path.resolve(__dirname, './dist'), // 打包后的目录
filename: '[name].[contenthash:6].js', // 打包后的文件
clean: true, // 清理打包目录 /dist 文件夹
publicPath: "/"
},
resolve: {
// 引入文件时不需要加后缀。
// 这里只配置vue,ts,js和json, 其他文件引入都要求带后缀,可以稍微提升构建速度
extensions: ['.ts', '.js', '.json'],
},
module: {
rules: [
{
test: /\.m?jsx?$/,
exclude: /node_modules/,
use: 'babel-loader'
},
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: [
{
loader: 'ts-loader',
options: {
transpileOnly: true, // 关闭类型检查,即只进行转译,不进行类型检查
}
}
],
}
]
}
}

18. babel的ts预设

1
npm i @babel/preset-typescript -D

配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const path = require('path')
/**
* @type {import('webpack').Configuration}
*/
module.exports = {
mode:"development",
entry: path.resolve(__dirname, './src/index.ts'), // 入口文件
output: {
path: path.resolve(__dirname, './dist'), // 打包后的目录
filename: '[name].[contenthash:6].js', // 打包后的文件
clean: true, // 清理打包目录 /dist 文件夹
publicPath: "/"
},
resolve: {
// 引入文件时不需要加后缀。
// 这里只配置vue,ts,js和json, 其他文件引入都要求带后缀,可以稍微提升构建速度
extensions: ['.ts', '.js', '.json'],
},
module: {
rules: [
{
test: /\.m?jsx?$/,
exclude: /node_modules/,
use: 'babel-loader'
},
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: [
// {
// loader: 'ts-loader',
// options: {
// transpileOnly: true, // 关闭类型检查,即只进行转译,不进行类型检查
// }
// },
{
loader: 'babel-loader',
options: {
presets: [
[
"@babel/preset-typescript",
{
allExtensions: true,
},
],
]
}
}
],
}
]
}
}