HR:CommonJS 与 ES Modules 有啥区别

576 阅读6分钟

模块化发展历程

2009: CommonJS 诞生(Node.js 御用)
2015: ES6 Modules 标准化
2020: Node.js 13+ 正式支持 ES Modules
2023: 90% 的新项目首选 ES Modules

引言

首先问一下自己,为什么需要了解他们的区别。任何知识点的学习都需要建立在一个合理的求知欲上,如果只是单纯的了解,但是缺乏动机,结果就是学了等于没学。所以先问问自己的动机是什么,是为了面试?还是为了开发时能正确引入第三方库?或者是为了解决项目中包体积问题?

概念篇

核心区别

首先结合记忆锚点对他们的核心区别产生基本印象,在有了印象后的这个阶段,可能会精彩混淆两者的特点,原因很简单,缺乏实际场景以及具体运用。下面我将对他们的特征举例展开,一起学起来!

特征CommonJSES6 Modules记忆锚点
加载时机运行时动态加载编译时静态解析运行时 vs 编译时
加载方式同步加载(阻塞执行)异步加载(非阻塞)同步 vs 异步
值传递导出基本类型时值拷贝导出值始终是实时引用值拷贝 vs 值引用
顶层作用域自由变量(非严格模式)自动严格模式自由 vs 严格
循环依赖处理可能得到未初始化的值自动处理未完成绑定危险 vs 安全
浏览器支持需打包工具转换原生支持(type="module")需转换 vs 原生

基本用法

导入导出

CommonJS 使用 require、module.exports,ES Module 使用 import、export

// CommonJS
const { readFile } = require('fs.cjs'); // 同步加载
module.exports = { data: 1 };

// ES6
import { readFile } from 'fs/promises'; // 异步加载
export const data = 1;

值传递

以下是值传递的两个代码例子:

// CommonJS(值拷贝)
// lib.js
let count = 0;
exports.count = count; // 显式导出值拷贝
exports.increment = () => { count++ };

// main.js
console.log(require('./lib').count); // 0(缓存中的拷贝值)
require('./lib').increment();
console.log(require('./lib').count); // 仍然 0(拷贝值未更新)

// ES6(实时绑定)
// lib.mjs
export let count = 0;
export const increment = () => { count++ };

// main.mjs
import { count, increment } from './lib.mjs';
increment();
console.log(count); // 1 ✅

通过上面两个例子,可以得出CommonJS和ES Modules本质的导出区别:

特性CommonJSES Modules
导出本质导出值的拷贝导出值的引用
内存关系每个 require() 持有独立拷贝所有 import 共享同一内存地址
变量可见性只能通过导出的方法间接修改内部状态导出变量可直接被外部修改
模块缓存模块加载后结果被缓存实时绑定无缓存机制
典型场景Node.js 服务端开发现代浏览器和 Node.js 新版本

 动态加载

// CommonJS 动态加载(无 Tree Shaking)
// 引入位置不限制
const moduleName = 'utils';
const utils = require(`./${moduleName}`); // 动态路径

// ES6 静态结构(支持 Tree Shaking)
// 需要在作用域顶层引入
import { funcA } from './utils.js'; // 路径必须静态

应用篇

决策指南

根据概念篇的模块特点,整理了一份决策指南,下面会举例相关的应用场景。

考虑维度推荐选择理由说明
浏览器项目ESM原生支持 + Tree Shaking
Node.js 服务CommonJS(旧版)生态兼容性
通用工具库双格式输出最大兼容性
代码可维护性ESM静态分析优势
旧系统维护CommonJS无需改造历史代码

浏览器项目

<!-- 现代浏览器原生支持 -->
<script type="module">
  import { renderChart } from 'https://cdn.com/data-viz.esm.js';
  
  // Tree Shaking 优化示例
  import { debounce } from 'lodash-es'; // 仅打包使用的方法
</script>

<!-- 对比 CommonJS 方案 -->
<script src="bundle.js"></script> 
<!-- 需通过 Webpack 转换,且无法利用原生模块缓存优势 -->

核心优势

  1. 预加载优化:支持 <link rel="modulepreload"> 提前加载关键模块
  2. 代码分割:原生支持动态 import()
  3. 性能提升:利用浏览器并行加载能力
  4. 体积优化:通过 Tree Shaking 平均减少 30%-50% 代码体积

Node.js 服务

// CommonJS 典型场景(Express 路由)
// -------------------------------
// routes/user.js
module.exports = (db) => { // 依赖注入
  return {
    getUsers: async (req, res) => {
      const data = await db.query('SELECT...');
      res.json(data);
    }
  };
};

// app.js
const userRoutes = require('./routes/user')(db); // 动态初始化

