javascript的作用域和闭包(三)闭包与模块
一些很重要的说明:前面三篇博客详细的介绍了,引擎与编译器和作用域的关系,重点需要理解的是编译器中的分词与词法分析,JavaScript的特有的“赋值操作的左右侧”引用操作;编译阶段的词法作用域的工作原理和eval、with的欺骗词法作用域;然后还有介绍了函数作用域与块级作用及相关的ES6新特性,接着对函数内部的提升机制和Variable Object的数据读写机制做了详细分析。
这篇博客将对闭包和模块化做详细的分析,但ES6的模块化机制不会在这里详细解析,这篇博客主要对闭包和立即执行函数实现模块化做分析。文末只会稍微以下ES6的模块化机制,方便后期在ES6部分起到一个承上启下的作用。
javasrcipt的作用域和闭包(一)
javasrcipt的作用域和闭包(二)
javasrcipt的作用域和闭包(二)续篇之:函数内部提升机制与Variable Object
正文部分:
博客不会对从头至尾的把作用域与闭包的内容全部讲一遍了,而是基于前面三篇博客,对闭包进行深入的剖析,然后延伸到模块部分(这里指的是手动构建模块,并非ES6的模块机制),如果你对编译和作用域的相关内容不清楚,可能理解起来会很难,所以建议尽量把前面三篇博客详细阅读过后再来看这篇博客。
一、闭包
闭包就是能够读取其他函数内部变量的函数。例如在javascript中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。
这个定义是从百度拷贝的,看看就好了,我们先来一段代码:
function foo(){ var a = 4; function bar(){ console.log(a); } return bar; } var baz = foo(); baz(); //4
在上一篇博客中我们讲到了Variable Object(变量对象),当一个函数执行时的前一刻,引擎就会给这个函数创建一个变量对象,程序就可以根据这个对象提供的方法和数据实现相应的功能了,但是在我们的词法作用域部分,我们讲到函数执行的内部的编译查询会基于作用域的嵌套往上级作用域查询,但这是基于什么机制实现的呢?
如果根据作用域的查询逻辑和变量赋值机制,上一段代码的baz应该呈现下面的结果?
var baz = function bar(){ console.log(a); } baz();//如果是这样的话应该报错:ReferenceError: a is not defined
为什么实际的结果和所理解的机制显式代码执行会有如此差异呢?
到这里需要用一个新的内部机制来理解代码了,就是我们JavaScript中的作用域链[[scope]]。通过前面的词法作用域我们知道了作用域的嵌套关系,而这个内部机制的就是依靠[[scope]]来完成的。那这个作用域链[[scope]]是个什么东西呢?
在前面我们提到的变量对象,在变量对象保存着当前函数的所有声明的变量和函数,程序执行时需要的方法和参数就到对应作用域的Variable Object(属性对象)上取,那词法作用域到上级嵌套的作用域查询取值又是怎么实现的呢?其实在Variable Object(变量对象)上除了函数内的变量和方法以外,引擎内部还赋予了一些内部隐藏的属性和方法,其中就包括[[scope]],这个属性的值就是嵌套的上级作用域(父级作用域的属性对象),所以引擎在执行程序时当前的作用域查询不到的数据会沿着每个作用域的[[scope]]往上查找,直到全局作用域为止。
在这里在介绍两个名称概念,然后再通过嵌套关系图来深入的理解作用域嵌套[[scope]]和闭包的关系。
通常情况下,我们在表示变量对象时,不会采用VO来表示,而是另外两个简写名称AO和GO来表示变量对象,AO表示函数执行产生的作用域的变量对象(全称:Activation Object),GO表示全局作用域的变量对象(全称:Global Object)。
通过流程图详细分析了嵌套作用域的内部运行机制,但是还有一个疑惑没有解决,就是在JavaScript函数执行结束后,引擎的垃圾回收机制就会对执行完的函数所产生的AO进行回收处理,腾出内存空间给其他程序执行使用。从上面这个流程图中可以看到,执行全局代码时,foo()是在全局函数(通常把全局也当做一个函数)内部,也就是说foo的执行也算是全局函数执行的一部分,但是foo有自己的独立作用域,其实在这时候就发生了函数的闭包行为,只是这个闭包持续的事件非常的短暂,当foo执行接着bar执行;bar执行的时候foo的作用域又被bar挟持着,所以这时候foo也发生闭包行为,然后bar执行结束,引擎最先销毁bar的AO,然后销毁foo的AO,由于GO上还有很多内部监听函数,所以GO并不会销毁,不然JS代码就会失效了。
闭包从本质上来说就是引擎为了保证作用域的内部函数执行时能顺利的获取到相关数据,但是上图示例并不是很明显,在这个代码片段执行时虽然会发生两个闭包行为,但是除了GO本身被长期保留以外,foo发生的闭包行为存在的时间微乎其微,但是它还是真实发生过。
接着我们来讨论这篇博客开头时的那段代码:(为了方便,重新添加一次,不用去前面看了)
function foo(){ var a = 4; function bar(){ console.log(a); } return bar; } var baz = foo(); baz(); //4
这段代码与流程图中的示例有一点区别就是:bar函数没有在foo函数内部被执行,而是作为foo函数执行的结果被返回,并赋给了baz,然后再通过baz执行这个函数。执行结果是4,通过前面对闭包的解析,这里打印的4很容可以被理解是来源于foo的闭包上的变量a的值。
也就是说,foo函数执行结束并返回bar时,foo的AO并没有被回收,所以结论就有了。当函数执行把内部的函数作为返回值返回,并被其他变量接收了这个值的时候,该函数的AO不会被回收,而是继续保持,持续到接收变量的值被其他值覆盖,否则将被永远保持。
所以在这个示例中的闭包状态是一个持续存在的状态,这样的闭包持久模式在实际开发中经常被用到,也有无意间的触发持久闭包,这些无疑触发的闭包会对程序带来很大的问题,这个问题被称作“内存泄漏”,前面我已经有讲引擎的垃圾会有机制,无意触发的闭包会导致占用大量的内存,这种占用内存的情况就叫做内存泄漏。
当然除了内存泄漏的弊端,应用闭包机制可以做很多强大的程序设计,这里重点介绍基于闭包机制的模块开发。
二、模块
值得感慨是使用js的时候,把所有方法和变量都放到全局,当开发到一定程度的时候就要崩溃了,变量冲突,查看前面开发代码更是一种折磨(维护时就更痛苦)。当我对js的学习到一定程度的时候,发现可以通过闭包这样的机制将方法当做后台语言的类一样使用(那时候还不知道这是闭包),所以接下来就通过一段代码来认识以下闭包的模块化设计应用。
function valuation(preference) { var half = 0.5; //半价 var discount = 0.85; //8.5折 var reduce = preference; //特惠促销直降 //商品计价实现方法 function original(price){ return price; } function contHalf(price){ return price * half; } function contDiscount(price){ return price * discount; } function contReduce(price,boo){ if(boo){ return price - reduce; } } //API return { original:original, contHalf:contHalf, contDiscount:contDiscount, contPreference:contReduce } } var val = valuation(); console.log(val.contHalf(10));//5
上面的代码实现了一个简单的商品计价API,将各种计价方法封装在一个闭包内,这样的设计让程序便有清晰有序,维护起来更方便。这种将同一行为的多种行为状态(方法)和行为条件(变量),然后返回接口(方法),封装在同一个函数内的设计方式就被称为模块。
1.必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块示例)
2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
三、模块与IIFE
在很多业务需求中,需要在页面加载的时候实现初始化状态,然后用户的后期操作都在初始化后的作用域和数据基础上来实现,上一个简单的示例来看看:
var foo = (function CoolModule(id){ function change(){ publicAPI.identify = identify2; } function restore(){ publicAPI.identify = identify1; } function identify1(){ console.log(id); } function identify2(){ console.log(id.toUpperCase()); } var publicAPI = { change:change, restore:restore, identify:identify1 } return publicAPI; })("foo module"); foo.identify();//foo module foo.change(); foo.identify();//FOO MODULE foo.restore(); foo.identify();//foo module
采用IIFE的模块模式设计JavaScript程序让所有功能都通过调用API来实现,内部具体怎么实现被很友好的隐藏起来,同时也非常符合程序设计的最小暴露原则。有了这样一些优秀的程序设计特性IIFE的模块模式设计程序就开始大放异彩了,比如大多数的模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的API。
有了这个思路,我们来模仿写一个模块管理器的核心程序:
var MyModules = (function Manager(){ var modules = {}; function define(name,deps,impl){ for(var i = 0; i < deps.length; i++){ deps[i] = modules[deps[i]]; } modules[name] = impl.apply(impl,deps); } function get(name){ return modules[name]; } return { define:define, get:get } }());
这段代码的核心是modules[name] = impl.apply(impl,deps),通过IIFE闭包模块模式实现了MyModules的模块包装函数,并返回值(模块的API),这样就实现了一个通过名字管理的模块列表。下面展示如何使用它来定义模块。
MyModules.define("bar", [], function(){ function hello(who){ return "Let me introduce:" + who; } return { hello:hello }; }); MyModules.define("foo", ["bar"], function(bar){ function awesome(who){ console.log(bar.hello(who).toUpperCase()); } return { awesome:awesome }; }); var bar = MyModules.get("bar"); var foo = MyModules.get("foo"); console.log(bar.hello("hippo"));//Let me introduce:hippo foo.awesome("boos");//LET ME INTRODUCE:BOOS
四、ES6的模块机制
ES6中为模块增加了一级语法,在通过模块系统进行加载时,ES6会将文件当做独立的模块来处理。每个模块都可以导入其他模块或特定的API成员,同样也可以导出自己的API成员。
基于函数的模块并不是一个能被静态识别的模块,只有在执行时才会实现API,所以可以在运行时修改一个模块的API。相比ES6模块API是静态的,可以在编辑时被编辑器识别到,当在编程时引用了一个并不存在的API是会出现错误提示,可以实现更友好的编程,也很好的预防了程序编辑的错误。
下面我们用代码来看看ES6是怎么实现模块模式的:
bar.js function hello(who){ return "Let me introduce:" + who; } export hello; foo.js import hello form "bar"; var hungry = "hippo"; function awesome(hungry){ console.log( hello(hungry).toUpperCase(); ); } export awesome; baz.js module foo from "foo"; module bar from "bar"; console.log( bar.hello("rhino"); );// Let me introduce:rhino foo.awesome();// LET ME INTRODUCE:HIPPO
ES6模块模式是将多个js文件作为模块的基本单元,通过关键字描述来实现API的导入与导出,这篇博客只对这个模式做一个展示,不深入解析,后期会有ES6部分的相关博客来详细解析这部分内容。
实现ES6模块模式的一些关键字:
import:将一个或多个API导入到当前作用域,并分别绑定在一个变量上;
module会将整个模块的API导入并绑定到一个变量上;
export会将当前模块的一个标识符(变量、函数)导出为公共API。
五、一个闭包的经典问题(循环与闭包)
for(var i = 1; i <= 5; i++){ setTimeout(function timer(){ console.log(i); },i*1000); }
这段代码并不能打印出理想的1,2,3,4,5,而是打印了5个6,这是因为for循环的执行并不会等异步执行的setTimeout,而程序的执行时按微秒计算,当一秒后开始执行异步打印时,作用域上i的值已经变成6了。因为闭包机制,setTimeout会在for所在的作用域上产生5个并列的子作用域。
原因找到了,解决的思路就是在每个子作用域中对每一次循环中i的值进行缓存,提供给异步的打印程序来调用。
for(var i = 1; i <= 5; i++){ (function(i){ setTimeout(function timer(){ console.log(i); },i*1000); }(i)); }
当然也可以通过let挟持块作用域的方式来解决:
for(let i = 1; i <= 5; i++){ setTimeout(function timer(){ console.log(i); },i*1000); }
通过三篇博客详细的解析了作用域和闭包的原理机制,我自己收货颇多,我并不是科班学习者,这些知识是基于自己的一些编程经验,然后在阅读《你不知道的JavaScript》一书后总结出来的,很多内容都是来源于原版书本,这个系列的书对知识的总结很到位,如果你想对前端技术深入研究,这个系列的书很适合,但是要注意看这个系列的书一定要一些JavaScript实际的编程经验,不然阅读起来会有些困难,如果在博客中出现错误,希望大家在评论区指出,也欢迎大家找我讨论前端开发的相关问题。