JavaScript底层必看:作用域、变量提升与执行上下文详解

170 阅读8分钟

引言

在JavaScript中,理解作用域、变量提升(hoisting)以及执行上下文的概念对于编写高效且可维护的代码至关重要。特别是刚接触代码的同学们看完这篇文章后,可以更好地掌握JavaScript的工作原理。本文将详细介绍这些概念,并通过示例代码来加深理解。

1. 执行上下文与调用栈

  • 执行上下文:当JavaScript代码执行时,它会创建一个执行上下文。每个函数调用都会创建一个新的执行上下文。
    执行上下文是JavaScript代码执行时的环境。每个执行上下文都有三个主要组成部分:

    1. 变量环境 (Variable Environment) : 用于存储该上下文中声明的所有变量和函数。
    2. 词法环境 (Lexical Environment) : 用于确定当前上下文中可以访问哪些外部变量。
    3. this: 指向当前上下文中的this关键字所代表的对象。
  • 调用栈:调用栈是一种数据结构,用于管理执行上下文。全局代码在最底层,每次函数调用都会将新的执行上下文推入栈顶,函数执行完毕后,该上下文被弹出栈。

function first() {
    console.log("Inside first function");
    second();
}

function second() {
    console.log("Inside second function");
}

first();

在这个例子中:

  1. 创建全局执行上下文并压入调用栈。
  2. first()被调用,创建一个新的执行上下文并将其压入调用栈顶部。
  3. first()内部调用了second(),于是再为second()创建一个新的执行上下文并压入调用栈顶部。
  4. second()执行完毕后,它的执行上下文从调用栈弹出。
  5. first()继续执行直到完成,然后其执行上下文也从调用栈弹出。
  6. 最终,只剩下全局执行上下文在调用栈中,直到整个脚本执行结束。

2. 变量提升(hoisting)与暂时性死区 (Temporal Dead Zone,TDZ)

  • 变量提升:在JavaScript中,使用var关键字声明的变量会经历一个称为“变量提升”(Variable Hoisting)的过程。这意味着无论你在代码中的哪个位置使用var声明了变量,这个声明实际上会被移动到当前作用域的最顶部。然而,需要注意的是只有变量的声明部分被提升了,而赋值(初始化)则不会被提升。因此,如果你在声明之前访问该变量,它将返回undefined

    下面通过具体的例子来说明这一点:

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

在这个例子中,尽管x的声明和赋值是在console.log(x)之后进行的,但由于变量提升的原因,实际执行过程等同于如下代码:

var x;  // 变量声明被提升到了这里
console.log(x);  // 此时x还未被赋值,所以输出为undefined
x = 10;  // 这是赋值操作,并没有被提升
console.log(x);  // 现在x已经被赋值为10,所以输出10

这解释了为什么第一次调用console.log(x)时输出undefined:因为此时x已经作为变量存在,但还没有被赋予任何值。第二次调用console.log(x)时,x已经被赋值为10,所以输出10。

此外,对于函数内部的变量提升同样适用。例如:

function testHoisting() {
    console.log(y);  // 输出: undefined
    var y = 5;
    console.log(y);  // 输出: 5
}

testHoisting();

这段代码中,虽然y的声明和赋值看起来是在console.log(y)之后,但实际上var y;已经被提升到了函数的顶部,使得首次打印y时其值为undefined

  • 函数提升:在JavaScript中,函数提升(Function Hoisting)是指无论函数声明出现在代码的哪个位置,解释器都会将其移动到当前作用域的最开始部分。这意味着您可以在实际声明函数之前就调用它。这是JavaScript的一个特性,适用于函数声明,但不直接适用于函数表达式。

    下面通过一个例子来展示函数提升的工作方式:

// 调用sayHello函数
sayHello();  // 输出: Hello, world!

// 函数声明
function sayHello() {
    console.log('Hello, world!');
}

在这个例子中,即使sayHello函数是在调用之后定义的,但由于函数提升的原因,这段代码能够正常运行而不会抛出错误。JavaScript引擎会将sayHello函数的声明提升至其所在作用域的顶部,因此等同于以下代码:

// 函数声明被提升到这里
function sayHello() {
    console.log('Hello, world!');
}

// 然后是函数调用
sayHello();  // 输出: Hello, world!

但是,请注意,如果使用的是函数表达式而不是函数声明,则情况不同。例如:

// 尝试调用myFunction
myFunction();  // TypeError: myFunction is not a function

// 函数表达式
var myFunction = function() {
    console.log('This is a function expression.');
};

