念一叨

将 JavaScript 成员函数「提升」成静态函数

众所周知,JavaScript 里的「成员」其实是个很塑料的关系,我们可以随意把一个对象的成员函数在另一个毫无关系的对象上调用:

const foo = {
	name: 'foo',
	print() {
		console.log(this.name);
	}
};
foo.print();	// log: foo
const bar = {
	name: 'bar'
};
bar.print = foo.print;
bar.print();	// log: bar

为了不用每次想在对象上调用其他对象的成员方法时都得手动赋一遍值——这样做又脏又麻烦—— 那么问题来了:今有一成员方法,如何包装之能够得到一个以 this 为第一参数的等价静态方法?

JavaScript 提供了 Function.prototype.call 这个方法来实现将 this 作为第一参数这个事。 它接收一个用作 this 的对象和任意数量的参数。 在函数上调用它,能够得到函数在动态绑定的 this 值上的调用结果:

function print() {
	console.log(this.name);
}
print.call({ name: 'bar' });	// log: bar

不过我们要的是一个等价静态方法,而不是每次调用都得额外写出 .call。 这个也很好解决,只需要写一个 wrapper 就可以了:

const wrapper = f => (_, ...args) => f.call(_, ...args);
const print = wrapper(function() {
	console.log(this.name);
});
print({ name: 'bar' });	// log: bar

至此可以算是解决了问题,但为了一些shuizishu美学上的考虑,我对这个 wrapper 不甚满意。

首先是那个尾大不掉的 ...args:为了照顾各种带参函数,完全违反了 currying 的精神; 其次是注意到 wrapper 的形参与 f.call 的实参完全相同,这说明我们只是在原封不动地传递参数。 这两点缺陷暗示我们应该去找一个无显参的实现方法。

所幸,Function.prototype.call 有个亲戚可以满足我们的需求:Function.prototype.bind。 它也接收一个作为 this 的对象和若干参数,但调用在函数上时并不立即返回函数的调用结果,而返回待调用的原函数的静态替身;替身的调用结果等价与原函数调用在动态绑定的 this 上的结果。我们可以用一小段代码来说明这件事:

function adder(n) {
	this.value += n;
}
const num = { value: 1 };
adder.call(num, 1);
console.log(num.value)  // log: 2

const num_adder = adder.bind(num);
num_adder(1);
console.log(num.value)  // log: 3

利用 Function.prototype.bind,我们可以将原先的 wrapper 写成这样:

// const wrapper = f => (_, ...args) => f.call(_, ...args);
const wrapper = f => Function.prototype.call.bind(f);

没看懂逻辑?不妨试着展开一下 wrapper 的调用栈:

wrapper(f)(this, ...args)
-> Function.prototype.call.bind(f)(this, ...args)
-> f.call(this, ...args)

这与一开始的 wrapper 一样。

事实上,我们能做得更好。 由于现在的 wrapper 只是计算将 f 送进 Function.prototype.call.bind 的结果,那我们完全可以认为 wrapper 本质上就是 Function.prototype.call.bind。 用数学语言说,如果函数 $f:=x\to g(x)$,那么 $f(x)\equiv g(x)$,即 $f$ 与 $g$ 是同一个函数。 我们尝试用 JavaScript 来转写这个想法:

// const wrapper = f => Function.prototype.call.bind(f);
const wrapper = Function.prototype.call.bind;
const print = wrapper(function() {
	console.log(this.name);
});
print({ name: 'bar' });
// Uncaught TypeError: Function.prototype.bind called on incompatible target

报错了。

恭喜你踩到了 JavaScript 的一个千年巨坑:先将成员函数赋值给变量再调用会导致 this 丢失。 还记得文章开头的那段代码吗:

bar.print = foo.print;
bar.print();	// log: bar

既然 foo.print 调用在 bar 上已经令其在运行时的 this 指向了 foo,同理可得,将 Function.prototype.call.bind 先赋给 wrapper 再调用的过程也改变了其运行时的 this(原本应该指向 Function.prototype.call)。

为了保持 this,我们需要再套一层 bind

// const wrapper = Function.prototype.call.bind;
const wrapper = Function.prototype.bind.bind(Function.prototype.call);
const print = wrapper(function() {
	console.log(this.name);
});
print({ name: 'bar' }); // log: bar

这下终于达成目的了。(全文完)