深入JavaScript闭包(二):作用域,作用域链

作用域

作用域是什么?

作用域字面意义上就是某某东西能发挥作用的区域,起作用的范围,在JavaScript中就是指变量、函数能够有效使用的区域,作用域也可以用来隔绝变量
看下面一段代码
image.png
变量color可以在全局范围内使用,变量anotherColor只能在函数内部使用,它们作用于不同的范围。
在不同的作用域内可以声明同名的变量,同名变量间不会产生冲突,作用域将其隔离开了。
同一个作用域定义同名变量会产生冲突。
有一点需要特别注意的是:作用域是在代码、函数定义时就确定的,而不是执行时确定的。这点要和执行上下文区分开。

小结一下,作用域就是一个范围、地盘。变量在里面可以发挥作用,不会泄露出去。作用域最重要的作用就是隔离变量。

作用域的类别

根据工作模式,作用域可分为:静态作用域,动态作用域。
根据代码内容,作用域可分为:全局作用域,函数作用域,块作用域。
ES6之前作用域只有全局作用域和函数作用域,ES6后作用域新增了块级作用域。

全局作用域

顾名思义,全局作用域就是最大的作用域,作用范围包含全局。比如window对象上的属性拥有全局作用域,可以在全局任一地方发挥作用。
一般而言,以下一些情形拥有全局作用域

1,定义在最外层的变量或函数,或者说全局上下文中存储的变量或函数拥有全局作用域。
image.png
上述代码中的 color、scope、changeColor拥有全局作用域。

2,所有未定义直接赋值的变量会自动声明为全局作用域的变量。
image.png
即使是在函数内部直接赋值的变量也会声明到全局上。

3,**window**对象上的属性拥有全局作用域,在代码的任何地方都可以使用**window**

函数作用域

函数作用域就是在函数内部发挥作用的区域,每个函数都会创建自己的函数作用域。在函数内部定义的变量自动拥有函数作用域。使用var关键字声明的变量具有函数作用域。
image.png

块级作用域

块是指代码中使用{}包裹起来的区域,如ifforwhile、匿名块等等。ES6后新增了letconst两个关键字,使用它们声明的变量具有块作用域。函数作用域是一种特殊的块作用域。
看一个例子吧。
image.png
上面函数有两个代码块,都声明了变量n,最终的结果输出为5,块作用域里let声明的变量是不会影响到外面作用域的。
如果把let换成var呢?

function f1() {
  var n = 5;
  if (true) { 
    var n = 10;  
  }  
  console.log(n);  //10
}

最终结果会输出10,因为var声明的变量是函数作用域,有变量提升的效果,在块作用域内声明的变量会提升到函数作用域顶部。
其等价于:

function f1() {
  var n;
  n = 5;
  if (true) { 
     n = 10;  
  }  
  console.log(n);  
}

再看一段经典的代码。

for(var i=0;i<5;i++){
   setTimeout(()=>{
       console.log(i);
   },0)
}
// 5 5 5 5 5

for(let j=0;j<5;j++){
   setTimeout(()=>{
      console.log(j);
   },0)
}
// 1 2 3 4 5

for循环里面使用var声明的i变量会提升到for外部,变成全局变量,由于事件循环机制,会在同步任务(for循环)执行完毕后再执行异步任务(setTimeout任务),此时i变量的值已经变成5了,所以结果全部都输出5。
使用let声明的变量只会在for循环内部起作用。正常情况下for循环执行完毕后,内部let声明的j变量会被销毁掉,但是在代码块内部通过setTimeout方法引用了这个变量,setTimeout是在主程序执行完毕后执行的,即使for循环执行完毕后其每一层的作用域也被setTimeout所维持,不会被销毁,所以setTimeout可以访问到for循环中每一层作用域的j变量的值。

补充知识点:暂时性死区

其实不仅是var声明的变量可以进行变量提升,let声明的变量也可以进行变量提升,区别在于var声明的变量会提升到函数作用域顶部,而使用let声明的变量只会提升到块作用域顶部。这个过程是在执行上下文中完成的。
image.png

这个时候有人可能会有疑问:如果使用let声明的变量也可以提升的话,那么在使用let声明变量前使用的话也应该和var一样值为undefined,而不是报错。

console.log(m);  //undefined
var m = 10;