在这个例子中,由于myFunction是一个函数表达式,并且赋值给变量myFunction的动作没有被提升,所以尝试在赋值前调用会导致错误。这是因为只有变量声明var myFunction;被提升了,但初始值undefined并不指向任何函数。因此,在使用函数时,理解函数声明和函数表达式的区别是很重要的。

  • 暂时性死区 (TDZ) :ES6引入了letconst,当使用letconst声明变量时,在声明之前访问这些变量会导致引用错误(ReferenceError)。这是因为从块的开始到变量声明之间的区域被称为“暂时性死区”。在这一区域内,尝试访问变量会导致错误。这与var不同,var声明的变量会被提升到其作用域的顶部,并且在声明前可以访问,尽管此时它的值为undefined

    下面是一些示例来说明letconst的暂时性死区:

使用 let

{
    console.log(a);  // ReferenceError: Cannot access 'a' before initialization
    let a = 10;
}

在这个例子中,即使let a = 10;是在console.log(a);之后声明的,但在let声明之前的任何地方尝试访问a都会导致一个引用错误,因为这个区域是暂时性死区。

对于const也有相同的行为:

{
    console.log(b);  // ReferenceError: Cannot access 'b' before initialization
    const b = 20;
}

同样地,尝试在const声明之前访问b会引发引用错误。

3. 块级作用域

  • 块级作用域letconst声明的变量具有块级作用域,即它们只在声明它们的块(如if语句、for循环等)内有效。
function foo() {
  var a = 1;
  let b = 2; // 局部于foo函数内部,但在内层块外
  
  {
    let b = 3; //局部于这个块
    var c = 4;
    let d = 5;  // 局部于这个块
    
    console.log(a);  // 1
    console.log(b);  // 3
  }
  
  console.log(b);  // 2
  console.log(c);  // 4
  console.log(d);  // ReferenceError: d is not defined
}

foo();
  • 块级作用域的生命周期
    1. 创建:当程序执行到一个带有let或const声明的代码块时,该块级作用域被创建。
    2. 活动:只要代码仍在该块内执行,这个作用域就处于活动状态,其中声明的变量可以被访问。
    3. 销毁:一旦控制流离开该代码块(即执行了最后一个花括号}之后),该块级作用域就被认为是“结束”了。此时,与该作用域相关的变量将不再可以直接访问。

lQLPKIVkGu9N1FXNAWHNAe-w1v5DBalNJ7IHLRQiwi4xAA_495_353.png

4. 词法作用域(outer属性)与作用域链

  • 词法作用域:函数的作用域是在定义时确定的,而不是在调用时。这意味着函数内部可以访问其外部作用域中的变量,但外部不能访问函数内部的变量。
  • 外层作用域:函数可以通过outer属性访问其外层作用域。
  • 作用域链:作用域链描述了当一个函数被执行时,它是如何查找变量的过程。每个执行上下文都有自己的变量对象,而这个变量对象会链接起来形成一个链条,从当前执行上下文开始向上追溯至全局执行上下文。如果在当前上下文中找不到某个变量,则会继续向上一级寻找,直到找到该变量或到达全局执行上下文为止。
function bar() {
  console.log(myname);  // lisi
}

function foo() {
  var myname = 'zhangsan';
  bar();
  console.log(myname);  // 肖总
}

var myname = 'lisi';
foo();

这段代码输出值是什么? 有些人可能觉得输出值都是zhangsan,实际上这段代码输出的是lisizhangsan
观察这张图,有一个outer属性指向了外部作用域。

lQLPJwXqKBGHWdXNAwPNAmew6gvQO4P_WwgHLRQsM_5LAA_615_771.png

由于bar是在全局作用域当中定义的,即使在foo中被调用,outer指向的还是全局作用域,不会经过foo。意味着如果在全局中没有定义var myname = 'lisi',会输出myname is undefined

总结

  • 执行上下文:管理代码执行的环境。
  • 词法环境与变量环境:分别存储变量和函数的标识符及其对应的值。
  • 调用栈:管理执行上下文的数据结构。
  • 变量提升var声明的变量会被提升到其所在作用域的顶部。
  • 暂时性死区 (TDZ)letconst声明的变量在声明之前不可访问。
  • 块级作用域letconst声明的变量具有块级作用域。
  • 词法作用域:函数的作用域在定义时确定。
  • 作用域链:变量查找从当前作用域逐级向上查找。

通过理解这些概念,你可以更好地编写和调试JavaScript代码,避免常见的陷阱,并提高代码的质量。