深入学习 JavaScript 系列(二):This 关键字
小举 筑基

1. 关于 this

This 关键字是 JavaScript 中最复杂的机制之一,它是一个很特别的关键字,被自动定义在所有函数的作用域中。但是即使是非常有经验的 JavaScript 开发者也很难说清楚它到底指向什么。在缺乏对 This 清晰认知的情况下,它对我们来说无异于一种魔法。

1.1 为什么要用 this 呢?

在解释为什么要用 this 之前我们先来看一个代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function identify() {
return this.name.toUpperCase();
}

function speak() {
var greeting = "Hello, I'm" + identify.call(this);
console.log(greeting);
}

var me = {
name: 'Eric',
};

var you = {
name: 'John',
};

identify.call(me); // "ERIC"
identify.call(you); // "JOHN"

speak.call(me); // "Hello, I'm ERIC"
speak.call(you); // "Hello, I'm JOHN"

这段代码可以在不同的上下文对象(me 和 you)中重复使用函数identify()speak()而不用针对每一个对象编写不同版本的函数。

如果不使用 this, 那就需要给identify()speak()函数显示的传入一个上下文对象,像这样子:

1
2
3
4
5
6
7
8
9
10
11
function identify(context) {
return context.name.toUpperCase();
}

function speak(context) {
var greeting = "Hello, I'm" + identify(context);
console.log(greeting);
}

identify(you); // "JOHN"
speak(me); // "Hello, I'm ERIC"

随着你的使用模式越来越复杂,显示的传递上下文对象会让代码变得越来越混乱。然而,this 提供了一种更优雅的方式来隐式的“传递”一个对象引用,因此可以将 API 设计的更加简洁并且易于复用,请记住:函数可以自动的引用合适的上下文对象是非常重要的。

1.2 对 this 的误解

在解释 this 是如何工作之前我们先来消除一些关于 this 的错误认知。

1.2.1 误解一:this 指向自身

顾名思义,我们很容易错误的把 this 理解为指向自身,为什么需要从函数内部引用函数自身呢?常见的场景时递归(从函数内部调用这个函数)。JavaScript 开发者通常会认为,既然把函数看作一个对象(JavaScript 中所有的函数都是对象),那么就可以在调用函数时存储状态,这当然是可行的,但是除了函数对象还有许多更适合存储状态的地方。不过我们先来分析一下这个模式,看看 this 是否如我们所想的那样指向函数本身。思考一下下面这个代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo(num) {
console.log('foo' + num);
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

根据输出我们知道 foo 被调用了 4 次,但是 foo.count仍然是 0,显然从字面理解 this 是错误的。执行 foo.count = 0 时,的确向函数对象 foo 添加了一个属性,但是函数内部代码this.count中的 this 并不是指向 foo 这个函数对象,虽然熟悉名相同,但是对象却不同。

如果要从函数内部引用它自身,只使用 this 是不够的,一般你需要通过一个指向函数对象的词法标识符来引用它。思考一下下面这两个函数:

1
2
3
4
5
6
function foo() {
foo.count = 4;
}
setTimeout(function () {
// 这是一个匿名函数,函数无法指向自身
}, 10);

注意第二个函数中我们传给 setTimeout 的回调函数没有函数名称标识符(匿名函数),因此函数无法从内部引用自身。

💡 有一种传统的但是现在已经被弃用和批判的方法是使用 arguments.callee 来引用当前正在运行的函数对象,这是唯一一种可以从匿名函数内部引用自身的方法了,但是更好的方法是避免使用匿名函数,至少在需要自身引用时使用具名函数。arguments.callee 已经被废弃了,不应该再使用它。

所以对于我们的例子来说,另一种解决方法是使用 foo 标识符来代替 this 来引用函数自身:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo(num) {
console.log('foo' + num);
// 记录 foo 被调用的次数
foo.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); // 4

然而这种方式回避了 this 的问题,完全依赖于变量 foo 的词法作用域。

另外一种方法是强制 this 指向 foo 函数对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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.call(foo, i); // 这里强制 this 指向 foo 函数对象
}
}
// foo 6
// foo 7
// foo 8
// foo 9

