[核心概念] javaScript中的闭包(closure)

时间:2021-1-8 作者:admin

系列开篇

为进入前端的你建立清晰、准确、必要概念和这些概念的之间清晰、准确、必要关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。

面试题

  • 什么是闭包
  • 闭包是用来干什么的
  • 各种根据闭包相关写结果的笔试题

这是干什么的?

最好先了解 作用域【关联概念(强)】的概念。

其实说清楚它的定义,基本上就是这篇文章的核心了,定义可能需要根据整个文章去体会。

先看看其他官方的各种定义引用:

MDN: 一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

红宝书: 闭包是指有权访问另外一个函数作用域中的变量的函数。

现代JavaScript教程: 闭包是指内部函数总是可以访问其所在的外部函数中声明的变量和参数,即使在其外部函数被返回(寿命终结)了之后。

stackOverFlow: A closure is a persistent scope which holds on to local variables even after the code execution has moved out of that block.

我的理解是:函数对其词法环境的引用 的组合。常用来间接地访问一个变量。当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

词法环境对象由两部分组成:
执行上下文 -> 词法环境

  • 环境记录:一个存储所有局部变量作为其属性(包括一些其他信息,例如this的值)的对象。
  • 外部词法环境引用,与外部代码相关联。

看不明白先跳过,回头理解更清晰。

简单来说就是我在写代码时候我们决定了这些变量的访问权限也就是词法作用域。然而我们可以用些手段(闭包)如return 一个函数。这样即使这个function在当前词法作用域外执行,也能访问原来定义时词法作用域内的变量,(间接地访问了这些变量)。

下面我们来看一段代码, 清晰地展示了闭包:

function foo() {
    var a = 2;
    function bar() {
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz(); // 2       这就是闭包的效果。

我们观察到: 按照词法作用域来说,baz的词法作用域是全局的,外部不能访问foo内部作用域的变量 a,但是 a确实被正常打印了。

函数 bar() 的词法作用域能够访问 foo() 的内部作用域。

然后我们将 bar() 函数本身当作一个值类型进行传递。

在这个例子中, 我们将 bar 所引用的函数对象本身当作返回值。
foo() 执行后, 其返回值(也就是内部的 bar()函数) 赋值给变量 baz 并调用 baz(), 实际上只是通过不同的标识符引用调用了内部的函数 bar()。bar() 显然可以被正常执行。

但是在这个例子中,它在自己定义的词法作用域以外 的地方执行了。

foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收GC【关联概念】用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。

而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域?原来是 bar() 本身在使用。拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。bar() 依然持有对该作用域的引用。

因此,在几微秒之后变量 baz 被实际调用(调用内部函数 bar),不出意料它可以访问定义时的词法作用域,因此它也可以如预期般访问变量 a。 这个函数在定义时的词法作用域以外的地方被调用闭包使得函数可以继续访问定义时的词法作用域

这样做的话一是可以读取函数内部的变量,二是可以让这些变量的值始终保存在内存中

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

我这里重复说了多遍类似的话,就是希望通过重复,给你加深印象,书多读两遍就懂了,重复是加深理解的重要手段。

本质上无论何时何地,如果将函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包。

怎么用?

你写不写函数? 那你就在用了,通常是不自觉的就开始用了。那我们就来说说在面试中怎么用。

例1

for (var i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i);
    }, i * 1000);
}

正常情况下, 我们对这段代码行为的预期是分别输出数字 1~5, 每秒一次, 每次一个。

但实际上, 这段代码在运行时会以每秒一次的频率输出五次 6。

首先解释 6 是从哪里来的。 这个循环的终止条件是 i 不再 <= 5。 条件首次成立时 i 的值是 6。 因此,输出显示的是循环结束时 i 的最终值。仔细想一下,这好像又是显而易见的, 延迟函数的回调会在循环结束时才执行

事实上,当定时器运行时即使每个迭代中执行的是setTimeout(.., 0), 所有的回调函数依然是在循环结束后才会被执行,这是[js的执行机制]【关联概念】, 因此会每次输出一个 6 出来。

这里引伸出一个更深入的问题, 代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢 ?
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“ 捕获” 一个 i 的副本。 但是 根据作用域的工作原理, 实际情况是尽管循环中的五个函数是在各个迭代中分别定义的, 但是它们都被封闭在一个共享的全局作用域中, 因此实际上只有一个 i

这样说的话, 当然所有函数共享一个 i 的引用。 循环结构让我们误以为背后还有更复杂的 机制在起作用, 但实际上没有。 如果将延迟函数的回调重复定义五次, 完全不使用循环, 那它同这段代码是完全等价的。

我们可以利用闭包来解决这个问题。
首先这种IIFE(立即执行函数表达式) 是一個定义完馬上就執行的 JavaScript function,可以创建闭包。

for (var i = 1; i <= 5; i++) {
    (function(j) {
        setTimeout(function timer() {
            console.log(j);
        }, j * 1000);
    })(i);
}

不熟IIFE用let也成,本质上这是将一个块转换成一个可以被关闭的作用域。也就是块级作用域。

for (var i = 1; i <= 5; i++) {
    let j = i; // 是的,闭包的块作用域! 
    setTimeout(function timer() {
        console.log(j);
    }, j * 1000);
}

另外 for 循环头部的let 声明还会有一 个特殊的行为。 这个行为指出变量在循环过程中不止被声明一次, 每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。所以这样也ok。

for (let i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i);
    }, i * 1000);
}

例2

在这儿我们用相同的 makeCounter 函数创建了两个计数器(counters):counter 和 counter2。

它们是独立的吗?第二个 counter 会显示什么?0,1 或 2,3 还是其他?

function makeCounter() {
  let count = 0;

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

let counter = makeCounter();
let counter2 = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1

alert( counter2() ); // ?
alert( counter2() ); // ?

答案是:0,1。

函数 counter 和 counter2 是通过 makeCounter 的不同调用创建的。

因此,它们具有独立的外部词法环境,每一个都有自己的 count。

例3

编写一个像 sum(a)(b) = a+b 这样工作的 sum 函数。

是的,就是这种通过双括号的方式(并不是错误)。

举个例子:

sum(1)(2) = 3
sum(5)(-1) = 4

为了使第二个括号有效,第一个(括号)必须返回一个函数。

function sum(a) {
  return function(b) {
    return a + b; // 从外部词法环境获得 "a"
  };
}
alert( sum(1)(2) ); // 3

这能让我们引出下个话题,函数柯里化 Currying 【关联概念】

原理是什么?

我们深刻理解了这个概念之后,可以探究下它的实现(面试也经常问到这方面源码),可能有人觉得没啥用,我觉得它的用处是拓展出其他相关联的【必知】概念,也可以看看你的硬编码能力,再不济看看你的记忆力如何也是好的。(^-^)

执行上下文 -> 词法环境 -> 例3

其他

new Function

在 JavaScript 中,所有函数都是天生闭包的(只有一个例外 “new Function” 语法 )

使用 new Function 创建的函数,它的 [[Environment]] 指向全局词法环境,而不是函数所在的外部词法环境。

因此,我们不能在 new Function 中直接使用外部变量。

具体用法可看 new Function

模块模式

简单来说,模块模式需要具备两个必要条件。

  • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块 实例)。
  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用 所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。

参考

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。