JavaScript 的作用域

93 阅读9分钟

JavaScript 的作用域

JavaScript 的作用域是惯常被讨论的基础概念之一,它定义了变量和函数的可访问范围,并是实现代码优化和充分别基础的举证。在这里,我们将对 JavaScript 的作用域进行深入分析,包括它的类型、特性和应用场景。

什么是作用域?

简单来说,作用域就是变量和函数的可访问性和生命周期。它决定了在哪些地方可以访问到某个变量或函数。你可以把它想象成一个“地盘”,变量在这个地盘里有效,出了这个地盘就可能无效或者访问不到了。

更正式的说法是,作用域是指程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期。

1. 作用域类型

JavaScript 中重要的作用域类型包括:

全局作用域 (Global Scope)

  • 全局作用域中的变量可在任何场景下被访问。
  • 全局作用域上的变量通常会被挂载到 window 对象(在浏览器中)或 global 对象(在 Node.js 中)。
  • 实例:
    var globalVar = "I am global";
    console.log(window.globalVar); // "I am global" (Browser Environment)
    

函数作用域 (Function Scope)

  • 变量在它所属的函数之内有效。
  • JavaScript 通过函数作用域实现了代码封装和隔离。
  • 实例:
    function example() {
        var localVar = "I am local";
        console.log(localVar); // "I am local"
    }
    console.log(localVar); // ReferenceError: localVar is not defined
    

块作用域 (Block Scope)

ES6 引入了块级作用域,通过letconst 关键字声明的变量拥有块级作用域。块级作用域存在于以下代码块中:

  • 函数内部
  • if 语句块
  • switch 语句块
  • for 循环块
  • while 循环块
  • do...while 循环块
  • letconst 声明的变量仅在块作用域内有效,而 var 不受块作用域限制。
  • 实例:
    {
        let blockVar = "I am block scoped";
        console.log(blockVar);
        // "I am block scoped"
    }
    console.log(blockVar); 
    // ReferenceError: blockVar is not defined
    
    

词法作用域 (Lexical Scope)

  • JavaScript 采用的是词法作用域,也称为静态作用域。这意味着变量的作用域在代码编写时就已经确定,而不是在运行时确定。函数的作用域基于函数在代码中声明的位置来决定。JavaScript 使用词法作用域,也称为静态作用域。这意味着函数的作用域在函数定义时已经确定,而不是在调用时。
  • 实例:
    function outer() {
        let outerVar = "I am from outer";
        function inner() {
            console.log(outerVar); 
            // "I am from outer"
        }
        inner();
    }
    outer();
    
模块作用域 (Module Scope)
  • ES6 模块作用域提供了独立的作用域,使模块不会交差。

  • 模块内的变量和函数需要明确导出,才能被其他模块访问。

  • 实例:

    // module.js
    export const a = 42;
    
    // main.js
    import { a } from './module.js';
    console.log(a); 
    // 42
    

2. 作用域链 (Scope Chain)

基本概念

  • 作用域链是当一个变量被调用时,JavaScript 通过相关作用域一级级向上查找,直至全局作用域。
  • 如果在查找过程中没有找到应该变量,则报错。

作用域链的创建过程

当函数被创建时,会创建一个内部属性 [[Scope]],它指向该函数创建时所在的词法环境。这个词法环境包含了该函数可以访问的所有变量和函数。

当函数被调用时,会创建一个新的执行上下文。这个执行上下文的词法环境会复制函数 [[Scope]] 属性中的词法环境,并创建一个新的环境记录(Environment Record),用于存储该函数执行过程中的变量和函数。

这个新的词法环境的外部环境引用(Outer Environment Reference)会指向函数 [[Scope]] 属性中的词法环境,从而形成作用域链。

实例
const globalVar = "global";

function outer() {
    const outerVar = "outer";
    function inner() {
        const innerVar = "inner";
        console.log(globalVar); // "global"
        console.log(outerVar); // "outer"
        console.log(innerVar); // "inner"
    }
    inner();
}
outer();

特性

  • 作用域链只能向上查找,不能向下访问。
  • 这使得上级作用域不会因下级作用域内部变量的存在而发生改变。

3. 闭包与作用域

闭包是指函数与其周围状态(词法环境)的捆绑。换句话说,闭包允许函数访问其外部作用域的变量,即使在其外部作用域已经执行完毕之后。

闭包的形成:当一个函数在其内部定义了另一个函数,并且内部函数引用了外部函数的变量时,就形成了闭包。

闭包的作用:

  • 访问函数内部的变量: 即使外部函数已经执行完毕,内部函数仍然可以访问其变量。
  • 创建私有变量: 通过闭包可以模拟私有变量,避免全局变量污染。

闭包的缺点:

  • 内存泄漏: 由于闭包会持有外部作用域的变量,如果使用不当,可能会导致内存泄漏。

闭包是指函数不仅可以访问自身的作用域,还可以访问定义时所在作用域的变量。

实现

function outerFunction() {
  let outerVar = "I'm from outer";

  function innerFunction() {
    console.log(outerVar); // 内部函数访问外部函数的变量
  }

  return innerFunction; // 返回内部函数
}

let myClosure = outerFunction(); // 调用外部函数,返回内部函数
myClosure(); // 调用内部函数,输出 "I'm from outer"

