JavaScript模块化编程简史

JavaScript

2016-09-14

84

26

对于任何一个 Web 站点开发者,JavaScript 模块化编程都是一个非常重要的知识。

我们可能已经在开发中使用模块化的开发,但是自己却没有意识到,比如在浏览器中使用 require.js 来异步加载需要的模块,比如在 Node 中使用 require 来引入模块等。

模块化开发的方式一直在改变,从 2009 年就诞生了 CommonJS 规范,在 2015 年的最新的 ES6 中,官方终于引入了对于模块的原生支持。在这段期间发生了哪些变化?在生产环境中究竟使用哪一种最好,我们是否需要刷新一下自己的知识库?

所以我花了一个周末的时间重新仔细回顾 JavaScript 模块化编程的历史,并且记录下我的学习笔记。

1、解释一下什么是模块(modules)?

好的作者把他们的书分为一些章和节,好的程序员把代码分为一些模块。

就像一本书的章节一样,模块只是把一些代码聚合在一起罢了。

但是好的模块,是高度独立的,它可以被随时加入或者移除,而不会损害系统。

使用模块有这样几个好处:

1) 可维护性。 因为模块是独立的,一个设计良好的模块会让外面的代码对自己的依赖越少越好,这样自己就可以独立去更新和改进。

2) 命名空间。 在 JavaScript 里面,如果一个变量在最顶级的函数之外声明,它就直接变成全局可用。因此,常常不小心出现命名冲突的情况。

所以在开发实践中,我们需要使用模块化开发来封装变量,避免污染全局环境。

3) 重用代码。 我们有时候会喜欢从之前写过的项目中拷贝代码到新的项目,这没有问题,但是更好的方法是,通过模块引用的方式,来达到更好的效果。

通过使用模块来重用代码,我们可以在更新了模块之后,让引用了该模块的所有项目都同步更新,还能通过指定版本号来避免 API 变更的问题。

2、如何实现模块化?

“模块”这种模式有点类似类(classes)。在其他语言中(比如Java和Python),我们可以把公开或者私有的属性和函数都放在一个类中。这可以让我们决定哪些属性和方法可以作为类的公共 API,哪些属性和方法隐藏在类内部。

而在 JavaScript 中(在 ES6 之前)不支持类。只有在创建函数的时候,才会在它内部产生一个新的运行环境。通过这个新的运行环境,我们可以把私有变量隐藏起来,从而避免父命名空间(也就是全局命名空间)冲突。

所以在 ES6 之前我们都从函数来入手,通过多种方式实现模块化开发,让我们一个一个分析。

2.1、使用匿名函数

在这个代码片段中,我们定义了一个匿名函数并立即执行。在这个匿名函数内定义的变量 myGrades 等都成为这个运行环境内的私有属性。

要记得的一点是一定要用圆括号把整个匿名函数包起来,因为如果语句直接以 function 关键词开始,那么这个语句会被认为是一个函数声明(FunctionDeclaration),而不是一个函数语句(FunctionExpression)。

函数声明必须是有名字的,函数语句才可以匿名,所以我们一定需要用括号来把整个函数体括起来。

2.2、把全局函数注入到匿名函数

刚才我们已经演示了使用一个匿名函数把变量和代码封装起来,接下来演示一种常用的做法,就是把全局变量“注入”到匿名函数中。jQuery就是这样用的。

在这个例子中, globalVarible 是唯一一个全局的变量。这种做法比完全匿名的闭包的好处是,代码结构更清晰,而且性能更好。我们能够看到函数内部传递进来了全局变量,所以依赖关系非常清晰。其次在函数内部调用 globalVarible 的时候,能够直接找到局部的 globalVarible ,就不用上溯到外部的 globalVarible

而且我们也可以对这个全局的 globalVarible 进行修改。但是这样做仍然可能导致命名冲突,强烈建议不要这样做。

2.3、提供一个对象作为接口

另一种创建模块的方法是使用一个自包含的对象接口,就像这样:

立即执行的匿名函数返回了一个对象。通过这种方式,我们可以定义哪些变量和方法是私有的,哪些是公开的。

2.4、把要公开的属性专门放在一个对象声明中返回

最后一种创建模块的方式跟上一个很类似:

在这段代码中,默认所有的变量和方法都是私有的,只有在最后显示 return 一个对象的时候,才选择性暴露出对外接口,很清晰。

3、CommonJS 和 AMD

上面这些方法都有一个共同点: 使用一个特定的全局模块名来把一些私有变量和方法包起来,然后通过闭包来创建一个私有的命名空间。

上面这些方法有一个明显的缺点,就是无法管理不同模块之间的依赖关系。比如说,如果我们的项目用到了 Backbone.js 库,所以我们会把 Backbone.js 的代码引入到项目中。

不过,由于 Backbone 依赖 Underscore.js,所以 Backbone.js 的代码需要放在 Underscore.js 之后。

当依赖的库多起来了,记住这些库应该以什么样的顺序去加载就是一件很头疼的事情了。

另一个明显的缺点是,依次引入这些模块仍然没有解决命名空间冲突的问题。比如 jQuery 和其他库可能都使用了 $ 来作为暴露的名字,或者我们引用了同一个模块的两个不同版本怎么办?

幸运的是,我们有两个已经得到广泛支持的解决方案:CommonJS 和 AMD。

3.1、CommonJS

CommonJS 最开始是 Mozilla 的工程师于 2009 年开始的一个项目,它的目的是让浏览器之外的 JavaScript (比如服务器端或者桌面端)能够通过模块化的方式来开发和协作。

在 CommonJS 的规范中,每个 JavaScript 文件就是一个独立的模块上下文(module context),在这个上下文中默认创建的属性都是私有的。也就是说,在一个文件定义的变量(还包括函数和类),都是私有的,对其他文件是不可见的。

如果想在多个文件分享变量,必须定义为global对象的属性。但是这样做是不推荐的。

推荐的做法是,通过 module.exports 对象来暴露对外的接口。

Node 就采用了 CommonJS 规范来实现模块依赖。

我们可以这样创建一个最简单的模块:

我们可以注意到,在定义了自己的 function 之后,通过 module.exports 来暴露了出去。为什么可以在没有定义 module 的情况下就使用它?因为 module 是 CommonJS 规范中预先已经定义好的对象。

如果其他代码想使用我们的 myModule 模块,只需要 require 它就可以了。

这种做法有两个明显的优势:

  1. 避免全局命名空间污染,require 进来的模块可以被赋值到自己随意定义的局部变量中,所以即使是同一个模块的不同版本也可以完美兼容
  2. 让各个模块的依赖关系变得很清晰

语法也非常紧凑,我个人很喜欢。

有的时候一个模块需要的代码量比较大,会分解成若干文件,然后放在一个目录中。这时,最好为该目录设置一个入口文件,让 require 方法可以通过这个入口文件,加载整个目录。

在目录中放置一个package.json文件,并且将入口文件写入main字段。

需要注意的是,CommonJS 规范的主要适用场景是服务器端编程,所以采用同步加载模块的策略。 如果我们依赖3个模块,代码会一个一个加载它们

因为服务器端的模块加载主要来源硬盘、或者内存,所以加载速度比较快,同步加载并不是很大问题。但是如果是在浏览器场景中,同步加载就有很大问题,因为从网络中加载一个模块比从硬盘加载慢得多。在等待加载的过程中,浏览器会挂起当前的进程,直到模块下载完成。

阮一峰老师实现了一个 在浏览器中加载 CommonJS 规范的模块的 JavaScript 库,可以作为学习或者测试模型来使用,但是在生产环境中使用,就有很大的性能问题了。

Node 开发社区的 SubStack 大神开发了一个 browserify 工具,browserify 可以把需要 r equire 进来的 a/b/c 等模块文件全部打包合并到一个单独的 JavaScript 文件中。

第一步通过 browserify 命令来合并输出 bundle.js。

第二步,页面中只需要引入一次 bundle.js,就可以得到一个暴露出了对应的 a/b/c 模块的 require 函数,避免了一个一个请求,性能非常好。

不过这种方式不太常用,仅作为了解使用即可,在浏览器端,我们更常用 AMD 来实现模块化开发。

3.2、AMD

AMD 是 Asynchronous Module Definition 的简称,即“异步模块定义”。

我们看一下 AMD 模块的使用方式:

在这里,我们使用了 define 函数,并且传入了两个参数。

第一个参数是一个数组,数组中有两个字符串也就是需要依赖的模块名称。 AMD 会以一种非阻塞的方式,在后台加载这两个模块。在两个模块都加载成功之后, define 会调用第二个参数中的回调函数。

第二个参数也就是回调函数,函数接受了两个参数,正好跟前一个数组里面的两个模块名一一对应。因为这里只是一种参数注入,所以我们使用自己喜欢的名称也是完全没问题的。

同时, define 既是一种引用模块的方式,也是定义模块的方式。

例如,myModule 的代码可能看上去是这样:

所以我们可以看到,AMD 优先照顾浏览器的模块加载场景,使用了异步加载和回调的方式,这跟 CommonJS 是截然不同的。

3.3、UMD

对于需要同时支持 AMD 和 CommonJS 的模块而言,可以使用 UMD(Universal Module Definition)。

在执行UMD规范时,会优先判断是当前环境是否支持AMD环境,然后再检验是否支持CommonJS环境,否则认为当前环境为浏览器环境(window)。

如果你写了一个小工具库,你想让它及支持AMD规范,又想让他支持CommonJS规范,那么采用UMD规范对你的代码进行包装吧。

4、ES6 模块

可能你已经注意到了,上面所有这些模型定义,没有一种是 JavaScript 语言原生支持的。无论是 AMD 还是 CommonJS,这些都是 JavaScript 函数来模拟的。

幸运的是,ES6 开始引入了原生的模块功能。

ES6 的原生模块功能非常棒,它兼顾了规范、语法简约性和异步加载功能。它还支持循环依赖。

最棒的是,import 进来的模块对于调用它的模块来是说是一个活的只读视图,而不是像 CommonJS 一样是一个内存的拷贝。

下面是一个 ES6 模块的示例:

如果只希望导出某个模块的部分属性,或者希望处理命名冲突的问题,可以有这样一些导入方式:

我什么时候可以使用ES6模块?

如果你现在就想在项目中加入新的模块语法,你需要使用 Babel 或 Traceur 这样的转译器。

总结

JavaScript 的模块模式是很有意思的话题,期望过几年之后所有的主流浏览器都可以原生支持 ES6 的模块系统,那时候我们也许会看到类似

打赏一个呗~~

关注我
长按二维码可识别关注
微信号:xwintop
微信号xwintop


追风

爱一个人就像执着于一种梦想,没有值不值得,只有愿不愿意···

关注我
长按二维码可识别关注
微信号:xwintop
微信号xwintop


关注我
长按二维码可识别关注
微信号:xwintop
微信号xwintop