模块机制
模块化是现代JavaScript开发的核心概念之一。在NodeJS环境中,模块机制允许我们将代码拆分成独立的文件,每个文件作为一个模块,通过导入导出的方式相互引用。本篇笔记我们将详细介绍NodeJS中的ESM(ECMAScript Modules)模块机制。
模块化的意义
在早期的浏览器环境下的JavaScript开发中,所有代码都写在一个文件里或者通过多个<script>标签引入多个文件,这些文件共享全局作用域,很容易造成变量冲突和代码混乱。模块化的主要主要解决了这些问题:
- 作用域隔离:每个模块都有独立的作用域,避免全局变量污染
- 代码复用:模块可以被多个文件引用,提高代码复用性
- 依赖管理:模块之间的依赖关系清晰明了,便于维护
- 按需加载:可以实现模块的动态加载,优化性能
CommonJS与ESM模块化机制
NodeJS历史上主要使用两种模块规范:
CommonJS:CommonJS是NodeJS最初采用的模块规范,它使用require()导入模块,使用module.exports导出模块。这种规范在NodeJS中沿用多年,大量的npm包都是基于CommonJS编写的。
ESM(ECMAScript Modules):ESM是JavaScript语言标准的模块规范,使用import和export关键字进行模块的导入导出。ESM是现代JavaScript的标准模块系统,具有静态分析、Tree Shaking等优势,也是现代浏览器原生支持的模块规范。
目前NodeJS同时支持这两种模块规范,但本篇笔记主要介绍ESM,因为它是JavaScript语言的官方标准,也是未来的发展方向。
启用ESM模块
前一篇笔记中我们已经介绍了,在NodeJS中使用ESM实际上有两种方式。
第一种方式是将文件后缀改为.mjs,NodeJS会将.mjs后缀的文件识别为ESM模块。
demo-node
|_index.mjs
|_utils.mjs
第二种方式是在package.json中添加"type": "module",这样配置后整个项目中的.js文件都会被识别为ESM模块。
{
"name": "demo-node",
"version": "1.0.0",
"type": "module"
}
目前来看,我们推荐使用第二种方式,这样可以保持.js后缀的使用习惯,同时享受ESM的所有特性。
导出模块
命名导出
命名导出允许一个模块导出多个值,导入时需要使用相同的名称,下面是一个例子。
utils.js
// 直接在声明时导出
export const PI = 3.14159;
export const add = (a, b) => {
return a + b;
};
export const subtract = (a, b) => {
return a - b;
};
// 也可以先声明后统一导出
const multiply = (a, b) => {
return a * b;
};
const divide = (a, b) => {
return a / b;
};
export { multiply, divide };
默认导出
默认导出则每个模块只能有一个。
calculator.js
const Calculator = {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
multiply: (a, b) => a * b,
divide: (a, b) => a / b,
};
export default Calculator;
混合导出
一个模块可以同时使用命名导出和默认导出。
math.js
export const PI = 3.14159;
export const E = 2.71828;
const MathUtils = {
square: (x) => x * x,
cube: (x) => x * x * x,
};
export default MathUtils;
导入模块
导入命名导出
使用大括号导入命名导出的内容。
import { PI, add, subtract } from "./utils.js";
console.log(PI); // 3.14159
console.log(add(1, 2)); // 3
导入时,可以使用as关键字对导入的内容重命名。
import { add as addition, subtract as subtraction } from "./utils.js";
console.log(addition(1, 2)); // 3
我们也可以导入模块的所有命名导出。
import * as utils from "./utils.js";
console.log(utils.PI); // 3.14159
console.log(utils.add(1, 2)); // 3
导入默认导出
导入默认导出时不需要大括号,且可以使用任意名称接收。
import Calculator from "./calculator.js";
console.log(Calculator.add(1, 2)); // 3
混合导入
我们可以同时导入默认导出和命名导出。
import MathUtils, { PI, E } from "./math.js";
console.log(PI); // 3.14159
console.log(MathUtils.square(3)); // 9
仅执行模块
有时候我们只需要执行模块中的代码,而不需要导入任何内容,此时可以使用类似下面的写法。
import "./init.js";
这种方式常用于执行一些初始化逻辑或注册全局配置。
导入NodeJS内置模块
NodeJS的内置模块在ESM中需要使用node:前缀导入。
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// 读取文件
const content = fs.readFileSync("./data.txt", "utf-8");
console.log(content);
注意:在ESM中,__dirname和__filename这两个CommonJS中的全局变量是不可用的,需要通过以下方式获取。
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__filename); // 当前文件的绝对路径
console.log(__dirname); // 当前文件所在目录的绝对路径
导入npm包
导入通过npm安装的第三方包与导入本地模块类似。
import express from "express";
import { v4 as uuidv4 } from "uuid";
const app = express();
console.log(uuidv4());
需要注意的是,并非所有npm包都支持ESM导入。如果包只提供了CommonJS格式,可能需要使用特殊的方式导入或寻找替代方案,不过现代的npm包通常会同时提供CommonJS和ESM两种格式,遇到这种问题的情况已经十分罕见了。
动态导入
ESM支持使用import()函数进行动态导入,它返回一个Promise。
const loadModule = async () => {
const module = await import("./utils.js");
console.log(module.add(1, 2)); // 3
};
loadModule();
动态导入常用于以下场景:
- 条件导入:根据条件决定是否导入某个模块
- 延迟加载:在需要时才加载模块,优化启动性能
- 导入路径动态生成:路径需要在运行时确定
下面例子演示了条件导入的实现。
const loadDatabase = async (dbType) => {
let db;
if (dbType === "mysql") {
db = await import("mysql2");
} else if (dbType === "postgres") {
db = await import("pg");
}
return db;
};
模块解析规则
ESM在解析模块路径时有以下规则。
相对路径:以./或../开头的路径被视为相对路径,相对于当前文件所在目录解析。
import { add } from "./utils.js";
import { helper } from "../common/helper.js";
绝对路径:以/开头或完整的文件URL。
import config from "/etc/app/config.js";
import data from "file:///home/user/data.js";
裸模块标识符:不以./、../或/开头的路径,用于导入npm包或NodeJS内置模块。
import express from "express";
import fs from "node:fs";
导入JSON文件
ESM中可以导入JSON文件,但需要使用断言语法。
import config from "./config.json" with { type: "json" };
console.log(config.database.host);
注意:with断言语法在较新版本的NodeJS中支持(v17.1+),旧版本可能需要使用assert关键字或其他方式处理。
ESM与CommonJS的互操作
虽然推荐使用ESM,但由于历史原因,部分npm包可能仍然仅提供CommonJS格式模块。目前版本的NodeJS中,ESM模块可以导入CommonJS模块。
// 导入CommonJS模块的默认导出
import lodash from "lodash";
// CommonJS模块的module.exports会被作为默认导出
console.log(lodash.chunk([1, 2, 3, 4], 2));
但是,CommonJS模块不能使用require()导入ESM模块,即这是一个单向的兼容性。如果一定要在CommonJS中使用ESM模块,可以使用动态import()。
// 在CommonJS中动态导入ESM模块
async function main() {
const esmModule = await import("./esm-module.mjs");
console.log(esmModule.default);
}
main();
模块的顶层await
ESM支持在模块顶层使用await关键字,无需包裹在async函数中,下面是一个例子。
config.js
const response = await fetch("https://api.example.com/config");
const config = await response.json();
export default config;
app.js
import config from "./config.js";
console.log(config.apiKey);
值得注意的是顶层await会阻塞模块的加载,直到Promise解析完成,使用时需要关注性能影响。
避免循环依赖
当两个模块相互引用时,会产生循环依赖。
a.js
import { b } from "./b.js";
export const a = "a";
console.log("a.js:", b);
b.js
import { a } from "./a.js";
export const b = "b";
console.log("b.js:", a);
在循环依赖场景下,可能会出现导入的值为undefined的情况。实际开发中,一个最佳实践是避免循环依赖,如果确实需要,可以将共享的内容提取到第三个模块中。