[JavaScript] 令人迷惑的《this》
介绍
本文将讨论一个与执行上下文密切相关的细节 => this
关键字
实践证明,理解this
难度较大,并且经常会导致在不同的执行环境中错误滴确定this
值的问题
举着栗子🌰:
var a = 'global';
function foo () {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
function doFoo (fn) {
fn();
}
doFoo(obj.foo); // global
许多程序员习惯于认为编程语言中的this
关键字与面向对象编程密切相关,指向构造器新创建的对象,在ECMAScript中,这个概念也被实现了,但是它不仅限于新创建的对象
我们来详细看看this
在ECMAScript中究竟是什么
定义
this
是执行上下文的属性,是执行代码的上下文中的特殊对象
activeExecutionContext = {
VO: {...},
this: thisValue
};
其中VO是变量对象。
this
与上下文的可执行代码的类型直接相关,该值是在进入上下文时确定的,在代码运行时是不可变的
让我们来分类讨论这些情况
全局代码中的this
这里一切都很简单,在全局代码中,this
总是全局对象本身,因此可以间接引用它:
this.a = 10;
console.log(a); // 10
b = 20;
console.log(this.b); // 20
var c = 30;
console.log(this.c); // 30
函数代码中的this
在函数代码中使用this
时,情况会更复杂
在这种类型的代码中,this
值的第一个主要特征是它在这里不是静态地绑定到一个函数上
正如上面提到的那样,这个值是在进入上下文时确定的,并且在函数代码的情况下,每次的值可以是完全不同的
然而在代码运行时,这个值是不可变的,也就是说不可能给它赋一个新的值,因为这不是一个变量:
var foo = {x: 10};
var bar = {
x: 20,
test: function () {
console.log(this === bar); // true
console.log(this.x); // 20
this = foo; // error, can't change this value
}
};
bar.test(); // true, 20
foo.test = bar.test;
foo.test(); // false, 10
那么什么影响函数代码中this
值的变化呢
首先,在通常的函数调用中,this
由调用者提供,该调用者激活执行上下文的代码,即调用函数的父上下文,而this
值由调用表达式的形式决定(就是调用该函数的语法形式)
记住这一点,以便能够在任何情况下确定
this
值,调用表达式的形式,即调用函数的方式,会影响被调用上下文的this
值,而不会影响其他值
(在一些文章甚至JavaScript书籍中可以看到,它声称this
值取决于如何定义函数:如果它是全局函数,那么this
值被设置为全局对象,如果函数是一个对象的方法,this
值总是被设置为这个对象,这是错误的描述)
接下来我们会看到即使是普通的全局函数也可以用不同形式的调用表达式来激活,这会产生不同的this
值:
function foo() {
console.log(this);
}
foo(); // global
console.log(foo === foo.prototype.constructor); // true
foo.prototype.constructor(); // foo.prototype
类似地可以调用被定义为某个对象的方法的函数,但是this
值不会被设置为该对象
var foo = {
bar: function () {
console.log(this);
console.log(this === foo);
}
};
foo.bar(); // foo, true
var exampleFunc = foo.bar;
console.log(exampleFunc === foo.bar); // true
exampleFunc(); // global, false
那么调用表达式的形式如何影响this
值呢,为了充分理解this
,我们来详细讨论一种内部类型=>引用类型(Reference
type)
引用类型
使用伪代码,引用类型的值可以表示为对象:base
是属性所属的对象,propertyName
是此对象中属性的名字,strict
表示是不是在严格模式下(strict mode):
var valueOfReferenceType = {
base: <base object>,
propertyName: <property name>,
strict: <boolean>
};
引用类型的值只出现在两种情况下:
- 当处理一个标识符的时候
- 进行属性访问的时候
标识符的处理会在作用域链中介绍,这里我们只要注意,此算法总返回一个引用类型的值(这对this
的值至关重要)
标识符就是变量名称,函数名称,函数参数的名称和全局对象的属性的名称
例如,对于以下标识符:
var foo = 10;
function bar() {}
中间过程对应的引用类型值如下:
var fooReference = {
base: global,
propertyName: 'foo'
};
var barReference = {
base: global,
propertyName: 'bar'
};
为了从Reference
类型的值中获取对象的实际值,可以使用GetValue
方法,该方法在伪代码中可以描述成如下形式:
function GetValue(value) {
if (Type(value) != Reference) {
return value;
}
var base = GetBase(value);
if (base === null) {
throw new ReferenceError;
}
return base.[[Get]](GetPropertyName(value));
}
其中内部[[Get]]
方法返回对象属性的实际值,还包括从原型链中继承的属性
GetValue(fooReference); // 10
GetValue(barReference); // function object "bar"
那么,引用类型的值如何与函数上下文的this
值相关联呢
这篇文章中最重要的时刻到了 (关好门窗屏住呼吸往下看)
在函数上下文中确定this
值的一般规则如下:
函数上下文中的
this
值由调用者提供,并由调用表达式的形式确定
如果在调用括号(...)的左侧存在引用类型的值,则将
this
值设置为该引用类型的base对象
在所有其他情况下(即非引用类型),
this
值始终设置为null,但是由于null值对this
没有任何意义,所以它被隐式转换为全局对象
让我们来看一些例子:
function foo() {
return this;
}
foo(); // global
上面代码中,调用括号的左侧是引用类型的值(因为foo是标识符),this
的值会设置为引用类型值的base对象,这里就是全局对象
var fooReference = {
base: global,
propertyName: 'foo'
};
属性访问也类似:
var foo = {
bar: function () {
return this;
}
};
foo.bar(); // foo
也是引用类型的值,它的base对象是foo对象,激活bar函数的时候,this
的值就设置为foo对象
var fooBarReference = {
base: foo,
propertyName: 'bar'
};
但是,同样的函数以不同的方式激活的话,this
的值就完全不同
var test = foo.bar;
test(); // global
test也是标识符,这样就产生了另外的引用类型的值,其中base对象(全局对象)就是this
的值
var testReference = {
base: global,
propertyName: 'test'
};
现在我们就可以解释,为什么同样的函数,以不同的调用方式激活,this
的值也会不同了,答案就是处理过程中,是不同的引用类型的值
function foo() {
console.log(this);
}
foo(); // global, because
var fooReference = {
base: global,
propertyName: 'foo'
};
console.log(foo === foo.prototype.constructor); // true
foo.prototype.constructor(); // foo.prototype, because
var fooPrototypeConstructorReference = {
base: foo.prototype,
propertyName: 'constructor'
};
下面是另外一种典型的利用调用表达式来动态决定this
值的例子
function foo() {
console.log(this.bar);
}
var x = {bar: 10};
var y = {bar: 20};
x.test = foo;
y.test = foo;
x.test(); // 10
y.test(); // 20
非引用类型的函数调用
正如我们已经指出的那样,如果在调用括号的左侧存在不是引用类型的值,则this
值自动设置为null,并且因此被设置为全局对象
我们来考虑这样的表达式的例子:
(function () {
alert(this); // null => global
})();
在这种情况下,我们有函数对象而不是引用类型的对象(它不是标识符也不是属性访问),因此this
值最终被设置为全局对象
var foo = {
bar: function () {
console.log(this);
}
};
foo.bar(); // Reference, OK => foo
(foo.bar)(); // Reference, OK => foo
(foo.bar = foo.bar)(); // global?
(false || foo.bar)(); // global?
(foo.bar, foo.bar)(); // global?
那么,为什么有一个属性访问,其中间结果应该是引用类型的值,在某些调用中,我们得到的值不是base对象(即foo),而是全局对象呢
主要在于最后三次调用在应用某些操作之后,在调用括号的左侧不再是引用类型的值
在第一种情况下,明确的引用类型,因此this
值是base对象,即foo
在第二种情况下,有一个组操作符(grouping operator),该操作符不会触发调用获取引用类型实际值的方法,GetValue方法,处理组操作符中间过程中获得的仍然是一个引用类型的值,这也就解释了为什么this
值设置成了base对象,foo
在第三种情况下,赋值运算符与组操作符不同的是,它会触发调用GetValue方法,最后返回的结果是一个函数对象(但不是引用类型的值),这意味着this
值设置为null,并因此变成全局对象
与第四种和第五种情况类似,逗号运算符和逻辑OR表达式调用GetValue方法,因此我们失去了类型Reference的值并获取函数类型的值,this
值就变成了全局对象
引用类型但this
值是null
有一种情况是,调用表达式在调用括号的左侧确定是引用类型的值,但是this
值设置为空,因此变为全局对象,它与引用类型值的base对象是活动对象有关
我们可以看到这个一个例子,当内部子函数在父函数中被调用的时候会发生这种情况:
function foo() {
function bar() {
console.log(this); // global
}
bar(); // the same as AO.bar()
}
活动对象总是会返回this
值为null(AO.bar()相当于null.bar()),如此前描述的,this的值会由null变为全局对象
当函数调用包含在with语句代码块中,且with对象包含一个函数属性时,会出现例外的情况,with语句会将该对象添加到作用域链的最前面,在活动对象之前,相应地,引用类型的值(标识符或者属性访问)的base对象不再是活动对象,而是with语句的对象
值得一提的是,它不仅仅只针对内部函数,全局函数也是如此, 原因在于with对象掩盖了作用域链中更高层的对象(全局对象或者活动对象):
var x = 10;
with ({
foo: function () {
console.log(this.x);
},
x: 20
}) {
foo(); // 20
}
// because
var fooReference = {
base: __withObject,
propertyName: 'foo'
};
类似的情况应该是调用作为catch子句的实际参数的函数,在这种情况下,catch对象也被添加到作用域链的前面,即在活动或全局对象之前,但这种行为被认为是ECMA-262-3的一个缺陷,并在新版标准ECMA-262-5中得到修复,即this
值应该设置为全局对象:
try {
throw function () {
console.log(this);
};
} catch (e) {
e(); // global
}
// null => global
var eReference = {
base: global,
propertyName: 'e'
};
与命名函数表达式的递归调用相同的情况,在函数的第一次调用中,base对象是父级活动对象(或全局对象),在递归调用时,base对象应当是一个存储了可选的函数表达式名字的特殊对象,但是在这种情况下,this值永远都是全局对象
(function foo(bar) {
console.log(this);
!bar && foo(1); // "should" be special object, but always (correct) global
})(); // global
函数作为构造器被调用时this
值
在函数上下文中还有一个与this
值相关的案例,它是作为构造函数的函数调用:
function A() {
console.log(this); // newly created object, below - "a" object
this.x = 10;
}
var a = new A();
console.log(a.x); // 10
在这种情况下,new运算符调用A函数的内部[[Construct]]
方法,而后者在创建对象后调用内部的[[Call]]
方法,即所有A函数中this
的值会设置为新创建的对象
为函数调用手动设置this
值
在Function.prototype中定义了两个方法(因此它们可以被所有函数访问),允许手动指定函数调用的this
值,这两个方法是:.apply和.call,它们都接受第一个参数作为调用上下文中this值
var b = 10;
function a(c) {
console.log(this.b);
console.log(c);
}
a(20); // this === global, this.b == 10, c == 20
a.call({b: 20}, 30); // this === {b: 20}, this.b == 20, c == 30
a.apply({b: 30}, [40]) // this === {b: 30}, this.b == 30, c == 40
它们之间的区别微不足道:对.apply来说,第二个参数接受数组类型(或者是类数组的对象,比如arguments), 而.call方法接受任意多的参数,这两个方法只有第一个参数是必要的,this
值