如何理解 JavaScript 函数调用及" this "对象 | 翻译

最近深入阅读React官网文档时,看到一篇关于JavaScript函数调用及“this”的文章https://yehudakatz.com/2011/08/11/understanding-javascript-function-invocation-and-this/,尽管这篇文章的创作时间有些久远,但博文中的案例确实有助于理解 JavaScript 函数调用及ES5语法中 bind() 方法。下面我将文章翻译成大白话,并附加个人的注解(蓝色字体区分)

Understanding JavaScript Function Invocation and "this"

如何理解 JavaScript 函数调用及 “this”

多年来,我发现很多人对 JavaScript 函数的调用感到混淆。尤其是,很多人抱怨函数调用中 this 的语义令人困惑。

我认为,只要通过理解核心函数调用原语,并且认为其他所有的函数调用方法都是基于核心原语上的一种糖衣,那么眼前的迷惑都会烟消云散。ECMAScript文档规范也采用了相同策略。从某些角度来说,这篇文章是对ECMAScript 规范的简化,但基本思想是一致的。

 

核心原语

首先,让我们来了解一下核心函数调用原语,call 方法[1]。这种方法使用相对简单。

1. 逐个列举传给函数的参数列表(argList),第一个参数除外;

2. 函数的第一个参数是 thisValue

3. 调用函数时,将 this 设置为 thisValue,并且 argList 作为 传入函数的参数列表

举个栗子:

function hello(thing) {
  console.log(this + " says hello " + thing);
}

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

在上面的例子中,当调用 hello 方法时将 this 设置为 "Yehuda",并且还传入了一个参数 "world"。这就是 JavaScript 函数调用的核心原语。我们可以这样理解,其他所有函数的调用都是将包裹在核心原语外的糖衣进行去糖衣的过程(“”去糖衣”就是使用更基本的核心原语来转化成简单的语法)

[1] 在ES5规范中,call 方法是基于另一个更加底层的原语,但 call 方法只是在该原语外包裹了一层非常轻薄的糖衣。因此,我使用了精简的表述,更多信息详见后文。

 

简单函数调用

