对js的广大初学者来说,闭包绝对是个难点。而且经常出现今天感觉懂了,明天就又不懂了的情况。本文就尝试从我自己的学习体会出发,尝试把这个概念讲清楚。
简单来说,闭包是指有权访问另一个函数作用域中的变量的函数。
下面这个函数是一个根据初始值自加的函数。
function count(init) {
return function() {
init++;
return init;
}
}
var f1 = count(1);
console.log(f1()); //2
console.log(f1()); //3
var f2 = count(11);
console.log(f2()); //12
console.log(f2()); //13
上面就是一个闭包的例子。count函数在执行完之后返回了内部匿名函数,并赋值给f1和f2,f1和f2依然可以访问count函数中init变量,f1和f2就是两个闭包。
要搞清楚其中的细节,我们就必须理解f1和f2在第一次调用的时候到底发生了什么。我们首先来看两个基本观念:执行环境及作用域。
执行环境及作用域
执行环境
执行环境(execution context,有时直接简称为“环境”)是ECMAScirpt中最为重要的一个概念,用来描述js代码执行的抽象概念。执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。换句话说,所有的js都是在某个执行环境中运行的,我们可以把执行环境想成一个执行js代码的盒子。每个执行环境都有一个与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。
全局执行环境是最外围的一个执行环境,根据ECMAScript实现所在的宿主环境的不同,表示执行环境的对象也不一样。在Web浏览器中,全局执行环境被认为是window对象,因此所有全局变量和函数都是作为window对象的属性和方法创建的。某个执行环境的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁。
每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入到环境栈中。而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。
作用域链
当js代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain)。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端, 始终是当前执行代码所在环境的变量对象. 如果这个环境是一个函数, 则将其活动对象(activation object)作为变量对象. 活动对象在最开始时只包含一个变量, 即arguments
对象(这个对象在全局环境中是不存在的). 作用域链中的下一个变量对象来自包含(外部)环境, 而再下一个变量对象则来自下一个包含环境. 这样一直延续到全局执行环境.
标识符解析是沿着作用域链一级一级地搜索标识符的过程. 搜索过程始终从作用域链的前端开始, 然后逐级地向后回溯, 直至找到标识符为止(如果找不到标识符, 通常导致错误发生)
闭包
我们再来看看我们的demo
function count(init) {
return function() {
init++;
return init;
}
}
var f1 = count(1);
console.log(f1()); //2
console.log(f1()); //3
f1之所以还能访问 变量 init, 是因为f1函数的作用域链包含 count函数的作用域.
下面是最关键的部分:
- 在创建count()函数时,会创建一个预先包含全局变量对象的作用域链,这个作用域链被保存在内部的[[Scope]]属性中。
- 当调用count()函数时,会为函数创建一个执行环境,然后通过复制函数的[[Scope]]属性中的对象构建起执行环境的作用域链. 此后, count()函数的活动对象被创建, 并被推入到执行环境作用域链的前端.
- 在count()函数内部的匿名函数会将count()函数的执行环境的作用域链初始化成自己的作用域链中. 这样匿名函数就可以访问count()函数中的所有变量了.
- 当count()函数中的匿名函数最终返回并赋值给f1, f1的作用域链就包含全局变量对象和count()函数的活动对象, 所以count()函数的活动对象不会被销毁. 换句话说, count()函数执行完毕后, count()函数的执行环境被销毁, 但是count()函数的活动对象直到f1被销毁后, 才会被销毁.
到这里我们就明白了, 只要你在一个函数内部定义了另一个函数, 闭包就产生了.
this对象
在闭包中使用this对象会遇到一些问题. 我们知道this对象指向了当前代码的执行环境. 也就是说, 在全局环境中this等于window(浏览器环境), 当被当做某个对象的方法调用时, this指向的就是那个方法.
当然, 也可以通过apply()和call()改变函数的执行环境
我们看一下下面的例子:
var name = "The Window";
var object = {
name : "My Object",
getNameFunc : function () {
return function () {
return this.name;
};
}
};
console.log(object.getNameFunc()());
这时候return回来的是”The Window”, 而不是”My Object”
我们分解一下来看:
- object.getNameFunc()执行时, getNameFunc()是作为object的方法执行的, this指向object, 然后返回一个匿名函数.
- 这个匿名函数在调用的时候, 实际上是在全局环境中执行的, 所以this指向全局环境, 返回this.name就是”The Window”
如果我们想返回”My Object”该咋办? 那我们就得想着怎么把第一步中的this传到第二步的匿名函数中.
getNameFunc : function () {
var that = this;
return function () {
return that.name;
};
}
在定义匿名函数前, 我们把this保存在that变量中, 这样闭包也可以访问that变量.
模仿块级作用域
我们知道Javascript中没有块级作用域, 也就是定义块中变量, 它的作用域是当前函数, 和块没有关系. 我们可以利用函数的作用域来模仿块级作用域.
!function() {
var i = 10;
console.log(i); //10
}();
console.log(i+1); //i is not defined
我们创建了一个函数并立即调用它, 这样其中的代码执行了, 而且因为函数执行完毕, 它的执行环境和其中的变量对象都会被销毁, 所以下面的代码提示i is not defined
封装
面向对象的三大基石之一就是封装. 封装简单来说就是只公开代码单元的对外接口, 而隐藏内部的具体实现.
Javascript是面向对象的语言, 那它如何实现封装呢? 我们知道Javascript中没有私有成员的概念, 所有对象的属性都是公开的. 但是呢, Javascript有私有变量的概念, 函数内部的变量外部是无法访问的. 这里, 我们就可以利用闭包来完成封装.
function Account() {
var balance = 0;
function save(money){
balance += money;
query();
}
function draw(money){
if(money > balance){
balance = 0;
}
else{
balance -= money;
}
query();
}
function query(){
console.log("Your balance is " + balance);
}
return {
Save : function(money){
save(money);
},
Draw : function(money){
draw(money);
}
}
}
var acount = new Account();
acount.Save(10);
acount.Draw(5);
acount.save(10); //save is not a function
console.log(acount.balance); //undefined
例子是个银行账户对象, 对外公开了存钱和取钱两种操作. 这里用工厂模式来创建对象, 用构造函数也是同样的道理. 我们把有权访问私有变量和方法的公有方法成为特权方法(Save和Draw方法)
呼呼, 好像我想说的都说完了, 下面开始一分钟满分作文时间, 来回顾一下我们都学到了什么:
- 当在函数内部定义了其他函数时, 就创建了闭包. 闭包有权访问函数内部的所有变量.
-闭包的作用域链, 包含着自己的作用域, 包含函数的作用域和全局的作用域
-通常, 函数的作用域和变量会在函数调用结束后销毁.
-但是, 当函数返回了闭包时, 函数的作用域会一直保存直到闭包不存在为止 - 创建并立即调用函数可以模仿块级作用域
- 闭包可以实现封装