深入理解 JavaScript 中的上下文、作用域与闭包

104 阅读6分钟

深入理解 JavaScript 中的上下文、作用域与闭包

作为前端开发者,我们每天都在与变量、函数打交道,但你是否真正搞懂过这些代码背后的运行机制?本文将带你深入剖析 JavaScript 中的三个核心概念 —— 上下文、作用域与闭包,帮你理清它们之间的联系与区别,写出更优雅、更可维护的代码。

一、上下文与作用域:别再搞混这两个概念

很多开发者会把上下文(Context)和作用域(Scope)混为一谈,但实际上它们是两个完全不同的概念:

  • 作用域:基于函数,决定了变量的访问权限和可见性
  • 上下文:基于对象,决定了 this 关键字的指向

简单来说,作用域回答了 "变量在哪里可以被访问" 的问题,而上下文回答了 "当前代码被哪个对象调用" 的问题。

举个例子帮助理解:

const obj = {
  name: '张三',
  sayHi() {
    console.log(this.name); // 上下文决定this指向obj
    function inner() {
      console.log(name); // 作用域决定这里会报错(找不到name)
    }
    inner();
  }
};
obj.sayHi();

在这个例子中,sayHi 方法中的 this 指向调用它的 obj(上下文),而 inner 函数中访问 name 时,会沿着作用域链向上查找,但全局作用域中并没有 name 变量,所以会报错。

二、执行上下文:函数运行的 "环境配置"

每当函数运行时,JavaScript 引擎会创建一个执行上下文(Execution Context)对象,它就像一个临时的运行环境,记录了函数运行所需的所有信息。

执行上下文有三个重要特性:

  1. 独立性:每次调用函数都会创建一个全新的执行上下文,即使是同一个函数多次调用,也会生成不同的执行上下文
  2. 栈结构:执行上下文以栈(调用栈)的形式管理,全局上下文在最底部,每次调用函数都会将其执行上下文压入栈顶
  3. 单线程执行:JavaScript 引擎每次只执行栈顶的执行上下文

执行上下文的生命周期可以分为三个阶段:

  • 创建阶段:确定 this 指向、创建变量对象、建立作用域链
  • 执行阶段:变量赋值、函数引用、执行代码
  • 销毁阶段:执行完毕后弹出调用栈,等待垃圾回收

当 JavaScript 代码首次运行时,会先创建全局执行上下文。之后每调用一个函数,就会创建一个新的执行上下文并压入栈顶,执行完成后再弹出栈。

三、作用域链:变量查找的 "路线图"

函数是一种特殊的对象,它有一个内部属性 [[Scope]](我们无法直接访问),这个属性存储了函数创建时的作用域信息。当函数执行时,[[Scope]] 会和当前执行上下文的变量对象组合形成作用域链(Scope Chain)。

作用域链就像一张变量查找的 "路线图",当我们访问一个变量时,JavaScript 引擎会沿着作用域链从内到外依次查找,直到找到该变量或到达全局作用域。

来看一个经典的嵌套函数示例:

function test1() {
    let color = "red"
    function test2() {
        color = "blue"  // 可以访问test1中的color
        let height = 10
        function test3() {
            color = "yellow"  // 可以访问test1中的color
            height = 20       // 可以访问test2中的height
            let weight = 10   // 仅在test3中可见
        }
    }
}

它们的作用域链关系如下:

┌─────────────────────────────────────┐
│         全局作用域 (Global Scope)    │
│  - 包含test1函数声明                │
└───────────────────┬─────────────────┘
                    ↓
┌─────────────────────────────────────┐
│        test1作用域 (Function Scope)  │
│  - 变量:color = "red"              │
│  - 包含test2函数声明                │
│  - 作用域链:自身 → 全局作用域      │
└───────────────────┬─────────────────┘
                    ↓
┌─────────────────────────────────────┐
│        test2作用域 (Function Scope)  │
│  - 变量:height = 10                │
│  - 包含test3函数声明                │
│  - 作用域链:自身 → test1 → 全局    │
└───────────────────┬─────────────────┘
                    ↓
┌─────────────────────────────────────┐
│        test3作用域 (Function Scope)  │
│  - 变量:weight = 10                │
│  - 作用域链:自身 → test2 → test1 → 全局 │
└─────────────────────────────────────┘

这种内层作用域可以访问外层作用域变量的特性,称为攀爬作用域链。值得注意的是,作用域链是单向的,外层作用域无法访问内层作用域的变量。

四、闭包:JavaScript 中的 "变量保鲜盒"

有了作用域链的基础,理解闭包就变得简单了。当内层函数引用了外层函数的变量,并且内层函数被返回到外层函数之外执行时,即使外层函数已经执行完毕,它的变量依然会被内层函数保留在内存中。这些被保留的变量集合,就称为闭包

闭包的经典应用场景:

function createCounter() {
  let count = 0; // 被闭包保留的变量
  return {
    increment() {
      count++;
      return count;
    },
    decrement() {
      count--;
      return count;
    },
    getCount() {
      return count;
    }
  };
}

const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1

在这个例子中,createCounter 函数执行完毕后,count 变量并没有被销毁,因为返回的对象中的方法引用了它,形成了闭包。

柯里化:闭包的典型应用

函数柯里化是闭包的一个重要应用,它指的是将一个多参数函数转换为一系列单参数函数的过程。

// 普通的加法函数
function add(a, b, c) {
  return a + b + c;
}

// 柯里化后的加法函数
function curriedAdd(a) {
  return function(b) {
    return function(c) {
      return a + b + c;
    };
  };
}

// 使用方式
console.log(add(1, 2, 3)); // 6
console.log(curriedAdd(1)(2)(3)); // 6

// 可以部分应用参数
const add1 = curriedAdd(1);
const add1And2 = add1(2);
console.log(add1And2(3)); // 6

柯里化的优势在于:

  • 提高代码复用性,可以部分应用参数
  • 延迟执行,将函数调用分解为多个步骤
  • 更灵活地组合函数

另一个实用的柯里化例子是创建乘法工具:

function multiply(a) {
  return function(b) {
    return a * b;
  }
}

// 创建特定的乘法函数
const double = multiply(2);
const triple = multiply(3);

console.log(double(5));  // 10
console.log(triple(5));  // 15

五、总结与注意事项

  1. 上下文决定 this 指向,作用域决定变量访问范围
  2. 执行上下文是函数运行的临时环境,以栈结构管理
  3. 作用域链是变量查找的路径,从内到外依次查找
  4. 闭包让内层函数可以保留外层函数的变量,柯里化是其典型应用

使用闭包时需要注意:过度使用闭包可能导致内存泄漏,因为被闭包引用的变量不会被垃圾回收机制回收。因此,在不需要使用闭包时,应及时解除引用。