模块机制

模块化是现代JavaScript开发的核心概念之一。在NodeJS环境中,模块机制允许我们将代码拆分成独立的文件,每个文件作为一个模块,通过导入导出的方式相互引用。本篇笔记我们将详细介绍NodeJS中的ESM(ECMAScript Modules)模块机制。

模块化的意义

在早期的浏览器环境下的JavaScript开发中,所有代码都写在一个文件里或者通过多个<script>标签引入多个文件,这些文件共享全局作用域,很容易造成变量冲突和代码混乱。模块化的主要主要解决了这些问题:

  1. 作用域隔离:每个模块都有独立的作用域,避免全局变量污染
  2. 代码复用:模块可以被多个文件引用,提高代码复用性
  3. 依赖管理:模块之间的依赖关系清晰明了,便于维护
  4. 按需加载:可以实现模块的动态加载,优化性能

CommonJS与ESM模块化机制

NodeJS历史上主要使用两种模块规范:

CommonJS:CommonJS是NodeJS最初采用的模块规范,它使用require()导入模块,使用module.exports导出模块。这种规范在NodeJS中沿用多年,大量的npm包都是基于CommonJS编写的。

ESM(ECMAScript Modules):ESM是JavaScript语言标准的模块规范,使用importexport关键字进行模块的导入导出。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();

动态导入常用于以下场景:

  1. 条件导入:根据条件决定是否导入某个模块
  2. 延迟加载:在需要时才加载模块,优化启动性能
  3. 导入路径动态生成:路径需要在运行时确定

下面例子演示了条件导入的实现。

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的情况。实际开发中,一个最佳实践是避免循环依赖,如果确实需要,可以将共享的内容提取到第三个模块中。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。