闭包

**闭包**是将函数与其周围状态(**词法环境**)的引用捆绑在一起(封闭)的组合。换句话说,闭包使函数能够访问其外部作用域。在 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

使用 此 JSFiddle 链接 运行代码,并注意 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 语句实际上创建了一个全局变量。下面还介绍了一个 实际示例,说明了当与闭包结合时,这如何会导致实际错误。

在 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 是一个函数工厂。它创建可以将其参数加到特定值的函数。在上面的示例中,函数工厂创建了两个新函数——一个将其参数加 5,另一个将其参数加 10。

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

实用闭包

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

因此,您可以在任何可能通常使用仅具有单个方法的对象的地方使用闭包。

在 Web 上,您可能希望执行此操作的情况尤其常见。在前端 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 function () {
    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>

使用 JSFiddle 运行代码。

使用闭包模拟私有方法

像 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
const makeCounter = function () {
  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();

尝试在 JSFiddle 中运行代码。

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();

使用 此 JSFiddle 链接 运行代码。

这按预期工作。回调不再共享单个词法环境,而是 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,因此每个闭包都绑定了块作用域变量,这意味着不需要额外的闭包。

另一种方法可能是使用 forEach() 遍历 helpText 数组并附加一个监听器到每个 <input>,如下所示

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)" },
  ];

  helpText.forEach(function (text) {
    document.getElementById(text.id).onfocus = function () {
      showHelp(text.help);
    };
  });
}

setupHelp();

性能注意事项

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

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

考虑以下情况

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;
};

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