Typescript 类型声明 All-in-one
TypeScript 的类型声明文件 .d.ts 提供了 JavaScript 代码的附加元信息,支持类型提示和自动补全。使用 DefinitelyTyped 仓库可以为没有类型声明的 npm 包提供类型支持,类型定义可通过 type 和 interface 进行,declare 语法用于为已有的 JS 变量或函数添加类型。三斜杠指令用于引入额外的文件依赖。
本文讨论了Node.js中ESM与CJS的模块解析策略,强调了tsconfig.json中的module和moduleResolution字段对模块解析的影响。通过分析Node对两种模块标准的支持,指出了配置不匹配导致的错误,并提供了解决方案,包括将moduleResolution修改为Node16以确保正确的解析算法。最后,强调了在使用ESM时必须在导入语句中包含文件扩展名。
彼时我正在开发一个内网npm包,用于在开发时生成TS接口类型。
该包中的代码(以及生成的代码,因为生成的代码为纯类型声明)并不会在浏览器环境中运行,也不会包含在产物中,因此可以看作是纯Node工具。
在开发时,我尝试使用ts-node进行调试,但出现了以下错误:
当时的我很疑惑,明明是ts-node为何会不支持.ts的拓展名呢?
经查阅,我发现ts-node的运行过程其实是先使用tsc编译成JS后再使用Node运行,在过程中会读取项目的tsconfig.json
中的配置项。
因此,只需要分别运行tsc编译和node执行两个阶段就可以找出到底在哪里出了问题。
我尝试新建一个最简单的环境来复现问题本身:
npm init
新建一个npm项目,安装typescript和ts-node两个包index.ts
和moduleA.ts
两个文件模拟模块导入导出tsc
编译并使用Node执行// moduleA.ts
export const a = "this is moduleA";
// index.ts
import { a } from "./moduleA";
console.log(a);
使用tsc编译后单独执行和直接使用ts-node都正常,运行效果是一样的:
在package.json
中设置type: module
后(指定全局为ESM模块),运行ts-node出现了和上面一样的报错:
而单独使用tsc+node运行报错则不一样,提示为模块类型不兼容(在ESM模块中使用了CJS语法):
因此可以判定为ts-node的内部执行代码与ESM模块兼容性有问题,可以通过下面两种方法修复:
项目整体修改为CommonJS模块(兼容CJS和ESM语法导入),需要修改项目npm和ts配置,完成下面两个步骤:
package.json
中的type
字段为CommonJS或留空tsconfig.json
的module
字段为CommonJS开启ts-node的ESM模式,无需修改项目配置,以下操作二选一即可:
ts-node --esm
或ts-node-esm
tsconfig.json
中"ts-node".esm
字段为true
修复后就可以解决上面的报错(新报错是由于没有修改tsconfig.json
中的module
):
修改了上述问题后,在原项目(非新建项目)中出现了下面的问题:找不到源码中导出的某个模块。
尝试在新建项目中复现类似错误,我们需要还原原项目中的配置(除了package.json
中的type: module
外的TS配置):
// tsconfig.json
{
"extends": "@tsconfig/recommended/tsconfig.json",
"exclude": ["./node_modules/**/*", "./dist/**/*", "./build/**/*"],
"include": ["./src/**/*", "./tsconfig.json", "./wagas.config.ts"],
"compilerOptions": {
"baseUrl": "./",
"paths": {
"src/*": ["./src/*"]
},
"outDir": "./build",
"typeRoots": ["src/types"],
"module": "ES2022",
"moduleResolution": "Node10",
"target": "ES6"
},
"ts-node": {
"esm": true,
"experimentalResolver": true
}
}
在新项目的tsconfig.json
中设置module: esnext
后,成功复现了原项目错误:
说明确实是module
字段相关的模块解析导致了这个错误。这才是本文的重头戏,也引出了后面关于Node和TS中模块解析策略的讨论。
在开始之前,先简单地谈谈JS模块化是什么。
在 前端包管理器 - npm 一文中,我简单提到了当下JS语言的两种主流模块化方案——CommonJS(CJS)和ES Module(ESM)。
它们主要的区别如下(详情可以到文章中去了解):
CJS | ESM | |
标准来源 | 社区 | ECMA Script 标准委员会 |
实现层次 | 应用层 | 语言标准 |
本质 | JS对象(值拷贝) | 符号引用(引用拷贝) |
但是,稍了解前端工具链生态的同学可能就会有疑问:我明明网页的最终产物都使用webpack打包成一整个js文件后放在<script>
标签里引用,这和模块也没关系呀?
虽说我们的页面代码最终是运行在浏览器环境中的<script>
脚本,但整个开发工具链(包括包管理器npm、构建工具webpack等)都是运行在NodeJS环境中的
graph TD
subgraph NodeJS环境
NodeJS[("NodeJS")]
NPM[("NPM")]
Webpack[("Webpack")]
Modules[("Node Modules")]
JSCode[("JS代码解释与执行")]
ResolveModules[("解析模块")]
NodeJS -->|管理工具链| NPM
NodeJS -->|构建工具| Webpack
NPM -->|管理| Modules
NodeJS -->|执行| JSCode
NodeJS -->|解析| ResolveModules
end
subgraph 浏览器环境
Browser[("浏览器")]
ScriptTag[("<script>脚本")]
JSContext[("JS上下文")]
Browser -->|运行| ScriptTag
ScriptTag -->|包含| JSContext
end
所谓NPM,管理的正是Node Package,即Node环境下的包而非浏览器环境下的包,浏览器环境中一个HTML对应一个完整的JS上下文,并不存在“包”的概念(但有“模块”的概念,这里暂时按下不表)。
作为运行环境,Node除了要负责JS代码的解释与执行外,还要负责解析模块(或者说找到并读取模块文件)。比如对于下面这个import
语句,Node就需要先找到它在哪里,并以正确的格式去读取它:
import { debounce } from "lodash";
解析模块的过程是从解析模块标识符(specifier)开始的,比如 import A from 'a'
中的 'a'
就是标识符(specifier)。
一般而言,Node会按照 路径读取 → node_modules
目录寻找 → 上层 node_modules
目录寻找 → ... 的方式去寻找对应模块的文件(详见npm博客)。
graph TD
Start[开始解析模块] --> Path[路径读取]
Path --> NodeModules[在当前目录的node_modules中寻找]
NodeModules --> UpperNodeModules[在上层目录的node_modules中寻找]
UpperNodeModules --> Continue[继续向上查找...]
NodeJS在v13.2.0版本后,正式支持了ESM模块解析,因此实际上Node中存在两套不同的Module Loader,分别处理CJS和ESM模块。
因此在取得标识符后,Node需要根据模块策略类型决定使用哪个Loader来加载模块:
Loader | 匹配条件 | 实现细节 |
CommonJS | 具有 cjs 拓展名最临近的 package.json 中 type 为 commonjs | 不支持异步导入,纯应用层实现,支持直接修改 支持目录模块(读取 index )和缺省拓展名支持读取任何拓展名的文件,都会被CJS Loader处理为JS脚本 (Node23新增)支持从CJS读取同步ESM模块,得到一个Module对象 |
ESM | 具有 mjs 拓展名最临近的 package.json 中 type 为 module | 根据语言标准实现,默认是异步的 不支持直接修改,只能使用loader-hooks修改 不支持模糊导入,如目录导入、不含拓展名的导入 JSON导入必须根据规范声明 with {type: "JSON"} 只支持js, mjs和cjs拓展名被处理为JS脚本(包括TS) 支持读取CJS模块 |
值得注意的是,在未命中任何匹配条件时(如直接运行一个 .js
文件),NodeJS会采用以下的兜底策略进行判断:
import
等)就会直接使用ESM Loader所以为什么在声明 type: module
后,下面的代码就会报错呢?
import { sum } from "./sum";
console.log("Hello World");
console.log(sum(1, 2, 3, 4, 5));
根据上面的模块加载逻辑,我们可知在声明了 type: module
后,Node会默认使用ESM Loader来加载模块,这个加载器不会尝试补全拓展名,因此导致了找不到模块的错误。
洋洋洒洒说了一大堆,心中还是有很多疑问,没有从更高层次将这些问题连接起来:
先要明确TS本身与Node的关系:
众所周知TS给自身的定义是JS的“超集”,但这只是语法上的定义,实际上看来TS更像是JS的“类型插件”,在JS运行层上加了一层额外的“类型分析层”,从TS代码编译成JS实际上是在做“类型擦除”就可以看出,真实代码运行时其实不需要任何类型信息。
而Node本身只接受JS代码,并不接受TS代码,因此我们就可以明确tsc和node各自的职权范围:
tsc负责将.ts
文件翻译成.js
文件,并将类型信息通过LSP传递给IDE,配置影响产物格式和IDE行为
.js
文件中的代码,配置影响运行时行为(如模块读取策略)综上所述,我们可以得到以下结论:
根据官方资料,tsconfig.json
中直接与模块解析相关的参数就只有下面几个:
module
:指定编译后文件使用的模块体系,如导入导出语句moduleResolution
:指定当前项目使用哪种模块解析策略esModuleInterop
:允许CJS模块使用ESM默认导出一个常见误区是,target
字段并不会影响模块解析策略,只会影响生成的JS语法特性范围。
一般来讲,直接修改 module
参数即可修改模块行为, moduleResolution
配置会随之取合适的值。如有特殊定制需求,如配合包管理器使用,也可以手动定义 moduleResolution
。
module 策略 | 对应 moduleResolution 策略 | 产物行为(直接由module影响) | LSP行为(由resolution间接影响) |
CommonJS(默认值) | node10 / node | 输出CJS模块 | 使用CJS Loader,支持目录、缺省拓展名导入 |
ES6/ES2015 | classic |
module
字段参考阅读 —— TS官方给出的JS模块哲学:https://www.typescriptlang.org/docs/handbook/modules/theory.html#the-module-output-format
TS支持多种模块标准,包括不同版本的CJS和ESM,设置该字段有助于TS编译器理解宿主环境(host runtime,如Node)使用什么类型的模块,它应该输出什么样的模块。
需要注意的是,修改module
字段也会隐式修改moduleResolution
字段,但显式地设置moduleResolution
字段优先级会更高
该字段最直观的影响就是改变产物中导入导出语句的格式,接下来将以这段代码为例展示不同产物:
export const a = "this is moduleA";
CommonJS
:默认值,表示编译产物使用CJS模块标准ES[*]
:表示使用ESM模块标准,允许的值为Next|6|2015|2020|2022,根据运行时宿主和语法特性要求环境选择不同的标准:
import.meta
和export from
语句的支持await
语句的支持Node16
:表示使用Node16+使用的模块解析算法,同时支持ESM和CJS,具体输出由pacakge.json
决定,详见后文NodeNext
/ ESNext
:这些带有XXNext的选项表示始终与最新版本同步,注意这将引入不确定性(因为Next的目标将随Typescript版本变化而变化),目前的最新目标如下:
moduleResolution
字段上面了解到module
字段用于告知编译器运行环境期待得到什么样的模块,moduleResolution
字段则用于告诉编译器运行环境使用什么算法来解析和读取模块路径说明符(如import a from "xxx"
中的xxx
就是模块路径说明符)
需要注意的是,Typescript本身并不真的负责读取和解析模块,这部分交给运行时去实现(如Node、Deno等),这也解释了为什么tsc不会修改模块路径说明符。
目前,Typescript支持以下可选项作为moduleResolution
的值,“对应的module”是指在取得这些值时moduleResolution
将取得对应的默认值。
选项 | 对应的module | 算法说明 |
classic | 除了commonjs , node16 和nodenext 外的任意值 | 使用RequireJS算法,不应在任何不使用RequireJS/AMD的项目中使用 |
node10 / node (常用) | commonjs | Node v12以前版本使用的模块解析算法,几乎忠实还原了大部分打包工具的查找算法,支持如嵌套查询node_modules 、使用index.js 替代目录和省略.js 拓展名等重要特性。但由于Node v12以后引入了ESM支持和新的解析规则,在现代Node项目中并非好选择。 |
node16 (现在node项目) | node16 | 在Node v12+中同时支持了ESM和CJS,且每种模块都有自己的解析算法。为了确认文件的模块类型,在ESM import中省略拓展名和index.js 文件代替目录特性不再被支持(因为.mjs 和.cjs 是模块类型判据),但CJS require仍支持(因为只支持CJS模块) |
nodenext | nodenext | 带next的选项将始终与最新标准同步,目前是node16 |
bundler | Typescript团队为了在新版Node中还原大部分打包器所使用的查找算法的努力,它还原了node10的大部分行为(require解析算法) |
除了type
字段可以指定项目内的隐式模块类型外,package.json
中还有一系列字段可以确认项目作为包暴露给外部的模块类型和入口:
目标为老版本node(仅支持CJS)时,推荐使用传统的main
和module
字段指定入口,注意这些字段在新版本中可用,但不推荐,且**在****exports
*字段存在时会被覆盖。
main
字段为包的主入口,如有module
字段则是CJS入口module
字段为包的ESM入口exports
字段所谓条件导出(conditional exports)是指根据导入语句的类型(CJS还是ESM)和路径去指定特定的导出文件入口。
import
和require
来分别定义ESM和CJS入口// package.json
{
"exports": {
"import": "./index-module.js",
"require": "./index-require.cjs"
},
"type": "module"
}
import a from "pkg/xxx"
这样的语句{
"exports": {
".": "./index.js",
"./feature.js": {
"node": "./feature-node.js",
"default": "./feature.js"
}
}
}
{
"exports": {
"node": {
"import": "./feature-node.mjs",
"require": "./feature-node.cjs"
},
"default": "./feature.mjs"
}
}
然而在实际生产中,ESM和CJS并非水火不容的关系,更多情况下它们存在复杂的相互调用关系。
事实上,由于CJS在很长一段时间内都是社区的事实标准,所以尽管ESM目前大行其道,但很多古老的依赖仍是CJS规范,因此很多即便模块规范为ESM的项目也免不了有一些CJS的依赖。
下表反映了我测试得到的ESM和CJS的互操作性结果:
Import 语句 | Export 语句 | 面向场景 | 解决方案 |
ESM import | CJS module.exports | ESM Module | import * as pkg from '' import pkg from '' |
CJS require | ESM export | CJS Module | Forbidden |
Dynamic Import (import() ) | CJS module.exports | Both | import() |
ESM export | Both |
由于ESM模块规范中的import
语句是静态导入(在过去与未来的npm一文中详细谈到过),和require
的实现逻辑不同,因此无法使用require
语句去引用ESM包。
为了解决这一问题,ECMA Script标准ES2020引入了动态导入(dynamic import)语句,它的实现是动态的、异步的,行为与require
更相似,因此可以实现CJS和ESM通吃。
需要注意的是,ESM的import()
结果是module,获取默认导出要用.default
才能拿到对象
下图为在CJS中使用Dynamic Import(import()
)的代码和效果:
最后,我们回到问题本身来,看看到底是什么引起了这个错误?如何修复这个错误?
回顾前文,该问题背后的运行环境如下:
package.json
中设置type: module
,即该项目全局默认为ESM模块tsconfig.json
中设置module
为ES2022,但moduleResolution
为node10
;即要求产物为ESM模块规范,但解析算法为node10的CJS算法Node解析ESM模块必需使用Node16规范算法,引用时不能省略拓展名
其实看完环境配置后答案已经呼之欲出了,让我们回顾之前moduleResolution
中对node v12后如何同时支持ESM和CJS的解析算法的叙述,可以得到以下分析过程:
import
语句需要同时支持CJS和ESM,而判断该文件是什么类型的信息与拓展名相关,因此在Node v12+(规范node16)中文件拓展名不能省略require
语句算法并没有修改,因此还是沿用之前的方案(规范node10),可以省略拓展名,以index.js
替代目录等特性仍可以继续使用(这也是最开始默认为CJS时没问题的原因)type: module
,则全局的JS文件都默认为ESM,使用ESM解析算法解析,即实际解析行为一定是Node16
node10
算法去匹配ESNext
模块规范,导致TS在编译时没有检查出错误,而Node在解析ESM模块的时候更是一脸懵逼,直接报错了既然我们已经知道了是moduleResolution
解析算法不匹配的原因,就只需要把算法修改为正确的Node16
即可,在该模式下可以匹配正确的Node算法,并根据配置自动处理ESM和CJS模块输出。
// tsconfig.json: 修改module会自动匹配对应的算法
{
"compileOptions": {
"module": "Node16"
}
}
修改后,我们还需要给我们的路径引入添加上拓展名:
需要注意的是,这里的.js
会自动匹配所有类型的扩展名,也可以被Typescript正确处理。
修改后,我们使用ts-node和tsc+node分别运行验证,均正常工作:
最近又开始捣鼓tsconfig相关的配置,主要集中在语法范围标准这一块,包括 lib
和 target
这些配置,让我自然而然地想到了之前处理过的 module
配置,就想着攒一套可以用于现代工程开发的 TS 配置,就回来看了看,结果就发现了新问题。
我在node v20上尝试复现博客内容,在做了以下工作:
type=module
,告诉 Node 这个文件夹内都是ESM优先结果还是得到了下面的错误:
我在Github上找到了相关的Issue,发现是Node 16以上版本会存在该问题。
于是我尝试降级到Node16,果然就可以正常运行了。
作者: ChlorineC
创建于: 2024-02-19 15:54:00
更新于: 2025-01-05 09:03:00
版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
TypeScript 的类型声明文件 .d.ts 提供了 JavaScript 代码的附加元信息,支持类型提示和自动补全。使用 DefinitelyTyped 仓库可以为没有类型声明的 npm 包提供类型支持,类型定义可通过 type 和 interface 进行,declare 语法用于为已有的 JS 变量或函数添加类型。三斜杠指令用于引入额外的文件依赖。
前端模块化的演变包括CJS、AMD、CMD、UMD和ESM,现代浏览器支持ESM,webpack负责模块处理和打包,确保在浏览器中高效运行模块,支持代码拆分和Tree Shaking以优化性能。
Bun 1.0是一个新的JavaScript运行时,作为Node.js的替代品,提供更快的包管理和开发解决方案。尽管目前在生产环境中尚不成熟,但它集成了多种工具,支持统一模块标准和Web API,具有显著的性能优势。Bun的包管理器速度比pnpm快4倍,支持原生JSX和TS,且具备构建工具的潜力。整体来看,Bun在运行时和工具链上均有加速效果,但仍需进一步发展以满足生产需求。
在React中,useRef和useEffect常用于管理组件的可变值和副作用,但在响应DOM元素挂载时,使用useCallback作为ref的回调函数更为有效。useCallback确保在元素挂载和卸载时被调用,避免了因useRef导致的生命周期回调未触发的问题。文中还介绍了其他几种优化Node操作的方法,如使用useState、useStateRef和useRefWithCallback,以提高性能和管理复杂性。