闭包

闭包是函数与对其周围状态(词法环境)的引用捆绑(或“封闭”)在一起的组合。换句话说,闭包让函数可以访问其外部作用域。在 JavaScript 中,每当函数被创建时,闭包都会在函数创建时生成。

词法作用域

请看下面的示例代码

js
function init() {
  var name = "Mozilla"; // name is a local variable created by init
  function displayName() {
    // displayName() is the inner function, that forms a closure
    console.log(name); // use variable declared in the parent function
  }
  displayName();
}
init();

init() 创建了一个名为 name 的局部变量和一个名为 displayName() 的函数。displayName() 函数是定义在 init() 内部的内部函数,并且只在 init() 函数体内部可用。注意 displayName() 函数本身没有局部变量。但是,由于内部函数可以访问外部作用域的变量,displayName() 可以访问在父函数 init() 中声明的变量 name

如果你在控制台中运行这段代码,你会看到 displayName() 函数中的 console.log() 语句成功显示了在其父函数中声明的 name 变量的值。这是一个“词法作用域”的例子,它描述了当函数嵌套时,解析器如何解析变量名。词法这个词指的是词法作用域使用变量在源代码中声明的位置来确定该变量的可用范围。嵌套函数可以访问在其外部作用域中声明的变量。

使用 let 和 const 进行作用域划分

传统上(ES6 之前),JavaScript 变量只有两种作用域:函数作用域全局作用域。用 var 声明的变量要么是函数作用域,要么是全局作用域,这取决于它们是在函数内部还是外部声明。这可能很棘手,因为带花括号的代码块不会创建作用域。

js
if (Math.random() > 0.5) {
  var x = 1;
} else {
  var x = 2;
}
console.log(x);

对于来自其他语言(例如 C、Java)的人来说,这些语言中代码块会创建作用域,上面的代码应该在 console.log 行抛出错误,因为我们在两个代码块中都超出了 x 的作用域。然而,由于代码块不会为 var 创建作用域,这里的 var 语句实际上创建了一个全局变量。下面还介绍了一个实际示例,说明了当它与闭包结合时如何导致实际的 bug。

在 ES6 中,JavaScript 引入了 letconst 声明,除了暂时性死区等特性外,它们还允许你创建块级作用域变量。

js
if (Math.random() > 0.5) {
  const x = 1;
} else {
  const x = 2;
}
console.log(x); // ReferenceError: x is not defined

实质上,ES6 中代码块最终被视为作用域,但仅限于你使用 letconst 声明变量的情况。此外,ES6 还引入了模块,它引入了另一种作用域。闭包能够捕获所有这些作用域中的变量,我们将在稍后介绍。

闭包

请看下面的代码示例

js
function makeFunc() {
  const name = "Mozilla";
  function displayName() {
    console.log(name);
  }
  return displayName;
}

const myFunc = makeFunc();
myFunc();

运行这段代码的效果与上面 init() 函数的先前示例完全相同。不同之处(也是有趣之处)在于,displayName() 内部函数在执行之前从外部函数返回。

乍一看,这段代码仍然有效似乎不合常理。在某些编程语言中,函数中的局部变量仅在函数执行期间存在。一旦 makeFunc() 执行完毕,你可能会期望 name 变量不再可访问。然而,由于代码仍然按预期工作,这显然不是 JavaScript 的情况。

原因在于 JavaScript 中的函数形成了闭包。一个“闭包”是函数和该函数被声明时的词法环境的组合。这个环境由创建闭包时在作用域内的所有变量组成。在这个例子中,myFunc 是对当 makeFunc 运行时创建的 displayName 函数实例的引用。displayName 的实例维护对其词法环境的引用,其中变量 name 存在。因此,当 myFunc 被调用时,变量 name 仍然可用,并且“Mozilla”被传递给 console.log

这里有一个稍微更有趣的例子——一个 makeAdder 函数

