← 返回 Posts ES Module 学习

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):如 reactlodash
  • 浏览器在解析 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/i
  • ssues/4809。注意这并不能绝对阻止路径导出,如果是通过”绝对路径”来导入,这个阻止逻辑不会生效
  • 提供多种导出方式,兼容 Commonjs 和 ES Module

exports 导出有如下几种写法:

1. 子路径导出

// 可以完全替代 main 的写法
{
  "exports": {
    ".": "./index.js"
  }
}

// 等同于
{
  "main": "./index.js"
}

// 并且可以通过语法糖的方式简化
{
  "exports": "./index.js"
}

// 也可以定义多个子路径导出
{
  "exports": {
    ".": "./main.js",
    "./utils": "./utils/index.js"
  }
}

2. 条件导出

根据 ES Module 和 CommonJS 提供不同导出模块版本

{
  "exports": {
    ".": {
      "import": "./esm/index.js",
      "require": "./cjs/index.js"
    }
  }
}

3. 默认导出

{
  "exports": {
    ".": {
      "default": "./dist/index.js"
    }
  }
}

可以以 Koa 的导出为参考

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/

1. 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 设置为 esnextcommonjs 是不正确的,即便所有由 TypeScript 编译器(tsc)输出的文件确实都是 ESM 或 CJS 格式。

    对于打算在 Node.js 中运行的项目,唯一正确的 module 设置应为 node16nodenext

    虽然在一个完全使用 ESM 的 Node.js 项目中,使用 esnextnodenext 编译所生成的 JavaScript 看起来可能完全相同,但它们的类型检查行为可能不同。更多详细信息,请参见关于 nodenext 的参考部分。

2. 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 关键字

3. esModuleInterop 支持默认导入

  • module 为 node16/nodenext 时,默认为 true,否则默认 false

4. allowSyntheticDefaultImports 允许从没有默认导出的模块中默认导入,仅用于类型检查

5. resolveJsonModule 允许导入 .json

6. baseUrlpaths 配置模块导入的基准路径和路径映射,简化模块导入路径

7. rootDirs 将多个目录视为一个虚拟目录

{
  "compilerOptions": {
    "rootDirs": ["src", "generated"] // 会将 src 和 generated 目录视为同一级别
  }
}

8. importHelpers 引入 tslib,减少很多 polyfill

9. isolatedModule 前端 babel 使用的配置,确保每个文件可以单独作为模块进行编译

Typescript 扩展功能

  • exports =import = require()

    • TypeScript 提供的完全兼容老旧 CommonJS 包的 module.exports = ... 形式的功能(如果不启用 esModuleInterop)
    • module.exports 类似于 ES module 的 export default,推荐直接用 export default
  • export type

参考文章

  1. Typescript 手册-V4.7 - Node.js 对 ECMAScript Module 的支持
  2. Typescript 手册-Module Referrence
  3. Node.js 如何处理 ES6 模块
  4. ES Modules: A cartoon deep-dive 这篇文章说明了 ES Module 加载的步骤细节
  5. Es Modules: A cartoon deep-dive 英文原文