一次微小的JS闭包使用与思考

干货满满(正直脸

Posted by Donggu Ho on 2017-05-10

Office 的坑还没填完【因为突然有了个脑洞
但是给我发红包领取内部抢先试阅版也是很欢迎的!

某个项目的后端(不是我)使用了 Node.js 进行开发,然后在数据库插入中遇到了问题。业务逻辑是,在一系列循环中,每次循环发送一个异步请求,在回调中需要获取本次循环的相关信息。于是为了面试强行学习的知识点闭包第一次在实际应用中排上了用场,还是很开心的。这个人的实践经历真是太薄弱了

最开始的源代码示意如下:

1
2
3
4
5
6
7
8
9
10
11
12
function f(memberIds){
for(var i in memberIds){
/* SQL 是我乱编的 */
var sql = "select * from member where user_id='"+ memberIds[i] +"'"

/* 异步请求 */
db.do_query(sql, function(result){
// do something
console.log(memberIds[i])
})
}
}

闭包的使用

该代码的主要问题是,由于是异步请求,回调函数在被调用的时候i已经发生了改变,导致无法输出有效信息。此时可以考虑使用闭包来隔离存储i

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function closure(memberIds, i){
var val = i
function wrapped(result){
// do something
console.log(memberIds[val])
}
return wrapped
}

function f(memberIds){
for(var i in memberIds){
/* SQL 是我乱编的 */
var sql = "select * from member where user_id='"+ memberIds[i]+"'"

/* 异步请求 */
var callback = closure(memberIds, i)
db.do_query(sql, callback)
}
}

此时由于i在回调前已经初始化传入closure内部,而valwrapped可以访问,因此不再被外界干扰,问题就解决了。

简化一下closure内部的语法,wrapped显然可以匿名:

1
2
3
4
5
6
7
function closure(memberIds, i){
var val = i
return function(result){
// do something
console.log(memberIds[val]))
}
}

简化

现在问题来了,memberIds在循环过程中没有变化,就可以直接传入并取用;但i是有变化的,为了以防万一我在闭包函数中将其复制给val保证隔离;但这一步到底有没有必要呢?

这主要涉及到了函数参数的求值策略问题。如果函数是传名调用(call by name),则表达式参数会在函数体内部被调用时才被取值;如果函数是传值调用(call by value),则表达式会被求值,然后值再被传入函数体。初闻之下感觉就是以前学的值传递和引用传递,但是仔细想想其实是两个完全不同的角度。

1
2
3
4
5
6
7
8
9
10
11
12
function f(x){
return x*2;
}

var a=5;
f(a+3);

// 按名调用,则等同于
(a+3)*2

// 按值调用,则等同于
f(16)

原本这个策略选择好像也没什么影响,但是在异步过程中,表达式何时求值会影响到表达式的实际结果,所以就变得重要起来。

而 JavaScript 选用的策略为按值调用。参数在传入的时候是什么样子,调用的时候就是什么样子(成龙大哥语气),所以val的存在就没有必要了,删掉还能省点内存:

1
2
3
4
5
6
function closure(memberIds, i){
return function(result){
// do something
console.log(memberIds[i]) // 直接使用i即可
}
}

不信的话可以试试:

1
2
3
4
5
6
var a = 3
var f = closure(a)
a = 4 // 在调用前更改a的值

console.log(f('这里的位置留给result'))
// 输出结果为 3

……等一下,既然两个参数都不会变的话……那我为什么还要用两个参数?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function closure(memberId){
return function(result){
// do something
console.log(memberId)
}
}

function f(memberIds){
for(var i in memberIds){
/* SQL 是我乱编的 */
var sql = "select * from member where user_id='"+ memberIds[i] + "'"

/* 异步请求 */
var callback = closure(memberIds[i])
db.do_query(sql, callback)
}
}

使用ES6语法

ES6语法人人爱,这种充满匿名函数的场合最适合使用箭头函数了;模板字符串虽然效率低一点,但是胜在方便啊,写sql语句的时候也是好伙伴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var closure = memberId=>{
return result=>{
// do something
console.log(memberId)
}
}

function f(memberIds){
for(var i in memberIds){
/* SQL 是我乱编的 */
var sql = `select * from member where user_id='${memberIds[i]}'`

/* 异步请求 */
var callback = closure(memberIds[i])
db.do_query(sql, callback)
}
}

由于closure函数直接返回,所以可以进一步简化:

1
2
3
4
var closure = memberId=>result=>{
// do something
console.log(memberId)
}

总结升华(?)

从最终形态的箭头函数可以更形象地看出,这种闭包某种角度而言是把一个一次多参数的函数拆成了多次传值,每次一个参数的函数:

1
2
3
4
5
// 本体
var y = f(x1, x2, x3)

// 变成
var y = f(x1)(x2)(x3)

而通过分阶段传值,我们可以让函数“预加载”一个参数(同步执行的时候传入i),然后异步执行完毕回调时再获取第二个参数,从而解决了问题。这么看来这两层箭头还是非常形象的!

……说出来你可能不信,这种拆函数的手法,学术上有专门的叫法为柯里化(Currying),它作为某种设计模式在软件工程被广泛地应用,被各种夸上了天【。

……哇哦。

所以说实际应用的话还是会比干看知识点体会要深刻很多啦!以及对语言底层基础理论知识的了解还是非常有助于实际开发中的调试与优化的,面试题诚不我欺【。

现在想想当初面试鹅厂的时候面试官问我“你在实际应用中闭包用在什么情景?”

我(只看过菜鸟学院的入门教程):“用在计数器什么的地方,比较方便”

大概也就告别前端了【。

需要注意的是,柯里化之后每次传参时,都会产生一个新的函数实例。

1
2
3
4
5
const fn = x1 => x2 => x1 + x2
const f1 = fn(3)
const f2 = fn(3)

console.log(f1===f2) // false

因此可能会有内存占用、运行速度方面的隐患,此时需要反柯里化。