// foo 被调用了多少次?
console.log(foo.count); // 4

1.2.2 误解二:this 的作用域

第二种常见的误解是,this 指向函数的作用域,这种说法在某种情况下是正确的,但是在其他情况下却是错误的。

需要明确的是,this 在任何情况下都不指向函数的词法作用域。在 JavaScript 内部,作用域确实和对象很类似,可见的标识符都是它的属性,但是作用域“对象”无法通过 JavaScript 代码访问,它存在于 JavaScript 引擎内部。

思考一下下面的这个例子,它试图跨越边界,使用 this 来隐式引用函数的词法作用域:

1
2
3
4
5
6
7
8
9
10
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,直接使用词法作用域的查找机制来引用 bar 函数。此外,这段代码还试图使用 this 联通 foo 和 bar 的词法作用域,从而让 bar 函数内部能访问到 foo 函数内部的局部变量 a。这当然是不可能实现的,使用 this 不可能在词法作用域中查找到什么的。

1.3 this 到底是什么?

排除了一些误解后,我们来看看 this 到底是一种什么样的机制?

this 是在运行时绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也被称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。this 就是这个活动记录中的一个属性,会在函数执行的过程中用到。

2. this 全面解析

我们刚刚排出了一些对 this 的误解,并且明白了每个函数的 this 是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。接下来我们来探究一下如何寻找到函数的调用位置,从而判断函数在执行过程中会如何绑定 this。

2.1 调用位置

所谓调用位置就是函数在代码中被调用的位置而不是被声明的位置,想要准确的找到函数被调用的位置,最重要的是分析调用站(就是为了到达当前执行位置所调用的所有函数),我们关心的调用位置就是在当前正在执行的函数中的前一个调用中。举个例子来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function baz() {
// 当前调用栈是 全局作用域 -> baz,因此当前的调用位置是调用 baz 的地方,也就是全局作用域
consoleloc('baz');
bar();
}

function bar() {
//当前的调用栈是 全局作用域 -> baz -> bar,因此当前的调用位置是调用 bar 的地方,也就是 baz 函数内部
console.log('bar');
foo();
}

function foo() {
// 当前的调用栈是 全局作用域 -> baz -> bar -> foo,因此当前的调用位置是调用 foo 的地方,也就是 bar 函数内部
console.log('foo');
}

baz(); // baz 的调用位置, 这里是全局作用域

2.2 绑定规则

调用位置确定后,我们就可以确定 this 的绑定规则了,判断需要应用下面四条规则中的哪一条:

2.2.1 默认绑定

第一条就是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。

思考下面这个代码:

1
2
3
4
5
6
function foo() {
console.log(this.a);
}
var a = 2;

foo(); // 2

应该注意到的第一件事是:声明在全局作用域中的变量(比如 var a = 2)就是全局对象的一个同名属性。 它们本质上就是一个东西,并不是通过复制得到的。我们看到当调用foo()时,this.a被解析成了全局变量 a,这是因为在这个例子中应用了 this 的默认绑定,this 指向全局对象。

如何确定这里时应用了默认绑定呢?很简单,我们分析一下调用位置来看看 foo 是如何被调用的。在这个例子中,foo 是直接使用不带任何修饰的函数引用进行调用的,所以只能应用默认绑定,无法应用其他规则。

如果使用严格模式(strict mode),则不能将全局对象用作默认绑定,此时 this 会被绑定到 undefined。代码如下:

1
2
3
4
5
6
7
function foo() {
'use strict';
console.log(this.a);
}

var a = 2;
foo(); // TypeError: Cannot read property 'a' of undefined

2.2.2 隐式绑定

第二条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,思考一下下面这个代码:

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

var obj = {
a: 2,
foo: foo,
};

obj.foo(); // 2

注意 foo 函数的声明方式,以及之后是如何被当作引用属性添加到 obj 中的。无论是直接在 obj 中定义还是先定义再添加为 obj 的属性,这个函数严格来说都不属于 obj 对象

然而调用位置会使用 obj 上下文来引用函数,因此你可以认为函数被调用时 obj 对象“拥有”或者“包含”函数引用

