文章

CommonJS规范与实现深度解析

CommonJS规范与实现深度解析

一句话概括

CommonJS 是 Node.js 采用的模块化规范,通过 require 同步加载模块、module.exports 导出接口,构建了服务器端的模块生态。

背景

在 ES Module 出现之前,JavaScript 没有官方模块系统。CommonJS 规范应运而生,最初为服务器端 JavaScript(Node.js)设计,后来也成为浏览器端打包工具(如 Webpack、Browserify)的模块标准。

为什么需要模块化?

  • 避免全局变量污染
  • 明确依赖关系
  • 支持代码复用与拆分
  • 便于维护和测试

概念与定义

核心概念

  • 模块(Module):一个独立的 JavaScript 文件,拥有自己的作用域
  • require():同步加载模块的函
  • module.exports:模块导出对象
  • exports:指向 module.exports 的引用(快捷方式)

模块作用域

每个 CommonJS 模块都有独立作用域,模块内部的变量、函数不会污染全局:

1
2
3
4
5
6
7
8
9
10
// module.js
const privateVar = '我只在本模块可见';

function privateFunc() {
  return '私有方法';
}

module.exports = {
  publicMethod: () => '公开方法'
};

最小示例

导出模块(math.js):

1
2
3
4
5
// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

module.exports = { add, subtract };

导入模块(main.js):

1
2
3
4
5
// main.js
const { add, subtract } = require('./math');

console.log(add(5, 3));      // 8
console.log(subtract(5, 3)); // 2

核心知识点拆解

1. require() 的查找规则

require() 接收模块标识符,按照以下顺序查找:

(1)核心模块(Node.js 内置)

1
2
3
const fs = require('fs');           // 文件系统模块
const path = require('path');       // 路径处理模块
const http = require('http');       // HTTP 模块

(2)相对路径 / 绝对路径

1
2
3
require('./module');    // 当前目录
require('../module');   // 上级目录
require('/module');     // 绝对路径

(3)node_modules 目录

1
require('lodash');      // 从 node_modules 查找

查找顺序:

  1. 当前目录的 node_modules
  2. 上级目录的 node_modules
  3. 逐级向上,直到根目录

(4)文件后缀自动补全

require('./module') 会依次尝试:

  1. module.js
  2. module.json
  3. module.node(C++ 插件)
  4. module/index.js(文件夹中的 index.js)

2. module.exports vs exports

常见误区:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 错误用法:直接给 exports 赋值
exports = {
  add: (a, b) => a + b
};
// 这样写无效!因为 exports 不再指向 module.exports

// ✅ 正确用法 1:给 module.exports 赋值
module.exports = {
  add: (a, b) => a + b
};

// ✅ 正确用法 2:给 exports 添加属性
exports.add = (a, b) => a + b;

原理:

1
2
3
4
5
// Node.js 模块包装器
(function(exports, require, module, __filename, __dirname) {
  // 你的模块代码在这里
  exports.add = (a, b) => a + b;
});
  • exports 只是 module.exports 的引用
  • 直接给 exports 赋值会切断引用关系
  • 最终导出的是 module.exports

3. 模块加载缓存机制

模块只会加载一次,后续调用返回缓存:

1
2
3
4
5
6
7
8
9
// counter.js
let count = 0;

module.exports = {
  increment() {
    count++;
    return count;
  }
};
1
2
3
4
5
6
// main.js
const counter1 = require('./counter');
const counter2 = require('./counter');

console.log(counter1.increment()); // 1
console.log(counter2.increment()); // 2(共享同一个实例)

缓存机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Node.js 内部实现(简化版)
const Module = {
  _cache: {},

  _load(filePath) {
    // 1. 检查缓存
    if (Module._cache[filePath]) {
      return Module._cache[filePath].exports;
    }

    // 2. 创建模块实例
    const module = new Module(filePath);

    // 3. 加载并执行模块
    module.load();

    // 4. 缓存模块
    Module._cache[filePath] = module;

    // 5. 返回 exports
    return module.exports;
  }
};

4. 循环依赖的处理

CommonJS 能处理循环依赖,但可能返回不完整模块:

