详谈 js 模块化发展历程

在找工作的伙伴,可以看看这里:双越老师联合几位博主(包括我)搞了一个前端面试网站 面试派 ------ 常见面试题 + 大厂面试流程 + 面试技巧。做一个真正专业的前端面试网站,旨在解决前端面试资料碎片化、老旧化、非专业化等一系列问题,网站开源免费且持续更新题库!

前言

早期的 js 语言仅仅是个脚本,代码很简单,实现的功能也不复杂,但是后面却被运用到复杂的项目中,那个时候也不存在 js 模块化一说,那么当时的前端切图仔们是如何在没有 import 的时代硬生生地设计出模块化思想的。本期文章我们来聊聊 js 模块化的发展历程。

另外理解 js 模块化发展我们可以很好理解 esmcjs 的区别。

2009 年之前

为什么需要模块化?

模块化实际上就是实现特定功能的一组方法,只要把不同的函数以及用来记录状态的变量放到一起就是一个模块;js 需要模块化主要就是为了解决代码复杂度增加后的可维护性,复用性,依赖管理等问题;这么看模块化就是工程化的地基,有了模块化才有工程化。

原始阶段

所有代码都通过 <script> 标签直接暴露在全局作用域中,导致严重问题

html 复制代码
<!-- index.html -->
<script src="module1.js"></script>
<script src="module2.js"></script>
js 复制代码
// module1.js
var data = "Hello"; // 污染全局作用域

// module2.js
var data = "World"; // 覆盖 module1 的 data
console.log(data); // "World"

问题:变量和函数命名冲突,无法管理依赖

对象命名空间

既然直接写命名会冲突,那不妨把变量和方法挂载到对象属性上,这就是对象命名空间,如下

js 复制代码
// module1.js
var MyModule = {
  data: "Hello",
  log: function() { console.log(this.data); }
};

// module2.js
var AnotherModule = {
  data: "World",
  log: function() { console.log(this.data); }
};

MyModule.log(); // "Hello"
AnotherModule.log(); // "World"

MyModule.data = 'Hacked' // 可以被修改

优点:将变量和方法挂载到对象属性上(如 MyModule.data),减少全局变量冲突。

缺点:对象属性仍可能被覆盖(如 MyModule.data = "Hacked");

IIFE( Immediately Invoked Function Expression) 自执行函数 + 闭包

自执行函数通过闭包隔离作用域,实现真正的私有变量和模块封装:

js 复制代码
// module1.js
var MyModule = (function() {
  var privateData = "Hello"; // 私有变量,外部无法访问
  
  return {
    log: function() { console.log(privateData); }
  };
})();

// module2.js
var AnotherModule = (function() {
  var privateData = "World";
  
  return {
    log: function() { console.log(privateData); }
  };
})();

MyModule.log(); // "Hello"
AnotherModule.log(); // "World"

优点:

  • 函数立即执行,返回一个对象(模块的公共接口)。
  • 内部变量(如 privateData)被闭包保护,外部无法直接访问。

缺点:

  1. 手动管理依赖:需通过 <script> 标签顺序或参数传递依赖,容易出错。

    html 复制代码
    <!-- 必须确保 jQuery 先加载 -->
    <script src="jquery.js"></script>
    <script src="my-module.js"></script>
  2. 无动态加载:无法按需加载模块,所有代码需一次性引入。

  3. 全局暴露入口:模块入口(如 MyModule)仍需挂载到全局变量。

  4. 无统一规范:不同开发者写法各异,协作困难。

2009年 node.js 发布,采用 CommonJS

CommonJS 的本质

require 本质上就是一个函数,而这个函数其实就是 node 环境中的一个参数,我们不妨在 node 环境中打印 arguments 试试看

输出如下:

js 复制代码
console.log(arguments);
// 输出如下
[Arguments] {
  '0': {},
  '1': [Function: require] {
    resolve: [Function: resolve] { paths: [Function: paths] },
    main: {
      id: '.',
      path: '***',
      exports: {},
      filename: '***',
      loaded: false,
      children: [],
      paths: [Array],
      [Symbol(kIsMainSymbol)]: true,
      [Symbol(kIsCachedByESMLoader)]: false,
      [Symbol(kIsExecuting)]: true
    },
    extensions: [Object: null prototype] {
      '.js': [Function (anonymous)],
      '.js': [Function (anonymous)],
      '.json': [Function (anonymous)],
      '.node': [Function (anonymous)]
    },
      '.json': [Function (anonymous)],
      '.node': [Function (anonymous)]
    },
      '.node': [Function (anonymous)]
    },
    },
    cache: [Object: null prototype] {
      '***': [Object]
      '***': [Object]
    }
    }
  },
  },
  '2': {
    id: '.',
    id: '.',
    path: '***',
    exports: {},
    filename: '***',
    loaded: false,
    children: [],
    paths: [
      '***',
      '***',
      '***',
      '***',
      '***'
    ],
    [Symbol(kIsMainSymbol)]: true,
    [Symbol(kIsCachedByESMLoader)]: false,
    [Symbol(kIsExecuting)]: true
  },
  '3': '当前文件的绝对路径',
  '4': '当前文件夹的绝对路径'
}