无论你如何称呼这个模式,当 foo 被调用时,它的前面确实加上了对 obj 的引用。当函数引用有上下文对象时,隐式绑定规则会把函数调用时的 this 绑定到这个上下文对象。此时this.aobj.a是一样的。

另外需要注意对象属性引用链中只有上一层或者最后一层在调用位置中起作用,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log(this.a);
}
var obj1 = {
a: 2,
obj2: obj2,
};
var obj2 = {
a: 42,
foo: foo,
};
obj1.obj2.foo(); // 42

一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上(取决于是否是严格模式)。思考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo() {
console.log(this.a);
}

var obj = {
a: 2,
foo: foo,
};

var bar = obj.foo; // 函数别名

var a = 'oops, global'; // a是全局对象的属性

bar(); // "oops, global"

虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是foo 函数本身,因此此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定。

另外一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function foo() {
console.log(this.a);
}

function doFoo(fn) {
// 这里的 fn 引用是 foo 函数本身
fn();
}

var obj = {
a: 2,
foo: foo,
};

var a = 'oops, global'; // a是全局对象的属性

doFoo(obj.foo); // "oops, global"

参数传递就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样。那如果我们把函数传入语言内置的函数(比如 setTimeout)而不是自己定义的函数呢?结果是一样的,没有区别:

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

var obj = {
a: 2,
foo: foo,
};

var a = 'oops, global'; // a是全局对象的属性

setTimeout(obj.foo, 1000); // "oops, global"

JavaScript 中的 setTimeout 函数的实现和下面这段伪代码类似:

1
2
3
4
function setTimeout(fn, delay) {
// 等待 delay 毫秒
fn();
}

可以看到,回调函数丢失 this 绑定是非常常见的。除此之外,调用回调函数的函数可能会修改 this。无论哪种情况,this 的改变都是意想不到的,实际上你根本就无法控制回调函数的执行方式,因此也就无法控制调用位置得到期望的绑定。

2.2.3 显式绑定

在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。

如果我们不想在对象内部包含一个函数的引用,但是又想在某个对象上强制调用某个函数该怎么办呢?

答案就是使用函数的 call 和 apply 方法,JavaScript 提供的绝大多数函数以及你自己创建的所有函数都可以使用 call 和 apply 方法。

这两个方法的第一个参数是一个对象,这个对象是给 this 准备的,接着在调用的时候将其绑定到 this,因为我们可以直接指定 this 的绑定对象,因此称之为显示绑定

举个例子:

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

var obj = {
a: 2,
};
foo.call(obj); // 2, 通过foo.call(obj),我们强制把它的this绑定到了obj上

💡 使用 call 和 apply 方法时,如果第一个参数你传入了一个原始值(字符串、布尔或者数字类型),来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String()、 new Boolean()或者 new Number()),这被称为“装箱”(boxing)。

遗憾的是,显示绑定并不能解决我们之前遇到的丢失绑定的问题。但是显示绑定的一个变种(硬绑定)可以解决这个问题,思考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function foo() {
console.log(this.a);
}

var obj = {
a: 2,
};

var bar = function () {
foo.call(obj);
};

bar(); // 2
setTimeout(bar, 1000); // 2

// 硬绑定的bar函数不可能再修改它的this
bar.call(window); // 2

