Skip to content

CommonJs、esModules 深入浅出

1. 为什么会有 CommonJs 和 esModules

在早期 JavaScript 开发中,开发者通常使用全局变量来定义和使用模块。这种方式虽然简单,但在大型项目中容易导致变量污染和命名冲突。为了解决这个问题,开发者开始使用模块系统来组织代码,确保每个模块的变量和函数不会相互干扰。

1.1 主要问题:

  1. 变量污染:js 文件作用域都是顶层,导致变量冲突。
  2. js 文件多,需要手动维护依赖关系。
  3. 文件依赖问题,若引入顺序错误,会导致报错。 为解决以上问题,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

使用特点

  1. 在 Commonjs 中每一个 js 文件都是一个独立的模块,每个模块都是一个单独的作用域。
  2. 包含 Commonjs 规范的核心变量是: module、exports、require。
  3. require 是 Commonjs 规范中用于导入模块的函数。

3. CommonJS 实现原理

CommonJS 的实现原理是一个非常精巧的设计,它通过模块包装器(Module Wrapper)和巧妙的对象引用机制来实现模块的隔离与导出。

3.1 模块包装机制

当我们编写一个 CommonJS 模块时,我们的代码实际上会被 Node.js 包装在一个函数里面。这个包装函数提供了私有的作用域,同时注入了一些重要的变量:

javascript
(function(exports, require, module, __filename, __dirname) {
    // 我们编写的模块代码会被放在这里
})

这个包装函数非常关键,它:

  • 创建了一个私有的作用域,避免了全局变量的污染
  • 注入了 module 和 exports 对象,使得模块能够导出内容
  • 提供了 require 函数,允许模块导入其他模块
  • 提供了文件相关的变量 __filename 和 __dirname

3.2加载机制详解

Node.js 的模块加载过程是同步的,按照以下步骤执行:

  1. 路径解析

    • 首先解析模块路径,支持多种格式(相对路径、绝对路径、包名)
    • 查找 node_modules 目录
    • 处理文件扩展名(.js、.json、.node)
  2. 缓存检查

    • Node.js 维护了一个模块缓存系统
    • 首次加载时,模块会被执行并缓存
    • 后续加载同一模块时直接返回缓存结果
javascript
// 简化版的模块加载实现
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 对象:

  1. 引用传递

    • module.exports 初始为空对象 {}
    • exports 变量是 module.exports 的引用
    • 最终导出的是 module.exports 指向的对象
  2. 值拷贝

    • 导入模块时得到的是值的拷贝,而不是引用
    • 这意味着导入的值不会随导出模块的变化而变化
javascript
// 在模块内部
var module = { exports: {} };
var exports = module.exports;

// 这样赋值是可以的
exports.hello = function() {};

// 这样会断开 exports 与 module.exports 的引用
exports = { hello: function() {} }; // 不推荐

// 这样才是正确的完整重新赋值方式
module.exports = { hello: function() {} };

3.4 循环依赖处理

CommonJS 通过返回"未完成"的导出来处理循环依赖:

  1. 模块在执行时标记为"正在加载"
  2. 如果在加载过程中遇到循环依赖
  3. 返回当前已执行的部分结果
  4. 继续执行未完成的加载过程

这种机制可能导致一些模块拿到的是不完整的导出,需要开发者特别注意。

3.5 require 文件加载流程

3.5.1 模块分类

require 可以加载三种类型的模块:

javascript
const fs = require('fs')           // ① 核心模块
const hello = require('./hello.js') // ② 文件模块
const crypto = require('crypto-js') // ③ 第三方模块

流程图:

接收模块标识符

判断标识符类型

┌─────┴─────┬────────┐
核心模块     路径模块   第三方模块
(fs/http)   (./ ../ /)  (其他)
   │           │         │
优先加载    解析真实路径   遍历node_modules
   │           │         │
返回内置    按扩展名查找   查找package.json
   │           │         │
   └─────┬────┴─────────┘

    返回模块内容

3.5.2 模块标识符处理原则

  1. 核心模块:如 fshttp 等,优先级最高
  2. 路径模块:以 ./..// 开头的模块
  3. 第三方模块:非路径且非核心模块

3.5.3 加载流程图

开始 require(id)

检查 Module._cache

   是否缓存? ──是─→ 返回 module.exports
      ↓ 否
  解析模块路径

