JavaScript中的闭包

对于使用过JavaScript的人来说,理解闭包的概念是需要付出非常多的努力和牺牲的。其实很多人在没有理解必闭包之前也已经使用了闭包,但是我们应该根据自己的意愿来正确的识别和使用闭包,这边文章主要是在我看了《你不知道的JavaScript》之后整理的。供自己以后方便查阅。

在理解闭包之前首先得先理解一下词法作用域

作用域

作用域简单的可以说: 作用域的本质是一套规则,规定了变量的生命周期和可使用范围。

《你不知道的JavaScript上卷》上是这么说的:

作用域就是一套设计良好的规则来存储变量,并且之后可以方便的找到这些变量

作用域的解析方式有两种:

  • 词法作用域
  • 动态作用域

JavaScript使用的是词法作用域。下面我们来讲词法作用域

词法作用域

词法作用域也叫静态作用域, 它的作用域在词法分析阶段的时候就已经确定了。是不会再改变了。词法作用域是由你在写代码的时候将变量和代码写在哪里来决定的。这样词法分析器在处理代码的时候就会保持作用域不变。

而作用域根据代码层次分层,子域可以访问到父作用域,通常沿着链式作用域查找,而父作用域是不能访问子作用域的。

我们看个例子:

1
2
3
4
5
6
var a = 10;
function foo() {
var a = 20;
console.log(a);
}
foo();

在词法作用域下:

  • 全局作用域有两个标识: a, foo
  • foo函数作用域下有一个标识: a

执行foo()的时候, console.log(a)是在 foo函数作用域下的,并且里面有 a 变量的标识, 所以输出的是 20 而不是 10

我们举个例子:

1
2
3
4
5
6
7
8
9
var a = 10;
function getA() {
console.log(a);
}
function foo() {
var a = 20;
getA();
}
foo();

在词法作用域下:

  • 全局作用域两个标识:a,getA, foo,
  • 在 foo 函数这个作用域下有一个标识: a

执行 foo 函数的时候,getA() 被执行。但是getA()是定义在全局作用域下的,所以获取到的值是10, 而不是20

闭包

我们先看闭包的定义,要先掌握它才能更加理解是识别闭包:

当函数可以记住和访问当前的词法作用域时,就产生了闭包。即使这个函数是在当前的词法作用域之外执行的。

我们来看一段代码:

1
2
3
4
5
6
7
8
function foo(){
var a = 2;
function bar(){
console.log(a); // 2
}
bar();
}
foo();

在词法作用域中:

  • 全局作用域有1一个标识: foo
  • 在函数 foo 中有两个标识: a, bar

执行函数 foo 的时候, 会去执行bar函数,而bar函数在 foo 函数创建的作用域中,并且 foo 函数创建的作用域中有 a 变量。所以输出了2。

上面代码中的 bar函数是闭包吗? 答案: 这不是闭包, 但是它是闭包中很重要的一部分:根据词法作用域的查找规则,它能够访问外部作用域。

我们再看下面这段代码:

1
2
3
4
5
6
7
8
9
function foo(){
var a = 2;
function bar(){
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2

这段代码中的 baz就是一个闭包。bar的词法作用域能够访问foo函数创建的作用域,然后把bar这个函数本身当做返回值,然后在调用foo的时候把 bar的引用复赋值给baz(其实两个标识引用同一个函数), 所以能够访问foo的作作用域。

这段代码就验证了前面所讲的定义:即使函数式在当前词法作用域之外执行。

其实按照正常情况下,引擎有卡机回收机器会释放不在使用的内存空间,如上面的代码中foo执行完之后就会将其回收。但是闭包的神奇之处就在于它可以阻止这件事的发生。

由于bar声明位置的原因(也就是定义在foo函数创建的作用域中,并且是在a 变量下面),它涵盖了内部作用域的闭包,使得该作用域一直存活,以供bar在之后任何时间进行引用。bar依然有对该作用域的引用,而这个引用就叫做闭包。

当然,形成闭包的方式不只是有上面这种在一个函数中return 一个函数的 的方式。

如: 无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包的存在:

1
2
3
4
5
6
7
8
9
10
11
12

function foo() {
var a = 1;
function getValue () {
console.log(a);
}
bar(getValue);
}
function bar(fn) {
fn(); // 1
}
foo();

上面这种情况,fn 也是闭包。 把函数foo内部的函数 getValue 传递给bar。 当调用 foo时会执行bar函数,bar函数里面执行传递进来的函数getValue的引用。所以它是能够访问foo内的作用域的。

传递函数也可以使间接的, 只要在词法作用域之外调用的是词法作用域内的函数的引用就能够形成闭包。

1
2
3
4
5
6
7
8
9
10
11

let fn;
function foo() {
var a = 1;
function getValue () {
console.log(a);
}
fn = getValue;
}
foo();
fn(); // 1 --> 这也是闭包

所以得到的结论为:

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

闭包的使用

闭包是无处不在的,我们不妨来看看几个常用的片段,看看闭包妙在哪里

1
2
3
4
5
6
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000)
}
wait("舒丽琦")

