干货分享(一)——深入理解JS作用域

1,842 阅读12分钟

引言

在JavaScript领域里,作用域是一个非常重要且让人头疼的概念。作用域定义了变量和函数的可访问性以及生命周期。如果你是一个JavaScript新手,可能会被作用域概念绕晕了,但别担心,今天我就来和大家一起详细聊一聊JavaScript的作用域。

什么是作用域

首先,相信大家都知道一个概念——变量,而在变量使用中常常会遇到两个重大的问题:变量存储在哪里?程序需要时又该如何找到它们?JS想要解决这两个问题,也就需要一套设计良好的规则来存储变量,并且之后可以方便的找到这些变量,这套规则就是作用域。换句话说,作用域就是根据名称查找变量的一套规则

而对于作用域最常见的书面解释为: 作用域是指变量或函数在代码中可访问的范围。作用域确定了一个标识符(如变量、函数等)的有效性和可见性。作用域规定了程序中某个特定部分的代码可以访问哪些变量,以及在特定时刻可以访问哪些变量。

作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域,JS采用的也是这种模型;另一种是动态作用域。

在 JavaScript 中,作用域可以分为全局作用域函数作用域块级作用域

全局作用域

全局作用域(Global Scope)指的是在整个程序中都可见的变量和函数的作用域范围。在 JS 中,全局作用域是程序的最外层作用域,任何在全局作用域中声明的变量或函数都可以在程序的任何地方被访问。全局作用域中定义的变量和函数被称为全局变量和全局函数。

全局作用域有以下特点:

  1. 范围:全局作用域包括了整个 JavaScript 程序的范围,在全局作用域中定义的变量和函数可以被任何部分的代码访问。
  2. 生命周期:全局作用域的生命周期从程序开始执行直到程序结束,全局变量在整个程序执行期间都存在。
  3. 访问性:全局作用域的变量可以被任何部分的代码访问,包括函数内部和外部,但需要注意避免全局变量被滥用导致变量污染和命名冲突。
  4. 定义方式:在 JavaScript 中,如果在最外层(函数外部)的代码中声明变量或函数,那么它们会被认为是在全局作用域中定义。

举例来说,以下是一个简单的例子展示了全局作用域的定义:

var globalVar = 10;

function globalFunction() {
    console.log("This is a global function");
}

console.log(globalVar); // 输出:10
globalFunction(); // 输出:This is a global function

在这个例子中,globalVar 和 globalFunction 都是在全局作用域中定义的,因此可以在整个程序中的任何地方被访问。

函数作用域

函数作用域(Function Scope)是指变量在函数内部定义时的作用域范围。在函数作用域中声明的变量只能在该函数内部被访问,外部代码无法访问到函数内部的变量。

函数作用域的特点包括:

  1. 范围:在函数内部声明的变量仅在该函数内部可见,函数外部无法直接访问这些变量。
  2. 生命周期:函数作用域中的变量生命周期与函数执行的生命周期相关,当函数执行完成后,这些变量会被销毁,释放内存空间。
  3. 封闭性:函数作用域中的变量对外部作用域是私有的,在函数外部无法直接访问函数内部的变量。

示例如下:

function functionScopeExample() { 
var localVar = "I'm a local variable"; 
console.log(localVar); // 可以在函数内部访问 localVar 
} 

// console.log(localVar); // 尝试在函数外部访问 localVar 会导致 ReferenceError 
functionScopeExample();

在这个例子中,localVar 是在函数 functionScopeExample 内部声明的局部变量,只能在该函数内部被访问。尝试在函数外部访问 localVar 会导致 ReferenceError,因为它的作用域限定在函数内部。

通过函数作用域,可以有效控制变量的可见性,避免不必要的干扰和混乱,同时提高代码的模块化和可维护性。

块级作用域

块级作用域是指变量或函数在一个代码块(由一对花括号 {} 包围的区域)内部可见的范围。在块级作用域中声明的变量或函数只在该代码块内部有效,而在代码块外部不可访问。

