关于 JS 模块化的最佳实践总结

 

模块化开发是 JS 项目开发中的必备技能,它如同面向对象、设计模式同样,能够兼顾提高软件项目的可维护性和开发效率。html

 

模块之间一般以全局对象维系通信。在小游戏中,GameGlobal 是全局对象。在小程序中,App 是全局对象,任何页面均可以使用 getApp() 获取这个全局对象。在 NodeJS 中,global 是全局对象。在传统浏览器宿主中,window 是全局对象。前端

 

如下是做者总结的模块化实践经验。简言之,除了在浏览器项目中使用 sea.js,其它类型项目均建议直接使用原生的 ES6 模块规范。jquery

 

目录编程

  1. CommonJS 规范
  2. AMD 规范
  3. CMD 规范
  4. ES6 模块规范
  5. 结论

 

CommonJS 规范

 

CommonJS 规范最先在 NodeJS 中实践并被推广开来。它使用 module.exports 输出模块,一个模块写在一个独立的文件内,一个文件便是一个模块。在另外一个JS文件中,使用 require 导入模块。各个模块相互隔离,模块之间的通信,经过全局对象 global 完成。小程序

 

值得特别注意的是,CommonJS 这种规范天生是为 NodeJS 服务的。NodeJS 是一种服务器端编程语言,源码文件都在硬盘上,读起来很方便。CommonJS 规范做为一种同步方案,后续代码必须等待前面的require指令加载模块完成。设计模式

 

使用 CommonJS 规范的代码示例以下:浏览器

 

// 定义模块math.js
var basicNum = 0;
function add(a, b) {
  return a + b;
}
module.exports = { //在这里写上须要向外暴露的函数、变量
  add: add,
  basicNum: basicNum
}
// 在另外一个文件中,引用自定义的模块时,参数包含路径,可省略后缀.js
var math = require('./math');
math.add(2, 5);

 

在小程序与小游戏的官方文档中,提到模块化时官方建议的规范便是 CommonJS 规范。但其实在做者看来,更适合小游戏/小程序开发的规范是 ES6 模块规范,缘由稍后便会讲到。服务器

 

AMD 规范

 

CommonJS 规范主要是为服务器端的 NodeJS 服务,服务器端加载模块文件无延时,可是在浏览器上就大不相同了。AMD 便是为了在浏览器宿主环境中实现模块化方案的规范之一。微信

 

AMD是一种使用JS语言自实现的模块化规范方案,主要由require.config()、define()、require 三个函数实现。require.config() 用于声明基本路径和模块名称;define() 用于定义模块对象;require() 则用于加载模块并使用。闭包

 

与 CommonJS 规范不一样,AMD 规范身处浏览器环境之中,是一种异步模块加载规范。在使用时,首先要加载模块化规范实现文件 require.js 及 JS 主文件,示例以下:

 

/** 网页中引入require.js及main.js **/
<script src="js/require.js" data-main="js/main"></script>

 

在上面的 Html 代码中,"js/require.js" 是实现 AMD 规范的类库文件,是任何使用 AMD 规范的网页都须要加载的;"js/main" 是开发者的代码主文件,在这个文件中加载并使用自定义模块,示例代码以下:

 

/** main.js 入口文件/主模块 **/
// 首先用config()指定各模块路径和引用名
require.config({
  baseUrl: "js/lib",
  paths: {
    "jquery": "jquery.min",  //实际路径为js/lib/jquery.min.js
    "underscore": "underscore.min",
  }
});
// 执行基本操做
require(["jquery","underscore","math"],function($,_,math){//在这里$表明jqurey、_表明underscore
  var sum = math.add(10,20);
  $("#sum").html(sum);
});

 

而用于模块的定义,在其它 JS 文件中是这样声明的:

 

// 定义math.js模块
define(function () {
    var basicNum = 0;
    var add = function (x, y) {
        return x + y;
    };
    return {
        add: add,
        basicNum :basicNum
    };
});

 

若是在一个模块定义中依赖另外一个模块对象,能够这样声明:

 

