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 导出有如下几种写法

  1. 子路径导出
    {
    	"exports": {
    		".": "./index.js"
    	}
    }
    // 可以完全替代 main 的写法
    {
    	"main": "./index.js"
    }
    // 并且可以通过语法糖的方式简化
    {
    	"exports": "./index.js"
    }
```json
{
  "exports": {
    ".": "./main.js",
    "./utils": "./utils/index.js"
  }
}
```
  1. 条件导出

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

    {
      "exports": {
        ".": {
          "import": "./esm/index.js",
          "require": "./cjs/index.js"
        }
      }
    }
  1. 默认导出
    {
      "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/

  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 目录视为同一级别
      }
}
  1. importHelpers 引入 tslib,减少很多 polyfill
  2. 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 英文原文