JavaScript 作用域详解与闭包

126 阅读8分钟

引言

JavaScript 作为一种广泛使用的编程语言,其作用域机制是理解代码行为的关键。本文将深入探讨 JavaScript 的作用域概念,并通过几个具体的代码示例来解释变量的声明、赋值和查找过程。我们将从基础的变量声明开始,逐步分析不同作用域下的变量行为,包括函数作用域、块级作用域、全局作用域以及严格模式下的行为差异。

作用域概述

在 JavaScript 中,作用域决定了变量的可访问性和生命周期。作用域可以分为以下几种类型:

  1. 全局作用域:在代码的任何地方都可以访问的变量。
  2. 函数作用域:在函数内部声明的变量,只能在该函数内部访问。
  3. 块级作用域:在 {} 块内部声明的变量,只能在该块内部访问。

变量声明关键字

JavaScript 提供了多种声明变量的方式,包括 varletconst。这些关键字在作用域和变量提升方面有所不同:

  • var:具有函数作用域或全局作用域,支持变量提升。
  • let:具有块级作用域,不支持变量提升。
  • const:具有块级作用域,声明后不能重新赋值。

在 JavaScript 中,varlet 都是用来声明变量的关键字,但它们之间存在一些重要的区别,主要体现在作用域、变量提升(hoisting)、以及重复声明处理上。下面是这些区别的详细说明:

  • var:

    • var 声明的变量具有函数作用域(function scope)或全局作用域(global scope)。
    • 如果 var 声明的变量位于函数内部,则该变量的作用域限制在该函数内。
    • 如果 var 声明的变量位于函数外部,则该变量的作用域为全局作用域。
  • let:

    • let 声明的变量具有块级作用域(block scope)。
    • 块级作用域是指 {} 包围的代码块,例如循环、条件语句等。
    • let 声明的变量只在声明它的代码块内部可见。

变量提升(Hoisting)

  • var:

    • var 声明的变量会被提升到其作用域的顶部,但初始化不会被提升。
    • 这意味着你可以在声明之前访问变量,但其值会是 undefined
  • let:

    • let 声明的变量也会被提升到其作用域的顶部,但不会被初始化。
    • 在声明之前访问 let 声明的变量会导致一个“暂时性死区”(Temporal Dead Zone, TDZ)错误,即 ReferenceError

重复声明

  • var:

    • 允许在同一作用域内多次声明同一个变量,不会报错,但后面的声明会覆盖前面的声明。
  • let:

    • 不允许在同一作用域内多次声明同一个变量,会抛出一个 SyntaxError

示例

var 示例

javascript
function exampleVar() {
  console.log(a); // undefined
  var a = 10;
  if (true) {
    var a = 20;
    console.log(a); // 20
  }
  console.log(a); // 20
}

exampleVar();
let 示例
javascript
function exampleLet() {
  console.log(b); // ReferenceError: Cannot access 'b' before initialization
  let b = 10;
  if (true) {
    let b = 20;
    console.log(b); // 20
  }
  console.log(b); // 10
}

exampleLet();

小总结

  • var 适用于需要函数作用域或全局作用域的场景。
  • let 适用于需要更精确控制变量作用域的场景,特别是在块级作用域内。

通常推荐使用 letconst 来声明变量,因为它们提供了更好的作用域控制和避免了一些常见的变量提升问题。

作用域链

在 JavaScript 中,每个执行上下文(如全局上下文、函数上下文等)都有一个与之关联的作用域链。作用域链用于解析标识符(如变量名、函数名等)。当 JavaScript 引擎在当前作用域中找不到某个标识符时,它会沿着作用域链向上查找,直到找到该标识符或到达全局作用域。

作用域链的构建

  1. 全局执行上下文:全局作用域是最外层的作用域,包含所有全局变量和函数。
  2. 函数执行上下文:每个函数调用都会创建一个新的执行上下文,其作用域链包括当前函数的作用域和其父级作用域。
  3. 块级执行上下文:使用 let 和 const 声明的变量具有块级作用域,其作用域链包括当前块和其父级作用域。

image.png

示例解析

示例 1:嵌套作用域

javascript
var a = 1
var b = 4
function foo() {
  var a = 2
  function bar() {
    var a = 3
    return a + b
  }
  console.log(a, b, c)  // 报错:c 未定义
}
foo()

在这个示例中,我们定义了三个变量 abc。变量 ab 在全局作用域和函数 foo 内部都有声明,而 c 只在 foo 内部被引用但未声明。

  • 全局作用域

    • var a = 1
    • var b = 4
  • 函数 foo 作用域

    • var a = 2:遮蔽了全局的 a,但在 foo 内部有效。
    • b:继承自全局作用域,值为 4。
    • c:未声明,导致 ReferenceError
  • 函数 bar 作用域

    • var a = 3:遮蔽了 foo 内部的 a,但在 bar 内部有效。
    • b:继承自全局作用域,值为 4。

示例 2:变量重复声明

