深度剖析JavaScript闭包之欧阳科创编.docx
《深度剖析JavaScript闭包之欧阳科创编.docx》由会员分享,可在线阅读,更多相关《深度剖析JavaScript闭包之欧阳科创编.docx(15页珍藏版)》请在冰豆网上搜索。
深度剖析JavaScript闭包之欧阳科创编
理解JavaScript闭包
时间:
2021.02.05
创作:
欧阳科
本文结合ECMA262规范详解了闭包的内部工作机制,让JavaScript编程人员对闭包的理解从“嵌套的函数”深入到“标识符解析、执行环境和作用域链”等等JavaScript对象背后的运行机制当中,真正领会到闭包的实质。
简介
Closure
所谓“闭包”,指的是一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。
闭包是ECMAScript(JavaScript)最强大的特性之一,但用好闭包的前提是必须理解闭包。
闭包的创建相对容易,人们甚至会在不经意间创建闭包,但这些无意创建的闭包却存在潜在的危害,尤其是在比较常见的浏览器环境下。
如果想要扬长避短地使用闭包这一特性,则必须了解它们的工作机制。
而闭包工作机制的实现很大程度上有赖于标识符(或者说对象属性)解析过程中作用域的角色。
关于闭包,最简单的描述就是ECMAScript允许使用内部函数--即函数定义和函数表达式位于另一个函数的函数体内。
而且,这些内部函数可以访问它们所在的外部函数中声明的所有局部变量、参数和声明的其他内部函数。
当其中一个这样的内部函数在包含它们的外部函数之外被调用时,就会形成闭包。
也就是说,内部函数会在外部函数返回后被执行。
而当这个内部函数执行时,它仍然必需访问其外部函数的局部变量、参数以及其他内部函数。
这些局部变量、参数和函数声明(最初时)的值是外部函数返回时的值,但也会受到内部函数的影响。
遗憾的是,要适当地理解闭包就必须理解闭包背后运行的机制,以及许多相关的技术细节。
虽然本文的前半部分并没有涉及ECMA262规范指定的某些算法,但仍然有许多无法回避或简化的内容。
对于个别熟悉对象属性名解析的人来说,可以跳过相关的内容,但是除非你对闭包也非常熟悉,否则最好是不要跳下面几节。
对象属性名解析
ECMAScript认可两类对象:
原生(Native)对象和宿主(Host)对象,其中宿主对象包含一个被称为内置对象的原生对象的子类(ECMA2623rdEdSection4.3)。
原生对象属于语言,而宿主对象由环境提供,比如说可能是文档对象、DOM等类似的对象。
原生对象具有松散和动态的命名属性(对于某些实现的内置对象子类别而言,动态性是受限的--但这不是太大的问题)。
对象的命名属性用于保存值,该值可以是指向另一个对象(Objects)的引用(在这个意义上说,函数也是对象),也可以是一些基本的数据类型,比如:
String、Number、Boolean、Null或Undefined。
其中比较特殊的是Undefined类型,因为可以给对象的属性指定一个Undefined类型的值,而不会删除对象的相应属性。
而且,该属性只是保存着undefined值。
下面简要介绍一下如何设置和读取对象的属性值,并最大程度地体现相应的内部细节。
值的赋予
对象的命名属性可以通过为该命名属性赋值来创建,或重新赋值。
即,对于:
varobjectRef=newObject();//创建一个普通的JavaScript对象。
可以通过下面语句来创建名为“testNumber”的属性:
objectRef.testNumber = 5; /* – 或- */ objectRef["testNumber"] = 5;
在赋值之前,对象中没有“testNumber”属性,但在赋值后,则创建一个属性。
之后的任何赋值语句都不需要再创建这个属性,而只会重新设置它的值:
objectRef.testNumber = 8; /* – or:
- */ objectRef["testNumber"] = 8;
稍后我们会介绍,Javascript对象都有原型(prototypes)属性,而这些原型本身也是对象,因而也可以带有命名的属性。
但是,原型对象命名属性的作用并不体现在赋值阶段。
同样,在将值赋给其命名属性时,如果对象没有该属性则会创建该命名属性,否则会重设该属性的值。
—————————————————————————————————–
值的读取
当读取对象的属性值时,原型对象的作用便体现出来。
如果对象的原型中包含属性访问器(propertyaccessor)所使用的属性名,那么该属性的值就会返回:
/* 为命名属性赋值。
如果在赋值前对象没有相应的属性,那么赋值后就会得到一个:
*/ objectRef.testNumber = 8; /* 从属性中读取值 */ var val = objectRef.testNumber; /* 现在, – val – 中保存着刚赋给对象命名属性的值 8*/
而且,由于所有对象都有原型,而原型本身也是对象,所以原型也可能有原型,这样就构成了所谓的原型链。
原型链终止于链中原型为null的对象。
Object构造函数的默认原型就有一个null原型,因此:
我测试的实例如下:
var youmila = new Object();//创建一个object对象 console.debug(youmila.__proto__);//查看它的原型是object var root = youmila.__proto__; console.debug(root.__proto__);//查看object的原型 是null var rootParent = root.__proto__; console.debug(rootParent.__proto__);//null是原型链表的终端所以会报TypeError:
rootParent is null错误
输出结果如下:
>>> var youmila = new Object(); console.debug(youmila…t.__proto__; console.debug(rootParent.__proto__); Object null TypeError:
rootParent is null var objectRef = new Object(); //创建一个普通的 JavaScript 对象。
创建了一个原型为Object.prototype的对象,而该原型自身则拥有一个值为null的原型。
也就是说,objectRef的原型链中只包含一个对象--Object.prototype。
但对于下面的代码而言:
/* 创建 – MyObject1 – 类型对象的函数*/ function MyObject1(formalParameter){ /* 给创建的对象添加一个名为 – testNumber – 的属性 并将传递给构造函数的第一个参数指定为该属性的值:
*/ this.testNumber = formalParameter; } /* 创建 – MyObject2 – 类型对象的函数*/ function MyObject2(formalParameter){ /* 给创建的对象添加一个名为 – testString – 的属性 并将传递给构造函数的第一个参数指定为该属性的值:
*/ this.testString = formalParameter; }
/*接下来的操作用MyObject1类的实例替换了所有与MyObject2类的实例相关联的原型。
而且,为MyObject1构造函数传递了参数–8–,因而其–testNumber–属性被赋予该值:
*/
MyObject2.prototype = new MyObject1( 8 );
/*最后,将一个字符串作为构造函数的第一个参数,创建一个–MyObject2–的实例,并将指向该对象的引用赋给变量–objectRef–:
*/
var objectRef = new MyObject2( “String_Value” );
被变量objectRef所引用的MyObject2的实例拥有一个原型链。
该链中的第一个对象是在创建后被指定给MyObject2构造函数的prototype属性的MyObject1的一个实例。
MyObject1的实例也有一个原型,即与Object.prototype所引用的对象对应的默认的Object对象的原型。
最后,Object.prototype有一个值为null的原型,因此这条原型链到此结束。
当某个属性访问器尝试读取由objectRef所引用的对象的属性值时,整个原型链都会被搜索。
在下面这种简单的情况下:
var val = objectRef.testString;
因为objectRef所引用的MyObject2的实例有一个名为“testString”的属性,因此被设置为“String_Value”的该属性的值被赋给了变量val。
但是:
var val = objectRef.testNumber;
则不能从MyObject2实例自身中读取到相应的命名属性值,因为该实例没有这个属性。
然而,变量val的值仍然被设置为8,而不是未定义--这是因为在该实例中查找相应的命名属性失败后,解释程序会继续检查其原型对象。
而该实例的原型对象是MyObject1的实例,这个实例有一个名为“testNumber”的属性并且值为8,所以这个属性访问器最后会取得值8。
而且,虽然MyObject1和MyObject2都没有定义toString方法,但是当属性访问器通过objectRef读取toString属性的值时:
var val = objectRef.toString;
变量val也会被赋予一个函数的引用。
这个函数就是在Object.prototype的toString属性中所保存的函数。
之所以会返回这个函数,是因为发生了搜索objectRef原型链的过程。
当在作为对象的objectRef中发现没有“toString”属性存在时,会搜索其原型对象,而当原型对象中不存在该属性时,则会继续搜索原型的原型。
而原型链中最终的原型是Object.prototype,这个对象确实有一个toString方法,因此该方法的引用被返回。
最后:
var val = objectRef.madeUpProperty;
返回undefined,因为在搜索原型链的过程中,直至Object.prototype的原型--null,都没有找到任何对象有名为“madeUpPeoperty”的属性,因此最终返回undefined。
不论是在对象或对象的原型中,读取命名属性值的时候只返回首先找到的属性值。
而当为对象的命名属性赋值时,如果对象自身不存在该属性则创建相应的属性。
这意味着,如果执行像objectRef.testNumber=3这样一条赋值语句,那么这个MyObject2的实例自身也会创建一个名为“testNumber”的属性,而之后任何读取该命名属性的尝试都将获得相同的新值。
这时候,属性访问器不会再进一步搜索原型链,但MyObject1实例值为8的“testNumber”属性并没有被修改。
给objectRef对象的赋值只是遮挡了其原型链中相应的属性。
注意:
ECMAScript为Object类型定义了一个内部[[prototype]]属性。
这个属性不能通过脚本直接访问,但在属性访问器解析过程中,则需要用到这个内部[[prototype]]属性所引用的对象链--即原型链。
可以通过一个公共的prototype属性,来对与内部的[[prototype]]属性对应的原型对象进行赋值或定义。
这两者之间的关系在ECMA262(3rdedition)中有详细描述,但超出了本文要讨论的范畴。
执行环境
执行环境是ECMAScript规范(ECMA262第3版)用于定义ECMAScript实现必要行为的一个抽象的概念。
对如何实现执行环境,规范没有作规定。
但由于执行环境中包含引用规范所定义结构的相关属性,因此执行环境中应该保有(甚至实现)带有属性的对象--即使属性不是公共属性。
所有JavaScript代码都是在一个执行环境中被执行的。
全局代码(作为内置的JS文件执行的代码,或者HTML页面加载的代码)是在我称之为“全局执行环境”的执行环境中执行的,而对函数的每次调用(
有可能是作为构造函数)同样有关联的执行环境。
通过eval函数执行的代码也有截然不同的执行环境,但因为JavaScript程序员在正常情况下一般不会使用eval,所以这里不作讨论。
有关执行环境的详细说明请参阅ECMA262(3rdedition)第10.2节。
当调用一个JavaScript函数时,该函数就会进入相应的执行环境。
如果又调用了另外一个函数(或者递归地调用同一个函数),则又会创建一个新的执行环境,并且在函数调用期间执行过程都处于该环境中。
当调用的函数返回后,执行过程会返回原始执行环境。
因而,运行中的JavaScript代码就构成了一个执行环境栈。
在创建执行环境的过程中,会按照定义的先后顺序完成一系列操作。
首先,在一个函数的执行环境中,会创建一个“活动”对象。
活动对象是规范中规定的另外一种机制。
之所以称之为对象,是因为它拥有可访问的命名属性,但是它又不像正常对象那样具有原型(至少没有预定义的原型),而且不能通过JavaScript代码直接引用活动对象。
为函数调用创建执行环境的下一步是创建一个arguments对象,这是一个类似数组的对象,它以整数索引的数组成员一一对应地保存着调用函数时所传递的参数。
这个对象也有length和callee属性(这两个属性与我们讨论的内容无关,详见规范)。
然后,会为活动对象创建一个名为“arguments”的属性,该属性引用前面创建的arguments对象。
接着,为执行环境分配作用域。
作用域由对象列表(链)组成。
每个函数对象都有一个内部的[[scope]]属性(该属性我们稍后会详细介绍),这个属性也由对象列表(链)组成。
指定给一个函数调用执行环境的作用域,由该函数对象的[[scope]]属性所引用的对象列表(链)组成,同时,活动对象被添加到该对象列表的顶部(链的前端)。
之后会发生由ECMA262中所谓“可变”对象完成的“变量实例化”的过程。
只不过此时使用活动对象作为可变对象(这里很重要,请注意:
它们是同一个对象)。
此时会将函数的形式参数创建为可变对象的命名属性,如果调用函数时传递的参数与形式参数一致,则将相应参数的值赋给这些命名属性(否则,会给命名属性赋undefined值)。
对于定义的内部函数,会以其声明时所用名称为可变对象创建同名属性,而相应的内部函数则被创建为函数对象并指定给该属性。
变量实例化的最后一步是将在函数内部声明的所有局部变量创建为可变对象的命名属性。
根据声明的局部变量创建的可变对象的属性在变量实例化过程中会被赋予undefined值。
在执行函数体内的代码、并计算相应的赋值表达式之前不会对局部变量执行真正的实例化。
事实上,拥有arguments属性的活动对象和拥有与函数局部变量对应的命名属性的可变对象是同一个对象。
因此,可以将标识符arguments作为函数的局部变量来看待。
最后,要为使用this关键字而赋值。
如果所赋的值引用一个对象,那么前缀以this关键字的属性访问器就是引用该对象的属性。
如果所赋(内部)值是null,那么this关键字则引用全局对象。
创建全局执行环境的过程会稍有不同,因为它没有参数,所以不需要通过定义的活动对象来引用这些参数。
但全局执行环境也需要一个作用域,而它的作用域链实际上只由一个对象--全局对象--组成。
全局执行环境也会有变量实例化的过程,它的内部函数就是涉及大部分JavaScript代码的、常规的顶级函数声明。
而且,在变量实例化过程中全局对象就是可变对象,这就是为什么全局性声明的函数是全局对象属性的原因。
全局性声明的变量同样如此。
全局执行环境也会使用this对象来引用全局对象。
——————————————————————-
作用域链与[[scope]]
调用函数时创建的执行环境会包含一个作用域链,这个作用域链是通过将该执行环境的活动(可变)对象添加到保存于所调用函数对象的[[scope]]属性中的作用域链前端而构成的。
所以,理解函数对象内部的[[scope]]属性的定义过程至关重要。
在ECMAScript中,函数也是对象。
函数对象在变量实例化过程中会根据函数声明来创建,或者是在计算函数表达式或调用Function构造函数时创建。
通过调用Function构造函数创建的函数对象,其内部的[[scope]]属性引用的作用域链中始终只包含全局对象。
通过函数声明或函数表达式创建的函数对象,其内部的[[scope]]属性引用的则是创建它们的执行环境的作用域链。
在最简单的情况下,比如声明如下全局函数:
-
function exampleFunction(formalParameter){ … // 函数体内的代码 }
-当为创建全局执行环境而进行变量实例化时,会根据上面的函数声明创建相应的函数对象。
因为全局执行环境的作用域链中只包含全局对象,所以它就给自己创建的、并以名为“exampleFunction”的属性引用的这个函数对象的内部[[scope]]属性,赋予了只包含全局对象的作用域链。
当在全局环境中计算函数表达式时,也会发生类似的指定作用域链的过程:
-
var exampleFuncRef = function(){ … // 函数体代码 }
在这种情况下,不同的是在全局执行环境的变量实例化过程中,会先为全局对象创建一个命名属性。
而在计算赋值语句之前,暂时不会创建函数对象,也不会将该函数对象的引用指定给全局对象的命名属性。
但是,最终还是会在全局执行环境中创建这个函数对象(当计算函数表达式时。
译者注),而为这个创建的函数对象的[[scope]]属性指定的作用域链中仍然只包含全局对象。
内部的函数声明或表达式会导致在包含它们的外部函数的执行环境中创建相应的函数对象,因此这些函数对象的作用域链会稍微复杂一些。
在下面的代码中,先定义了一个带有内部函数声明的外部函数,然后调用外部函数:
/* 创建全局变量 – y – 它引用一个对象:
- */ var y = {x:
5}; // 带有一个属性 – x – 的对象直接量 function exampleFuncWith(){ var z; /* 将全局对象 – y – 引用的对象添加到作用域链的前端:
- */ with(y){ /* 对函数表达式求值,以创建函数对象并将该函数对象的引用指定给局部变量 – z – :
- */ z = function(){ … // 内部函数表达式中的代码; } } … } /* 执行 – exampleFuncWith – 函数:
- */
exampleFuncWith();在调用exampleFuncWith函数创建的执行环境中包含一个由其活动对象后跟全局对象构成的作用域链。
而在执行with语句时,又会把全局变量y引用的对象添加到这个作用域链的前端。
在对其中的函数表达式求值的过程中,所创建函数对象的[[scope]]属性与创建它的执行环境的作用域保持一致--即,该属性会引用一个由对象y后跟调用外部函数时所创建执行环境的活动对象,后跟全局对象的作用域链。
当与with语句相关的语句块执行结束时,执行环境的作用域得以恢复(y会被移除),但是已经创建的函数对象(z。
译者注)的[[scope]]属性所引用的作用域链中位于最前面的仍然是对象y。
例3:
包装相关的功能
闭包可以用于创建额外的作用域,通过该作用域可以将相关的和具有依赖性的代码组织起来,以便将意外交互的风险降到最低。
假设有一个用于构建字符串的函数,为了避免重复性的连接操作(和创建众多的中间字符串),我们的愿望是使用一个数组按顺序来存储字符串的各个部分,然后再使用Array.prototype.join方法(以空字符串作为其参数)输出结果。
这个数组将作为输出的缓冲器,但是将数组作为函数的局部变量又会导致在每次调用函数时都重新创建一个新数组,这在每次调用函数时只重新指定数组中的可变内容的情况下并不是必要的。
一种解决方案是将这个数组声明为全局变量,这样就可以重用这个数组,而不必每次都建立新数组。
但这个方案的结果是,除了引用函数的全局变量会使用这个缓冲数组外,还会多出一个全局属性引用数组自身。
如此不仅使代码变得不容易管理,而且,如果要在其他地方使用这个数组时,开发者必须要再次定义函数和数组。
这样一来,也使得代码不容易与其他代码整合,因为此时不仅要保证所使用的函数名在全局命名空间中是唯一的,而且还要保证函数所依赖的数组在全局命名空间中也必须是唯一的。
而通过闭包可以使作为缓冲器的数组与依赖它的函数关联起来(优雅地打包),同时也能够维持在全局命名空间外指定的缓冲数组的属性名,免除了名称冲突和意外交互的危险。
其中的关键技巧在于通过执行一个单行(in-line)函数表达式创建一个额外的执行环境,而将该函数表达式返回的内部函数作为在外部代码中使用的函数。
此时,缓冲数组被定义为函数表达式的一个局部变量。
这个函数表达式只需执行一次,而数组也只需创建一次,就可以供依赖它的函数重复使用。
下面的代码定义了一个函数,这个函数用于返回一个HTML字符串,其中大部分内容都是常量,但这些常量字符序列中需要穿插一些可变的信息,而可变的信息由调用函数时传递的参数提供。
通过执行单行函数表达式返回一个内部函数,并将返回的函数赋给一个全局变量,因此这个函数也可以称为全局函数。
而缓冲数组被定义为外部函数表达式的一个局部变量。
它不会暴露在全局命名空间中,而且无论什么时候调用依赖它的函数都不需要重新创建这个数组。
/*声明一个全局变量–getImgInPositionedDivHtml–并将一次调用一个外部函数表达式返回的内部函数赋给它。
这个内部函数会返回一个用于表示绝对定位的DIV元素包围着一个IMG元素的HTML字符串,这样一来,所有可变的属性值都由调用该函数时的参数提供:
*/vargetImgInPositionedDivHtml=(function(){/*外部函数表达式的局部变量–buffAr–保存着缓冲数组。
这个数组只会被创建一次,生成的数组实例对内部函数而言永远是可用的因此,可供每次调用这个内部函数时使用。
其中的空字符串用作数据占位符,相应的数据将由内部函数插入到这个数组中:
*/varbuffAr=['
如果一个函数依赖于另一(或多)个其他函数,而其他函数又没有必要被其他代码直接调用,那么可以运用相同