原文地址

一直以来,我对 JavaScript 的方法调用存有困惑。尤其是很多人一直抱怨,方法中的 this 语义含糊不清。

在我看来,只要理解了方法调用原语就可以解开这些疑惑,其他所有的方法调用都可以认为是原语上的语法糖。事实上,ECMAScript 规范就是这么做的,这篇文章可以认为是简化版的规范说明,基本思路是一样的。

原语

方法调用的原语是 call 这个方法:

  1. 接受一个参数列表 argList
  2. 第一个参数是 thisValue
  3. 触发函数调用,设置 thisValuethis, 剩余的参数作参数列表

举例:

1
2
3
4
5
function hello(thing) {
console.log(this + " says hello " + thing);
}

hello.call("Yehuda", "world") //=> Yehuda says hello world

你会发现,this 的值被设置为 Yehuda, 只有一个参数 thing 被设置为 “world” 。这就是 JavaScript 的方法调用原语。可以认为其他的方法调用都是这种形式的语法糖。

:在 ES5规范 中,call 被当做更底层的原语,但是仅仅是一层比较浅的封装,因此这里做了简化。

简单的方法调用

如果一直使用 call 来做函数调用,会显得很麻烦,因此 JavaScript 允许我们使用 hello("world") 这种语法去做函数调用,JavaScript 会帮我们去糖:

1
2
3
4
5
6
7
8
9
function hello(thing) {
console.log("Hello " + thing);
}

// this:
hello("world")

// desugars to:
hello.call(window, "world");

在 ECMAScript 5 中,如果使用 strict mode,行为会有所改变:

1
2
3
4
5
// this:
hello("world")

// desugars to:
hello.call(undefined, "world");

更简短的描述可以认为 fn(...args)fn.call(window [ES5-strict: undefined], ...args) 的调用是一样的。

内部函数的声明也是这样,诸如 (function() {})() 的函数声明和 (function() {}).call(window [ES5-strict: undefined) 也相同。

:事实上,这里有一点不是很准确,ECMAScript 5 规范中声明了 undefined 一定会被传递,但是当方法在非严格模式中被调用的时候,应该把 thisValue 的值设置为全局变量,这可以让严格模式下的调用者避免破坏已存在的非严格模式的库。

成员方法

另一种常见的函数调用形式就是作为成员函数被调用,person.hello()。这种情景下,函数调用会变成如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
var person = {
name: "Brendan Eich",
hello: function(thing) {
console.log(this + " says hello " + thing);
}
}

// this:
person.hello("world")

// desugars to this:
person.hello.call(person, "world");

请注意,hello 函数作为谁的成员并不重要,我们生命 hello 为单独的函数,下面看一下如何动态绑定对象:

1
2
3
4
5
6
7
8
9
10
function hello(thing) {
console.log(this + " says hello " + thing);
}

person = { name: "Brendan Eich" }
person.hello = hello;

person.hello("world") // still desugars to person.hello.call(person, "world")

hello("world") // "[object DOMWindow]world"

注意,hello 并不会一直持有 this 的绑定,他总是根据调用者的调用形式,在调用的时候动态设定的。

使用 Function.prototype.bind

有时候绑定 this 为固定值是非常常见和方便的,因此有些时候人们会利用闭包来实现 this 值的绑定。

1
2
3
4
5
6
7
8
9
10
var person = {
name: "Brendan Eich",
hello: function(thing) {
console.log(this.name + " says hello " + thing);
}
}

var boundHello = function(thing) { return person.hello.call(person, thing); }

boundHello("world");

尽管 boundHello 仍然被去糖,变成 boundHello.call(window, "world") 形式的调用,我们曲线救国,重新绑定了 this 的值为我们想要绑定的对象。

我们可以微调一下,让这种调用更通用:

1
2
3
4
5
6
7
8
var bind = function(func, thisValue) {
return function() {
return func.apply(thisValue, arguments);
}
}

var boundHello = bind(person.hello, person);
boundHello("world") // "Brendan Eich says hello world"

为了理解这种调用,需要注意两点:

  1. arguments 是一个数组,代表了函数调用的所有参数
  2. applycall 基本一致,除了他接受的参数是数组形式,而不是需要一个个的列举出来

bind 函数返回了一个新的函数。当函数被调用的时候,bind 仅仅是触发了参数中传递进来的函数,this 的值也是通过第二个参数重新绑定。

既然这种形式得到大家的共识,因此 ES5 在 Function 对象上添加了 bind 函数:

1
2
var boundHello = person.hello.bind(person);
boundHello("world") // "Brendan Eich says hello world"

当把一个纯函数作为 callback 传递的时候是非常有用的:

1
2
3
4
5
6
7
8
var person = {
name: "Alex Russell",
hello: function() { console.log(this.name + " says hello world"); }
}

$("#some-div").click(person.hello.bind(person));

// when the div is clicked, "Alex Russell says hello world" is printed

这种形式并不优雅,因此 TC39(一个委员会致力于下一代 ECMAScript 的标准)一直在研究一种方案,技能向前兼容,又足够优雅。

jQuery

jQuery 大量使用了匿名回调,内部调用了 call 来设置 callback 的 this 为更有用的值。例如,jQuery 触发 callback 的时候把设置了事件监听的元素作为 this 的值,而不是不经过处理,直接把 window 作为 this 的值。

这非常有用,因为匿名回调函数里的默认 this 的值通常并没有什么用,对于 JavaScript 的初学者来说,this 的值经常容易造成困难并且很难解释清楚。

如果你掌握了把方法调用去糖为 func.call(thisValue, ...args) 的基本法则,你将会如鱼得水。

PS: 偷梁换柱

在一些地方,我简化了 JavaScript 规范中的繁文缛节。比如,我把 func.call 称作原语。实际上,规范中, 对于 func.call[obj.]func() 有原语的概念([[Call]])。

然而,我们可以看一下 func.call 的定义:

  1. 如果 IsCallable(func) 为 false, 抛出 TypeError 异常
  2. argList 设置为空列表
  3. 如果方法被调用的参数多余一个,从 arg1 开始,从左至右的顺序组成 argList
  4. 返回 func 内部的 [[Call]] 的调用结果,把 thisArg 作为 this 的值,argList 作为参数列表

显而易见,这个定义其实就是 JavaScript 对原语 [[Call]] 的绑定。

如果你浏览一下函数触发的定义,前面几步是设置 thisValueargList, 最后一步是返回 func 内部的 [[Call]] 的调用结果,把 thisArg 作为 this 的值,argList 作为参数列表。

一旦 thisValueargList 确定下来,含义都是一样的。

我把 call 称作原语其实是偷梁换柱,正如前面所讲,这和规范中的描述其实都是一致的。

PS:有一些情况我并没有覆盖到,比如使用 with 关键字的情况。