我们创建了函数 bar, 并在它的内部手动调用了`foo.call(obj),因此强制把 foo 的 this 绑定到了 obj 上。无论之后如何调用 bar,它总会手动在 obj 上调用 foo。这种绑定是一种强制绑定,称之为硬绑定

硬绑定的一个典型的应用场景就是创建一个包裹函数,负责接收参数并返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo(something) {
console.log(this.a, something);
return this.a + something;
}

var obj = {
a: 2,
};

var bar = function () {
// arguments 是调用bar时实际接受到的参数,是一个类数组对象
return foo.apply(obj, arguments);
};

var b = bar(3); // 2 3
console.log(b); // 5

另一种方法是创建一个可重复使用的辅助函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo(something) {
console.log(this.a, something);
return this.a + something;
}

function bind(fn, thisContext) {
return function () {
return fn.apply(thisContext, arguments);
};
}

var obj = {
a: 2,
};

var bar = bind(foo, obj);

var b = bar(3); // 2 3
console.log(b); // 5

硬绑定是一种非常有用的模式,ES5 提供了内置的方法 Function.prototype.bind,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo(something) {
console.log(this.a, something);
return this.a + something;
}

var obj = {
a: 2,
};

var bar = foo.bind(obj); // 返回一个新的函数,这个函数被硬绑定到了obj上

var b = bar(3); // 2 3
console.log(b); // 5

bind 会返回一个硬编码的新函数,它会把指定的参数设置为 this 的上下文并调用原始函数。

JavaScript 语言和宿主环境的许多新的内置函数,以及第三方库的许多函数,都提供了一个可选的参数,通常被称为“上下文”(context),这个参数可以用来显式指定函数的 this 绑定对象。它和 bind 的作用一样,确保你的函数使用指定的 this。 举例来说:

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

var obj = {
id: 'awesome',
}[
// 调用 foo 函数,并显式指定 this 的绑定对象为 obj
(1, 2, 3)
].forEach(foo, obj); // 1 awesome, 2 awesome, 3 awesome

这些函数实际上就是通过 call 或 apply 实现了显示绑定。

2.2.4 new 绑定

这是最后一条 this 的绑定规则,不过在详细介绍之前我们需要先澄清一个非常常见的关于 JavaScript 中函数和对象的误解。

在传统的面相类的语言中,“构造函数”是类中的一些特殊方法,使用 new 初始化类时会自动调用类中的构造函数,通常的的使用形式是这样的:something = new myClass(args); JavaScript 也有一个 new 操作符,使用起来也和那些语言差不多,因此绝大多数开发着都认为 JavaScript 也和那些语言的 new 的机制是一样的。然而,JavaScript 中 new 的机制和面向类的语言完全不同

在 JavaScript 中,构造函数只是一些使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们和普通的函数没有任何区别,只是被 new 操作符调用的普通函数而已。

ES5 这样描述 Number() 作为构造函数时的行为:

Number 构造函数:当 Number 在 new 表达式中被调用时,它是一个构造函数,它会初始化新建的对象。

所以,包括内置对象函数(比如 Number())在内的所有函数都可以用 new 来调用,这种函数调用被称为构造函数调用。这里有一个重要但是非常细微的区别:实际上并不存在所谓的“构造函数”,只有对函数的“构造调用”

使用 new 来调用函数,或者发生函数的构造调用时,会自动执行下面的操作:

  1. 创建(或者说构造)一个全新的对象。
  2. 这个对象会被执行[[Prototype]]链接,因此会继承构造函数的原型。
  3. 这个对象会绑定到函数调用的 this 关键字。
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。

思考下面的代码:

1
2
3
4
5
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log(bar.a); // 2

使用 new 来调用 foo 时,我们会构造一个新的对象并把它绑定到 foo 调用中的 this 上。new 是最后一种可以影响函数调用时 this 绑定行为的方法, 我们称之为 new 绑定

2.3 优先级

在函数调用中决定 this 绑定的四条规则中,如果某个位置可以应用多条规则该怎么办?换句话说,这四条规则的优先级是什么?

毫无疑问,默认绑定的优先级是四条规则中最低的,所以可以先不考虑它。隐式绑定和显示绑定哪个优先级更高呢?我们不妨来测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo() {
console.log(this.a);
}

var obj1 = {
a: 2,
foo: foo,
};

var obj2 = {
a: 3,
foo: foo,
};

obj1.foo(); // 2
obj2.foo(); // 3

obj1.foo.call(obj2); // 3
obj2.foo.call(obj1); // 2

可以看到,显示绑定的优先级更高,因此在判断时应该先考虑是否存在显示绑定。

接下来需要弄清楚 new 绑定和隐式绑定的优先级高低:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo(something) {
this.a = something;
}

var obj1 = {
foo: foo,
};

var obj2 = {};

obj1.foo(2); // 隐式绑定,this 绑定到 obj1
console.log(obj1.a); // 2

obj1.foo.call(obj2, 3); // 显示绑定,this 绑定到 obj2
console.log(obj2.a); // 3

var bar = new obj1.foo(4); // new 绑定,this 绑定到新创建的对象而不是 obj1
console.log(obj1.a); // 2
console.log(bar.a); // 4

可以看到 new 绑定比隐式绑定优先级高。最后,new 绑定和显示绑定哪个优先级更高呢?

还记得硬绑定是如何工作的吗?Function.prototype.bind()会创建一个新的包装函数,这个函数会忽略它当前的 this 绑定(无论绑定的对象是什么),并把我们提供的对象绑定到 this 上。这样看起来硬绑定(也是显示绑定的一种)似乎比 new 绑定的优先级更高,无法使用 new 来控制 this 绑定。我们来测试一下:

1
2
3
4
5
6
7
8
9
10
11
function foo(something) {
this.a = something;
}
var obj1 = {};
var bar = foo.bind(obj1); // 新返回的 bar 函数将 this 硬绑定到了 obj1
bar(2);
console.log(obj1.a); // 2

var baz = new bar(3); // 将硬绑定了 obj1 的新的函数 bar 作为构造函数调用
console.log(obj1.a); // 2
console.log(baz.a); // 3 可以看到 this 指向了新新创建的对象 baz 上而不是 obj1

出乎意料,尽管 bar 函数被硬绑定到了 obj1 上,但是 new bar(3) 并没有像我们预计的那样把 obj1.a 修改为 3。相反,new 修改了硬绑定(到 obj)调用 bar 中的 this,因为使用了 new 绑定,我们得到了一个名为 baz 的新的对象,并且 baz.a 的值为 3。

来看一下 ES5 中内置的 Function.prototype.bind() 方法的一种实现(来自 MDN):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
if (!Function.prototype.bind) {
Function.prototype.bind = function (oThis) {
if (typeof this !== 'function') {
// 如果调用 bind 方法的对象不是函数,则抛出一个 TypeError 异常
throw new TypeError(
'Function.prototype.bind - what is trying to be bound is not callable'
);
}
var aArgs = Array.prototype.slice.call(arguments, 1); // 获取调用 bind 方法时传入的参数, 注意这里是从 index 1 开始,因为第一个参数是要绑定到 this 的对象
var fToBind = this; // 将调用 bind 方法的函数保存下来
var fNOP = function () {}; // 空函数, 作为返回的函数的原型
var fBound = function () {
// 包裹函数
return fToBind.apply(
this instanceof fNOP && oThis ? this : oThis,
aArgs.concat(Array.prototype.slice.call(arguments))
);
};

fNOP.prototype = this.prototype; // 为 fNOP 设置原型
fBound.prototype = new fNOP(); // 为 fBound 设置原型,并将其原型设置为 fNOP 的实例

return fBound; // 返回包裹函数
};
}

这个实现的核心部分是这段代码:

1
2
3
4
this instanceof fNOP && oThis ? this : oThis;
// 以及这部分:
fBound.prototype = new fNOP();
fBound.prototype = new fNOP();

这部分代码是模拟 bind 方法实现中最关键的一部分,处理了绑定函数的调用方式构造函数行为的兼容性问题。我们来逐步分析:

首先是这句代码:this instanceof fNOP && oThis ? this : oThis:

  • bind 的目标是创建一个新的函数(fBound),并绑定一个指定的 this 值(oThis)以及一组参数(aArgs)。
  • 新的函数(fBound)可以以两种方式调用:
    1. 普通函数调用:绑定的 this 值应该是 oThis。
    2. 构造函数调用:如果用 new 调用 fBound,this 应该指向新创建的对象,而不是绑定时的 oThis。
  • this instanceof fNOP正是用来判断当前的调用方式是否是通过 new 调用的(即构造函数调用),fBound 的原型被设置为 fNOP 的实例(fBound.prototype = new fNOP())。因此,如果当前的 this 是 fNOP 的实例,则说明 fBound 是通过 new 调用的。
  • 如果 this instanceof fNOP为真,进一步检查是否存在 oThis。&& oThis是为了确保在普通函数调用时能正确绑定到指定的对象。

完整的逻辑就是:如果 this 是 fNOP 的实例(即通过 new 调用),返回当前的 this,表示 fBound 被用作构造函数,this 应该是新创建的对象。如果不是构造函数调用,返回 oThis,表示普通的函数调用中,this 应该绑定到指定的对象 oThis。

最后fNOP.prototype = this.prototype;的作用是确保通过 bind 创建的函数(fBound)能够继承原始函数(fToBind)的原型链,从而保持一致的原型行为。

简单的说: 如果硬绑定函数是通过 new 调用的,则会忽略硬绑定的 this,而是将 this 绑定到新创建的对象上

那为啥非得在 new 中使用硬绑定的函数呢?直接使用普通函数不行吗?其实主要目的是预先设置函数的一些参数,这样在使用 new 进行初始化的时候就可以只传其余参数。bind 函数的功能之一就是可以把除了第一个参数(第一个参数用于绑定 this)之外的其余参数都传递给下层的函数(这种技术被称为“部分应用”,是“柯里化”的一种)。举例来说:

1
2
3
4
5
6
7
function foo(p1, p2) {
this.val = p1 + p2;
}
// 之所以使用 null 是因为在本例子中我们不关心硬绑定的 this 是什么,反正使用 new 时都会忽略它
var bar = foo.bind(null, 'p1');
var baz = new bar('p2');
console.log(baz.val); // 'p1p2'

2.4 判断 this

现在我们基本上可以根据优先级来判断函数在某个调用位置应用的是哪条规则了,可以按照下面的顺序来进行判断:

  1. 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。 var bar = new foo();
  2. 函数是否通过 call、apply(显示绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。 var bar = foo.call(obj2);
  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。 obj1.foo();
  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到全局对象。var bar = foo();

这几条基本上涵盖了所有函数正常调用时 this 的绑定原理了。然而,凡事总有例外。

2.5 绑定例外

在某些情况下 this 的绑定行为会出乎意料之外,你认为应当应用其他规则时,实际上应用的可能是默认绑定规则。

2.5.1 被忽略的 this

如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则:

1
2
3
4
5
function foo() {
console.log(this.a);
}
var a = 2;
foo.call(null); // 2

什么情况下会传入 null 呢?一种非常常见的做法是使用 apply 来“展开”一个数组,并当作参数传入一个函数。类似的使用 bind 也可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用:

1
2
3
4
5
6
7
8
9
function foo(a, b) {
console.log('a:' + a + ', b:' + b);
}
//把数组“展开“成参数
foo.apply(null, [2, 3]); // a:2, b:3

// 使用 bind 进行柯里化
var bar = foo.bind(null, 2);
bar(3); // a:2, b:3

在这两个例子中,函数都不关心 this,但是你仍旧需要传入一个占位值,这时 null 可能是一个不错的选择。不过总是使用 null 来忽略 this 绑定可能产生一些副作用。如果某个函数确实使用了 this(比如第三方库的某个函数)),那默认绑定规则会把 this 绑定到全局对象,这可能会导致不可预计的后果。

💡 关于展开一个数组,ES6 提供了 … 操作符可以代替 apply 来“展开”数组,foo(…[1,2]) 和 foo.apply(null, [1,2]) 效果相同,这样可以避免不必要的 this 绑定。

一种“更安全”的做法是传入一个“特殊的对象“,把 this 绑定到这个对象上不会对你的程序产生任何副作用,这个对象就是空的非委托对象。在 JavaScript 中,创建一个空对象最简单的方法就是使用Object.create(null),它和 {}很像,但是并不会创建Object.prototype这个委托,所以它比{}

1
2
3
4
5
6
7
8
9
10
11
12
function foo(a, b) {
console.log('a:' + a + ', b:' + b);
}
// 空的非委托对象
var Ø = Object.create(null);

// 把数组展开成参数
foo.apply(Ø, [2, 3]);

// 使用 bind 进行柯里化
var bar = foo.bind(Ø, 2);
bar(3);

使用 Ø 不仅让函数变得更加“安全”,而且可以提高代码的可读性,Ø 表示“我希望 this 是空”,这比 null 的含义更清楚。

2.5.2 间接调用

另一个需要注意的是我们可能(有意或者无意)创建一个函数的“间接引用”,在这种情况下,调用这个函数会使用默认绑定规则。间接引用最容易在赋值时发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
console.log(this.a);
}
var a = 2;
var o = {
a: 3,
foo: foo,
};
var p = {
a: 4,
};
o.foo(); // 3
(p.foo = o.foo)(); // 2

赋值表达式(p.foo = o.foo)的返回值是目标函数的引用,因此调用位置是foo()而不是p.foo()或者o.foo()。在这种情况下,应用的是默认绑定规则。

2.5.3 软绑定

之前我们提到了硬绑定,这种绑定方式可以把 this 绑定到指定的对象(除了使用 new 进行调用时),当时有时候硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 this 了。如果可以给默认绑定指定一个除了全局对象和 undefine 以外的值,就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显示绑定修改 this 的能力。比如一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (!Function.prototype.softBind) {
Function.prototype.softBind = function (obj) {
var fn = this;
var curried = [].silce.call(arguments, 1); // 保存剩余参数
var bound = function () {
return fn.apply(
!this || this === (window || global) ? obj : this,
curried.concat.apply(curried, arguments)
);
};
bound.prototype = Object.create(fn.prototype);
return bound;
};
}

核心是这句代码:!this || this === (window || global) ? obj : this,它将决定 fn.apply 方法的 this 值:如果 this 绑定到了全局对象或者 undefined, 那就把 this 绑定到指定的对象 obj 上,否则就保持原来的 this。接下来看看如何使用这个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo() {
console.log('name:' + this.name);
}
var obj1 = { name: 'obj1' };
var obj2 = { name: 'obj2' };
var obj3 = { name: 'obj3' };

var fooOBJ = foo.softBind(obj1); // fooOBJ 就是上边代码片段中返回的 bound 函数
fooOBJ(); // name:obj1,这里的效果和硬绑定一样

obj2.foo = foo.softBind(obj1);
obj2.foo(); // name:obj2, 注意这里不一样了,这里是在obj2的上下文中调用foo的(属于隐式绑定),如果是硬绑定的话隐式绑定是无法修改 this 的,this还会是obj1, 然而这里是软绑定,this会被绑定到obj2

fooOBJ.call(obj3); // name:obj3, 这里我们用显示绑定的方式调用 fooOBJ,this 被绑定到了 obj3 上,还是和硬绑定不一样

setTimeout(obj2.foo, 100); // name:obj1, 这里obj2.foo是引擎直接在全局调用的,按照默认绑定规则,this 应该绑定到了全局对象,然而软绑定仍旧会把 this 绑定到 obj1

2.6 this 的词法

前面的四条规则已经可以涵盖所有正常的函数,但是 ES6 引入了一种无法使用这些规则的特殊类型的函数:箭头函数。箭头函数并不是使用 function 关键字定义的,而是使用被称为“胖箭头”的操作符=>定义的。箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局作用域)来决定 this。

来看一下箭头函数的词法作用域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function foo() {
return () => {
// this 继承自 foo
console.log(this.a);
};
}

var obj1 = {
a: 2,
};

var obj2 = {
a: 3,
};

var bar = foo.call(obj1);
bar.call(obj2); // 2 注意这里的 this 并不是 obj2,而是 obj1

foo 内部创建的箭头函数会“捕获”调用时 foo 的 this。由于 foo 的 this 绑定到 obj1,因此 bar(引用的是箭头函数)的 this 也会绑定到 obj1,箭头函数的绑定无法被修改(new 也不行)。

箭头函数最常用于回调函数中,如事件处理器或者定时器:

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
setTimeout(() => {
// 这里的 this 在词法上继承自 foo
console.log(this.a);
}, 100);
}

var obj = {
a: 2,
};

foo.call(obj); // 2

箭头函数可以像 bind 一样确保函数的 this 被绑定到指定的对象,此外其重要性还体现在它用更常见的词法作用域取代了传统 this 机制。实际上在 ES6 之前我们就已经在使用一种几乎和箭头函数一模一样的模式:

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
var self = this;
setTimeout(function () {
console.log(self.a);
}, 100);
}

var obj = {
a: 2,
};

foo.call(obj); // 2

虽然self = this和箭头函数看起来都可以取代 bind,但是从本质上来说,它们想替换的是 this 机制。