前言
随着主流浏览器逐步支持 ESM
,越来越多目光投注于 Node
对于 ESM
的支持上。但是Node
使用 CJS
作为官方模块方案,导致 Node
无法运行 ESM
的问题。
但是 ESM
日渐强大,Node
随后也出方案支持 ESM
运行。
支持ESM
Node
发布了v8.9.0
,从此只要在命令中加上--experimental-modules
,Node
就可象征性地支持ESM
了。
node --experimental-modules index.js
接着Node
发布了v13.2.0
带来一些新特性,正式取消--experimental-modules
启动参数。当然并不是删除--experimental-modules
,而是在其原有基础上实现对ESM
的实验性支持并默认启动。
--experimental-modules
特性包括以下方面。
-
使用
type
指定模块方案- 在
package.json
中指定type
为commonjs
,则使用CJS
- 在
package.json
中指定type
为module
,则使用ESM
- 在
-
使用
--input-type
指定入口文件的模块方案,与type
一样- 命令中加上
--input-type=commonjs
,则使用CJS
- 命令中加上
--input-type=module
,则使用ESM
- 命令中加上
-
支持新文件后缀
.cjs
- 文件后缀使用
.cjs
,则使用CJS
- 文件后缀使用
-
使用
--es-module-specifier-resolution
指定文件名称引用方式- 命令中加上
--es-module-specifier-resolution=explicit
,则引用模块时必须使用文件后缀(默认
) - 命令中加上
--es-module-specifier-resolution=node
,则引用模块时无需使用文件后缀
- 命令中加上
-
使用
main
根据type
指定模块方案加载文件- 在
package.json
中指定main
后会根据type
指定模块方案加载文件
- 在
Node内部对CJS/ESM判断方式
Node
默认要求使用ESM
的文件需采用.mjs
后缀,只要文件中存在import/export命令
就必须使用.mjs
后缀。若不希望修改文件后缀,可在package.json
中指定type
为module
。基于此,若其他文件使用CJS
,就需将其文件后缀改为.cjs
。若在package.json
中未指定type
或指定type
为commonjs
,则以.js
为后缀的文件会被解析为CJS
。
简而言之,mjs文件
使用ESM
解析,cjs文件
使用CJS
解析,js文件
使用基于package.json
指定的type
解析。
type=module
使用ESM
type=commonjs
使用CJS
,未指定 type 时,默认是commonjs
,会把.js
后缀的文件解析成cjs
刚才说了Node v13.2.0
在默认情况下,会启动对ESM
的实验支持,无需在命令中加上--experimental-modules
参数。那Node
是如何区分CJS
与ESM
?简而言之,Node
会将以下情况视为ESM
。
- 文件后缀为
.mjs
- 文件后缀为
.js
且在package.json
中指定type
为module
- 命令中加上
--input-type=module
- 命令中加上
--eval cmd
Node的ESM开发环境方案
将Node v13.2.0
作为高低版本分界线,当版本>=13.2.0
则定为高版本,当版本<13.2.0
则定为低版本。
高版本使用Node原生部署方案
,低版本使用Node编译部署方案
。
先部署一个实例,在根目录中创建package.json
并执行npm i
安装项目依赖,代码如下:
{
"name": "node-in-esm",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"@yangzw/bruce-us": "1.0.3"
}
}
创建src/index.js
文件:
import { NodeType } from "@yangzw/bruce-us/dist/node";
console.log(NodeType());
做好准备前期准备后,就要根据我们 Node
版本来区分方案了,先查看本地的 Node
版本:
node -v
Node原生部署方案
假设Node
是v13.2.0
以上版本,执行npm start
,输出以下信息表示运行失败。
> node-in-esm@1.0.0 start
> node src/index.js
(node:32228) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
C:\Users...\node-in-esm\src\index.js:1
import { NodeType } from "@yangzw/bruce-us/dist/node";
^^^^^^
SyntaxError: Cannot use import statement outside a module
原因很简单,就是 Node
无法识别出是 ESM
,上面有讲到这一块的东西,知道原因就可以找解决方案了。
保持 .js
的前提下,在package.json
中指定type
为module
。
为了让Node
支持ESM
,还需为其指定Node/Npm
版本限制。这是为了避免预设与实际情况不同而报错,例如预设该项目在高版本运行,实际却在低版本运行。
Node
与Npm
是成双成对地安装,可通过Node Releases查询到对应的版本,例如我的 Node v16.14.2
对应 Npm v8.5.0
"type": "module",
"engines": {
"node": ">=13.2.0",
"npm": ">=6.13.1"
}
重新运行 npm start
,还是失败了:
node:internal/process/esm_loader:94
internalBinding('errors').triggerUncaughtException(
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'C:\Users...\node-in-esm\node_modules@yangzw\bruce-us\dist\node' imported from C:\Users...\node-in-esm\src\index.js
Did you mean to import @yangzw/bruce-us/dist/node.js?
根据报错提示,可知模块路径不存在,这主要是因为显式文件名称使用不对。
高版本Node
在默认情况下,对import命令
的文件后缀存在强制性,必须显示的定义文件名后缀。其次,CJS
的自动后缀处理行为可通过--es-module-specifier-resolution=node
开启,但模块主入口并不会受到ESM
的影响,例如import Path from "path"
照样可正常运行。在命令中加上--es-module-specifier-resolution=node
就能解决显示文件名称的问题。
更改文件后缀:
// src/index.js
import { NodeType } from "@yangzw/bruce-us/dist/node.js";
console.log(NodeType());
或者加上 --es-module-specifier-resolution=node
:
{
"scripts": {
"start": "node --es-module-specifier-resolution=node src/index.js"
}
}
重新修改文件名称后再执行npm start
,输出以下信息运行成功,这次就无任何问题了!
{
nodeVs: '16.14.2',
npmVs: '8.5.0',
system: 'windows',
systemVs: '10.0.17134'
}
但是,使用ESM
就不再提供Node
某些特性与不能灵活引用json文件
了,因此__dirname
、__filename
、require
、module
和exports
这几个特性将无法使用。
不过,还是有官方还是出了对应的解决方案:
__filename
与__dirname
可用import.meta
对象重建require
、module
和exports
可用import
与export
代替json文件
的引用可用Fs模块
的readFileSync
与JSON.parse()
代替
import { readFileSync } from "fs";
import { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log({
__filename,
__dirname,
});
const json = readFileSync("./package.json");
const info = JSON.parse(json);
console.log("info", info);
CJS
的循环依赖关系已通过缓存各个模块的module.exports
对象解决,但ESM
用了所谓的绑定。简而言之,ESM
模块不会导出导入值而是引用值。
- 导入引用模块可访问该引用但无法修改它。
- 导出引用模块可为引用该模块的模块重新分配值且该值由导入引用模块使用
而CJS
允许在任何时间点将引用分配给模块的module.exports
对象,让这些改动仅部分反映在其他模块。
Node编译部署方案
Npm
大部分模块都使用CJS
编码,因为同时使用require
与export/import
会报错,所以单个模块无法切换到ESM
。
可用babel
将代码从ESM
转换为CJS
,因此使用babel
编译ESM
代码是低版本Node
支持ESM
最稳定的方案无之一。在Node v8.9.0
前的版本无法使用--experimental-modules
支持ESM
,也就更需babel
解决该问题了。
当然在任何版本中,babel
都能让新语法转换为与旧环境兼容的代码,因此在高版本Node
中也同样适用。
接着在v13.2.0
以下版本中部署。执行以下命令安装babel
相关工具链到devDependencies
。
npm i @babel/cli @babel/core @babel/node @babel/preset-env -D
4个包都有各自的作用,很重要:
- @babel/cli:提供支持
@babel/core
的命令运行环境 - @babel/core:提供转译函数
- @babel/node:提供支持
ESM
的命令运行环境 - @babel/preset-env:提供预设语法转换集成环境
安装完毕,在package.json
中指定babel
相关配置,将start
命令中的node
替换为babel-node
。
{
"scripts": {
"start": "babel-node src/index.js"
},
"babel": {
"presets": [
"@babel/preset-env"
]
}
}
执行 npm run start
,输出一下信息表示成功:
{
nodeVs: '12.22.10',
npmVs: '6.14.16',
system: 'windows',
systemVs: '10.0.17134'
}
该方案无需在package.json
中指定engines
,毕竟其目的还是将代码的模块方案从ESM
转换为CJS
。若需兼容更低版本Node
,可在package.json
中指定babel
的targets
。
{
"babel": {
"presets": [
["@babel/preset-env", { "targets": { "node": "8.0.0" } }]
]
}
}
监听自启动命令
每次修改脚本都需重启命令才能让脚本内容生效,这太麻烦了,所以我始终喜欢在Node
中使用nodemon
。nodemon是一个自动检测项目文件发生变化就重启服务的Npm模块
,是Node开发
的必备工具。
以Node编译部署方案的示例为例。执行npm i -D nodemon
安装nodemon
,在package.json
中指定nodemonConfig
相关配置,将start
命令替换为nodemon -x babel-node src/index.js
。
{
"nodemonConfig": {
"env": {
"NODE_ENV": "dev"
},
"execMap": {
"js": "node --harmony"
},
"ext": "js json",
"ignore": [
"dist/"
],
"watch": [
"src/"
]
}
}
修改src/index.js
内容,nodemon
就能快速响应改动并重启命令。nodemon配置
可查看Nodemon官网。