JS 模块化 本质是将复杂的 JS 代码按照功能、职责拆分成独立的文件(模块),每个模块只暴露需要对外提供的接口,同时隔离内部实现,解决全局变量污染、代码复用、依赖管理等问题。
CommonJS (Node)
CommonJS 是一套 Node.js 中默认的模块化规范,也是前端模块化发展中重要的一环,主要用于解决代码的模块化组织和依赖管理问题。
一、CommonJS 的特点
-
同步加载:
require()是同步执行的,会阻塞后续代码,直到模块加载完成。
适合 Node.js 环境(模块存于本地磁盘,加载速度快),但不适合浏览器(网络加载慢,会阻塞页面渲染)。 -
运行时加载:模块的导入和导出在代码运行时执行,属于 “动态加载”。
例如,require()可以写在条件语句中,根据运行时条件动态加载模块。 -
值拷贝:导入的是模块导出值的 “拷贝”,若原模块后续修改了导出的基本类型值,导入方不会同步更新(引用类型除外,因拷贝的是引用地址)。
二、语法
module.exports
module.exports:模块的默认导出对象,本质是一个空对象 {},模块最终导出的内容以 module.exports 为准。
function add(a, b) {
return a + b;
}
function sub(a, b) {
return a - b;
}
module.exports.add = add;
module.exports.sub = sub;
module.exports.multiply = (a, b) => a * b;
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
// 导出 add 和 subtract 函数
module.exports = {
add,
subtract
};
导出单个成员:若模块只需要导出一个核心功能,可直接给 module.exports 赋值(非对象)。
// utils.js
module.exports = (str) => str.toUpperCase(); // 导出一个函数
语法 exports
exports:是 module.exports 的引用(快捷方式),初始时 exports === module.exports。
注意:不能直接给
exports赋值新对象(会断开与module.exports的引用关系)。
function add(a, b) {
return a + b;
}
function sub(a, b) {
return a - b;
}
exports.add = add;
exports.sub = sub;
exports.multiply = (a, b) => a * b;
语法 require
console.log("require", require);
require 是一个函数,且是 CommonJS 模块系统中最核心的函数 —— 它是 Node.js 内置的、挂载在每个模块的 module.require 上的函数,用于同步加载并执行其他模块,返回目标模块的 module.exports 对象。
但要注意:
require不是普通函数,它是「函数对象」(兼具函数和对象的特性),还挂载了多个实用属性(如require.resolve、require.cache),是 Node.js 为 CommonJS 模块定制的特殊函数。
console.log('module.require', module.require)
在 Node.js 中直接打印 module 对象时,控制台输出里看不到 require 函数,但却能通过 module.require 访问到这个函数 —— 核心原因是 require 是 module 对象的不可枚举属性,默认不会被 console.log 打印出来,但实际存在且可调用。
require 是 Node.js 为开发者封装的 “易用版” 模块加载函数,而 module.require 是更接近底层的 “核心版” 加载函数 —— 前者挂载了大量辅助开发的属性,后者仅保留最核心的全局状态。
require.resolve
require.resolve 方法用于解析模块标识符对应的绝对路径(只解析路径,不加载模块)。
console.log(require.resolve('./utils.js'))
// /Users/xxx/Documents/code/cloudcode/blog/模块化/utils.js
console.log(require.resolve('fs')) // fs
console.log(require.resolve('axios'))
// /Users/xxx/Documents/code/cloudcode/blog/模块化/node_modules/axios/dist/node/axios.cjs
require.resolve.paths
require.resolve.paths 函数用于返回 Node.js 查找模块时的所有候选路径(即「模块查找路径数组」)。
console.log(require.resolve.paths('./utils.js'))
console.log(require.resolve.paths('fs'))
console.log(require.resolve.paths('axios'))
require.main
require.main 指向启动当前 Node.js 进程的主模块(即你执行 node index.js 时的 index.js)。
require.extensions
require.extensions 是 Node.js 用于处理不同后缀模块的加载器映射,键是文件后缀,值是对应的加载函数。
该属性已被 Node.js 废弃(不推荐自定义),但仍保留用于兼容旧代码。
require.cache
require.cache 是 Node.js 模块系统的缓存容器(以模块绝对路径为键,模块对象为值),用于缓存已加载的模块,避免重复加载 / 执行。
模块缓存是 CommonJS 「加载一次,多次复用」的核心(多次
require同一个模块,只会执行一次模块代码);可通过delete require.cache[模块路径]清除指定模块的缓存(常用于热更新场景)。
内置局部对象 module
console.log("module", module);
module 是每个 CommonJS 模块的「内置局部对象」(非全局),console.log(module) 打印的内容完整展示了当前模块的元信息、运行状态、依赖关系。
- id,模块的唯一标识。主模块(直接运行的模块)id 为
.;被导入的模块 id 等于filename。 - path,模块所在的目录路径(不含文件名)
- exports,模块的导出对象(核心)。
- loaded,判断模块是否加载 / 执行完成。
- filename,模块的完整绝对路径(包含文件名)
- children,当前模块加载的子模块列表(每个子项都是 Module 实例)
- paths,模块查找第三方依赖的路径列表,Node.js 会按这个顺序查找 node_modules 目录(从当前目录向上递归)。
const utils = require("./utils.js");
const fs = require('fs');
console.log("utils.add", utils.add(1, 2));
fs.readFile('./main.js', 'utf-8', (err, data) => {
if (err) {
console.log(err);
return;
}
console.log('main.js content ended');
});
module.exports = {
add: utils.add,
};
console.log("module", module);
console.log("require", require);
// utils.js
function add(a, b) {
return a + b;
}
function sub(a, b) {
return a - b;
}
module.exports.add = add;
module.exports.sub = sub;
module.exports.multiply = (a, b) => a * b;
console.log("utils loading");
模块机制
Node.js 的模块加载过程可概括为:路径分析 → 文件定位 → 编译执行 → 返回导出对象。
Node.js 中的模块主要分为以下三类:
核心模块(Core Modules),Node 提供(核心模块在 Node 源代码编译过程中,编译进了二进制执行文件,在 Node 进程启动时,部分核心模块就直接被加载进内存中,加载速度快)。因此,在核心模块引入时。文件定位和编译执行这两个步骤可以省略,并且路径分析中优先判断,因此加载速度是最快。文件模块(File Modules),用户编写的,在运行时动态加载,需要完成的路径分析、文件定位、编译执行过程,因此速度比核心模块,加载速度慢。第三方模块(Third-party Modules),通过 npm 安装的模块,位于node_modules目录中。这类模块的查找是最费时的,也是所有方式中最慢的一种。
一、路径分析
require 的参数可以是以下几种:
- 核心模块:如
require('fs'),直接返回内置模块。 - 相对/绝对路径:如
require('./utils'),基于当前文件路径解析。 - 模块名:如
require('lodash'),从当前目录的node_modules开始向上递归查找。
二、文件定位
如果未指定扩展名,Node.js 会尝试按 .js、.json、.node 的顺序补全。如果路径指向一个目录,则会查找该目录下的 package.json 的 main 字段,若不存在则加载 index.js。
三. 编译执行
Node.js 每个文件模块都是一个对象,有以下定义:
function Module(id, parent) {
this.id = id; // 模块的完整路径(或标识符)
this.exports = {}; // 模块导出的内容
this.parent = parent; // 父模块(require 该模块的模块)
this.filename = null; // 模块文件的绝对路径
this.loaded = false; // 模块是否已加载完成
this.children = []; // 该模块直接加载的子模块列表
this.paths = []; // 模块查找路径(node_modules 路径数组)
}
Node.js 会根据文件类型采用不同的加载方式:
.js:读取文件内容,将代码包装在函数中,在沙箱环境执行。.json:使用JSON.parse解析文件内容,返回对象。.node:作为 C++ 插件,通过process.dlopen加载。
1、js的加载方式
- 当你在 Node.js 中
require一个.js文件时,会利用fs.readFileSync读取源码字符串。 - 将源码包装到一个函数中(提供
exports,require,module,__filename,__dirname参数)。 - 然后包装后的代码会通过原生 v8.runInThisContext 方法执行,返回一个function对象,然后将当前模块对象的exports属性、require方法、module、文件当前的完整文件路径和文件目录作为参数传递给这个function执行。
- 最后,将
module.exports存入require.cache。
(function (exports, require, module, __filename, __dirname) {
const add = (a, b) => a + b;
const pi = 3.14;
module.exports = { add, pi };
});
缓存分析
- 核心模块。
- 缓存位置? Node.js 启动时已将核心模块(如
fs、http)编译进可执行文件,通过NativeModule或internalBinding内部管理,不暴露在require.cache中。 - 特点? 核心模块的加载优先级最高,且无法被用户代码删除或替换缓存。多次
require('fs')返回的是同一个内部对象。
- 缓存位置? Node.js 启动时已将核心模块(如
- 文件模块/第三方模块。
- 缓存位置? 所有通过文件路径加载的模块(包括相对路径、绝对路径以及从
node_modules解析的第三方模块)都会被缓存在require.cache对象中,键为模块的绝对路径。 - 过程? 第一次
require时,Node.js 解析模块路径,读取文件,编译并执行,将module.exports存入require.cache;后续require同一路径时,直接从缓存中取出exports,不再重复执行模块代码。 - 如何删除缓存?
require.cache是一个普通对象,可以通过delete require.cache[modulePath]手动删除缓存
- 缓存位置? 所有通过文件路径加载的模块(包括相对路径、绝对路径以及从
ES Module (浏览器、Node)
ES Module(简称 ESM,ES6 模块)是 ECMAScript 2015(ES6)引入的官方官方模块化规范,旨在统一浏览器和 Node.js 的模块化方案。
它通过静态分析(编译时解析依赖)实现更高效的模块管理,支持树摇(Tree-shaking)、循环依赖处理等高级特性,现已成为现代前端开发的主流模块化标准。
相比于早期制定的 CommonJS 规范,ES6的模块化设计有 3 点不同。
- CommonJS 在
运行时完成模块的加载,而 ES6 模块是在编译时完成模块的加载,效率要更高。 - CommonJS 模块是
对象,而 ES6 模块可以是任何数据类型,通过 export 命令指定输出的内容,并通过import命令引入即可。 - CommonJS 模块会在
require 加载时完成执行,而 ES6 的模块是动态引用,只在执行时获取模块中的值。ES6 模块核心的内容在于 export 命令和 import 命令的使用,两者相辅相成,共同为模块化服务。
语法 exports
1、export 的是接口,而不是值
不能直接通过 export 输出变量值,而是需要对外提供接口,必须与模块内部的变量建立一一对应的关系。
let obj = {};
let a = 1;
function foo() {}
export obj; // 错误写法
export a; // 错误写法
export foo; // 错误写法
let obj = {};
function foo() {}
export let a = 1; // 正确写法
export { obj }; // 正确写法
export { foo }; // 正确写法
2、export 值的实时性
export 对外输出的接口,在外部模块引用时,是实时获取的,并不是 import 那个时刻的值。假如在文件中 export 一个变量,然后通过定时器修改这个变量的值,那么在其他文件中不同时刻使用 import 的变量,值也会不同。
// 导出文件export1.js
const name = 'kingx2';
// 一秒后修改变量name的值
setTimeout(() => name = 'kingx3', 1000);
export { name };
// 导入文件import1.js
import { name } from './export1.js';
console.log(name); // kingx2
setTimeout(() => {
console.log(name); // 'kingx3'
}, 1000);
3、使用 as 关键字设置别名如果不想对外暴露内部变量的真实名称,可以使用 as 关键字设置别名,同一个属性可以设置多个别名。
const _name = 'kingx';
export {_name as name};
export {_name as name2};
4、相同变量名只能够 export 一次
在同一个文件中,同一个变量名只能够 export 一次,否则会抛出异常。
const _name = ‘kingx’;
const name = 'kingx';
export { _name as name };
export { name }; // 抛出异常,name作为对外输出的变量,只能export一次
5、尽量统一 export
如果文件 export 的内容有很多,建议都放在文件末尾处统一进行export,这样对export的内容能一目了然。
const name = 'kingx';
const age = 12;
const sayHello = function () {
console.log('hello');
};
export {
name,
age,
sayHello
};
语法 export default
使用 import 引入的变量名需要和 export 导出的变量名一样。在某些情况下,我们希望不设置变量名也能供 import 使用,import 的变量名由使用方自定义,这时就要使用到export default命令了。
注意:一个文件只有一个
export default语句。import 的内容不需要使用大括号括起来。
// export.js
const defaultParam = 1;
export default defaultParam;
// import.js
import param from './export.js';
console.log(param); // 1
语法 import
1、与 export 的变量名相同
import 命令引入的变量需要放在一个大括号里,括成对象的形式,而且 import 的变量名必须与 export 的变量名一致。
// export.js
const _name = 'kingx';
export { _name as name };
// import.js
import { _name } from './export.js'; // 抛出异常
import { name } from './export.js'; // 引入正常
2、相同变量名的值只能 import 一次
相同变量名的值只能 import 一次,否则会抛出异常。假如从多个不同的模块中 import 进相同的变量名,则会抛出异常。
// export1.js
export const name = 'kingx';
// export2.js
export const name = 'cat';
// 同时从两个模块中引入name变量,会抛出异常。
import {name} from './export1.js';
import {name} from './export2.js'; // 抛出异常
3、import 命令具有提升的效果
import 命令具有提升的效果,会将 import 的内容提升到文件头部。
// export.js
export const name = 'kingx';
// import.js
console.log(name); // kingx
import {name} from './export.js';
在上面的代码中,import 语句出现在输出语句的后面,但是仍然能正常输出。本质上是因为 import 是在编译期运行的,在执行输出代码之前已经执行了 import 语句。
4、多次 import 时,只会一次加载
每个模块只加载一次,每个JS文件只执行一次,如果在同一个文件中多次 import 相同的模块,则只会执行一次模块文件,后续直接从内存读取。
// export.js
console.log('开始执行');
export const name = 'kingx';
export const age = 12;
// import.js
import {name} from './export.js';
import {age} from './export.js';
在上面的代码中,import 两次 export.js 文件,但是最终只输出了一次“开始执行”。
5、import 的值本身是只读的,不可修改
使用 import 命令导入的值,如果是基本数据类型,那么它们的值是不可以修改的,相当于一个 const 常量;如果是引用数据类型的值,那么它们的引用本身是不能修改的,只能修改引用对应的值本身。
// export.js
const obj = {
name: 'kingx5'
};
const age = 15;
export {obj, age};
// import.js
import {obj, age} from './export.js';
obj.name = 'kingx6'; // 修改引用指向的值,正常
obj = {}; // 抛出异常,不可修改引用指向
age = 15; // 抛出异常,不可修改值本身
6、设置引入变量的别名
// export1.js
export const name = 'kingx';
// export2.js
export const name = 'cat';
// 使用as关键字设置两个不同的别名,解决了问题
import {name as personName} from './export1.js';
import {name as animalName} from './export2.js';
7、模块整体加载
当我们需要加载整个模块的内容时,可以使用星号(*)配合 as 关键字指定一个对象,通过对象去访问各个输出值。
// export.js
const obj = {
name: 'kingx'
};
export const a = 1;
export { obj };
// import.js
import * as a from './export.js';
语法 import.meta
import.meta 是一个只读的全局对象,存在于每个 ES Module 模块的顶层作用域中。仅在 ES Module 环境中可用(CommonJS 中打印 import.meta 会直接报错)。
console.log('import.meta', import.meta);
import.meta.url返回当前模块的文件 URL 路径。import.meta.resolve异步解析模块路径(替代require.resolve),返回 Promise。
命名导入(Named Import)
命名导入用于导入模块中通过 export 关键字导出的特定绑定,这些绑定具有明确的名称。
- 导入的名称必须与导出名称完全一致(除非使用
as重命名)。 - 可以一次导入多个绑定,用逗号分隔。
- 导入的是实时绑定(live binding),导出模块中值的变化会反映在导入处。
- 支持重命名:
import { add as sum } from './math.js'。
// 命名导出
export const PI = 3.14159;
export function add(a, b) { return a + b; }
// 命名导入
import { PI, add } from './math.js';
console.log(PI); // 3.14159
console.log(add(1,2));// 3
副作用导入 Side Effect Import
仅执行模块的代码,不导入任何变量或绑定。模块中的顶层代码会立即运行,但模块的导出内容不会被引入当前作用域。
- 不会返回任何东西,也不需要在代码中使用模块的导出。
- 模块只会被执行一次(基于缓存机制)。
// 加载 CSS 文件
import './styles.css
// 执行 polyfill 或初始化代码
import 'core-js‘
命名空间导入(Namespace Import)
将模块的所有导出(包括默认导出和命名导出)聚合到一个对象中,该对象作为命名空间。
- 适用于需要动态访问模块导出,或不想逐一列出命名导入的场景
// module.js
export default function main() {}
export const version = '1.0';
// 导入方
import * as foo from './module.js';
foo.default(); // 调用默认导出
console.log(foo.version); // 1.0
浏览器中使用 ES Module
在 <script> 标签中添加 type="module" 声明模块。
<!-- 引入 ESM 模块 -->
<script type="module" src="./main.js"></script>
浏览器会对模块进行 CORS 校验,本地开发需启动服务器(如 live-server),不能直接通过 file:// 协议访问。
Node 环境中使用 ES Module
Node.js 默认使用 CommonJS,可通过以下方式启用 ESM:
- 文件名后缀改为
.mjs; - 在
package.json中添加"type": "module"。
导入时需显示指定 .js 后缀(如 import { add } from './math.js')。
Dynamic Import 动态导入
import(moduleName)
import(moduleName, options)
加载机制
- 解析:将模块标识符转换为绝对 URL/路径。
- 获取:如果未缓存,则下载/读取模块源码(浏览器发网络请求,Node.js 读文件)。
- 解析:将源码解析为模块记录(Module Record),同时递归解析其静态
import的依赖。 - 实例化:为模块创建执行环境,链接导入/导出变量(此时尚未执行代码)。
- 执行:按照依赖图顺序执行模块的顶层代码(即模块函数体)。
第一中 ES Module 环境
// main.js
function add(a, b) {
return a + b;
}
console.log("main. loading");
const arr = [1, 2, 3];
function getName(arg) {
arr.push(...arg);
}
export { add, arr, getName };
import("./main.js")
.then((module) => {
console.log("module", module);
})
.catch((err) => {
console.log("error", err);
});
// utils.js
function add(a, b) {
return a + b;
}
console.log("main. loading");
const arr = [1, 2, 3];
function getName(arg) {
arr.push(...arg);
}
export { add, arr, getName };
export default {
getFullName: (firstName, lastName) => `${firstName} ${lastName}`,
}
第二种 CommonJS 环境
// utils.js
function add(a, b) {
return a + b;
}
function sub(a, b) {
return a - b;
}
module.exports.add = add;
module.exports.sub = sub;
module.exports.multiply = (a, b) => a * b;
console.log("utils loading");
import("./utils.js")
.then((module) => {
console.log("module", module);
})
.catch((err) => {
console.log("error", err);
});
import attributes 导入属性
import() 导入属性(Import Attributes)是 ES2025(ESNext)的核心新特性,也被称为「Import Assertions 2.0」(导入断言的升级版)。
必须在 ES Module 环境中使用。
Node.js 从 v20.6.0 开始正式支持 ES2025 导入属性(需确保 Node.js 版本 ≥20.6.0)。
导入属性(Import Attributes)功能用于告知运行时应如何加载模块,包括模块解析、获取、解析与执行的行为。它在 import 声明、export…from 声明以及动态导入 import () 中均受支持。
一、静态 import ... with { ... }
静态 import ... with { ... } 中,只能写 type 属性,其他属性一律抛错。
type 只支持 type: "json"或type: "webassembly"。
import data from "./config.json" with {
type: "json", // 标准属性:指定模块类型为 JSON
};
二、动态 import
async function loadJSON() {
const json = await import("./config.json", { with: { type: "json" } });
console.log("loadJSON", json.default);
}
loadJSON();
三、export…from 声明
// ✅ 合法:导出 JSON 模块的默认导出,仅声明 type: "json"
// 核心:JSON 模块必须加 with { type: "json" }
export { default as config } from "./config.json" with { type: "json" };
// 也可导出所有内容(命名空间导出)
export * from "./config.json" with { type: "json" };
最后
- 《JavaScript重难点实例精讲》 出版社:人民邮电出版社
- node ES Module
- node CommonJS
- 《深入浅出 Node.js》