很显然,如果每次都要使用 call 来调用函数,那还真是非常令人窒息的操作呐。别紧张,JavaScript 给我们提供了一个圆括号语法( 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");

【译者注】在严格模式下,未指定环境对象而调用函数,则 this 值不会转型为 window。除非明确把函数添加到某个对象或者调用 apply()或 call(),否则 this 值将是undefined。 

简单来说:诸如 fn (...args) 调用方式等同于 fn.call ( window [ES5-strict: undefined], ...args )。

同样,立即调用的匿名函数 (function(){})() 等价于 (function(){}).call( window [ES5-strict: undefined] )。

[2] 实际上,我说的夸张了一些。ECMAScript 5 规范中 undefined 总是(几乎)可以在函数中被传递的。不过,在非严格模式下调用函数时,thisValue 的值会被转换成全局对象。这样可以防止严格模式下的函数调用行为破坏非严格模式库。

【译者注】JavaScript 中一个最大的安全问题,也是最容易让人迷茫的地方,就是在某些情况下如何抑制 this 的值。在非严格模式下使用函数的 apply()或 call()方法时,null 或 undefined 值会被转换为全局对象。而在严格模式下,函数的 this 值始终是指定的值,无论指定的是什么值。

 

成员函数

另一个非常常见的调用方式是以一个对象成员的方式调用( 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 方法是如何附加到 person对象中的,回想一下之前定义的 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"

敲黑板!上面定义的 hello 函数中的 this 并不是一个固定的值,this 值取决于hello函数被谁调用。

【译者注】this 是函数内部的一个特殊对象,其行为与 Java 和 C#中的 this 大致类似。换句话说,this引用的是函数据以执行的环境对象——或者也可以说是 this 值(当在网页的全局作用域中调用函数时,this 对象引用的就是 window)。上文代码中,person.hello("world") 中的 this对象引用的是 person 。

 

使用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"),但我们将call方法封装进函数中,并且可以重新给 this 赋值。

我们将封装一个bind函数,使其更加通用化:

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 是一个类数组的对象,包含传入函数中所有参数;第二点,call() 方法与 apply() 方法的作用相同,它们的区别仅在于接收参数的方式不同。对于 call()方法而言,第一个参数是 this 值没有变化,变化的是其余参数都直接传递给函数。换句话说,在使用call()方法时,传递给函数的参数必须逐个列举出来。

【译者注】在使用 call()方法的情况下,必须明确地传入每一个参数。结果与使用 apply()没有什么不同。至于是使用 apply()还是 call(),完全取决于你采取哪种给函数传递参数的方式最方便。如果你打算直接传入 arguments 对象,或者包含函数中先接收到的也是一个数组,那么使用 apply()肯定更方便;否则,选择 call()可能更合适。(在不给函数传递参数的情况下,使用哪个方法都无所谓。)

上文代码中,bind()方法的作用仅仅是创建一个新函数 boundHello。它的目的是让新函数boundHello的原始方法与传入的person.hello进行绑定,所有的参数都通过 bind() 函数直接传给了person.hello,这样就可以将person对象设置为原始方法的this值。

随着bind方法逐渐成为日益流行的高级技巧,ES5为所有函数定义了一个原生的bind()方法,进一步简化了操作。换句话说,不用再自己定义bind() 函数了,而是可以直接在函数上调用这个方法:

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

【译者注】支持 bind()方法的浏览器有 IE9+、Firefox 4+、Safari 5.1+、Opera 12+和 Chrome

只要是将某个函数指针以值得形式进行传递,同时该函数必须在特定环境中执行,bind()方法的作用就突显出来了:

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

当然,bind()函数与普通函数相比更加笨拙,它们需要更多的内存。TC39(负责制定ECMAScript下一个版本的委员会)继续致力于更优雅,持续向后兼容的解决方案。

 

关于JQuery

因为jquery大量使用匿名回调函数,所以它在内部使用call() 方法将回调函数中的 this 值设置为更加有用的值。举个栗子,JQuery 将事件处理器作为 call()方法的第一个参数,而不是使用默认的window对象。这种做法灰常有用,因为默认值对于匿名函数来说没什么用武之地。但也给JavaScript初学者留下一个奇怪的,善变的,难以捉摸的印象。如果你真的明白如何将包裹了糖衣炮弹的函数层层剥开,转换成去糖的 func.call(thisValue, ...args) 函数的基本规则,那么恭喜你啦,从此不用怕跳进JavaScript语法中的 this 值大坑中了(鼓掌)。

 

补充

在上文有几处地方,我对ECMA规范做了精简的表述。也许最值得注意的简化就是我将 call 函数称为“原语”。实际上,ECMA文档中有一个原语 [[Call]],这是一个内部方法,func.call 和 [obj.]func() 都使用到了它。

尽管如此,请看函数 func.call 的定义:

1、如果IsCallable(func) 的值为 false ,则抛出一个 TypeError 错误;

2、将 argList 列表清空;

3、如果同时传入了多个参数,那么按照从 arg[1] 开始的从左到右的顺序将每一个参数追加到 argList列表中;

4、将thisArg 赋值给 this,将剩余参数作为函数的arguments参数,调用函数内部方法 [[Call]],并返回结果。

综上,这是一个非常简单的使用原语 [[Call]] 操作的JavaScript语法定义。

如果你看过了上面所说的函数调用的定义,前七步设置了 thisValue 和 argList 的值,最后一步是:将thisArg 赋值给 this,将剩余参数作为函数的arguments参数,调用函数内部方法 [[Call]],并返回结果。一旦 argList 和 thisValue的值确定了,二者基本上是进行完全相同的操作了。

在上文中,我将call 简述为原语,但是其基本含义与我在本文开头中从文档中节选的内容和章节基本相同。

另外还有一些(关于with的用法)的案例在本文中没有涉及到。

 

 

 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值