JS模块化浅谈【CommonJS、AMD、CMD、UMD、ESM】

1,736 阅读12分钟

模块化伴随着前端的发展,从无到有,从“伪”到“真”,再到后来的有成熟体系和规范并且适用于浏览器环境下的模块化。让我们来看看模块化到底经历了什么。

什么是模块化?为什么需要模块化?

在最初的前端,js 只负责比较简单的交互,代码量非常有限,我们将所有代码都混在一起。但是随着前端技术的发展,js 可以做的事情也越来越多,这就导致 js 代码量激增。 这时对于一个复杂的应用程序,与其将所有代码一股脑地放在一个文件当中,不如按照一定的语法,遵循特定的规范将一个庞大的文件拆分为几个独立的文件。 这些文件应该具有相互独立和功能逻辑单一的特性,对外暴露数据或接口,在需要的时候再进行导入或引用。这就是模块化的概念。

前端模块化发展主要经历了三个阶段:

  1. 早期“伪”模块化时代;
  2. 多种多种规范标准时代;
  3. ES 原生时代。

“伪”模块化时代

借助函数作用域来模拟实现“伪”模块化,我称其为函数模式,即将不同功能封装成不同的函数:

function fn1() {
  //...
}
function fn2() {
  //...
}

其实这样的方式根本连“伪”都不算,各个函数在同一个文件中,混乱地互相调用,而且存在命名冲突和变量污染的问题,致命的缺点让开发者很快就将其抛弃。

很快就出现了第二种方式,姑且称它为对象模式,即利用对象实现“伪”模块化:

const module1 = {
  data1: "data1",
  fn1: function () {
    //...
  },
};

const module2 = {
  data2: "data2",
  fn2: function () {
    //...
  },
};

这种方式稍微有了那么一点模块的雏形,可是这样的方式也带来一个大的问题,数据安全性非常低,对象内部成员可以随意被改写。

如:

module2.data2 = "data1";

数据被随意改写会造成很多的问题,首先就是极容易造成 bug,勤劳的前端开发者怎么会任由 bug 横行呢。

在之前关于闭包的文章里有这样一句话“闭包简直就是为解决数据访问性问题而生的”。 我们通过立即执行函数构造一个私有的作用域,再通过闭包的特性,将需要对外暴露的数据和接口输出。

代码如下:

(function (window) {
  var data = "data";

  function showData() {
    console.log(`data is ${data}`);
  }
  function updateData() {
    data = "newData";
    console.log(`data is ${data} `);
  }
  window.module1 = { showData, updateData };
})(window);

这样的实现,数据 data完全做到了私有和独立,不会受到外界任何变量的干扰,外界无法随意修改 data值, 只能通过调用模块module1暴露给外界(window)的函数修改 data值。

module1.showData(); // data is data

修改 data 值的途径,也只能由模块 module1 提供:

module1.updateData(); // data is newData

jQuery库也是如此方式实现的。 其实 jQuery的做法就是使用了一个匿名函数形成一个闭包,然后自执行,所有逻辑都在这个闭包中完成,这样不会污染全局变量,也无法在其他地方访问闭包内的变量。最后将 jQuery对象进行暴露,这样在外部就可以通过 jQuery或者 $访问闭包内的其他变量了。

代码片段如下:

(function (window, undefined) {
  //...
  if (typeof window === "object" && typeof window.document === "object") {
    window.jQuery = window.$ = jQuery;
  }
})(window);

很多人(包括我)最开始不能理解为什么自执行函数要传入 window,主要有两个原因:

  1. 使window又全局变量变成局部变量,当内部代码访问window对象时,不用顺着作用域链逐级查找,可以更快的访问 window
  2. 为了压缩代码时更好的优化;

另外传入 undefined 一部分原因是因为压缩优化,另一部分是由于一些低版本浏览器的兼容需要,不展开说了。

此时,模块化已经初具规模,已经可以实现一些基础功能。事实上,这就是现代模块化方案的基石。

多种规范标准时代 —— CommonJS

Node.js 无疑对前端的发展具有极大的促进作用,其中 CommonJS 模块化规范更是颠覆了人们对于模块化的认知: Node.js应用由模块(采用的 CommonJS 模块规范)组成。即一个文件就是一个模块,拥有自己独立的作用域,变量和方法都是存在独立作用域内。

Node.js 中的 CommonJS 规范在浏览器端实现依靠的就是 module.exportsrequire方法。 CommonJS 规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的 exports属性(即 module.exports)是对外的接口。 加载某个模块,其实是加载该模块的 module.exports属性。使用 require方法加载模块。

