六、JavaScript之this理解

定义

在前面我们已经知道,当JavaScript代码执行一段可执行代码(executable code)时,会创建对应的执行上下文(execution context)。
对于每个执行上下文,都有三个重要属性:

  • 变量对象(Variable object,VO)
  • 作用域链(Scope chain)
  • this

前面我们已经讨论了变量对象和作用域链,本文主要说说对this的理解。
this与上下文的可执行代码类型直接相关。该值在进入上下文时确定,并且在代码在上下文中运行时是不可变的。

全局代码中This的值

在全局代码中,this值始终是全局对象本身。因此,可以间接引用它:

1
2
3
4
5
6
7
8
9
10
11
// 显式定义全局对象
this.a = 10; // global.a = 10
console.log(a); // 10

// 通过赋值隐式定义
b = 20;
console.log(this.b); // 20

// 全局上下文变量声明
var c = 30;
console.log(this.c); // 30

函数代码中This的值

this的第一个(可能也是主要的)特征是在这种类型代码中的值没有静态绑定到函数。

如上所述,this值是在进入上下文时确定的,如果使用功能代码,则每次的值都可能完全不同。
所以,在代码运行时this值是不可变的,即无法为其分配新值,因为它不是变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var foo = {x: 10};
var bar = {
x: 20,
test: function () {
console.log(this === bar); // true
console.log(this.x); // 20
// this = foo; // error, Invalid left-hand side in assignment
console.log(this.x); // 如果不报错这里将会是10,
}
};
bar.test(); // true, 20
foo.test = bar.test;
//现在this的值将指向“foo”——即使我们正在调用相同的函数
foo.test(); // false, 10

那么是什么影响了this函数代码中值的变化呢?有几个因素。

首先,在通常的函数调用中,this是由激活上下文代码的调用者来提供的,即调用函数的父上下文(parent context )。this取决于调用函数的方式。

理解和记住这重要的一点能帮助我们在任何情况下准确无误的确定this值。正是调用函数的方式影响了调用的上下文中的this值,所以,即使是正常的全局函数也会被调用方式的不同形式激活,这些不同的调用方式导致了不同的this值。

1
2
3
4
5
6
7
8
9
function foo() {
console.log(this);
}

foo(); // global
console.log(foo === foo.prototype.constructor); // true

// 但是同一个function的不同的调用表达式,this是不同的
foo.prototype.constructor(); // foo.prototype

有可能作为一些对象定义的方法来调用函数,但是this将不会设置为这个对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

// 再一次,同一个function的不同的调用表达式,this是不同的

exampleFunc(); // global, false

为了充分理解this值的确定,我们需要知道在 ECMAScript 规范中还有一种只存在于规范中的类型,它们的作用是用来描述语言底层行为逻辑。
本文主要讨论下其中的 Reference 类型。它与 this 的指向有着密切的关联。

引用类型(Reference type)

那么什么是Reference?ECMAScript中6.2.3章有介绍:

The Reference type is used to explain the behaviour of such operators as deletedelete, typeoftypeof, the assignment operators, the supersuper keyword and other language features. For example, the lefthand operand of an assignment is expected to produce a reference.

翻译一下,Reference 类型用于解释诸如delete、typeof、赋值运算符、super关键字和其他语言功能等运算符的行为 。例如,赋值的左侧操作数预计会产生一个引用
再看接下来的这段具体介绍 Reference 的内容:

  • A Reference is a resolved name or property binding.
  • A Reference consists of three components, the base value component, the referenced name component, and the Boolean-valued strict reference flag.
  • The base value component is either undefined, an Object, a Boolean, a String, a Symbol, a Number, a BigInt, or an Environment Record.
  • A base value component of undefined indicates that the Reference could not be resolved to a binding.
  • The referenced name component is a String or Symbol value.
    简单来说,Reference 的构成,由三个组成部分,分别是:
  • base value
  • referenced name
  • strict reference

base value 就是属性所在的对象或者就是 EnvironmentRecord,它的值只可能是 undefined, an Object, a Boolean, a String, a Number, or an environment record 其中的一种。
referenced name 就是属性的名称,它的值可能是 a String or Symbol value。
使用伪代码可以将Reference type的值表示为具有三个属性的对象:base(即属性所属的对象)、base中的propertyName和strict reference(如果use strict有效则为真):

1
2
3
4
5
var valueOfReferenceType = {
base: <base object>,
propertyName: <property name>,
strick: <strict reference>
};

在这里我们需要明白一个很重要的东西,返回引用类型的值只有两种情况:

  1. 标识符的处理
  2. 一个属性访问器

标识符的处理

标示符的处理(标识符的解析)过程,用来确定一个变量(或函数声明)属于哪个变量对象。
这个算法的返回值中,总是一个引用类型的值,它的base组件是相应的变量对象(或若未找到则为null),属性名组件是向上查找的标示符的名称。
标识符是变量名,函数名,函数参数名和全局对象中未识别的属性名。例如,下面标识符的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var foo = 10;
function bar() {}

