经常使用而你却不知道它是闭包(js基础提升之四)

背景知识

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

这是Kyle Simpson大神在你不知道的javascript中所描述的闭包。个人觉得把闭包说的非常的贴切。记得有人说过如果你不能把闭包说到5岁孩子都懂的话说明你还不是很理解闭包。

先拿一道闭包面试题镇楼,看完理解完面试题咱们在一步一步的来说闭包。

1
2
3
4
5
6
7
8
9
10
11
function fun(n,o) { //<-fun_1
console.log(o)
return {
fun:function(m){ //<-fun_2
return fun(m,n); //<-fun_3
}
};
}
var a = fun(0); a.fun(1); a.fun(2); a.fun(3);
var b = fun(0).fun(1).fun(2).fun(3);
var c = fun(0).fun(1); c.fun(2); c.fun(3);

我们先用标注注释一下代码的几个关键变量,我们把fun分为fun_1,fun_2,fun_3姑且把三个fun当成三个变量来看。

fun_1有两个形参,分别为n,o。fun_2有1一个形参为m。fun_3有两个形参为m,n。fun_1运行时会打印o形参。fun_1运行返回{fun:fun_2}。fun_2运行返回fun_3的执行结果。而fun_3跟fun_1的内存指向地址一样也就是说他们是同一个函数,所以返回值还是{fun:fun_2}。

先看fun(0, undefined),毫无疑问console.log(o)为undefined。运行结果为{fun:fun_2}。通过console.log(fun(0))来求证一下我们的推断

bingo看来推想没错。就是着么个逻辑。那么继续执行这个返回结果

1
console.log(fun(0).fun(1))

看到这里fun_1,fun_2,fun_3的执行逻辑已经清清楚楚,我们在来分析console.log(o),它到底打出了那个变量。

在看我们传入的参数,分析参数是如何在三个fun之间传递的呢

题目看到这里,前期的分析已经全部做完,咱们在看题目的问题以及答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var a = fun(0);  a.fun(1);  a.fun(2);  a.fun(3);
/*
* var a = fun(0, undefined); //undefined毫无异议
* a.fun(1) //同fun(0).fun(1)也就是咱们分析的fun('fun_1_n','fun_1_o').fun('fun_2_m1'),
* 只不过把fun_1_n换成了0,fun_1_o换成了undefined,fun_2_m1换成了1。结果就是undefined,0
* a.fun(2),a.fun(3)也是一样。我们会输出a的fun(0)中的0
* */
var b = fun(0).fun(1).fun(2).fun(3);
/*
* var b = fun(0).fun(1).fun(2).fun(3);
* 同fun('fun_1_n','fun_1_o').fun('fun_2_m1').fun('fun_2_m2').fun('fun_2_m3')
* 即undefined,0,1,2
* */
var c = fun(0).fun(1); c.fun(2); c.fun(3);
/*
* c = fun(0).fun(1) // undefined,0
* fun(0).fun(1).fun(2) // undefined,0,1
* fun(0).fun(1).fun(3) // undefined,0,1
* */

这道题把闭包的深度展现的淋漓尽致,如果你能直接回答出答案并且分析的非常清楚,那么恭喜你,js闭包的问题应该没有什么能难住你的了。

注意这里是回答正确并且分析清楚,分析比理解更难,很多时候你明明知道答案但是你说不出来,或者说你的解释别人听不明白这都是问题,也是本文的目的,让你口中的闭包不再晦涩难懂。


作用域,词法作用域,函数作用域以及块作用域。

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

还是贴出咱们闭包的概念,闭包的概念是函数可以在词法作用域之外记住并访问所在的词法作用域。所以作用域是最最最重要的。如果没有作用域,那么闭包是不存在的。所以我们就一起看看什么是作用域以及形形色色的作用域。

1.作用域是一套规则,用于确定在何处以及如何查找变量。
2.词法作用域是定义在词法阶段的作用域。词法阶段即编译器对声明变量进行赋值的过程。
3.属于这个函数的全部变量都可以在整个函数的范围内使用及复用即为函数作用域。
4.js本不存在块级作用域,但是es6中增加了块作用域的概念。块作用域由{ }包括,if语句和for语句里面的{ }也属于块作用域。

借用你不知道的javascript中的一张图来形象的说明词法作用域和函数作用域。一些特例咱们就不提了,毕竟用到的是少数。