创建新的 module 对象

将 module 加入缓存

  加载并执行模块

module.loaded = true

返回 module.exports

3.5.4 模块查找顺序

  1. 核心模块

    • 直接返回编译好的内置模块
  2. 文件模块

require('./module')

  检查 module.js

是否存在?──否──┐
    │是         ↓
    ↓      检查 module.json
 加载执行        ↓
    │      是否存在?──否──┐
    │          │是        ↓
    │          ↓     检查 module.node
    │       加载执行        ↓
    │          │      是否存在?──否──→ 抛出错误
    │          │          │是
    │          │          ↓
    └──────────┴──────→ 加载执行
  1. 第三方模块
require('module-name')

当前目录/node_modules

  是否找到模块?───是──→ 返回模块
         ↓ 否
  向上级目录查找

父级/node_modules

  是否找到模块?───是──→ 返回模块
         ↓ 否
继续向上查找直到根目录

  抛出模块未找到错误

3.5.5 避免循环引用的机制

javascript
// a.js 加载 b.js 时的过程
require('b.js')

将 b.js 加入缓存 // 关键步骤!

执行 b.js

b.js 尝试 require('a.js')

发现 a.js 在缓存中

直接返回未完成的 a.js 的 exports

3.5.6 核心实现代码

javascript
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 是同步加载的,但它也支持动态加载。这意味着我们可以在代码运行时根据条件来决定加载哪个模块:

javascript
const moduleName = require('./module.js')

if (condition) {
    moduleName = require('./module2.js')
}

4. export 和 module.exports 的区别与使用

4.1 exports 和 module.exports 的关系

  1. exports 是 module.exports 的引用
  2. 初始时 exports = module.exports = {}
  3. 最终导出的是 module.exports 指向的对象

4.2 exports = {} 直接赋值的问题

  1. 这会切断 exports 与 module.exports 的引用关系
  2. module.exports 仍然指向原来的空对象
  3. 导致导出的内容仍是空对象

4.3 exports 的正确使用方式

javascript
// 1. 正确:通过属性赋值
exports.name = 'value'
exports.method = function() {}

// 2. 正确:通过 module.exports 整体赋值
module.exports = {
    name: 'value',
    method: function() {}
}

// 3. 错误:直接赋值会断开引用
exports = {  // 这样会导致导出失败
    name: 'value'
}

4.4 内部实现原理

javascript
// 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 使用建议

  1. 保持一致性

    • 在同一模块中,选择一种导出方式并坚持使用
    • 不要混用 exports.x 和 module.exports 赋值
  2. 避免同时使用

    javascript
    // 不推荐
    exports.name = 'value'
    module.exports = { method: function() {} }
  3. 推荐写法

    javascript
    // 方式一:属性导出
    exports.name = 'value'
    exports.method = function() {}
    
    // 方式二:整体导出
    module.exports = {
        name: 'value',
        method: function() {}
    }

4.6 常见陷阱

  1. 导出空对象

    javascript
    exports = { name: 'value' }  // 错误:模块会导出空对象
    console.log(module.exports)  // {}
  2. 引用丢失

    javascript
    // 错误示例
    exports = function() {}      // 引用丢失
    exports.name = 'value'       // 无效赋值
    
    // 正确示例
    module.exports = function() {}
    module.exports.name = 'value'
  3. 异步赋值

    javascript
    // 注意:异步赋值可能导致意外结果
    setTimeout(() => {
        exports.name = 'value'  // 如果在 require 之后,导入方无法获取此值
    }, 1000)

4.7 为什么同时存在 exports 和 module.exports?

  1. 历史原因

    • Node.js 早期只有 module.exports
    • exports 是为了提供更简便的写法而创建的别名
    • exports = module.exports 的设计让开发者可以少写 "module."
  2. 使用便利性

    javascript
    // 使用 module.exports 较繁琐
    module.exports.method1 = function() {}
    module.exports.method2 = function() {}
    
    // 使用 exports 更简洁
    exports.method1 = function() {}
    exports.method2 = function() {}