// ESM 新型实践(Node.js 14+)
// -------------------------------
// routes/product.mjs
export const getProducts = (pool) => ({ // 更清晰的接口定义
  list: async (req, res) => {
    const [rows] = await pool.query('...');
    res.json(rows);
  }
});

// app.mjs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const legacyModule = require('./old-module.cjs'); // 混合加载

版本决策矩阵

Node.js 版本推荐模块系统注意事项
< 12.xCommonJS无原生 ESM 支持
12.x-14.xCommonJS需 --experimental-modules 标志
≥ 14.xESM推荐使用 .mjs 扩展名

通用代码库

双格式输出配置

如果不太清楚什么是双格式输出,我们可以反向了解什么是双格式。下面这段代码我们应该很熟悉,在日常开发中输出npm包时,我们需要在导出的时候,针对不同的运行环境做相应的处理。

// 应对不同环境的入口文件
// src/entry.js
if (typeof require !== 'undefined' && require.main === module) {
  module.exports = require('./node-entry.js'); // CommonJS
} else {
  export * from './browser-entry.js'; // ESM
}

上面的例子中,Commonjs导出的文件是.js结尾,而不是.cjs结尾,和用法篇中的例子有区别。这是为啥?嗯,如果你有这样的疑问,那么我们可以开始介绍双格式输出配置了,下面是一个配置例子(遵循的是Node Pakage Export规范):

// package.json
{
  "name": "universal-lib",
  "main": "./dist/cjs/index.js",        // CommonJS 入口
  "module": "./dist/esm/index.mjs",     // ESM 入口
  "exports": {
    ".": {
      "require": "./dist/cjs/index.js", // Node.js 标准
      "import": "./dist/esm/index.mjs"  // ESM 标准
    },
    "./utils": {
      "require": "./dist/cjs/utils.js",
      "import": "./dist/esm/utils.mjs"
    }
  },
  "types": "./dist/types/index.d.ts"    // TypeScript 类型声明
}

上面的配置例子里,因为缺少 type 字段,所以模块类型默认是commonjs。通过module、exports字段来定义导出的ES Module模块的信息,完成双格式输出配置。

各字段作用详解表

字段规范来源典型值示例作用场景
mainnpm/Node.js./dist/cjs/index.js兼容旧版 Node.js 和打包工具
module社区构建工具约定./dist/esm/index.mjs优化 Tree Shaking 的 ESM 入口
exportsNode.js 标准见上文现代模块解析和条件导出
typesTypeScript./dist/types/index.d.ts类型声明入口
typeNode.js"module" 或 "commonjs"定义默认模块类型
sideEffectsWebpack/Rollupfalse 或文件列表优化 Tree Shaking

构建工具配置示例

Rollup
// rollup.config.js
export default {
  input: 'src/index.js',
  output: [
    {
      dir: 'dist/cjs',
      format: 'cjs',
      exports: 'auto'
    },
    {
      dir: 'dist/esm',
      format: 'esm',
      preserveModules: true
    }
  ]
};
Webpack 5
// webpack.config.js
const path = require('path');

const createConfig = (format) => ({
  entry: './src/index.js',
  mode: 'production',
  output: {
    path: path.resolve(__dirname, `dist/${format}`),
    filename: 'index.js',
    library: {
      type: format === 'esm' ? 'module' : 'commonjs2'
    },
    chunkFormat: format === 'esm' ? 'module' : 'commonjs',
    environment: {
      module: format === 'esm' // 环境特性检测
    }
  },
  experiments: {
    outputModule: format === 'esm'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', { 
                modules: false // 保留 ESM 语法
              }]
            ]
          }
        }
      }
    ]
  }
});

module.exports = [createConfig('esm'), createConfig('cjs')];
Rspack
// rspack.config.js
const { defineConfig } = require('@rspack/cli');

// 双格式一体化配置
module.exports = defineConfig([
  // CommonJS 配置
  {
    mode: 'production',
    entry: './src/index.js',
    output: {
      path: 'dist/cjs',
      filename: 'index.js',
      library: {
        type: 'commonjs2'
      }
    },
    builtins: {
      treeShaking: true // 内置 Tree Shaking
    }
  },
  // ESM 配置
  {
    mode: 'production',
    entry: './src/index.js',
    output: {
      path: 'dist/esm',
      filename: 'index.mjs', // 推荐使用 .mjs 扩展名
      library: {
        type: 'module'
      }
    },
    experiments: {
      outputModule: true
    }
  }
]);

面试篇

我考考你