CommonJS 模块的特点如下

  • 所有代码都运行在模块作用域内,不会污染全局作用域;
  • 模块加载的顺序,按照其在代码中引入的顺序;
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果会被缓存,之后不论加载几次,都会直接读取缓存。清除缓存后方可再次运行;
  • module.exports属性输出的是值的拷贝,一旦输出操作完成,模块内发生的任何变化不会影响到已输出的值;
  • 注意 module.exportsexports的用法以及区别;

module.exports && exports 详解

  1. module.exports: module.exports属性表示当前模块对外输出的接口,当其他文件加载该模块,实际上就是读取 module.exports这个属性;

  2. exports node 为每一个模块提供了一个 exports对象 ,这个 exports对象的引用指向 module.exports。这相当于隐式的声明 var exports = module.exports;。 如此一来,在对外输出时,可以在这个变量上添加属性方法。 例如:exports.test = function () { // ... }; 注意:不能把 exports直接指向一个值(exports = xxx方式赋值),这样会改变exports的引用地址,相当于切断了exports module.exports的关系。

总结下 module.exports 和 exports 的区别就是:

  1. exports = module.exports = {}exportsmodule.exports的一个引用
  2. require引用模块后,返回给调用者的是 module.exports而不是 exports; 3.exports.xxx的方式更新属性,相当于修改了module.exports,那么该属性对调用模块可见;
  3. exprots = xxx的方式相当于给 exports重新赋值,改变引用,失去了之前的 module.exports引用,该属性对调用模块不可见;

如果你还是分不清,那么就使用 module.exports

多种规范标准时代 —— AMD

AMD 规范,全称为:Asynchronous Module Definition。存在即合理,从 Node.js 搬过来的 CommonJS 已经可以帮助前端实现模块化了,那 AMD 存在的意义又是什么呢?

这还要从 Node.js 自身说起,Node.js 运行于服务器端,文件都存在本地磁盘中,不需要去发起网络请求异步加载,所以 CommonJS 规范加载模块是同步的,对于 Node.js 来说自然没有问题,但是应用到浏览器环境中就显然不太合适了。 AMD 规范就是解决这一问题的。

AMD 不同于 CommonJS 规范,是异步的,可以说是专为浏览器环境定制的。AMD 规范中定义了如何创建模块、如何输出、如何导入依赖。 更加友好的是,require.js 库为我们准备好了一切,我们只需要通过define方法,定义为模块;再通过require方法,加载模块。 因为是异步的,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

define 定义模块 define 方法的第一个参数可以注入一些依赖的其他模块,如 jQuery 等

define([], function () {
  // 模块可以直接返回函数,也可返回对象
  return {
    fn() {
      // ...
    },
  };
});

AMD 规范也采用 require 方法加载模块 但是不同于 CommonJS 规范,它要求两个参数: 第一个参数就是要加载的模块的数组集合,第二个参数就是加载成功后的回调函数。

require([module], callback);

有精力的同学可以看看 require.js 的源码

从源码中可以看到,require.js 在全局定义了 definerequire。并且在最外层包裹的是一个自执行函数,将 global, setTimeout传入其中。

以下为截取 define方法内的一小段代码:

if (!deps && isFunction(callback)) {
  deps = [];

  if (callback.length) {
    callback
      .toString()
      .replace(commentRegExp, commentReplace)
      .replace(cjsRequireRegExp, function (match, dep) {
        deps.push(dep);
      });

    deps = (callback.length === 1
      ? ["require"]
      : ["require", "exports", "module"]
    ).concat(deps);
  }
}

define方法内部可以大致理解为对依赖的收集,deps.push(dep)

require的主要作用是根据依赖创建 script 标签,请求模块,对模块进行加载和执行。值得注意的是所有模块在加载完成后都会执行 removeScript方法。 该方法会将加载完成后的 script 标签移除,这也就是为什么require中生成 script 标签加载模块,但是在代码中并没有出现这些标签,奥秘就在removeScript 中。

require.js 的源码非常绕,推荐有一些源码阅读经验的同学再尝试阅读。

多种规范标准时代 —— CMD

CMD 规范全称为:Common Module Definition,综合了 CommonJS 和 AMD 规范的特点,推崇 as lazy as possible。代表库为 sea.js 。

CMD 规范和 CMD 规范不同之处

  • AMD 需要异步加载模块,而 CMD 可以同步可以异步;
  • CMD 推崇依赖就近,AMD 推崇依赖前置。

多种规范标准时代 —— UMD

UMD 叫做通用模块定义规范(Universal Module Definition)。 它可以通过运行编译时让同一个代码模块在使用 CommonJs、CMD 甚至是 AMD 的项目中运行。 这样就使得 JavaScript 包运行在浏览器端、服务区端甚至是 APP 端都只需要遵守同一个写法就行了。

他的规范就是综合其他的规范,没有自己专有得规范。

代码如下:

(function (root, factory) {
  if (typeof define === "function" && define.amd) {
    // AMD 规范
    define(["b"], factory);
  } else if (typeof module === "object" && module.exports) {
    // 类 Node 环境,并不支持完全严格的 CommonJS 规范
    // 但是属于 CommonJS-like 环境,支持 module.exports 用法
    module.exports = factory(require("b"));
  } else {
    // 浏览器环境
    root.returnExports = factory(root.b);
  }
})(this, function (b) {
  // 返回值作为 export 内容
  return {};
});

在定义模块得时候会检测当前得环境,将不同的模块定义方式转换为同一种写法。

ES 原生模块化

ES 模块化最大的两个特点是:

1.ES 模块化规范中模块输出的是值的引用

复习下 CommonJS 规范下的使用: module1.js 中:

var data = "data";
function updateData() {
  data = "newData";
}

module.exports = {
  data: data,
  updateData: updateData,
};

index.js 中:

var myData = require("./module1").data;
var updateData = require("./module1").updateData;
console.log(myData); // data
updateData();
console.log(myData); // data

因为 CommonJS 规范下,输出的值只是拷贝,通过 updateData方法改变了模块内的 data的值,但是datamyData并没有任何关联,只是一份拷贝,所以模块内的变量值修改,也就不会影响到修改之前就已经拷贝过来的 myData啦。

再看 ES 模块化规范的表现 module1.js:

let data = "data";
function updateData() {
  data = "newData";
}
export { data, updateData };

index.js:

import { data, updateData } from "./module1.js";
console.log(data); // data
updateData();
console.log(data); // newData

由于 ES 模块化规范中导出的值是引用,所以不论何时修改模块中的变量,在外部都会有体现。

2.静态化,编译时就确定模块之间的关系,每个模块的输入和输出变量也是确定的

ES 模块化设计成静态的目的何在? 首要目的就是为了实现 tree shaking 提升运行性能(下面会简单说 tree shaking)。 ES 模块化的静态特性也带来了局限:

  • import依赖必须在文件顶部;
  • export导出的变量类型严格限制;
  • 依赖不可以动态确定。

ES 的 exportexport default要用谁? ES 模块化导出有 exportexport default两种。这里我们建议减少使用 export default导出! 原因很简单:

  • 其一 export default导出整体对象,不利于 tree shaking;
  • 其二 export default导出的结果可以随意命名,不利于代码管理;

tree shaking

tree shaking 就是通过减少web项目中 JavaScript 的无用代码,以达到减少用户打开页面所需的等待时间,来增强用户体验。对于消除无用代码,并不是 JavaScript 专利,事实上业界对于该项操作有一个名字,叫做 DCE(dead code elemination) ,然而与其说 tree shaking 是 DCE 的一种实现,不如说 tree shaking 从另外一个思路达到了DCE的目的。

无用代码的减少意味着更小的代码体积,缩减 bundle size,从而获得更好的用户体验。

如何实现 tree shaking? 两个先决条件:

  • 首先既然要实现的是减少浏览器下载的资源大小,因此要 tree shaking 的环境必然不能是浏览器,一般宿主环境是 Node;
  • 其次,如果 JavaScript 是模块化的,那么必须遵从的是 ES 模块化规范,原因上面已经提到过了。

另外需要注意的是,对于单个文件和模块化来说 webpack 要实现 tree-shaking 必须依赖 uglifyJs。这里就不展开过多的阐述了,想了解更多内容可以阅读这篇文章《Tree-Shaking性能优化实践 - 原理篇》

目前各大浏览器早已在新版本中支持 ES 模块化了。如果我们想在浏览器中使用原生 ES 模块方案,只需要在 script 标签上添加一个 type="module"属性。通过该属性,浏览器知道这个文件是以模块化的方式运行的。

<script type="module">
    import module1 from './module1'
</script>

而对于不支持的浏览器,需要通过 nomodule 属性来指定某脚本为 fallback 方案:

<script nomodule>
        alert('你的浏览器不支持 ES Module,请先升级!')
</script>

Node 也从 9.0 版本开始支持 ES 模块,可见 ES 模块化由于它的开箱即用的 tree shaking 和未来浏览器兼容性支持等优点,已经渐渐成为web项目的首选。