// 定义一个依赖underscore模块的模块
define(['underscore'],function(_){
  var classify = function(list){
    _.countBy(list,function(num){
      return num > 30 ? 'old' : 'young';
    })
  };
  return {
    classify :classify
  };
})

 

AMD 规范看起来完美解决了浏览器模块化开发的难题。可是它有一个天生的缺陷,对于依赖的模块不管实际须要与否,都会先加载并执行。以下所示:

 

define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) { 
    // 等于在最前面声明并初始化了要用到的全部模块
    if (false) {
      // 即使没用到某个模块 b,但 b 仍是提早执行了
      b.foo()
    } 
});

 

在上面的代码中,模块 a、b、c、d、e、f 都会加载并执行,即便它们在实际的模块代码中没有被用到。为了解决这个“浪费”的问题,CMD 规范应运而生。

 

CMD 规范

 

CMD 规范单从名字来看,它也与 AMD 规范很像。CMD 与 AMD 规范同样,一样是一种 JS 语言自实现的模块化方案。不一样之处在于,AMD 规范是依赖前置、模块提早加载并执行;CMD 是依赖后置、模块懒惰加载再执行。示例代码以下:

 

/** CMD写法 **/
define(function(require, exports, module) {
    var a = require('./a'),
     b = require('./b'),
     c = require('./c'); //在须要时申明、加载和使用
    a.doSomething();
    if (false) {
        var b = require('./b');
        b.doSomething();
    }
});

 

在上面的代码中,模块 a 在使用时才被声明并加载。sea.js 是一个模块加载器,是 AMD 规范的主要实现者之一。使用 sea.js 定义和使用模块的示例以下所示:

 

/** sea.js **/
// 定义模块 math.js
define(function(require, exports, module) {
    var $ = require('jquery.js');
    var add = function(a,b){
        return a+b;
    }
    exports.add = add;
});
// 加载模块
seajs.use(['math.js'], function(math){
    var sum = math.add(1+2);
});

 

与 AMD 相比,CMD 貌似确实节省了无谓的模块加载。可是 AMD 规范自己就是一种异步模块加载方案,是只有在运行时才被加载并运行的,用则加载,不用不加载,有何浪费可言?何况,比起在代码中分别以 require 函数加载模块,直接在 define 方法的第一个参数中声明,彷佛还更简洁与潇洒些。

 

sea.js 做为 AMD 规范的升级版,简化了使用方法,在使用上更加方便,值得推崇。可是 sea.js 即是浏览器开发中最佳的模块化解决方案吗?未必,还要看是什么类型的项目,后面会讲到。

 

ES6 模块规范

 

在讲 ES6 模块规范以前,咱们先看一下规范前驱 CommonJS 的一个缺陷。以下所示:

 

// 模块定义代码:lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};
// 模块使用代码:main.js
var mod = require('./lib');
console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 3
var mod2 = require('./lib');
console.log(mod2.counter);  // 3

 

在上面的代码中,为何三个 mod.counter 的输出均是3?

 

CommonJS 规范是一种动态加载、拷贝值对象执行的模块规范。每一个模块在被使用时,都是在运行时被动态拉取并被拷贝使用的,模块定义是惟一的,但有几处引用便有几处拷贝。因此,对于不一样的 require 调用,生成的是不一样的运行时对象。

 

即便如此,在上面的代码中,mod 只有一个,为何 mod.incCounter() 对这个模块对象——即 mod 中的 counter 变量改变无效?相反,对于如下的代码:

 

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  get counter() {
    return counter
  },
  incCounter: incCounter,
};
// main.js
var mod = require('./lib');
console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 4

 

第二个输出是4。只是将 counter 声明为一个 getter 存取器属性,调用便正常了,为何?

 

这是因为 CommonJS 的拷贝机制形成的。因为 CommonJS 规范的拷贝运行机制,在 lib.js 中使用 module.exports 输出的对象,是从 lib 模块内拷贝而得,当时 counter 的值是几,便拷贝了几。不管执行 incCounter 多少次,改变的都不是输出对象的 counter 变量。

 