js
function makeAdder(x) {
  return function (y) {
    return x + y;
  };
}

const add5 = makeAdder(5);
const add10 = makeAdder(10);

console.log(add5(2)); // 7
console.log(add10(2)); // 12

在此示例中,我们定义了一个函数 makeAdder(x),它接受一个参数 x,并返回一个新函数。它返回的函数接受一个参数 y,并返回 xy 的和。

本质上,makeAdder 是一个函数工厂。它创建能够将其参数与特定值相加的函数。在上面的示例中,函数工厂创建了两个新函数——一个将其参数与五相加,另一个将其参数与 10 相加。

add5add10 都形成了闭包。它们共享相同的函数体定义,但存储不同的词法环境。在 add5 的词法环境中,x 是 5,而在 add10 的词法环境中,x 是 10。

实际应用中的闭包

闭包很有用,因为它们允许你将数据(词法环境)与操作该数据的函数关联起来。这与面向对象编程有明显的相似之处,在面向对象编程中,对象允许你将数据(对象的属性)与一个或多个方法关联起来。

因此,你可以在任何通常只使用一个方法的对象的地方使用闭包。

你可能想在网络上这样做的情况尤其常见。前端 JavaScript 中编写的大部分代码都是事件驱动的。你定义一些行为,然后将其附加到由用户触发的事件(例如点击或按键)。代码作为回调(一个响应事件执行的单一函数)附加。

例如,假设我们想在页面上添加按钮来调整文本大小。一种方法是指定 body 元素的字体大小(以像素为单位),然后使用相对 em 单位设置页面上其他元素(例如标题)的大小。

css
body {
  font-family: "Helvetica", "Arial", sans-serif;
  font-size: 12px;
}

h1 {
  font-size: 1.5em;
}

h2 {
  font-size: 1.2em;
}

这样的交互式文本大小按钮可以更改 body 元素的 font-size 属性,并且由于相对单位,页面上的其他元素会获取这些调整。

这是 JavaScript 代码

js
function makeSizer(size) {
  return () => {
    document.body.style.fontSize = `${size}px`;
  };
}

const size12 = makeSizer(12);
const size14 = makeSizer(14);
const size16 = makeSizer(16);

size12size14size16 现在是分别将正文文本大小调整为 12、14 和 16 像素的函数。你可以将它们附加到按钮上,如以下代码示例所示。

js
document.getElementById("size-12").onclick = size12;
document.getElementById("size-14").onclick = size14;
document.getElementById("size-16").onclick = size16;
html
<button id="size-12">12</button>
<button id="size-14">14</button>
<button id="size-16">16</button>
<p>This is some text that will change size when you click the buttons above.</p>

使用闭包模拟私有方法

像 Java 这样的语言允许你将方法声明为私有的,这意味着它们只能由同一类中的其他方法调用。

出现之前,JavaScript 没有一种原生的方式来声明私有方法,但可以使用闭包来模拟私有方法。私有方法不仅对于限制对代码的访问有用。它们还提供了一种管理全局命名空间的强大方式。

以下代码说明了如何使用闭包来定义可以访问私有函数和变量的公共函数。请注意,这些闭包遵循模块设计模式

js
const counter = (function () {
  let privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }

  return {
    increment() {
      changeBy(1);
    },

    decrement() {
      changeBy(-1);
    },

    value() {
      return privateCounter;
    },
  };
})();

console.log(counter.value()); // 0.

counter.increment();
counter.increment();
console.log(counter.value()); // 2.

counter.decrement();
console.log(counter.value()); // 1.

在前面的示例中,每个闭包都有自己的词法环境。然而在这里,只有一个词法环境由三个函数共享:counter.incrementcounter.decrementcounter.value

共享的词法环境是在一个匿名函数体内创建的,该函数一旦定义立即执行(也称为IIFE)。词法环境包含两个私有项:一个名为 privateCounter 的变量和一个名为 changeBy 的函数。你无法从匿名函数外部访问这些私有成员。相反,你通过从匿名包装器返回的三个公共函数间接访问它们。

