了解 Javascript 模块化,更好地掌握 Vite 、Webpack、Rollup 等打包工具

103 阅读16分钟

关系图模块化.png

JS 模块化 本质是将复杂的 JS 代码按照功能、职责拆分成独立的文件(模块),每个模块只暴露需要对外提供的接口,同时隔离内部实现,解决全局变量污染、代码复用、依赖管理等问题。

CommonJS (Node)

CommonJS 是一套 Node.js 中默认的模块化规范,也是前端模块化发展中重要的一环,主要用于解决代码的模块化组织和依赖管理问题。

一、CommonJS 的特点

  1. 同步加载require() 是同步执行的,会阻塞后续代码,直到模块加载完成。
    适合 Node.js 环境(模块存于本地磁盘,加载速度快),但不适合浏览器(网络加载慢,会阻塞页面渲染)。

  2. 运行时加载:模块的导入和导出在代码运行时执行,属于 “动态加载”。
    例如,require() 可以写在条件语句中,根据运行时条件动态加载模块。

  3. 值拷贝:导入的是模块导出值的 “拷贝”,若原模块后续修改了导出的基本类型值,导入方不会同步更新(引用类型除外,因拷贝的是引用地址)。

二、语法

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.resolverequire.cache),是 Node.js 为 CommonJS 模块定制的特殊函数。

image.png

console.log('module.require', module.require)

在 Node.js 中直接打印 module 对象时,控制台输出里看不到 require 函数,但却能通过 module.require 访问到这个函数 —— 核心原因是 requiremodule 对象的不可枚举属性,默认不会被 console.log 打印出来,但实际存在且可调用。

image.png

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')) 

image.png

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 目录(从当前目录向上递归)。

image.png

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");

image.png

image.png

模块机制

Node.js 的模块加载过程可概括为:路径分析 → 文件定位 → 编译执行 → 返回导出对象

Node.js 中的模块主要分为以下三类:

  1. 核心模块(Core Modules),Node 提供(核心模块在 Node 源代码编译过程中,编译进了二进制执行文件,在 Node 进程启动时,部分核心模块就直接被加载进内存中,加载速度快)。因此,在核心模块引入时。文件定位和编译执行这两个步骤可以省略,并且路径分析中优先判断,因此加载速度是最快。
  2. 文件模块(File Modules),用户编写的,在运行时动态加载,需要完成的路径分析、文件定位、编译执行过程,因此速度比核心模块,加载速度慢。
  3. 第三方模块(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 读取源码字符串。
  • 将源码包装到一个函数中(提供 exportsrequiremodule__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 };
});

缓存分析

  1. 核心模块。
    • 缓存位置? Node.js 启动时已将核心模块(如 fshttp)编译进可执行文件,通过 NativeModule 或 internalBinding 内部管理,不暴露在 require.cache 中
    • 特点? 核心模块的加载优先级最高,且无法被用户代码删除或替换缓存。多次 require('fs') 返回的是同一个内部对象。
  2. 文件模块/第三方模块。
    • 缓存位置? 所有通过文件路径加载的模块(包括相对路径、绝对路径以及从 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。

image.png

image.png

命名导入(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)

加载机制

  1. 解析:将模块标识符转换为绝对 URL/路径。
  2. 获取:如果未缓存,则下载/读取模块源码(浏览器发网络请求,Node.js 读文件)。
  3. 解析:将源码解析为模块记录(Module Record),同时递归解析其静态 import 的依赖。
  4. 实例化:为模块创建执行环境,链接导入/导出变量(此时尚未执行代码)。
  5. 执行:按照依赖图顺序执行模块的顶层代码(即模块函数体)。

第一中 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);
  });

image.png

// 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}`,
}

image.png

第二种 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);
  });

image.png

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" };

最后

  1. 《JavaScript重难点实例精讲》 出版社:人民邮电出版社
  2. node ES Module
  3. node CommonJS
  4. 《深入浅出 Node.js》