应用场景

  • 数据隔离:通过闭包实现封装和隔离数据,避免全局变量写入日志。
  • 活动跟踪:完成对负责数据的完全隔离。

4.变量提升(Hoisting)

在 JavaScript 中,变量和函数的声明会被提升到其作用域的顶部。这意味着你可以在声明变量之前使用它,但其值为 undefined。使用 let 和 const 声明的变量也会提升,但它们不会被初始化,如果在声明之前访问它们,会抛出一个 ReferenceError。

// 全局作用域
var globalVar = "I'm global";

function myFunction() {
  // 函数作用域
  var functionVar = "I'm in function";
  console.log(globalVar); // 可以访问全局变量
  console.log(functionVar); // 可以访问函数内部变量

  if (true) {
    // 块级作用域
    let blockVar = "I'm in block";
    console.log(blockVar); // 可以访问块级变量
  }

  // console.log(blockVar); // 报错:blockVar is not defined,块级变量在块外部无法访问
}

myFunction();
console.log(globalVar); // 可以访问全局变量
// console.log(functionVar); 
// 报错:functionVar is not defined,函数内部变量在函数外部无法访问

5. 立即执行函数表达式(Immediately Invoked Function Expression,IIFE)

IIFE 是一种在定义后立即执行的函数。它可以创建一个独立的作用域,避免全局变量污染。

IIFE 的写法:

(function() {
  // 代码
})();

// 或
!function() {
  // 代码
}();

6. this 关键字与作用域的区别

this 关键字和作用域是两个不同的概念。this 关键字是在函数调用时确定的,而作用域是在代码编写时确定的。this 关键字指向的是函数执行时的上下文对象,而作用域决定了变量的可访问性。

JavaScript 作用域是面试中一个非常重要的考点,它涉及到变量的可见性和生命周期。理解作用域对于编写高效且无 bug 的 JavaScript 代码至关重要。下面我将总结一些常见的 JavaScript 作用域面试题,并进行详细的解答,希望能帮助你更好地理解和掌握这部分知识。

7. 关于作用域的常见面试题

1. 什么是作用域?

作用域是指在程序中定义变量的区域。它决定了变量的可见性和生命周期,即在哪些地方可以访问变量,以及变量何时被创建和销毁。

2. JavaScript 中有哪些类型的作用域?

  • 全局作用域(Global Scope): 在函数外部声明的变量拥有全局作用域。全局变量在整个脚本中都可访问。在浏览器环境中,全局作用域是 window 对象。
  • 函数作用域(Function Scope): 在函数内部声明的变量拥有函数作用域。这些变量只能在函数内部访问。
  • 块级作用域(Block Scope): 使用 letconst 关键字声明的变量拥有块级作用域。块级作用域存在于 if 语句、for 循环、while 循环等代码块中。

3. varletconst 声明变量的区别?

特性varletconst
作用域函数作用域/全局作用域块级作用域块级作用域
变量提升存在变量提升(hoisting)不存在变量提升不存在变量提升
重复声明允许在同一作用域内重复声明同名变量不允许在同一作用域内重复声明同名变量不允许在同一作用域内重复声明同名变量
修改变量可以修改变量的值可以修改变量的值声明时必须初始化,之后不能修改变量的值(对于对象和数组,可以修改其属性或元素)

4. 什么是变量提升(Hoisting)?

变量提升是指在 JavaScript 代码执行前,JavaScript 引擎会将变量和函数的声明“提升”到其作用域的顶部。使用 var 声明的变量会被提升到其作用域的顶部,并被初始化为 undefined。而使用 letconst 声明的变量也会被提升,但它们不会被初始化,如果在声明之前访问这些变量,会导致 ReferenceError

5. 什么是作用域链(Scope Chain)?

当在 JavaScript 中访问一个变量时,JavaScript 引擎会首先在当前作用域中查找该变量。如果找不到,它会沿着作用域链向上查找,直到找到该变量或到达全局作用域。作用域链是由当前作用域和所有父级作用域组成的链式结构。

6. 什么是闭包(Closure)?

闭包是指函数与其周围状态(词法环境)的捆绑。换句话说,闭包允许函数访问其外部作用域的变量,即使在其外部函数执行完毕后仍然可以访问。

7. 常见的面试题及解答:

例题 1:

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

解答:

  • 第一次 console.log(a) 输出 undefined。因为在 foo 函数内部,var a = 2 存在变量提升,所以在 console.log(a) 执行时,a 已经被声明,但尚未赋值,所以输出 undefined
  • 第二次 console.log(a) 输出 2
  • 最后一次 console.log(a) 输出 1。因为全局变量 a 的值是 1

例题 2:

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}

解答:

这段代码会输出五个 5。因为 setTimeout 是异步执行的,当循环结束后,i 的值已经变成了 5。而 setTimeout 中的函数执行时,访问的是全局变量 i,所以都输出了 5

改进方法:

  • 使用闭包:
for (var i = 0; i < 5; i++) {
  (function(i) {
    setTimeout(function() {
      console.log(i);
    }, 1000);
  })(i);
}
  • 使用 let
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}

使用 let 声明的 i 具有块级作用域,每次循环都会创建一个新的 i,所以 setTimeout 中的函数可以正确地访问到对应的 i 值。