而当定义了 getter 属性以后,该属性指向了模块定义对象中的 counter 变量了吗?不,是指向了被 incCounter 方法以闭包形式囊括的 counter 变量,这个变量是输出的模块对象的一部分。

 

CommonJS 规范的这个缺陷,有时候让程序很无奈,一不当心就写出了错误的代码。这个缺陷在 ES6 中获得了很好的解决。

 

在 ES6 模块规范中,只有 export 与 import 两个关键字。示例以下:

 

/** 定义模块 math.js **/
var basicNum = 0;
var add = function (a, b) {
    return a + b;
};
export { basicNum, add };
/** 引用模块 **/
import { basicNum, add } from './math';
function test(ele) {
    ele.textContent = add(99 + basicNum);
}

 

在上面的代码中,使用 export 关键字在 math.js 文件中输出模块,这里使用了对象字面量的属性名称简写与方法名称简写。在另外一个文件中引用模块,在 import 关键字后面,{basicNum, add} 这是对象变量析构的写法。

 

若是在 export 模块时,使用了 default 限定词,以下所示:

 

//定义输出
export default { basicNum, add };
//引入
import math from './math';
function test(ele) {
    ele.textContent = math.add(99 + math.basicNum);
}

 

在 import 引入时,即可以省去花括号。这样看起来代码更清爽简洁。

 

ES6 模块规范与 CommonJS 规范相比,有如下不一样:

 

(1)ES6 模块规范是解析(是解析不是编译)时静态加载、运行时动态引用,全部引用出去的模块对象均指向同一个模块对象。在上面使用 CommonJS 规范声明的 lib 模块,若是使用 ES6 模块规范声明,根本不会出现 counter 变量含糊不清的问题。

 

(2)CommonJS 规范是运行时动态加载、拷贝值对象使用。每个引用出去的模块对象,都是一个独立的对象。

 

结论

 

因此综上所述,在模块化方案上最佳选择是什么?

 

在小程序(包括小游戏)开发项目中,因为支持 ES6,因此小程序最好的模块化方案即是使用ES6模块规范。虽然官方文档中提到的模块化规范是 CommonJS,但最佳方案做者认为却应该是 ES6。

 

小程序在手机端(不管 iOS 仍是 Android)的底层渲染内核都是类 Chrome v8 引擎。v8 引擎在执行JS代码时,是将代码先以 MacroAssembler 汇编库在内存中先编译成机器码再送往 CPU 执行的,并非像其它 JS 引擎那样解析一行执行一行。因此,静态加载的 ES6 模块规范,更有助于 v8 引擎发挥价值。而运行时加载的 CommonJS 规范、AMD 规范、CMD 规范等,均不利于 v8 引擎施展拳脚。遇到 CommonJS 代码,v8 可能会怒骂:“有什么话能不能一次讲完,你这样猫拉屎式的作法只能让我更慢!”

 

在 NodeJS 开发项目中,Node9 已经支持 ES6 语法,彻底可使用 ES6 模块规范。NodeJS 的诞生,自己就基于 Google 的 v8 引擎,没有理由不考虑发挥 v8 的最大潜能。

 

在浏览器 JS 开发项目中,由于从服务器加载文件须要时间,使用 CommonJS 规范确定是不合适了。至因而使用原生的 ES 模块规范,仍是使用sea.js,要看具体场景。若是想页面尽快加载,sea.js 适合;若是是单页面网站,适合使用原生的 ES6 模块规范。还有一点,浏览器并不是只有 Chrome 一家,对于没有使用 v8 引擎的浏览器,使用 ES6 原生规范的优点就又减小了一点。

 

2019年1月21日于北京

 


 

参考资料

  • 浏览器已原生支持 ES 模块,这对前端开发来讲意味着什么?
  • Node 9下import/export的丝般顺滑使用
  • Sea.js 是什么?
  • 前端模块化:CommonJS,AMD,CMD,ES6
  • Module 的加载实现

 

本文首先于微信公众号「艺述思惟」:关于 JS 模块化的最佳实践总结

相关文章
相关标签/搜索