侧边栏

TypeScript模块机制

发布于 | 分类于 前端/模块化

在之前的一篇文章提到了NodeJS中CommonJS和ESModule混用的问题。

现在的前端项目中,还可能存在TS和JS的混用,叠加上各种模块机制的混用,导致整个项目比较复杂,以至于在开发过程中,可能会遇见很多不同的错误,这些错误大多数是没有弄懂TS的模块机制导致的。

本文将整理TypeScript中的模块系统,以及模块混用时的一些常见问题。

参考

这两篇官方文档,非常建议大家详细阅读一下。

TypeScript的两个任务

让我们思考一下,一份TS代码最终会在哪里运行

  • esbuildBunDeno可以作为TS的运行时runtime,直接运行TS代码
  • 浏览器或者NodeJS 无法直接运行TS代码,需要通过tsc编译之后才能被加载和运行
  • ts-node内置了tsc的步骤,将TS代码转成JS代码后使用NodeJS运行,这种工具也被称作转义加载器transpiling loader
  • 一些打包器如webpack,可以直接处理TS代码,并生成打包bundle文件

这些TS代码最终运行的环境(运行时和打包器)也被成为主机 host

类型检测与编译

可以将上面的host分为两大类

  • 使用ts编写NodeJS程序,完成某些特定的脚本任务
  • 使用ts编写前端业务逻辑,最后通过webpack、vite等构建前端应用

我们暂时忽略使用Deno等TS的运行时、或者ts-node等转义加载器直接运行TS的场景,只考虑TS会被编译的场景。

如果是编写NodeJS程序,那么整个程序需要被tsc编译后,才能够被NodeJS运行。TS在这个时候需要承担类型检测编译TS为JS代码的任务。

如果是编写前端应用,整个程序会交给打包器webapck等构建,TS只需要提供类型检测的任务。

需要明确的是,TS的主要目标是为JavaScript代码添加静态类型检查,提前在编译阶段发现并捕获代码的运行时错误,而不是作为一种构建工具。

那么,当TS在遇到import语句的时候,到底会干什么呢?

  • 对于类型检查任务,TS需要知道如何加载这个文件中的类型信息,从而提供类型检查的能力

  • 对于编译任务,TS需要将编写的模块语法,转换成构建目标支持的模块语法

    • 注意TS并不会并不会将对应模块的js或者ts代码引入进来,这个是webpack等打包器、或者NodeJS等运行时才会做的事情

这两个任务对应了两个不同的配置字段

  • module对应编译任务的模块语法转换
  • moduleResolution对于类型检查的模块解析

模块类型module

由于存在多种Host,开发者需要通过配置来告诉TS的编译器的规则。TS通过module字段来判断模块文件采用的模块系统,该字段

  • 告知TS编译器如何检测每个文件的模块类型,允许不同类型的模块互相导入,以及是否提供像 import.meta 和顶层 await 这样的特性。
  • 告诉Host最终最终产出的 JavaScript 的模块格式,比如是ESM还是CJS
    • 如果是使用tsc编译,tsc也会根据该字段编译出对应的js产物

下面例举了一些常见的module字段取值。

nodenext

目前与node16反映了 Node.js v16+ 的模块系统,它支持 ES 模块和 CJS 模块并排使用,具有特定的互操作性和检测规则

module配置项为nodenext的时候,TypeScript会采用与与NodeJS一样的模块检测机制

  • .mts/.mjs/.d.mts 会被当做ESM模块
  • .cts/.cjs/.d.cts 会被当做CJS模块
  • .ts/.tsx/.js/.jsx/.d.ts 会根据最近的package.json中的type字段来判断,如果为module,则会被当做ESM模块;否则会被当做CJS模块

当这些文件被tsc编译成的js代码的模块类型,也会根据上面的规则讲文件内的代码编译成对应的模块语法。

因此如果ts代码只是运行的NodeJS环境下,nodenext将会是最适合的module配置值。

esnext或者commonjs

