美文网首页
JS模块化浅谈【CommonJS、AMD、CMD、UMD、ESM

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

作者: _BuzzLy | 来源:发表于2020-04-29 17:00 被阅读0次

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

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

    在最初的前端,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项目的首选。

    相关文章

      网友评论

          本文标题:JS模块化浅谈【CommonJS、AMD、CMD、UMD、ESM

          本文链接:https://www.haomeiwen.com/subject/fwjnwhtx.html