1
2
3
var i = 'abc'
for(var i=0;i<10;i++) {}
console.log(i) // 10

看看上面的代码,我们定义了一个变量i为字符串’abc’,然后不小心在for循环中使用了i,当我们回头想用i的时候,发现i已经从’abc’变成了10。于是乎bug出现。

虽然这个只是一个非常低端的问题,但是不能说它并不会发生,所以es6中增加了一个块级作用域的概念。即在两个大括号中通过es6的声明方式可以解决这种变量污染。

1
2
3
4
5
6
7
8
var i = 'abc'
for(let i = 0;i<10;i++) {}
console.log(i) // abc

{
const A = 'abc'
}
console.log(A) // A is not defined

这样做可以尽可能的避免在作用范围内复用某些变量,导致一些错误的出现。当然可以生成块作用域的方法不仅仅是es6中才有的。包括with和try/catch也可以创建块级作用域。但是我们不细说了,有兴趣的可以自行看看,关于var,let和const在上一篇博客This到底是个什么东西中有提到。


变量提升

在上篇博客This到底是个什么东西中也提到过,但是没有仔细说明。这次说到闭包也有涉及变量提升的地方,所以我们来看看什么是变量提升。

1
2
3
4
5
6
7
8
9
10
11
12
a = 2
var a
console.log(a) // 2

console.log(b) // undefined
var b

c()
console.log(c) // c is not a function
var c = function() {}

console.log(d) // d is not defined

都知道js是顺序执行的一种语言,也就是说我们写的代码会从上倒下执行。看我们的例子我们发现并不是这样。我们先给a赋值,再定义a哪a应该是没有值为undefined才对啊。我们先console.log(b)再声明b哪应该跟c一样报错才对啊。但是结果却跟我们想的完全不通。a的值变成了2,b也变成了undefined。

其实上面的a和b都牵扯到了js编译器的机制。叫做变量提升。

说到变量提升,我们就不得不说一下js源码在执行之前经历了什么。

虽说js和其他传统编译语言有区别,但是抽象来说代码编译也只是经历了三个阶段:

词法分析 -> 语法分析 -> 代码生成

穿插在这三个过程中又有三个不通的角色去处理各自相应的工作。分别是:

引擎:负责javascript程序的编译及执行。

编译器:负责语法分析及代码生成。

作用域:负责收集维护所有声明的变量,并且实施一套语法规则,确定当前执行代码对变量访问权限。

所以我们的代码会先编译再执行。也就是说我们的变量和函数在内的所有声明都会再代码被执行之前处理。处理逻辑如下

1
2
3
4
5
6
7
8
9
10
11
var a,b,c // <--变量提升

a = 2
console.log(a)

console.log(b)

console.log(c)
c = function() {}

console.log(d)

而函数声明和变量声明中又会出现一些相应的机制,比如函数优先等。

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

var foo

foo = function () { // <-称为foo_2
console.log(2)
}

foo() // 2

function foo() { // <-称为foo_1
console.log(1)
}

foo = function () { // <-称为foo_3
console.log(3)
}

foo() // 3

我们来看看这个例子,我们定义了一个foo变量和一个foo函数,并且对foo进行了重写。看第一个foo的输出,我们第一次调用foo()函数时直接调用的是foo_1因为函数优先,函数声明直接提升。所以我们执行的是foo_1。之后foo_2对foo进行了赋值所以foo_2被执行了。foo_3说明后出现的函数声明还是可以覆盖之前所声明的函数。

作用域和闭包

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

重要的事情说三遍,对闭包来说这个定义太贴切了。下面闭包大军来袭,我们会有不同兵种的闭包袭来,请准备好枪支弹药!

最最普通的 - 闭包小兵

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

foo()() // 2 <--此处产生了闭包。

咱们先看几个定义。foo,a,bar。a和bar是foo作用域内部的变量。而bar能访问foo内部的a变量。当我们执行foo()的时候拿到了foo函数的返回值bar。这个bar就是foo内部的bar()函数。然后我们直接调用实际上是在bar定义的词法作用域以外的地方执行。

闭包可以干掉引擎的垃圾回收机制。根据js引擎的垃圾回收机制,在foo执行之后会foo内部的作用域都会被销毁不再占用内存空间。而当闭包存在的时候,因为bar()拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供以后在任何时间调用。

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

