详谈 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

相关推荐
apcipot_rain10 分钟前
【应用密码学】实验五 公钥密码2——ECC
前端·数据库·python
油丶酸萝卜别吃11 分钟前
OpenLayers 精确经过三个点的曲线绘制
javascript
ShallowLin15 分钟前
vue3学习——组合式 API:生命周期钩子
前端·javascript·vue.js
Nejosi_念旧36 分钟前
Vue API 、element-plus自动导入插件
前端·javascript·vue.js
互联网搬砖老肖37 分钟前
Web 架构之攻击应急方案
前端·架构
pixle01 小时前
Vue3 Echarts 3D饼图(3D环形图)实现讲解附带源码
前端·3d·echarts
麻芝汤圆2 小时前
MapReduce 入门实战:WordCount 程序
大数据·前端·javascript·ajax·spark·mapreduce
juruiyuan1113 小时前
FFmpeg3.4 libavcodec协议框架增加新的decode协议
前端
Peter 谭4 小时前
React Hooks 实现原理深度解析:从基础到源码级理解
前端·javascript·react.js·前端框架·ecmascript
周胡杰4 小时前
鸿蒙接入flutter环境变量配置windows-命令行或者手动配置-到项目的创建-运行demo项目
javascript·windows·flutter·华为·harmonyos·鸿蒙·鸿蒙系统