这三个公共函数形成了共享相同词法环境的闭包。由于 JavaScript 的词法作用域,它们都可以访问 privateCounter 变量和 changeBy 函数。

js
function makeCounter() {
  let privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment() {
      changeBy(1);
    },

    decrement() {
      changeBy(-1);
    },

    value() {
      return privateCounter;
    },
  };
}

const counter1 = makeCounter();
const counter2 = makeCounter();

console.log(counter1.value()); // 0.

counter1.increment();
counter1.increment();
console.log(counter1.value()); // 2.

counter1.decrement();
console.log(counter1.value()); // 1.
console.log(counter2.value()); // 0.

请注意这两个计数器如何保持相互独立。每个闭包通过其自身的闭包引用了不同版本的 privateCounter 变量。每次调用其中一个计数器时,其词法环境都会通过更改此变量的值而改变。一个闭包中变量值的更改不会影响另一个闭包中的值。

注意:以这种方式使用闭包提供了通常与面向对象编程相关的好处。特别是,数据隐藏封装

闭包作用域链

嵌套函数对外部函数作用域的访问包括外部函数的封闭作用域——有效地创建了一个函数作用域链。为了演示,请考虑以下示例代码。

js
// global scope
const e = 10;
function sum(a) {
  return function (b) {
    return function (c) {
      // outer functions scope
      return function (d) {
        // local scope
        return a + b + c + d + e;
      };
    };
  };
}

console.log(sum(1)(2)(3)(4)); // 20

你也可以不使用匿名函数来编写

js
// global scope
const e = 10;
function sum(a) {
  return function sum2(b) {
    return function sum3(c) {
      // outer functions scope
      return function sum4(d) {
        // local scope
        return a + b + c + d + e;
      };
    };
  };
}

const sum2 = sum(1);
const sum3 = sum2(2);
const sum4 = sum3(3);
const result = sum4(4);
console.log(result); // 20

在上面的例子中,有一系列嵌套函数,所有这些函数都可以访问外部函数的作用域。在这种情况下,我们可以说闭包可以访问“所有”外部作用域。

闭包也可以捕获块作用域和模块作用域中的变量。例如,下面代码在块作用域变量 y 上创建了一个闭包

js
function outer() {
  let getY;
  {
    const y = 6;
    getY = () => y;
  }
  console.log(typeof y); // undefined
  console.log(getY()); // 6
}

outer();

模块上的闭包可能更有趣。

js
// myModule.js
let x = 5;
export const getX = () => x;
export const setX = (val) => {
  x = val;
};

这里,模块导出一对 getter-setter 函数,这些函数闭包在模块作用域变量 x 上。即使 x 无法直接从其他模块访问,也可以使用这些函数进行读写。

js
import { getX, setX } from "./myModule.js";

console.log(getX()); // 5
setX(6);
console.log(getX()); // 6

闭包也可以关闭导入的值,这些值被视为实时绑定,因为当原始值改变时,导入的值也会相应改变。

js
// myModule.js
export let x = 1;
export const setX = (val) => {
  x = val;
};
js
// closureCreator.js
import { x } from "./myModule.js";

export const getX = () => x; // Close over an imported live binding
js
import { getX } from "./closureCreator.js";
import { setX } from "./myModule.js";

console.log(getX()); // 1
setX(2);
console.log(getX()); // 2

在循环中创建闭包:一个常见错误

在引入 let 关键字之前,当你在循环中创建闭包时,一个常见的问题发生了。为了演示,请考虑以下示例代码。

html
<p id="help">Helpful notes will appear here</p>
<p>Email: <input type="text" id="email" name="email" /></p>
<p>Name: <input type="text" id="name" name="name" /></p>
<p>Age: <input type="text" id="age" name="age" /></p>
js
function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your email address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    // Culprit is the use of `var` on this line
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function () {
      showHelp(item.help);
    };
  }
}