你会好奇怎么在 node 单个文件中直接打印 arguments 也能有值。

实际上,在 node 模块化系统中,每个文件都相当于被包装在了一个立即执行函数(IIFE)里,这个包装函数会传入五个参数:exports, require, module, __filename, __dirname;若我们用了 esm 语法就看不到这些参数了,就不是函数环境了

所以我们是可以直接在 node 中打印上面这五个参数的,大家感兴趣可以自行打印看看长啥样

下面是一段 CommonJS 的伪代码实现,理解这段代码可以很好理解 CommonJS

js 复制代码
function require (modulePath) {
    // 根据模块路径获取模块绝对路径,用作唯一 id
    var moduleId = getModuleId(modulePath);
    // 如果模块已经加载过,直接返回缓存结果
    if (cache[moduleId]) {
        return cache[moduleId];
    }
    function _require (exports, require, module, __filename, __dirname) {
        // 将目标模块的代码包裹在一个函数中,并执行
    }
    // 创建模块对象,用于存储模块的导出结果
    var module = {
        exports: {}
    }
    var exports = module.exports;
    var __filename = module.filename;
    var __dirname = getDirname(__filename);
    _require.call(exports, exports, require, module, __filename, __dirname);
    // 将模块对象添加到缓存中
    cache[moduleId] = module.exports;
    return module.exports;
}

第一步就是要给文件一个唯一 ID,而文件的绝对路径一定就是唯一ID

第二步就是判断缓存,若此前运行过就直接返回,这就解释了为什么 CommonJS 的模块只运行一次

第三步就是将导入的模块代码放到一个函数中,这也能解释为什么 node 模块化中的环境就是函数环境,并且有五个参数

第四步就是集齐五个参数,第一个参数 exports ,这个参数初始值就是空对象,第二个参数 require 就是函数本身,第三个参数 __filename 就是文件的绝对路径,第五个__dirname 就是文件夹的绝对路径

第五步通过 call 改变 this 指向到 exports 去执行函数 _require

所以我们不难看出 exportsmodule.exports 以及 this 都是一个东西

java 复制代码
console.log(exports === module.exports, exports === this, module.exports === this); // true true true
CommonJS 的优缺点

CommonJS 由于本质上就是个 IIFE

函数,这样可以有效防止全局变量污染,甚至向每个模块中传入了这五个参数exports, require, module, __filename, __dirname

不知道是否有人像我一样好奇,为什么 CommonJS 内部都有了 I/O 流(文件路径的读取)却还是同步执行。我们通常理解 I/O 操作时都认定为异步操作,但是像是 路径的读取 实际上是同步进行的,并且 node 中的 fs 模块提供同步和异步的方法。其实在 node 模块加载的过程中,同步读取文件就是为了确保模块之间的加载顺序,并且文件通常位于本地文件系统,读取速度非常快,这种同步加载不会对性能产生明显的影响

优点(相对此前) 缺点(相对之后)
标准化接口 :通过统一的 require()module.exports 定义模块,避免各自实现差异 同步加载:模块加载采用同步文件 I/O,在浏览器环境下会阻塞后续代码执行
模块缓存机制:首次加载后缓存模块,避免重复执行,提升性能 浏览器原生不支持:浏览器中没有内置 CommonJS 环境,必须借助打包工具转换
依赖管理清晰 :显式调用 require(),使模块依赖关系一目了然 静态分析受限:模块输出为值的拷贝,难以在编译时对依赖进行优化(如 tree shaking)
自动作用域隔离:模块代码自动被包装在私有作用域中,避免全局变量污染
生态系统完善:Node.js 社区和众多工具(如 Browserify、Webpack)广泛支持 CommonJS 模块

2010-2011年 AMD(RequireJS), CMD(Sea.js)出现

