JavaScript中的作用域与变量提升

1.静态作用域  

变量的作用域是程序源代码中的一个区域,在这个区域中变量是有定义的。不同的程序设计语言会采用静态作用域或者动态作用域,JavaScript采用了静态作用域。在使用静态作用域的语言中,由一个声明引入的标识符在这个声明所在的作用域中可见,而且在其作用域嵌套的每个作用域中也可见。当内部被嵌套的作用域声明了与外部作用域同名的标识符,则外部作用域中的标识符会被覆盖。为了找到某个给定标识符所引用的对象,编译器首先会在当前最内层的作用域中查找。如果此时找到了一个对应的声明,也就可以找到该标识符所引用的对象,否则就会在上一层作用域中查找。如果没有找到对应的声明,编译器就会向外逐层查找,直到最顶层的作用域,也就是全局对象声明所在的作用域。如果在所有层次的作用域中都没有找到对应的声明,编译器就会报错。

 1 let x = 1;
 2 
 3 function f1() {
 4     console.log(x);
 5 }
 6 
 7 function f2() {
 8     let x = 2;
 9     f1();
10 }
11 
12 f2(); //1

在定义函数f1时,f1已经知道了自己应该输出在顶层作用域中声明的变量x。在使用静态作用域的程序设计语言中,当调用一个函数时,如果函数中使用的变量在函数体中没有定义,那么编译器就会去定义该函数的位置查找该变量,因此执行函数f2将输出1。

2.作用域提升与函数作用域

在ES6之前,JS中声明变量的唯一方式是使用var关键字,我们无法通过var声明常量。使用var声明的变量具有函数作用域而不是块级作用域,这种变量的作用域为包含它的函数体。使用var声明变量时,该声明会被“提升”到函数的顶部,但变量的初始化仍然在声明语句的位置完成。

1 var x = 1;
2 function f3() {
3     console.log(x);
4     var x = 2;
5 }
6 
7 f3(); //undefined

执行函数f3将输出undefined,因此其函数体内的变量x具有函数作用域,可以在函数内部的任何地方使用而不会报错,其声明被提升到了f3的最顶部,但其初始化仍然在声明语句处进行,因此f3输出undefined。这种特性会成为某些bug的来源,因此我们应该使用let声明变量。

var x = 1;
function f3() {
    console.log(x);
    let x = 2;
}

f3(); //ReferenceError: Cannot access 'x' before initialization

在使用let声明f3内部的变量x后,在其声明语句之前访问它将会出现错误。从报错信息可以看出,在函数内部使用let语句声明的变量在某种程度上也进行了作用域提升,但JS不允许我们在其初始化前访问它,这种特性就是暂时性死区。

除了变量声明,函数声明也可以进行作用域提升。

1 function f4() {
2     f5();
3 
4     function f5() {
5         console.log(1);
6     }
7 }
8 
9 f4(); //1

执行函数f4将输出1,这是因为函数f5的声明被提升到f4的顶部,因此调用f5的语句可以出现在声明f5的语句之前。但此时需要注意的是,当以函数表达式的形式声明f5时,程序会报错,因为在初始化之前f5等于undefined,它不是一个函数。

1 function f4() {
2     f5();
3 
4     var f5 = function() {
5         console.log(1);
6     }
7 }
8 
9 f4(); //TypeError: f5 is not a function

变量与函数的声明都会进行作用域提升,但函数声明的“提升优先级”更高,即函数会先于变量声明。

1 var f6 = 1;
2 function f6() {}
3 
4 console.log(typeof f6);  //number

上述代码将输出number,这是因为f6函数先于f6变量被声明,变量的声明覆盖了函数的声明。

3.块级作用域

ES6引入了块级作用域。通过let声明的变量和通过const声明的常量具有块作用域,这些变量和常量只在let和const语句所在的代码块中有定义。粗略地说,如果某个变量或者常量声明在一对花括号中,这对花括号就限定了它的作用域。

 1 function f7() {
 2     let x = 1;
 3 
 4     {
 5         let x = 2;
 6         var y = 2;
 7     }
 8 
 9     console.log(x, y);
10 } 11 12 f7(); //1 2

