CommonJs、esModules 深入浅出
1. 为什么会有 CommonJs 和 esModules
在早期 JavaScript 开发中,开发者通常使用全局变量来定义和使用模块。这种方式虽然简单,但在大型项目中容易导致变量污染和命名冲突。为了解决这个问题,开发者开始使用模块系统来组织代码,确保每个模块的变量和函数不会相互干扰。
1.1 主要问题:
- 变量污染:js 文件作用域都是顶层,导致变量冲突。
- js 文件多,需要手动维护依赖关系。
- 文件依赖问题,若引入顺序错误,会导致报错。 为解决以上问题,javascript 社区出现了 CommonJS 和 ES Modules 是 JavaScript 中两种主要的模块系统。它们的出现是为了解决模块化开发中的问题,提供了一种更结构化和可维护的代码组织方式。 解决变量污染问题:
- 每个文件都是独立的作用域,所以不存在变量污染问题。 解决代码维护与文件依赖问题:
- 通过 import/require 语句明确声明依赖关系
- 模块系统会自动解析和维护依赖树
- 避免手动管理文件加载顺序
- 支持按需加载,提高性能
2. Commonjs
Commonjs 弥补了 javascript 没有模块化,没有统一标准的缺陷。 nodejs 借鉴了 Commonjs 规范,实现了模块化。 Commonjs 规范规定,一个单独的文件就是一个模块,每个模块都是一个单独的作用域;每个模块都有独自的独立作用域,模块与模块之间互不干扰,并且每个模块中定义的变量、函数、对象都是私有的,对其他模块不可见。
2.1 CommonJS 应用场景
- Node.js 是 CommonJS 的主要实现,Node.js 环境中默认使用 CommonJS 规范
- Browserify 是一个打包工具,可以将 CommonJS 模块打包成浏览器可识别的代码
- Webpack 支持 CommonJS 模块规范,可以处理和打包 CommonJS 模块
2.2 CommonJS 语法
- 导入模块:js
const module = require('./module.js')
- 导出模块:js
// 方式一:使用 module.exports module.exports = { method1, method2 } // 方式二:使用 exports exports.method1 = method1 exports.method2 = method2
使用特点
- 在 Commonjs 中每一个 js 文件都是一个独立的模块,每个模块都是一个单独的作用域。
- 包含 Commonjs 规范的核心变量是: module、exports、require。
- require 是 Commonjs 规范中用于导入模块的函数。
3. CommonJS 实现原理
CommonJS 的实现原理是一个非常精巧的设计,它通过模块包装器(Module Wrapper)和巧妙的对象引用机制来实现模块的隔离与导出。
3.1 模块包装机制
当我们编写一个 CommonJS 模块时,我们的代码实际上会被 Node.js 包装在一个函数里面。这个包装函数提供了私有的作用域,同时注入了一些重要的变量:
(function(exports, require, module, __filename, __dirname) {
// 我们编写的模块代码会被放在这里
})
这个包装函数非常关键,它:
- 创建了一个私有的作用域,避免了全局变量的污染
- 注入了 module 和 exports 对象,使得模块能够导出内容
- 提供了 require 函数,允许模块导入其他模块
- 提供了文件相关的变量 __filename 和 __dirname
3.2加载机制详解
Node.js 的模块加载过程是同步的,按照以下步骤执行:
路径解析:
- 首先解析模块路径,支持多种格式(相对路径、绝对路径、包名)
- 查找 node_modules 目录
- 处理文件扩展名(.js、.json、.node)
缓存检查:
- Node.js 维护了一个模块缓存系统
- 首次加载时,模块会被执行并缓存
- 后续加载同一模块时直接返回缓存结果
// 简化版的模块加载实现
function require(moduleId) {
// 1. 检查模块是否已经被加载
if (require.cache[moduleId]) {
return require.cache[moduleId].exports;
}
// 2. 创建模块对象
const module = {
exports: {},
loaded: false,
id: moduleId
};
// 3. 将模块放入缓存
require.cache[moduleId] = module;
// 4. 加载模块
loadModule(moduleId, module, module.exports);
// 5. 返回导出的内容
return module.exports;
}
3.3 导出机制的实现
CommonJS 的导出机制基于 module.exports 对象:
引用传递:
- module.exports 初始为空对象 {}
- exports 变量是 module.exports 的引用
- 最终导出的是 module.exports 指向的对象
值拷贝:
- 导入模块时得到的是值的拷贝,而不是引用
- 这意味着导入的值不会随导出模块的变化而变化
// 在模块内部
var module = { exports: {} };
var exports = module.exports;
// 这样赋值是可以的
exports.hello = function() {};
// 这样会断开 exports 与 module.exports 的引用
exports = { hello: function() {} }; // 不推荐
// 这样才是正确的完整重新赋值方式
module.exports = { hello: function() {} };
3.4 循环依赖处理
CommonJS 通过返回"未完成"的导出来处理循环依赖:
- 模块在执行时标记为"正在加载"
- 如果在加载过程中遇到循环依赖
- 返回当前已执行的部分结果
- 继续执行未完成的加载过程
这种机制可能导致一些模块拿到的是不完整的导出,需要开发者特别注意。
3.5 require 文件加载流程
3.5.1 模块分类
require 可以加载三种类型的模块:
const fs = require('fs') // ① 核心模块
const hello = require('./hello.js') // ② 文件模块
const crypto = require('crypto-js') // ③ 第三方模块
流程图:
接收模块标识符
↓
判断标识符类型
↓
┌─────┴─────┬────────┐
核心模块 路径模块 第三方模块
(fs/http) (./ ../ /) (其他)
│ │ │
优先加载 解析真实路径 遍历node_modules
│ │ │
返回内置 按扩展名查找 查找package.json
│ │ │
└─────┬────┴─────────┘
↓
返回模块内容
3.5.2 模块标识符处理原则
- 核心模块:如
fs
、http
等,优先级最高 - 路径模块:以
./
、../
、/
开头的模块 - 第三方模块:非路径且非核心模块
3.5.3 加载流程图
开始 require(id)
↓
检查 Module._cache
↓
是否缓存? ──是─→ 返回 module.exports
↓ 否
解析模块路径
↓
创建新的 module 对象
↓
将 module 加入缓存
↓
加载并执行模块
↓
module.loaded = true
↓
返回 module.exports
3.5.4 模块查找顺序
核心模块:
- 直接返回编译好的内置模块
文件模块:
require('./module')
↓
检查 module.js
↓
是否存在?──否──┐
│是 ↓
↓ 检查 module.json
加载执行 ↓
│ 是否存在?──否──┐
│ │是 ↓
│ ↓ 检查 module.node
│ 加载执行 ↓
│ │ 是否存在?──否──→ 抛出错误
│ │ │是
│ │ ↓
└──────────┴──────→ 加载执行
- 第三方模块:
require('module-name')
↓
当前目录/node_modules
↓
是否找到模块?───是──→ 返回模块
↓ 否
向上级目录查找
↓
父级/node_modules
↓
是否找到模块?───是──→ 返回模块
↓ 否
继续向上查找直到根目录
↓
抛出模块未找到错误
3.5.5 避免循环引用的机制
// a.js 加载 b.js 时的过程
require('b.js')
↓
将 b.js 加入缓存 // 关键步骤!
↓
执行 b.js
↓
b.js 尝试 require('a.js')
↓
发现 a.js 在缓存中
↓
直接返回未完成的 a.js 的 exports
3.5.6 核心实现代码
function require(id) {
// 1. 检查缓存
const cachedModule = Module._cache[id]
if (cachedModule) {
return cachedModule.exports
}
// 2. 创建模块
const module = {
exports: {},
loaded: false,
id: id
}
// 3. 缓存模块
Module._cache[id] = module
// 4. 加载模块
loadModule(id, module, module.exports)
// 5. 标记完成
module.loaded = true
// 6. 返回导出
return module.exports
}
3.6 require 动态加载
CommonJS 的 require 是同步加载的,但它也支持动态加载。这意味着我们可以在代码运行时根据条件来决定加载哪个模块:
const moduleName = require('./module.js')
if (condition) {
moduleName = require('./module2.js')
}
4. export 和 module.exports 的区别与使用
4.1 exports 和 module.exports 的关系
- exports 是 module.exports 的引用
- 初始时 exports = module.exports = {}
- 最终导出的是 module.exports 指向的对象
4.2 exports = {} 直接赋值的问题
- 这会切断 exports 与 module.exports 的引用关系
- module.exports 仍然指向原来的空对象
- 导致导出的内容仍是空对象
4.3 exports 的正确使用方式
// 1. 正确:通过属性赋值
exports.name = 'value'
exports.method = function() {}
// 2. 正确:通过 module.exports 整体赋值
module.exports = {
name: 'value',
method: function() {}
}
// 3. 错误:直接赋值会断开引用
exports = { // 这样会导致导出失败
name: 'value'
}
4.4 内部实现原理
// Node.js 模块系统内部实现简化版
function require(/* ... */) {
// 1. 创建模块对象
const module = { exports: {} }
// 2. 创建 exports 引用
const exports = module.exports
// 3. 执行模块代码(在这里可能会修改 exports)
(function(exports, require, module) {
// 模块代码在这里执行
})(exports, require, module)
// 4. 返回 module.exports(而不是 exports)
return module.exports
}
4.5 使用建议
保持一致性
- 在同一模块中,选择一种导出方式并坚持使用
- 不要混用 exports.x 和 module.exports 赋值
避免同时使用
javascript// 不推荐 exports.name = 'value' module.exports = { method: function() {} }
推荐写法
javascript// 方式一:属性导出 exports.name = 'value' exports.method = function() {} // 方式二:整体导出 module.exports = { name: 'value', method: function() {} }
4.6 常见陷阱
导出空对象
javascriptexports = { name: 'value' } // 错误:模块会导出空对象 console.log(module.exports) // {}
引用丢失
javascript// 错误示例 exports = function() {} // 引用丢失 exports.name = 'value' // 无效赋值 // 正确示例 module.exports = function() {} module.exports.name = 'value'
异步赋值
javascript// 注意:异步赋值可能导致意外结果 setTimeout(() => { exports.name = 'value' // 如果在 require 之后,导入方无法获取此值 }, 1000)
4.7 为什么同时存在 exports 和 module.exports?
历史原因:
- Node.js 早期只有 module.exports
- exports 是为了提供更简便的写法而创建的别名
- exports = module.exports 的设计让开发者可以少写 "module."
使用便利性:
javascript// 使用 module.exports 较繁琐 module.exports.method1 = function() {} module.exports.method2 = function() {} // 使用 exports 更简洁 exports.method1 = function() {} exports.method2 = function() {}
4.8 module.exports 的局限性
整体赋值会覆盖之前的导出:
javascriptmodule.exports.method1 = function() {} module.exports = { method2: function() {} } // method1 被覆盖掉了
不支持渐进式导出:
javascript// 当需要分散在多处导出时,module.exports 不够灵活 if (condition) { module.exports = { /* ... */ } // 会覆盖之前的导出 } else { module.exports = { /* ... */ } // 也会覆盖 }
代码组织性差:
javascript// 使用 exports 可以就近导出,更清晰 function method1() {} exports.method1 = method1 function method2() {} exports.method2 = method2 // 使用 module.exports 往往需要集中导出,可读性较差 function method1() {} function method2() {} module.exports = { method1, method2 }
重构成本高:
javascript// 如果使用 module.exports 整体导出,添加或删除方法需要修改导出对象 module.exports = { method1, method2, // 添加新方法需要修改这里 } // 使用 exports 则更灵活 exports.method1 = method1 exports.method2 = method2 // 直接添加新方法即可,无需修改其他代码 exports.method3 = method3
5. ES Modules
5.1 基本概念
ES Modules (ESM) 是 JavaScript 官方的标准模块系统,具有以下特点:
静态性:
- 导入导出语句只能在模块顶层
- 导入导出的模块路径必须是字符串常量
- 在编译时就能确定模块的依赖关系
异步加载:
- 模块文件的加载是异步的
- 执行是在所有模块加载完成之后
优势
- 借助 Es Modules 的静态导入导出的优势,实现了 tree-shaking 优化,可以有效减少打包后的代码体积。
- 利用 import() 懒加载方式实现了代码分割
5.2 基本语法
Es6 Modules 的导入和导出都是静态的,import 会自动提升到代码顶层,而 require 是动态的,需要等到运行时才能确定依赖关系; import, export 不能放在块级作用于或条件语句中。
// 1. 命名导出
export const name = 'value'
export function method() {}
// 2. 默认导出
export default function() {}
// 3. 命名导入
import { name, method } from './module.js'
// 4. 默认导入
import defaultMethod from './module.js'
// 5. 混合导入
import defaultMethod, { name, method } from './module.js'
5.3 加载机制
ES6 Modules 和 CommonJS 的加载机制一样,对于相同的 js 文件,会保存静态属性。 但 ES6 Modules 的加载机制是提前加载并执行模块文件,ES6 模块在编译时就能确定模块的依赖关系,而 CommonJS 是同步的,需要等到运行时才能确定依赖关系。
1. 构造阶段
↓
2. 实例化阶段
↓
3. 求值阶段
↓
4. 所有模块加载完成
↓
5. 执行模块代码
5.4 与 CommonJS 的主要区别
加载时机:
- CommonJS:同步加载
- ES Modules:异步加载
导入导出:
javascript// CommonJS const module = require('./module') module.exports = { /* ... */ } // ES Modules import module from './module' export const value = { /* ... */ }
模块对象:
- CommonJS:值拷贝
- ES Modules:引用绑定
循环依赖:
javascript// ES Modules 的循环依赖处理更优雅 // a.js import { b } from './b.js' export const a = 'a' // b.js import { a } from './a.js' export const b = 'b'
5.5 ES Modules 的优势
静态分析:
- 支持树摇(tree-shaking)
- 编译时优化
- 更好的代码提示
实时绑定:
javascript// module.js export let count = 0 export function increment() { count++ } // main.js import { count, increment } from './module.js' console.log(count) // 0 increment() console.log(count) // 1
严格模式:
- 自动启用严格模式
- 更安全的代码执行环境
5.6 浏览器支持
<!-- 1. 基本使用 -->
<script type="module" src="main.js"></script>
<!-- 2. 内联模块 -->
<script type="module">
import { method } from './module.js'
</script>
<!-- 3. 降级处理 -->
<script nomodule src="fallback.js"></script>
5.7 动态导入
// 1. 条件导入
if (condition) {
const module = await import('./module.js')
}
// 2. 按需导入
button.onclick = async () => {
const { method } = await import('./module.js')
method()
}
- import 被导入的模块会保存静态属性,运行在严格模式;而 require 被导入的模块会保存动态属性,运行在非严格模式。
- import 被导入的变量都是只读的,不能直接赋值;而 require 被导入的变量是可读可写的。
- import 被导入得到变量是与原变量绑定/关联的,而 require 被导入得到变量是与原变量不绑定/不关联的。
import 功能
- 动态加载
if(condition){
import('./module.js').then(module => {
console.log(module)
})
}
- 懒加载
[
{
path: 'home',
name: '首页',
component: ()=> import('./home') ,
},
]
import() 这种加载效果,可以很轻松的实现代码分割。但也要避免一次性加载大量 js 文件,造成首次加载白屏时间过长的情况。
5.8 Tree-Shaking
Tree-Shaking 是一种优化技术,用于移除未使用的代码,如一些被 import 但其实没有被使用到的代码。在 ES6 Modules 中,Tree-Shaking 可以有效减少打包后的代码体积。
6. CommonJs 和 Es Module 总结
CommonJS 的特点总结:
运行时加载: CommonJS 是在代码运行时才确定模块的依赖关系。
// 你点菜时才知道需要哪些食材。导出方式简单: 只能导出一个对象(exports)。 // 就像一个包裹,所有要导出的东西都要放在这个包裹里。
灵活加载: 可以根据条件来决定加载哪个模块,而且会缓存加载过的模块避免重复加载。
// 就像去超市买东西,买过的东西会记在账上。同步执行: 加载模块时会暂停等待,直到模块加载完成。
// 就像排队买票,前面的人没买完,后面的人只能等着。
ES Module 的特点总结:
编译时加载: 在代码编译阶段就确定模块的依赖关系。 // 就像提前列好购物清单。
动态引用: 导入的值和原值是绑定的,原值改变时导入的值也会改变。
// 就像连体婴儿,一个动另一个也会动。导出方式灵活: 可以导出多个独立的值,可以按需导入导出。
// 就像从抽屉里可以单独拿东西,不用整个抽屉都搬走。更好的优化: 支持删除未使用的代码(Tree Shaking)和代码分割(Code Splitting)。 // 就像整理衣柜,把不穿的衣服扔掉,把衣服按季节分类。
严格模式: 默认在严格模式下运行,可以避免一些常见错误。
// 就像开车系安全带,更安全。