CommonJS 主要用于服务器端,如Node.js 就是同步加载模块的。而 AMD 是针对浏览器的异步加载,因为浏览器需要避免阻塞。CommonJS 的同步特性在服务器端没问题,因为模块在本地硬盘,加载快。但浏览器端如果同步加载,会因为网络问题导致性能低下,所以 AMD 应运而生,解决了这个问题。

AMDCMD 都是前端模块化规范中定义模块的一套 API(语法规范),它们并非语言内置语法,而是由相应的库(如 RequireJSSea.js)提供支持,所以像 AMDdefine 函数通常是全局可用的,无需额外引入。

AMD(RequireJS)
js 复制代码
// utils.js 定义一个模块
define([], function () {
    return {
        add: function (a, b) {
            return a + b;
        }
    }
})

// main.js 使用模块
require(['./utils'], function (utils) {
    console.log(utils.add(1, 2));
})

AMD 特点如下

  • 依赖必须在 define 语句中提前声明(依赖前置)。
  • 使用 require 进行异步加载,不会阻塞页面渲染。
  • 适用于浏览器环境,尤其适合需要动态加载多个模块的场景。
CMD(Sea.js)
js 复制代码
// 定义一个模块
define(function(require, exports, module) {
    // 依赖按需引入(就近依赖)
    let dep1 = require('dependency1');
    let dep2 = require('dependency2');

    exports.result = dep1.someFunction() + dep2.someOtherFunction();
});

// 使用模块
seajs.use(['moduleA'], function(moduleA) {
    console.log(moduleA.result);
});

CMD 特点如下:

  • 依赖在模块内部按需 require(),符合开发者直觉(就近依赖)。
  • 仍然是异步加载,但 require() 只有在执行到时才真正加载模块,而 AMD 是提前加载依赖。
  • 适用于浏览器环境,但设计理念更接近 CommonJS,方便前端开发者上手。
AMD 出现后,为什么 CMD 又出现?

CMD 的出现并非去完全替代AMD,而是针对 AMD 的部分问题进行改进,比如 AMD 的依赖必须前置,也就是依赖必须在模块定义时声明,而 CMD 不需要在定义时显示声明所有依赖,而是按需 require(),更加符合开发者的书写习惯,简化了模块加载逻辑,其实 AMD 就是更加贴近 CommonJSCMD 只有在 require() 时才触发加载对应的模块

CommonJS || AMD || CMD 对比
特点 CommonJS AMD (RequireJS) CMD (Sea.js)
加载机制 同步加载(适用于服务器端,本地 I/O 很快) 异步加载(适用于浏览器环境,避免阻塞页面渲染) 异步加载(与 AMD 类似,但提倡"就近依赖")
模块定义语法 使用 require() 加载和 module.exports 导出 使用 define(id?, [deps], factory) 来定义模块 使用 define(function(require, exports, module){ ... })
依赖声明方式 在代码中动态调用 require() 依赖前置:在模块定义时通过依赖数组提前声明依赖 就近依赖:在模块工厂函数内按需调用 require()
静态分析能力 不支持静态分析 支持静态分析(依赖数组明确,便于预加载和优化) 较弱,依赖位置灵活不便静态分析
适用环境 主要用于 Node.js 等服务器端环境 专为浏览器设计,适合网络环境下的异步加载 主要用于浏览器环境,兼顾前端开发者的书写习惯
模块缓存机制 模块加载后会被缓存,避免重复执行 同样支持缓存机制,但由于异步加载,依赖关系由框架管理 具有模块缓存机制,加载完成后缓存模块

2015年 ES6 发布,引入 ESModule

15年,js 官方终于提出了 ESM ,定义了一个新的模块化语法,包括 import 和 export,目的是提供一种原生支持的,统一的模块化方案,以取代 CommonJSAMDCMD 等第三方规范。然而15 年时,ESModule 只是语言规范的一部分,浏览器和 Node.js 还不支持,因此 ESM 仍然无法在浏览器中直接运行,需要使用 BabelWebpack 进行转译。

2017年浏览器原生支持 ESModule

17 年,现代浏览器(如 Chrome 61+、Firefox 60+、Safari 10.1+、Edge 16+)开始正式支持 ESModule,允许开发者直接在 <script> 标签中使用 ESM,而无需依赖工具转换。更新内容如下:

  • 原生支持 importexport 语法,可以在浏览器端直接加载 ESM 模块文件。

  • 新增 <script type="module">,用于告诉浏览器该脚本是 ESM,支持模块化特性(如自动延迟执行、作用域隔离)。

html 复制代码
<script type="module">
    import { sayHello } from './module.js';
    sayHello();
