前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >关于 JS 模块化的最佳实践总结

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

作者头像
LIYI
发布2019-09-02 17:15:02
2.4K0
发布2019-09-02 17:15:02
举报
文章被收录于专栏:艺述论专栏艺述论专栏

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

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

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

目录

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


CommonJS 规范

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

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

使用 CommonJS 规范的代码示例如下:

代码语言:javascript
复制
// 定义模块math.js
代码语言:javascript
复制
var basicNum = 0;
代码语言:javascript
复制
function add(a, b) {
代码语言:javascript
复制
  return a + b;
代码语言:javascript
复制
}
代码语言:javascript
复制
module.exports = { //在这里写上需要向外暴露的函数、变量
代码语言:javascript
复制
  add: add,
代码语言:javascript
复制
  basicNum: basicNum
代码语言:javascript
复制
}
代码语言:javascript
复制
// 在另一个文件中,引用自定义的模块时,参数包含路径,可省略后缀.js
代码语言:javascript
复制
var math = require('./math');
代码语言:javascript
复制
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 主文件,示例如下:

代码语言:javascript
复制
/** 网页中引入require.js及main.js **/
代码语言:javascript
复制
<script src="js/require.js" data-main="js/main"></script>

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

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

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

代码语言:javascript
复制
// 定义math.js模块
代码语言:javascript
复制
define(function () {
代码语言:javascript
复制
    var basicNum = 0;
代码语言:javascript
复制
    var add = function (x, y) {
代码语言:javascript
复制
        return x + y;
代码语言:javascript
复制
    };
代码语言:javascript
复制
    return {
代码语言:javascript
复制
        add: add,
代码语言:javascript
复制
        basicNum :basicNum
代码语言:javascript
复制
    };
代码语言:javascript
复制
});

如果在一个模块定义中依赖另一个模块对象,可以这样声明:

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

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

代码语言:javascript
复制
define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) { 
代码语言:javascript
复制
    // 等于在最前面声明并初始化了要用到的所有模块
代码语言:javascript
复制
    if (false) {
代码语言:javascript
复制
      // 即便没用到某个模块 b,但 b 还是提前执行了
代码语言:javascript
复制
      b.foo()
代码语言:javascript
复制
    } 
代码语言:javascript
复制
});

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

CMD 规范

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

代码语言:javascript
复制
/** CMD写法 **/
代码语言:javascript
复制
define(function(require, exports, module) {
代码语言:javascript
复制
    var a = require('./a'),
代码语言:javascript
复制
     b = require('./b'),
代码语言:javascript
复制
     c = require('./c'); //在需要时申明、加载和使用
代码语言:javascript
复制
    a.doSomething();
代码语言:javascript
复制
    if (false) {
代码语言:javascript
复制
        var b = require('./b');
代码语言:javascript
复制
        b.doSomething();
代码语言:javascript
复制
    }
代码语言:javascript
复制
});

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

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

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

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

ES6 模块规范

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

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

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

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

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

代码语言:javascript
复制
// lib.js
代码语言:javascript
复制
var counter = 3;
代码语言:javascript
复制
function incCounter() {
代码语言:javascript
复制
  counter++;
代码语言:javascript
复制
}
代码语言:javascript
复制
module.exports = {
代码语言:javascript
复制
  get counter() {
代码语言:javascript
复制
    return counter
代码语言:javascript
复制
  },
代码语言:javascript
复制
  incCounter: incCounter,
代码语言:javascript
复制
};
代码语言:javascript
复制
// main.js
代码语言:javascript
复制
var mod = require('./lib');
代码语言:javascript
复制
console.log(mod.counter);  // 3
代码语言:javascript
复制
mod.incCounter();
代码语言:javascript
复制
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 两个关键字。示例如下:

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

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

如果在 export 模块时,使用了 default 限定词,如下所示:

代码语言:javascript
复制
//定义输出
代码语言:javascript
复制
export default { basicNum, add };
代码语言:javascript
复制
//引入
代码语言:javascript
复制
import math from './math';
代码语言:javascript
复制
function test(ele) {
代码语言:javascript
复制
    ele.textContent = math.add(99 + math.basicNum);
代码语言:javascript
复制
}

在 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 的加载实现
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-01-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 艺述论 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
命令行工具
腾讯云命令行工具 TCCLI 是管理腾讯云资源的统一工具。使用腾讯云命令行工具,您可以快速调用腾讯云 API 来管理您的腾讯云资源。此外,您还可以基于腾讯云的命令行工具来做自动化和脚本处理,以更多样的方式进行组合和重用。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档