JavaScript常见面试题:闭包

此文为译文,原文为mdn关于闭包的解释,由于官方的介绍已经非常详细,建议有英语阅读能力的阅读官方文档。
本文后续会出相关闭包的使用场景

概念

闭包是捆绑在一起(封闭)的函数与对其周围状态(词法环境)的引用的组合。通俗的讲,闭包使您可以从内部函数访问外部函数的范围。在 JavaScript 中,每次创建函数时都会创建闭包

词法作用域 Lexical scoping

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

init() 创建一个名为 name 的局部变量和一个名为 displayName() 的函数。 displayName() 函数为 init() 内部定义的内部函数,并且仅在 init() 函数体中可用。

请注意,displayName() 函数没有自己的局部变量。但由于内部函数可以访问外部函数的变量,因此 displayName() 可以访问父函数 init() 中声明的变量名。

这是一个词法作用域的例子,它描述了当函数嵌套时解析器如何解析变量名。

词法指的是词法作用域使用源代码中声明变量的位置来确定该变量可用的位置,嵌套函数可以访问在其外部范围内声明的变量。

ES6 之前,JavaScript 只有两种作用域:函数作用域全局作用域。用 var 声明的变量要么是函数范围的,要么是全局范围的,这取决于它们是在函数内声明还是在函数外声明。这可能会困扰人,因为带有花括号的块不会创建范围:


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

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


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

本质上,块最终在 ES6 中被视为作用域,但前提是您使用 letconst 声明变量。此外,ES6 引入了模块,它引入了另一种范围。闭包能够捕获所有这些范围内的变量,我们稍后会介绍。

闭包 Closure

先看一个例子


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 的实例维护对其词法环境的引用,其中存在变量名。因此,当调用 myFunc 时,变量名仍然可用,并且“Mozilla”被传递给 console.log

再看一个例子加深理解

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。

闭包的作用

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

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

您可能想要执行此操作的情况在网络上特别常见。用前端 JavaScript 编写的大部分代码都是基于事件的。您定义一些行为,然后将其附加到由用户触发的事件(例如单击或按键)。代码作为回调(响应事件而执行的单个函数)附加。

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

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

h1 {
  font-size: 1.5em;
}

h2 {
  font-size: 1.2em;
}

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

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

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

size12, size14, and size16 are now functions that resize the body text to 12, 14, and 16 pixels, respectively. You can attach them to buttons (in this case hyperlinks) as demonstrated in the following code example.

document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;

<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>

闭包模拟私有化方法

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

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

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


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.increment、counter.decrement 和 counter.value。

共享词法环境是在匿名函数的主体中创建的,该函数在定义后立即执行(也称为 IIFE)。词法环境包含两个私有项:一个名为 privateCounter 的变量和一个名为 changeBy 的函数。您不能从匿名函数外部访问这些私有成员中的任何一个。相反,您可以使用从匿名包装器返回的三个公共函数来访问它们。

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

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 变量。每次调用其中一个计数器时,其词法环境都会通过更改此变量的值而改变。一个闭包中变量值的更改不会影响另一个闭包中的值。

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

闭包作用域链

每个闭包都有三个作用域:

  • local
  • 封闭范围(可以是块、函数或模块范围)
  • global

一个常见的错误是没有意识到在外部函数本身是嵌套函数的情况下,对外部函数作用域的访问包括外部函数的封闭作用域——有效地创建了函数作用域链。为了演示,请考虑以下示例代码。

// 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)); // log 20

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


// 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); //log 20


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

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

function outer() {
  const x = 5;
  if (Math.random() > 0.5) {
    const y = 6;
    return () => console.log(x, y);
  }
}

outer()(); // logs 5 6

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

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

在这里,模块导出了一对 gettersetter 函数,它们关闭了模块范围的变量 x。即使 x 不能从其他模块直接访问,也可以使用函数对其进行读写。

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

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

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

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

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

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

闭包的常见错误

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

<p id="help">Helpful notes will appear here</p>
<p>E-mail: <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>

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

function setupHelp() {
  var helpText = [
    { id: 'email', help: 'Your e-mail 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 是用 var 声明的,因此由于提升而具有函数范围。

item.help 的值是在执行 onfocus 回调时确定的。因为此时循环已经运行,所以 item 变量对象(由所有三个闭包共享)一直指向 helpText 列表中的最后一个条目。

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

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

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

function setupHelp() {
  var helpText = [
    { id: 'email', help: 'Your e-mail 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();

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

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

function setupHelp() {
  const helpText = [
    { id: 'email', help: 'Your e-mail 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>,如下所示:

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

function setupHelp() {
  var helpText = [
    { id: 'email', help: 'Your e-mail 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();

性能问题

如前所述,每个函数实例都管理自己的作用域和闭包。

如果特定任务不需要闭包,就没必要在函数里面创建函数;因为它会在处理速度和内存消耗方面对脚本性能产生负面影响。

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

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

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

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

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

继承的原型可以被所有对象共享,并且方法定义不必在每次创建对象时都出现。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容

  • 闭包 在本文章中** 闭包是指那些能够访问独立(自由)变量的函数 (变量在本地使用,但定义在一个封闭的作用域中)。...
    __Seve阅读 507评论 0 0
  • 函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包(closure)。...
    Benzic阅读 432评论 0 0
  • 本文目录: 1.词法作用域 2.变量的生命周期 3.闭包案例分析 4.实用的闭包 5.用闭包模拟私有方法 6.在循...
    前端辉羽阅读 423评论 0 12
  • 闭包的定义 一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数...
    LcoderQ阅读 833评论 0 0
  • 前言 如果你耐心地阅读完这篇文章,你将会了解到闭包的定义、用法、优点以及缺点! 简单来说:Closures(闭包)...
    silly鸿阅读 430评论 0 0