由浅入深理解JavaScript中的作用域

210 阅读11分钟

前言:我们在学习一门语言的过程中,作用域是深入理解这门语言的敲门砖,今天就让我们一起来学习下JS的作用域

一、前置知识

1.1 JS的执行引擎

我们知道一段代码如果在记事本中打开,那么它就只是一段段冰冷的字符,中看不中用,什么作用都实现不了。所以一段代码要运行,就需要有发动机去驱动它。V8就是一种常见的由Google开发的高性能JavaScript执行引擎,它可以读取和执行JavaScript代码将代码转换为机器能够理解并执行的指令。

1.2 JS代码的执行过程

JS代码的执行过程可大致分为以下三步

  1. 词法分析
  2. 语法分析
  3. 生成代码

1.2.1 词法分析

我们在做英语阅读理解的时候,会先将一句话拆分成一个个单词。同样的,JavaScript引擎开始处理代码时,第一步就是进行词法分析,它会将代码字符串分解成一个个的词法单元(token)。例如,对于代码var num = 5;,就会被分解成var(关键字)、num(标识符)、=(运算符)、5(字面量)和;(标点符号)等词法单元。这是理解代码语义的第一步。

1.2.2 语法分析

我们同样以英语阅读理解为例,在进行完单词拆分后我们还需要对一句话进行语法分析,分析这句话的主谓宾、这句话是否是一个从句等。相同的,在第一步词法分析的基础上,语法分析会将这些词法单元构建成抽象语法树(AST)。抽象语法树是代码语法结构的一种树形表示。

1.2.3 生成代码

在进行完前面两个步骤后,JavaScript执行引擎就会进行编译。

二、作用域的分类

js有全局作用域和函数作用域以及块级作用域,作用域规则:内层作用域可以访问外层作用域,外层不能访问内层。

全局作用域

首先我们来介绍下全局作用域,全局作用域是JavaScript中最外层的作用域,在全局作用域中定义的变量和函数可以在代码的任何位置被访问。 例如下面这段代码,毋庸置疑这里会输出2,因为这里a变量的声明在整个代码块都起作用,是一个作用在全局作用域的变量。

var a = 2
switch (a) {
    case 1:
        console.log(1);
        break;
    case 2:
        console.log(2);
        break;
    default:
        console.log(3);
}// 输出2

局部作用域

在JavaScript中,局部作用域是指在函数或代码块内声明的变量只在该函数或代码块内部可见和可访问。局部作用域使得变量在特定范围内有效,避免了在全局作用域中污染变量命名空间。

我们来看一段代码,这段代码会直接报错。为什么?因为在JS中作用域有这样的规则:内层作用域可以访问外层作用域,外层不能访问内层。

function foo() {
    var a = 1  // 'a' 是函数作用域中的变量, 只能在'foo'函数内部访问
}
foo()
console.log(a); //报错:a 未定义

块级作用域

在JavaScript中块级作用域是指在代码块{ }内声明的变量只能在该块中访问,出了这个块就无法访问了。典型的代码块有:

  • 控制结构:如if,for,while等语句
  • 函数块:函数内部的{ }。
  • 普通代码块:没有特定语句的有{ }包裹的代码

我们上代码来看个例子

{
    let a = 10;
    const b = 20;
    console.log(a); // 输出 10
}
console.log(a); // 报错:a 未定义
console.log(b); // 报错:b 未定义

在上面的代码中,a和b只在 { }花括号内的块级作用域中可见,出了这个代码块就无法访问了,所有后面两个输出语句都会报错。

作用域的底层逻辑

V8引擎在执行JavaScript代码时会由调用栈创建一个执行上下文,执行上下文的创建顺序是:1.创建全局上下文 2.创建函数执行上下文 3. 创建块级执行上下文。因为调用栈也是一种栈结构,所以它也遵循先进后出的性质。我们仍然以上面的代码为例,画出它的调用栈结构帮助大家更好地理解。

image.png 如上图所示,因为一个函数执行完毕后,它的执行上下文就会被销毁。而栈结构又是自上而下出栈的,所以到了全局执行上下文时,它里面就只剩个foo函数,a自然就找不到。这也是为什么外层不能访问内层。

三、var、let、const的区别与用法

在JavaScript中,var、let和const是三种用于声明变量的关键字。它们的作用域、提升行为和重新赋值规则各不相同。以下是它们的区别和用法详细介绍。

var是JavaScript最早的变量声明方式。它具有以下特点

  1. 函数作用域:var声明的变量具有函数作用域。这意味着在函数内部声明的变量只能在函数内部访问,但如果在块级结构中声明,它依然可以在块外访问。
function example() {
    if (true) {
        var x = 10;
    }
    console.log(x); // 输出 10,虽然 `x` 声明在 if 块内,但它具有函数作用域
}
example();
  1. 变量提升:var声明的变量会在其作用域内提升到作用域的顶部。提升后,变量初始化为undefined,可以在声明前访问到该变量,但值为undefined。
console.log(a);
var a = 1   //输出undefined

如下代码,因为var声明的变量提升(将变量的声明提升到当前作用域的顶端)所以上面的代码最终会输出undefined

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

3.可重新声明和重新赋值:使用var可以多次声明同一个变量,并且可以重新赋值。

var x = 5;
var x = 10; // 重新声明并赋值,不会报错
x = 20;     // 重新赋值
console.log(x); // 输出 20

let

