浅谈一下JS中的作用域

216 阅读14分钟

关于JS的作用域,其实还有不少容易出错的地方。在今天仔细学习了一下作用域之后,发现有不少可以拿出来说的点,于是有感而发,写下这篇文章,如有出错缺漏的地方,望各位读者指正。

首先,众所周知,JS这门语言的执行引擎只有两种,浏览器和node。当你在编译器中写下一段JS代码时,JS的执行引擎就开始执行这段代码,它会先对所有代码进行词法分析和语法分析,然后生成代码。

1.作用域的概念

理解完这一点,我们先来通过一个例子来理解一下作用域的概念。请各位看下面这段代码。

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

熟悉JS的朋友一下就能知道这份代码的输出结果是1,而初次接触JS的朋友就会疑惑,这个函数里面明明没有a,为什么输出结果会是1呢?它是怎么找到a的呢?

其实,对于JS的执行引擎来说,这整份JS代码就是一个全局,就是JS代码起作用的地方,也叫全局作用域。我们说过,当你写完这段代码并运行时,JS执行引擎就开始进行词法分析。对于词法分析,别的不看,我们只看最重要的。对于这个全局作用域,我们来找找它的有效标识符。有效标识符指的就是你在这个作用域自己定义或声明的量。我们一下就能发现,这个全局作用域的有效标识符是afoo

而对于这个函数foo来说,它其实也有一个自己的作用域,就是花括号括起来的地方。这个作用域就叫函数作用域。对于这个函数作用域,它的有效标识符是什么呢?我们发现,花括号括起来的地方根本没有我们自己声明或定义的量。所以,在这个函数作用域中,并没有有效标识符。

根据JS的执行规则,我们调用了这个函数。这个函数的作用是输出a的值。它会首先在自己的作用域找有没有a的值,也就是在内层的函数作用域中。我们发现,根本找不到,于是,它就会越到外层的全局作用域中去找。而在全局作用域中我们刚好定义了a的值,于是a的值被拿过来用,输出了a的值为1。

image.png

在理解了这一点后,我们来看看下面这段代码,请你想一想它的输出结果是什么。

var a = 1;
function foo() {
    var a = 2;
    console.log(a);
}
foo();

我们可以非常轻松的知道它的输出结果是2。因为在foo的函数作用域中,我们定义了a的值为2,所以他就不会去访问外层的全局作用域

image.png

再来,我们再看下面这段代码,我们在函数foo中再嵌套一个函数bar。作用域是可以随意嵌套的。它的输出结果又会是什么呢?

var a = 1;
function foo() {
    var a = 2;
    function bar() {
        console.log(a);
    }
    bar();
}
foo();

我们来仔细分析一下。首先代码执行到foo(),调用这个函数,于是去执行foo,而在foo里面,我们又调用了bar这个函数,这个函数是要输出a。我们说过,花括号括起来的部分叫函数作用域。对于bar这个函数来说,它也有自己的作用域,它先在自己的作用域中找有没有a的值,发现并没有a。于是,他就会越到上一层作用域中去找,也就是foo的作用域。而我们在foo中定义了a的值为2,于是a的值被调用,它就不会再到上一层去调用了。所以输出结果为2。

屏幕截图 2024-11-11 232711.png

我们再来看一个例子:

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

它的输出结果是什么呢? 我们直接看一下结果:

image.png 我们发现,结果显示a并没有被定义,程序报错了。这是为什么呢?我们来分析一下。 首先是不是有一个全局作用域,然后有一个函数foo,它有一个函数作用域。程序首先执行foo(),调用这个函数,然后执行输出a。我们发现,对于这个全局作用域来说,我们并没有定义a的值,我们只在foo的函数作用域中定义了这个值,而输出结果却报错。我们就能发现JS的执行规律,外层的作用域并不能访问内层的作用域。

在理解了上面这些后,我们就可以下一个结论:在JS当中,有全局作用域和函数作用域的概念。内层作用域可以访问外层作用域,外层不能访问内层。

2.欺骗词法

欺骗词法是作用域中值得一提的一个东西,它比较容易出错。那什么是欺骗词法呢?从字面意思上来看就是欺骗了JS执行引擎的词法分析呗。我们来通过几个例子来了解一下欺骗词法。

2.1 eval()

请各位看下面这段代码。

function foo(a) {
    console.log(a, b);
}
var b = 2;
foo(1)

我们定义了一个函数foo,里面有一个形参a,它的作用是输出a和b,然后我们定义了b的值为2,调用这个函数,传了个实参1进去。这段代码的运行结果是什么呢?

