深入理解 JavaScript 作用域及其底层逻辑

7,830 阅读6分钟

一:前置知识

1.1:JS引擎

在认识JS作用域之前,我们要明白JS代码是交给浏览器去解读的,浏览器中含有JS引擎进行解读。其中不得不提的就是谷歌的v8执行引擎,可以说是“遥遥领先”了,以下以v8为例进行讲解。

v8引擎解读JS代码可分为三步

  1. 进行词法分析,得到词法单元
  2. 进行解析,将词法单元转换成一个逐级嵌套的程序语法结构树
  3. 生成代码

一个简单的例子:JS代码为var a = 2,词法分析得到的词法单元为var, a, =, 2,通过解析后生成代码var a = 2,虽然看上去解析前后的JS代码是一样的,但v8读的是最终生成的代码,而不是最开始用户写的代码。

1.2:有效标识符

有效标识符是指用来给变量、函数等起名字的字符串,需满足以下简单规则:

  • 以字母、下划线 _ 或美元符号 $ 开头。
  • 后续可以包含字母、数字(0-9)、下划线或美元符号。
  • 不能使用JavaScript的保留关键字作为名称,比如ifelsewhile等。
  • 区分大小写,myVar 和 MyVar 被视为两个不同标识符。

二:全局作用域与函数作用域

请看下面这个例子,其中红色框内为foo函数的作用域,黄色框内为bar函数的作用域,编译运行可知,我们能够得到正确答案-->6。我们在全局调用了foo函数,变量a在foo函数中声明,能够得到正确答案说明在调用bar函数时,内层领域能够拿到外层领域b的值,也就是说,内层领域能够访问外层领域

微信图片_20240607162632.jpg

然而,在下面这个例子当中,却无法正确编译,报错:a is not defined,为什么呢? 我们可以发现,代码在全局声明了一个函数体foo,在foo函数中声明了一个变量a,故a是函数foo中的有效标识符,也就是说,a是处在foo函数的作用域中。然而console.log(a)是写在了全局作用域中,看到这里,诸君心里是不是有了答案-->外层作用域无法访问内层作用域

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

那么,问题来了,既然内层作用域可以访问外层作用域,外层作用域不可以访问内层作用域,那直接哐哐往在全局声明变量,这不就可以避免作用域带来的无法访问问题吗?显然是错误的想法,在实际开发中,全局声明过多变量,是容易混淆和重复的,正确的做法是根据函数功能,合理的封装,请看下列代码,试着自己分析下,体会代码的优雅

function foo(a){
    var b

    function bar(a<img src="){" alt="" width="70%" />
        return a-1
    }
    bar(a*2)
    console.log(b*3)

}

foo(2)

微信图片_20240607181239.jpg

三:作用域的底层逻辑

v8在执行代码时,第一时间是创建一个调用栈,首先碰到的一定是全局作用域,会创建一个全局执行上下文,再碰到局部作用域,会创建一个局部执行上下文。之所以内层作用域可以访问外层作用域,本质上是因为栈的先进后出,单个出口的性质。一定是取完上面的,才能取下面的。请看下面的代码分析示例,相信诸君会对其有更深的理解。

var a = 1

function foo(){
    var a = 2
    console.log(a);//输出2
}

foo()

微信图片_20240607182544.jpg

四:let与var

4.1:声明提升

  • var 声明的变量会进行声明提升(hoisting),这意味着变量可以在声明之前被访问,尽管其值会是undefined
  • let 声明的变量不会被提升,必须在声明之后才能使用,否则会导致引用错误(ReferenceError)。
console.log(a);//不会报错,输出undefined
var a = 1
console.log(a);//报错
let a = 1

4.2:块级作用域

  • var 声明的变量具有函数作用域或全局作用域。如果在任何函数外部声明,它将成为全局变量。即使在块级作用域(如if语句或for循环内)中声明,var 变量也会提升到包含它的函数或全局作用域的顶部。
  • let 提供了块级作用域,这意味着变量只在它被声明的代码块(例如,if语句、for循环或一对花括号内)中可用。
if(true){
    var a=1
}
console.log(a);//输出1
if(true){
    let a=1
}
console.log(a);//a is not defined
for(var i=0;i<10;i++){
    console.log(i);//输出0-9
}
console.log(i);//输出10
{
    let i;
    for(i=0;i<10;i++){
    }
}
console.log(i);//i is not defined

4.3:重复声明

  • var允许在同一作用域内多次声明同名变量,不会报错,可能会导致意料之外的行为。
  • 在同一作用域内,不允许重复声明同一个let变量,这样做会抛出语法错误。

4.4:暂时性死区

let声明的变量,在变量声明之前,该变量在其作用域内处于暂时性死区,任何访问都会导致错误。

let a=1
if(1){
    console.log(a);//暂时性死区
    let a=2
}//编译报错Cannot access 'a' before initialization

五:欺骗词法作用域

5.1:eval

eval()能够“欺骗”词法作用域,将原本不属于这里的代码变成就像天生定义在了这里一样。下例中eval(str)等同于var b=3

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

foo('var b=3',1)

5.2:with

with(){} 用于修改一个对象中的属性值,但如果修改的属性在原对象中不存在,那么该属性就会被泄露在全局域中。下例中with(obj){ a=2 }a泄露在全局之中,所以console.log(a)能够得到2

function foo(obj){
    with(obj){
        a=2
    }
}
var o2={
    b:3
}
foo(o2)
console.log(a);//输出2

六:知识点get

  • JS引擎解读代码的过程(见前置知识)
  • 有效标识符
  • 内层作用域可以访问外层作用域
  • 外层作用域不可以访问内层作用域
  • 作用域的底层逻辑,引擎执行代码时会第一时间创建调用栈
  • let和var的与作用域相关的四个区别
  • eval和with能够欺骗词法作用域

最后再总结下三种作用域:

  1. 全局作用域

    • 全局变量在整个脚本或文件中都是可见的,从脚本的任何位置都可以访问。
    • 在函数外部定义的变量拥有全局作用域。
    • 在浏览器环境中,全局变量默认绑定到window对象上。
  2. 函数作用域

    • 函数内部声明的变量(使用var关键字)仅在该函数内部可见,称为局部变量。
    • 函数参数也属于函数作用域。
    • 在函数内部可以访问全局变量,但全局变量无法直接访问函数内部的局部变量。
  3. 块级作用域 (ES6新增):

    • 使用letconst关键字声明的变量在它们所在的代码块(如{}内的代码)内可见,而不是整个函数。
    • 区块作用域包括ifforwhile等控制结构的花括号内,以及函数表达式和箭头函数的花括号内。
    • let变量可以在其作用域内重新赋值,但const变量一旦赋值后不可更改。

本文的下篇:https://juejin.cn/post/7377647067575418932,配合食用,效果更佳