发布于 2026-01-05 8 阅读
0

JavaScript - 闭包深度解析

JavaScript - 闭包深度解析

学习 JavaScript 中的闭包概念

原文发布于我的个人博客debuggr.io

本文将介绍 JavaScript 中的闭包概念,我们将了解如何使函数具有状态并在多次执行之间持久化数据。我们还将探讨闭包的一些常见用例以及不同的使用方法。

让我们先引用MDN的一段话

闭包是将一个函数与其外部状态(词法环境)的引用捆绑在一起(封装)而成的。换句话说,闭包允许你从内部函数访问外部函数的作用域。在 JavaScript 中,每次创建函数时都会创建闭包。

如果问我的意见,我会说闭包使我们能够创建有状态的函数。

有状态函数

有状态函数是指能够“记住”先前执行数据的函数。例如,我们可以创建一个函数,它能够“记住”并统计自身的执行次数。每次调用该函数时,它都会记录执行次数。

为此,我们需要一个counter变量来保存当前的执行次数,并且每次调用该函数时都会递增。这里的挑战是决定将此变量放在哪里。

让我们来探讨第一种方法:

function counter(){
  let numOfExecutions = 0;
  numOfExecutions++;
  console.log(numOfExecutions);
}

counter() // 1
counter() // 1
Enter fullscreen mode Exit fullscreen mode

显然这样做行不通,因为numOfExecutions每次调用时我们都会重新创建变量counter()

执行上下文

每次调用函数时,都会创建一个新的执行上下文,每个执行上下文都有自己的“变量环境”或“作用域”。这个局部变量环境保存着传递给它的所有参数以及函数体内的所有声明,在本例中就是numOfExecutions变量本身。当函数“执行完毕”时(例如,通过 ` returnend` 语句或没有更多代码需要执行时),引擎会将其标记为待垃圾回收,这意味着它的整个环境将被释放。

这就是我们上面的代码无法正常工作的原因,每次调用时,counter我们都会创建一个新的执行上下文,并重新声明变量numOfExecutions,然后将其递增为该值1

全局执行上下文

当我们启动程序时,引擎会为我们创建一个全局执行上下文,它与我们调用函数时创建的执行上下文并无不同。它也像其他执行上下文一样拥有一个“变量环境”,区别在于全局执行上下文永远不会“终止”(当然,前提是我们的程序正在运行),因此它的变量环境不会被垃圾回收器释放。

因此,了解了这一点后,我们或许可以将它存储numOfExecutions在全局变量环境中,这样我们就知道它不会在每次调用时都被重新创建counter

let numOfExecutions = 0;

function counter(){
  numOfExecutions++;
  console.log(numOfExecutions);
}

counter() // 1
counter() // 2
Enter fullscreen mode Exit fullscreen mode

这确实如我们所料,我们得到了正确的调用次数,但您可能已经知道,将变量存储在全局环境中被认为是一种不良做法。例如,如果另一个函数想要使用完全相同的变量会发生什么情况:

let numOfExecutions = 0;

function counter() {
  numOfExecutions++;
  console.log(numOfExecutions);
}

function someFunc() {
  numOfExecutions = 100;
}

someFunc()
counter() // 101
counter() // 102
Enter fullscreen mode Exit fullscreen mode

正如你所看到的,这里面有一些错误数字。

这种方法的另一个问题是,我们无法运行超过 1 个实例counter

词汇范围

词法作用域本质上是“静态作用域”的一种比较花哨的说法,意思是我们在创建函数时就知道它的作用域是什么。

请仔细阅读以下内容:

函数定义的位置决定了函数被调用可以访问哪些变量。

换句话说,你在哪里以及如何调用该函数并不重要,重要的是它在哪里声明的。

但是,我们如何在一个地方声明一个函数,然后在另一个地方调用它呢?其实,我们可以在一个函数内部创建一个函数并返回它:

function createFunc() {
  function newFunc(){

  }

  return newFunc;
}

const myFunc = createFunc();
myFunc()
Enter fullscreen mode Exit fullscreen mode

这或许看起来没什么用,但让我们来探究一下程序的执行阶段:

  1. createFunc我们在全局变量环境中声明一个带有标签的新函数。
  2. 我们在全局变量环境中声明一个新变量myFunc,它的值将是执行该函数的返回值createFunc
  3. 我们调用该createFunc函数。
  4. 创建一个新的执行上下文(具有局部变量环境)。
  5. 我们声明一个函数,并为其赋予一个标签newFunc(存储在局部变量环境中createFunc)。
  6. 我们回来了newFunc
  7. 返回存储createFuncmyFunc全局变量环境中。
  8. 变量环境createFunc被标记为待处置(意味着该newFunc变量将不存在)。
  9. 我们调用myFunc