通过上面的学习,我们应该很容易分析得出:foo是不是也有一个自己的作用域,叫函数作用域,它里面有没有有效标识符呢?有的,是a,形参也算有效标识符。然后我们看,要输出a和b的值,它应该先是在自己的作用域中找有没有a和b的值,然后我们发现这个函数作用域中并没有a和b的值。它就会越到外层的全局作用域中去找。我们发现,我们传来一个实参1给形参a,形参a的值就是1了,我们还定义了b的值为2,所以理所当然输出结果应该是1和2。

image.png

我们再来看下面这段代码,请大家思考它的输出结果是什么。

function foo(str, a) {
    eval(str); 
    console.log(a, b);
}
var b = 2;
foo('var b = 3', 1)

我们来分析一下这段代码:根据我们上面所学到的,我们定义了一个函数foo,它里面有两个形参str和1,然后有一个方法eval(),然后要输出a和b,在这个函数外面我们定义了一个b = 2,然后调用了这个函数foo(),传了两个实参,一个是字符串‘var b = 3’,一个是1。

根据上述分析,我们推测输出结果应该是1和2才对。因为在foo自己的作用域,我们并没有定义a和b的值,所以它应该越到外层作用域去找。找到a的值为1,然后我们还var b = 2,所以输出理应是1和2。那么是这样吗?我们来看看输出结果:

image.png

我们发现输出结果竟然是1和3。a的值是1没错,怎么b的值变成3了呢?我们不是定义了b的值为2吗?

这就是所谓的欺骗词法了。函数foo里面有一个方法eval(),我们给它传了一个形参str,值为‘var b = 3’,它的作用就是把本来并不存在于函数作用域的这条语句‘var b = 3’变得好像定义在了这个地方,欺骗了作用域规则,也就是说,在foo的作用域中有这么一条语句‘var b = 3’,就是eval()起的作用,它就好像相当于下面这段代码:

function foo(str, a) {
    var b = 3console.log(a, b);
}
var b = 2;
foo('var b = 3', 1)

这样一看,嗯,输出结果确实是1和3,在foo的作用域中有有效标识符b,并且值为3。就不需要跑到外层作用域去找b了。

2.2 with(){}

还有一种欺骗词法。我们再来看几个例子

请看下面这段代码:

var obj = {
    a: 1,
    b: 2,
    c: 3,
}
obj.a = 2,
obj.b = 3,
obj.c = 4,

console.log(obj);

我们定义了一个对象obj,里面有三个属性a,b,c,值分别为1,2,3。然后我们做了这样一个操作,让所有属性的值都加1,然后输出obj。这是一种很正常的修改对象中属性的值的方法,输出结果就是:

屏幕截图 2024-11-12 110953.png

但其实还有一种修改对象中属性的方法,请看下面这段代码:

var obj = {
    a: 1,
    b: 2,
    c: 3,
}
with (obj) {
    a = 2,
    b = 3,
    c = 4
}
console.log(obj);

我们利用了with() 这个方法去修改了obj中属性的值,比上面那种直接修改稍微简洁一点。

知道了有这么一个方法后我们再来看下面这个例子:

var o1 = {
    a: 1,
}
function foo(obj) {
    with (obj) {
        a = 2
    }
}
foo(o1);
console.log(o1);
}

我们定义了一个对象o1,里面有一个属性a,值为1,然后定义了一个函数foo(),里面有一个方法with(),将a的值改为2,然后调用这个函数,再输出o1,看看a的值是否被改为了2。

屏幕截图 2024-11-12 113725.png

我们通过运行结果可知,a的值确实被改为了2。因为我们刚刚学习了一个这个方法with(),它能更改一个对象中属性的值,所以a的值被改为2了,这很合理。

那么再来看下面这段代码,请推测一下它的运行结果:

var o1 = {
    a: 1,
}
var o2 = {
    b: 2,
}
function foo(obj) {
    with (obj) {
        a = 2
    }
}
foo(o2);
console.log(a);

我们又定义了一个对象o2,里面有一个属性b,值为2。然后我们调用这个函数foo(o2),然后输出a。

根据我们上面所学的,with() 这个方法可以更改obj属性中的值。在这个例子中,我们用这个方法去更改o2中属性a的值,但o2中我们并没有定义一个属性a。那它会发生什么呢?我们要求输出a看看,请注意一点,我们在全局作用域中并没有声明一个a,o1中的a只是它的属性。所以按理来说程序应该会报错,因为根本没有a。那么结果会是什么呢?

image.png

我们发现输出结果竟然是2,明明在全局作用域中我们根本没有声明一个a,怎么会输出a的值为2呢?

