你不知道的JavaScript 上卷 第二部分 this和对象原型

第一章 关于this

this 关键字是JavaScript 中最复杂的机制之一。它是一个很特别的关键字,被自动定义在
所有函数的作用域中。但是即使是非常有经验的JavaScript 开发者也很难说清它到底指向
什么。

任何足够先进的技术都和魔法无异。 ——Arthur C. Clarke

实际上,JavaScript 中this 的机制并没有那么先进,但是开发者往往会把理解过程复杂化,
毫无疑问,在缺乏清晰认识的情况下,this 对你来说完全就是一种魔法。

“this”是沟通过程中极其常见的一个代词。所以,在交流过程中很难区分
我们到底把“this”当作代词还是当作关键字。清晰起见,我总一直使用
this 表示关键字,使用“this”或者this 来表示代词。

1.1 为什么要用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, 我是KYLE
speak.call( you ); // Hello, 我是 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( you ); // READER
speak( me ); //hello, 我是KYLE

然而,this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将API 设计
得更加简洁并且易于复用。
随着你的使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用this
则不会这样。当我们介绍对象和原型时,你就会明白函数可以自动引用合适的上下文对象
有多重要。

1.2 误解

我们之后会解释this 到底是如何工作的,但是首先需要消除一些关于this 的错误认识。
太拘泥于“this”的字面意思就会产生一些误解。有两种常见的对于this 的解释,但是它
们都是错误的。

1.2.1 指向自身

人们很容易把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 语句产生了4 条输出,证明foo(..) 确实被调用了4 次,但是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++;
}
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 函数对象:

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,没有回避它。如果你仍然感到困惑的话,不用担心,之后我们会详
细解释具体的原理。

1.2.2 它的作用域

第二种常见的误解是,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 和词法作用域的查找混合使用时,一定要提醒自己,这是无法实现的。

1.3 this到底是什么

排除了一些错误理解之后,我们来看看this 到底是一种什么样的机制。
之前我们说过this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调
用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包
含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的
其中一个属性,会在函数执行的过程中用到。
在下一章我们会学习如何寻找函数的调用位置,从而判断函数在执行过程中会如何绑定
this。

1.4 小结

对于那些没有投入时间学习this 机制的JavaScript 开发者来说,this 的绑定一直是一件非
常令人困惑的事。this 是非常重要的,但是猜测、尝试并出错和盲目地从Stack Overflow
上复制和粘贴答案并不能让你真正理解this 的机制。
学习this 的第一步是明白this 既不指向函数自身也不指向函数的词法作用域,你也许被
这样的解释误导过,但其实它们都是错误的。
this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

第二章 this全面解析

在第1 章中,我们排除了一些对于this 的错误理解并且明白了每个函数的this 是在调用
时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。

2.1 调用位置

在理解this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的
位置(而不是声明的位置)。只有仔细分析调用位置才能回答这个问题:这个this 到底引
用的是什么?
通常来说,寻找调用位置就是寻找“函数被调用的位置”,但是做起来并没有这么简单,
因为某些编程模式可能会隐藏真正的调用位置。
最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的
调用位置就在当前正在执行的函数的前一个调用中。
下面我们来看看到底什么是调用栈和调用位置:

function baz() {
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域
console.log( "baz" );
bar(); // <-- bar 的调用位置
}
function bar() {
    // 当前调用栈是baz -> bar
// 因此,当前调用位置在baz 中
console.log( "bar" );
foo(); // <-- foo 的调用位置
}
function foo() {
// 当前调用栈是baz -> bar -> foo
// 因此,当前调用位置在bar 中
console.log( "foo" );
}
baz(); // <-- baz 的调用位置

注意我们是如何(从调用栈中)分析出真正的调用位置的,因为它决定了this 的绑定。

你可以把调用栈想象成一个函数调用链,就像我们在前面代码段的注释中所
写的一样。但是这种方法非常麻烦并且容易出错。另一个查看调用栈的方法
是使用浏览器的调试工具。绝大多数现代桌面浏览器都内置了开发者工具,
其中包含JavaScript 调试器。就本例来说,你可以在工具中给foo() 函数的
第一行代码设置一个断点,或者直接在第一行代码之前插入一条debugger;
语句。运行代码时,调试器会在那个位置暂停,同时会展示当前位置的函数
调用列表,这就是你的调用栈。因此,如果你想要分析this 的绑定,使用开
发者工具得到调用栈,然后找到栈中第二个元素,这就是真正的调用位置。