1
2
3
4
5
6
7
8
9
// a.js
console.log('a 开始加载');
exports.x = 'a1';

const b = require('./b');
console.log('在 a 中,b.y =', b.y);

exports.z = 'a2';
console.log('a 加载完成');
1
2
3
4
5
6
7
8
9
10
// b.js
console.log('b 开始加载');
exports.y = 'b1';

const a = require('./a');
console.log('在 b 中,a.x =', a.x);
console.log('在 b 中,a.z =', a.z); // undefined!

exports.w = 'b2';
console.log('b 加载完成');

执行结果:

1
2
3
4
5
6
7
a 开始加载
b 开始加载
在 b 中,a.x = a1
在 b 中,a.z = undefined
b 加载完成
在 a 中,b.y = b1
a 加载完成

原因:

  • a.js 加载时,执行到 require('./b') 暂停
  • b.js 加载时,执行到 require('./a') 直接从缓存获取 a.js 的 module.exports
  • 此时 a.js 还没执行完,exports.z 还未定义

解决方案:

  1. 使用函数导出(运行时取值)
  2. 避免紧密耦合的循环依赖
  3. 使用 ES Module(静态分析更好)

实战案例

案例 1:创建一个工具库

项目结构:

1
2
3
4
5
6
7
my-utils/
├── package.json
├── lib/
│   ├── string.js
│   ├── array.js
│   └── object.js
└── index.js

lib/string.js:

1
2
3
4
5
6
7
8
9
module.exports = {
  capitalize(str) {
    return str.charAt(0).toUpperCase() + str.slice(1);
  },

  camelCase(str) {
    return str.replace(/-(\w)/g, (_, c) => c.toUpperCase());
  }
};

lib/array.js:

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
  unique(arr) {
    return [...new Set(arr)];
  },

  flatten(arr) {
    return arr.reduce((flat, item) =>
      flat.concat(Array.isArray(item) ? this.flatten(item) : item), []
    );
  }
};

index.js(统一导出):

1
2
3
4
5
module.exports = {
  ...require('./lib/string'),
  ...require('./lib/array'),
  ...require('./lib/object')
};

使用:

1
2
3
4
const utils = require('my-utils');

console.log(utils.capitalize('hello')); // Hello
console.log(utils.unique([1, 2, 2, 3])); // [1, 2, 3]

案例 2:模拟 require() 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const Module = require('module');
const path = require('path');
const fs = require('fs');

function myRequire(filePath) {
  // 1. 解析绝对路径
  const absolutePath = path.resolve(filePath);

  // 2. 检查缓存
  if (Module._cache[absolutePath]) {
    return Module._cache[absolutePath].exports;
  }

  // 3. 读取文件内容
  const code = fs.readFileSync(absolutePath, 'utf-8');

  // 4. 创建模块实例
  const module = {
    exports: {},
    filename: absolutePath
  };

  // 5. 包装代码(模拟 Node.js 模块包装器)
  const wrappedCode = `(function(exports, require, module, __filename, __dirname) {
    ${code}
  })`;

  // 6. 执行代码
  const fn = eval(wrappedCode);
  fn(module.exports, myRequire, module, absolutePath, path.dirname(absolutePath));

  // 7. 缓存模块
  Module._cache[absolutePath] = module;

  // 8. 返回 exports
  return module.exports;
}

底层原理

Node.js 模块包装器

Node.js 在执行模块代码前,会将其包装在一个函数中:

1
2
3
(function(exports, require, module, __filename, __dirname) {
  // 模块代码
});

