RadiomM
文章22
标签13
分类1
作用域与闭包

作用域与闭包

闭包可以说是一个面试中非常常见的问题了,想要很好的理解闭包,那么我们就需要学习JS中的作用域的问题,什么是作用域,作用域链呢?接下来我会分两种不同的角度去解释这个问题。

编译原理

要想更深层次的理解作用域,那么就要想理解JavaScript的编译原理。那么在JS中就有三个比较重要的角色要出现了:

  • 引擎

    从头到尾负责整个 JavaScript 程序的编译及执行过程。

  • 编译器

    引擎的好朋友之一,负责语法分析及代码生成等脏活累活。

  • 作用域

    引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查

    询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限 。

在JS的编译里,其实一般都是边编译边执行,但是这个编译过程又是那么的快(一般几ms都搞定了),接下来就是执行过程。
我们都知道啊,对于JS来说,编译过程就是对整个代码块的变量函数等校验的一个过程。看下面代码:

1
2
3
4
5
var a = 1

function b() {
console.log(a)
}

这个JS拿到上面代码的时候,首先要做的就是对变量a以及函数b进行校验,校验其实就是作用域这个好朋友在起作用,
在当前作用域下查找是否有相同命名的变量,如果有的话编译器就会忽略,继续进行编译,没有的话就会在当前作用域下声明一个新的
变量并命名(如上面的代码的分别就是变量a以及函数b)。这个过程就是预编译过程,这里常会出现变量提升问题

进行完预编译过程以后就是执行过程,以上述代码为例的话,就是对变量a进行赋值,不同的是函数b其实是一整个提升。其实就是函数可以先调用的解释

1
2
3
4
5
6
add()

function add() {
console.log('add函数')
}

变量提升

其实对于变量提升的知识点并不是很多,归结起来其实就两点,如下:

  • 在当前作用域下,调用未定义的变量,会出现undefined
  • 变量的提升次于函数的提升

尝试一下代码:

1
2
3
4
var v='Hello World';
(function(){
alert(v);
})()

结果很容易就可以猜得到,其实就是 Hello World。

那么在尝试下面的代码:

1
2
3
4
5
var v='Hello World';
(function(){
alert(v);
var v='I love you';
})()

怎么样,会不会有出乎意料的输出结果,怎么会输出这个undefined讨人厌的家伙,其实这就是变量提升做的怪,其实这里就印证了上面的第一点。至于分析的话,其实就是作用域链的问题了。

为了印证第二点,可以尝试下面的代码:

1
2
3
4
5
6
7
8
console.log(add) // 函数add

function add() {
console.log(2)
}

var add = 1
console.log(add) // 1

这时候有聪明的朋友就会想到,函数不是还有函数表达式的方式吗?其实也很简单可以印证,如下:

1
2
3
4
5
6
7
8
9
10
11
add() // 2

function add() {
console.log(2)
}

var add = function() {
console.log(1)
}

add() // 1

还有更聪明的朋友就会想到,哎呀,这变量跟函数以及函数表达式都重名会怎么样啊?!,伸手党是吧,你自己不会去证明吗?!,作为一个有素质的博主,肯定为你们考虑啦,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log(add) // 函数add

function add() {
console.log(2)
}

var add = function() {
console.log(1)
}

console.log(add) // 函数表达式add

var add = 1
console.log(add) // 1

其实函数表达式的表现跟变量其实是一样的。 什么?你想问我结论?,上面不是早就写了?你是不是瞎啊?!

词法作用域

说到词法作用域,其实就是变量作用域,我们知道,变量都是有它的作用的(这是一段废话),先看下面的代码:

1
2
3
4
5
6
7
8
9
var a = 1

funcion b() {
var c = 2
console.log(a)
console.log(c)
}

b()

上面代码的词法作用域,可以用下图来演示:

作用域1

其中像变量a这种在别的地方调用的,我们成为是自由变量。其实所谓的作用域链,就是作用域套作用域,像上图,就是函数b的作用域套在全局作用,就构成了作用域链。作用域链的查找变量是向上层作用域查找,就是一层层向上寻找,直到全局作用域
其实不难发现,作用域链的作用就是,找不存在与当前作用域的变量,像变量a这种在全局作用域的变量,我们就可以叫做是全局变量。像变量c这种的,我们就可以叫做局部变量,局部变量的特性就是不能在它作用域外访问,但是可以在它作用域内访问,像变量a这种自由变量就是一个例子。

闭包

什么是闭包

要想知道什么是闭包,我们先举一个闭包例子看看:

1
2
3
4
5
6
7
8
9
10
function add() {
var a = 0
return function() {
return a++
}
}

let result = add()

console.log(resule()) // 反复执行这句

上面的代码应该是最典型的闭包例子。以前面试的时候,面试官问我什么是闭包的时候,我都会说,就是一个函数内定义个变量,通过返回函数的方式,在返回函数中调用定义的变量,这就是闭包。咋一看好像也没有错是吧,按照上面的例子说的通。那么我们再看下面的例子:

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

function foo() {
var a = 0
func = funcion() {
return a++
}
}

foo()

console.log(func()) // 反复执行这句

