node-thunkify 的实现及源码阅读

3.8k words

最近看了些项目的源码,其中就包括 thunkify。虽然 thunkify 代码简单,很快就读完了,不过看看项目的测试文件和提交历史,也还是能看出很多事物的。这次阅读的代码是当前最新版本,commit 编号 0bd83e

功能陈述

将一个函数转换为一个为 Thunk 函数,这个函数被调用后会返回一个以回调函数为参数的函数。可以参考 Thunk 函数

简单实现

因为需求比较简单,所以我自己先实现了一下,然后比较与官方仓库的差异,可以发现一些源码的特点。呢

我自己的实现:

1
2
3
4
5
6
7
module.exports = function (fn) {
return function (...args) {
return function (callback) {
fn(...args, callback)
}
}
}

从测试看问题

源码有 test 文件,可以将源码的测试文件 clone 到本地后测试。将上面的代码测试后可以发现 3 个测试未通过:

  1. thunkify(fn) should maintain the receiver
  2. thunkify(fn) should catch errors
  3. thunkify(fn) should ignore multiple callbacks

maintain the receiver

通过 test 文件代码发现,这里主要涉及到一个关于 this 的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function load (fn) {
fn(null, this.name)
}

var user = {
name: 'tobi',
load: thunkify(load)
}

user.load()(function (err, name) {
if (err) return done(err)
assert(name === 'tobi')
done()
})

原函数 f 可能是某个对象的方法,所以要保证 thunkify 后的函数,称它为 tf, 仍然能正常访问 this ,所以 tf 函数里需要能引用到原函数 f 里 this 的值,thunkify 源码中用 ctx 变量对其表示原函数 f 的 this

1
2
var ctx = this; // line 27
fn.apply(ctx, args); // line 43

catch errors

尽管原函数 f 是需要一个回调函数做参数,理论上这个回调函数应该能捕捉异常了,但有些时候,这个回调函数可能没有正常 catch,例如测试文件中的样例:

1
2
3
4
5
6
7
8
9
10
11
12
// fn 作为回调函数却没有捕捉这个异常
function load (fn) {
throw new Error('boom')
}

load = thunkify(load)

load()(function (err) {
assert(err)
assert(err.message === 'boom')
done()
})

而 thunkify 则 “帮” 其捕捉了。当发现原函数 f 抛出异常时,源码中自动用用户传进的回调函数捕捉了。

1
2
3
4
5
6
7
// line 42 - 47
// done 代表用户传进的回调函数
try {
fn.apply(ctx, args);
} catch (err) {
done(err);
}

ignore multiple callbacks

理论上讲,一个回调函数 callback 只能被调用一次,但在实际情况中,仍然会存在被调用多次的意外情况,例如测试文件中的例子:

1
2
3
4
5
6
7
8
9
function load (fn) {
fn(null, 1)
fn(null, 2)
fn(null, 3)
}

load = thunkify(load)

load()(done)

为了确保回调函数 callback 只被调用了一次,thunkify 对回调函数进行了一次封装:

1
2
3
4
5
6
7
8
// line 34 - 40
var called;

args.push(function(){
if (called) return;
called = true;
done.apply(null, arguments);
});

called 作为一个 flag,第一次调用时,if 语句会把 called 当作否定值,所以 return 不会被执行。但从第二次开始,called 都会变成 true,所以 return 都会执行,确保了回调函数 callback 只会被调用一次。

这里需要注意一下 args 这个变量,它每次都会 push 一下,因为 thunkify 后的函数 tf 可能被引用调用多次:

1
2
3
4
5
6
7
8
9
10
11
12
function fn (done) {
done(null, Date.now())
}

let tf = thunkify(fn)()

let c1, c2, c3
c1 = c2 = c3 = (err, value) => console.log(value)

tf(c1)
tf(c2)
tf(c3)

这个程序最后只会输出一次值。因为根据闭包规则,第二次调用 tf 开始,回调函数就被 pushargs 里了。当第三次调用 tf 时,此时的 args 等价为 [c1, c2, c3],根据源码, tf(c3) 相当于调用 tf.apply(ctx, [c1, c2, c3])。所以此时真正执行的回调是 c1,如前文所说,这个 c1 是被源码封装过的,里面的内容只会被执行一次。

自己的实现(改进后)

结合 ES6,自己在解决上述问题后又实现了一遍,功能上没有改变,全是增加鲁棒性

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
module.exports = function (fn) {
assert('function' === typeof fn, 'function required')

return function (...args) {
const ctx = this // fix test 'should maintain the receiver'

return function (callback) {
const refinedCallback = function (callback) {
let first = true
return function (...args) {
if (!first) return
first = false
callback(...args)
}
}

args.push(refinedCallback(callback))

try {
fn.call(ctx, ...args)
} catch (err) {
callback(err)
}
}
}
}

从 Commit 看改进

查看各个 commit,以及以前的代码,可以发现一些有趣的事。

crankshaft

d53746 这个 commit 中,提交者改变了 arguments 变成了数组的方式,从简单的 slice 方法,变成了声明一个数组然后一一赋值的方法。

第一次看到 crankshaft 还不知道是什么,后来才知道是指代 Chrome 的一个引擎。Pull request #12 有提到这个优化,虽然我还是觉得这个优化在某种程度牺牲了部分可读性。

remove memoization

参考 30f25a 移除了一个记忆化操作。

在这个版本之前的代码,如果执行下面的程序,会发现这三次执行都输出同样的结果,这多多少少有点反直觉,所以 commit 上 tj 也说 promises have different expectations

1
2
3
4
5
6
7
8
9
function fn (done) {
done(null, Date.now())
}

let dtn = thunkify(fn)()

dtn((err, value) => console.log(value))
dtn((err, value) => console.log(value))
dtn((err, value) => console.log(value))

所以这个改进后,执行上述程序只会输出一次结果。

add assert(fn)

05abda 处增加了一个 assert 调用避免被 thunkify 的参数不是函数 – 一个提高函数鲁棒性的功能。

remove support for eager execution

a504b9 算一次比较大的改进,发现这个 commit 大大缩减了代码。不过这次删去了避免回调函数被多次执行的代码,也就是对回调函数的封装。在这之后的几个 commit 里,维护者又把这一层封装添加了回去。

总结

在没有看过 thunkify 源码的情况下,这个功能的实现并不算难。但 thunkify 代码比在本文开始我自己写的代码更优秀的地方就在于代码的鲁棒性,这一点从测试文件和 commit 日志中可见一斑,确实考虑了生产环境中可能出现的众多复杂情况,更适合日常使用。同时,阅读 thunkify 源码的目的也并不限于代码的实现,更多的是学习维护和增加代码的鲁棒性。

参考

  1. Generator 函数的异步应用
  2. node-thunkify源码阅读笔记