关于 JS 模块化的最佳实践总结
模块化开发是 JS 项目开发中的必备技能,它如同面向对象、设计模式一样,可以兼顾提升软件项目的可维护性和开发效率。
模块之间通常以全局对象维系通讯。在小游戏中,GameGlobal 是全局对象。在小程序中,App 是全局对象,任何页面都可以使用 getApp() 获取这个全局对象。在 NodeJS 中,global 是全局对象。在传统浏览器宿主中,window 是全局对象。
以下是作者总结的模块化实践经验。简言之,除了在浏览器项目中使用 sea.js,其它类型项目均建议直接使用原生的 ES6 模块规范。
目录
- CommonJS 规范
- AMD 规范
- CMD 规范
- 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日于北京
参考资料
- 浏览器已原生支持 ES 模块,这对前端开发来说意味着什么?
- Node 9下import/export的丝般顺滑使用
- Sea.js 是什么?
- 前端模块化:CommonJS,AMD,CMD,ES6
- Module 的加载实现
本文首先于微信公众号「艺述思维」:关于 JS 模块化的最佳实践总结
关于 JS 模块化的最佳实践总结的更多相关文章
- ABP vnext模块化架构的最佳实践的实现
在上一篇文章<手把手教你用Abp vnext构建API接口服务>中,我们用ABP vnext实现了WebAPI接口服务,但是并非ABP模块化架构的最佳实践.我本身也在学习ABP,我认为AB ...
- Web前端开发最佳实践(10):JavaScript代码不好读,不好维护?你需要改变写代码的习惯
前言 这篇文章本应该在上一篇文章:使用更严格的JavaScript编码方式,提高代码质量之前发布,但当时觉得这篇文章太过基础,也就作罢.后来咨询了一些初级的开发者,他们觉得有必要把这篇文章也放上来.尽 ...
- nodejs进阶(1)——npm使用技巧和最佳实践
nodejs进阶教程,小白绕道!!! npm使用技巧和最佳实践 前提:请确保安装了node.js npm的最佳实践 npm install是最常见的npm cli命令,但是它还有更多能力!接下来你会了 ...
- Vue中CSS模块化最佳实践
Vue风格指南中介绍了单文件组件中的Style是必须要有作用域的,否则组件之间可能相互影响,造成难以调试. 在Vue Loader Scope CSS和Vue Loader CSS Modules两节 ...
- require.js 最佳实践【转】
https://www.cnblogs.com/digdeep/p/4607131.html require.js是一个js库,相关的基础知识,前面转载了两篇博文:Javascript模块化编程(re ...
- require.js 最佳实践
require.js是一个js库,相关的基础知识,前面转载了两篇博文:Javascript模块化编程(require.js), Javascript模块化工具require.js教程,RequireJ ...
- JavaScript best practices JS最佳实践
JavaScript best practices JS最佳实践 0 简介 最佳实践起初比较棘手,但最终会让你发现这是非常明智之举. 1.合理命名方法及变量名,简洁且可读 var someItem = ...
- 思索 p5.js 的最佳实践
思索 p5.js 的最佳实践 本文写于 2020 年 12 月 18 日 p5.js 是一个 JavaScript 库,用于为艺术家.设计师提供更容易上手的创意编程. 它有着完整的一套基于 Canva ...
- vue.js+boostrap最佳实践
一.为什么要写这篇文章 最近忙里偷闲学了一下vue.js,同时也复习了一下boostrap,发现这两种东西如果同时运用到一起,可以发挥很强大的作用,boostrap优雅的样式和丰富的组件使得页面开发变 ...
随机推荐
- 【Microsoft Azure学习之旅】测试消息队列(Service Bus Queue)是否会丢消息
组里最近遇到一个问题,微软的Azure Service Bus Queue是否可靠?是否会出现丢失消息的情况? 具体缘由如下, 由于开发的产品是SaaS产品,为防止消息丢失,跨Module消息传递使用 ...
- TeamViewer 软件完全卸载
TeamViewer 软件似乎用于商业环境中 - 彻底卸载 Windows 1. 检测为商业用途该软件似乎用于商业环境中.请注意:免费版仅供个人使用.您的会话将在 5 分钟后终止. 2.1 Close ...
- Element-ui安装与使用(网站快速成型工具)
我之所以将Element归类为Vue.js,其主要原因是Element是(饿了么团队)基于MVVM框架Vue开源出来的一套前端ui组件.我最爱的就是它的布局容器!!! 下面进入正题: 1.Elemen ...
- SAP CRM和C4C的产品主数据price维护
SAP CRM 点了Edit List之后,可以直接修改产品主数据的Price信息: C4C 在C4C的product administration工作中心里: 点击Edit按钮进入编辑模式,Pric ...
- RPC电源监控总结
首先说一下监控机箱的监控原理. 设备的信息传输是通过tcp或者udp传输十六进制的数然后进行解析,传输数据. 如图: 设备反馈信息也是返回来的十六机制,然后按照对应的位置进将数据解析成二进制,用二进制 ...
- 指定类型的成员XX”不支持实体LINQ。只有初始化,成员单位,和实体导航性能的支持。
The specified type member 'DeleteFlag' is not supported in LINQ to Entities. Only initializers, enti ...
- 引用类型(三):Function类型
一. Function类型函数实际上是对象.每个函数都是Function类型都实例,而且都与其他引用类型一样具有属性和方法.由于函数是对象,因此函数名实际上也是一个指向函数对象都指针.1.函数通常是使 ...
- 怎么让Sublime Text不自动打开最近的文件/项目
"hot_exit": false,"remember_open_files": false,
- C#在派生类中调用基类成员
一.在派生类中调用基类成员 在C#的派生类中,我们可以使用base关键字调用基类中的公有或者受保护成员.这些成员只能是构造函数.实例方法或者实例属性. base关键字调用基类成员的语法格式如下: ba ...
- wgan pytorch,pyvision, py-faster-rcnn等的安装使用
因为最近在读gan的相关工作,wgan的工作不得不赞.于是直接去跑了一下wgan的代码. 原作者的wgan是在lsun上测试的,而且是基于pytorch和pyvision的,于是要装,但是由于我们一直 ...