JavaScript 模块化入门
author:一佰互联 2019-05-26   click:150

作为一名JS初学者。假如你听到了一些诸如“模块化构建&模块化载入” “Webpack&Browserify” 或者 “AMD&CMD”之类的术语,肯定瞬间就凌乱了。

JavaScript的模块化听起来挺深奥,可其实理解它对开发者来说特别实用。

在这篇文章里,我会尽量深入浅出地把这些深奥的术语翻译成浅显易懂的人话(加上一些代码示例)。希望你多少能从中学到点东西。

为了避免长篇大论,整个内容会分为两篇文章,这是第一部分,主要介绍模块化是什么,为什么要使用模块。之后的第二部分会介绍如何打包JS模块,以及各类构建工具。

求解释到底什么是模块化

称职的作家会把他的书分章节和段落;好的程序员会把他的代码分成模块。

就好像书籍的一章,模块仅仅是一坨代码而已。

好的代码模块分割的内容一定是很合理的,便于你增加减少或者修改功能,同时又不会影响整个系统。

为什么要使用模块

模块化可以使你的代码低耦合,功能模块直接不相互影响。我个人认为模块化主要有以下几点好处:

1.可维护性:根据定义,每个模块都是独立的。良好设计的模块会尽量与外部的代码撇清关系,以便于独立对其进行改进和维护。维护一个独立的模块比起一团凌乱的代码来说要轻松很多。

2.命名空间:在JavaScript中,最高级别的函数外定义的变量都是全局变量(这意味着所有人都可以访问到它们)。也正因如此,当一些无关的代码碰巧使用到同名变量的时候,我们就会遇到“命名空间污染”的问题。

这样的问题在我们开发过程中是要极力避免的。

后面的内容里我也会举一些具体的例子来说明这一点。

3.可复用性:现实来讲,在日常工作中我们经常会复制自己之前写过的代码到新项目中。

复制粘贴虽然很快很方便,但难道我们找不到更好的办法了么?要是……有一个可以重复利用的模块岂不妙哉?

如何引入模块

引入模块有很多种方式,这里我们先介绍一些:

模块模式

模块模式一般用来模拟类的概念(因为原生JavaScript并不支持类,虽然最新的ES6里引入了Class不过还不普及)这样我们就能把公有和私有方法还有变量存储在一个对象中——这就和我们在Java或Python里使用类的感觉一样。这样我们就能在公开调用API的同时,仍然在一个闭包范围内封装私有变量和方法。

实现模块模式的方法有很多种,下面的例子是通过匿名闭包函数的方法。(在JavaScript中,函数是创建作用域的唯一方式。)

例1:匿名闭包函数