面试官:说说 CommonJS 和 ESM 模块的区别?

  1. 加载机制:CommonJS 是运行时同步加载,ESM 是编译时异步加载
  2. 值传递:CommonJS 导出基本类型是值拷贝,ESM 是实时引用
  3. 应用场景:CommonJS 主要用于 Node.js,ESM 是现代浏览器和 Node 新版本的标准
  4. 附加优势:ESM 支持 Tree Shaking 和静态分析,更适合构建优化
  5. 兼容方案:可通过 createRequire 和动态 import() 实现互操作

面试官:为什么 CommonJS 的计数器不更新,而 ESM 可以?
:这体现了两种模块系统的核心差异:

  1. CommonJS 导出的是值的拷贝,每次 require() 获取的是模块的缓存副本。案例中的 count 是模块内部变量,外部只能通过导出的方法修改,但无法直接读取最新值
  2. ES Modules 通过实时绑定机制,所有 import 都指向同一内存地址。修改导出变量会同步到所有引用处,因此能立即获取最新值

这种差异导致在需要状态共享的场景下,ES Modules 是更优选择

面试官:为什么 ESM 支持 Tree Shaking 而 CommonJS 不行?
:当然是因为👇

  • ESM:编译时静态分析依赖关系,构建工具(如 Webpack)可准确识别未使用的导出

    // 可被 Tree Shaking
    export const used = () => {...};
    export const unused = () => {...};
    
  • CommonJS:动态加载特性导致无法在构建阶段确定依赖

    // 无法 Tree Shaking
    const utils = require('./utils');
    if (condition) {
      utils.dynamicMethod(); // 动态调用无法分析
    }
    

面试官:两者的模块加载流程有何不同?
:加载流程如下!
CommonJS 加载流程

  1. 解析模块路径
  2. 检查缓存(require.cache
  3. 同步读取文件内容
  4. 包裹函数:(exports, require, module, __filename, __dirname) => { ... }
  5. 执行模块代码,填充 module.exports
  6. 返回 module.exports 并缓存

ESM 加载流程

  1. 解析依赖图(编译阶段)
  2. 异步下载所有模块文件
  3. 解析模块(Parse)
  4. 实例化模块(内存分配)
  5. 执行代码(变量绑定)
  6. 缓存结果

面试官:如何在 ESM 中加载 CommonJS 模块
:嗯嗯有两种方式

// 方法1:默认导入
import cjsModule from 'commonjs-module'; 
console.log(cjsModule.namedExport); // 需要模块兼容

// 方法2:通过 createRequire
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const legacy = require('commonjs-module');

面试官:为什么 Node.js 最初选择 CommonJS?

  1. 前端当时无模块标准
  2. 同步加载适合服务端 I/O 阻塞场景
  3. 简单易实现(2009年时的技术限制)

面试官:如何将旧项目从 CommonJS 迁移到 ESM?
:采用渐进式迁移的方法最为保守,在不影响存量代码的基础上升级新代码的导出模式。

阶段一:混合模式(不修改旧文件)
// package.json
{
  "type": "commonjs", // 显式声明默认类型
  "scripts": {
    "start": "node src/index.js" // 常规启动
  }
}

操作步骤

  1. 新文件使用 .mjs 扩展名

    // src/new-module.mjs
    export const hello = () => console.log('ESM');
    
  2. 旧文件保持 .js 不变

  3. 在 CommonJS 中加载 ESM:

    // src/legacy.js
    import('new-module.mjs').then(({ hello }) => {
      hello(); // 正确执行
    });
    
阶段二:渐进改造
// package.json
{
  "type": "module", // 新默认类型
  "scripts": {
    "start": "node --experimental-modules src/index.js"
  }
}

操作步骤

  1. 将改造完成的旧文件重命名为 .mjs

    mv src/utils.js src/utils.mjs
    
  2. 未改造的文件保持 .cjs 扩展名

    // src/unmodified.cjs
    module.exports = { key: 'value' }; // 明确 CommonJS
    
阶段三:完全迁移
// package.json
{
  "type": "module",
  "scripts": {
    "start": "node src/index.js" // 无需实验性标志
  }
}

操作步骤

  1. 删除所有 .cjs 文件

  2. 全局替换 require() 为 import

  3. 处理特殊变量:

    // 替换 __dirname
    import { fileURLToPath } from 'url';
    const __dirname = path.dirname(fileURLToPath(import.meta.url));
    

迁移工具链配置

  1. Babel 兼容方案
// .babelrc
{
  "presets": [
    ["@babel/preset-env", { 
      "modules": false // 保留 ESM 语法
    }]
  ]
}
  1. TypeScript 配置
// tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext", // 输出 ESM
    "moduleResolution": "NodeNext"
  }
}
  1. 混合加载工具函数
// src/loaders.js
import { createRequire } from 'module';

export const loadCJS = (path) => {
  const require = createRequire(import.meta.url);
  return require(path);
};

// 使用示例
const legacyModule = loadCJS('./legacy.cjs');