JavaScript之作用域与闭包详解
前言:
JavaScript是一种应用非常广泛的语言,其也有一些自身特点和优势,本文重在讲述其作用域机制以及闭包,会从一些实例来探讨其机理。
作用域在JavaScript程序员日常使用中有不同的含义,如下所示:
- this绑定的值;
- this绑定的值定义的执行上下文;
- 一个变量的“生命周期”;
- 变量的值解析方案,或词法绑定。
下面将讲诉JavaScript作用域概念,由此引出变量值解析方案的一般想法,最后再探讨JavaScript里闭包这一重要知识点。
1.全局作用域
所有浏览器都支持 window 对象,它表示浏览器窗口,JavaScript 全局对象、函数以及变量均自动成为 window 对象的成员。所以,全局变量是 window 对象的属性,全局函数是 window 对象的方法,甚至 HTML DOM 的 document 也是 window 对象的属性之一。
全局变量是JavaScript里生命周期(一个变量多长时间内保持一定的值)最长的变量,其将跨越整个程序,可以被程序中的任何函数方法访问。
在全局下声明的变量都会在window对象下,都在全局作用域中,我们可以通过window对象访问,也可以直接访问。
1 var name = "jeri";
2 console.log(window.name); // 输出:jeri
3 console.log(name); // 输出:jeri
在JS中任何位置,没有使用var关键字声明的变量也都是全局变量。
1 function fun() {
2 name = "jeri";
3 alert(name);
4 }
5
6 console.log(name); // 输出:jeri
全局变量存在于整个函数的生命周期中,然而其在全局范围内很容易被篡改,我们在使 用全局变量时一定要小心,尽量不要使用全局变量。在函数内部声明变量没有使用var也会产生全局变量,会为我们造成一些混乱,比如变量覆盖等。所以,我们 在声明变量的任何时候最好都要带上var。
全局变量存在于程序的整个生命周期,但并不是通过其引用我们一定可以访问到全局变量。
2.词法作用域
词法作用域:函数在定义它们的作用域里运行,而不是在执行它们的作用域里运行。也就是说词法作用域取决于源码,通过静态分析就能确定,因此词法作用域也叫做静态作用域。with和eval除外,所以只能说JS的作用域机制非常接近词法作用域(Lexical scope)。词法作用域也可以理解为一个变量的可见性,及其文本表述的模拟值。
1 var name = "global";
2
3 function fun() {
4 var name = "jeri";
5 return name;
6 }
7
8 console.log(fun()); // 输出:jeri
9 console.log(name); // 输出:global
在通常情况下,变量的查询从最近接的绑定上下文开始,向外部逐渐扩展,直到查询到第一个绑定,一旦完成查找就结束搜索。就像上例,先查找离它最近的name="jeri",查询完成后就结束了,将第一个获取的值作为变量的值。
3.动态作用域
在编程实践中,最容易低估和过度滥用的概念就是动态作用域,因为很少有语言支持这种方式为绑定解析方案。
动态作用域与词法作用域相对而言的,不同于词法作用域在定义时确定,动态作用域在执行时确定,其生存周期到代码片段执行为止。动态变量存在于动态作用域中,任何给定的绑定的值,在确定调用其函数之前,都是不可知的。
在代码执行时,对应的作用域链常常是保持静态的。然而当遇到with语句、 call方法、apply方法和try-catch中的catch时,会改变作用域链的。以with为例,在遇到with语句时,会将传入的对象属性作为 局部变量来显示,使其便于访问,也就是说把一个新的对象添加到了作用域链的顶端,这样必然影响对局部标志符的解析。当with语句执行完毕后,会把作用域链恢复到原始状态。实例如下:
1 var name = "global";
2
3 // 使用with之前
4 console.log(name); // 输出:global
5
6 with({name:"jeri"}){
7 console.log(name); // 输出:jeri
8 }
9
10 // 使用with之后,作用域链恢复
11 console.log(name); // 输出:global
在作用域链中有动态作用域时,this引用也会变得更加复杂,不再指向第一次创建时的上下文,而是由调用者确定。比如在使用apply或call方法时,传入它们的第一个参数就是被引用的对象。实例如下:
1 function globalThis() {
2 console.log(this);
3 }
4
5 globalThis(); // 输出:Window {document: document,external: Object…}
6 globalThis.call({name:"jeri"}); // 输出:Object {name: "jeri"}
7 globalThis.apply({name:"jeri"},[]); // 输出:Object {name: "jeri"}
因为this引用是动态作用域,所以在编程过程中一定要注意this引用的变化,及时跟踪this的变动。
4.函数作用域
函数作用域,顾名思义就是在定义函数时候产生的作用域,这个作用域也可以称为局部作用域。和全局作用域相反,函数作用域一般只在函数的代码片段内可访问到,外部不能进行变量访问。在函数内部定义的变量存在于函数作用域中,其生命周期随着函数的执行结束而结束。实例如下:
1 var name = "global";
2
3 function fun() {
4 var name = "jeri";
5 console.log(name); // 输出:jeri
6
7 with ({name:"with"}) {
8 console.log(name); // 输出:with
9 }
10 console.log(name); // 输出:jeri
11 }
12
13 fun();
14
15 // 不能访问函数作用域
16 console.log(name); // 输出:global
5.没有块级作用域
不同于其他编程语言,在JavaScript里并没有块级作用域,也就是说在for、if、while等语句内部的声明的变量与在外部声明是一样的,在这些语句外部也可以访问和修改这些变量的值。实例如下:
1 function fun() {
2
3 if(0 < 2) {
4 var name = "jeri";
5 }
6 console.log(name); // 输出:jeri
7 name = "change";
8 console.log(name); // 输出:change
9 }
10
11 fun();
6.作用域链
JavaScript里一切皆为对象,包括函数。函数对象和其它对象一样,拥有可以通过代码访问的属性和一系列仅供JavaScript引擎访问的内部属性。其中一个内部属性是作用域,包含了函数被创建的作用域中对象的集合,称为函数的作用域链,它用来保证对执行环境有权访问的变量和函数的有序访问。
当一个函数创建后,它的作用域链会被创建此函数的作用域中可访问的数据对象填充。在全局作用域中创建的函数,其作用域链会自动成为全局作用域中的一员。而当函数执行时,其活动对象就会成为作用域链中的第一个对象(活 动对象:对象包含了函数的所有局部变量、命名参数、参数集合以及this)。在程序执行时,Javascript引擎会通过搜索上下文的作用域链来解析诸 如变量和函数名这样的标识符。其会从作用域链的最里面开始检索,按照由内到外的顺序,直到完成查找,一旦完成查找就结束搜索。如果没有查询到标识符声明,则报错。当函数执行结束,运行期上下文被销毁,活动对象也随之销毁。实例如下:
1 var name = 'global';
2
3 function fun() {
4 console.log(name); // output:global
5 name = "change";
6 // 函数内部可以修改全局变量
7 console.log(name); // output:change
8 // 先查询活动对象
9 var age = "18";
10 console.log(age); // output:18
11 }
12
13 fun();
14
15 // 函数执行完毕,执行环境销毁
16 console.log(age); // output:Uncaught ReferenceError: age is not defined
7.闭包
闭包是JavaScript的一大谜团,关于这个问题有很多文章进行讲述,然而依然有相当数量的程序员对这个概念理解不透彻。闭包的官方定义为:一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。
一句话概括就是:闭包就是一个函数,捕获作用域内的外部绑定。这些绑定是为之后使用而被绑定,即使作用域已经销毁。
自由变量
自由变量与闭包的关系是,自由变量闭合于闭包的创建。 闭包背后的逻辑是,如果一个函数内部有其他函数,那么这些内部函数可以访问在这个外部函数中声明的变量(这些变量就称之为自由变量)。然而,这些变量可以 被内部函数捕获,从高阶函数(返回另一个函数的函数称为高阶函数)中return语句实现“越狱”,以供以后使用。内部函数在没有任何局部声明之前(既不 是被传入,也不是局部声明)使用的变量就是被捕获的变量。实例如下:
1 function makeAdder(captured) {
2 return function(free) {
3 var ret = free + captured;
4 console.log(ret);
5 }
6 }
7
8 var add10 = makeAdder(10);
9
10 add10(2); // 输出:12
从上例可知,外部函数中的变量captured被执行加法的返回函数捕获,内部函数从未声明过captured变量,却可以引用它。
如果我们再创建一个加法器将捕获到同名变量captured,但有不同的值,因为这个加法器是在调用makeAdder之后被创建:
1 var add16 = makeAdder(16);
2
3 add16(18); // 输出:24
4
5 add10(10); // 输出:20
如上述代码所示,每一个新的加法器函数都保留了自己创建时捕获的captured实例。
变量遮蔽
在JavaScript中,当变量在一定作用域内声明,然后在另一个同名变量在一个较低的作用域声明,会发生变量的遮蔽。实例如下:
1 var name = "jeri";
2 var name = "tom";
3
4 function glbShadow() {
5 var name = "fun";
6
7 console.log(name); // 输出:fun
8 }
9
10 glbShadow();
11
12 console.log(name); // 输出:tom
当在一个变量同一作用域内声明了多次时,最后一次声明会生效,会遮蔽以前的声明。
变量声明的遮蔽很好理解,然而函数参数的遮蔽就略显复杂。例如:
1 var shadowed = 0;
2
3 function argShadow(shadowed) {
4 var str = ["Value is",shadowed].join(" ");
5 console.log(str);
6 }
7
8 argShadow(108); // output:Value is 108
9
10 argShadow(); // output:Value is
函数argShadow的参数shadowed覆盖了全局作用域内的同名变量。即使没有传递任何参数,仍然绑定的是shadowed,并没有访问到全局变量shadowed = 0。
任何情况下,离得最近的变量绑定优先级最高。实例如下:
1 var shadowed = 0;
2
3 function varShadow(shadowed) {
4 var shadowed = 123;
5 var str = ["Value is",shadowed].join(" ");
6 console.log(str);
7 }
8
9 varShadow(108); // output:Value is 123
10
11 varShadow(); // output:Value is 123
varShadow(108)打印出来的并不是108而是123,即使没有参数传入也是打印的123,先访问离得最近的变量绑定。
遮蔽变量同样发生在闭包内部,实例如下:
1 function captureShadow(shadowed) {
2
3 console.log(shadowed); // output:108
4
5 return function(shadowed) {
6
7 console.log(shadowed); // output:2
8 var ret = shadowed + 1;
9 console.log(ret); // output:3
10 }
11 }
12
13 var closureShadow = captureShadow(108);
14
15 closureShadow(2);
在编写JavaScript代码时,因为变量遮蔽会使很多变量绑定超出我们的控制,我们应尽量避免变量遮蔽,一定要注意变量命名。
典型误区
下面是一个非常典型的问题,曾经困扰了很多人,下面也来探讨下。
1 var test = function() {
2 var ret = [];
3
4 for(var i = 0; i < 5; i++) {
5 ret[i] = function() {
6 return i;
7 }
8 }
9
10 return ret;
11 };
12 var test0 = test()[0]();
13 console.log(test0); // 输出:5
14
15 var test1 = test()[1]();
16 console.log(test1); //输出:5
从上面的例子可知,test这个函数执行之后返回一个函数数组,表面上看数组内的 每个函数都应该返回自己的索引值,然而并不是如此。当外部函数执行完毕后,外部函数虽然其执行环境已经销毁,但闭包依然保留着对其中变量绑定的引用,仍然 驻留在内存之中。当外部函数执行完毕之后,才会执行内部函数,而这时内部函数捕获的变量绑定已经是外部函数执行之后的最终变量值了,所以这些函数都引用的 是同一个变量i=5。
下面有个更优雅的例子来表述这个问题:
1 for(var i = 0; i < 5; i++) {
2
3 setTimeout(function() {
4 console.log(i);
5 }, 1000);
6 }
7
8 // 每隔1秒输出一个5
按照我们的推断,上例应该输出1,2,3,4,5。然而,事实上输出的是连续5个5。为什么出现这种诡异的状况呢?其本质上还是由闭包特性造成的,闭包可以捕获外部作用域的变量绑定。
上面这个函数片段在执行时,其内部函数和外部函数并不是同步执行的,因为当调用setTimeout时会有一个延时事件排入队列,等所有同步代码执行完毕后,再依次执行队列中的延时事件,而这个时候 i 已经 是5了。
那怎么解决这个问题呢?我们是不是可以在每个循环执行时,给内部函数传进一个变量的拷贝,使其在每次创建闭包时,都捕获一个变量绑定。因为我们每次传参不同,那么每次捕获的变量绑定也是不同的,也就避免了最后输出5个5的状况。实例如下:
1 for(var i = 0; i < 5; i++) {
2
3 (function(j) {
4
5 setTimeout(function() {
6 console.log(j);
7 }, 1000);
8 })(i);
9 }
10
11 // 输出:0,1,2,3,4
闭包具有非常强大的功能,函数内部可以引用外部的参数和变量,但其
参数和变量不会被垃圾回收机制回,常驻内存,会增大内存使用量,使用不当很容易造成内存泄露。但,闭包也是
javascript
语言的一大特点,主要应用闭包场合为:设计私有的方法和变量。
模拟私有变量
从上文的叙述我们知道,变量的捕获发生在创建闭包的时候,那么我们可以把闭包捕获到的变量作为私有变量。实例如下:
1 var closureDemo = (function() {
2 var PRIVATE = 0;
3
4 return {
5 inc:function(n) {
6 return PRIVATE += n;
7 },
8 dec:function(n) {
9 return PRIVATE -= n;
10 }
11 };
12 })();
13
14 var testInc = closureDemo.inc(10);
15 //console.log(testInc);
16 // 输出:10
17
18 var testDec = closureDemo.dec(7);
19 //console.log(testDec);
20 // 输出:3
21
22 closureDemo.div = function(n) {
23 return PRIVATE / n;
24 };
25
26 var testDiv = closureDemo.div(3);
27 console.log(testDiv);
28 //输出:Uncaught ReferenceError: PRIVATE is not defined
自执行函数closureDemo执行完毕之后,自执行函数作用域和 PRIVATE随之销毁,但PRIVATE仍滞留在内存中,也就是加入到closureDemo.inc和closureDemo.dec的作用域链中, 闭包也就完成了变量的捕获。但之后新加入的closureDemo.div并不能在作用域中继续寻找到PRIVATE了。因为,函数只有被调用时才会执行 函数里面的代码,变量的捕获也只发生在创建闭包时,所以之后新加入的div方法并不能捕获PRIVATE。
创建特权方法
通过闭包我们可以创建私有作用域,那么也就可以创建私有变量和私有函数。创建私有函数的方式和声明私有变量方法一致,只要在函数内部声明函数就可以了。当然,既然可以模拟私有变量和私有函数,我们也可以利用闭包这个特性,创建特权方法。实例如下:
1 (function() {
2
3 // 私有变量和私有函数
4 var privateVar = 10;
5
6 function privateFun() {
7 return false;
8 };
9
10 // 构造函数
11 MyObj = function() {
12
13 };
14
15 // 公有/特权方法
16 MyObj.prototype.publicMethod = function() {
17 privateVar ++;
18 return privateFun();
19 }
20 })();
上面这个实例创建了一个私有作用域,并封装了一个构造函数和对应的方法。需要注意 的是在上面的实例中,在声明MyObj这个函数时,使用的是不带var的函数表达式,我们希望产生的是一个全局函数而不是局部的,不然我们依然在外部无法 访问。所以,MyObj就成为了一个全局变量,能够在外部进行访问,我们在原型上定义的方法publicMethod也就可以使用,通过这个方法我们也就 可以访问私有函数和私有变量了。
总的来说,因为闭包奇特的特性,可以通过它实现一些强大的功能。但,我们在日常编程中,也要正确的使用闭包,要时刻注意回收不用的变量,避免内存泄露。