Node 环境使用 ESM

概念

名词解释:

  • 模块化:将代码拆分成多个文件(即模块)。

    模块化的好处是:

    1. 解决变量名冲突问题
    2. 将代码拆分,能够整理成可复用、易维护、可读性强的代码块
    3. 自动处理依赖关系,不会造成混乱
    4. 抽离公共代码
    5. 隔离作用域,不会污染全局变量

    模块的核心是:

    1. 拆分:将代码拆分成多个可复用的模块
    2. 加载:通过指定方式加载模块并执行
    3. 注入:将一个模块的输出注入到另一个模块中
    4. 管理:管理各个模块间的依赖关系
  • ESM:ES6 模块化方案 —— 常用 importexport等引入、导出模块

  • CJS:CommonJS 模块化方案 —— Node 服务端环境采用方案,常用 required 来引入模块

CJS 和 ESM 的主要区别

-CJSESM
语法类型动态静态
关键声明requireexportimport
加载方式运行时加载编译时加载
加载行为同步加载异步加载
书写位置任何位置顶层位置
指针指向this指向当前模块this指向undefined
执行顺序首次引入时加载模块 再次引入时读取缓存引入时生成只读引用 执行时才是正式取值
属性引用基本类型属于复制不共享 引用类型属于浅拷贝且共享所有类型属于动态只读引用
属性修改工作空间可修改引入的值工作空间不可修改引入的值 但可通过引用的方法修改
  • 运行时加载指整体加载模块生成一个对象,再从对象身上获取所需的属性和方法。最大特点是全局加载,只有运行时才能得到该对象
  • 编译时加载指直接从模块中获取所需的属性和方法。最大特性是按需加载。在编译时就完成模块加载,效率比其他方案高,无法引用模块本身(本身不是对象),但可拓展 JS 高级语法(类型校验)。

高版本 Node 解决方案

web 端已大量采用 ESM 编码,但由于历史原因, Node 服务端官方是采用 CJS 编码的,使用ESM编码时需要做一些细微的处理。

对于Node v13.2.0版本及以上的 Node 环境,只需要做以下操作之一即可实现 ESM

  • 使用type指定模块方案:
    • package.json中指定typecommonjs,则使用CJS
    • package.json中指定typemodule,则使用ESM
  • 使用--input-type指定入口文件的模块方案,与type一样:
    • 命令中添加参数--input-type=commonjs,则使用CJS
    • 命令中添加参数--input-type=module,则使用ESM
  • 支持新扩展名:
    • 文件后缀名使用.cjs,则使用CJS
    • 文件后缀名使用.mjs,则使用ESM

除此之外还有一些新特性:

  • 使用--es-module-specifier-resolution指定文件名称引入方式:
    • 命令中添加参数--es-module-specifier-resolution=explicit,则引入模块时必须使用文件扩展名(默认)
    • 命令中添加参数--es-module-specifier-resolution=node,则引入模块时无需使用文件扩展名
  • package.json中指定main后会根据type指定模块方案加载文件

示例

bash
1$ mkdir node-demo
2$ cd node-demo
3$ code .
4$ npm init

一直按回车后生成 package.json

json
1{
2  "name": "demo",
3  "version": "1.0.0",
4  "description": "",
5  "main": "index.js",
6  "scripts": {
7    "test": "echo \"Error: no test specified\" && exit 1"
8  },
9  "author": "qiuyanxi",
10  "license": "ISC"
11}

然后新建 main.js文件和 utils.js文件

utils.js

js
1export const user = { name: 'qiuyanxi' }

main.js

js
1import { user } from './utils'
2
3console.log(user)

执行命令:

js
1$ node main.js

会出来以下报错:

js
1(node:7248) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
2(Use `node --trace-warnings ...` to show where the warning was created)
3/Users/qiuyanxi/Desktop/node-demo/main.js:1
4import { user } from './utils';
5^^^^^^

根据提示,在 package.json写入 "type": "module" 字段,或者将 main.js后缀改成 .mjs

此时重新执行命令,会报错:

