TypeScript Module 踩坑记

众所周知,现代前端最大的难点在于和一堆说不清道不明的配置文件打交道,在 TypeScript 盛行的今天,想要绕过 tsconfig.json 基本是不可能的事。今天就是在 module 和 moduleResolution 上踩坑了,所以记录一下。

无论什么语言,都要有把文件抽象成一个模块被其他文件引用的机制。而在 JavaScript 这里比较麻烦。

有关 JavaScript 的模块机制,其实有很大的历史原因,这个说起来有点麻烦,而且也不是本文的重点,所以这里就不说了(我相信如果你想知道的话能找到像山一样多的资料)。

两种模块(如果你已经熟悉 cjs 和 esm 可以跳过)

目前主流模块有两种:CommonJS(CJS) 和 ESModule(ESM)

其中 CJS 用的是:

// a.js
module.exports = {
  hello: 'hello'
}

// main.js
const a = require('./a.js')

ESM 用的是:

// a.js
export defualt {
  hello: 'hello'
}

// main.js
import a from './a'

CJS 出现得比 ESM 早,ESM 是语言标准,但 CJS 存在于各种历史代码中,所以目前大部分主流工具都支持两种模块写法。

目前的约束是:如果文件以 .cjs 结尾,则认为模块使用 CommonJS,如果以 .mjs 结尾,则认为模块使用 ESModule。如果以 js 结尾,则看 package.json 里面的 type 字段,如果 type 字段是 module,则 .js 文件使用 ESM,如果为 commonjs 或者没有,则 .js 文件使用 CommonJS。

问题

上面只是顺带介绍一下两种模块,本次遇到的问题和上文无关。

这次遇到的问题是,把 tsconfig.json 中的 module 和 moduleResolution 改为以下内容后,ts 文件中的 import 失效了。

明明之前都可以的,为什么改了这两个字段就不可以了呢?

TypeScript

TypeScript 只是一个语言标准,意在给 JavaScript 工程提供类型提示,目前基本所有大型新工程都会使用 TypeScript 编写,很少会直接去写 JavaScript,所以 TypeScript 已经成为工业上的事实标准了。

TypeScript 大部分时间需要转译为 JavaScript 才可以运行。听说已经有支持直接运行 TypeScript 的工具了,不过我不了解这个。

tsc

TypeScript 官方出的工具叫 tsc,可以用来检查 TypeScript 语法,也可以把 TypeScript 代码转译为 JavaScript。但是能把 TypeScript 转以为 JavaScript 的工具有很多,除了 tsc,Babel 也可以转译 TypeScript。

另外一些打包工具不仅会转译,还会自动把代码打包成不同的片段,比如 Webpack、Vite、Rollup、ESBuild 等工具。这时,通常会使用 tsc 作为语法检查工具,但不会用于打包。这么做的时候,一般会把 compilerOptions 中的 noEmit 设置为 true,这样 tsc 就不会输出转移后的文件,真正的转译工作则是交给打包工具。

tsconfig.json 是用来配置 tsc 的,且只用来配置 tsc。其他打包工具有自己的配置, tsconfig.json 的修改不会影响到打包工具。一开始很多新手(包括我)不明白为什么修改了 tsconfig.json 却对打包产物没有影响,其实是因为没有分清楚 tsc 和其他打包工具的区别。

宿主

JavaScript 代码可以运行在很多地方,主要的两个环境是浏览器以及 Node.js。在上面的示例工程中,我们用 tsc 生成了 js 代码,最终会使用 node.js 来运行这个 js 代码。

js 代码最终的执行环境被称为「宿主(host)」,在这里,我们的 host 就是 node.js。

在 ESModule 还没有流行起来前,旧版本的 Node.js 只支持 CommonJS module。所以,在以前如果想在 .ts 文件里写 ESModule,又想要让代码运行在 Node.js 上的话,还需要先把使用 ESModule 方式的代码转译成使用 CommonJS 的代码。

在 tsconfig.json 中,compilerOptions 的 module 字段用于指定 tsc 转移后的代码应该使用什么样的 module 格式。如果指定 “module”: “commonjs”,则会把代码转译为使用 CommonJS 模块的代码。可以在 .ts 文件中写 import xxx from 'xxx',转译后的代码就会变成 const xxx = require('xxx')

不过,现在的 Node.js 和主流浏览器早就原生支持 ESModule 了,不用特意转译成 CommonJS 也可以在 Node.js 环境上运行。

运行模型

整个流程大概如图:

所以,需要生成什么样的 .js 代码,要看宿主支持什么样的模块系统。.ts 代码使用的模块系统不一定和宿主使用的模块系统一致。

Module 和 ModuleResolution

那,为什么我们把 module 和 moduleResolution 改成 “nodenext” 就出问题了呢?

有关这两个选项,TypeScript 官方文档有很详细的解释:

https://www.typescriptlang.org/docs/handbook/modules/theory.html#the-module-output-format

https://www.typescriptlang.org/docs/handbook/modules/reference.html#node16-nodenext

不过不用看也可以,下面我会帮你总结。

