JavaScript 闭包
概念
闭包(closure)是 Javascript语言 的一个难点,也是它的特色,很多高级应用都需要依靠闭包实现。有不少的开发人员总是分不清匿名函数和闭包这两个的概念,因此经常混用。匿名函数 是指创建一个函数并将它赋值给变量,这种情况下创建的函数叫做匿名函数(anonymous function。),因为 function 关键字后面没有标识符。匿名函数的 name 属性是空字符串。闭包是指有权访问另一个函数作用域(当某个函数被调用时,会创建一个执行环境及相应的作用域链。)中的变量的函数。 换言之,闭包是指在 Javascript 中,内部函数总是可以访问其所在外部函数中申明的参数和变量,即使在其外部函数被返回(寿命终结)之后。它是一种特殊的对象,由两部分构成:函数,以及创建该函数的环境。创建闭包的常见方式,就是在一个函数内创建个另一函数。
变量(variable)
变量 是用于存储信息的"容器"。要理解闭包,首先必须理解 Javascript 特殊的变量作用域。JavaScript 变量可以是局部变量或全局变量。在web页面中全局变量属于 window 对象,
全局变量可应用于页面上的所有脚本。局部变量只能用于定义它函数内部,对于其他的函数或脚本代码是不可用的。
全局和局部变量即便名称相同,它们也是两个不同的变量。修改其中一个,不会影响另一个的值。对此我们需要知道的是Javascript语言的特殊之处,就在于函数内部可以直接读取全局变量。而正常在函数外部却无法读取函数内的局部变量。
变量对象(variable object) 是一个抽象的对象,用于储存执行环境中的变量、函数声明、函数参数。在全局作用域下变量对象的值指向全局对象,在浏览器中为 window。我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。
执行环境(execution context)
执行环境 是 Javascript 中最为重要的一个概念。执行环境(也称 执行上下文)始终是this关键字的值 。其定义了变量或函数有权访问的其他数据,决定了他们各自的行为。每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。
全局执行环境是最外围的一个执行环境。在Web浏览器中,全局环境被认为是 window对象,因此所有全局变量和函数都是作为window对象的属性和方法创建的。
当 JavaScript解释器 初始化执行代码时,它首先默认进入全局执行环境,从此刻开始,函数的每次调用都会创建一个新的执行环境。Javascript 引擎以推栈的方式(后进先出)处理执行环境,栈底永远是全局环境,栈顶是当前执行的上下文。某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也会随之被销毁。全局执行环境直到应用程序退出(例如关闭网页或浏览器)时才会被销毁。
执行环境分为两个阶段:
执行上下文初始阶段,变量对象按如下的顺序填充和:
this 赋值
函数参数(若未传入,初始化值为 undefined)。
函数声明(每找到一个函数声明,就在变量对象中用函数名建立一个属性,值指向该函数在内存中的地址的一个引用人,若以函数名为属性名已经存在在变量对象中,则会被覆盖)。
变量声明(初始化值为 undefined,若命名重复则忽略)。
示例代码
console.log(text);//报错
console.log(text);//undefined
var text;
console.log(text);//undefined
var text = "something";
console.log(text)
//前者报错,因为a未定义。后二者输出都是undefined,即浏览器在执行console.log(text) 时,已经知道了text是undefined,但却不知道text的值。
console.log(text);//function text(){}
function text(){}//函数声明
console.log(text);//undefined
var text = function text(){};//函数表达式
console.log(this)//window
以上所有的例子说明在执行函数中的代码前,已经完成了上面所列的执行上下文初始阶段的任 务。
代码执行阶段:执行函数中的代码,给变量对象中的变量属性赋值。
this 关键字
与其他语言相比,函数的 this 关键字在 JavaScript 中的表现略有不同,在绝大多数情况下,函数的调用方式决定了 this 的值。this 不能在执行期间被赋值,并且在每次函数被调用时 this 的值也可能会不同。ES5 引入了 bind方法 来设置函数的this值,而不用考虑函数如何被调用的,ES2015 引入了支持 this 词法解析的箭头函数(它在闭合的执行上下文内设置this的值)。此外,在严格模式和非严格模式之间也会有一些差别。下面主要就非严格模式下this关键字的值做下讲解。
全局上下文
在全局执行上下文中(在任何函数体外部)。 无论是否在严格模式下,this 都指代全局对象。在浏览器中即 window。
函数上下文 在函数内部,this的值取决于函数被调用的方式 。
直接调用。 因为 this 的值不是通过调用设置的,所以 this 的值默认指向全局对象。在严格模式下,如果 this 未在执行的上下文中定义,那它将会默认为 undefined 。
作为对象的一个方法。 当以对象里的方法的方式调用函数时,它们的 this 是调用该函数的对象。示例代码
var obj = { text: 3, f: function() { return this.text; } }; console.log(obj.f()); // logs 3
这样的行为,不受函数定义方式或位置的影响。var obj = {text: 3}; function demo() { return this.text; } obj.f = demo; console.log(obj.f()); // logs 3
如果f函数不作为obj的一个属性被调用,会是什么结果呢?var obj = { text: 3, f: function() { return this.text; } }; var objTwo = obj.f; console.log(objTwo()); //undefined此时this的值就是window。
构造函数。 当一个函数用作构造函数时(使用new关键字),它的 this 被绑定到正在构造的新对象。构造函数的函数名第一个字母大写(规则约定)。 示例代码
function demo(){ this.text = 3; } var demoTwo = new demo(); console.log(demoTwo.text); // logs 3
虽然构造器返回的默认值是this所指的那个对象,但它仍可以手动返回其他的对象(如果返回值 不是一个对象,则返回this对象)。function demo(){ this.text = 3; return {text:5} } var demoTwo = new demo(); console.log(demoTwo.text); // logs 5
原型链中的 this。 相同的概念在定义在原型链中的方法也是一致的。如果该方法存在于一个对象的原型链上,那么 this 指向的是调用这个方法的对象,就好像该方法本来就存在于这个对象上。示例代码
var obj = { demo : function(){ return this.textOne + this.textTwo; } }; var objTwo = Object.create(obj); objTwo.textOne = 3; objTwo.textTwo = 5; console.log(objTwo.demo()); // 8
对象objTwo没有属于它自己的demo属性,它的demo属性继承自它的原型。最终在obj中找到demo 属性的查找过程首先从objTwo.demo的引用开始,所以函数中的this指向objTwo。也就是说,因 为demo是作为objTwo的方法调用的,所以它的this指向了objTwo。
call 和 apply方法。 如果要想把 this 的值从一个 context 传到另一个,就要用 call,或者 apply方法。call()方法: 调用一个函数,其具有一个指定的 this 值和分别地提供的参数。apply()方法: 其具有一个指定的 this 值和作为一个数组(或类数组的对象)提供的参数。call() 和 apply()方法属于间接调用(indirect invocation)。当一个函数的函数体中使用了 this 关键字时,通过 call()方法 和 apply()方法调用,this 的值可以绑定到一个指定的对象上。如果传递的 this 值不是一个对象,JavaScript 将会尝试使用内部 ToObject 操作将其转换为对象。 示例代码
function demo(c, d) { return this.a + this.b + c + d; } var obj = {a: 1, b: 3}; // 第一个参数是作为‘this’使用的对象 // 后续参数作为参数传递给函数调用 demo.call(obj, 5, 7); // 1 + 3 + 5 + 7 = 16 // 第一个参数也是作为‘this’使用的对象 // 第二个参数是一个数组,数组里的元素用作函数调用中的参数 demo.apply(obj, [10, 20]); // 1 + 3 + 10 + 20 = 34
bind 方法。 ECMAScript 5 引入了 Function.prototype.bind。调用 demo.bind(某个对象)会创建一个与 demo 具有相同函数体和作用域的函数,但是在这个新函数中,this 将永久地被绑定到了 bind 的第一个参数,无论这个函数是如何被调用的。示例代码
function demo(){ return this.a; } //this被固定到了传入的对象上 var objTwo = demo.bind({a:"azerty"}); console.log(objTwo()); // azerty var objThree = objTwo.bind({a:'yoo'}); //bind只生效一次! console.log(objThree()); // azerty var obj = {a:37, demo:demo, objTwo:objTwo, objThree:objThree}; console.log(obj.demo(), obj.objTwo(), obj.objThree()); // 37, azerty, azerty
getter 与 setter 中的 this。 相同的概念也适用时的函数作为一个 getter 或者 一个 setter 调用。用作 getter 或 setter 的函数都会把 this 绑定到正在设置或获取属性的对象。示例代码
function demo() { return this.textOne + this.textTwo + this.textThree; } var obj = { textOne: 1, textTwo: 2, textThree: 3, get average() { return (this.textOne + this.textTwo + this.textThree) / 3; } }; Object.defineProperty(obj, 'demo', { get: demo, enumerable: true, configurable: true}); console.log(obj.average, obj.demo); // logs 2, 6
箭头函数。 箭头函数表达式的语法比函数表达式更短,并且不绑定自己的 this,arguments,super 或 new.target。这些函数表达式最适合用于非方法函数,并且它们不能用作构造函数。 在箭头函数中,this 是根据当前的词法作用域来决定的,就是说,箭头函数会继承外层函数调用的 this 绑定(无论this绑定到什么)。在全局作用域中,它会绑定到全局对象上。
作为一个 DOM事件处理函数。 当函数被用作事件处理函数时,它的 this 指向触发事件的元素(一些浏览器在使用非addEventListener的函数动态添加监听函数时不遵守这个约定)。
作为一个内联事件处理函数。 当代码被内联处理函数调用时,它的 this 指向监听器所在的 DOM元素。
作用域(scope)和作用域链(scope chain)
ES6 之前 JavaScript 没有块级作用域,除了全局作用域之外,只有函数可以创建作用域
示例代码 。
ES6 开始新增加了一个 let,可以在{}, if, for里声明。用法同 var,但作用域限定在块级,let 声明的变量不存在变量提升。在代码块内,在let声明之前使用变量都是不可用的。
每个环境能够访问的标识符集皆可称为“作用域 ”。当代码在一个环境中执行时,会创建变量对象的一个 作用域链 。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问,且能隔离变量,使得不同作用域下同名变量不会有冲突。 作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象作为变量对象。活动对象在最开始时只包含一个变量,即 arguments对象 (这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境。这样一直延续到全局环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。
标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直到找到标识符。如果找不到标识符,通常会导致错误发生。
(function(){ for (var i = 0; i<3; i++) { console.log(i)//输出0,1,2 } alert(i)//弹出3 })()
运行以上代码,执行for循环会输出3个值,分别为0,1,2。执行到2意味着for循环结 束,其他语言下i就会销毁,执行alert(i)按理说会为undefined,但在js中i会一直存在函数 中,也就是说执行到alert(i)时会弹出 3。而这个3是经过for循环累加后的i。
(function(){ (function(){ for (var i = 0; i<3; i++) { console.log(i)//输出0,1,2 } })() alert(i)//报错:i is not defined })()
运行以上代码,执行for循环会输出3个值,分别为0,1,2。执行到2意味着for循环结 束,因为for循环是在第二个(里面)匿名函数的作用域内定义的,所以报告了一个错误,这样 就很类似其他语言中的for循环中的i在执行循环后被销毁。
作用域和执行环境的关系
作用域只是一个“地盘”,一个抽象的概念,其中没有变量。要通过作用域对应的执行上下文环境来获取变量的值。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。所以,作用域中变量的值是在执行过程中产生的确定的。
所以对于函数来说,上下文环境是在调用时创建的,而作用域却是在函数创建时就确定了。而且一个作用域下可能包含若干个上下文环境。如果要查找一个作用域下某个变量的值,就需要找到这个作用域对应的执行上下文环境,再在其中寻找变量的值。
闭包(closure)
闭包是一个函数和函数所声明的词法环境的结合。
首先我们来看下面的一段代码:
1 function demo(){ 2 var name = "Anani"; 3 return function displayName(){ 4 console.log(name); 5 } 6 } 7 8 var demoTest = demo(); 9 demoTest();
运行这段代码,将会弹出字符串“Anani",我们知道函数中的局部变量仅在函数的执行期间可用,此处 displayName 函数在执行前被从其外围函数中返回了,name 变量应该不再可用,然而因为 demoTest()是一个闭包,由 displayName 函数和闭包创建时存在的 "Anani" 字符串形成,所以它仍然可以访问 name 变量。
要理解上面的闭包首先要对前面讲解的概念要熟悉,特别是执行环境和作用域,这是参透闭包的关键。
当 JavaScript解释器 初始化执行代码时,它首先默认进入全局执行环境,此时全局环境是活动状态,并对其中的变量进行赋值。执行第 8 行代码时,调用 demo(),产生 demo()的执环环境,压栈,并设置为活动状态。当 demo()调用完成,按理说应该销毁 demo()的执行环境,但是调用 demo()时返回了 displayName 函数,函数可以创建一个独立的作用域,而且刚好返回的函数中有一个 name 自由变量要在 demo()的执行环境中取值,因此这里的 demo()的执行环境不会被销毁,依然存在于执行环境栈中。
当代码执行到第 9 行时,执行 demoTest(),即执行 displayName(),创建 displayName()的执行环境,并将其设置为活动状态。因为在 displayname()中 name 是自由变量,需要向创建该函数的作用域中查找,最终在 demo()的执行环境中找到 name 的值为“Anani”。执行完第 9 行的代码后执行环境开始反向销毁。
闭包的缺陷
闭包的缺点就是常驻内存会增大内存使用量,并且使用不当很容易造成内存泄露。
如果不是因为某些特殊任务而需要闭包,在没有必要的情况下,在其它函数中创建函数是不明智的,因为闭包对脚本性能具有负面影响,包括处理速度和内存消耗。