2.2 绑定规则

我们来看看在函数的执行过程中调用位置如何决定this 的绑定对象。
你必须找到调用位置,然后判断需要应用下面四条规则中的哪一条。我们首先会分别解释
这四条规则,然后解释多条规则都可用时它们的优先级如何排列。

2.2.1 默认绑定

首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用
其他规则时的默认规则。
思考一下下面的代码:

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:

function foo() {
"use strict";
console.log( this.a );
}
var a = 2;
foo(); // TypeError: this is undefined

这里有一个微妙但是非常重要的细节,虽然this 的绑定规则完全取决于调用位置,但是只
有foo() 运行在非strict mode 下时,默认绑定才能绑定到全局对象;严格模式下与foo()
的调用位置无关:

function foo() {
console.log( this.a );
}
var a = 2;
(function(){
"use strict";
foo(); // 2
})();

通常来说你不应该在代码中混合使用strict mode 和non-strict mode。整个
程序要么严格要么非严格。然而,有时候你可能会用到第三方库,其严格程
度和你的代码有所不同,因此一定要注意这类兼容性细节。

2.2.2 隐式绑定

另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包
含,不过这种说法可能会造成一些误导。
思考下面的代码:

function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2

首先需要注意的是foo() 的声明方式,及其之后是如何被当作引用属性添加到obj 中的。
但是无论是直接在obj 中定义还是先定义再添加为引用属性,这个函数严格来说都不属于
obj 对象。
然而,调用位置会使用obj 上下文来引用函数,因此你可以说函数被调用时obj 对象“拥
有”或者“包含”它。
无论你如何称呼这个模式,当foo() 被调用时,它的落脚点确实指向obj 对象。当函数引
用有上下文对象时,隐式绑定规则会把函数调用中的this 绑定到这个上下文对象。因为调
用foo() 时this 被绑定到obj,因此this.a 和obj.a 是一样的。
对象属性引用链中只有最顶层或者说最后一层会影响调用位置。举例来说:

function foo() {
console.log( this.a );
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.foo(); // 42
  • 隐式丢失

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

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() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。
一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时:

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"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一
个例子一样。
如果把函数传入语言内置的函数而不是传入你自己声明的函数,会发生什么呢?结果是一
样的,没有区别:

function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
setTimeout( obj.foo, 100 ); // "oops, global"

JavaScript 环境中内置的setTimeout() 函数实现和下面的伪代码类似:

function setTimeout(fn,delay) {
// 等待delay 毫秒
fn(); // <-- 调用位置!
}

就像我们看到的那样,回调函数丢失this 绑定是非常常见的。除此之外,还有一种情
况this 的行为会出乎我们意料:调用回调函数的函数可能会修改this。在一些流行的
JavaScript 库中事件处理器常会把回调函数的this 强制绑定到触发事件的DOM 元素上。
这在一些情况下可能很有用,但是有时它可能会让你感到非常郁闷。遗憾的是,这些工具
通常无法选择是否启用这个行为。
无论是哪种情况,this 的改变都是意想不到的,实际上你无法控制回调函数的执行方式,
因此就没有办法控制会影响绑定的调用位置。之后我们会介绍如何通过固定this 来修复
(这里是双关,“修复”和“固定”的英语单词都是fixing)这个问题。

2.2.3 显式绑定

就像我们刚才看到的那样,在分析隐式绑定时,我们必须在一个对象内部包含一个指向函
数的属性,并通过这个属性间接引用函数,从而把this 间接(隐式)绑定到这个对象上。
那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么
做呢?
JavaScript 中的“所有”函数都有一些有用的特性(这和它们的[[ 原型]] 有关——之后我
们会详细介绍原型),可以用来解决这个问题。具体点说,可以使用函数的call(..) 和
apply(..) 方法。严格来说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们
并没有这两个方法。但是这样的函数非常罕见,JavaScript 提供的绝大多数函数以及你自
己创建的所有函数都可以使用call(..) 和apply(..) 方法。
这两个方法是如何工作的呢?它们的第一个参数是一个对象,它们会把这个对象绑定到
this,接着在调用函数时指定这个this。因为你可以直接指定this 的绑定对象,因此我
们称之为显式绑定。
思考下面的代码:

function foo() {
console.log( this.a );
}
var obj = {
a:2
};
foo.call( obj ); // 2

通过foo.call(..),我们可以在调用foo 时强制把它的this 绑定到obj 上。
如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作this 的绑定对
象,这个原始值会被转换成它的对象形式(也就是new String(..)、new Boolean(..) 或者
new Number(..))。这通常被称为“装箱”。

从this 绑定的角度来说,call(..) 和apply(..) 是一样的,它们的区别体现
在其他的参数上,但是现在我们不用考虑这些。

可惜,显式绑定仍然无法解决我们之前提出的丢失绑定问题。

  1. 硬绑定

但是显式绑定的一个变种可以解决这个问题。
思考下面的代码:

function foo() {
console.log( this.a );
}
var obj = {
a:2
};
var bar = function() {
foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2
// 硬绑定的bar 不可能再修改它的this
bar.call( window ); // 2

我们来看看这个变种到底是怎样工作的。我们创建了函数bar(),并在它的内部手动调用
了foo.call(obj),因此强制把foo 的this 绑定到了obj。无论之后如何调用函数bar,它
总会手动在obj 上调用foo。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。
硬绑定的典型应用场景就是创建一个包裹函数,传入所有的参数并返回接收到的所有值:

function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = function() {
return foo.apply( obj, arguments );
};
var b = bar( 3 ); // 2 3
console.log( b ); // 5

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

function foo(something) {
console.log( this.a, something );
return this.a + something;
}
// 简单的辅助绑定函数
function bind(fn, obj) {
return function() {
return fn.apply( obj, arguments );
};
}
var obj = {
a:2
};
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

由于硬绑定是一种非常常用的模式,所以在ES5 中提供了内置的方法Function.prototype.
bind,它的用法如下:

function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = foo.bind( obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

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

  1. API调用的“上下文”

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

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(..);

JavaScript 也有一个new 操作符,使用方法看起来也和那些面向类的语言一样,绝大多数开
发者都认为JavaScript 中new 的机制也和那些语言一样。然而,JavaScript 中new 的机制实
际上和面向类的语言完全不同。
首先我们重新定义一下JavaScript 中的“构造函数”。在JavaScript 中,构造函数只是一些
使用new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,
它们甚至都不能说是一种特殊的函数类型,它们只是被new 操作符调用的普通函数而已。
举例来说,思考一下Number(..) 作为构造函数时的行为,ES5.1 中这样描述它:

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

所以,包括内置对象函数(比如Number(..),详情请查看第3 章)在内的所有函数都可
以用new 来调用,这种函数调用被称为构造函数调用。这里有一个重要但是非常细微的区
别:实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
使用new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。

  • 创建(或者说构造)一个全新的对象。
  • 这个新对象会被执行[[ 原型]] 连接。
  • 这个新对象会绑定到函数调用的this。
  • 如果函数没有返回其他对象,那么new 表达式中的函数调用会自动返回这个新对象。

我们现在关心的是第1 步、第3 步、第4 步,所以暂时跳过第2 步,第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 绑定的四条规则,你需要做的就是找到函数的调用位
置并判断应当应用哪条规则。但是,如果某个调用位置可以应用多条规则该怎么办?为了
解决这个问题就必须给这些规则设定优先级,这就是我们接下来要介绍的内容。
毫无疑问,默认绑定的优先级是四条规则中最低的,所以我们可以先不考虑它。
隐式绑定和显式绑定哪个优先级更高?我们来测试一下:

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 绑定和隐式绑定的优先级谁高谁低:

function foo(something) {
this.a = something;
}
var obj1 = {
foo: foo
};
var obj2 = {};
obj1.foo( 2 );
console.log( obj1.a ); // 2
obj1.foo.call( obj2, 3 );
console.log( obj2.a ); // 3
var bar = new obj1.foo( 4 );
console.log( obj1.a ); // 2
console.log( bar.a ); // 4

可以看到new 绑定比隐式绑定优先级高。但是new 绑定和显式绑定谁的优先级更高呢?

new 和call/apply 无法一起使用,因此无法通过new foo.call(obj1) 来直接
进行测试。但是我们可以使用硬绑定来测试它俩的优先级。

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

function foo(something) {
this.a = something;
}
var obj1 = {};
var bar = foo.bind( obj1 );
bar( 2 );
console.log( obj1.a ); // 2
var baz = new bar(3);
console.log( obj1.a ); // 2

出乎意料! bar 被硬绑定到obj1 上,但是new bar(3) 并没有像我们预计的那样把obj1.a
修改为3。相反,new 修改了硬绑定(到obj1 的)调用bar(..) 中的this。因为使用了
new 绑定,我们得到了一个名字为baz 的新对象,并且baz.a 的值是3。
再来看看我们之前介绍的“裸”辅助函数bind:

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

非常令人惊讶,因为看起来在辅助函数中new 操作符的调用无法修改this 绑定,但是在刚
才的代码中new 确实修改了this 绑定。
实际上,ES5 中内置的Function.prototype.bind(..) 更加复杂。下面是MDN 提供的一种
bind(..) 实现,为了方便阅读我们对代码进行了排版:

if (!Function.prototype.bind) {
Function.prototype.bind = function(oThis) {
if (typeof this !== "function") {
// 与 ECMAScript 5 最接近的
// 内部 IsCallable 函数
throw new TypeError(
"Function.prototype.bind - what is trying " +
"to be bound is not callable"
);
}
var aArgs = Array.prototype.slice.call( arguments, 1 ),
fToBind = this,
fNOP = function(){},
fBound = function(){
return fToBind.apply(
(
this instanceof fNOP &&
oThis ? this : oThis
),
aArgs.concat(
Array.prototype.slice.call( arguments )
);
}
;
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
};
}

这种bind(..) 是一种polyfill 代码(polyfill 就是我们常说的刮墙用的腻
子,polyfill 代码主要用于旧浏览器的兼容,比如说在旧的浏览器中并没
有内置bind 函数,因此可以使用polyfill 代码在旧浏览器中实现新的功
能),对于new 使用的硬绑定函数来说,这段polyfill 代码和ES5 内置的
bind(..) 函数并不完全相同(后面会介绍为什么要在new 中使用硬绑定函
数)。由于polyfill 并不是内置函数,所以无法创建一个不包含.prototype
的函数,因此会具有一些副作用。如果你要在new 中使用硬绑定函数并且依
赖polyfill 代码的话,一定要非常小心。

下面是new 修改this 的相关代码:

this instanceof fNOP &&
oThis ? this : oThis
// ... 以及:
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();

我们并不会详细解释这段代码做了什么(这非常复杂并且不在我们的讨论范围之内),不
过简单来说,这段代码会判断硬绑定函数是否是被new 调用,如果是的话就会使用新创建
的this 替换硬绑定的this。
那么,为什么要在new 中使用硬绑定函数呢?直接使用普通函数不是更简单吗?
之所以要在new 中使用硬绑定函数,主要目的是预先设置函数的一些参数,这样在使用
new 进行初始化时就可以只传入其余的参数。bind(..) 的功能之一就是可以把除了第一个
参数(第一个参数用于绑定this)之外的其他参数都传给下层的函数(这种技术称为“部
分应用”,是“柯里化”的一种)。举例来说:

function foo(p1,p2) {
this.val = p1 + p2;
}
// 之所以使用null 是因为在本例中我们并不关心硬绑定的this 是什么
// 反正使用new 时this 会被修改
var bar = foo.bind( null, "p1" );
var baz = new bar( "p2" );
baz.val; // p1p2
  • 判断this

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

  • 函数是否在new 中调用(new 绑定)?如果是的话this 绑定的是新创建的对象。
var bar = new foo()
  • 函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是
    指定的对象。
var bar = foo.call(obj2)
  • 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上
    下文对象。
6.6 小结

在软件架构中你可以选择是否使用类和继承设计模式。大多数开发者理所当然地认为类是
唯一(合适)的代码组织方式,但是本章中我们看到了另一种更少见但是更强大的设计模
式:行为委托。
行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript 的
[[Prototype]] 机制本质上就是行为委托机制。也就是说,我们可以选择在JavaScript 中努
力实现类机制(参见第4 和第5 章),也可以拥抱更自然的[[Prototype]] 委托机制。
当你只用对象来设计代码时,不仅可以让语法更加简洁,而且可以让代码结构更加清晰。
对象关联(对象之前互相关联)是一种编码风格,它倡导的是直接创建和关联对象,不把
它们抽象成类。对象关联可以用基于[[Prototype]] 的行为委托非常自然地实现。


《你不知道的JavaScript 上卷》下载地址

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值