此外module还有另外一些常用了配置项

  • esnext: 目前与 es2022 相同,反映了最新的 ECMAScript 规范,以及预计将包含在即将到来的规范版本中的与模块相关的 Stage 3+ 提案,
  • commonjs, system, amd, 和 umd: 每个都以命名的模块系统发出一切,并假设一切都可以成功地导入到那个模块系统中。这些不再推荐用于新项目

大多数 TypeScript 文件都是使用 ESM 语法(importexport 语句)编写的,而不考虑输出格式

不修改模块路径

TS编译器并不知道最终TS代码的运行环境,也不知道开发者编写的import代码,最终会被哪个Host来处理。

因此,TypeScript有一条规则:模块路径字符串module specifier会不会被处理

关于这条规则,TypeScript小组的开发负责人RyanCavanaugh在某个issue下也给出了回复TypeScript doesn't modify JavaScript code you write

TS不会做、也不会提供相关配置项,用于修改模块的路径字符串

这个规则的原因,在后面模块解析的部分会进一步说明,先简单看一个例子试一下

ts
import m3 from './m3.xxx'

即使根本不存在./m3.xxx这个模块,tsc会给错误,但还是会编译出下面的内容(module:commonjs),不会修改模块的路径

js
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const m3_1 = __importDefault(require("./m3.xxx"));

即使module为esnext等,tsc还是会输出如下所示的js代码

js
import m3 from './m3.xxx'

从这个角度也可以看出,ts只关心import的模块类型,并不关心模块的运行时代码。

因此,TS的编译是比较有限的

  • 将ts语法编译成对应版本的js代码,由target字段指定
  • import等模块相关的语法转成其他的语法代码require之类的模块代码转换

TS的模块语法

要知道TS如何编译模块语法,就得先知道TS支持哪些模块语法。Typescript中的importexport,与ES6的ESM模块语法基本相似

除此之外,TS的模块系统还有一些扩展用法

import type

除了导出了实际的值(如变量和方法),TS还支持导出类型

ts
export interface Person {
  	name: string
}

可以通过import引入模块的内容,包括模块导出的类型

ts
import { Person } from './mod1'

对于某个类型而言,即可以通过import导入,也可以通过import type导入,这两者有什么区别呢?

typescript 3.8文档提到

导入类型只导入要用于类型注释和声明的声明。它总是被完全擦除,所以在运行时没有残留。

类似地,export type只提供了一个可以用于类型上下文的导出,并且也会从TypeScript的输出中删除

也就是说import type完全是非运行时的,只是用来进行类型声明和检查,在打包后运行时是完全不存在的。

因此,像enum这些运行时相关的类型就不能通过import type来引入,否则编译时就会报错。

环境模块

参考:

如果一个文件不带有任何顶级的import或者export声明,那么它的内容被视为当前ts项目中全局可见的,也被称作环境模块Ambient Modules

比如下面这段测试代码,在一个global.ts中声明了一个类型GlobalTypeA,由于该文件中没有使用importexport字段,因此就是一个全局模块。

index.ts中,就可以直接使用这个global.ts文件中定义的类型GlobalTypeA,而无须显式引入。

这是因为 TypeScript 默认采用了一种叫做“全局模块”的模块解析策略,这种解析策略是为了兼容早期的 JavaScript 开发方式。

在早期的 JavaScript 中,并没有模块系统,所有的变量和函数都是在全局作用域下定义的。

为了与这种方式保持一致,TypeScript 允许在没有显式声明模块的文件中,定义的变量和函数可以被其他文件访问,就像它们是在全局作用域下定义的一样。

根据全局变量一样,全局类型也会可能污染全局作用域,下图演示两个全局类型冲突的情况

解决办法是将某个文件内添加export或者import,通过类型覆盖避免冲突。

在现代的 JavaScript 开发中,通常会使用模块系统来组织代码,以避免全局作用域的污染,并且提供更好的封装和可维护性。

因此,推荐的做法是尽可能地使用模块化的方式来组织 TypeScript 代码。也就是说,即使是在没有显式声明模块的文件中也可以使用 exportimport 来明确指定文件之间的依赖关系,从而避免全局命名空间的冲突和不确定性。