console.log(n);  //Uncaught ReferenceError: Cannot access 'n' before initialization
let n = 100;

这是因为ES6在新增letconst 关键字后提出的一个概念:暂时性死区。
ES6明确规定,如果区块中存在letconst命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前使用这些变量,就会报错。这在语法上称为“暂时性死区”。

var tmp = 123;

if (true) {
  tmp = 'abc'; // Uncaught ReferenceError: Cannot access 'tmp' before initialization
  let tmp;
}

上面的一段代码按照正常的理解,我已经在全局声明了tmp变量,之后在if块中使用应该是没有问题的。但是它在if块中声明了同名的tmp变量,由于暂时性死区的缘故,不能在声明前使用变量,所以报错了。

作用域链

直白点来讲,作用域链就是作用域的集合,其中子集可以访问父集,父集不能访问子集。
看一段代码
image.png
上面这段代码总共有三个作用域,全局作用域、fn函数作用域、bar函数作用域。它们之前是包含关系,全局作用域中包含fn函数作用域,fn函数作用域中包含bar函数作用域。这种包含关系组成了一种链式结构,即作用域链
image.png
在bar函数体中使用了变量a、b,但在bar函数作用域中并没有找到这些变量,于是它就会到其父级fn函数作用域中继续查找,找到了变量b,变量a还没有找到。继续查找,到fn函数作用域的父级全局作用域中查找,找到变量a,至此查找结束。
查找方向是不可逆的,子级可以到父级中查找,但父级不能到子级中查找。

静态作用域

静态作用域又称词法作用域,是指函数的作用域是在定义时确定的,即根据函数定义的位置确定它的作用域链。JavaScript采用的是静态作用域。
还是看一段代码

//采用静态作用域
let a = 10;
let b = 100;
function fn(){
    let b = 20;
    function bar(){
        console.log(a+b)
    }
    return bar;
}
let x = fn();    
x();  //30

上述代码中定义了两个函数:fn函数和bar函数。并且bar函数是在fn函数内部定义的,fn函数是在全局定义的。根据函数定义时的位置,可以得到这段代码的作用域链如下图:
image.png
再分析下这段代码的执行过程:
代码执行第11行,调用fn函数,并将fn函数的返回值赋值给x。fn函数的返回值是bar函数,x就相当于是bar函数。
代码执行第12行,调用bar函数,在控制台打印a+b的值。但bar函数作用域中没有变量a、b,必须去其他作用域中查找。此时bar函数是在全局作用域中被调用的,全局作用域和fn函数作用域中都有变量b的值,该取哪个作用域中的值呢?
这个时候就要看我们刚才生成的作用域链了,bar函数作用域中没有查找到变量,所以要去它的父级作用域中查找,即在fn函数作用域中查找,得到结果b的值为20,而不是在全局作用域中查找,即使它是在全局作用域中被调用的。
小结一下:当查找函数中未定义的变量时,要到函数被创建的作用域中查找,而不是到函数被调用的作用域中查找。

动态作用域

动态作用域是指函数的作用域是在调用时被确定的。也就是说我们不能在初始时就确定作用域链,必须在函数执行时确定
还是这段代码

//采用动态态作用域
let a = 10;
let b = 100;
function fn(){
    let b = 20;
    function bar(){
        console.log(a+b)
    }
    return bar;
}
let x = fn();    
x();  //110

再分析下这段代码的执行过程:
代码执行第11行,调用fn函数,并将fn函数的返回值赋值给x。fn函数的返回值是bar函数,x就相当于是bar函数。
代码执行第12行,调用bar函数,在控制台打印a+b的值。但bar函数作用域中没有变量a、b,必须去其他作用域中查找。此时bar函数是在全局作用域被调用的,此时生成的动态作用域链如下:
image.png
所以bar函数会直接到全局作用域中寻找变量a、b。最终得出结果110。

现在绝大多数的语言都是使用的词法作用域(静态作用域),JavaScript也是使用的是静态作用域,但是它的eval()、with、this的机制很像动态作用域。
小结一下:静态作用域是在代码或函数定义时确定的,而动态作用域是在代码或函数运行时确定的。静态作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

作用域与执行上下文

很多人容易将作用域和执行上下文弄混,它们是完全不同的两个概念。