(function () { // 在函数的作用域中下面的变量是私有的 var myGrades = [93, 95, 88, 0, 55, 91]; var average = function() { var total = myGrades.reduce(function(accumulator, item) { return accumulator + item}, 0); return "Your average grade is " + total / myGrades.length + "."; } var failing = function(){ var failingGrades = myGrades.filter(function(item) { return item < 70;}); return "You failed " + failingGrades.length + " times."; } console.log(failing()); }()); // 控制台显示:"You failed 2 times."

通过这种构造,我们的匿名函数有了自己的作用域或“闭包”。 这允许我们从父(全局)命名空间隐藏变量。

这种方法的好处在于,你可以在函数内部使用局部变量,而不会意外覆盖同名全局变量,但仍然能够访问到全局变量,如下所示:

var global = "Hello, I am a global variable :)"; (function () { // 在函数的作用域中下面的变量是私有的 var myGrades = [93, 95, 88, 0, 55, 91]; var average = function() { var total = myGrades.reduce(function(accumulator, item) { return accumulator + item}, 0); return "Your average grade is " + total / myGrades.length + "."; } var failing = function(){ var failingGrades = myGrades.filter(function(item) { return item < 70;}); return "You failed " + failingGrades.length + " times."; } console.log(failing()); console.log(global); }()); // 控制台显示:"You failed 2 times." // 控制台显示:"Hello, I am a global variable :)"

要注意的是,一定要用括号把匿名函数包起来,以关键词function开头的语句总是会被解释成函数声明(JS中不允许没有命名的函数声明),而加上括号后,内部的代码就会被识别为函数表达式。其实这个也叫作立即执行函数(IIFE)感兴趣的同学可以在这里了解更多

例2:全局引入

另一种比较受欢迎的方法是一些诸如jQuery的库使用的 全局引入 。和我们刚才举例的匿名闭包函数很相似,只是传入全局变量的方法不同:

(function (globalVariable) { // 在函数的作用域中下面的变量是私有的 var privateFunction = function() { console.log("Shhhh, this is private!"); } // 通过全局变量设置下列方法的外部访问接口 // 与此同时这些方法又都在函数内部 globalVariable.each = function(collection, iterator) { if (Array.isArray(collection)) { for (var i = 0; i < collection.length; i++) { iterator(collection[i], i, collection); } } else { for (var key in collection) { iterator(collection[key], key, collection); } } }; globalVariable.filter = function(collection, test) { var filtered = []; globalVariable.each(collection, function(item) { if (test(item)) { filtered.push(item); } }); return filtered; }; globalVariable.map = function(collection, iterator) { var mapped = []; globalUtils.each(collection, function(value, key, collection) { mapped.push(iterator(value)); }); return mapped; }; globalVariable.reduce = function(collection, iterator, accumulator) { var startingValueMissing = accumulator === undefined; globalVariable.each(collection, function(item) { if(startingValueMissing) { accumulator = item; startingValueMissing = false; } else { accumulator = iterator(accumulator, item); } }); return accumulator; }; }(globalVariable));

在这个例子中,globalVariable 是唯一的全局变量。这种方法的好处是可以预先声明好全局变量,让你的代码更加清晰可读。

例3:对象接口

像下面这样,还有一种创建模块的方法是使用独立的对象接口:

var myGradesCalculate = (function () { // 在函数的作用域中下面的变量是私有的 var myGrades = [93, 95, 88, 0, 55, 91]; // 通过接口在外部访问下列方法 // 与此同时这些方法又都在函数内部 return { average: function() { var total = myGrades.reduce(function(accumulator, item) { return accumulator + item; }, 0); return"Your average grade is " + total / myGrades.length + "."; }, failing: function() { var failingGrades = myGrades.filter(function(item) { return item < 70; }); return "You failed " + failingGrades.length + " times."; } } })(); myGradesCalculate.failing(); // "You failed 2 times."  myGradesCalculate.average(); // "Your average grade is 70.33333333333333."

例4:揭示模块模式 Revealing module pattern

这和我们之前的实现方法非常相近,除了它会确保,在所有的变量和方法暴露之前都会保持私有:

var myGradesCalculate = (function () { // 在函数的作用域中下面的变量是私有的 var myGrades = [93, 95, 88, 0, 55, 91]; var average = function() { var total = myGrades.reduce(function(accumulator, item) { return accumulator + item; }, 0); return"Your average grade is " + total / myGrades.length + "."; }; var failing = function() { var failingGrades = myGrades.filter(function(item) { return item < 70; }); return "You failed " + failingGrades.length + " times."; }; // 将公有指针指向私有方法 return { average: average, failing: failing } })(); myGradesCalculate.failing(); // "You failed 2 times."  myGradesCalculate.average(); // "Your average grade is 70.33333333333333."

到这里,其实我们只聊了模块模式的冰山一角。感兴趣的朋友可以阅读更详细的资料:

  • 深入理解JavaScript 模块模式
  • 深入理解JavaScript系列(3):全面解析Module模式

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 语句来引入。

还是举个直观的例子吧:

function myModule() { this.hello = function() { return "hello!"; } this.goodbye = function() { return "goodbye!"; } } module.exports = myModule;

通过指定导出的对象名称,CommonJS模块系统可以识别在其他文件引入这个模块时应该如何解释。

然后在某个人想要调用 myMoudle 的时候,只需要 require 一下:

var myModule = require("myModule"); var myModuleInstance = new myModule(); myModuleInstance.hello(); // "hello!" myModuleInstance.goodbye(); // "goodbye!"

这种实现比起模块模式有两点好处:

  1. 避免全局命名空间污染
  2. 明确代码之间的依赖关系

并且这种书写方式也非常舒服友好,我自己很喜欢。

需要注意的一点是,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 (typeof define === "function" && define.amd) { // AMD define(["myModule",