</script>

如果不加 type="module",浏览器会把 import 语法当作普通脚本解析,导致语法错误。

此外,浏览器对 ESM 还做了一些额外的优化:

  • 默认延迟执行(defer):所有 <script type="module"> 脚本都会自动延迟执行,等 HTML 解析完后再运行,无需手动加 defer
  • 严格模式(Strict Mode):ESModule 自动使用严格模式,避免一些 js 旧特性带来的问题。
  • 跨域加载限制:浏览器要求 ESM 模块必须符合 CORS 规则,不能直接加载跨域的 js 文件(除非服务器允许 Access-Control-Allow-Origin)。
时间 事件 说明
2015 年 ES6 规范引入 ESModule import/export 语法被定义,但浏览器和 Node.js 还不支持,需要 Babel/Webpack 转换
2017 年 浏览器原生支持 ESModule 现代浏览器可以直接解析 <script type="module">,无需编译即可运行 ESModule
ESM 的工作原理

ESM 有两种形式,一个我们常见的静态导入,一个是 动态 导入 import()

下面举个🌰

html 复制代码
<body>
	<script src='./index.js' type='module'></script>
</body>
js 复制代码
// index.js
import foo from './foo.js';

import('./dynamic.js').then(module => {
    console.log('dynamic.js', module.default);
});

console.log('index.js', foo, bar);

import bar from './bar.js';

// foo.js
import bar from './bar.js';

console.log('foo.js', bar);

export default 'foo';

// bar.js
const bar = 'bar';

console.log('bar.js', bar);

export default bar;

// dynamic.js
import bar from './bar.js';

console.log('dynamic.js', bar);

export default 'dynamic';

// 最终输出如下
bar.js bar
foo.js bar
index.js foo bar
dynamic.js bar
dynamic.js dynamic

先说结论:ESM 的工作原理是先解析再运行,运行之前完成所有的解析工作,解析的工作就是看 importexportexport 的结果相当于会生成一个对象,这个对象的keyvalue 就是你抛出的内容;另外 import() 是一个动态导入,这个执行的时机发生在运行时;

实际上 <script src='./index.js' type='module'></script> 会将 src 属性转换成一个 url 地址,把文件内容下载下来进行解析

第一步解析:从 html 看,先引入了 index.js,这个 文件 里顶部只有一个 import,中间有一个 动态 import() 先不看,下面也有个 import,其实浏览器会帮我们把 import 提前,所以 index.js 在解析时有两个 import,分别将两个文件下载下来先,然后分别分析这两个文件的 import,先看 foo,foo 里也有一个 import bar,而 bar 此前在 index.js 中已经解析好了,不会再重复解析,而 bar.js 没有任何 import;

第二步运行:从 index.js 看先执行 import foo,再执行 import bar,我们先进入到 foo,foo 确又是先执行 import bar,所以这里还是先执行 bar,于是先输出 bar,bar.js 中有个默认导出,其实就是抛出了个对象 key 为 default,value 为 bar。bar 执行完毕后回到 foo,这里的 import bar 其实是给 bar 一个引用地址,bar 和 'bar'共用一个内存空间,于是打印 foo.js,然后foo抛出一个对象 key 为 default, value 为 foo,注意每个对象都是属于当前文件的,不会产生冲突;

现在回到 index.js 的 import bar,此前执行过就不会再执行了,执行的目的就是为了获取这个对象,也就是 key 为 default ,value 为 bar;随后执行动态 import,动态import 不难看出本质是个 promise 函数,then 回调就是个微任务,但是 动态导入 的 ./dynamic.js 还是会正常解析运行的,解析其实就是下载文件,这个过程又是异步的,所以先执行后面的同步打印语句,最后才是执行 解析动态 导入,这个过程同样是先下载好文件,然后解析文件中是否还存在 import,这里虽存在 bar 的导入,但是 bar 此前已经生成了 key 为 default ,value 为 bar 的对象,所以直接拿取,然后执行 dynamic 文件,dynamic 文件 exportdynamic 字符串,这个返回结果就是 then 回调的入参,因此 module.default 就是 dynamic

js 的基本数据类型是值传递还是址传递?

其实上面的 esm 导出基本类型时非常特殊,这个变量和原模块的变量共用一个内存空间,只有这里是址传递

js 的基本类型是值传递,所谓引用传递如下这样,真正意义上只要是赋值都是共用一个内存空间

js 复制代码
let a = 1
b = a
a = 2
// 引用传递下 b 也会变成 2,可 js 不是

