原文链接:https://yehudakatz.com/2011/08/11/understanding-javascript-function-invocation-and-this/
JavaScript function(函数)调用用很多令人困惑的地方,特别是function中关键字 this 的指向问题。
我认为,如果理解function调用的核心,然后把其他的调用方式都看作基于核心的语法糖,很多问题就变清晰了。实际上,这也正是ECMAScript规范的想法,本文可以说是该规范的简略说明,但基本思想是一致的。
function调用核心
首先,函数调用的核心为——Function的 call 方法[1],call方法执行过程:
- 从第1位开始到最后一位的参数,记为参数列表 (argList)
- 首个参数(第0位参数)记为 thisValue
- 调用Function,将Function内部的
this
设置为 thisValue,将Function的 参数列表 设置为 argList.
例如:
1 function hello(thing) { 2 console.log(this + " says hello " + thing); 3 } 4 5 hello.call("Yehuda", "world") //=> Yehuda says hello world
这里,我们通过 hello.call 调用 hello 函数。
hello 函数内部 this 被设置为 hello.call 的第0位参数 "Yehuda"。
hello 函数的参数列表被设置为 hello.call 从第1位参数开始的、只有一个参数的参数列表 "world"。
这就是JavaScript函数调用的核心,你可以把所有其他的函数调用方式都看作对它进行包装之后的语法糖,这些语法糖会让我们使用起来更方便。
[1] 在ECMAScript规范中,call方法的描述更底层,我们这里进行了简化。有关更多信息,可以参考本文末尾。
简单function调用
显然,Function.call 很繁琐,JavaScript允许我们以更简单直接的语法去调用函数: hello("world") .
当我们这样调用时,实际底层调用的仍然是 Function.call .
1 function hello(thing) { 2 console.log("Hello " + thing); 3 } 4 5 // 我们这样调用: 6 hello("world") 7 8 // 实际底层调用: 9 hello.call(window, "world");
注意,这里的 thisValue 是 window 。
有一个例外,那就是当你使用ECMAScript 5 strict mode时,thisValue 会变成 undefined .即:
1 // ECMAScript 5 strict mode下我们这样调用: 2 hello("world") 3 4 // 实际底层调用: 5 hello.call(undefined, "world");
将上面的两点总结一下就是:
当你这样调用函数时:fn(...args)
实际的底层调用会是:fn.call(window [ES5-strict: undefined], ...args)
立即执行函数同样适用:(function(){})() 等同于 (function(){}).call(window [ES5-strict: undefined])
对象成员function调用
另一个常用是对象成员函数调用:person.hello()
当我们调用对象的成员函数时,实际也是调用的函数的 call 方法。
1 var person = { 2 name: "Brendan Eich", 3 hello: function(thing) { 4 console.log(this + " says hello " + thing); 5 } 6 } 7 8 // 我们这样调用: 9 person.hello("world") 10 11 // 实际底层调用: 12 person.hello.call(person, "world");
注意,这里的 hello 方法如何与对象进行关联并不重要。
我们在之前定义过独立的 hello 函数,我们现在就来看看将独立函数动态关联到对象,会怎样。
1 function hello(thing) { 2 console.log(this + " says hello " + thing); 3 } 4 5 person = { name: "Brendan Eich" } 6 person.hello = hello; 7 8 person.hello("world") // 底层调用仍然是 person.hello.call(person, "world") 9 10 hello("world") // "[object DOMWindow]world"
我们发现,独立函数的 this 指向不是固定不变的,它取决于函数被调用的方式。
使用 Function.prototype.bind
我们常常希望一个函数的 this 指向是不变的,这会在使用上带来很多方便。
过去,大家常常用一个简单的包装函数来达到这个目的,这个包装函数会将 this 指定为一个不变的值:
1 var person = { 2 name: "Brendan Eich", 3 hello: function(thing) { 4 console.log(this.name + " says hello " + thing); 5 } 6 } 7 8 var boundHello = function(thing) { return person.hello.call(person, thing); } 9 10 boundHello("world");
在这里,尽管我们的 boundHello 调用仍然会被转换为 boundHello.call(window, "world") ,但是我们通过显式调用 person.hello.call 方法,并指定 person 为 thisValue 的方式,将 this 变回我们想要的值。
我们可以做一些调整,将上面的技巧整理成一个通用的function:
1 var bind = function(func, thisValue) { 2 return function() { 3 return func.apply(thisValue, arguments); 4 } 5 } 6 7 var boundHello = bind(person.hello, person); 8 boundHello("world") // "Brendan Eich says hello world"
想要理解上面的代码,需要了解2个知识点。
- arguments 是一个类数组对象,它代表传递给当前函数的所有参数;
- apply 方法与 call 方法类似,不同点是不需要将参数列表值一个一个传给它,而是直接传递一个类数组对象。
我们上面的 bind 方法,简单地返回了一个新的方法。当 bind 被调用时,新方法会简单地调用传入bind的原始方法 func,并将传入bind的 thisValue 作为原始方法 func 的 this,当然也传递了 arguments 。
由于这种做法已经太普遍,类似一种不成文的规定,所以 ES5 引入了 bind 方法,所有的 Function 都实现了这个行为。那么上面的通用function可改写为:
1 var boundHello = person.hello.bind(person); 2 boundHello("world") // "Brendan Eich says hello world"
这在指定回调函数时十分有用:
1 var person = { 2 name: "Alex Russell", 3 hello: function() { console.log(this.name + " says hello world"); } 4 } 5 6 $("#some-div").click(person.hello.bind(person)); 7 8 // 当div被点击时, 会输出 "Alex Russell says hello world"
当然,这看起来还是有点笨拙,TC39(制定ECMAScript规范的委员会)在寻求更简单、向后兼容的解决方案。
关于jQuery
由于jQuery大量使用匿名回调函数,所以在调用 call 方法时,他会自动将 this 值指定为一个更有意义的值。例如,在没有特殊处理的情况下,所有的 event handler 中的 this 值不是 window,jQuery会在调用回调函数的 call 方法时,将设置event handler的元素作为首参。
这非常有用,因为虽然匿名回调函数中的 this 不是十分有用,但是他会给刚接触 JavaScript 的人带来这样的印象:this 是一个很怪异、很难揣摩的东西。
如果你理解了我们的基本原则,所有调用函数的方法都是语法糖,都会被解析成核心 call 方法 func.call(thisValue, ...args) ,那么你会感觉 JavaScript 的 this 值使用起来并不那么凶险。
附:澄清
本文的很多地方都使用了比规范更简略的表达。最重要的一点就是,我将 func.call 方法描述为调用函数的核心方法。实际上,规范中介绍了一个更核心的方法 [[Call]] ,它会被 func.call 及 [obj.]func() 使用。
但请看一眼 func.call 的说明:
- 如果IsCallable(func)值为false,抛出TypeError异常。
- 将argList置为空列表。
- 如果方法被调用时传入了一个以上的参数,那么从第一个参数开始,按照从左到右的顺序,依次加入argList列表中。
- 调用func的内部方法[[Call]],thisArg作为this值,argList作为参数列表,并返回结果。
你可以发现,func.call 只是对核心方法 [[Call]] 的一个简单的包装。
如果你看函数的调用,会发现前面的步骤都是在设定 thisValue 和 argList 的值,而最后一步都为 “调用func的内部方法[[Call]],thisArg作为this值,argList作为参数列表,并返回结果”。一旦 thisValue 和 argList 值确定了,其余的描述都基本相同了。
虽然称 call 为核心方法是不准确的,但本质思想与我在本文最开始引用的规范中的描述都是相同的。