请注意,当我们返回函数时newFunc,我们返回的是实际的函数定义,而不是标签。

好的,那么我们可以用这种方法做什么呢?

事实证明,当我们返回一个函数时,我们不仅返回函数定义,还返回了它的整个词法环境。也就是说,如果我们在同一个上下文(或外部上下文)中声明了一些变量,我们返回的函数会覆盖这些变量,并保留对它们的引用。

让我们通过一个counter例子来看看它是如何运作的:

function createCounter() {
  // creating a wrapping execution context
  // so we won't pollute the global environment
  let numOfExecutions = 0;

  // creating and returning an inner function
  // that closes over the lexical environment
  function counter() {
    numOfExecutions++;
    console.log(numOfExecutions);
  }

  return counter;
}

const counter = createCounter();

counter() // 1
counter() // 2

Enter fullscreen mode Exit fullscreen mode

如您所见,我们创建了一个包装执行上下文(createCounter)来存储numOfExecutions变量,并返回该counter函数。这样,​​每次调用该函数时,counter它都能访问该numOfExecutions变量。由于我们不会重新运行该函数createCounter,而只是运行一次,这使得counter我们可以在多次执行该函数时保持numOfExecutions变量的持久性counter,从而使counter该函数具有状态性,这意味着我们可以与该函数的多次执行共享数据。

如果我们调试counter执行,可以在开发者工具中看到它numOfExecutions没有存储在局部变量环境中,counter而是存储在它的“闭包”作用域中([[Scope]]在规范中指的是)。

在开发工具调试器中显示 numOfExecutions 的闭包作用域

但如果我们想返回的是一个对象而不是一个函数呢?

没问题,它仍然会按预期运行:

function createCounter() {
  let count = 0;

  function increment() {
    count++;
    return count;
  }

  function decrement() {
    count--;
    return count;
  }

  function reset() {
    count = 0;
  }

  function log() {
    console.log(count)
  }

  const counterObj = {
    increment,
    decrement,
    reset,
    log
  }

  return counterObj;
}

const counter = createCounter();

counter.increment()
counter.increment()
counter.increment()

counter.log() // 3

Enter fullscreen mode Exit fullscreen mode

☝️ 顺便一提,这种模式通常被称为“模块模式”。

如你所见,我们返回什么值并不重要,我们在何时何地调用函数也不重要,唯一重要的是我们在哪里定义了这些函数:

函数定义的位置决定了函数被调用可以访问哪些变量。

返回函数或包含函数的对象带来的另一个好处是,我们可以创建多个实例counter,每个实例都是有状态的,并且在执行过程中共享数据,但实例之间不会发生冲突:

function createCounter() {
  let numOfExecutions = 0;

  function counter() {
    numOfExecutions++;
    console.log(numOfExecutions);
  }

  return counter;
}

const counter1 = createCounter();
const counter2 = createCounter();

counter1() // 1
counter1() // 2

counter2() // 1
counter2() // 2
Enter fullscreen mode Exit fullscreen mode

如您所见,counter1它们counter2都是有状态的,但彼此的数据并不冲突,这是我们使用全局变量无法做到的。

优化

每个返回的函数都会封闭整个词法作用域,这意味着整个词法作用域不会被垃圾回收🤔。这似乎会浪费内存,甚至可能导致内存泄漏。我们是否应该在每次需要有状态函数时都重新考虑使用闭包呢?

不,并非如此。大多数浏览器(如果不是全部的话)都优化了这种机制,这意味着在大多数情况下,只有函数实际使用的变量才会附加到函数的 `__init__` 属性上[[scope]]。为什么是“大多数”而不是“所有”呢?因为在某些情况下,浏览器无法确定函数正在使用哪些变量,例如使用`eval`的情况。显然,这是使用 `__init__` 属性时最小的问题eval使用Function构造函数会更安全。

总结

我们了解了“闭包”的底层工作原理,以及它与周围词法上下文的关联。我们看到,从作用域的角度来看,函数何时何地运行并不重要,重要的是函数的定义位置,换句话说:词法(静态)绑定。当我们返回一个函数时,实际上不仅返回了函数本身,还将其与周围所有上下文的整个词法变量环境绑定在一起(浏览器会进行优化,只绑定被引用的变量)。这使我们能够创建具有跨执行共享数据的有状态函数,也允许我们创建全局执行上下文无法访问的“私有”变量。

希望这篇文章对您有所帮助。如果您有任何补充、建议或反馈,欢迎随时联系我,您可以通过推特或私信联系我@ sag1v。🤓

更多文章请访问debuggr.io

文章来源:https://dev.to/sag1v/javascript-closure-in-deep-4no4