天天看点

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

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

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

在最初的前端,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.exports

require

方法。

CommonJS 规范规定,每个模块内部,

module

变量代表当前模块。这个变量是一个对象,它的

exports

属性(即

module.exports

)是对外的接口。

加载某个模块,其实是加载该模块的

module.exports

属性。使用

require

方法加载模块。

CommonJS 模块的特点如下:

  • 所有代码都运行在模块作用域内,不会污染全局作用域;
  • 模块加载的顺序,按照其在代码中引入的顺序;
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果会被缓存,之后不论加载几次,都会直接读取缓存。清除缓存后方可再次运行;
  • module.exports

    属性输出的是值的拷贝,一旦输出操作完成,模块内发生的任何变化不会影响到已输出的值;
  • 注意

    module.exports

    exports

    的用法以及区别;

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 = {}

    exports

    module.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.js 的源码。

从源码中可以看到,require.js 在全局定义了

define

require

。并且在最外层包裹的是一个自执行函数,将

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

的值,但是

data

myData

并没有任何关联,只是一份拷贝,所以模块内的变量值修改,也就不会影响到修改之前就已经拷贝过来的

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 的

export

export default

要用谁?

ES 模块化导出有

export

export 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项目的首选。

继续阅读