CommonJS vs ES Modules:require 是运行时加载,import 是编译时静态分析

CommonJS vs ES Modules:从运行时加载到编译时静态分析的演进之路

大家好,欢迎来到今天的讲座。今天我们不聊框架、不聊工具链,也不讲什么“最佳实践”这种听起来很虚的概念——我们来深入探讨一个看似基础却极其重要的话题:CommonJS 与 ES Modules 的本质区别

你可能已经在项目中用过 require 或者 import,但你是否真正理解它们背后的设计哲学?为什么 Node.js 最初选择 CommonJS,后来又逐步拥抱 ES Modules?为什么现代前端构建工具(如 Webpack、Vite)对这两种模块系统的处理方式完全不同?

这篇文章将带你一步步揭开这些谜团,从语法差异到执行机制,再到实际开发中的影响。我会尽量避免使用术语堆砌,而是通过代码示例和逻辑推导,让你真正明白“require 是运行时加载,import 是编译时静态分析”这句话到底意味着什么。


一、什么是模块系统?

在 JavaScript 发展早期,它只是一个浏览器脚本语言,没有内置的模块机制。随着应用复杂度上升,开发者需要一种方式来组织代码:把功能拆分成独立文件,按需引入,避免全局污染。

于是出现了两种主流方案:

特性 CommonJS (Node.js) ES Modules (ES6+)
出现时间 2009年左右 2015年(ES6)
主要用途 Node.js 服务端 浏览器 + Node.js
加载方式 运行时动态加载 编译时静态分析
导出语法 module.exports / exports export / export default
导入语法 require() import

这两个系统虽然都能实现模块化,但底层逻辑完全不同。下面我们逐个剖析。


二、CommonJS:运行时加载的经典代表

1. 基础语法示例

假设我们有两个文件:

math.js

function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

module.exports = {
  add,
  multiply
};

main.js

const { add, multiply } = require('./math');

console.log(add(2, 3));     // 5
console.log(multiply(4, 5)); // 20

2. 核心特点:运行时加载(Runtime Loading)

当你执行 require('./math') 时,Node.js 并不会立刻读取并解析该文件内容。相反,它会:

  • 缓存已加载模块(避免重复加载)
  • 同步读取文件内容
  • 执行模块代码(即执行 math.js 中的所有语句)
  • 返回 module.exports 对象

这个过程发生在程序运行期间,也就是说:

require() 是一个函数调用
✅ 它可以出现在任何地方(条件判断、循环内部等)
✅ 模块路径可以在运行时计算出来

示例:动态加载模块

// 动态加载不同模块
const moduleName = process.argv[2] || 'utils';
const module = require(`./${moduleName}`); // 路径由变量决定!
console.log(module.someFunction());

这正是 CommonJS 的灵活性所在:你可以根据环境、用户输入或配置动态决定加载哪个模块。

但这带来了问题——无法提前优化


三、ES Modules:编译时静态分析的新标准

1. 基础语法示例

math.js

export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

main.js

import { add, multiply } from './math.js';

console.log(add(2, 3));      // 5
console.log(multiply(4, 5)); // 20

注意:这里必须写 .js 扩展名(除非你在 package.json 中设置 "type": "module")。

2. 核心特点:编译时静态分析(Static Analysis at Build Time)

ES Modules 的导入是静态的,这意味着:

import 必须出现在顶层作用域(不能在 if、for 中)
✅ 导入路径必须是字符串字面量(不能是变量)
✅ 所有依赖关系在代码解析阶段就能确定

示例:不允许动态导入(这是关键!)

// ❌ 错误!不能这样做
const path = './utils.js';
import utils from path; // SyntaxError: Cannot use import statement outside a module

这是因为浏览器和 Node.js 在加载前会先做一次“静态扫描”,找出所有 importexport,然后一次性加载全部依赖。

🧠 这就是为什么 Webpack/Vite 等打包工具能进行 Tree Shaking(摇树优化)的原因:它们知道哪些模块被用了,哪些没用,从而移除无用代码。


四、核心差异总结(对比表格)