js
1node:internal/process/esm_loader:94
2    internalBinding('errors').triggerUncaughtException(
3                              ^
4
5Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/Users/qiuyanxi/Desktop/node-demo/utils' imported from /Users/qiuyanxi/Desktop/node-demo/main.js
6Did you mean to import ../utils.js?

这是因为我们引入时没有写.js后缀名。

修改命令:

js
1$ node --es-module-specifier-resolution=node main.js

此时就没有问题了。

这段命令可以写到package.jsonscripts属性中:

diff
1  "scripts": {
2    "test": "echo \"Error: no test specified\" && exit 1",
3+   "start": "node  --es-module-specifier-resolution=node main.js"
4  },

此时执行以下命令,也会有一样的效果。

js
1$ npm start

为了让Node支持ESM,我们还需为其指定Node/Npm版本限制。这是为了避免预设和实际情况不同而报错,比如说预设该项目在高版本中运行,实际却在低版本中运行。

NodeNpm是成双成对地安装,可通过Node Releases查询到Node v13.2.0对应Npm v6.13.1

json
1{
2  "type": "module",
3  "engines": {
4    "node": ">=13.2.0",
5    "npm": ">=6.13.1"
6  }
7}

低版本 Node 解决方案

低版本Node不支持 ESM

而且Npm 包很多都是用 CJS 编码,同时使用requireexport/import会报错,所以有些模块可能没办法使用 ESM

对于这个问题,我们需要用babel将代码从ESM转化成CJS

步骤:

  1. 安装 babel 相关模块

    bash
    1npm install @babel/cli @babel/core @babel/node @babel/preset-env -dev
    • @babel/cli - 提供@babel/core的命令行环境
    • @babel/core - 提供转译函数
    • @babel/node - 提供支持ESM的命令行运行环境
    • @babel/preset-env - 语法转换的预设环境
  2. 修改 package.json

    json
    1  "scripts": {
    2    "start": "babel-node src/index"
    3  },
    4  "babel": {
    5    "presets": [
    6      "@babel/preset-env"
    7    ]
    8  }
  3. 运行 npm start

这个方案下,我们不需要设置package.jsontypeengines字段了。

一句话总结这个方案就是开发用 ESM,编译后的代码依然是 CMJ

添加 nodemon

nodemon 是自动检测项目文件发生变化就会重启的 Npm 模块。每次修改代码后,都会自动帮我们执行npm start

以下以低版本 Node 解决方案为例:

安装:

bash
1npm install nodemon -dev

修改package.json

json
1"scripts": {
2    "start": "nodemon -x babel-node src/index"
3  },
4"nodemonConfig": {
5		"env": {
6			"NODE_ENV": "dev"
7		},
8		"execMap": {
9			"js": "node --harmony"
10		},
11		"ext": "js json",
12		"ignore": [
13			"dist/"
14		],
15		"watch": [
16			"src/"
17		]
18	}

Node 环境下使用 TypeScript

Node 环境下使用 TypeScript

按照上面的步骤就可以在 Node 环境下运行 TypeScript并直接使用ESM 编码,相当于低版本 Node +nodemon的解决方案。

TypeScript 会直接根据tsconfig.json中的module来对代码进行编码并转译成对应的JS代码。

例如,默认的modulecommonjs,那么会生成commonjs的代码。

示例代码:

src/index.ts

typescript
1import user from '../utils'
2console.log(user)

utils.ts

typescript
1const user = { name: 'qiuyanxi' }
2export default user

转译成:

src/index.js

js
1'use strict'
2exports.__esModule = true
3var utils_1 = require('../utils')
4console.log(utils_1['default'])

Utils.js

js
1'use strict'
2exports.__esModule = true
3var user = { name: 'qiuyanxi' }
4exports['default'] = user

Node 使用 ESM 产生的差异

Node 环境使用 ESM 后会有以下差异:

  • 不能使用__dirname__filename
  • 不能使用requiremoduleexports
  • 不能引入 json 文件

解决方法:

  • __dirname__filename使用import.meta.url重建

    js
    1import { dirname } from 'path'
    2import { fileURLToPath } from 'url'
    3
    4const __filename = fileURLToPath(import.meta.url)
    5const __dirname = dirname(__filename)

    如果是找项目的根目录,则可以使用 process来完成

    js
    1process.cwd()
  • requiremoduleexports使用importexport语法代替

  • json 文件通过 fs模块来引入

    js
    1import { readFileSync } from 'fs'
    2
    3const json = readFileSync('./info.json')
    4const info = JSON.parse(json)