环境模块中的普通类型可以使用,因为在编译时会被忽略;但是全局模块中的值、以及枚举等运行时相关的类型,则不能被使用,否则在构建时会报变量未定义的错误。

allowJs

由于js庞大的模块生态,在某些情况下,我们不得不混用js和ts文件,比如在一个新的ts文件中引入旧的js文件模块中的API。

js
import m3 from './m3.js'

要在ts中加载js,首先需要设置allowJs为true,该字段允许ts加载js模块,否则会提示TS7016: Could not find a declaration file for module

allowJs为true时,还可以通过设置checkJs为true,借助ts编译器对js文件进行类型检查,从而在js文件中发现潜在的类型错误。

js中本身没有类型,ts编译器如何发现潜在错误呢?

虽然 js 本身并没有显式的类型系统,但是 ts 编译器会尝试根据变量的使用情况、函数的参数和返回值等上下文信息来推断类型,并且根据这些推断的类型进行静态类型检查

具体来说,ts 编译器会根据以下几个方面来进行类型推断和检查:

  • 类型推断:ts 编译器会根据变量的赋值表达式、函数参数、函数返回值等上下文信息来推断变量的类型。例如,如果一个变量被赋值为一个字符串,那么 ts 就会推断它的类型为字符串类型。

  • JSDoc 注释:ts 中可以使用 JSDoc 注释来提供类型信息。ts 编译器会尝试解析JSDoc注释中的类型信息,并根据这些信息进行类型检查。

  • 类型注解:在 ts 文件中,你也可以使用 ts 类型注解来明确指定变量的类型。例如,你可以通过 /** @type {string} */ 注释来明确指定一个变量的类型为字符串类型。

不过类型检测需要一定的时间,如果是大量校验安装在node_modules中的第三方库,可能会导致很多无意义的js文件被检查,maxNodeModuleJsDepth可以限制检测的层级。

兼容CJS

目前,绝大多数TS文件都是使用 ESM 语法(importexport 语句)编写的。

由于历史原因,JS生态中存在大量CJS的模块。如果在TS中引入CJS模块,有下面两个特定的语法

esModuleInterop

js
// m1.cjs
module.exports = {
    test() {
        console.log('test')
    }
}

要在ts中引入这种CJS的模块,可以通过下面这种方式引入

ts
import * as m1 from './m1.cjs'

m1.test()

这种引入的方式并不是很优雅,ts提供了一个配置项esModuleInterop,当将该选项设置为 true 时,TS就支持像下面这种方式来导入CJS模块了

ts
import m1 from './m1.cjs'

export =

此外,为了兼容CJS和AMD的模块系统中的exports变量,ts还支持export = 方式。

export = 是一种默认导出的方式,它允许将一个值或对象作为整个模块的默认导出。

ts
export = {
    test(msg:string) {
        console.log('mod test')
    }
}

这种方式等价于CJS中的module.exports一致

js
module.exports = {
    test(){}
}

如果要引入export =导出的模块,需要通过import = require()

ts
import mod2 = require('./mod3')
mod2.test()

需要注意的是这种写法只能在tsconfig.json配置了"module": "commonjs"的情况下使用,如果配置的是esnext等字段,会出现如下提示

上面这两种特殊的兼容的写法,建议只在非用不可的场景下使用(比如某个强依赖的第三方库,只提供了CJS的支持)。

对于这种ESM模块与CJS模块混用的做法,不同的host对于其支持也不尽相同

  • 纯ESM模块,比如浏览器,不支持CJS模块
  • 打包器,一般同时支持ESM和CJS互相调用,并最终构建bundle文件
  • NodeJS,CJS需要动态引入ESM的模块;ESM可以直接引入module.exports,具体参考:NodeJS中CommonJS和ESModule混用的问题

不携带.ts后缀

在浏览器或者NodeJS中使用ESM,import模块时需要显式携带文件的后缀

  • 浏览器本身需要通过网络请求加载服务器上面的文件资源,肯定是需要完整的文件路径
  • Node.js早期的一个设计缺陷就是require会自动推断后缀、尝试添加index等默认行为,导致模块解析过于复杂;因此在支持ESM时,修复了这些缺陷,也需要手动指定文件后缀。

