模块化开发是 JS 项目开发中的必备技能,它如同面向对象、设计模式一样,可以兼顾提升软件项目的可维护性和开发效率。
模块之间通常以全局对象维系通讯。在小游戏中,GameGlobal 是全局对象。在小程序中,App 是全局对象,任何页面都可以使用 getApp() 获取这个全局对象。在 NodeJS 中,global 是全局对象。在传统浏览器宿主中,window 是全局对象。
以下是作者总结的模块化实践经验。简言之,除了在浏览器项目中使用 sea.js,其它类型项目均建议直接使用原生的 ES6 模块规范。
目录
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日于北京
参考资料