javascript
var b = 1
function foo() {
  var a = 1
  // var a 会被忽略
  var a = 2
  console.log(a, b)  // 输出:2, 1
}
foo()

在这个示例中,我们在函数 foo 内部两次声明了变量 a。虽然第二次声明会被忽略,但最终 a 的值为 2。

  • 全局作用域

    • var b = 1
  • 函数 foo 作用域

    • var a = 1:声明并初始化 a
    • var a = 2:重新赋值 a,但不会重新声明。
    • console.log(a, b):输出 2 和 1。

示例 3:RHS 查找失败

javascript
// ReferenceError: b is not defined
// RHS 查找 失败时,会爆出  ReferenceError 异常
function foo(a) {
  console.log(a + b)  // 报错:b 未定义
  b = a
}
foo(2)

在这个示例中,我们在函数 foo 内部尝试访问未声明的变量 b,导致 ReferenceError

  • 函数 foo 作用域

    • a:参数,值为 2。
    • console.log(a + b):尝试访问未声明的 b,导致 ReferenceError
    • b = a:如果 b 已经声明,则将其赋值为 2。

示例 4:全局变量 vs 局部变量

javascript
function foo() {
  b = 2;   // LHS 查询,默认声明变量
}
foo()
// 这里的 b 是全局变量
console.log(b)  // 输出:2

// function foo() {
//   var b = 2;   // LHS 查询,默认声明变量
// }
// foo()
// console.log(b)  // 报错:b 未定义

在这个示例中,我们展示了全局变量和局部变量的区别。

  • 全局作用域

    • b:通过 b = 2 声明为全局变量。
  • 函数 foo 作用域

    • b = 2:如果没有使用 var,则 b 会成为全局变量。
    • console.log(b):输出 2。

如果使用 var 声明 b,则 b 会成为局部变量,外部无法访问。

示例 5:严格模式

javascript
"use strict"
// 严格模式时
function foo() {
  b = 2  // 报错:b 未声明
}
foo()
console.log(b)

在严格模式下,未声明的变量赋值会导致 ReferenceError

  • 全局作用域

    • b:未声明。
  • 函数 foo 作用域

    • b = 2:在严格模式下,未声明的变量赋值会导致 ReferenceError

示例 6:变量提升与类型错误

javascript
var a   // undefined
a = 2  // LHS 负责 number
a()    // RSH  a number 没有执行操作
// TypeError: a is not a function

在这个示例中,我们展示了变量提升和类型错误。

  • 全局作用域

    • var a:声明 a,初始值为 undefined
    • a = 2:将 a 赋值为 2。
    • a():尝试将 a 作为函数调用,导致 TypeError

示例 7:函数声明与调用

javascript
function a() {
  console.log(1)
}
a()  // 输出:1

在这个示例中,我们展示了函数声明和调用。

  • 全局作用域

    • function a():声明函数 a
    • a():调用函数 a,输出 1。

闭包

闭包是 JavaScript 中一个非常重要的概念,它允许一个函数访问其外部作用域中的变量,即使该外部作用域已经关闭。

闭包的形成

闭包的形成涉及以下三个条件:

  1. 内部函数:一个函数内部定义的另一个函数。
  2. 外部作用域:内部函数可以访问其外部作用域中的变量。
  3. 返回或保存:内部函数被返回或以某种方式保存,使得它可以继续访问外部作用域中的变量。

闭包示例

javascript
function createCounter() {
  let count = 0;
  return function() {
    count++;
    console.log(count);
  }
}

const counter = createCounter();
counter();  // 输出:1
counter();  // 输出:2
counter();  // 输出:3

在这个示例中,createCounter 函数返回了一个内部函数,该内部函数可以访问 createCounter 函数中的 count 变量。即使 createCounter 函数已经执行完毕,内部函数仍然可以访问 count 变量。

  • 全局作用域

    • createCounter:定义并调用 createCounter 函数,返回一个内部函数。
    • counter:保存返回的内部函数。
  • 内部函数作用域

    • count:在 createCounter 函数中声明,被内部函数访问。
    • count++:每次调用 counter 时,count 增加 1 并输出。

闭包的应用

  1. 数据封装:闭包可以用来封装私有数据,防止外部直接访问。
  2. 模块化:闭包可以用来实现模块化编程,隐藏内部实现细节。
  3. 回调函数:闭包在异步编程中非常有用,可以保持状态信息。

结论

通过上述示例,我们可以看到 JavaScript 的作用域机制在变量声明、赋值和查找过程中起着至关重要的作用。理解这些机制有助于编写更高效、更可靠的代码。特别是 varlet 的区别,以及严格模式下的行为差异,都是开发中需要注意的重要细节。

闭包作为 JavaScript 中的一个重要概念,不仅能够实现数据封装和模块化,还在异步编程中发挥重要作用。希望本文能帮助你更好地理解和应用 JavaScript 的作用域和闭包概念。如果你有任何疑问或建议,欢迎留言交流。

image.png