JavaScript中的闭包

闭包(Closure)有着非常强大的作用,尤其是在像SwiftJavaScript这种可以直接把函数当成值来使用的编程语言中,闭包几乎无处不在。在《JavaScript语言精粹》一书中,作者将闭包列为了JavaScript的优美特性之一,可见理解闭包很重要。在MDN上对闭包的描述如下:

函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包(closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在 JavaScript 中,每当函数被创建,就会在函数生成时生成闭包。

先看下面这段代码:

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

运行之后,打印了2。按照词法作用域的查找规则,函数内部声明的参数和变量在函数内部的任意位置都可见,能打印2似乎也没有什么奇怪的。如果闭包只是这样就没意思了,不要忘了,在JavaScript里函数也是对象,所以函数也可以作为参数传递,作为返回值返回。修改一下上面的代码,将bar返回:

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

var bar = foo()
bar()

即使bar已经脱离了当时定义它的外部函数才被调用,但是bar依然与a绑定,导致a没有释放,还是打印了2。这就是闭包强大的地方,内部函数会劫持创建它时的作用域,导致该作用域中的局部变量延迟释放,之后无论这个内部函数在何处调用,都能访问到它劫持到的局部变量。

下面总结了在JavaScript中可以用闭包来解决的实际问题,后面发现其他的情况,再回来继续补充。

闭包与循环

说到闭包,下面这段代码就会经常拿来举例:

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

代码并没有像语义上表达的这样打印从0到4,而是打印了5个5。因为var定义的变量不会产生块作用域,所以每次循环用的都是同一个i变量,并且在循环结束后i变量也不会销毁。每次循环创建的timer函数中,引用的也是同一个i变量。所以导致所有的timer在调用时输出的都是这个i最终的值。

对上面的代码进行如下改动:

1
2
3
4
5
6
7
8
9
10
var helper = function (j) {
// timer会劫持j
return function timer() {
console.log(j)
}
}

for (var i = 0; i < 5; i++) {
setTimeout(helper(i), 100)
}

这回打印了从0到4,看似helper没有做什么,直接将timer函数返回了,但在每次迭代时helper的参数j都会重新创建,所以timer每次绑定的都是一个新的变量。

更简化一些,可以使用let声明变量,也会达到想要的结果:

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

因为let变量会在每次迭代时都重新声明,然后使用上一次迭代得到的值初始化。

再来看下面两种写法,会输出什么结果呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 第一种
for (var i = 0; i < 5; i++) {
let j = i
setTimeout(function timer() {
console.log(j)
}, 100)
}

// 第二种
for (var i = 0; i < 5; i++) {
var j = i
setTimeout(function timer() {
console.log(j)
}, 100)
}

let变量每一次迭代都重新声明,所以第一种写法会打印0到4,var变量不会在每次迭代时重新声明,所以j实际上只会被声明一次,所以j最后的值是4,最后会打印5个4。

回调

函数使得对不连续事件的处理变得容易。例如,发起一个网络请求,在请求结束时将结果显示在屏幕上。因为请求从发出到得到响应结果会经过一段时间,如果用同步请求,那在这段时间内用户只能等待,什么也做不了,体验会很差。更好的方式是用异步请求,提供一个当服务器响应到达时随即触发的回调函数。

1
2
3
4
request = createRequest()
sendRequstAsyn(request, function (response) {
display(response)
})

传递一个函数作为参数,在拿到响应数据时即可调用这个函数。

用闭包封装私有变量

构造模块

我们可以使用函数和闭包来构造模块。模块是一个提供借口却隐藏状态与实现的函数或对象。通过使用函数产生模块,我们几乎可以完全摒弃全局变量的使用。

举个例子,给String增加一个可以将HTML字符实体替换为对应字符的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String.prototype.deentityify = function () {
// 实体字符表
var entity = {
quot: '"',
lt: '<',
gt: '>'
}
return function () {
return this.replace(/&([^&;]+);/g, function (a, b) {
// a是匹配到的字符串,b是括号中匹配到的内容
var r = entity[b]
return typeof r === 'string' ? r : a
})
}
}()

我们这里定义了一个函数,该函数返回了真正的对字符串进行处理的deentityify函数。注意最后一行的(),定义完成后立即执行了这个函数。我们把entity放在这个函数中,很好地对外隐藏了它,还使得返回的函数也能访问到它。为什么不把entity直接放到deentityify函数中呢?因为这会带来运行时的损耗,每次执行该函数时entity都要被求值一次。

模块模式的一般形式是:一个定义了私有变量和函数的函数,利用闭包创建可以访问私有变量和函数的特权函数,或者把它们保存到一个可以访问到的地方。

模块模式也可以用来产生安全对象。假如我们要构造一个用来产生序列号的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var serialMaker = function () {
var prefix = ''
var seq = 0
return {
setPrefix: function (p) {
prefix = p
},
setSeq: function (s) {
seq = s
},
gensym: function () {
var result = prefix + seq
seq += 1
return result
}
}
}

var seqer = serialMaker()
seqer.setPrefix('Q')
seqer.setSeq(1000)
var unique = seqer.gensym() // unique是"Q1000"

第三方调用者只能通过setter方法来改变prefixseq,所以seqer是安全的。实际上seqer就是一组函数,这组函数拥有特权可以访问私有变量。

实现单例模式

单例对象,就是在全局只有一个实例的对象。在像Java这样基于类的语言中,一般实现单例的方式是在创建类的实例前先判断是否已经存在了一个该类的单例,如果存在则直接返回,否则创建一个再返回。但是在JavaScript中呢,实现单例其实特别容易,我们只要在全局作用域中用var声明一个变量,那这个变量就会是唯一的了,之后出现该变量时都是同一个。但是全局变量容易发生命名冲突等许多问题,所以应该尽量减少时候全局变量。通过前面的介绍,我们可以将这个存储单例的变量放在一个闭包内。

下面创建一个只用来获取唯一对象的闭包:

1
2
3
4
5
6
var getSingle = function ( fn ) {
var result
return function () {
return result || ( result = fn .apply(this, arguments ) )
}
}

假如现在要创建一个登录弹窗,一个应用程序中登录弹窗通常都是只有一个的,就可以像下面这样使用getSingle

1
2
3
4
5
6
7
8
var createLoginLayer = function () {
var div = document.createElement( 'div' )
div.innerHTML = '我是登录浮窗'
return div
}

var createSingleLoginLayer = getSingle(createLoginLayer)
var singleLoginLayer = createSingleLoginLayer()

其实完全可以在创建登录弹窗时先判断是否已经创建过了,但这样做就使得代码复用度降低了,破坏了软件设计要遵循的单一原则,将判断是否创建过,和创建的过程分离开,使得getSingle不仅可以createSingleLoginLayer,也可以createSingleIframecreateSingleScript等等。而且如果有一天需求变得登录弹窗不是只能有一个的时候,我们也不需要修改任何代码。

柯里化

柯里化(Curry)是将一个有N个参数的函数,转换为N个只有一个参数的函数,并进行调用的技术。比如下面这个add函数:

1
2
3
4
5
function add(x, y) {
return x + y
}

add(1, 2)

经过柯里化后变为:

1
2
3
4
5
6
7
8
9
10
11
12
13
function add(x) {
return function (y) {
return x + y
}
}

// 用一个变量来接收 add 返回的函数,后面我们可以用这个函数让任意数与1想加
var increment = add(1)
var addTen = add(10)

increment(2) // 返回 3

addTen(2) // 返回 12

上面的例子虽然简单,但是让我们看到了柯里化的一大好处,只要传一小部分参数,就得到了一个记住这些参数的新函数。得到的新函数更加方便复用,我们不用再一遍一遍写这些固定的参数。下面看一个更加实用的例子,利用lodash封装好的将函数进行柯里化的方法curry方法,对matchfilter进行改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var match = _.curry(function(what, str) {
return str.match(what)
})

var filter = _.curry(function(f, arr) {
return arr.filter(f)
})

// hasSpaces即为function(x) { return x.match(/\s+/g) }
var hasSpaces = match(/\s+/g)

// 之后我们可以判断任意指定的字符串是否包含空格,而不需要再重复写正则表达式
hasSpaces('Hello World')

// 我们还可以将hasSpaces作为参数传给filter,过滤出包含空格的字符串
filter(hasSpaces, ['Hello World', 'hello_world'])
// 返回[ 'hello_world' ]

改造之后matchfilter可以轻松用于多种情况,而无需我们反复写相同的代码。可以移步这里查看更多关于curry的例子。

记忆

函数可以将先前操作的结果记录在某个对象里,从而避免无谓的重复运算。这种优化被称为记忆(Memoization)。看一个用递归计算Fibonacci数列的例子:

1
2
3
var fibonacci = function (n) {
return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2)
}

