一. this
1. 误解
(1)指向自身: 相信很多人跟我一样,看到this,很容易从字面意思去理解,认为this 指向函数自身;
(2)它的作用域: 第二种常见的误解是,this 指向函数的作用域。
那么this到底指向谁?
this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。
2. this全面解析
调用位置
function baz() {
// 当前调用位置是全局作用域
console.log( "baz" );
bar(); // <-- bar 的调用位置
}
function bar() {
// 当前调用位置在 baz 中
console.log( "bar" );
foo(); // <-- foo 的调用位置
}
function foo() {
// 当前调用位置在 bar 中
console.log( "foo" );
}
baz(); // <-- baz 的调用位置
绑定规则
当我们找到函数的调用位置以后,还需要判断其适用的绑定规则,总共有四种规则:
默认绑定
隐式绑定
显式绑定
new绑定
(1) 默认绑定
首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用
其他规则时的默认规则。
function foo() {
console.log( this.a );
}
var a = 2;
foo(); // 2
- 在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用
默认绑定
,无法应用其他规则。 - 声明在全局作用域中的变量(比如 var a = 2)就是全局对象的一个同名属性。
- 这里需要注意一点: 如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此 this 会绑定到 undefined。
(2) 隐式绑定
另一条需要考虑的规则是调用位置是否有上下文对象
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
首先需要注意的是 foo() 的声明方式,及其之后是如何被当作引用属性添加到 obj 中的。
但是无论是直接在 obj 中定义还是先定义再添加为引用属性,这个函数严格来说都不属于
obj 对象。
然而,调用位置会使用 obj 上下文来引用函数,因此你可以说函数被调用时 obj 对象“拥
有”或者“包含”它。
特殊情况: 隐式丢失
- 一个最常见的 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"
setTimeout( obj.foo, 100 ); // "oops, global"
参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一
个例子一样。类似的setTimeout( obj.foo, 100 );也会是同样的结果。
(3) 显式绑定
使用 call(..) 和 apply(..) 方法
这两个方法是如何工作的呢?它们的第一个参数是一个对象,它们会把这个对象绑定到
this,接着在调用函数时指定这个 this。因为你可以直接指定 this 的绑定对象,因此我
们称之为显式绑定
。
function foo() {
console.log( this.a );
}
var obj = {
a:2
};
foo.call( obj ); // 2
通过 foo.call(..),我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。
- 硬绑定
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
由于硬绑定是一种非常常用的模式,所以在 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
- 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(..) 实现了显式绑定,这样你可以少些一些
代码。
(4) new绑定
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
- 创建(或者说构造)一个全新的对象。
- 这个新对象会被执行 [[ 原型 ]] 连接。
- 这个新对象会绑定到函数调用的 this。
- 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2
判断this
现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的
顺序来进行判断:
- 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
var bar = new foo() - 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是
指定的对象。
var bar = foo.call(obj2) - 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上
下文对象。
var bar = obj1.foo() - 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到
全局对象。
var bar = foo()
箭头函数不适用以上规则
ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定
this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么)。这
其实和 ES6 之前代码中的 self = this 机制一样。
二. 对象原型
1. 对象
对象就是键 / 值对的集合。可以通过 .propName 或者 ["propName"] 语法来获取属性值。访
问属性时,引擎实际上会调用内部的默认 [[Get]] 操作(在设置属性值时是 [[Put]]),
[[Get]] 操作会检查对象本身是否包含这个属性,如果没找到的话还会查找 [[Prototype]]链。
语法
对象可以通过两种形式定义:声明(文字)形式和构造形式。
var myObj = {
key: value
// ...
};
var myObj = new Object();
myObj.key = value;
对象是 JavaScript 的基础。在 JavaScript 中一共有六种主要类型:
• string
• number
• boolean
• null
• undefined
• object
注意,简单基本类型(string、boolean、number、null 和 undefined)本身并不是对象。
有一种常见的错误说法是“JavaScript 中万物皆是对象”,这显然是错误的。
[[Get]]
属性访问在实现时有一个微妙却非常重要的细节,思考下面的代码:
var myObject = {
a: 2
};
myObject.a; // 2
myObject.a 是一次属性访问,但是这条语句并不仅仅是在 myObjet 中查找名字为 a 的属性。
myObject.a 在 myObject 上实际上是实现了 [[Get]] 操作。
首先在对象中查找是否有名称相同的属性,如果找到就会返回这个属性的值。
然而,如果没有找到名称相同的属性,就会遍历可能存在的 [[Prototype]] 链(原型链)。
如果无论如何都没有找到名称相同的属性,那 [[Get]] 操作会返回值 undefined。
[[Put]]
既然有可以获取属性值的 [[Get]] 操作,就一定有对应的 [[Put]] 操作。
- 如果已经存在这个属性,[[Put]] 算法大致会检查下面这些内容。
- 属性是否是访问描述符(给一个属性定义 getter、setter)?如果是并且存在 setter 就调用 setter。
- 属性的数据描述符中 writable 是否是 false ?如果是,在非严格模式下静默失败,在
严格模式下抛出 TypeError 异常。 - 如果都不是,将该值设置为属性的值。
- 如果对象中不存在这个属性,[[Put]] 操作会更加复杂。我们会在讨论 [[Prototype]]
时详细进行介绍。
Getter和Setter
对象默认的 [[Put]] 和 [[Get]] 操作分别可以控制属性值的设置和获取, 在 ES5 中可以使用 getter 和 setter 部分改写默认操作。
var myObject = {
// 给 a 定义一个 getter
get a() {
return this._a_;
},
// 给 a 定义一个 setter
set a(val) {
this._a_ = val * 2;
}
};
Object.defineProperty(
myObject, // 目标对象
"b", // 属性名
{ // 描述符
// 给 b 设置一个 getter
get: function(){ return this.a * 2 },
// 确保 b 会出现在对象的属性列表中
enumerable: true
}
);
myObject.a; // 2
myObject.b; // 4
2. 原型
[[Prototype]]
JavaScript 中的对象有一个特殊的 [[Prototype]] 内置属性,其实就是对于其他对象的引
用。几乎所有的对象在创建时 [[Prototype]] 属性都会被赋予一个非空的值。
var anotherObject = {
a:2
};
// 创建一个关联到 anotherObject 的对象
var myObject = Object.create( anotherObject );
myObject.a; // 2
- 属性查找过程会持续到找到匹配的属性名或者查找完整条 [[Prototype]] 链。如果是后者的话,[[Get]] 操作的返回值是 undefined。
- 但是到哪里是 [[Prototype]] 的“尽头”呢?
所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype。由于所有的“普通”对象都“源于”这个 Object.prototype 对象,所以它包含 JavaScript 中许多通用的功能。
属性设置和屏蔽
前面我们提到过,给一个对象设置属性并不仅仅是添加一个新属性或者修改已有的属性值。
现在我们完整地讲解一下这个过程:
myObject.foo = "bar";
- 如果 myObject 对象中包含名为 foo 的普通数据访问属性,这条赋值语句只会修改已有的属性值。
- 如果 foo 不是直接存在于 myObject 中,[[Prototype]] 链就会被遍历,类似 [[Get]]操作。
如果原型链上找不到 foo,foo 就会被直接添加到 myObject 上。 - 然而,如果 foo 存在于原型链上层,赋值语句 myObject.foo = "bar" 的行为就会有些
不同(而且可能很出人意料)。稍后我们会进行介绍。
如果属性名 foo 既出现在 myObject 中也出现在 myObject 的 [[Prototype]] 链上层,那
么就会发生屏蔽。
屏蔽比我们想象中更加复杂。下面我们分析一下如果 foo 不直接存在于 myObject 中而是存
在于原型链上层时 myObject.foo = "bar" 会出现的三种情况。
- 如果在 [[Prototype]] 链上层存在名为 foo 的普通数据访问属性并且没有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo 的新属性,它是屏蔽属性。
- 如果在 [[Prototype]] 链上层存在 foo,但是它被标记为只读(writable:false),那么
无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会
抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。 - 如果在 [[Prototype]] 链上层存在 foo 并且它是一个 setter,那就一定会调用这个 setter。
foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这个 setter。
“类”
JavaScript 和面向类的语言不同,它并没有类来作为对象的抽象模式或者说蓝图。JavaScript 中只有对象。
实际上,JavaScript 才是真正应该被称为“面向对象”的语言,因为它是少有的可以不通
过类,直接创建对象的语言。
“类”函数
多年以来,JavaScript 中有一种奇怪的行为一直在被无耻地滥用,那就是模仿类。
这种奇怪的“类似类”的行为利用了函数的一种特殊特性:所有的函数默认都会拥有一个
名为 prototype 的公有并且不可枚举的属性,它会指向另一个对象:
function Foo() {
// ...
}
Foo.prototype; // {
这个对象通常被称为 Foo 的原型,因为我们通过名为 Foo.prototype 的属性引用来访问它。然而不幸的是,这个术语对我们造成了极大的误导。
function Foo() {
// ...
}
var a = new Foo();
Object.getPrototypeOf( a ) === Foo.prototype; // true
调用 new Foo() 时会创建 a,其中的一步就是给 a 一个内部的 [[Prototype]] 链接,关联到 Foo.prototype 指向的那个对象。
最后我们得到了两个对象,它们之间互相关联,就是这样。我们并没有初始化一个类,实
际上我们并没有从“类”中复制任何行为到一个对象中,只是让两个对象互相关联。
在面向类的语言中,类可以被复制(或者说实例化)多次,就像用模具制作东西一样。之所以会这样是因为实例化(或者继承)一个类就意味着“把类的行为复制到物理对象中”,对于每一个新实例来说都会重复这个过程。
但是在 JavaScript 中,并没有类似的复制机制。你不能创建一个类的多个实例,只能创建
多个对象,它们 [[Prototype]] 关联的是同一个对象。但是在默认情况下并不会进行复制,
因此这些对象之间并不会完全失去联系,它们是互相关联的。
“构造函数”
function Foo() {
// ...
}
var a = new Foo();
到底是什么让我们认为 Foo 是一个“类”呢?
其中一个原因是我们看到了关键字 new,在面向类的语言中构造类实例时也会用到它。另
一个原因是,看起来我们执行了类的构造函数方法,Foo() 的调用方式很像初始化类时类
构造函数的调用方式。
实际上,Foo 和你程序中的其他函数没有任何区别。函数本身并不是构造函数,然而,当
你在普通的函数调用前面加上 new 关键字之后,就会把这个函数调用变成一个“构造函数
调用”。实际上,new 会劫持所有普通函数并用构造对象的形式来调用它。
换句话说,在 JavaScript 中对于“构造函数”最准确的解释是,所有带 new 的函数调用。
(原型)继承
我们已经看过了许多 JavaScript 程序中常用的模拟类行为的方法,但是如果没有“继承”
机制的话,JavaScript 中的类就只是一个空架子。
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
};
function Bar(name,label) {
Foo.call( this, name );
this.label = label;
}
// 我们创建了一个新的 Bar.prototype 对象并关联到 Foo.prototype
Bar.prototype = Object.create( Foo.prototype );
Bar.prototype.myLabel = function() {
return this.label;
};
var a = new Bar( "a", "obj a" );
a.myName(); // "a"
a.myLabel(); // "obj a"
这段代码的核心部分就是语句 Bar.prototype = Object.create( Foo.prototype )。调用
Object.create(..) 会凭空创建一个“新”对象并把新对象内部的 [[Prototype]] 关联到你
指定的对象(本例中是 Foo.prototype)。
两种把 Bar.prototype 关联到 Foo.prototype 的方法:
// ES6 之前需要抛弃默认的 Bar.prototype
Bar.ptototype = Object.create( Foo.prototype );
// ES6 开始可以直接修改现有的 Bar.prototype
Object.setPrototypeOf( Bar.prototype, Foo.prototype );
对象关联
我们已经明白了为什么 JavaScript 的 [[Prototype]] 机制和类不一样,也明白了它如何建立
对象间的关联。
那 [[Prototype]] 机制的意义是什么呢?为什么 JavaScript 开发者费这么大的力气(模拟
类)在代码中创建这些关联呢?
var foo = {
something: function() {
console.log( "Tell me something good..." );
}
};
var bar = Object.create( foo );
bar.something(); // Tell me something good...
Object.create(..) 会创建一个新对象(bar)并把它关联到我们指定的对象(foo),这样
我们就可以充分发挥 [[Prototype]] 机制的威力(委托)并且避免不必要的麻烦(比如使
用 new 的构造函数调用会生成 .prototype 和 .constructor 引用)
行为委托
在软件架构中你可以选择是否使用类和继承设计模式。大多数开发者理所当然地认为类是
唯一(合适)的代码组织方式,但是本章中我们看到了另一种更少见但是更强大的设计模
式:行为委托。
行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript 的
[[Prototype]] 机制本质上就是行为委托机制。
我们会通过一些示例(Foo、Bar)代码来比较一下两种设计模式(面向对象和对象关联)
具体的实现方法。
- 面向对象:
function Foo(who) {
this.me = who;
}
Foo.prototype.identify = function() {
return "I am " + this.me;
};
function Bar(who) {
Foo.call( this, who );
}
Bar.prototype = Object.create( Foo.prototype );
Bar.prototype.speak = function() {
alert( "Hello, " + this.identify() + "." );
};
var b1 = new Bar( "b1" );
var b2 = new Bar( "b2" );
b1.speak();
b2.speak();
- 对象关联
Foo = {
init: function(who) {
this.me = who;
},
identify: function() {
return "I am " + this.me;
}
};
Bar = Object.create( Foo );
Bar.speak = function() {
alert( "Hello, " + this.identify() + "." );
};
var b1 = Object.create( Bar );
b1.init( "b1" );
var b2 = Object.create( Bar );
b2.init( "b2" );
b1.speak();
b2.speak();
非常重要的一点是,这段代码简洁了许多,我们只是把对象关联起来,并不需要那些
既复杂又令人困惑的模仿类的行为(构造函数、原型以及 new)。
下面我们看看两段代码对应的思维模型。
首先,类风格代码的思维模型强调实体以及实体间的关系:
现在我们看看对象关联风格代码的思维模型:
让我们再来
- 在传统的类设计模式中,我们会把基础的函数定义在名为 Controller 的类中,然后派生两
个子类 LoginController 和 AuthController,它们都继承自 Controller 并且重写了一些基
础行为:
// 父类
function Controller() {
this.errors = [];
}
Controller.prototype.showDialog(title,msg) {
// 给用户显示标题和消息
};
Controller.prototype.success = function(msg) {
this.showDialog( "Success", msg );
};
Controller.prototype.failure = function(err) {
this.errors.push( err );
this.showDialog( "Error", err );
};
// 子类
function LoginController() {
Controller.call( this );
}
// 把子类关联到父类
LoginController.prototype =
Object.create( Controller.prototype );
LoginController.prototype.getUser = function() {
return document.getElementById( "login_username" ).value;
};
LoginController.prototype.getPassword = function() {
return document.getElementById( "login_password" ).value;
};
LoginController.prototype.validateEntry = function(user,pw) {
user = user || this.getUser();
pw = pw || this.getPassword();
if (!(user && pw)) {
return this.failure(
"Please enter a username & password!"
);
}
else if (user.length < 5) {
return this.failure(
"Password must be 5+ characters!"
);
}
// 如果执行到这里说明通过验证
return true;
};
// 重写基础的 failure()
LoginController.prototype.failure = function(err) {
// “super”调用
Controller.prototype.failure.call(
this,
"Login invalid: " + err
);
};
// 子类
function AuthController(login) {
Controller.call( this );
// 合成
this.login = login;
}
// 把子类关联到父类
AuthController.prototype =
Object.create( Controller.prototype );
AuthController.prototype.server = function(url,data) {
return $.ajax( {
url: url,
data: data
} );
};
AuthController.prototype.checkAuth = function() {
var user = this.login.getUser();
var pw = this.login.getPassword();
if (this.login.validateEntry( user, pw )) {
this.server( "/check-auth",{
user: user,
pw: pw
} )
.then( this.success.bind( this ) )
.fail( this.failure.bind( this ) );
}
};
// 重写基础的 success()
AuthController.prototype.success = function() {
// “super”调用
Controller.prototype.success.call( this, "Authenticated!" );
};
// 重写基础的 failure()
AuthController.prototype.failure = function(err) {
// “super”调用
Controller.prototype.failure.call(
this,
"Auth Failed: " + err
);
};
var auth = new AuthController();
auth.checkAuth(
// 除了继承,我们还需要合成
new LoginController()
);
所 有 控 制 器 共 享 的 基 础 行 为 是 success(..)、failure(..) 和 showDialog(..)。 子 类
LoginController 和 AuthController 通过重写 failure(..) 和 success(..) 来扩展默认基础
类行为。此外,注意 AuthController 需要一个 LoginController 的实例来和登录表单进行
交互,因此这个实例变成了一个数据属性。
另一个需要注意的是我们在继承的基础上进行了一些合成。AuthController 需要使用
LoginController,因此我们实例化后者(new LoginController())并用一个类成员属性
this.login 来引用它,这样 AuthController 就可以调用 LoginController 的行为。
- 但是,我们真的需要用一个 Controller 父类、两个子类加上合成来对这个问题进行建模
吗?能不能使用对象关联风格的行为委托来实现更简单的设计呢?当然可以!
var LoginController = {
errors: [],
getUser: function() {
return document.getElementById(
"login_username"
).value;
},
getPassword: function() {
return document.getElementById(
"login_password"
).value;
},
validateEntry: function(user,pw) {
user = user || this.getUser();
pw = pw || this.getPassword();
if (!(user && pw)) {
return this.failure(
"Please enter a username & password!"
);
}
else if (user.length < 5) {
return this.failure(
"Password must be 5+ characters!"
);
}
// 如果执行到这里说明通过验证
return true;
},
showDialog: function(title,msg) {
// 给用户显示标题和消息
},
failure: function(err) {
this.errors.push( err );
this.showDialog( "Error", "Login invalid: " + err );
}
};
// 让 AuthController 委托 LoginController
var AuthController = Object.create( LoginController );
AuthController.errors = [];
AuthController.checkAuth = function() {
var user = this.getUser();
var pw = this.getPassword();
if (this.validateEntry( user, pw )) {
this.server( "/check-auth",{
user: user,
pw: pw
} )
.then( this.accepted.bind( this ) )
.fail( this.rejected.bind( this ) );
}
};
AuthController.server = function(url,data) {
return $.ajax( {
url: url,
data: data
});
};
AuthController.accepted = function() {
this.showDialog( "Success", "Authenticated!" )
};
AuthController.rejected = function(err) {
this.failure( "Auth Failed: " + err );
};
由于 AuthController 只是一个对象(LoginController 也一样),因此我们不需要实例化
(比如 new AuthController()),只需要一行代码就行:
AuthController.checkAuth();
借助对象关联,你可以简单地向委托链上添加一个或多个对象,而且同样不需要实例化:
var controller1 = Object.create( AuthController );
var controller2 = Object.create( AuthController );
在行为委托模式中,AuthController 和 LoginController 只是对象,它们之间是兄弟关系,
并不是父类和子类的关系。代码中 AuthController 委托了 LoginController,反向委托也
完全没问题。
这种模式的重点在于只需要两个实体(LoginController 和 AuthController),而之前的模
式需要三个。