各自的定义

作用域:作用域本质上类似于一个地盘,范围,主要起隔绝变量的作用。
执行上下文:执行上下文又称执行环境,其实就是代码执行前的准备工作,最终会生成一个活动对象,用来存储变量和函数。查找作用域中的变量都是从作用域对应的上下文的活动对象中查找的。

创建时间

作用域:JavaScript采用的是静态作用域工作模式,所以作用域是在代码或函数定义时就确定的。
执行上下文:执行上下文是在代码块执行前生成的。

变化过程

作用域:作用域是静态的,代码或函数定义时作用域就确定了,不会更改。
执行上下文:执行上下文是动态的,在代码块执行前生成,同一个作用域中,不同的调用会生成不同的上下文。

扩展:变量提升和函数提升及其重名问题

通过之前关于执行上下文的说明,可以知道在JS引擎解析JS代码生成执行上下文的过程中会有变量,函数的提升操作,但其优先级是怎样的呢?
先看一个例子,下面代码的输出结果是什么?

var a = 1;
function b(){
    console.log(a);
    a = 10;
    function a(){
        console.log(a);
    }
}
b();
console.log(a);

先看结果:
image.png
这个结果是如何实现的呢?先理清楚提升

变量的提升(var声明)

这个比较好理解,就是**使用var声明变量时会把所有变量声明都拉倒函数作用域的顶部。**在代码中的表现形式就是可以在变量声明前使用它。

function foo(){
    console.log(age);
    var age = 23;
}
foo();  //undefined

//等价于
function foo(){
    var age;
    console.log(age);
    age = 23;
}
foo();   //undefined

函数的提升

在JS中定义函数有两种方式:函数声明函数表达式。这两种方式的提升效果是不相同的。
红宝书中对它们的说明是这样的:

JavaScript引擎在任何代码执行之前,会先读取函数声明并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。

来看两个简单的例子:
函数声明:

console.log(foo);
function foo(a,b){
  return a + b;
}

image.png
输出结果为函数,可见初始生成的执行上下文是这样的: 初始时就给foo赋值了。
image.png

函数表达式:

console.log(foo);
var foo = function(a,b){
  return a + b;
}
//undefined

//等价于
var foo
console.log(foo);
foo = function(a,b){
  return a + b;
}1

初始时其对应的执行上下文是这样的:只做了变量foo的提升,没有赋值。
image.png

变量与函数重名

看一个例子:

console.log(foo);
function foo (a,b){
  return a + b;
}

var foo = 10;

console.log(foo);

最终结果:
image.png
调换一下顺序:

console.log(foo);

var foo = 10;
function foo (a,b){
  return a + b;
}

console.log(foo);

最终结果还是:
image.png
通过代码对比可以看出:函数声明提升比代码声明提升更优先
所以,其等价于:

function foo (a,b){
  return a + b;
}
var foo;
console.log(foo);
foo = 10;
console.log(foo);

再来看最开始的例子,其解析执行的顺序是这样的:
1,先是全局代码,转化为如下形式:

var a;
a = 1;
function b(){
    console.log(a);
    a = 10;
    function a(){
        console.log(a);
    }
}
b();
console.log(a);

2,然后执行代码,执行到b函数时,将b函数体内部的代码再做一次解析,将函数声明方式定义的a函数提升到顶部。

var a;
a = 1;
function b(){
    function a(){
        console.log(a);
    }
    console.log(a);
    a = 10;
}
b();
console.log(a);

3,执行b函数体代码,打印a函数,最后将函数体内部的a函数变为10。其执行上下文变动如下:
image.png
4,b函数执行完毕,b函数上下文被销毁,然后打印全局变量a,此时全局变量a的值为1。再整个过程中共有两个变量a,一个是全局变量a,一个是b函数内部的变量a。变量的查找是向上查找的,最后的打印变量a实在全局作用域中寻找,不会向下到函数作用域中查找。所以,最终的输出为:

参考文章:
https://juejin.cn/post/6844903473683628046
https://www.cnblogs.com/wangfupeng1988/p/3991151.html
https://juejin.cn/post/6844903797135769614?searchId=202307132014242A3EC1DC5A8F9D9A3754

阮一峰的ES6快速入门
https://es6.ruanyifeng.com/#docs/let

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值