执行函数f7将输出1和2。显然,x的值为1而不是2,因为f7内部的代码块中的变量x仅具有块级作用域,它只在该代码块中可见,而在该代码块中声明的变量y具有函数作用域,其在整个函数体中都可见。具有块级作用域的变量不允许在其所在代码块中存在同名变量。

1 let x = 1;
2 var x = 2;
3 console.log(x); //SyntaxError: Identifier 'x' has already been declared

使用var在同一个代码块中声明同名变量不会出现上述错误。

1 var x = 1;
2 var x = 2;
3 console.log(x); //2

4.作用域链

当我们访问一个变量时,会先在当前作用域中查找,如果没找到就继续到外层作用域中查找。如果直到全局作用域都没有找到该变量,程序就会报错。

 1 let x = 1;
 2 
 3 function f8() {
 4     function f9() {
 5         console.log(x);
 6     }
 7 
 8     f9();
 9 }
10 
11 f8(); //1

变量x的作用域链上有三个对象:f9作用域 -> f8作用域 -> 全局作用域。这条作用域链也是查找变量x的顺序。有两种语句会延长作用域链,一种是try-catch语句,另一种是with语句。

1 try {
2     throw ("Hello");
3 } catch (e) {
4     console.log(e); //Hello
5 }

当catch语句被执行时,其后面的代码块对应的作用域将被添加到作用域链中。with语句也能延长作用域链,它可以将某个对象添加到作用域的最前端,即最先在该对象中查找所需变量。在with执行结束后,作用域链将恢复正常。

1 function f10(obj, x) {
2     with (obj) {
3         console.log(x);
4     }
5 
6     console.log(x);
7 }
8 
9 f10({x:1}, 2); //1 2

执行函数f10将输出1和2,因为with语句将obj对象添加到了作用域链的最前面,程序将首先在obj中查找x,因此第一条输出语句输出1。而在with语句执行结束后,obj从作用域链中移除,因此第二条输出语句输出2。

5.循环中的块级作用域

在使用var声明的变量作为循环变量时,即使在循环结束之后也能访问它。

1 for (var i = 0; i < 10; i++) {
2     
3 }
4 
5 console.log(i); //10

由于使用let声明的变量具有块级作用域,使用let声明循环变量可以避免上述情况的发生。

1 for (let i = 0; i < 10; i++) {
2 
3 }
4 
5 console.log(i); //ReferenceError: i is not defined

长久以来,var声明让开发者在循环中创建函数变得异常困难,因为变量在循环结束之后仍能访问。

 1 var funcs = [];
 2 
 3 for (var i = 0; i < 10; i++) {
 4     funcs.push(
 5         function () {
 6             console.log(i);
 7         }
 8     )
 9 }
10 
11 funcs.forEach(
12     function (func) {
13         func(); //输出10个10
14     }
15 )

上述代码将输出10个10,这是因为循环的每次迭代都共享着同一个循环变量i,而循环内部创建的函数都保留了对同一个变量的引用。由于循环结束时i的值为10,因此在执行funcs中的函数时都将输出10。解决这个问题的方法有两种,一种是采用立即调用函数表达式:

 1 var funcs = [];
 2 
 3 for (var i = 0; i < 10; i++) {
 4     funcs.push(
 5         (function (value) {
 6             return function () {
 7                 console.log(value);
 8             }
 9         }(i))
10     );
11 }
12 
13 
14 funcs.forEach(
15     function (func) {
16         func(); //输出0-9
17     }
18 )

在循环内部,立即调用函数表达式为每次迭代时的变量i都创建了一个副本并存储到了value中,这个value的值就是每次迭代创建的函数所使用的值,因此最后会输出0-9。另一种方法则是使用let声明循环变量。使用let声明循环变量将在每次迭代时创建一个新变量,并之前的迭代中同名变量的值将其初始化。

 1 var funcs = [];
 2 
 3 for (let i = 0; i < 10; i++) {
 4     funcs.push(
 5         function () {
 6             console.log(i);
 7         }
 8     )
 9 }
10 
11 funcs.forEach(
12     (funcs) => {
13         funcs(); //输出0-9
14     }
15 )
posted @ 2021-04-08 22:31  曹冲字仓舒  阅读(176)  评论(0)    收藏  举报