在上面我们提到var定义的变量可以重新赋值,这样的话在开发过程中就存在很大的安全隐患,所以在ES6中引入了let,解决了var声明带来的一些问题。let具有以下特点 1.块级作用域:let声明的变量也具有块级作用域。这意味着它只能在最近的{}代码块中访问,减少了意外污染全局作用域和函数作用域的风险。

if (true) {
    let y = 20;
}
console.log(y); // 报错:y 未定义

2.变量提升,但存在“暂时性死区”:let声明的变量会在作用域内提升。但在变量声明之前的代码中无法访问,这段无法访问的代码称为“暂时性死区”(TDZ)。访问TDZ中的变量会导致ReferenceError。

console.log(b); // 报错:Cannot access 'b' before initialization
let b = 10;

3.不能重新声明,但可以重新赋值:在同一个作用域中,let不能用于重新声明同名变量,但可以重新赋值

let z = 15;
// let z = 25; // 报错:Identifier 'z' has already been declared
z = 25;       // 重新赋值是可以的
console.log(z); // 输出 25

const

const也是在ES6中引入的,用于声明常量,即值在声明后不能重新赋值的变量。它具有以下的特点:

  1. 块级作用域:和let一样,const也具有块级作用域,即声明的变量只能在最近的代码块里访问。
if (true) {
    const c = 30;
}
console.log(c); // 报错:c 未定义
  1. 变量提升,但存在”暂时性死区“:const变量也会被提升,但在初始化之前无法访问,但同样有”暂时性死区“。
console.log(d); // 报错:Cannot access 'd' before initialization
const d = 40;
  1. 必须在声明时赋值,且不能重新赋值:使用const声明的变量在声明时必须赋值,否则会报错。此外,const声明的变量不能重新赋值。
const e = 50;
e = 60; // 报错:Assignment to constant variable
  1. 对于对象和数组,内容是可变的:虽然const声明的对象和数组引用不能改变,但对象属性和数组元素是可以改变的。
const obj = { name: "Java" };
obj.name = "python"; // 合法,可以修改对象属性
console.log(obj);  // 输出 { name: "python" }

const arr = [1, 2, 3];
arr.push(4); // 合法,可以修改数组内容
console.log(arr); // 输出 [1, 2, 3, 4]

四、补充知识:欺骗语法

在JavaScript中存在一种欺骗语法比如eval()和with(),它们的作用域规则和我们前面提到的有些许不同,接下来让我们来看看它们的作用规则。

eval函数

eval() 是一个全局函数,可以将一段字符串当作 JavaScript 代码来执行。它会在运行时解析并执行传入的字符串内容。并且eval() 会在当前作用域中执行代码,可以操作作用域中的变量。

我们来看一段代码

function foo(str, a) {
    eval(str)
    console.log(a, b);

}

var b = 2
foo('var b = 3', 1)

我们定义了一个函数,这个函数有两个形参,类型分别为str和num。因为eval可以把字符串执行,虽然var b = 2先定义,按道理来说应该会输出1 2,但最终结果输出的确是1 3,因为eval可以操作作用域内的变量。

with()语句

with 是一种用于扩展作用域链的语法。它允许你指定一个对象作为作用域,在该作用域中可以直接访问对象的属性,而无需每次都引用对象。

我们来看一段代码

let x = 10;
const obj = { y: 20 };

with (obj) {
    x = 30; // 这里修改的是全局的 x,而不是 obj 的 x
    y = 40; // 这里修改的是 obj 的 y
}
console.log(x); // 输出 30
console.log(obj.y); // 输出 40

当对象中没有属性X时,with修改X属性会导致X泄露到全局。这样会导致代码难以理解和调试,所以在JavaScript中不建议使用with语句。

五、总结

前置知识

  • JS 的执行引擎:以 V8 引擎为例,它是 Google 开发的高性能 JavaScript 执行引擎,能将 JavaScript 代码转换为机器可理解并执行的指令,驱动代码运行。
  • JS 代码的执行过程:分为词法分析(将代码字符串分解成词法单元,如关键字、标识符等)、语法分析(基于词法单元构建抽象语法树)、生成代码(进行编译)三步。

作用域与函数作用域

  • 全局作用域:是最外层的作用域,其中定义的变量和函数可在代码任何位置被访问。
  • 局部作用域:在函数或代码块内声明的变量只在该函数或代码块内部可见和可访问,遵循内层作用域可访问外层、外层不能访问内层的规则。
  • 块级作用域:在代码块(如控制结构、函数块、普通代码块)内声明的变量只能在该块中访问,出块则无法访问。
  • 作用域的底层逻辑:V8 引擎执行代码时,通过调用栈创建执行上下文,创建顺序依次为全局上下文、函数执行上下文、块级执行上下文,调用栈遵循先进后出原则,函数执行完毕其执行上下文会被销毁,这解释了外层不能访问内层作用域变量的原因。

var、let、const 的区别与用法

  • var:最早的变量声明方式,具有函数作用域,变量会在其作用域内提升到顶部且初始化为 undefined,可重新声明和重新赋值。
  • let:ES6 引入,有块级作用域,变量虽提升但存在 “暂时性死区”(声明前访问会报错),不能重新声明但可重新赋值。
  • const:ES6 引入,用于声明常量,同样有块级作用域和 “暂时性死区”,声明时必须赋值且不能重新赋值,但对于对象和数组,其内容是可变的。

补充知识:欺骗语法

  • eval 函数:是全局函数,能将字符串当作 JavaScript 代码在当前作用域中执行,可操作作用域内的变量。
  • with()语句:用于扩展作用域链,可指定对象作为作用域直接访问其属性,但可能导致变量泄露到全局,使代码难以理解和调试,不建议使用。