在 JavaScript 中,通过使用 letconst 关键字可以创建块级作用域,因为它们的作用范围仅限于声明它们的代码块内部。相比之下,使用 var 声明的变量在整个函数内都是可见的。

块级作用域的主要特点包括:

  1. 可见性:在块级作用域内部声明的变量只在该代码块内部可见,外部无法访问。这有助于避免变量污染和冲突。
  2. 局部性:块级作用域让程序员更容易理解代码,因为变量只在其被定义的块中有效,减少了变量在不应该被访问的情况下被访问的可能性。

示例如下:

// 使用 let 创建块级作用域 
function blockScopeExample() { 
    let localVar = 'I am a local variable'; 
    if (true) { 
        let blockVar = 'I am a block-scoped variable'; 
        console.log(blockVar); // 在代码块内可以访问 blockVar 
    } 
    // console.log(blockVar); // 尝试在这里访问 blockVar 会导致 ReferenceError 
} 
// console.log(localVar); // 尝试在这里访问 localVar 会导致 ReferenceError 
// console.log(blockVar); // 尝试在这里访问 blockVar 会导致 ReferenceError 
blockScopeExample(); 

在这个示例中,localVar 是在函数内部使用 let 声明的局部变量,只在该函数内部可见。blockVar 是在 if 代码块内部使用 let 声明的块级作用域变量,只在该代码块内部可见。

作用域访问规则

在日常使用中,作用域往往不会孤立存在。多个作用域相互嵌套是非常正常的现象。

如下图所示,就有3个逐级嵌套的作用域,每个气泡代表一个作用域。

A882B1A00A65946059161D7CD6B6B28E.jpg

而对于作用域的访问规则:内层作用域可以访问外层作用域, 反之则不行,并且作用域会在找到第一个匹配的标识符时停止。

示例:

var a = 1;
var b = 2;

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

foo();

根据以上规则,内部函数可以使用与外部相同的标识符而不产生冲突,因为作用域会在找到第一个匹配的标识符时停止。

var和let的区别

在块级作用域的说明中我们使用了一个新的关键字let,下面我们就来介绍一下let,以及它与var的区别。

let 是 ES6(ECMAScript 2015)引入的关键字(用来解决var所带来的一些bug),在使用上与var一样,也用于变量声明。

以下是它与var之间的区别:

  1. 作用域
  • var:使用 var 声明的变量属于函数作用域,即在声明的函数内部有效,跨越代码块。如果在函数内部使用 var 声明变量,它在整个函数内都是可见的。
  • let:使用 let 声明的变量属于块级作用域,即在包含该变量的代码块内有效。
  1. 声明提升
  • varvar声明的变量存在变量提升的特性,即无论变量声明在哪里,都会被提升到函数作用域的顶部。这意味着可以在变量声明之前访问这些变量,但它们的值会是 undefined
  • letlet 声明的变量不存在变量提升,如果在变量声明之前访问这些变量,会导致暂时性死区(Temporal Dead Zone),即在这段代码块中变量被声明但是仍不可访问使用。
  1. 重复声明
  • var:可以对同一个变量使用 var 进行多次声明而不会抛出错误,新的声明将覆盖之前的声明。
  • let:不允许对同一个变量使用 let 进行重复声明,否则会引发语法错误。
  1. 全局对象属性
  • var:使用 var 声明的全局变量会成为全局对象(如 window)的属性。
  • let:使用 let 声明的变量不会成为全局对象的属性,从而避免全局变量的污染。

声明提升

在 JavaScript 中,声明提升(Hoisting)是一种特性,它指的是在代码执行过程中,JavaScript 引擎会将变量声明和函数声明提升到其所在作用域的顶部

这意味着在代码执行之前,JavaScript 引擎会先处理变量和函数的声明,使它们在作用域中可被访问,而赋值操作则留在原来的位置

声明提升适用于以下两种情况:

  1. 变量声明提升
  • 使用 var 声明的变量会被提升到当前作用域的顶部,但并不会提升变量的赋值。这意味着可以在变量声明之前访问该变量,其值会是 undefined

示例:

console.log(myVariable); // 输出:undefined 
var myVariable = 10; 