但是在ts中,引入ts模块时,并不能添加文件后缀.ts,否则反而会得到如下错误提示

从编译的角度思考一下,对于下面这段ts代码,如果添加了ts后缀

ts
// ts代码
import { test } from './m4.ts'
test()

在由于ts不会修改模块的路径字符串,最终输入的JS代码中还是会包含如下内容

js
// js代码
// module:esnext
import { test } from './m4.ts'
// module:commonjs
const m4_1 = __importDefault(require("./m4.ts"));

这显然是会报错的,js找不到这个名字为m4.ts的模块,这也是为什么.ts在默认情况下是不能编写的原因。

既然不能写后缀,那我就把这个后缀去掉就可以吗?去掉后缀,重新tsc编译

ts
import { test} from './m4'
test()

那么这段代码可以运行吗?

显然也是不行的,module:"nodenext"构建的是ESM模块,NodeJS要求ESM模块引入本地模块时必须知道文件后缀,否则会提示

Error [ERR_MODULE_NOT_FOUND]: Cannot find module

WTF?加也不行,不加也不行?

要让编译后的代码可以被NodeJS ESM正常运行,可行的方法是:将ts代码添加.js后缀

ts
import { test} from './m4.js'

WTFFF?

现在编译后的js代码可以正常执行了,但目录下都没有m4.js这个文件,只有m4.ts这个文件,为什么ts不会报错,可以正常解析呢?

这个./m4.js,就可以看做是开发者为了构建目标module:"nodenext"编写的模块路径,TS是如何理解这个路径,在不报错的情况下,还可以加载对应文件的类型呢。

看起来我们需要深入学习TS的模块解析。

模块解析moduleResolution

模块解析module resolution,指的是如何根据模块字符串加载模块文件。

上面这个明明存在的./m4.ts模块会发出警告,而明明不存在的./m4.js文件模块却可以正常解析,就可以看做是TypeScript的一个特殊的模块解析规则。

不同Host的模块解析

参考: tc39 module 文档

虽然 ECMAScript 规范定义了ESM模块,给出了如何解析和解释import语句export的规则,但并没有定义如何进行模块解析;相反地,它将这个实现留给了host。

因此,很多运行时和打包器,特别是那些想要同时支持ESMCJS 的,都各自实现了自己的模块解析,不同host的模块解析有很大差异。

举个例子

  • NodeJS为了解决历史问题,严格要求ESM模块必须写上文件后缀.js.mjs
  • 而在webpackrollup等打包器中,这些后缀是可以通过一些配置完全可以省略的
js
resolve: {
  extensions: ['.js', '.mjs', '.jsx', '.json'] // webpack尝试按此配置列表解析确定的扩展名
}

由于TypeScript编译器不修改模块路径,开发者需要自己控制模块的路径编写方式,这样才能保证编译后的js代码可以正常运行。

这要求开发者必须根据配置的module,用合适的语法编写模块的引入路径,比如是commonjs就可以不写js文件后缀,而esnext的就要写js文件后缀。

这也是为什么上面在ts文件中,要编写./m4.js的原因,只有这样,编译后的js代码才可以在NodeJS的ESM模块下运行。

ts
import { test} from './m4.js'

由于TypeScript的最主要目的是是提前在编译阶段发现并捕获代码的运行时错误,因此TypeScript必须要理解这些开发者为module编写的各种模块路径,这样TS才能够加载这些文件里面的类型,并进行类型检测。

因此TS会尝试根据构建目标Host,模拟对应Host的模块解析方式,来解析import等模块路径。

这个模拟过程是很重要的,即使我们最终并不需要通过TS来编译JS文件。

比如我们正在用webpack编写ts开发的前端应用,在开发过程中,我们

  • 会通过ts-loader等加载ts文件并最终将应用渲染在页面上进行调试;这要求我们引入的模块路径必须按照webpack能够解析的方式进行,可能是配置reslove.extensions忽略扩展名,或者是配置reslove.alias支持@/components等方式
  • 此外我们还需要依赖ts的类型提示,获得更好的开发体验;这要求ts必须能够理解文件中编写的模块路径