方面 CommonJS ES Modules
加载时机 运行时(动态) 编译时(静态)
导入语法 require() 函数调用 import 语句(声明式)
导出语法 module.exports / exports export / export default
是否支持动态路径 ✅ 支持(如 require(path) ❌ 不支持(必须是字符串常量)
是否支持条件加载 ✅ 支持(if/else 中使用) ❌ 不支持(只能顶层)
打包优化能力 ⚠️ 较弱(难以静态分析) ✅ 强(可做 tree-shaking)
Node.js 兼容性 默认支持(非模块模式) 需启用 "type": "module"
浏览器原生支持 ❌ 不支持(需打包) ✅ 原生支持()

五、实际影响:性能、调试与构建工具

1. 性能差异:懒加载 vs 预加载

CommonJS 的运行时加载意味着:

  • 只有真正执行到 require() 时才会去读文件
  • 如果某个模块从未被使用,它永远不会加载(节省资源)

ES Modules 则不同:

  • 所有导入都会在启动时被扫描并加载(即使你不使用)
  • 优点是可以提前发现错误(比如拼写错误),缺点是可能多加载一些不需要的模块

示例:CommonJS 的懒加载优势

// utils.js
console.log('Loading utils...'); // 这行只会在 require 时打印

// main.js
if (someCondition) {
  const utils = require('./utils'); // 只有满足条件才加载
}

而 ES Modules:

// main.js
if (someCondition) {
  import utils from './utils'; // ❌ 报错!import 必须在顶层
}

👉 所以 CommonJS 更适合“按需加载”的场景,比如插件系统、路由懒加载等。

2. 构建工具如何处理?

Webpack、Rollup、Vite 等工具都支持两者,但处理策略完全不同:

工具 CommonJS 处理方式 ES Modules 处理方式
Webpack 使用 require.context() 或动态 import 自动识别 import/export,进行 tree-shaking
Vite 支持热更新(HMR)但需额外配置 原生支持,无需转换即可热更新
Rollup 可以打包成 UMD 或 IIFE 更高效,更适合库开发

💡 小贴士:如果你正在写一个公共库(Library),推荐使用 ES Modules,因为它的静态特性让构建工具更容易优化,减少最终包体积。


六、兼容性问题与解决方案

1. Node.js 的双模式共存

Node.js 同时支持两种模块系统,但默认是 CommonJS(非模块模式)。你要使用 ES Modules,必须:

  • 文件扩展名为 .mjs
  • 或者在 package.json 中添加 "type": "module"
{
  "type": "module"
}

这样以后所有 .js 文件都会按 ES Modules 解析。

⚠️ 注意:一旦设置了 "type": "module",你就不能再用 require(),否则会报错!

示例:混合使用(谨慎操作)

// node_modules/my-lib/index.js
module.exports = { hello: () => console.log("Hi!") };

// app.js (如果 type=module)
import myLib from './my-lib'; // ❌ 错误!不能混用

解决办法:要么统一用 ES Modules,要么统一用 CommonJS。

2. 如何在 ES Modules 中模拟 require?

你可以用 import() 动态导入,它是异步的,类似于 require() 的动态行为:

async function loadModule() {
  const { default: utils } = await import('./utils.js');
  return utils;
}

这就是所谓的“动态 import”,它是 ES Modules 的补充机制,用于替代 CommonJS 的动态加载需求。


七、实战建议:什么时候选哪种?

场景 推荐模块系统 理由
Node.js 后端服务 CommonJS(旧项目)或 ES Modules(新项目) CommonJS 更成熟;ES Modules 更现代,利于打包优化
前端项目(React/Vue/Angular) ES Modules 浏览器原生支持,构建工具友好,利于 tree-shaking
库开发(npm 包) ES Modules 更容易被其他项目引用,且构建工具可优化
插件系统 / 动态加载 CommonJS 支持运行时动态 require,灵活性更高
教学入门 CommonJS 更直观,适合初学者理解模块概念

八、结语:理解本质,才能写出更好的代码

今天我们一起回顾了 CommonJS 和 ES Modules 的根本区别:

  • CommonJS 是运行时加载:灵活、动态、适合服务器端、插件系统。
  • ES Modules 是编译时静态分析:可预测、易优化、适合前端、库开发。

记住一句话:“require 是运行时加载,import 是编译时静态分析”——这不是一句口号,而是两种设计哲学的根本差异。

未来几年,随着 Node.js 完全转向 ES Modules(v14+ 已默认支持),以及浏览器生态越来越强大,你会看到越来越多项目采用 ES Modules。但不要盲目跟风,理解它们各自的适用场景,才是写出高质量 JavaScript 的第一步。

希望今天的分享对你有所帮助。如果你还有疑问,欢迎留言讨论!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注