Module

module 字段决定的是「生成的 .js 代码使用什么样的模块系统」,就算同样是 ESModule,版本不同,支持的特性也不同。所以会有这么多的选项:

目前 “nodenext” === “node16″,这里需要注意,如果 module 指定了 “nodenext”,那也就默认 “moduleResolution” 为 “nodenext”。

至于 “moduleResolution” 为 “nodenext” 意味着什么,等下会说。

ModuleResolution

这里可以直接引用文档中的话:

import xxx from 'ooo'

其中这个 'ooo' 叫 「import specifiers」,我暂且译为导入说明符。tsc 在转译代码的时候对导入说明符不做任何修改

在上面的例子中,无论 module 选项为什么,最终生成的代码都会类似于

import xxx from 'ooo'
const xxx = require('ooo')

'ooo' 这一部分是不变的。

也就是说,「怎么找到所引用的模块」这个工作完全是宿主决定的,转译工具没有办法知道,所以我们需要告诉转译工具宿主怎么解析导入说明符。这就是 ModuleResolution 的作用。

https://www.typescriptlang.org/docs/handbook/modules/theory.html#module-resolution-is-host-defined

上面的官网文档有详细解释,想看的可以看看,不看也行,我下面会解释。

tsc 会模仿宿主的行为

moduleResolution 确定后,tsc 会使用对应的方法解析导入说明符,去找到所引用的模块。

举个例子:

// a.ts
export default {
  eat: 'shit'
}

// main.ts
// import a from './a.ts' 会报错

import a from './a.js'
console.log(a.eat)

这个例子乍一看挺反直觉的。为什么 a 是 .ts 文件,导入的却是 a.js?

因为 tsc 不会修改导入说明符,如果导入的是 a.ts,会导致生成的 .js 文件里导入的也是 a.ts,但是生成文件中没有 a.ts,最终导致运行错误。tsc 意识到会有这个错误,所以在还没生成的时候就用语法检查提示你。

只要意识到 tsc 在转译代码的时候对导入说明符不做任何修改,很多事情就解释得通了。

下面对几种主要的 moduleResolution 进行介绍。

“moduleResolution”: “node”

现在(2024年7月),”node” === “node10” 。

它很好地模拟了早于 v12 的 Node.js 版本,有时也能近似反映大多数打包工具的模块解析方式。

在这种解析方式中,支持两种在工程中常见的导入方式:

导入不需要加后缀

// a.ts
export default {
  eat: 'shit'
}

// main.ts
import a from './a.js' // 可以
import a from './a'    // 也可以

console.log(a.eat)

可以使用文件夹导入

// b/index.ts
export default {
    holy: 'high'
}

// main.ts
import b from "./b"             // 可以
import b from "./b/index"       // 也可以
import b from "./b/index.js"    // 也可以

const print = console.log.bind(console)
print(b.holy)

可是现在(2024 年 7 月)的 Node.js 版本早就到达了 22.4,早就不支持这种解析方式了。如果指定 “moduleResolution”: “node”,并且使用以上这两个特性的话,用 Node.js 运行生成的代码会报找不到对应模块的错误。

“moduleResolution”: “nodenext”

现在(2024年7月),”node” === “node16″。随着时间推荐,”nodenext” 可能指向新的版本,而 “node16” 则会固定。

这种解析方式对导入说明符的要求更加严格,”moduleResolution”: “node” 中的两个特性都不可用了。也就是说要老老实实加上 .js 后缀或者指定 /index.js 文件,否则会报错。这和新版本中的 Node.js 表现一致。

指定 “moduleResolution”: “nodenext” 后,生成的代码可以直接在当前版本的 Node.js 运行。

“moduleResolution”: “bundler”

这种解析方式和 “moduleResolution”: “node” 很像,”moduleResolution”: “node” 的那两个特性都支持。这种解析方式会模拟主流打包工具的解析行为。

如果只使用 tsc 作为语法检查工具,而真正的转译工作交给其他打包工具的话,用这个选项就好。

破案

至此,我们已经拥有了所以解决问题的信息。

之前的 “moduleResolution” 为 “node”,所以不需要写后缀名也可以成功引入,后来改成了 “nodenext”,对导入说明符的要求更严隔了,省略后缀名的写法不被允许了,所以会报错。

但因为我的代码最终是要通过 vite 打包运行在浏览器上的,而不是运行在 node.js 上,所以 “moduleResolution”: “nodenext” 并不符合我的需求。

把 “moduleResolution” 改成 “bundler” 就好了。

补充

使用 tsc 进行转译的话,.js 文件结构会和 .ts 文件结构保持一致。

使用打包工具的话,因为对文件进行分块了,所以一般来说文件结构和原来的 .ts 文件是不一致的。

比如 Vite:

作者: 梁小顺

脑子不太好用的普通人。 顺带一提性格也有点古怪。 在老妈子和厌世肥宅中来回切换。

《TypeScript Module 踩坑记》有2个想法

  1. 在 lib 模式下利用 vite 的多入口特性,也是可以让 vite 的输出同原始输入一致的

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据