而如果把var换成没有声明提升的let就会出现报错——ReferenceError: Cannot access 'myVariable' before initialization

  1. 函数声明提升
  • 函数声明也会被提升到其所在作用域的顶部,使函数在声明之前就可以被调用。

示例:

myFunction(); // 输出:Hello! 
function myFunction() { 
console.log("Hello!"); 
} 

需要注意的是,虽然变量和函数的声明会被提升,但赋值操作不会被提升,仅限于声明部分。

暂时性死区

在 JavaScript 中,暂时性死区(Temporal Dead Zone,TDZ)是指使用 letconst 关键字声明变量时,变量在当前作用域中存在但尚未初始化的阶段。在这个阶段,如果尝试访问这些变量,就会触发暂时性死区错误,即在声明前访问会导致 ReferenceError。

暂时性死区的关键点如下:

  1. 变量存在但未初始化:在进入变量声明语句所在的作用域时,变量已经存在,但尚未被赋值初始化。
  2. 访问导致错误:在暂时性死区中,尝试访问这些变量会触发 ReferenceError,因为 JavaScript 引擎认为在声明前不能安全地使用这些变量。
  3. 仅适用于 let 和 const:暂时性死区仅适用于使用 letconst 声明的变量,因为 var 声明的变量会被提升至作用域的顶部,不会进入暂时性死区。

示例:

var a = 1;

if (1) {
    console.log(a); //输出 ReferenceError: Cannot access 'a' before initialization
    let a = 2;
}

按照我们的惯性思维,程序运行到console.log(a);let a = 2;还未运行而let又不存在声明提升,那么他在当前作用域中找不到a,就会外层作用域寻找从而输出1。但结果并不如此,这是因为:

当 JavaScript 引擎编译代码时,它会扫描整个作用域来找出变量声明,而在暂时性死区中,变量虽然已经被声明,但尚未初始化。在这种情况下,JavaScript 引擎仍然会为这些变量分配内存空间,这样就可以捕获尝试访问尚未初始化变量的错误。这就是为什么会出现 ReferenceError 错误,即使 let 声明不会被提升,JavaScript 引擎仍然能够识别这些变量存在于暂时性死区内。

因此,当在 console.log(a); 中尝试访问在 let a = 2; 声明之前的变量 a 时,由于变量 a 存在于暂时性死区中,JavaScript 引擎会抛出 ReferenceError 错误。

总结来说,尽管 let 声明不存在变量提升,但在暂时性死区内,仍然会对变量进行一些提前的处理,以确保在访问未初始化变量时能够捕获错误。

欺骗词法作用域

正常来说作用域完全由写代码期间所声明的位置来定义,但是仍存在一些方法,可以在词法分析器处理之后依旧可以修改作用域。

1. eval

eval(...)函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在与程序中这个位置一样。

示例:

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

按照常规逻辑,console.log(a, b)应该输出 1 2,但是由于eval(str)的作用,引擎并不知道var b = 3;是动态插入进来的,而认为它本来就在这里,从而b在本作用域中找到了相应的标识符从而输出 1 3;

2. with

通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

示例示例:

var obj = {
    a: 1,
    b: 2
};

with (obj) {
    a = 2;
    b = 3;
}

console.log(obj); // 输出 { a: 2, b: 3 }

这样看没有什么问题,甚至还很方便,但是再看看以下代码:

var obj = {
    a: 1,
};

with (obj) {
    a = 2;
    b = 3;
}

console.log(obj); //输出 { a: 2 }
console.log(b); // 输出 3

正常来说应该返回ReferenceError: b is not defined,但是with在使用时会产生一个bug—— 如果修改的属性在原对象中不存在,那么该属性就会泄露到全局

结语

在本篇文章中,我们一起探讨了三种不同的作用域、作用域访问规则、let和var的区别、声明提升、暂时性死区以及欺骗词法作用域的相关内容。希望通过这篇文章,你对JavaScript作用域有了更深入的了解。今天的内容就到这里了。如果你觉得这篇文章有帮助或有所启发,别忘了给我一个鼓励的赞哦!