翻译 | 深入理解JavaScirpt的函数调用和"this"

过去很多年里,我看到过太多关于JavaScript函数调用的混淆。尤其是,很多人抱怨函数调用中this的语义令人困惑。
在我看来,通过理解核心函数调用原语,然后将其他所有调用函数的方法视为在原语之上的语法糖,如此便可澄清很多这类疑惑。事实上,这正是ECMAScript规范对此的看法。在某些方面,这篇文章是规范的简化,但基本思路是一样的。

核心原语

首先,我们先看一下函数调用的核心原语,Function对象的call方法[1]。调用方法方法相对简单。

  1. 从参数1到末尾创建一个参数列表(argList)
  2. 第一个参数(参数0)是thisValue
  3. 通过将this的值设为thisValueargList作为其参数列表调用函数

举例:

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

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

如你所见,我们通过将this设置为“Yehuda”和单个参数“world”来调用hello方法。这正是JavaScript中函数调用的核心原语。你可以认为所有其他方式的函数调用都可”去糖“得到这个原语。(“去糖”是指采用一种方便的语法并用更基本的核心原语来描述它)。

[1]在ES5规范中,call方法是用另一个更底层的原语来描述的,但它是在那个原语之上的简单封装,所以我在这里简化了一下。有关更多信息,请参阅本文末尾。

简单的函数调用

显而易见,一直用call调用函数将会非常烦人。JavaScript允许我们直接使用括号语法hello("world")来调用函数。当我们这样做时,调用“去糖”如下:

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");

仅在使用严格模式[2]的ECMAScript 5中,此行为将改变:

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() {})()(function() {}).call(window [ES5-strict: undefined)是一模一样的。

[2]事实上,我撒了一点小谎。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"

注意,函数对其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"),但我们改变方向并使用我们的原语call方法将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"

为了理解这一点,您只需要两个额外的知识。首先,arguments是一个类Array对象,它表示传递给函数的所有参数。其次,apply方法的工作原理和call原语除了它采用类Array对象而不是一次列出一个参数之外完全一样。
我们的bind方法简单地返回一个新函数。当它被调用时,我们的新函数只是调用传入的原始函数,并将原始值设置为其this值,当然它也传递参数。
因为这是一个有点常见的习惯用法,ES5在所有Function对象上引入了一个新方法bind,实现了此行为:

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

当您需要将原始函数作为回调传递时,此方法将非常有用:

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方法将这些回调的this值设置为更有用的值。举个例子,在所有事件处理程序中(如不进行特殊干预),jQuery不接收window作为其this值,而是通过把设置事件处理程序的元素作为它第一个参数在回调函数上调用call
这非常有用,因为匿名回调函数中的默认this的值并不是特别有用,除了它给初学者对javascript的一种印象,this通常是一个奇怪的,经常变动至于难以解释的概念。
如果你理解了将“含糖”函数调用转换为“已去糖”的func.call(thisValue, ...args)的基本规则,那么你应该能够在并不是那么危险的JavaScriptthis水域中航行。

PS:我撒谎的部分

在个别地方,我从规范的确切措辞中略微简化了事实。可能最严重的欺骗是我称呼func.call为原语的说法。实际上,规范有一个func.call[obj.]func()都使用的原语(内部称为[[Call]])。
然而,还是看一下func.call的定义吧:

  1. 如果IsCallable(func)值为false,则抛出TypeError异常
  2. argList为一个空的List
  3. 如果使用多个参数调用此方法,则从arg1开始,从左往右将每个参数追加为argList的最后一个元素
  4. 提供thisArg作为this的值,并将argList作为参数列表,返回调用func的内部方法[[Call]]的结果

如你所见,此定义本质上是一种很简单的JavaScript语义绑定到原语[[Call]]操作。
如果你看一下调用函数的定义,前七个步骤设置thisValueargList,最后一步是:“提供thisArg作为this的值,并将列表argList作为参数值,返回调用func的内部方法[[Call]]的结果。”
一旦确定了argListthisValue,它基本上是相同的措辞。
我在称call是一个原语时作了一些欺骗,但其含义基本上与我在文章开头提出的规范和引用的章节是一样的。
还有一些我没有在这里介绍的其他案例(最值得注意的是with)。

原文地址