很明显,上述代码并没有返回什么函数,而是将全局变量func赋值成匿名函数表达式,并在内部对变量a的引用,这样也可以实现前面代码实现的效果。这样就可以推翻了上面的说法,那么到底什么是闭包呢?我在红宝书第四版中找到了说法,它说闭包是那些引用了另一个函数作用域中的变量的函数,通常在嵌套函数中实现。这就麻烦了,什么是函数作用域啊?怎么会有这个概念的呢?别急,看我表演了时候到了,请看下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
function compare(value1,value2) {
if(value1 < value2) {
return -1
} else if( value1 > value2) {
return 1
}else {
return 0
}
}

let result = compare(10,5)

compare函数是运行在全局作用域下的。那么在compare函数调用的时候会发什么呢?

在JS的函数执行时,每个执行上下文都会有一个包含其中的变量的对象,那么全局上下文中的叫做变量对象,那么函数中的就叫做活动对象,顾名思义,这个活动对象只存在函数的过程中,函数执行完时就会进行垃圾回收,进行销毁。那么在compare函数执行过程中,创建执行上文,首先会预设变量对象(即全局上下文),接着会创建函数的执行上下文并将活动对象(即compare函数的)推到作用域链的首位。那么在这个例子中,compare函数的作用域链就是compare函数作用域 =》全局作用域。如下图:

闭包1

那么闭包该怎么表示呢?看下面修改的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function compare(value1,value2) {
let res;
if(value1 < value2) {
res = -1
} else if( value1 > value2) {
res = 1
}else {
res = 0
}

return function() {
return res
}
}

let result = compare(10,5)

console.log(reult())

如下图展示:

闭包2

其实正常的函数活动对象会在执行完的时候销毁,但是由于闭包的存在,对compare函数的活动对象还存在引用关系,迫使不能立即销毁,而保留下来了。那么怎么消除闭包呢?其实对于上述代码很简单,只需要加上下面的代码即可:

1
result = null

取消了对compare活动对象的依赖,那么就可以正常的垃圾回收到内存。

回归到什么是闭包这个问题,其实红宝书的解释我觉得就可以了,但是如何要说到作用域上面,我那么我觉得可以这么说:函数不在其定义时的作用域调用,并且对当前作用域变量保持引用。

闭包的用途

维护私有变量

这个功能应该是比较输出,其实上面的例子中就有用到,看看下面代码:

1
2
3
4
5
6
7
8
9
10
11
function hostName() {
var name = 'ydw'
return function() {
return name
}
}

let result = hostName()

console.log(result())

可以看到,其实我们并不能修改里面name的值,只能获取到name的值,如果想要修改里面的值,其实我们可以对代码稍微的改动即可。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function hostName() {
var name = 'ydw'
return {
getName: function() {
console.log(name)
return name
},
setName: function(changeName) {
name = changeName;
console.log(name)
}
}
}

let result = hostName()

result.getName()

result.setName('RadiomM')
result.getName()

通过改造代码,返回时对象形式的两个函数,从而到达可以修改name的目的。

函数柯里化

柯里化其实一种综合多个函数作用的一个表现形式,来想一个场景,我们需要计算我们自己的月销是多少的时候,可能会写到下面的代码:

1
2
3
4
5
6
7
8
9
10
var monthCost = 0

var cost = function(money) {
monthCost += money
}

cost(100) // monthCost: 100
cost(200) // monthCost: 300
cost(300) // monthCost: 600

虽然这样可算出一个月的消费是多少,但是,在计算过程中我们其实不需要关心我到底每天用了多少钱,我只关心到一个月到底消费了多少钱。实现这个需求其实就可以用到闭包了,我们可以存一个变量,来记录每次相加的时候得到的值,然后在最后计算到结果是多少,代码调整如下:

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

var cost = (function(){
var args = [];

return function() {
if (arguments.length === 0) {
var money = 0;
for(let i = 0; i < args.length;i++){
money += args[i]
}
return money
} else {
[].push.apply(args,arguments)
}
}
})()

cost( 100 ); // 未计算到结果
cost( 200 ); // 未计算到结果
cost( 300 ); // 未计算到结果


console.log( cost() ) // 600

实际上还可以对代码进行优化,因为对于cost函数来说,只需要计算最后的结果多少就可以了,其他对于函数参数的判断,可以交给另外一个函数做,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var currying = function(fn) {
var args = []

return function() {
if(arguments.length === 0) {
return fn.apply(this,args);
}else {
[].push.apply(args, arguments);
return arguments.callee;
}
}
}

var cost = (function() {
var money = 0

return function() {
for(let i = 0; i < arguments.length;i++){
money += arguments[i]
}
return money
}
})()

var cost1 = currying(cost)

cost( 100 ); // 未计算到结果
cost( 200 ); // 未计算到结果
cost( 300 ); // 未计算到结果


console.log( cost() ) // 600

总结

好了,闭包的内容就只只有以上内容了,觉得可以的朋友可以关注一下我的博客喔。

参考书籍:

《JavaScript设计模式与开发实践》
《JavaScript高级程序设计 第四版》
《JavaScript权威指南 第六版》
《解锁前端面试体系攻略》 修言