闭包、作用域和作用域链
# 一、作用域与作用域链
# 作用域
- 在JavaScript中,我们可以将作用域定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。
这里的标识符,指的是变量名或者函数名
- JavaScript中只有全局作用域与函数作用域
函数作用域
JavaScript 不是块级作用域而是通过函数来管理作用域,在函数内部声明的变量只能在这个函数内部使用
- 块级作用域测试
if(true){
// 只限于var声明的变量
var test=1;
}
console.log(test); //1
2
3
4
5
通过上面两个例子我们可以看出 在函数里声明的变量在外边是不能用的、而块级是可以的
- 函数级作用域测试
(function() {
var test = 1;
})();
console.log(test); //Uncaught ReferenceError: test is not defined
2
3
4
全局变量
全局变量是在函数外定义的变量或者未定义就使用的变量(隐式全局变量)又或是直接用window.属性名=值添加的变量,每一个javascript运行环境都有一个全局对象,例如在浏览器中window就指向这个全局对象(nodeJS是global),其实我们创建的每一个全局变量或者全局方法都是全局对象的一个属性和方法
var a=5;
(function(){
console.log(a);
console.log(window.a);
})();//5 5
2
3
4
5
由此可见 全局变量其实就是全局对象上的一个属性、我们甚至可以通过window来添加一个全局变量
(function(){
window.a=7;
})();
console.log(a);//7
2
3
4
变量提升
在Javascript中,函数及变量的声明都将被提升到函数的最顶部,也就是说我们可以先使用后声明。
但函数表达式和变量表达式只是将函数或者变量的声明提升到函数顶部,函数表达式和变量的初始化将不被提升我们来看一下案例
fun();//hello,world哥
function fun(){
console.log("hello,world哥");
}
2
3
4
方法先使用后声明这显然是没有问题的、那么我们接着看变量的提升
console.log(a);//undefined
console.log(b);//Uncaught ReferenceError: b is not defined
var a=5;
2
3
a的声明被提升,但是初始化值并没有提升、打印a的值为undefined、说明有这个变量但是它没有值、没有声明的b直接报了错,说明变量的声明是会提升的但是变量的初始化不会提升
var fun=function(){
console.log("本事啦,我的弟");
}
fun();//本事啦,我的弟
2
3
4
显然没有任何问题,我们把声明和使用颠倒位置
fun();
var fun=function(){
console.log("本事啦,我的弟");//Uncaught TypeError: fun is not a function
}
2
3
4
我们发现报错了,但如果我们直接使用fun则会报一个"Uncaught ReferenceError: fun is not defined"的错误(和不声明fun报错是不一样的),其实fun也是一个变量,只不过他是function(){console.log("本事啦,我的弟");}的一个引用,fun的声明被提升了,但是初始化没有被提升
var a=1;
(function(){
console.log(a);
var a=2;
console.log(a);
})();
2
3
4
5
6
打印结果:undefined 2 大家应该想的是1,2 其实这个结果就是变量提升的原因
JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。
# 作用域链
先来看下执行上下文生命周期
我们知道函数在调用激活时,会开始创建对应的执行上下文,在执行上下文生成的过程中,变量对象,作用域链,以及this的值会分别被确定。
作用域链,是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。
var a = 20;
function test() {
var b = a + 10;
function innerTest() {
var c = 10;
return b + c;
}
return innerTest();
}
test();
2
3
4
5
6
7
8
9
10
11
12
13
14
在上面的例子中,全局,函数test,函数innerTest的执行上下文先后创建。我们设定他们的变量对象分别为VO(global),VO(test), VO(innerTest)。而innerTest的作用域链,则同时包含了这三个变量对象,所以innerTest的执行上下文可如下表示。
innerTestEC = {
VO: {...}, // 变量对象
scopeChain: [VO(innerTest), VO(test), VO(global)], // 作用域链
}
2
3
4
我们可以直接用一个数组来表示作用域链,数组的第一项scopeChain[0]为作用域链的最前端,而数组的最后一项,为作用域链的最末端,所有的最末端都为全局变量对象。
很多人会误解为当前作用域与上层作用域为包含关系,但其实并不是。以最前端为起点,最末端为终点的单方向通道我认为是更加贴切的形容。如图。
注意,因为变量对象在执行上下文进入执行阶段时,就变成了活动对象,因此图中使用了AO来表示。Active Object
是的,作用域链是由一系列变量对象组成,我们可以在这个单向通道中,查询变量对象中的标识符,这样就可以访问到上一层作用域中的变量了。
# 二、闭包
闭包是一种特殊的对象。
它由两部分组成。执行上下文(代号A),以及在该执行上下文中创建的函数(代号B)。
当B执行时,如果访问了A中变量对象中的值,那么闭包就会产生。
在大多数理解中,包括许多著名的书籍,文章里都以函数B的名字代指这里生成的闭包。而在chrome中,则以执行上下文A的函数名代指闭包。
因此我们只需要知道,一个闭包对象,由A、B共同组成,在以后的篇幅中,我将以chrome的标准来称呼。
// demo01
function foo() {
var a = 20;
var b = 30;
function bar() {
return a + b;
}
return bar;
}
var bar = foo();
bar();
2
3
4
5
6
7
8
9
10
11
12
13
14
上面的例子,首先有执行上下文foo,在foo中定义了函数bar,而通过对外返回bar的方式让bar得以执行。当bar执行时,访问了foo内部的变量a,b。因此这个时候闭包产生。
JavaScript拥有自动的垃圾回收机制,关于垃圾回收机制,有一个重要的行为,那就是,当一个值,在内存中失去引用时,垃圾回收机制会根据特殊的算法找到它,并将其回收,释放内存。
而我们知道,函数的执行上下文,在执行完毕之后,生命周期结束,那么该函数的执行上下文就会失去引用。其占用的内存空间很快就会被垃圾回收器释放。可是闭包的存在,会阻止这一过程。
先来一个简单的例子。
var fn = null;
function foo() {
var a = 2;
function innnerFoo() {
console.log(a);
}
fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}
function bar() {
fn(); // 此处的保留的innerFoo的引用
}
foo();
bar(); // 2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在上面的例子中,foo()执行完毕之后,按照常理,其执行环境生命周期会结束,所占内存被垃圾收集器释放。但是通过fn = innerFoo,函数innerFoo的引用被保留了下来,复制给了全局变量fn。这个行为,导致了foo的变量对象,也被保留了下来。于是,函数fn在函数bar内部执行时,依然可以访问这个被保留下来的变量对象。所以此刻仍然能够访问到变量a的值。
这样,我们就可以称foo为闭包。
下图展示了闭包foo的作用域链。
闭包foo的作用域链,图中标题写错了,请无视
我们可以在chrome浏览器的开发者工具中查看这段代码运行时产生的函数调用栈与作用域链的生成情况。如下图。
在上面的图中,红色箭头所指的正是闭包。其中Call Stack为当前的函数调用栈,Scope为当前正在被执行的函数的作用域链,Local为当前的局部变量。
所以,通过闭包,我们可以在其他的执行上下文中,访问到函数的内部变量。比如在上面的例子中,我们在函数bar的执行环境中访问到了函数foo的a变量。个人认为,从应用层面,这是闭包最重要的特性。利用这个特性,我们可以实现很多有意思的东西。
对上面的例子稍作修改,如果我们在函数bar中声明一个变量c,并在闭包fn中试图访问该变量,运行结果会抛出错误。
var fn = null;
function foo() {
var a = 2;
function innnerFoo() {
console.log(c); // 在这里,试图访问函数bar中的c变量,会抛出错误
console.log(a);
}
fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}
function bar() {
var c = 100;
fn(); // 此处的保留的innerFoo的引用
}
foo();
bar();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 闭包的应用场景
在实践中,闭包有两个非常重要的应用场景。分别是模块化与柯里化。
在函数式编程中,利用闭包能够实现很多炫酷的功能,柯里化便是其中很重要的一种。
模块
模块是闭包最强大的一个应用场景。
(function () {
var a = 10;
var b = 20;
function add(num1, num2) {
var num1 = !!num1 ? num1 : a;
var num2 = !!num2 ? num2 : b;
return num1 + num2;
}
window.add = add;
})();
add(10, 20);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在上面的例子中,我使用函数自执行的方式,创建了一个模块。add是模块对外暴露的一个公共方法。而变量a,b被作为私有变量。在面向对象的开发中,我们常常需要考虑是将变量作为私有变量,还是放在构造函数中的this中,因此理解闭包,以及原型链是一个非常重要的事情。
此图中可以观看到当代码执行到add方法时的调用栈与作用域链,此刻的闭包为外层的自执行函数
为了验证自己有没有搞懂作用域链与闭包,这里留下一个经典的思考题,常常也会在面试中被问到。
利用闭包,修改下面的代码,让循环输出的结果依次为1, 2, 3, 4, 5
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log(i);
}, i*1000 );
}
// 6 6 6 6 6
2
3
4
5
6
for(var i=1; i<=5; i++){
(function(i){
setTimeout( function timer() {
console.log(i);
}, i*1000 );
})(i)
}
// 1 2 3 4 5
2
3
4
5
6
7
8