this关键字是JavaScript中最复杂的机制之一。它是一个很特别的关键字,被自动定义在所有函数的作用域中。但是即使是非常有经验的JavaScript开发者也很难说清它到底指向什么。
任何足够先进的技术都和魔法无异 --Arthur C. Clarke
实际上JavaScript中this的机制并没有那么先进,但是开发者往往会把理解过程复杂化,毫无疑问,在缺乏清晰认识的情况下,this对你来说完全就是一种魔法。
“this”是沟通过程中极其常见的一个代词。所以,在交流过程中很难区分我们到底把“this”当做代词还是当做关键字。清晰起见,我总会使用this表示关键字,使用“this”或者this或者this来表示代词。
为什么要用this?
如果对于有经验的JavaScript开发者来说this都是一种非常复杂的机制,那它到底有用在哪里呢?真的值得我们付出这么大的代价学习吗?的确,在介绍“怎么做”之前我们需要先明白“为什么”。
下面我们来解释一下为什么要使用this:
function identify() {
return this.name.toUpperCase();
}
function speak() {
var greeting = "Hello, I'm " + identify.call( this );
console.log( greeting );
}
var me = {
name: "Kyle"
};
var you = {
name: "Reader"
};
identify.call( me ); // KYLE
identify.call( you ); // READER
speak.call( me ); // Hello, I'm KYLE
speak.call( you ); // Hello, I'm READER
看不懂这段代码?不用担心!我们很快就会进行讲解。现在请暂时抛开这些问题,专注于“为什么”。
这段代码可以在不同的上下文对象(me和you)中重复使用函数identify()和speak(),不用针对每个对象编写不同版本的函数。
如果不使用this,那就需要给identify()和speak()显式传入一个上下文对象。
function identify(context) {
return context.name.toUpperCase();
}
function speak(context) {
var greeting = "Hello, I'm " + identify( context );
console.log( greeting );
}
identify( me ); // KYLE
identify( you ); // READER
然而,this提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将API设计得更加简洁并且易于复用。
随着你的使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用this则不会这样。当我们介绍对象和原型时你就会明白函数可以自动引用合适的上下文对象有多重要。
我们之后会解释this到底是如何工作的,但是首先需要消除一些关于this的错误认识。
太拘泥于“this”的字面意思就会产生一些误解。有两种常见的对于this的解释,但是它们都是错误的。
人们很容易把this理解成指向函数自身,这个推断从英语的语法角度来说是说得通的。
那么为什么需要从函数内部引用函数自身呢?常见的原因是递归(从函数内部调用这个函数)或者可以写一个在第一次被调用后自己解除绑定的事件处理器。
JavaScript的新手开发者通常会认为,既然函数看做一个对象(JavaScript中的所有函数都是对象!),那就可以在调用函数时存储状态(属性的值)。这是可行的,有些时候也确实有用,但是在本书即将介绍的许多模式中你会发现,除了函数对象还有许多更合适存储状态的地方。
不过现在我们先来分析一下这个模式,让大家看到this并不像我们所想的那样指向函数本身。
我们想要记录一下函数foo被调用的次数,思考一下下面的代码:
function foo(num) {
console.log( "foo: " + num );
// 记录`foo`被调用的次数
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// `foo`被调用了多少次?
console.log( foo.count ); // 0 -- WTF?
console.log
语句产生了四条输出,证明foo(..)确实被调用了四次,但是foo.count仍然是0。显然从字面意思来理解this是错误的。
执行foo.count
= 0的时候的确向函数对象foo添加了一个属性count。但是函数内部代码this.count
中的this并不是指向那个函数对象,所以虽然属性名相同,根对象却并不相同,困惑随之产生。
负责的开发者一定会问“如果我增加的count属性和预期的一不一样,那我增加的是哪个count?”实际上,如果他深入探索的话就会发现这段代码在无意中创建了一个全局变量count(原理参见第2章),它的值为NaN。当然,如果他发现了这个奇怪的结果,那一定会接着问“为什么它是全局的,为什么它的值是NaN而不是其他更合适的值?”(参见第2章)
遇到这样的问题时,许多开发者并不会深入思考为什么this的行为和预期的不一致,也不会试图回答那些很难解决但却非常重要的问题。他们只会回避这个问题并使用其他方法来达到目的,比如创建另一个带有count属性的对象。
function foo(num) {
console.log( "foo: " + num );
// 记录`foo`被调用的次数
data.count++;
}
var data = {
count: 0
};
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// `foo`被调用了多少次?
console.log( data.count ); // 4
从某种角度来说这个方法确实“解决”了问题,不幸的是它忽略了真正的问题——无法理解this的含义和工作原理——而是返回舒适区,使用了一种更熟悉的技术:词法作用域。
词法作用域是一种非常优秀并且有用的技术;从各种意义上说(可以参考本书系列图书中的《作用域和闭包》)我都没有贬低它的意思。但是如果你仅仅是因为无法猜对this的用法就放弃学习this并且使用词法作用域的话,就不能算是一种很好的解决办法了。
如果要从函数对象内部引用它自身,那只使用this是不够的。一般来说你需要通过一个指向函数对象的词法标识符(变量)来引用它。
思考一下下面这两个函数:
function foo() {
foo.count = 4; // `foo`指向它自身
}
setTimeout( function(){
// 匿名(没有名字的)函数无法指向自身
}, 10 );
第一个函数被称为命名函数,在它内部可以使用foo来引用自身。
但是在第二个例子中,传入setTimeout(..)的回调函数没有名称标识符(这种函数被称为匿名函数),因此无法从函数内部引用自身。
还有一种传统但是现在已经被弃用和批判的用法是使用
arguments.callee
来引用当前正在运行的函数对象。这是唯一一种可以从匿名函数对象内部引用自身的方法。然而,更好的方式是避免使用匿名函数,至少在需要自引用的时候使用命名函数(表达式)。arguments.callee
已经被弃用,不应该再使用它。
所以,对于我们的例子来说另一种解决方法是使用foo标识符来替代this,从而可以引用函数对象:
function foo(num) {
console.log( "foo: " + num );
// 记录`foo`被调用的次数
foo.count++;
}
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// `foo`被调用了多少次?
console.log( foo.count ); // 4
然而,这种方法同样回避了this的问题,并且完全依赖于变量foo的词法作用域。
另一种方法是强制this指向foo函数对象:
function foo(num) {
console.log( "foo: " + num );
// 记录`foo`被调用的次数
// 注意:在当前的调用方式下(参见下方代码),`this`确实指向`foo`
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
// 使用`call(..)`可以确保`this`指向函数对象foo本身
foo.call( foo, i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// `foo`被调用了多少次?
console.log( foo.count ); // 4
这次我们接受了this,没有回避它。如果你仍然感到困惑的话,不用担心,之后我们会详细解释具体的原理。
第二种常见的误解是,this指向函数的作用域。这个问题有点复杂,因为在某种情况下它是正确的,但是在其他情况下它是错误的。
需要明确的是,this并不总是指向函数的词法作用域。在JavaScript内部,作用域确实和对象类似,可见的标识符都是它的属性。但是作用域“对象”是无法通过JavaScript代码访问的,它存在于JavaScript引擎内部。
思考一下下面的代码,它试图(但是没有成功)跨越边界,使用this来隐式引用函数的词法作用域:
function foo() {
var a = 2;
this.bar();
}
function bar() {
console.log( this.a );
}
foo(); // ReferenceError: a is not defined
这段代码中的错误不止一个。虽然这段代码看起来好像是我们故意写出来的例子,但是实际上这段代码出自一个公共社区中互助论坛的精华代码。这段代码非常完美(同时也令人伤感)地展示了错误理解this可能导致的后果。
首先,这段代码假设可以通过this.bar()
来引用bar()函数。这是绝对不可能成功的,我们之后会解释原因。调用bar()最自然的方法是省略前面的this,直接进行词法引用。
此外,编写这段代码的开发者还试图使用this联通foo()和bar()的词法作用域,从而让bar()可以访问foo()作用域里的变量a。这是不可能实现的,你不能使用this来引用一个词法作用域内部的东西。
排除了一些错误理解之后,我们来看看this到底是一种什么样的机制。
之前我们说过this是在运行时进行绑定的,并不是在编写时候绑定,它的上下文取决于函数调用时的各种条件。this的绑定和它在哪个函数中声明没有任何关系,只取决于函数的调用方式。
当一个函数被调用的时候,会创建一个活动记录(有时候也被称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等等信息。this就是它的一个属性,会在函数执行的过程中用到。
在下一章我们会学习如何寻找函数的调用位置,从而判断函数在执行过程中会如何绑定this。
对于那些没有投入时间学习this机制的JavaScript开发者来说,this的绑定一直是一件非常令人困惑的事。this是非常重要的,但是猜测、尝试并出错和盲目地从Stack Overflow上复制和粘贴答案并不能让你真正理解this的机制。
学习this的第一步是明白this既不指向函数自身也不指向函数的词法作用域,虽然你可能被这样的解释误导过,但是它们都是错误的。
this实际上是在函数被调用的时候绑定的,它指向什么完全取决于函数在哪里被调用。