作用:

  1. 提供模块作用域(避免全局污染)
  2. 注入模块专有变量(__filename, __dirname
  3. 提供导出接口(exports, module

模块加载流程(简化版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Node.js 内部实现(极度简化)
function loadModule(filename, module, require) {
  const wrappedSrc = `(function(module, exports, require) {
    ${fs.readFileSync(filename, 'utf-8')}
  })(module, module.exports, require);`;

  eval(wrappedSrc);
}

function require(filename) {
  const module = { exports: {} };
  loadModule(filename, module, require);
  return module.exports;
}

缓存机制的本质

1
2
3
4
5
6
7
8
9
// Module._cache 是一个对象
Module._cache = {
  '/path/to/module.js': Module { /* 模块实例 */ }
};

// require() 时先查缓存
if (Module._cache[filename]) {
  return Module._cache[filename].exports;
}

高频面试题解析

Q1: module.exports 和 exports 的区别是什么?

答:

  • exportsmodule.exports 的引用(快捷方式)
  • module.exports 才是真正的导出对象
  • 直接给 exports 赋值会切断引用关系,导致导出失败
1
2
3
4
5
6
7
8
// ✅ 正确:添加属性
exports.foo = 'bar';

// ✅ 正确:直接赋值给 module.exports
module.exports = { foo: 'bar' };

// ❌ 错误:直接给 exports 赋值
exports = { foo: 'bar' }; // 无效!

Q2: CommonJS 和 ES Module 的核心区别?

答:

特性CommonJSES Module
加载方式同步加载异步加载
导入导出require / module.exportsimport / export
静态分析不支持(运行时加载)支持(编译时解析)
值拷贝输出值的拷贝(缓存)输出值的引用(实时绑定)
循环依赖可能返回不完整模块支持更好的循环依赖处理
使用场景Node.js 服务端浏览器 + 服务端(通用)

值拷贝 vs 值引用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// CommonJS(值拷贝)
// counter.js
let count = 0;
module.exports = { count, increment };

function increment() {
  count++;
}

// main.js
const { count, increment } = require('./counter');
console.log(count); // 0
increment();
console.log(count); // 还是 0!(count 是值拷贝)
1
2
3
4
5
6
7
8
9
10
11
12
13
// ES Module(值引用)
// counter.mjs
export let count = 0;

export function increment() {
  count++;
}

// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1!(count 是实时绑定)

Q3: require() 的查找顺序是什么?

答:

  1. 缓存检查(Module._cache
  2. 核心模块(Node.js 内置)
  3. 相对/绝对路径模块
  4. node_modules 目录(逐级向上查找)
  5. 文件后缀自动补全(.js.json.nodeindex.js

Q4: CommonJS 如何处理循环依赖?有什么问题?

答:

  • CommonJS 能处理循环依赖,但可能返回不完整的模块
  • 原因:模块未执行完时就被缓存,后续加载返回不完整的 exports

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// a.js
exports.x = 'a1';
const b = require('./b');
exports.z = 'a2';

// b.js
exports.y = 'b1';
const a = require('./a');
console.log(a.x); // 'a1'(已定义)
console.log(a.z); // undefined(还未定义)

// main.js
const a = require('./a');
console.log(a.z); // 'a2'(完整导出)

解决方案:

  1. 避免循环依赖(重构代码)
  2. 导出函数(运行时取值)
  3. 使用 ES Module(静态分析更好)

总结与扩展

核心要点

  1. CommonJS 是 Node.js 的模块化规范
    • 使用 require() 同步加载
    • 使用 module.exports 导出
  2. module.exports vs exports
    • exportsmodule.exports 的引用
    • 直接给 exports 赋值无效
  3. 模块缓存机制
    • 模块只会加载一次
    • 后续调用返回缓存的 exports
  4. 循环依赖的处理
    • 可能返回不完整的模块
    • 应避免紧密耦合的循环依赖

扩展学习

  • ES Module 规范import / export 语法
  • 模块打包原理:Webpack、Rollup 的模块打包机制
  • 动态导入require() 的动态加载 vs import() 动态导入
  • Tree Shaking:基于 ES Module 静态分析的死代码消除

实战建议

  1. 优先使用 module.exports
    • 避免 exports 赋值的误区
  2. 避免循环依赖
    • 重构代码,解耦模块关系
  3. 合理使用缓存
    • 利用模块缓存避免重复计算
  4. 迁移到 ES Module
    • 新项目优先使用 ES Module
    • 老项目逐步迁移(Node.js 支持 ES Module)

参考资料:

  • Node.js 官方文档 - Modules
  • 《深入浅出 Node.js》 - 朴灵
  • CommonJS 规范 - commonjs.org
本文由作者按照 CC BY 4.0 进行授权