理解这个先后顺序非常关键,我们可以不使用ts,但我们要通过webpack(或者其他打包器,这个不重要)来构建前端应用,因此按照对应打包器要求的模块解析方式编写路径是必须的。

在这个基础上,我们使用了ts,并希望获得类型提示,由于Host解析路径方式是无法变更的,因此只有让TS来适配,即让TS来模拟对应的打包器(host)的模块解析。

moduleResolution

参考:moduleResolution官方文档

前面提到,开发者会根据Host的模块解析规则来编写模块路径,以NodeJs作为Host为例,module配置项决定了开发者编写模块路径的方式。

同时,TS会模拟Host的模块解析,这些模块路径去加载类型,方便进行类型分析。

那么能不能根据直接module配置项对应的模块格式,来模拟对应的Host(这里也就是NodeJs)模块解析的方式呢。

答案是不行的,单纯根据module字段,并无法推断出具体的host,比如同一种模块格式如ESM,在NodeJS中跟在webpack中的模块解析方式也是不一样的。

由于不同Host的模块解析本身可能就有冲突通用的模块解析,所以内置一套模块解析规则并无法覆盖全部的情况。

因此TS还提供了一个moduleResolution配置项,让开发者告诉TS编译器应该模拟哪种Host的模块解析方式。

  • classic,TS自己的早期的默认模块解析方式,即将被弃用,不建议使用

  • node10,模拟NodeJS早期的require模块解析方式,不推荐在新项目使用

  • nodenext,按照新版本 node 的方式,判断当前项目是ESM还是CJS,然后寻找对应模块

  • bundler,一些打包器如webpack、rollup会使用package.json 中的 "exports""imports" 字段来进行模块解析,这种模块解析模式为针对打包器的代码提供了一个基本算法

一些具体的规则

OK,让我们来看看TypeScript是如何解析这个不存在的./m4.js文件的。

正如我们反复提到的:TS的首要任务是为JavaScript代码添加静态类型检查,在查找一个模块文件时,TS只会去解析对应的类型文件。

具体来说,TypeScript 编译器在查找模块文件时,会进行文件扩展名替换,会按照 m4.tsm4.tsxm4.d.tsm4.js顺序进行解析:

即使明确地写了 import './m4.js',TypeScript 编译器仍会首先查找 m4.tsm4.tsxm4.d.ts 这些文件。

如果这些 .ts 文件都不存在,那么编译器就会尝试查找 m4.js 文件。

即使 m4.js 文件不存在,TypeScript 编译器也不会报错,因为它期望在构建过程中,入口文件的 .ts 文件被编译为 .js 文件后,其导入的 m4.js 自然也就存在了。

所以在上面的这个例子中,尽管工作目录下只有 m4.ts 文件,没有 m4.js 文件,但 TypeScript 编译器认为这没有问题,因为 m4.ts 将被编译为 m4.js

当然,如果模块文件真的无法被解析,TypeScript 编译器最终还是会报错的。

TypeScript也会根据定义的的moduleResolution,来模拟对应Host的模块解析机制。

即:将import/export/require等语句中的字符串字面值解析为磁盘上的文件,方便TS进行类型检测,如果开发者编写的路径,无法被TS解析出来,TS就会抛出Cannot find module的错误。

TS具体要模拟的一些模块解析规则,包括

  • 文件扩展名替换,比如上面演示的m4.tsm4.tsxm4.d.tsm4.js的等过程
  • 忽略后缀
  • 目录模块,将./mod解析为./mod/index
  • paths路径别名
  • baseUrl
  • package.json 中的exports字段

根据不同的moduleResolution,这些规则可能有不同的处理方式,具体的解析规则可以参考官方文档,这里不再赘述。

需要单独提一下的是paths字段。

webpackvite等打包器有一个路径别名resolve.alias的配置项,允许在代码中编写诸如@/components的模块路径

js
resolve: {
  alias: {
    '@': path.resolve(__dirname, './src'),
  },
},

