欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-06-13 22:06:23
...

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

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

在最初的前端,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的引用地址,相当于切断了exportsmodule.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项目的首选。