理解JavaScript函数调用和“this”
前言
我看到很多人都有关于JavaScript函数调用的困惑。特别是,很多人抱怨函数调用中的语义令人困惑。
在我看来,通过理解核心函数调用原语,可以清除很多这种混淆,然后查看在该原语之上调用函数作为语法糖的所有其他方法。
事实上,这正是ECMAScript规范对此的看法。
在某些地方,这篇文章对ECMAScript规范做了一些简化,但基本思路是一样的。
核心原语
首先,让我们看一下核心函数调用原语,一个函数的调用方法[1]。调用方法相对简单。
- 从第一个参数到最后一个参数创建一个参数列表(argList)
- 第一个参数是thisValue
- 通过调用指向thisValue的this和表示参数列表的argList调用该函数
举例:
function hello(thing) { console.log(this + " says hello " + thing); } hello.call("Cloudy", "world") //=> Cloudy says hello world
正如你所看到的,我们在调用hello时通过call使this指向"Cloudy",同时为它传递一个"world"参数。这是JavaScript函数调用的核心原语。你可以将所有其他函数调用视为对该原语的“脱糖(desugars)”。 (所谓“脱糖(desugars)”就是采用更为简的语法并用更基本的核心原语来描述它)。
[1]在ES5规范中,调用方法是用另一个更低级的原语来描述的,但它在该原语之上是一个非常薄的包装器,所以我在这里简化了一下。有关更多信息,请参阅本文末尾。
简单的函数调用
显然,一直调用函数会非常烦人。JavaScript允许我们使用parens语法(hello(“world”)直接调用函数。当我们这样做时,调用脱糖:
function hello(thing) { console.log("Hello " + thing); } // this: hello("world") // desugars to: hello.call(window, "world");
但在ECMAScript 5 严格模式[2]时中会有不一样的结果:
// 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).
[2]实际上,我撒谎了一下。 ECMAScript 5规范说,undefined(几乎)总是被传递,但被调用的函数应该在不处于严格模式时将其thisValue更改为全局对象。这允许严格模式调用者避免破坏现有的非严格模式库。
成员函数
调用函数的下一个非常常见的场景就是将函数作为对象的成员(person.hello())。
在这种情况下,调用脱糖:
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定义为独立函数。让我们看看如果我们动态地附加到对象会发生什么:
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的函数:
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"),现在我们重新使用我们的原始调用方法将此值更改回我们想要的值。
我们可以通过一些调整使这个技巧达到通用目的:
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方法只返回一个新函数。调用它时,我们的新函数只调用传入的原始函数,将原始值设置为此值。它也通过参数传递。
因为这是一个有点常见的习惯用法,ES5在所有实现此行为的Function对象上引入了一个新方法bind:
var boundHello = person.hello.bind(person); boundHello("world") // "Brendan Eich says hello world"
当您需要将原始函数作为回调传递时,这非常有用:
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下一版本的委员会)继续研究更优雅,仍然向后兼容的解决方案。
PS: 一些cheat
在一些地方,我从规范的确切措辞中略微简化了现实。可能最重要的cheat是我称func.call为“原始”的方式。实际上,规范有一个原语(内部称为[[Call]])func.call和[obj.func()都使用。
但是,看一下func.call的定义:
- 如果IsCallable(func)为false,则抛出TypeError异常。
- 让argList为空List。
- 如果使用多个参数调用此方法,则从arg1开始以从左到右的顺序将每个参数附加为argList的最后一个元素
- 返回调用func的[[Call]]内部方法的结果,提供thisArg作为此值,并将argList作为参数列表。
如您所见,此定义本质上是一种非常简单的JavaScript语言绑定到原始[[Call]]操作。
如果你看一下调用函数的定义,前七个步骤设置thisValue和argList,最后一步是:“返回在func上调用[[Call]]内部方法的结果,将thisValue作为此值并提供列表argList作为参数值。”
一旦确定了argList和thisValue,它基本上是相同的措辞。
我在调用call一个原语时作了一些cheat,但其含义基本上与我在本文开头提取规范并引用章节和诗句时的含义相同。
还有一些我没有在这里介绍的其他案例。
原文请参考:
《Understanding JavaScript Function Invocation and "this"》 -- 作者Yehuda Katz
推荐阅读:
【专题:JavaScript进阶之路】
JavaScript之“use strict”
JavaScript之new运算符
JavaScript之call()理解
JavaScript之对象属性
我是Cloudy,年轻的前端攻城狮一枚,爱专研,爱技术,爱分享。
个人笔记,整理不易,感谢阅读、点赞和收藏。
文章有任何问题欢迎大家指出,也欢迎大家一起交流前端各种问题!