在 Node 中 优雅使用esm

4,633 阅读7分钟

前言

随着主流浏览器逐步支持 ESM ,越来越多目光投注于 Node 对于 ESM 的支持上。但是Node 使用 CJS 作为官方模块方案,导致 Node无法运行 ESM 的问题。

但是 ESM 日渐强大,Node 随后也出方案支持 ESM 运行。

支持ESM

Node发布了v8.9.0,从此只要在命令中加上--experimental-modulesNode就可象征性地支持ESM了。

node --experimental-modules index.js

接着Node发布了v13.2.0带来一些新特性,正式取消--experimental-modules启动参数。当然并不是删除--experimental-modules,而是在其原有基础上实现对ESM的实验性支持并默认启动。

--experimental-modules特性包括以下方面。

  • 使用type指定模块方案

    • package.json中指定typecommonjs,则使用CJS
    • package.json中指定typemodule,则使用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中指定typemodule。基于此,若其他文件使用CJS,就需将其文件后缀改为.cjs。若在package.json中未指定type或指定typecommonjs,则以.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是如何区分CJSESM?简而言之,Node会将以下情况视为ESM

  • 文件后缀为.mjs
  • 文件后缀为.js且在package.json中指定typemodule
  • 命令中加上--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原生部署方案

假设Nodev13.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中指定typemodule

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

NodeNpm是成双成对地安装,可通过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__filenamerequiremoduleexports这几个特性将无法使用。

不过,还是有官方还是出了对应的解决方案:

  • __filename__dirname可用import.meta对象重建
  • requiremoduleexports可用importexport代替
  • json文件的引用可用Fs模块readFileSyncJSON.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编码,因为同时使用requireexport/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中指定babeltargets

{
	"babel": {
		"presets": [
			["@babel/preset-env", { "targets": { "node": "8.0.0" } }]
		]
	}
}

监听自启动命令

每次修改脚本都需重启命令才能让脚本内容生效,这太麻烦了,所以我始终喜欢在Node中使用nodemonnodemon是一个自动检测项目文件发生变化就重启服务的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官网