在递归的过程中会多次计算已经计算过的值,比如一个求fibonacci(5)的过程可分解成这样:

1
2
3
4
5
6
7
8
9
                f(5)
/ \
f(4) + f(3)
/ \ / \
f(3) + f(2) f(2) + f(1)
/ \ / \
f(2) + f(1) f(1) + f(0)
/ \
f(1) + f(0)

可以使用一个数组来保存每次计算的值,在继续递归之前先判断数组中是否已有值,改造后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
var fabonacci = function (n) {
var memo = [0, 1]
var fib = function (n) {
var result = memo[n]
if (typeof result !== 'number) {
result = fib(n - 1) + fib(n - 2)
memo[n] = result
}
return result
}
return fib
}()

能这样做得益于JavaScript的数组可以取任意位置的值,而不会有越界的问题导致崩溃,还可以像任意位置直接赋值。所以用JavaScript的对象和数组,实现记忆优化非常方便。

上面的代码还可以改造的更加通用一些,使之可以服务给定的任意递归计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var memoizer = function (memo, formula) {
var recur = function (n) {
var result = memo[n]
if (typeof result !== 'number') {
result = formula(recur, n)
memo[n] = result
}
return result
}
return recur
}

var factorial = memoizer([1, 1], function (resur, n) {
return n * resur(n - 1)
})

console.log(factorial(5))

参考资料

【1】 《JavaScript语言精粹》

【2】 https://github.com/lodash/lodash

【3】 《JavaScript设计模式与开发实践》

本文作者:意林
本文链接:http://shinancao.cn/2019/07/22/JS-Closure/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!