在这种情况下,如果想要TS也能够找到具体的模块文件,可以在tsonfig.json配置了paths

json
"paths": {
  "@/*": [
    "src/*"
  ]
}

paths只是告诉TS去某个路径查找类型,并不会修改最终编译成的js代码引入路径。

所以如果是先在ts的paths配置了路径别名,由于ts编译时并不会修改模块路径,在这种情况下,输入文件需要结合对应打包器的配置,才能保证最终编译的js代码正确运行。

如果最终Host是NodeJS,由于其本身不支持路径映射,需要安装额外的包入module-alias,这里也不再展开。

携带.ts后缀

在了解了上面的规则之后可以发现,在大多数时候,好像都不需要为模块添加.ts后缀。

但如果你期望编写跟NodeJS ESM模块一样的代码,显式添加.ts后缀,比如下面这些场景

  • TS代码会通过打包器如webpack的处理
  • 直接使用Deno、bun等运行时运行ts代码,不会编译成js代码
  • 直接使用ts-node等转义加载器运行ts代码

这个时候,可以

  • 打开emitDeclarationOnly配置项,声明不会显式生成js代码,
  • 然后打开allowImportingTsExtensions运行.ts后缀

之后,就可以编写import './m4.ts'这样的代码了。

练习:ts-node的常见错误

编写的ts代码,需要使用特定的运行时,或者将ts代码经过tsc编译转成js代码之后,才能被运行。

工作中比较常用的是ts-node。下面整理了ts-node常用的一些问题,用上面的知识可以很快定位。

在执行ts-node xxx.ts命令时,会读取当前项目的tsconfig.json配置项,将ts代码转成js代码后通过NodeJS运行js代码。

ts-node在运行时会既会读取 package.json 又会读取 tsconfig.json,在这个过程中

  • 输出的js文件内的模块代码,由文件后缀或者tsconfig.json中的module字段控制
  • NodeJS运行时采用的模块,由文件后缀或者package.jsontype字段确定

如果二者的配置相互冲突,运行的时候就会报错,来看看几种具体的错误和具体原因。

CJS && esnext

比如整个项目是CJS的,但是tsc配置的moduleesnext,运行时就会提示

SyntaxError: Cannot use import statement outside a module

这是因为NodeJS默认的CJS不会识别import

ESM && esnext

整个项目是ESM的,也让tsc输出了esnext,但直接使用ts-node运行代码会提示

TypeError: Unknown file extension ".ts"

这是因为NodeJS的ESM是一个实验性质的,在引入 ESM 之后,Node.js 花了 5 年时间才在没有 --experimental-modules 标志的情况下支持它。

为了保证稳定性,ts-node通过开关来进行控制ESM的运行,因此运行ESM模块时,需要显式声明。

可以通过ts-node --esm、 或添加"ts-node": {"esm": true}tsconfig.json中。

ESM && commonjs

如果整个项目是ESM的,但tsc输出的是commonjs,即使配置了ts-nodeesm选项,也会出现

ReferenceError: exports is not defined in ES module scope

这是因为tsc输出的代码中插入了下面的代码(注意该代码只会在ts文件是一个模块的情况下插入,即ts文件中使用了import或者export

js
Object.defineProperty(exports, "__esModule", { value: true });

而在ESM中,NodeJS并没有exports这个全局变量,所以就报错了。

可以看出,要使ts-node能够正常运行ts代码,对于package.jsontypetsconfig.json中的module都有要求

  • type不配置,或者配置为commonjs,module配置commonjs
  • type为module,module输出esnext,同时ts中的源就不支持export = 等写法

小结

TypeScript的代码会在各种不同的打包器或者运行时(统称为Host)下面运行。

TypeScript的首要目标是进行类型检测,根据这个原则,了解了模块系统在Typescript中的设计理念:

  • module字段,告诉Host最终生成的js模块使用的模块格式
  • moduleResolution,模拟Host进行模块解析,并尝试加载对应模块的类型信息进行提示。

理解这两个配置项,是深入学习TypeScript模块的基础。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。