ES Module 学习
发展历史
- 无模块时期
仅浏览器使用 js,通过<script>
标签的先后顺序在设置 global scope 的模块引用。amd/seajs 等现已不用,忽略 - Commonjs 时期
Nodejs 为了模块化发明了一种模块加载方式,同步加载,require 后立即执行 - ES Module 时期
ES5 开始 js 引入了模块标准,同时还能解决前端对于 treeshaking/编译时就能分析依赖结构等需求,异步加载,因此可以支持 top-level await
主要疑问
1. .mjs/.cjs/type:module 都是什么
ECMAScript 没有规定文件名规范,是**“Host Environment宿主环境”**决定了规范。
Host 通常有:Node.js / 浏览器 / Bundler
.mjs/.cjs 是 Nodejs 为了兼容 Commonjs 和 ES Module 提出的规范:
- 如果文件名为 .cjs, 则强制使用 Commonjs 规范,不能用 import
- 如果文件名为 .mjs, 则强制使用 ES Module 规范,不能用 require
- 如果 package.json 中指定了
“type”: ”module”
, 则强制使用 ES Module 规范
2. 导入包一定要用 import x from ‘foo.js’ 这样的带文件后缀方式吗
- ES Module 规范中
import { foo } from “module”
,module 可以是- 相对路径:如
'./utils.js'
、'../lib/math.js'
- 绝对路径(URL):如
'https://example.com/module.js'
- 裸模块名(bare specifier):如
'react'
、'lodash'
- 相对路径:如
- 浏览器在解析 ES Modules 时要求必须提供完整的文件路径和扩展名
import x from './utils.js';
- 例外1: 前端普遍使用 vite/webpack 等打包器,会自动帮忙加入这个扩展名, 此时根据配置,可能可以省略
- 例外2: 使用 Import Maps,就可以使用裸模块名,但根据 caniuse 的数据,这个支持的很不好,实际也不太常用**
{
"imports": {
"react": "https://cdn.skypack.dev/react"
}
}
- Node.js 环境下, 如果指定使用 ES Module(前文说明的.mjs文件或package.json写type:module),则也强制提供完整文件路径和扩展名。如果是默认的 commonjs 则不要求
3. package.json 中的 imports/exports 关键字是什么
Node.js 为了兼容 Commonjs 和 ES Module 提出的一些自定义关键字
exports
- 为了替代原 package.json 的 main 单一入口文件,exports 能支持定义多个子模块入口
- 隐藏了包的其他路径,以 Eggjs 曾经的一个 bug 为例,由于其依赖的 koa 不允许导出某些路径,导致 egg 的一个 require 逻辑报错 https://github.com/eggjs/egg/issues/4809 注意这并不能绝对阻止路径导出,如果是通过”绝对路径“来导入,这个阻止逻辑不会生效
- 提供多种导出方式,兼容 Commonjs 和 ES Module
exports 导出有如下几种写法
- 子路径导出
{
"exports": {
".": "./index.js"
}
}
// 可以完全替代 main 的写法
{
"main": "./index.js"
}
// 并且可以通过语法糖的方式简化
{
"exports": "./index.js"
}
```json
{
"exports": {
".": "./main.js",
"./utils": "./utils/index.js"
}
}
```
- 条件导出
根据 ES Module 和 CommonJS 提供不同导出模块版本
{
"exports": {
".": {
"import": "./esm/index.js",
"require": "./cjs/index.js"
}
}
}
- 默认导出
{
"exports": {
".": {
"default": "./dist/index.js",
}
}
}
可以以 Koa 的导出为参考
https://github.com/koajs/koa/blob/master/package.json
imports
- 创建路径别名,替代类似于 TypeScript 的 baseUrl 和 path 等功能,必须以 #开头
- ES 规范中 # 通常是私有的,用 #开头的导入不能被其他包访问
似乎很少见到有包在用,但如果试用过 ts-go,会发现他们在将来的 ts v7 版本中可能会废弃对 paths 的支持,而是要求人们使用 ES Module 的 imports,估计未来会有大面积使用
4. import()
- require 支持 require(
${fooPath}/xxx
) 这样的方式加载包,但 import 并不支持,所以 ES2020 引入了 import() 提供这样的”动态路径加载“的方式
async function loadRoute(route) {
const module = await import(`./routes/${route}.js`);
module.render();
}
Typescript 模块
由于 TypeScript 的特殊定位,他必须要既能兼容 Nodejs 的模块,又能兼容 ES Module, 还要兼容现代前端的 Bundler 如 Vite/Webpack 等。所以可能有很多复杂的配置让他既能模拟 Nodejs 行为,又能模拟浏览器行为,在官方文档有详细的说明 ,这里只看常用的
tsconfig.json 中关于模块相关的配置 https://www.typescriptlang.org/tsconfig/
- module 指定 ts 编译后生成的模块格式
-
es2015/es2020/es2022/esnext:生成 ES 模块,更新版本支持更新的功能, 如 es2020 支持
export * as ns from “mod”
, es2022 支持 top-level await,esnext 目前等于 es2022 -
node16/node18/nodenext: 适用于 Node.js 12 及以上版本,同时支持 ES 和 CJS,能互相操作(如在 ES 模块中 require)
-
commonjs:nodejs 的 Commonjs 模块 注意 TypeScript 官方已不推荐新项目使用 commonjs
-
umd/amd/system 等很少用了
-
如果 tsconfig 的 “target” 为 es5 及更低,module 默认为 commonjs, 否则为 es6, 这一点在从零开始搭建 tsconfig.json 时可以留意一下
-
ts 文档中特别提示:
Node.js 的模块格式检测与互操作规则,使得在运行于 Node.js 的项目中将
module
设置为esnext
或commonjs
是不正确的,即便所有由 TypeScript 编译器(tsc
)输出的文件确实都是 ESM 或 CJS 格式。对于打算在 Node.js 中运行的项目,唯一正确的
module
设置应为node16
或nodenext
。虽然在一个完全使用 ESM 的 Node.js 项目中,使用
esnext
和nodenext
编译所生成的 JavaScript 看起来可能完全相同,但它们的类型检查行为可能不同。更多详细信息,请参见关于nodenext
的参考部分。
-
- moduleResolution 指定 ts 如何查找模块
- node/node10:仅支持 commonjs
- node16/nodenext: 适用于 Node.js 12 及以上版本,支持 ES 和 Node.js 的包解析规则. 目前 nodenext 就是 node16
- classic:早期(ts 1.6之前)使用,已废弃
- bundler: ts5引进,前文提到 vite 等工具会魔法的给 es module 自动加入后缀名,ts 可以兼容这种行为, 还兼容了 exports/imports 关键字
- esModuleInterop 支持默认导入
- module 为 node16/nodenext 时,默认为 true,否则默认 false
- allowSyntheticDefaultImports 允许从没有默认导出的模块中默认导入, 仅用于类型检查
- resolveJsonModule 允许导入 .json
- baseUrl 和 paths 配置模块导入的基准路径和路径映射,简化模块导入路径
- rootDirs 将多个目录视为一个虚拟目录
{
"compilerOptions": {
"rootDirs": ["src", "generated"] // 会将 src 和 generated 目录视为同一级别
}
}
- importHelpers 引入 tslib,减少很多 polyfill
- isolatedModule 前端 babel 使用的配置,确保每个文件可以单独作为模块进行编译
Typescript 扩展功能
exports =
和import = require()
- typescript 提供的完全兼容老旧 CommonJS 包的 module.exports = … 形式的功能(如果不启用 esModuleInterop)
- module.exports 类似于 ES module 的 export default, 推荐直接用 export default
- export type