JavaScript 方法调用和 this 解析
/ / 点击 / 阅读耗时 11 分钟一直以来,我对 JavaScript 的方法调用存有困惑。尤其是很多人一直抱怨,方法中的 this
语义含糊不清。
在我看来,只要理解了方法调用原语就可以解开这些疑惑,其他所有的方法调用都可以认为是原语上的语法糖。事实上,ECMAScript 规范就是这么做的,这篇文章可以认为是简化版的规范说明,基本思路是一样的。
原语
方法调用的原语是 call
这个方法:
- 接受一个参数列表
argList
- 第一个参数是
thisValue
- 触发函数调用,设置
thisValue
为this
, 剩余的参数作参数列表
举例:
1 | function hello(thing) { |
你会发现,this
的值被设置为 Yehuda
, 只有一个参数 thing
被设置为 “world” 。这就是 JavaScript 的方法调用原语。可以认为其他的方法调用都是这种形式的语法糖。
注:在 ES5规范 中,call
被当做更底层的原语,但是仅仅是一层比较浅的封装,因此这里做了简化。
简单的方法调用
如果一直使用 call
来做函数调用,会显得很麻烦,因此 JavaScript 允许我们使用 hello("world")
这种语法去做函数调用,JavaScript 会帮我们去糖:
1 | function hello(thing) { |
在 ECMAScript 5 中,如果使用 strict mode
,行为会有所改变:
1 | // this: |
更简短的描述可以认为 fn(...args)
和 fn.call(window [ES5-strict: undefined], ...args)
的调用是一样的。
内部函数的声明也是这样,诸如 (function() {})()
的函数声明和 (function() {}).call(window [ES5-strict: undefined)
也相同。
注:事实上,这里有一点不是很准确,ECMAScript 5 规范中声明了 undefined
一定会被传递,但是当方法在非严格模式中被调用的时候,应该把 thisValue
的值设置为全局变量,这可以让严格模式下的调用者避免破坏已存在的非严格模式的库。
成员方法
另一种常见的函数调用形式就是作为成员函数被调用,person.hello()
。这种情景下,函数调用会变成如下形式:
1 | var person = { |
请注意,hello
函数作为谁的成员并不重要,我们生命 hello
为单独的函数,下面看一下如何动态绑定对象:
1 | function hello(thing) { |
注意,hello
并不会一直持有 this
的绑定,他总是根据调用者的调用形式,在调用的时候动态设定的。
使用 Function.prototype.bind
有时候绑定 this
为固定值是非常常见和方便的,因此有些时候人们会利用闭包来实现 this
值的绑定。
1 | var person = { |
尽管 boundHello
仍然被去糖,变成 boundHello.call(window, "world")
形式的调用,我们曲线救国,重新绑定了 this
的值为我们想要绑定的对象。
我们可以微调一下,让这种调用更通用:
1 | var bind = function(func, thisValue) { |
为了理解这种调用,需要注意两点:
arguments
是一个数组,代表了函数调用的所有参数apply
和call
基本一致,除了他接受的参数是数组形式,而不是需要一个个的列举出来
bind
函数返回了一个新的函数。当函数被调用的时候,bind
仅仅是触发了参数中传递进来的函数,this
的值也是通过第二个参数重新绑定。
既然这种形式得到大家的共识,因此 ES5 在 Function
对象上添加了 bind
函数:
1 | var boundHello = person.hello.bind(person); |
当把一个纯函数作为 callback 传递的时候是非常有用的:
1 | var person = { |
这种形式并不优雅,因此 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
的定义:
- 如果
IsCallable(func)
为 false, 抛出TypeError
异常 argList
设置为空列表- 如果方法被调用的参数多余一个,从
arg1
开始,从左至右的顺序组成argList
- 返回 func 内部的
[[Call]]
的调用结果,把thisArg
作为this
的值,argList
作为参数列表
显而易见,这个定义其实就是 JavaScript 对原语 [[Call]]
的绑定。
如果你浏览一下函数触发的定义,前面几步是设置 thisValue
和 argList
, 最后一步是返回 func 内部的 [[Call]]
的调用结果,把 thisArg
作为 this
的值,argList
作为参数列表。
一旦 thisValue
和 argList
确定下来,含义都是一样的。
我把 call
称作原语其实是偷梁换柱,正如前面所讲,这和规范中的描述其实都是一致的。
PS:有一些情况我并没有覆盖到,比如使用 with
关键字的情况。