这就是方法with(){} 搞得鬼了。当with(){} 去修改一个对象中根本不存在的属性值时,它会使这个修改值泄露到全局作用域中去,也就是说在全局作用域中凭空多了一行这个代码:‘var a = 2’,所以当我们输出a的值时我们会发现程序不仅没有报错并且还输出了a的值为2。这也是一种欺骗词法

所以总结:在JS中,有两种方法会欺骗词法,分别是eval()和with(){} ,并且对于with(){}当对象中没有属性 x 时,with修改x属性会导致 x 泄露到全局。

3. var,let,const的区别

在上面我们说过,在JS中,有两种作用域,分别是全局作用域函数作用域。但其实还有一种作用域,它就是块级作用域。所以,接下来我们就来介绍一下块级作用域是什么。

而想要清楚地了解块级作用域是什么之前,我们必须搞清楚var,let,const这三个关键字之间的区别。很多人可能知道它们都能用来定义变量,那么它们之间的区别是什么呢?

3.1 var和let之间的区别

3.1.1 区别1

我们先通过一个例子来了解一下var,请看下面这段代码:

console.log(a);
var a = 1;

熟悉JS的朋友一下就知道输出结果是:

image.png 而对于初次接触JS的朋友来说,可能会有点疑惑:代码不是从上往下执行的吗?明明在输出a这条语句之前我们并没有定义a,怎么程序还正常运行了呢?

这就要说到var这个关键字的特点了,var 声明的变量会存在声明提升(将变量的声明提升到当前作用域的顶部),也就是会变成下面这样:

 var a
 console.log(a);
 a = 1;

这样各位一下就能看懂了,先定义了一个变量a并没有赋值,然后输出a,再给a赋值1,因为代码是从上往下执行的,输出结果自然就是Undefined了。

而对于let来说,它有没有这个特性呢?我们试一下就知道了。

console.log(a);
let a = 1;

我们来看看运行结果:

屏幕截图 2024-11-12 122341.png

程序报错了,不能在a初始化之前访问a。 所以我们有了var和let之间的第一个区别:var 声明的变量会存在声明提升(将变量的声明提升到当前作用域的顶部),而let不会

3.1.2 区别2

我们再来看一个例子:

var a = 1;
var a = 2;
console.log(a);

我们先声明了一个变量a为1,又声明了a为2,重复声明了同一个变量,按理来说在别的编程语言中是不合适的,而在JS中可行。输出结果是:

image.png

我们发现输出结果是2,这就说明第二行代码将第一行代码覆盖掉了,这就是var的第二个特性:var可以重复声明变量。而let可不可以呢?我们来看看:

let a = 1;
let a = 2;
console.log(a);

输出结果是:

image.png 程序直接报错了,说明let并没有这个特性。

所以var和let的第二个区别:var可以重复声明变量,let 不行。

3.1.3 区别3

我们简单提一下第三个区别:var 在全局声明的变量会默认添加在 window对象上,而let 不会。 通过下面两个例子:

image.png

image.png

我们发现通过var声明的变量通过对象window访问能访问得到,而通过let不行。

3.2 var和const的区别

首先,关于const,它与let在效果上基本相同,只有一点不一样。

我们来看下面这个例子:

const a = 1;
a = 2;
console.log(a);

如果是使用var和let声明的变量,先定义一个变量a赋值为1,再重新赋值为2,输出结果自然是2.而在const中却不是这样。我们来看看输出结果:

屏幕截图 2024-11-12 125116.png

程序直接报错了。

所以const的特性就是:const声明的变量值无法修改。

4. 块级作用域

在了解完var,let和const三者之间的区别之后,能帮助我们更加清晰地认识块级作用域。

先来看下面这个例子:

for (var i = 0; i < 10; i++) {
    console.log(i);
}
console.log(i);

我们在for循环中定义了var i= 0,是不是相当于在全局作用域下定义了一个i为0。注意这里并不是在函数里面哦,这只是一个for循环。所以在i自增到10的时候循环结束,输出i的值自然是10.所以输出结果应该是1到10:

image.png 而当我们换成用let定义时:

for (let i = 0; i < 10; i++) {
    console.log(i);
}
console.log(i);

我们来看一下输出结果:

image.png 程序输出到9就结束了,并没有输出10。

这是因为用let加花括号{}可以形成一个块级作用域,所以在全局作用域的语句想要输出i的值就不能访问内层的作用域。我们说过:内层作用域可以访问外层作用域,外层不能访问内层。

let形成的块级作用域有效地防止内层作用域的变量泄露到外层。

请注意一点,let只对它定义的变量有块级作用域的效果,比如:

if (true) {
    let a = 1
    var b = 2
}
console.log(b);

输出结果是:

image.png

b照样成功输出,因为它是使用var定义的变量,所以它在全局作用域中。