//在操作的中间结果中,引用类型对应的值如下:
var fooReference = {
base: global,
propertyName: 'foo',
strick: false
};

var barReference = {
base: global,
propertyName: 'bar',
strick: false
};

为了从引用类型中得到一个对象真正的值,伪代码中的GetValue方法可以做如下描述:

1
2
3
4
5
6
7
8
9
10
11
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]]方法返回对象属性真正的值(base.[[Get]] === global),包括对原型链中继承的属性分析。

1
2
GetValue(fooReference); // 10 
GetValue(barReference); // function object "bar"

属性访问器

属性访问器都应该熟悉。它有两种变体:点(.)语法(此时属性名是正确的标示符,且事先知道),或括号语法([])。

1
2
foo.bar();
foo['bar']();

在中间计算的返回值中,引用类型的值如下

1
2
3
4
5
6
7
var fooBarReference = {
base: foo,
propertyName: 'bar',
strick: false
};

GetValue(fooBarReference); // function object "bar"

引用类型的值与This的关系

引用类型的值与函数上下文中的this值如何相关?——从最重要的意义上来说。 这个关联的过程是这篇文章的核心。 一个函数上下文中确定this值的通用规则如下:

在一个函数上下文中,this由调用者提供,由调用函数的方式来决定。如果调用括号()的左边是引用类型的值,this将设为引用类型值的base对象(base object),在其他情况下(与引用类型不同的任何其它属性),这个值为null。不过,实际不存在this的值为null的情况,因为当this的值为null的时候,其值会被隐式转换为全局对象。
注:第5版的ECMAScript中,已经不强迫转换成全局变量了,而是赋值为undefined。

我们看看这个例子中的表现:

1
2
3
4
5
function foo() {
return this;
}

foo(); // global

我们看到在调用括号的左边是一个引用类型值(因为foo是一个标示符)。

1
2
3
4
5
var fooReference = {
base: global,
propertyName: 'foo',
strick: false
};

相应地,this也设置为引用类型的base对象。即全局对象。

同样,使用属性访问器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var foo = {
bar: function () {
return this;
}
};

foo.bar(); // foo

// 我们再次拥有一个引用类型,其base是foo对象,在函数bar激活时用作this。
var fooBarReference = {
base: foo,
propertyName: 'bar'
};

// 但是,用另外一种形式激活相同的函数,我们得到其它的this值。
var test = foo.bar;
test(); // global

// 因为test作为标示符,生成了引用类型的其他值,其base(全局对象)用作this 值。
var testReference = {
base: global,
propertyName: 'test'
};

现在,我们可以很明确的告诉你,为什么用表达式的不同形式激活同一个函数会不同的this值,答案在于引用类型(type Reference)不同的中间值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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'
};

函数调用和非引用类型

正如我们已经指出,当调用括号的左边不是引用类型而是其它类型,这个值自动设置为null,结果为全局对象。
让我们再思考这种表达式:

1
2
3
(function () {
console.log(this); // null => global
})();

在这个例子中,我们有一个函数对象但不是引用类型的对象(它不是标示符,也不是属性访问器),相应地,this值最终设为全局对象。

更多复杂的例子:

1
2
3
4
5
6
7
8
9
10
11
12
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?

为什么我们有一个属性访问器,它的中间值应该为引用类型的值,在某些调用中我们得到的this值不是base对象,而是global对象?

问题在于后面的三个调用,在应用一定的运算操作之后,在调用括号的左边的值不再是引用类型。

第一个例子很明显———明显的引用类型,结果是,this为base对象,即foo。
在第二个例子中,组运算符并不适用,想想上面提到的,从引用类型中获得一个对象真正的值的方法,如GetValue。相应的,在组运算的返回中———我们得到仍是一个引用类型。这就是this值为什么再次设为base对象,即foo。
第三个例子中,与组运算符不同,赋值运算符调用了GetValue方法。返回的结果是函数对象(但不是引用类型),这意味着this设为null,结果是global对象。
第四个和第五个也是一样——逗号运算符和逻辑运算符(OR)调用了GetValue 方法,相应地,我们失去了引用而得到了函数。并再次设为global。

作为构造器调用的函数中的this

还有一个与this值相关的情况是在函数的上下文中,这是一个构造函数的调用。

1
2
3
4
5
6
7
function A() {
console.log(this); // "a"对象下创建一个新属性
this.x = 10;
}

var a = new A();
console.log(a.x); // 10

在这个例子中,new运算符调用“A”函数的内部的[[Construct]] 方法,接着,在对象创建后,调用内部的[[Call]] 方法。 所有相同的函数“A”都将this的值设置为新创建的对象。

函数调用中手动设置this

在函数原型中定义的两个方法(因此所有的函数都可以访问它)允许去手动设置函数调用的this值。它们是.apply和.call方法。他们用接受的第一个参数作为this值,this 在调用的作用域中使用。这两个方法的区别很小,对于.apply,第二个参数必须是数组(或者是类似数组的对象,如arguments,反过来,.call能接受任何参数。两个方法必须的参数是第一个——this。

1
2
3
4
5
6
7
8
9
10
11
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

参考文献