setupHelp();

helpText 数组定义了三个有用的提示,每个提示都与文档中输入字段的 ID 相关联。循环遍历这些定义,为每个定义设置一个 onfocus 事件,该事件显示关联的帮助方法。

如果你尝试这段代码,你会发现它没有按预期工作。无论你将焦点放在哪个字段,都会显示关于你年龄的消息。

造成这种情况的原因是,赋给 onfocus 的函数形成了闭包;它们由函数定义和从 setupHelp 函数作用域捕获的环境组成。循环创建了三个闭包,但每个闭包都共享相同的单一词法环境,其中有一个值不断变化的变量(item)。这是因为变量 item 是用 var 声明的,因此由于变量提升而具有函数作用域。item.help 的值是在执行 onfocus 回调时确定的。由于循环届时已经完成,变量 item 对象(由所有三个闭包共享)已指向 helpText 列表中的最后一个条目。

在这种情况下,一个解决方案是使用更多的闭包:特别是,像前面描述的那样使用一个函数工厂。

js
function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function makeHelpCallback(help) {
  return function () {
    showHelp(help);
  };
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your email address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
  }
}

setupHelp();

这按预期工作。回调不再共享一个单一的词法环境,makeHelpCallback 函数为每个回调创建了一个新的词法环境,其中 help 引用了 helpText 数组中对应的字符串。

另一种使用匿名闭包来编写上述代码的方法是

js
function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your email address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    (function () {
      var item = helpText[i];
      document.getElementById(item.id).onfocus = function () {
        showHelp(item.help);
      };
    })(); // Immediate event listener attachment with the current value of item (preserved until iteration).
  }
}

setupHelp();

如果你不想使用更多的闭包,你可以使用 letconst 关键字。

js
function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  const helpText = [
    { id: "email", help: "Your email address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (let i = 0; i < helpText.length; i++) {
    const item = helpText[i];
    document.getElementById(item.id).onfocus = () => {
      showHelp(item.help);
    };
  }
}

setupHelp();

此示例使用 const 而不是 var,因此每个闭包都绑定块作用域变量,这意味着不需要额外的闭包。

如果你正在编写现代 JavaScript,你可以考虑除了普通的 for 循环之外的更多替代方案,例如使用 for...of 循环并将 item 声明为 letconst,或者使用 forEach() 方法,这两种方法都避免了闭包问题。

js
for (const item of helpText) {
  document.getElementById(item.id).onfocus = () => {
    document.getElementById("help").textContent = item.help;
  };
}

helpText.forEach((item) => {
  document.getElementById(item.id).onfocus = () => {
    showHelp(item.help);
  };
});

性能注意事项

如前所述,每个函数实例都管理自己的作用域和闭包。因此,如果特定任务不需要闭包,则不应在其他函数内部不必要地创建函数,因为它会对脚本性能(包括处理速度和内存消耗)产生负面影响。

例如,在创建新对象/类时,方法通常应该与对象的原型关联,而不是定义在对象构造函数中。原因是每次调用构造函数时,方法都会被重新分配(即,每次对象创建时)。

请看以下情况

js
function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
  this.getName = function () {
    return this.name;
  };

  this.getMessage = function () {
    return this.message;
  };
}

由于前面的代码在这个特定实例中没有利用使用闭包的好处,我们因此可以重写它以避免使用闭包,如下所示:

js
function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype = {
  getName() {
    return this.name;
  },
  getMessage() {
    return this.message;
  },
};

然而,不建议重新定义原型。下面的例子反而会追加到现有原型中。

js
function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype.getName = function () {
  return this.name;
};
MyObject.prototype.getMessage = function () {
  return this.message;
};

在前面的两个示例中,继承的原型可以被所有对象共享,并且方法定义不需要在每次对象创建时都发生。有关更多信息,请参阅继承与原型链