4.8 module.exports 的局限性

  1. 整体赋值会覆盖之前的导出

    javascript
    module.exports.method1 = function() {}
    module.exports = { method2: function() {} }  // method1 被覆盖掉了
  2. 不支持渐进式导出

    javascript
    // 当需要分散在多处导出时,module.exports 不够灵活
    if (condition) {
        module.exports = { /* ... */ }  // 会覆盖之前的导出
    } else {
        module.exports = { /* ... */ }  // 也会覆盖
    }
  3. 代码组织性差

    javascript
    // 使用 exports 可以就近导出,更清晰
    function method1() {}
    exports.method1 = method1
    
    function method2() {}
    exports.method2 = method2
    
    // 使用 module.exports 往往需要集中导出,可读性较差
    function method1() {}
    function method2() {}
    
    module.exports = {
        method1,
        method2
    }
  4. 重构成本高

    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 官方的标准模块系统,具有以下特点:

  1. 静态性

    • 导入导出语句只能在模块顶层
    • 导入导出的模块路径必须是字符串常量
    • 在编译时就能确定模块的依赖关系
  2. 异步加载

    • 模块文件的加载是异步的
    • 执行是在所有模块加载完成之后
  3. 优势

  • 借助 Es Modules 的静态导入导出的优势,实现了 tree-shaking 优化,可以有效减少打包后的代码体积。
  • 利用 import() 懒加载方式实现了代码分割

5.2 基本语法

Es6 Modules 的导入和导出都是静态的,import 会自动提升到代码顶层,而 require 是动态的,需要等到运行时才能确定依赖关系; import, export 不能放在块级作用于或条件语句中。

javascript
// 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 的主要区别

  1. 加载时机

    • CommonJS:同步加载
    • ES Modules:异步加载
  2. 导入导出

    javascript
    // CommonJS
    const module = require('./module')
    module.exports = { /* ... */ }
    
    // ES Modules
    import module from './module'
    export const value = { /* ... */ }
  3. 模块对象

    • CommonJS:值拷贝
    • ES Modules:引用绑定
  4. 循环依赖

    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 的优势

  1. 静态分析

    • 支持树摇(tree-shaking)
    • 编译时优化
    • 更好的代码提示
  2. 实时绑定

    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
  3. 严格模式

    • 自动启用严格模式
    • 更安全的代码执行环境

5.6 浏览器支持

html
<!-- 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 动态导入

javascript
// 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 功能

  1. 动态加载
javascript
 if(condition){
  import('./module.js').then(module => {
      console.log(module)
  })
 }
  1. 懒加载
javascript
[
 {
      path: 'home',
      name: '首页',
      component: ()=> import('./home') ,
 },
]

import() 这种加载效果,可以很轻松的实现代码分割。但也要避免一次性加载大量 js 文件,造成首次加载白屏时间过长的情况。

5.8 Tree-Shaking

Tree-Shaking 是一种优化技术,用于移除未使用的代码,如一些被 import 但其实没有被使用到的代码。在 ES6 Modules 中,Tree-Shaking 可以有效减少打包后的代码体积。

6. CommonJs 和 Es Module 总结

CommonJS 的特点总结:

  1. 运行时加载: CommonJS 是在代码运行时才确定模块的依赖关系。
    // 你点菜时才知道需要哪些食材。

  2. 导出方式简单: 只能导出一个对象(exports)。 // 就像一个包裹,所有要导出的东西都要放在这个包裹里。

  3. 灵活加载: 可以根据条件来决定加载哪个模块,而且会缓存加载过的模块避免重复加载。
    // 就像去超市买东西,买过的东西会记在账上。

  4. 同步执行: 加载模块时会暂停等待,直到模块加载完成。
    // 就像排队买票,前面的人没买完,后面的人只能等着。

ES Module 的特点总结:

  1. 编译时加载: 在代码编译阶段就确定模块的依赖关系。 // 就像提前列好购物清单。

  2. 动态引用: 导入的值和原值是绑定的,原值改变时导入的值也会改变。
    // 就像连体婴儿,一个动另一个也会动。

  3. 导出方式灵活: 可以导出多个独立的值,可以按需导入导出。
    // 就像从抽屉里可以单独拿东西,不用整个抽屉都搬走。

  4. 更好的优化: 支持删除未使用的代码(Tree Shaking)和代码分割(Code Splitting)。 // 就像整理衣柜,把不穿的衣服扔掉,把衣服按季节分类。

  5. 严格模式: 默认在严格模式下运行,可以避免一些常见错误。
    // 就像开车系安全带,更安全。