《JavaScript 模块化入门Ⅰ:理解模块》正文开始,本次阅读大概9分钟。
作为一名JS初学者。假如你听到了一些诸如“模块化构建模块化载入” “WebpackBrowserify” 或者 “AMDCMD”之类的术语,肯定瞬间就凌乱了。
JavaScript的模块化听起来挺深奥,可其实理解它对开发者来说特别实用。
在这篇文章里,我会尽量深入浅出地把这些深奥的术语翻译成浅显易懂的人话(加上一些代码示例)。希望你多少能从中学到点东西。
为了避免长篇大论,整个内容会分为两篇文章,这是第一部分,主要介绍模块化是什么,为什么要使用模块。之后的第二部分会介绍如何打包JS模块,以及各类构建工具。
求解释到底什么是模块化
称职的作家会把他的书分章节和段落;好的程序员会把他的代码分成模块。
就好像书籍的一章,模块仅仅是一坨代码而已。
好的代码模块分割的内容一定是很合理的,便于你增加减少或者修改功能,同时又不会影响整个系统。
为什么要使用模块
模块化可以使你的代码低耦合,功能模块直接不相互影响。我个人认为模块化主要有以下几点好处:
1.可维护性:根据定义,每个模块都是独立的。良好设计的模块会尽量与外部的代码撇清关系,以便于独立对其进行改进和维护。维护一个独立的模块比起一团凌乱的代码来说要轻松很多。
2.命名空间:在JavaScript中,最高级别的函数外定义的变量都是全局变量(这意味着所有人都可以访问到它们)。也正因如此,当一些无关的代码碰巧使用到同名变量的时候,我们就会遇到“命名空间污染”的问题。
这样的问题在我们开发过程中是要极力避免的。
后面的内容里我也会举一些具体的例子来说明这一点。
3.可复用性:现实来讲,在日常工作中我们经常会复制自己之前写过的代码到新项目中。
复制粘贴虽然很快很方便,但难道我们找不到更好的办法了么?要是……有一个可以重复利用的模块岂不妙哉?
如何引入模块
引入模块有很多种方式,这里我们先介绍一些:
模块模式
模块模式一般用来模拟类的概念(因为原生JavaScript并不支持类,虽然最新的ES6里引入了Class不过还不普及)这样我们就能把公有和私有方法还有变量存储在一个对象中——这就和我们在Java或Python里使用类的感觉一样。这样我们就能在公开调用API的同时,仍然在一个闭包范围内封装私有变量和方法。
实现模块模式的方法有很多种,下面的例子是通过匿名闭包函数的方法。(在JavaScript中,函数是创建作用域的唯一方式。)
例1:匿名闭包函数
(function(){ //在函数的作用域中下面的变量是私有的 varmyGrades=[93,95,88,0,55,91]; varaverage=function(){ vartotal=myGrades.reduce(function(accumulator,item){ returnaccumulator+item},0); returnYouraveragegradeis+total/myGrades.length+.; } varfailing=function(){ varfailingGrades=myGrades.filter(function(item){ returnitem70;}); returnYoufailed+failingGrades.length+times.; } console.log(failing()); }()); //控制台显示:Youfailed2times.通过这种构造,我们的匿名函数有了自己的作用域或“闭包”。 这允许我们从父(全局)命名空间隐藏变量。
这种方法的好处在于,你可以在函数内部使用局部变量,而不会意外覆盖同名全局变量,但仍然能够访问到全局变量,如下所示:
varglobal=Hello,Iamaglobalvariable:); (function(){ //在函数的作用域中下面的变量是私有的 varmyGrades=[93,95,88,0,55,91]; varaverage=function(){ vartotal=myGrades.reduce(function(accumulator,item){ returnaccumulator+item},0); returnYouraveragegradeis+total/myGrades.length+.; } varfailing=function(){ varfailingGrades=myGrades.filter(function(item){ returnitem70;}); returnYoufailed+failingGrades.length+times.; } console.log(failing()); console.log(global); }()); //控制台显示:Youfailed2times. //控制台显示:Hello,Iamaglobalvariable:)
要注意的是,一定要用括号把匿名函数包起来,以关键词function开头的语句总是会被解释成函数声明(JS中不允许没有命名的函数声明),而加上括号后,内部的代码就会被识别为函数表达式。其实这个也叫作立即执行函数(IIFE)感兴趣的同学可以在这里了解更多
例2:全局引入
另一种比较受欢迎的方法是一些诸如jQuery的库使用的全局引入。和我们刚才举例的匿名闭包函数很相似,只是传入全局变量的方法不同:
(function(globalVariable){ //在函数的作用域中下面的变量是私有的 varprivateFunction=function(){ console.log(Shhhh,thisisprivate!); } //通过全局变量设置下列方法的外部访问接口 //与此同时这些方法又都在函数内部 globalVariable.each=function(collection,iterator){ if(Array.isArray(collection)){ for(vari=0;icollection.length;i++){ iterator(collection[i],i,collection); } }else{ for(varkeyincollection){ iterator(collection[key],key,collection); } } }; globalVariable.filter=function(collection,test){ varfiltered=[]; globalVariable.each(collection,function(item){ if(test(item)){ filtered.push(item); } }); returnfiltered; }; globalVariable.map=function(collection,iterator){ varmapped=[]; globalUtils.each(collection,function(value,key,collection){ mapped.push(iterator(value)); }); returnmapped; }; globalVariable.reduce=function(collection,iterator,accumulator){ varstartingValueMissing=accumulator===undefined; globalVariable.each(collection,function(item){ if(startingValueMissing){ accumulator=item; startingValueMissing=false; }else{ accumulator=iterator(accumulator,item); } }); returnaccumulator; }; }(globalVariable));
在这个例子中,globalVariable 是唯一的全局变量。这种方法的好处是可以预先声明好全局变量,让你的代码更加清晰可读。
例3:对象接口
像下面这样,还有一种创建模块的方法是使用独立的对象接口:
varmyGradesCalculate=(function(){ //在函数的作用域中下面的变量是私有的 varmyGrades=[93,95,88,0,55,91]; //通过接口在外部访问下列方法 //与此同时这些方法又都在函数内部 return{ average:function(){ vartotal=myGrades.reduce(function(accumulator,item){ returnaccumulator+item; },0); returnYouraveragegradeis+total/myGrades.length+.; }, failing:function(){ varfailingGrades=myGrades.filter(function(item){ returnitem70; }); returnYoufailed+failingGrades.length+times.; } } })(); myGradesCalculate.failing();//Youfailed2times. myGradesCalculate.average();//Youraveragegradeis70.33333333333333.
例4:揭示模块模式 Revealing module pattern
这和我们之前的实现方法非常相近,除了它会确保,在所有的变量和方法暴露之前都会保持私有:
varmyGradesCalculate=(function(){ //在函数的作用域中下面的变量是私有的 varmyGrades=[93,95,88,0,55,91]; varaverage=function(){ vartotal=myGrades.reduce(function(accumulator,item){ returnaccumulator+item; },0); returnYouraveragegradeis+total/myGrades.length+.; }; varfailing=function(){ varfailingGrades=myGrades.filter(function(item){ returnitem70; }); returnYoufailed+failingGrades.length+times.; }; //将公有指针指向私有方法 return{ average:average, failing:failing } })(); myGradesCalculate.failing();//Youfailed2times. myGradesCalculate.average();//Youraveragegradeis70.33333333333333.
到这里,其实我们只聊了模块模式的冰山一角。
CommonJS AMD
上述的所有解决方案都有一个共同点:使用单个全局变量来把所有的代码包含在一个函数内,由此来创建私有的命名空间和闭包作用域。
虽然每种方法都比较有效,但也都有各自的短板。
有一点,作为开发者,你必须清楚地了解引入依赖文件的正确顺序。就拿Backbone.js来举个例子,想要使用Backbone就必须在你的页面里引入Backbone的源文件。
然而Backbone又依赖 Underscore.js,所以Backbone的引入必须在其之后。
而在工作中,这些依赖管理经常会成为让人头疼的问题。
另外一点,这些方法也有可能引起命名空间冲突。举个例子,要是你碰巧写了俩重名的模块怎么办?或者你同时需要一个模块的两个版本时该怎么办?
难道就没有不通过全局作用域来实现的模块方法么?
当然是有的。
接下来介绍两种广受欢迎的解决方案:CommonJS 和 AMD.
CommonJS
CommonJS 扩展了JavaScript声明模块的API.
CommonJS模块可以很方便得将某个对象导出,让他们能够被其他模块通过 require 语句来引入。要是你写过 Node.js 应该很熟悉这些语法。
通过CommonJS,每个JS文件独立地存储它模块的内容(就像一个被括起来的闭包一样)。在这种作用域中,我们通过 module.exports 语句来导出对象为模块,再通过 require 语句来引入。
还是举个直观的例子吧:
functionmyModule(){ this.hello=function(){ returnhello!; } this.goodbye=function(){ returngoodbye!; } } module.exports=myModule;
通过指定导出的对象名称,CommonJS模块系统可以识别在其他文件引入这个模块时应该如何解释。
然后在某个人想要调用 myMoudle 的时候,只需要 require 一下:
varmyModule=require(myModule); varmyModuleInstance=newmyModule(); myModuleInstance.hello();//hello! myModuleInstance.goodbye();//goodbye!
这种实现比起模块模式有两点好处:
避免全局命名空间污染
明确代码之间的依赖关系
并且这种书写方式也非常舒服友好,我自己很喜欢。
需要注意的一点是,CommonJS以服务器优先的方式来同步载入模块,假使我们引入三个模块的话,他们会一个个地被载入。
它在服务器端用起来很爽,可是在浏览器里就不会那么高效了。毕竟读取网络的文件要比本地耗费更多时间。只要它还在读取模块,浏览器载入的页面就会一直卡着不动。(在下一篇第二部分的教程里我们会讨论如何解决这个问题)
AMD
CommonJS已经挺不错了,但假使我们想要实现异步加载模块该怎么办?答案就是Asynchronous Module Definition(异步模块定义规范),简称AMD.
通过AMD载入模块的代码一般这么写:
define([myModule,myOtherModule],function(myModule,myOtherModule){ console.log(myModule.hello());});
这里我们使用 define 方法,第一个参数是依赖的模块,这些模块都会在后台无阻塞地加载,第二个参数则作为加载完毕的回调函数。
回调函数将会使用载入的模块作为参数。在这个例子里就是 myMoudle 和 myOtherModule.最后,这些模块本身也需要通过 define 关键词来定义。拿 myModule 来举个例子:
define([],function(){ return{ hello:function(){ console.log(hello); }, goodbye:function(){ console.log(goodbye); } }; });重申一下,不像CommonJS,AMD是优先浏览器的一种异步载入模块的解决方案。(记得,很多人认为一个个地载入小文件是很低效的,我们将在下一篇文章理介绍如何打包模块)
除了异步加载以外,AMD的另一个优点是你可以在模块里使用对象、函数、构造函数、字符串、JSON或者别的数据类型,而CommonJS只支持对象。
再补充一点,AMD不支持Node里的一些诸如 IO,文件系统等其他服务器端的功能。另外语法上写起来也比CommonJS麻烦一些。
UMD
在一些同时需要AMD和CommonJS功能的项目中,你需要使用另一种规范:Universal Module Definition(通用模块定义规范)。
UMD创造了一种同时使用两种规范的方法,并且也支持全局变量定义。所以UMD的模块可以同时在客户端和服务端使用。
下面是一个解释其功能的例子:
(function(root,factory){ if(typeofdefine===functiondefine.amd){ //AMD define([myModule,myOtherModule],factory); }elseif(typeofexports===object){ //CommonJS module.exports=factory(require(myModule),require(myOtherModule)); }else{ //Browserglobals(Note:rootiswindow) root.returnExports=factory(root.myModule,root.myOtherModule); } }(this,function(myModule,myOtherModule){ //Methods functionnotHelloOrGoodbye(){};//Aprivatemethod functionhello(){};//Apublicmethodbecauseitsreturned(seebelow) functiongoodbye(){};//Apublicmethodbecauseitsreturned(seebelow) //Exposedpublicmethods return{ hello:hello, goodbye:goodbye } }));
更多有关UMD的例子请看其Github上的官方repo.
原生JS
希望你坚持读到了现在,我们最后再介绍一种定义模块的方式。
你可能注意到了,上述的这几种方法都不是JS原生支持的。要么是通过模块模式来模拟,要么是使用CommonJS或AMD.
幸运的是在JS的最新规范ECMAScript 6 (ES6)中,引入了模块功能。
ES6 的模块功能汲取了CommonJS 和 AMD 的优点,拥有简洁的语法并支持异步加载,并且还有其他诸多更好的支持。
我最喜欢的ES6 模块功能的特性是,导入是实时只读的。(CommonJS 只是相当于把导出的代码复制过来)。
来看例子:
//lib/counter.js varcounter=1; functionincrement(){ counter++; } functiondecrement(){ counter--; } module.exports={ counter:counter, increment:increment, decrement:decrement }; //src/main.js varcounter=require(../../lib/counter); counter.increment(); console.log(counter.counter);//1
上面这个例子中,我们一共创建了两份模块的实例,一个在导出的时候,一个在引入的时候。
在 main.js 当中的实例是和原本模块完全不相干的。这也就解释了为什么调用了 counter.increment() 之后仍然返回1。因为我们引入的 counter 变量和模块里的是两个不同的实例。
所以调用 counter.increment() 方法只会改变模块中的 counter .想要修改引入的 counter 只有手动一下啦:
counter.counter++;console.log(counter.counter);//2
而通过 import 语句,可以引入实时只读的模块:
//lib/counter.js exportletcounter=1; exportfunctionincrement(){ counter++; } exportfunctiondecrement(){ counter--; } //src/main.js import*ascounterfrom../../counter; console.log(counter.counter);//1 counter.increment(); console.log(counter.counter);//2
这看起来很酷不是么?这样就实现了我们把模块分隔在不同的文件里,需要的时候又可以合并在一起而且不影响它的功能。