拿出定义我们再比对bar记住了foo的作用域且在foo的词法作用域之外被执行了。所以说产生了闭包。

最常使用的闭包 - 回调侦察兵

1
2
3
4
5
6
7
function wait(message) {
setTimeout(function timmer() {
console.log(message)
},1000)
}

wait("i'm closure")

这个是一个最最简单的回调体现,我们通过setTimeout方法,传入timmer,当我们wait执行1秒之后,它内部的作用域并没有消失timmer在执行的时候依然保有wait的message变量。

当我们在使用回调的时候,无论是同步还是异步,我们都无法清晰的得知回调执行的环境以及执行的时间,所以闭包机制会保有相应作用域以及变量以供我们随时去调用。所以说只要存在回调函数,哪闭包就是存在的!

而回调函数在代码中无所不在,所以说回调才是隐藏起来的闭包侦察兵。

佩戴炸药包的循环闭包 - 爆破兵

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

在不理解闭包的时候我们会觉得我们不是应该输出1,2,3,4,5么为啥会输出5个6呢。而且还是同时输出,并没有每隔一秒输出一次。

首先我们来看。我们可以把for循环理解成立即执行完成的,因为在js引擎中for循环也就仅仅执行了几微秒。所以说我们直接执行了5次setTimeout,而这5次的setTimeout都是在一秒以后执行。所以说同时在一秒以后输出5次是合情合理的。

再看我们之前说的闭包场景。i是一个setTimeout作用域外的一个变量,且setTimeout在该作用域内,可以读取该作用域内的变量i。5次setTimout都在该作用域内,且i变量在该作用域下只有一个,所以5次setTimout打印出的都是同一个i。而这个i是什么呢,是for循环中的i,而for循坏跳出的条件是i大于5的时候。所以我们打印出了5个6。

为了可以让闭包在正确的环境爆破,所以这时候我们得带上我们的gps - 块作用域

解决方式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
for (var i = 1; i <= 5; i++) {
(function (j) {
setTimeout(function () {
console.log(j) // 1,2,3,4,5
},1000)
})(i)
}

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

我们用两种方式解决了闭包带来的自爆问题。用一个自执行函数添加了一个函数作用域,在函数作用域内重新定义新变量,改变该变量的作用域,可以理解为老式gps。而es6新引入的let也可以完美解决该问题,前文中和上一篇博客This到底是个什么东西中都有提到let,这里就不多说了。

闭包 + 循环就像爆破兵,如果定位不准或者说根本没有定位,往往容易带来一些问题,炸药包没有放好就在己方阵营中炸了。所以一定注意跟着相应的作用域一起使用,才能那里不爽炸那里,闭包循环爆破兵,你值得拥有!

战争核武器 - 模块化

首先我们通过一段代码理解一下什么是模块化

1
2
3
4
5
6
7
8
9
10
function module(id) {
function identify() {
console.log(id)
}
return {
identify: identify
}
}

module('实现模块化').identify()

我们通过闭包机制,把module中的所有内部方法放在了module()的返回值中,然后再调用其内部方法。这样我们就对我们的模块化方法进行了一次封装。

众所周知面向对象编程的三个特点是封装、继承和多态。通过闭包机制我们可以封装代码,通过原型和原型链我们可以实现继承,而多js天生就是无态的(弱类型语言)天生就支持多态。所以用js就是一个完美的面向对象语言。具体面向对象编程写法后面的文章会慢慢的提到。我们先干掉闭包大军再慢慢道来!

当然我们说的模块化核武器不会像例子着么水,炸了也没有多少水花。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*!
* jQuery JavaScript Library v3.2.1
* https://jquery.com/
*
* Includes Sizzle.js
* https://sizzlejs.com/
*
* Copyright JS Foundation and other contributors
* Released under the MIT license
* https://jquery.org/license
*
* Date: 2017-03-20T18:59Z
*/
(function (global, factory) {
/*
* 省略源码
* */

})(typeof window !== "undefined" ? window : this, function (window, noGlobal) {
/*
* 省略源码
* */
return jQuery;
});

拿出jquery大法,是不是也似曾相识,对jquery也是通过模块化来实现的。

写在最后

其实闭包在日常代码中真的是无处不在,因为我们对闭包的理解不清楚透彻所以说无法说出个一二三。记住定义

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

闭包也可以随你征战码场!