将内部函数timer作为参数传递给 setTimeout。而timer能够访问wait的内部作用域。

使用过jQuery的人对下面的代码应该知道:

1
2
3
4
5
6
7
function setupBot(name,selector){
$(selector).click(function activator(){
console.log("message:" + name);
})
}
setupBot("舒丽琦1","#btn_1");
setupBot("舒丽琦2","#btn_2");

上面这个两个例子可以得到结论:

无论何时何地,如果将函数(访问他们各自的词法作用域)当前值类型到处传递,就会看到闭包在这些函数中的应用。在定时器,时间监听,Ajax请求异步或者同步的任务中, 只要有回调函数,实际上就是在使用闭包。

我们看一个经典的面试题:

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

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

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

这是为什么呢? 这是因为 var 定义了一个全部的变量 i。 当i = 6的时候, 结束了循环。根据词法作用域的原理, 这几个定时器共享同一个全局作用域。定时器执行的时候,全局变量 i 已经变成6 了。

那怎么解决呢? 我们看下面的代码

1
2
3
4
5
6
7
8

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

这段代码使用一个立即执行函数把当前 i传递进去,这个立即执行函数有自己的作用域。而我们将一个函数传递给定时器,传递给定时器的函数就会保持对原有的作用域的引用。所有立即函数的作用域不会被回收。所以代码能够符合我们预期的小运行。

除了这个办法还有别的办法吗? 有的, 那就是 ES6新出的let就可以解决这个问题:

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

结果按照我们的预期输出。这是为什么呢?

  • for 有自己的块作用域(()是父级作用域,{} 是子级作用域)。
  • let 定义的变量只能在自己所在的代码块中有效。

那么上面可以这个理解:在父级作用域(())中定义了一个变量 i。 在子级中有一个内部函数,该内部函数使用了 i 这个变量。并且这个函数被当作值传递了出去。所有它持有对原始作用域的引用。

上面我们都是识别闭包, 那我们能用闭包来做什么呢?

我们看一个例子:

1
2
3
4
5
var box = {
age : 18,
}
box.age = 20;
console.log(box.age); // 20

上面的代码中,对象的age属性可以随意的被修改。但是如果我们使用闭包的话,就可以实现私有化,将age属性保护起来,只做允许的修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
const bar = (function() {
var age = 18;
return {
addAge: function() {
++age;
},
getAge: function() {
console.log(age)
}
}
})()
bar.addAge();
bar.getAge();

这样就能够实现 age 属性的私有化了,只允许 age 进行加操作。

闭包的优缺点

所以我们可以想到的闭包的用途:

  • 可以获取函数内部的变量
  • 让变量的值始终保存在内存中, 不会被垃圾回收机制回收
  • 可以实现私有化

当然也是有缺点的:

  • 由于使用闭包使得变量始终保存在内存中,内存消耗很大,所以不能滥用,否则会造成网页的性能。

    解决办法: 在退出函数之后将不使用的变量全部删除)

  • 闭包会在父函数外面改变内部内部变量的值。

    所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。

闭包的释放

上面讲过闭包的会值得变量的值用于保存在内存当中。这样是有可能造成我们网页的性能问题的。那我们该如何去释放闭包呢?

上面是将内部的引用传递出去, 那么我们把函数的引用改了, 是不是就释放了?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

function add() {
var n = 0;
function bar () {
n++;
console.log(n);
}
return bar;
}

let fn = add(); // 得到内部函数bar的引用
fn(); // 1
fn(); // 2
fn(); // 3
fn = null; // 手动释放 bar 的引用

// bar 的引用 fn 被释放了,现在 f 的作用域也被释放了。num再次归零了。
fn = add();
fn(); // 1

手动修改 内部函数的引用, 就释放了当前作用域的了。

最后

最后来一个面试中经常问到的题目:实现 sum(1)(2)(3) 返回结果是 1,2,3 之和

1
2
3
4
5
6
7
8
function sum(x) {
return function(y) {
return function(z) {
return x + y + z
}
}
}
console.log(sum(1)(2)(3)); // 6

如果有很多的回调呢? 怎么办?

这就需要封装一个 柯里化的函数了, 具体可移步 什么是函数柯里化

文章作者: 舒小琦
文章链接: https://shuliqi.github.io/2018/10/23/JavaScript中的闭包/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 舒小琦的Blog