但是 es6 之后有了引用传递,就是在 esm

js 复制代码
// a.js
export const a = 1
// b.js
import { a } from './a.js'
console.log(a);

esm 时,a.jsb.js 是共用一个内存空间的,所以 a.jsa 变了,b.jsa 也会变,所以我们导出基本类型尽量去用 const 去定义常量

所以对于基本类型:总是按值传递,复制出新值。

对于引用类型:传递的是引用的副本,但多个变量指向同一内存,修改对象内容会相互影响。

ESM 的导入导出:使用活绑定机制,导入的变量始终与导出模块中对应的变量保持同步(类似"按引用传递"的效果)。这有个专业名词叫做符号绑定

2019-2020年 Node.js 正式支持 ESM

esm 的崛起不得不让 cjs 这种非官方标准服软,为了让 node.js 兼容 esm,更新了以下主要内容

  • 原生支持 import/export 语法:开发者可以直接在 Node.js 中编写 ES 模块,无需通过 Babel 转译。这意味着你可以使用标准的 importexport 语法来组织代码。
  • 文件扩展名和 package.json 配置:
    • 使用 .mjs 扩展名:Node.js 会将这些文件视为 ES 模块。
    • 或者在项目的 package.json 中设置 "type": "module",这样默认情况下 .js 文件就会按照 ES 模块来解析,而不是 CommonJS 模块。因此 package.json 若是没有设置 type 就是默认 cjs 模块机制

面试官:说说 ESModuleCommonJS 的区别

标准来源不同

node 是在 09 年诞生比 es6 的 15年 早了 6 年,由于 node 模块化是由 node 社区定义的,这是非官方的机构,因此它无法改动你语法层面的东西,你可以理解为 CommonJS 的 require 其实就是它新增的 API 属性,module.exports 就是它新增的一个对象,module 就是一个全局可用的对象;

ESModule 是一个官方标准,直接新增了语法,这其实就是一个权威和非权威的对抗,最终 ESM 才能一统

我们需要认定的一个现象是,非官方往往做出的改变都是 API 层面,不是语法层面,因为它没有这个权利

时态时态不同

CommonJS 仅支持编译时,ESModule 编译时和运行时都支持

前端就是两个时态,一个编译时,一个运行时。编译时是 V8 引擎在作用,而一个非官方的 CommonJS 是无法干预编译时,所以它就是运行时,在运行时确定依赖关系,所以 CommonJS 是可以写出如下这样的代码

js 复制代码
if (xxx) {
	require(xxx)
} else {
	require(xxx)
}

ESM 支持的编译时就是我们平常使用 importexport 导入导出语法,而运行时在 es7 开始支持,语法为 import()

编译时态我们一般也称之为静态,编译时态还没有运行,所以 import xxx from a, 这个 a 一定不能是一个变量,变量值的一定是要运行才能确定;在不讨论 import() 动态导入语法的情况下,我们称 esm 就是编译时,编译过程就能确定好依赖关系,也就是说运行之前;所以我们不能将 import 写在 判断体中,甚至我们只能把 import 语句都写在顶部,一定是在运行所有代码前就确定好依赖关系

因为 esm 在编译时就能确定好依赖关系的这个特点,tree-shaking 树摇优化才能发挥它在编译时工作的能力,毕竟打包时还没运行

最后

从最初的 IIFE 到 cjs,再到 amd, cmd 最后 esm 的大一统趋势,IIFE 虽然解决了命名冲突问题,但是写法上很难统一,cjs 虽成体系但不作用于浏览器,且同步会阻塞代码执行,这当然对 node 端不成影响,amd,cmd 虽然异步且可作用于浏览器但是究竟是个第三方库,且 api 用法冗长,也不好静态分析,模块化也不统一,最后 esm 通通解决了这些痛点

这么来看 js 模块化的发展历程就是一个从混乱到规范,从分散到标准化的过程,涉及多个技术方案和社区实践到最后落地的演变。

文章中若出现错误内容还请各位大佬见谅并指正。如果有任何问题或建议,欢迎指出,另外,有不懂之处欢迎在评论区留言。如果觉得文章对你的学习有所帮助,还请 关注、点赞、收藏 一键三连,感谢支持!欢迎关注我的公众号: Dolphin_Fung

相关推荐
passerby606118 分钟前
完成前端时间处理的另一块版图
前端·github·web components
掘了25 分钟前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅28 分钟前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅1 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment1 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅2 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊2 小时前
jwt介绍
前端
爱敲代码的小鱼2 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte2 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc