引言
一次又一次的事实证明,小的、组织好的代码远比庞大的代码更容易理解和维护。
因此,优化程序 的结构和组织方式,就是把它们分成小的、耦合度低的片段。我们把这样的片段,称为 模块。
- 模块
模块是比对象和函数更大的代码单元。使用模块可以将程序进行归类。为什么需要模块?在有些时候,js中可能都出是全局变量(如果你在主线程代码中定义变量,该变量会被自动识别为全局变量),并且可被其他部分的代码访问。当,程序开始扩展,引入第三方代码后,命名冲突的可能性就会大大提高。
在ES6之前,javascript并没有提供内置的模块特性,通常是开发者利用js的特性,如对象、闭包、立即执行函数等,开发出模块化技术。
当我们要开发模块化技术时,请牢记模块化系统至少具备下列2点功能:
- 定义模块接口:供外部代码调用该模块
- 隐藏模块的内部实现细节:模块的调用者/使用者无需关心模块内部的实现细节。
了解了模块,下面我们就来谈谈几种模块化方案。
ES6之前的模块化方案
(1)对象+闭包+立即执行函数方案
基于模块化的2个特点,在该方案中:
- 立即执行函数:隐藏内部实现细节
- 对象+闭包:形成接口,对外暴露模块功能,同时保持闭包活跃。
示例:
const MouseCounterModule = function() {//MouseCounter 全局模块变量,返回立即执行函数的结果 let numClick = 0 ;//模块私有变量 const handleClick = () => { //模块私有方法 alert(++numClick); }; return {//接口:返回一个对象 countClicks: ()=> {//通过闭包,可以访问模块私有变量和方法 document.addEventListener("click",handleClick); } }; }();
我们把立即执行函数+对象+闭包来创建模块的方式,称为模块模式。
一旦有能力定义模块,就能将不同的模块拆分为多个文件。或者在已有模块上不修改原有代码就可以定义更多功能。
扩展模块
- 模块模块时,不能修改原有模块的代码,原有模块代码需要保持不变。
示例:我们对MouseCounterModule模块进行扩展
const MouseCounterModule = function() {//MouseCounter 全局模块变量,返回立即执行函数的结果 let numClick = 0 ;//模块私有变量 const handleClick = () => { //模块私有方法 alert(++numClick); }; return {//接口:返回一个对象 countClicks: ()=> {//通过闭包,可以访问模块私有变量和方法 document.addEventListener("click",handleClick); } }; }(); //扩展模块: 调用立即执行函数,并传入需要扩展的模块作为参数: (function(module) { let numScrolls = 0; const handleScroll = () => { alert(++numScrolls); } module.countScrolls = () => {//扩展模块接口 document.addEventListener("wheel",handleScroll); } })(MouseCounterModule); //将模块传入,作为参数
使用:
MouseCounterModule.countClicks(); //初始化接口 MouseCounterModule.countScrolls(); //调用模块新增扩展的方法
通过模块模式的方式,建立模块技术,有一点缺点,即模块扩展无法共享模块的私有变量,因为扩展的函数和原有模块里的模块私有函数是处在不同的环境中定义,不可以访问对方的内部变量。但这并不是非常糟糕!
糟糕的是,当我们创建模块化应用时,模块本身常常会依赖其他模块的功能(如jquery),模块模式无法实现这样的依赖关系。作为开发者,在这种情况下,不得不考虑正确的依赖顺序,这样模块才能具有执行时所需的完整依赖。
(2)AMD与CMD
开发者为了解决前面提到的问题,AMD和CMD出现了。
AMD
AMD源于Dojo toolkit,可以很容易指定模块及依赖关系。目前流行的实现有RequireJS。
示例:
define('MouseCounterModule',['jQuery'],$=> { //定义模块ID : MouseCounterModule, 依赖列表:['jQuery'] let numClicks = 0; const handleClick = () => { alert(++numClicks); }; return {//模块公共接口 countClicks: () => { $(document).on("click",handleClick); } }; });
上面看到,在ADM中,使用define
函数指定模块及其依赖,模块工厂函数会创建对应的模块。由此归纳define接收参数:
- 新创建模块的ID。使用该ID,可以在系统的其他部分引用该模块。
- 当前模块依赖的模块ID列表。
- 初始化模块的工厂函数,该工厂函数接收依赖的模块列表作为参数。
上面的例子中,模块MouseCounterModule依赖于JQuery,因此AMD首先请求JQuery模块,如果需要从服务端请求,那么请求上需要时间。同时,这个过程是异步的,可以避免阻塞。当所有依赖的模块下载并解析完成后,调用模块的工厂函数,并传入所依赖的模块(如JQuery)。
模块的工厂函数,是与前面提到的模块模式
类似的创建模块的过程。
AMD的优点:
- 自动处理依赖,无需考虑依赖顺序
- 异步加载模块,避免阻塞
- 在同一个文件中可以定义多个模块。
CMD
AMD与CMD最大一个区别在于AMD面向浏览器,CMD面向通用Javascript环境。
因此,CMD目前拥有更多的用户。
CMD基于文件模块,
每个文件中只能定义一个模块
。CMD提供module变量,其具有exports属性,通过exports可以很容易扩展额外属性。module.exports是模块的公共接口。
前面提到,CMD拥有广泛的用户,主要因为客户端与服务端原因。因为CMD基于文件,在服务端只需要读取文件系统,加载速度更快。而在客户端必须从远程服务器下载文件,且是同步下载文件,故会更慢下载,容易造成阻塞。
因此,在Nodejs中,默认使用CMD方式引入模块/包。
示例:
//MouseCounterModule.js const $ = require("jQuery"); //同步方式,引入JQuery模块 let numClicks = 0; const handleClick = () => { alert(++numClicks) }; module.exports = { //使用module.exports定义模块公共接口 countClicks: () => { $(document).on('click',handleClick); } } //a.js 引用MouseCounterModule.js const MouseCounterModule = require('MouseCounter.js'); MouseCounterModule.countClicks();
由此我们知道,CMD规定:一个文件只允许定义一个模块。同时,不需要使用立即执行函数包装变量。而是使用module.exports
在模块中定义的变量都是安全地包含在当前模块中,不会泄露到全局作用域。
例如:上面的例子中,模块变量 $,numClicks,handleClick
虽然是在模块代码顶部定义,但仍然在模块作用域中。如果是在标准Javascript文件中,这样的写法将产生全局作用域!
同时,只有通过module.exports对象暴露的对象或函数才可以在模块外部访问。
CMD优点
- 语法简单。只需要定义
module.exports
属性。剩下的模块代码与标准的Javascript无大差异。同时,只需要使用require函数
引用模块。 - CMD是NodeJS默认的模块格式。
CMD缺点
- 不能显式支持浏览器。因为浏览器不支持
module变量、exports属性
,需要使用浏览器支持的打包工具(如Browserify)来实现。
小结
上面提到的AMD与CMD,两者是属于相互竞争的方案。这就不可避免的产生问题:如当我们偏向一方使用,如果与其他项目(使用另一方方案)产生冲突,就需要解决障碍。脑壳疼!那么ES6模块化方案出现了!
ES6 模块化方案
ES6 的模块化方案结合了CMD和AMD的优点,例如:
- 模块语法简单,基于文件,即每个文件是一个模块
- 异步加载模块
ES6 目前还有一些浏览器不支持,可以使用其他工具进行编译,如:
主要思想
ES6模块化方案,必须显式地使用标识符导出模块,才能从外部访问模块。其它标识符,甚至在最顶级作用域中定义的标识符,只能在模块内使用。为了实现这样的功能,ES6提供两个关键字:
- export :从模块外部指定标识符
- import :导入模块标识符
示例:
//a.js const name = "imaginecode"; //在模块a.js中定义一个顶级变量name export function sayHello() { //通过模块公共API访问模块内部变量 return 'Hello' + name; } //b.js import A from 'a.js' ; //导入模块 A.sayHello();
注意:
- 导入已经命令的导出内容,必须使用花括号
- 导入默认的导出不需要使用花括号
- 重命名:只能在export/import 表达式中进行重命名。重命名后只能使用别名。当需要在当前上下文提供更合适的命名,或者避免命名冲突,别名可以发挥作用。