每一个独立的作用域,都会有一个变量对象(variable object)用于存储其作用域中所有的变量。但是自从ES5以后已经对变量对象的概念进行修改,不再使用变量对象,取而代之的是一个新的概念词法环境(lexical environments)。词法环境听起来挺玄的,其实它和变量对象的区别并不大。
无论是变量对象还是词法环境,都来自于ES的标准。在不同的浏览器中有着不同的实现方式,所以变量对象、词法环境这些东西知道一下即可,不必过分深究。下边的内容中,为了方便理解,我会依然采取变量对象这一称呼。
变量对象作为作用域中的一部分,用来存储当前作用域中的所有变量。除了变量对象外,在作用域中还会对当前作用域的外层作用域进行存储,它大概的结构是这样的:
举个例子,全局作用域中有如下代码:
let a = 10;
let b = 'hello';
a和b两个变量存储在全局作用域中,全局作用域已经是最外层的作用域了,所以对于它来说,外层作用域就是全局对象(window、globalThis)。
下边我们再让结构复杂一点:
let a = 10;
let b = 'hello';
{
let c = 33
}
上例代码中多了一个代码块,代码块会创建出块作用域,块作用域位于全局作用域内部,所以在块作用域中会对其外部的全局作用域进行存储。
如果在块作用域内部再创建一个块,此时这个块的作用域就会指向它外部的作用域,它外部的作用域指向全局作用域,全局作用域指向全局对象。如此一来我们就得到一条由作用域组成的链条,这个链被称为作用域链,这个链在开发者工具中也可以看到。
当我们将代码停止在全局作用域时,注意右侧Scope区域显示的就是作用域链,最上边的Script表示全局作用域,Script下的Global表示它的外层作用域也就是全局对象。
代码停在块作用域时,右侧作用域链多了一个Block表示块作用域,块作用域的外层是Script全局作用域,Script的外层是Global全局对象。
代码停在内部代码块时,作用域链会在原来的基础上增加一个块作用域。随着代码嵌套层次越来越复杂,其作用域链也会越来越长。
有啥用?
作用域链使得当前作用域和它所有的外层作用域连接到了同一条链上。它有什么用呢?作用域链决定了变量的搜索顺序。当我们使用一个变量时,浏览器会首先在当前作用域中寻找,如果找到了则直接使用,没有找到,则去当前作用域的外层作用域中寻找,找到了则使用,没有则继续寻找。直到找到全局对象,如果依然没有,则报错。
举个例子:
//全局作用域
let a = 10; let b = 'hello'; { //外层代码块 let c = 33; { //内层代码块 let d = 44; } }
上例中,以内层代码块为例,假设我们要在内层代码块中通过console.log(d)
来访问变量d,此时浏览器会先在当前的块作用域中寻找,如果找到了直接返回d的值。如果没有找到,则去到外层代码块所在的作用域中寻找,找到则使用,没有找到则去外层的全局作用域中寻找,找到了则使用,没找到则去全局对象中寻找,找到了使用,如果依然没有找到则报错。
上图中已经标识出了查找的顺序,简而言之使用变量时,浏览器会沿着作用域链去搜索,先找到哪个就优先使用哪个,都没找到就报错。
函数作用域道理也是一样的,可以自己试一试。
函数作用域
在三种作用域中(全局、块、函数),函数作用域是一种比较特殊的作用域。最典型的区别就是它们不同的生命周期。所谓的生命周期指的是作用域何时创建何时销毁,其实就是作用域的生和死的时机。
全局作用域是最大的作用域,它的生命周期和整个网页的生命周期是相同的。这意味着当你打开页面的那刻起全局作用域就已经创建好了,当你关闭网页时全局作用域也就消失了。它的生与死和网页的生命周期是一样的。
{
let a = 10;
}
块作用域会在块开始执行时创建,执行结束后销毁。换句话说在进入左大括号{时块作用域就创建了,离开右大括号}时就销毁了。
function fn(){
let a = 33;
let b = 'hello';
}
函数作用域不太一样,像上例中的代码并不会创建函数作用域。函数作用域只会在函数调用时才会创建,声明创建函数并不会产生新的作用域。要产生函数作用域必须对函数进行调用。
function fn(){
let a = 33;
let b = 'hello';
}
fn();
上例中调用fn()函数,此时就产生了函数作用域在函数调用时,作用域创建,调用完毕作用域消失,并且每次调用函数都会创建一个全新的作用域。例如调用fn()函数10次,就会创建10个作用域,并且10个作用域之间是相互独立的,互不影响。
let a = 10; let b = 'hello'; function
fn
() { let a = 22; console.log(a);
// 22
console.log(b);
// 'hello'
console.log(c);
// 报错
}
fn
()
上例中,fn是一个全局函数,在函数内部打印了变量a和b,a和b的值分别是多少呢?fn是全局函数,它的作用域是这样一个结构:
函数作用域(fn)➡️ 全局作用域 ➡️ 全局对象。
访问变量a时,会先在函数作用域中寻找,函数作用域中定义了a,所以直接 返回a的值,即22。
访问变量b时,在函数作用域中没有变量b,所以会去上一级全局作用域中寻找,全局中有变量b,直接返回全局中的变量b的值,即’hello’。
访问变量c时,依然在函数作用域中找c,没有。再去上一级全局作用域中找,没有。最后去全局对象window中寻找,依然没有,没辙了,最后报错!
如果代码是这样的呢?
let a = 10; let b = 'hello'; function
fn
(a, b, c) { console.log(a);
// undefined
console.log(b);
// undefined
console.log(c);
// undefined
}
fn
()
上述案例中,为fn指定了三个形参a、b和c。定义形参相当于在函数中声明了变量,虽然没有传递实参但是变量也确实是存在的只不过值是undefined。所以打印a、b、c时,三个值都是undefined。总之记住了,形参即变量。
现在,函数在全局中定义,也在全局中调用,所以对于函数来说它的外部作用域就是全局作用域,所以寻找变量时,顺序是先找自己再找全局,如果修改代码的结构会是一个什么样子的情况呢?
let a = 10; let b = 'hello'; function
fn
() { console.log(a); //10 console.log(b); //'hello' } { let a = 100 let b = '你好'
fn
() }
上例中,函数的调用是在一个代码块中进行的,在代码块中有两个局部变量a和b,在全局中也有两个全局变量a和b,在fn函数中访问了两个变量,问此时fn中打印的a和b的值是多少呢?是全局的a和b,还是块作用域中的局部变量a和b呢?
即使结构在怎么调整,你需要记住只要是找变量就是沿着作用域链去寻找。这里在函数内部访问了a和b,首先还是在函数作用域中寻找,没有。接着去外层作用域中寻找,现在的问题是外层作用域是谁?全局作用域还是块作用域?如果是全局作用域,那么a的值应该是10,b的值应该是’hello’。如果是块作用域,a的值应该是100,b的值应该是’你好’。
所以这个问题的关键在于,函数作用域的外层作用域到底是谁?
函数的外层作用域由函数的定义位置决定!
记住这句话,函数的外层作用域和函数的调用位置无关,完全有函数定义的位置决定,函数定义在全局中,函数作用域的外层作用域就是全局作用域。函数定义在块中,函数的外层作用域就是块作用域。
上例中,函数定义在了全局中,所有它的外层作用域就是全局作用域,所以这是a和b的值就是全局作用域中的a和b,即10和’hello’。
如果把函数定义在代码块中呢?
let a = 10; let b = 'hello'; { let a = 100 let b = '你好'
function
fn
() { console.log(a); //100 console.log(b); //'你好' } }
fn
()
此时在代码块中定义函数,在全局作用域中调用函数(在代码块通过函数声明创建的函数也是全局函数)。函数定义在了代码块中,那么它的外层作用域就是块作用域,所以这时的作用域关系是这样的:
函数作用域 ➡️ 块作用域 ➡️ 全局作用域 ➡️ 全局对象
当在函数作用域中找不到变量a和b时,它会去块作用域中寻找,所以上边的例子中,a的值是100,b的值是’你好’。
看文章时突发的一个想法发,李老师能不能开设一个专栏,每一期出一些前端的题目给我们做,小的demo,然后有兴趣的同学可以提交,哈哈
好主意