《你必须知道的javascript(上)》- 2.this与对象原型
1 关于this
1.1 为什么使用this
随着你的使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用this
则不会这样。当我们介绍对象和原型时,你就会明白函数可以自动引用合适的上下文对象有多重要。
1.2 关于误解
首先需要消除一些关于this的错误认识。
1.2.1 指向自身
先来看个例子:
function foo(num) {
console.log("foo: " + num);
// 记录foo被调用的次数,这里this指向window
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?
执行foo.count = 0
时,的确向函数对象foo添加了一个属性count。但是函数内部代码this.count
中的this并不是指向那个函数对象(指向全局变量 window),所以虽然属性名相同,根对象却并不相同,困惑随之产生。
如果要从函数对象内部引用它自身,那只使用this是不够的。一般来说你需要通过一个指向函数对象的词法标识符(变量)来引用它。
解决方法一:
一种解决方法是用词法作用域:
function foo(num) {
console.log( "foo: " + num );
// 记录foo被调用的次数
data.count++;
}
var data = {
count: 0
};
解决方法二:
另一种解决方法是使用foo标识符替代this来引用函数对象:
function foo(num) {
console.log( "foo: " + num );
// 记录foo被调用的次数
foo.count++;
}
foo.count=0;
然而,这种方法同样回避了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
1.2.2 它的作用域
第二种常见的误解是,this指向函数的作用域。这个问题有点复杂,因为在某种情况下它是正确的,但是在其他情况下它却是错误的。
this在任何情况下都不指向函数的词法作用域。在JavaScript内部,作用域确实和对象类似,可见的标识符都是它的属性。但是作用域“对象”无法通过JavaScript代码访问,它存在于JavaScript引擎内部。
function foo() {
var a = 2;
this.bar();
}
function bar() {
console.log(this.a);
}
foo(); // ReferenceError: a is not defined
编写这段代码的开发者还试图使用this联通foo()和bar()的词法作用域,从而让bar()可以访问foo()作用域里的变量a。这是不可能实现的,你不能使用this来引用一个词法作用域内部的东西。
每当你想要把this
和词法作用域
的查找混合使用时,一定要提醒自己,这是无法实现的。
1.3 this到底是什么
之前我们说过this是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this就是记录的其中一个属性,会在函数执行的过程中用到。
this
实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。
2 this全面解析
2.1 调用位置
最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的调用位置就在当前正在执行的函数的前一个调用中。
function baz() {
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域
console.log("baz"+this.text);
bar(); // <-- bar的调用位置
}
function bar() {
// 当前调用栈是baz -> bar
// 因此,当前调用位置在baz中
console.log("bar"+this.text);
foo(); // <-- foo的调用位置
}
function foo() {
debugger;
// 当前调用栈是baz -> bar -> foo
// 因此,当前调用位置在bar中
console.log("foo"+this.text);
}
baz(); // <-- baz的调用位置
2.2 绑定规则
2.2.1 默认绑定
function foo() {
console.log(this.a);
}
var a = 2;
foo(); // 2
函数调用时应用了this的默认绑定,因此this指向全局对象。
那么我们怎么知道这里应用了默认绑定呢?可以通过分析调用位置来看看foo()是如何调用的。在代码中,foo()是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。
2.2.2 隐式绑定
function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
无论你如何称呼这个模式,当foo()被调用时,它的落脚点确实指向obj对象。
当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this
绑定到这个上下文对象。因为调用foo()时this被绑定到obj,因此this.a和obj.a是一样的。
隐式丢失
function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名! bar这里调用的是全局foo()
var a = "oops, global"; // a是全局对象的属性
bar(); // "oops, global"
虽然bar是obj.foo的一个引用,但是实际上,它引用的是foo函数本身(obj.foo只是引用,没有执行调用),因此此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定。
2.2.3 显式绑定
不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?
具体点说,可以使用函数的
call(..)
和apply(..)
方法。它们的第一个参数是一个对象,它们会把这个对象绑定到this,接着在调用函数时指定这个this。因为你可以直接指定this的绑定对象,因此我们称之为显式绑定。
可惜,显式绑定仍然无法解决我们之前提出的丢失绑定问题。
硬编码 bind
但是显式绑定的一个变种可以解决这个问题。
硬绑定的典型应用场景就是创建一个包裹函数,传入所有的参数并返回接收到的所有值:
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 ); //这里内置的 Function.prototype.bind
var b = bar( 3 ); // 2 3
console.log( b ); // 5
API调用“上下文”
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绑定
在JavaScript中,构造函数只是一些使用new操作符时被调用的函数。
实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
(1) 创建一个新对象;
(2) 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象);
(3) 执行构造函数中的代码(为这个新对象添加属性);
(4) 返回新对象。
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四条规则的优先级,默认绑定最低。
隐式绑定 VS 显式绑定
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
当我们使用call(obj2)显式绑定时,输出的值为obj2的值(a=3),所以显式绑定的优先级更高。
new绑定 VS 隐式绑定
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
bar.a //4
可以看到new绑定比隐式绑定优先级高.
new绑定 VS 显示绑定
new和call/apply无法一起使用,因此无法通过new foo.call(obj1)来直接进行测试。但是我们可以使用硬绑定来测试它俩的优先级。
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
console.log(baz.a); // 3
出乎意料!bar被硬绑定到obj1上,但是new bar(3)并没有像我们预计的那样把obj1.a 修改为3。相反,new修改了硬绑定(到obj1的)调用bar(..)中的this。因为使用了new绑定,我们得到了一个名字为baz的新对象,并且baz.a的值是3。
总结
new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定
判断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()
2.5 this词法
箭头函数并不是使用function关键字定义的,而是使用被称为“胖箭头”的操作符=>定义的。箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)作用域来决定this。
箭头函数的绑定无法被修改。(new也不行!)
function foo() {
// 返回一个箭头函数
return (a) => {
//this继承自foo()
console.log(this.a);
};
}
var obj1 = {
a: 2
};
var obj2 = {
a: 3
};
var bar = foo.call(obj1);
bar.call(obj2); // 2, 不是3!
其重要性还体现在它用更常见的词法作用域取代了传统的this机制。实际上,在ES6之前我们就已经
在使用一种几乎和箭头函数完全一样的模式。
虽然self = this和箭头函数看起来都可以取代bind(..),但是从本质上来说,它们想替代的是this机制。
如果你经常编写this风格的代码,但是绝大部分时候都会使用self = this或者箭头函数来否定this机制,那你或许应当:
- 只使用词法作用域并完全抛弃错误this风格的代码;
- 完全采用this风格,在必要时使用bind(..),尽量避免使用self = this和箭头函数。
当然,包含这两种代码风格的程序可以正常运行,但是在同一个函数或者同一个程序中混合使用这两种风格通常会使代码更难维护,并且可能也会更难编写。
3 对象
3.1 语法
文字语法
var myObj = {
key: value
// ...
};
构造形式
var myObj = new Object();
myObj.key = value;
3.2 类型
基本类型
JavaScript中一共有六种主要类型
• string
• number
• boolean
• null
• undefined
• object
前面5个是基本类型。
typeof null
时会返回字符串"object"(这是语言bug)。null
本身是基本类型。
复杂基本类型
JavaScript中有许多特殊的对象子类型,我们可以称之为复杂基本类型。
函数就是对象的一个子类型(从技术角度来说就是“可调用的对象”)。JavaScript中的函数是“一等公民”,因为它们本质上和普通的对象一样(只是可以调用),所以可以像操作其他对象一样操作函数(比如当作另一个函数的参数)。
数组也是对象的一种类型,具备一些额外的行为。数组中内容的组织方式比一般的对象要稍微复杂一些。
内置对象
JavaScript中还有一些对象子类型,通常被称为内置对象。
• String
• Number
• Boolean
• Object
• Function
• Array
• Date
• RegExp
• Error
这些内置函数可以当作构造函数(由new产生的函数调用——参见第2章)来使用,从而可以构造一个对应子类型的新对象。举例来说:
var strPrimitive = "I am a string";
typeof strPrimitive; // "string"
strPrimitive instanceof String; // false ,没有new
var strObject = new String( "I am a string" );
typeof strObject; // "object"
strObject instanceof String; // true
// 检查sub-type对象
Object.prototype.toString.call( strObject ); // [object String]
原始值"I am a string"并不是一个对象,它只是一个字面量,在必要时语言会自动把字符串字面量转换成一个String对象,也就是说你并不需要显式创建一个对象。JavaScript社区中的大多数人都认为能使用文字形式时就不要使用构造形式。
var strPrimitive = "I am a string";
console.log( strPrimitive.length ); // 13
console.log( strPrimitive.charAt( 3 ) ); // "m"
引擎自动把字面量转换成String对象,所以可以访问属性和方法。number
与boolean
同样如此。
null
和undefined
没有对应的构造形式,它们只有文字形式。相反,Date
只有构造,没有文字形式。
3.3 内容
“内容”:存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度来说就是引用)一样,指向这些值真正的存储位置。
var myObject = {
a: 2
};
myObject.a; // 2
myObject["a"]; // 2
如果要访问myObject中a位置上的值,我们需要使用.操作符或者[]操作符。.a语法通常被称为“属性访问”,["a"]语法通常被称为“键访问”。实际上它们访问的是同一个位置,并且会返回相同的值2,所以这两个术语是可以互换的。
在对象中,属性名永远都是字符串。如果你使用string(字面量)以外的其他值作为属性名,那它首先会被转换为一个字符串。即使是数字也不例外,
3.3.1 可计算属性名(ES6)
可以在文字形式中使用[]包裹一个表达式来当作属性名:
var prefix = "foo";
var myObject = {
[prefix + "bar"]:"hello",
[prefix + "baz"]: "world"
};
myObject["foobar"]; // hello
myObject["foobaz"]; // world
3.3.2 属性与方法
有些函数具有this引用,有时候这些this确实会指向调用位置的对象引用。但是这种用法从本质上来说并没有把一个函数变成一个“方法”,因为this是在运行时根据调用位置动态绑定的,所以函数和对象的关系最多也只能说是间接关系。
无论返回值是什么类型,每次访问对象的属性就是属性访问。如果属性访问返回的是一个函数,那它也并不是一个“方法”。属性访问返回的函数和其他函数没有任何区别(除了可能发生的隐式绑定this,就像我们刚才提到的)。
function foo() {
console.log(this + "foo");
}
var someFoo = foo; // 对foo的变量引用
var myObject = {
someFoo: foo
};
foo(); // function foo(){..}
someFoo(); // function foo(){..}
myObject.someFoo(); // function foo(){..} 这里的this隐式绑定object
//无论哪种引用形式都不能称之为“方法”。
3.3.3 数组
数组也支持[]访问形式,数组期望的是数值下标,也就是说值存储的位置(通常被称为索引)是整数。
数组也是对象,所以虽然每个下标都是整数,你仍然可以给数组添加属性:
var myArray = [ "foo", 42, "bar" ];
myArray.length; // 3
myArray.baz = "baz";
myArray.caz = "caz";
myArray.length; // 3
myArray.baz; // "baz"
虽然添加了命名属性,数组的length值并未发生变化。
所以最好只用对象来存储键/值
对,只用数组来存储数值下标/值
对。
注意:如果你试图向数组添加一个属性,但是属性名“看起来”像一个数字,那它会变成一个数值下标(因此会修改数组的内容而不是添加一个属性):
var myArray = [ "foo", 42, "bar" ]; myArray["3"] = "baz"; myArray.length; // 4 myArray[3]; // "baz"
3.3.4 复制对象
function anotherFunction() { /*..*/ }
var anotherObject = {
c: true
};
var anotherArray = [];
var myObject = {
a: 2,
b: anotherObject, // 引用,不是复本!
c: anotherArray, // 另一个引用!
d: anotherFunction
};
anotherArray.push( anotherObject, myObject );
首先,我们应该判断它是浅复制还是深复制。
浅拷贝:复制出的新对象中a的值会复制旧对象中a的值,也就是2,但是新对象中b、c、d三个属性其实只是三个引用,它们和旧对象中b、c、d引用的对象是一样的。
深拷贝:除了复制myObject以外还会复制anotherObject和anotherArray。anotherArray引用了anotherObject和myObject,所以又需要复制myObject,这样就会由于循环引用导致死循环。
疑问:
1.我们是应该检测循环引用并终止循环(不复制深层元素)?
2.还是应当直接报错或者是选择其他方法?
JSON安全:
对于JSON安全(也就是说可以被序列化为一个JSON字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的复制方法:
var newObj = JSON.parse( JSON.stringify( someObj ) );
这种方法需要保证对象是JSON安全的,所以只适用于部分情况。
ES6的浅复制 Object.assign(..)
assign:赋值
浅复制非常易懂并且问题要少得多,所以ES6定义了Object.assign(..)
方法来实现浅复制。
Object.assign(..)
方法的第一个参数是目标对象,之后还可以跟一个或多个源对象。它会遍历一个或多个源对象的所有可枚举(enumerable,参见下面的代码)的自有键(owned key,很快会介绍)并把它们复制(使用=操作符赋值)到目标对象,最后返回目标对象,
var newObj = Object.assign( {}, myObject );
newObj.a; // 2
newObj.b === anotherObject; // true
newObj.c === anotherArray; // true
newObj.d === anotherFunction; // true
3.3.5 属性描述符
在ES5之前,JavaScript语言本身并没有提供可以直接检测属性特性的方法,比如判断属性是否是只读。
Descriptor:描述符号
var myObject = {
a:2
};
Object.getOwnPropertyDescriptor( myObject, "a" );
// {
// value: 2,
// writable: true,
// enumerable: true,
// configurable: true
// }
它还包含另外三个特性:writable(可写)、enumerable(可枚举)和configurable(可配置)。
我们使用defineProperty(..)
给myObject添加了一个普通的属性并显式指定了一些特性。然而,一般来说你不会使用这种方式,除非你想修改属性描述符
var myObject = {};
Object.defineProperty( myObject, "a", {
value: 2,
writable: true,
configurable: true,
enumerable: true
} );
myObject.a; // 2
3.3.6 不变性
很重要的一点是,所有的方法创建的都是浅不变形,也就是说,它们只会影响目标对象和它的直接属性。如果目标对象引用了其他对象(数组、对象、函数,等),其他对象的内容不受影响,仍然是可变的:
myImmutableObject.foo; // [1,2,3]
myImmutableObject.foo.push( 4 );
myImmutableObject.foo; // [1,2,3,4]
假设代码中的myImmutableObject
已经被创建而且是不可变的,但是为了保护它的内容myImmutableObject.foo,你还需要使用下面的方法让foo也不可变。
1 对象常量 writable:false 与 configurable:false
结合writable:false
和configurable:false
就可以创建一个真正的常量属性(不可修改、重定义或者删除):
var myObject = {};
Object.defineProperty( myObject, "FAVORITE_NUMBER", {
value: 42,
writable: false,
configurable: false
} );
2 禁止扩展 Object.preventExtensions(..)
Object.preventExtensions() 方法让一个对象变的不可扩展,也就是永远不能再添加新的属性。
如果你想禁止一个对象添加新属性并且保留已有属性,可以使用Object.preventExtensions(..)
:
var myObject = {
a:2
};
Object.preventExtensions( myObject );
myObject.b = 3;
myObject.b; // undefined
3 密封 Object.seal(..)
Object.seal(..)
会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用Object.preventExtensions(..)
并把所有现有属性标记为configurable:false
。所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)。
4 冻结 Object.freeze(..)
Object.freeze(..)
会创建一个冻结对象,这个方法实际上会在一个现有对象上调用Object.seal(..)
并把所有“数据访问”属性标记为writable:false
,这样就无法修改它们的值。
(不过就像我们之前说过的,这个对象引用的其他对象是不受影响的,可以使用“深度冻结”)。
总结:冻结 > 密封 > 禁止扩展 > 对象常量
对象常量:可以再添加新的属性
禁止扩展:不能再添加新的属性
密封:不能再添加新的属性,不能重新配置或者删除任何现有属性
冻结:不能再添加新的属性,不能重新配置或者删除任何现有属性,不能修改它们的值
3.3.7 [[Get]]
var myObject = {
a: 2
};
myObject.a; // 2
myObject.a在myObject上实际上是实现了[[Get]]操作。然而,如果没有找到名称相同的属性,按照[[Get]]算法的定义会执行另外一种非常重要的行为。(其实就是遍历可能存在的[[Prototype]]链,也就是原型链)
如果无论如何都没有找到名称相同的属性,那[[Get]]操作会返回值undefined:
var myObject = {
a:2
};
myObject.b; // undefined
注意,这种方法和访问变量时是不一样的。
如果你引用了一个当前词法作用域中不存在的变量,并不会像对象属性一样返回undefined
,而是会抛出一个ReferenceError异常:
3.3.8 [[Put]]
你可能会认为给对象的属性赋值会触发[[Put]]来设置或者创建这个属性。但是实际情况并不完全是这样。
[[Put]]被触发时,实际的行为取决于许多因素,包括对象中是否已经存在这个属性(这是最重要的因素)。
对象存在
- 属性是否是访问描述符(参见3.3.9节)?如果是并且存在setter就调用setter。
- 属性的数据描述符中writable是否是false?如果是,在非严格模式下静默失败,在严格模式下抛出TypeError异常。
- 如果都不是,将该值设置为属性的值。
对象不存在
第5章讨论[[Prototype]]时详细进行介绍。
3.3.9 Getter 和 Setter
当你给一个属性定义getter、setter或者两者都有时,这个属性会被定义为“访问描述符”(和“数据描述符”相对)。对于访问描述符来说,JavaScript会忽略它们的value和writable特性,取而代之的是关心set和get(还有configurable和enumerable)特性。
var myObject = {
// 给a定义一个getter
get a() {
return 2;
}
};
Object.defineProperty(
myObject, // 目标对象
"b", // 属性名
{ // 描述符
// 给b设置一个getter
get: function(){ return this.a * 2 },
// 确保b会出现在对象的属性列表中
enumerable: true
}
);
myObject.a; // 2
myObject.b; // 4
3.3.10 存在性
前面我们介绍过,如myObject.a的属性访问返回值可能是undefined,但是这个值有可能是属性中存储的undefined,也可能是因为属性不存在所以返回undefined。那么如何区分这两种情况呢?
3.4 遍历
for..in循环可以用来遍历对象的可枚举属性列表(包括[[Prototype]]链)。但是如何遍历属性的值呢?
数组
for循环
var myArray = [1, 2, 3];
for (var i = 0; i < myArray.length; i++) {
console.log( myArray[i] );
}
// 1 2 3
实际上并不是在遍历值,而是遍历下标来指向值,如myArray[i]。
遍历数组下标时采用的是数字顺序(for循环或者其他迭代器),但是遍历对象属性时的顺序是不确定的.一定不要相信任何观察到的顺序,它们是不可靠的。
ES5中增加了一些数组的辅助迭代器,包括forEach(..)
、every(..)
和some(..)
。
forEach(..)会遍历数组中的所有值并忽略回调函数的返回值。
every(..)会一直运行直到回调函数返回false(或者“假”值)。
some(..)会一直运行直到回调函数返回true(或者“真”值)。
如何直接遍历值而不是数组下标(或者对象属性)呢?幸好,ES6增加了一种用来遍历数组的for..of循环语法:
var myArray = [ 1, 2, 3 ];
for (var v of myArray) {
console.log( v );
}
// 1
// 2
// 3
数组有内置的@@iterator
,因此for..of
可以直接应用在数组上。
4 混合对象“类”
面向类的设计模式:实例化(instantiation)、继承(inheritance)和(相对)多态(polymorphism)
4.1 类理论
4.1.1 “类”设计模式
如果你有函数式编程(比如Monad)的经验就会知道类也是非常常用的一种设计模式。但是对于其他人来说,这可能是第一次知道类并不是必须的编程基础,而是一种可选的代码抽象。
4.1.2 JavaScript中的“类”
ES6中新增了一些元素,比如class关键字
这是不是意味着JavaScript中实际上有类呢?简单来说:不是。
javascript中的类,是一种设计模式
4.2 类的机制
在许多面向类的语言中,“标准库”会提供Stack类,它是一种“栈”数据结构。但是在这些语言中,你实际上并不是直接操作Stack,Stack类仅仅是一个抽象的表示,它描述了所有“栈”需要做的事,但是它本身并不是一个“栈”你必须先实例化Stack类然后才能对它进行操作。
5 原型
5.1 [[Prototype]]
JavaScript中的对象有一个特殊的[[Prototype]]内置属性,其实就是对于其他对象的引用。几乎所有的对象在创建时[[Prototype]]属性都会被赋予一个非空的值。
var myObject = {
a:2
};
myObject.a; // 2
在第3章中我们说过,当你试图引用对象的属性时会触发[[Get]]操作,比如myObject.a。对于默认的[[Get]]操作来说,第一步是检查对象本身是否有这个属性,如果有的话就使用它。
但是如果a不在myObject中,就需要使用对象的[[Prototype]]链了。
var anotherObject = {
a:2
};
// 创建一个关联到anotherObject的对象
var myObject = Object.create( anotherObject );
myObject.a; // 2
但是,如果anotherObject中也找不到a并且[[Prototype]]链不为空的话,就会继续查找下去。这个过程会持续到找到匹配的属性名或者查找完整条[[Prototype]]链。如果是后者的话,[[Get]]操作的返回值是undefined。
使用for..in遍历对象时原理和查找[[Prototype]]链类似,任何可以通过原型链访问到(并且是enumerable,参见第3章)的属性都会被枚举。使用in操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(无论属性是否可枚举):
var anotherObject = {
a: 2
};
// 创建一个关联到anotherObject的对象
var myObject = Object.create(anotherObject);
// myObject.a; // 2
for (var k in myObject) {
//这里k,有"a"
console.log("found: " + k);
}
// found: a
("a" in myObject); // true
5.1.1 Object.prototype
所有普通的[[Prototype]]链最终都会指向内置的Object.prototype
。所以它包含JavaScript中许多通用的功能。比如说.toString()和.valueOf(),第3章还介绍过.hasOwnProperty(..)。稍后我们还会介绍.isPrototypeOf(..),这个你可能不太熟悉。
5.1.2 属性设置和屏蔽
给一个对象设置属性并不仅仅是添加一个新属性或者修改已有的属性值。
myObject.foo = "bar";
如果myObject对象中包含名为foo的普通数据访问属性,这条赋值语句只会修改已有的属性值。
如果foo不是直接存在于myObject中,[[Prototype]]链就会被遍历,类似[[Get]]操作。
如果原型链上找不到foo,foo就会被直接添加到myObject上。
如果属性名foo既出现在myObject中也出现在myObject的[[Prototype]]链上层,那么就会发生屏蔽。myObject中包含的foo属性会屏蔽原型链上层的所有foo属性,因为myObject.foo总是会选择原型链中最底层的foo属性。
下面我们分析一下如果foo不直接存在于myObject中而是存在于原型链上层时myObject.foo = "bar"会出现的三种情况。
- 如果在[[Prototype]]链上层存在名为foo的普通数据访问属性(参见第3章)并且没有被标记为只读(writable:false),那就会直接在myObject中添加一个名为foo的新属性,它是屏蔽属性。(只有这个会发生屏蔽)
- 如果在[[Prototype]]链上层存在foo,但是它被标记为只读(writable:false),那么无法修改已有属性或者在myObject上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
- 如果在[[Prototype]]链上层存在foo并且它是一个setter(参见第3章),那就一定会调用这个setter。foo不会被添加到(或者说屏蔽于)myObject,也不会重新定义foo这个setter。
如果你希望在第二种和第三种情况下也屏蔽foo,那就不能使用=操作符来赋值,而是使用Object.defineProperty(..)(参见第3章)来向myObject添加foo。
隐式产生屏蔽
var anotherObject = {
a: 2
};
var myObject = Object.create(anotherObject);
anotherObject.a; // 2
myObject.a; // 2
anotherObject.hasOwnProperty("a"); // true
myObject.hasOwnProperty("a"); // false
myObject.a++; // 隐式屏蔽!
anotherObject.a; // 2
myObject.a; // 3
myObject.hasOwnProperty("a"); // true
++操作相当于myObject.a = myObject.a + 1。
修改委托属性时一定要小心。如果想让anotherObject.a的值增加,唯一的办法是 anotherObject.a++。
5.2 “类”
再说一遍,JavaScript中只有对象。
5.2.1 “类”函数
Foo.prototype
这个对象是在调用new Foo()
(参见第2章)时创建的,最后会被(有点武断地)关联到这个“Foo点prototype”对象上。
function Foo() {
// ...
}
var a = new Foo();
Object.getPrototypeOf( a ) === Foo.prototype; // true
在面向类的语言中,实例化(或者继承)一个类就意味着“把类的行为复制到物理对象中”,对于每一个新实例来说都会重复这个过程。
但是在JavaScript中,并没有类似的复制机制。你不能创建一个类的多个实例,只能创建多个对象,它们[[Prototype]]关联的是同一个对象。但是在默认情况下并不会进行复制,因此这些对象之间并不会完全失去联系,它们是互相关联的。
实际上,绝大多数JavaScript开发者不知道的秘密是,new Foo()这个函数调用实际上并没有直接创建关联,这个关联只是一个意外的副作用。new Foo()只是间接完成了我们的目标:一个关联到其他对象的新对象。
那么有没有更直接的方法来做到这一点呢?当然!功臣就是Object.create(..)
。
关于名称
在JavaScript中,我们并不会将一个对象(“类”)复制到另一个对象(“实例”),只是将它们关联起来。从视觉角度来说,[[Prototype]]机制如下图所示,箭头从右到左,从下到上:
这个机制通常被称为原型继承。
容易混淆的组合术语“原型继承”(以及使用其他面向类的术语比如“类”、“构造函数”、“实例”、“多态”,等等)严重影响了大家对于JavaScript机制真实原理的理解。
继承意味着复制操作,JavaScript(默认)并不会复制对象属性。相反,JavaScript会在两
个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。委托(参见第6章)这个术语可以更加准确地描述JavaScript中对象的关联机制。
5.2.2 “构造函数”
function Foo() {
// ...
}
var a = new Foo();
到底是什么让我们认为Foo是一个“类”呢?其中一个原因是我们看到了关键字new。看起来我们执行了类的构造函数方法,Foo()的调用方式很像初始化类时类构造函数的调用方式。
Foo.prototype还有另一个绝招。
function Foo() {
// ...
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; // true
Foo.prototype默认(在代码中第一行声明时!)有一个公有并且不可枚举(参见第3章)的属性.constructor
,这个属性引用的是对象关联的函数(本例中是Foo)。此外,我们可以看到通过“构造函数”调用new Foo()创建的对象a也有一个a.constructor
属性,指向“创建这个对象的函数”。
1.构造函数还是调用
当你在普通的函数调用前面加上new关键字之后,就会把这个函数调用变成一个“构造函数调用”。实际上,new会劫持所有普通函数并用构造对象的形式来调用它。
function NothingSpecial() {
console.log( "Don't mind me!" );
}
var a = new NothingSpecial();
// "Don't mind me!"
a; // {}
NothingSpecial
只是一个普通的函数,但是使用new调用时,它就会构造一个对象并赋值给a,这看起来像是new的一个副作用(无论如何都会构造一个对象)。但是NothingSpecial本身并不是一个构造函数。
在JavaScript中对于“构造函数”最准确的解释是,所有带new的函数调用。
函数不是构造函数,但是当且仅当使用new时,函数调用会变成“构造函数调用”。
5.2.3 技术
回顾“构造函数”
之前讨论.constructor属性时我们说过,看起来a.constructor === Foo为真意味着a确实有一个指向Foo的.constructor属性,但是事实不是这样。
把.constructor属性指向Foo看作是a对象由Foo“构造”非常容易理解,但这只不过是一种虚假的安全感。a.constructor只是通过默认的[[Prototype]]委托指向Foo,这和构造”毫无关系。相反,对于.constructor的错误理解很容易对你自己产生误导。
举例来说,Foo.prototype的.constructor属性只是Foo函数在声明时的默认属性。如果你创建了一个新对象并替换了函数默认的.prototype对象引用,那么新对象并不会自动获得.constructor属性。
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新原型对象
var a1 = new Foo(); //Object(..)并没有“构造”a1
a1.constructor === Foo; // false!
a1.constructor === Object; // true!
Object(..)并没有“构造”a1,对吧?看起来应该是Foo()“构造”了它。如果你认为“constructor”表示“由……构造”的话,a1.constructor应该是Foo,但是它并不是Foo!
a1并没有.constructor属性,所以它会委托[[Prototype]]链上的Foo.prototype。但是这个对象也没有.constructor属性(不过默认的Foo.prototype对象有这个属性!),所以它会继续委托,这次会委托给委托链顶端的Object.prototype。这个对象有.constructor属性,指向内置的Object(..)函数。
constructor并不表示被构造
实际上,对象的.constructor会默认指向一个函数,这个函数可以通过对象的.prototype引用。“constructor”和“prototype”这两个词本身的含义可能适用也可能不适用。最好的办法是记住这一点“constructor并不表示被构造”。
结论
a1.constructor是一个非常不可靠并且不安全的引用。通常来说要尽量避免使用这些引用。
5.3 (原型)继承
还记得这张图吗,它不仅展示出对象(实例)a1到Foo.prototype的委托关系,还展示出Bar.prototype到Foo.prototype的委托关系,而后者和类继承很相似,只有箭头的方向不同。图中由下到上的箭头表明这是委托关联,不是复制操作。
function Foo(name) {
this.name = name; //调用位置:Foo.call(this, name); this绑定:2.call绑定,this为 指定的对象,这里的指定对象为 Bar 对象
}
Foo.prototype.myName = function () {
return this.name; //调用位置:a.myName(); this绑定:3.上下文对象:a
};
function Bar(name, label) {
Foo.call(this, name); //调用位置:new Bar("a", "obj a"); this绑定:1.new绑定,this为 Bar 对象
this.label = label;
}
// 我们创建了一个新的Bar.prototype对象并关联到Foo.prototype
Bar.prototype = Object.create(Foo.prototype);
// 注意!现在没有Bar.prototype.constructor了
// 如果你需要这个属性的话可能需要手动修复一下它
Bar.prototype.myLabel = function () {
return this.label; //调用位置:a.myLabel(); this绑定:3.上下文对象:a
};
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”。
注意,下面这两种方式是常见的错误做法,实际上它们都存在一些问题:
// 和你想要的机制不一样!
Bar.prototype = Foo.prototype;
// 基本上满足你的需求,但是可能会产生一些副作用 :( 上面这里会多个name:undefined
Bar.prototype = new Foo();
ES6 的方法 Object.setPrototypeOf(..)
如果能有一个标准并且可靠的方法来修改对象的[[Prototype]]关联就好了。在ES6之前,我们只能通过设置.__proto__
属性来实现,但是这个方法并不是标准并且无法兼容所有浏览器。ES6添加了辅助函数Object.setPrototypeOf(..)
,可以用标准并且可靠的方法来修改关联。
// ES6之前需要抛弃默认的Bar.prototype
Bar.ptototype = Object.create( Foo.prototype );
// ES6开始可以直接修改现有的Bar.prototype
Object.setPrototypeOf( Bar.prototype, Foo.prototype );
检查“类”关系
假设有对象a,如何寻找对象a委托的对象(如果存在的话)呢?
检查一个实例(JavaScript中的对象)的继承祖先(JavaScript中的委托关联)通常被称为内省(或者反射)。
function Foo() {
// ...
}
Foo.prototype.blah = ...;
var a = new Foo();
- 第一种,方法是站在“类”的角度来判断(不准确):
a instanceof Foo; // true
instanceof:在a的整条[[Prototype]]链中是否有指向Foo.prototype的对象?
这个方法只能处理对象(a)和函数(带.prototype引用的Foo)之间的关系。只用instanceof无法实现。
- 第二种,判断[[Prototype]]反射的方法(正确),它更加简洁:
Foo.prototype.isPrototypeOf( a ); // true
在本例中,我们实际上并不关心(甚至不需要)Foo,我们只需要一个可以用来判断的对象(本例中是Foo.prototype)就行。
isPrototypeOf(..):在a的整条[[Prototype]]链中是否出现过Foo.prototype?
我们只需要两个对象就可以判断它们之间的关系。举例来说:
// 非常简单:b是否出现在c的[[Prototype]]链中?
b.isPrototypeOf( c );
Object.getPrototypeOf(...)
与 __proto__
我们也可以直接获取一个对象的[[Prototype]]链。在ES5中,标准的方法是:
Object.getPrototypeOf( a );
可以验证一下,这个对象引用是否和我们想的一样:
Object.getPrototypeOf( a ) === Foo.prototype; // true
绝大多数(不是所有!)浏览器也支持一种非标准的方法来访问内部[[Prototype]]属性:
a.__proto__ === Foo.prototype; // true
这个奇怪的.__proto__
(在ES6之前并不是标准!)属性“神奇地”引用了内部的[[Prototype]]对象,如果你想直接查找(甚至可以通过.__proto__.__ptoto__
...来遍历)原型链的话,这个方法非常有用。
和我们之前说过的.constructor一样,.__proto__
实际上并不存在于你正在使用的对象中(本例中是a)。实际上,它和其他的常用函数(.toString()、.isPrototypeOf(..),等等)样,存在于内置的Object.prototype中。(它们是不可枚举的,参见第2章。)此外,.__proto__
看起来很像一个属性,但是实际上它更像一个getter/setter(参见第3章)。
.__proto__
的实现大致上是这样的(对象属性的定义参见第3章):
Object.defineProperty( Object.prototype, "__proto__", {
get: function() {
return Object.getPrototypeOf( this );
},
set: function(o) {
// ES6中的setPrototypeOf(..)
Object.setPrototypeOf( this, o );
return o;
}
} );
因此,访问(获取值)a.__proto__
时,实际上是调用了a.__proto__()
(调用getter函数)。虽然getter函数存在于Object.prototype对象中,但是它的this指向对象a(this的绑定规则参见第2章),所以和Object.getPrototypeOf( a )结果相同。
.__proto__
是可设置属性,之前的代码中使用ES6的Object.setPrototypeOf(..)进行设置。然而,通常来说你不需要修改已有对象的[[Prototype]]。
我们只有在一些特殊情况下(我们前面讨论过)需要设置函数默认.prototype对象的[[Prototype]],让它引用其他对象(除了Object.prototype)。这样可以避免使用全新的对象替换默认对象。此外,最好把[[Prototype]]对象关联看作是只读特性,从而增加代码的可读性。
5.4 对象关联
现在我们知道了,[[Prototype]]机制就是存在于对象中的一个内部链接,它会引用其他对象。
原型链:通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。
5.4.1 创建关联
那[[Prototype]]机制的意义是什么呢?
本章前面曾经说过Object.create(..)是一个大英雄,现在是时候来弄明白为什么了:
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引用)。
我们并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。而Object.create(..)不包含任何“类的诡计”,所以它可以完美地创建我们想要的关联关系。
5.4.2 关联关系是备用
var anotherObject = {
cool: function() {
console.log( "cool!" );
}
};
var myObject = Object.create( anotherObject );
myObject.cool(); // "cool!"
但是如果你这样写只是为了让myObject在无法处理属性或者方法时可以使用备用的anotherObject,那么你的软件就会变得有点“神奇”,而且很难理解和维护。
当你给开发者设计软件时,假设要调用myObject.cool(),如果myObject中不存在cool()时这条语句也可以正常工作的话,那你的API设计就会变得很“神奇”,对于未来维护你软件的开发者来说这可能不太好理解。
但是你可以让你的API设计不那么“神奇”,同时仍然能发挥[[Prototype]]关联的威力:
var anotherObject = {
cool: function() {
console.log( "cool!" );
}
};
var myObject = Object.create( anotherObject );
myObject.doCool = function() {
this.cool(); // 内部委托! 这里this绑定myObject
};
myObject.doCool(); // "cool!"
这里我们调用的myObject.doCool()是实际存在于myObject中的,这可以让我们的API设计更加清晰(不那么“神奇”)。从内部来说,我们的实现遵循的是委托设计模式(参见第6章),通过[[Prototype]]委托到anotherObject.cool()。
内部委托比起直接委托可以让API接口设计更加清晰。
6 行为委托
首先简单回顾一下第5章的结论:[[Prototype]]机制就是指对象中的一个内部链接引用另一个对象。
如果在第一个对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”
换句话说,JavaScript中这个机制的本质就是对象之间的关联关系。
6.1 面向委托的设计
为了更好地学习如何更直观地使用[[Prototype]],我们必须认识到它代表的是一种不同于类(参见第4章)的设计模式。
我们需要试着把思路从类和继承的设计模式转换到委托行为的设计模式。
6.1.2 委托理论
Task = {
setID: function (ID) { this.id = ID; },
outputID: function () { console.log(this.id); }
};
// 让XYZ委托Task
XYZ = Object.create(Task);
XYZ.prepareTask = function (ID, Label) {
this.setID(ID);
this.label = Label;
};
XYZ.outputTaskDetails = function () {
this.outputID();
console.log(this.label);
};
XYZ.prepareTask(3, 100);
XYZ.outputTaskDetails();
相比于面向类(或者说面向对象),我会把这种编码风格称为“对象关联”(OLOO,objects linked to other objects)。
6.1.3 比较思维模型
现在你已经明白了“类”和“委托”这两种设计模式的理论区别:
下面是典型的(“原型”)面向对象风格:
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();
下面我们看看两段代码对应的思维模型。
首先,类风格代码的思维模型强调实体以及实体间的关系:
下面我们来看一张简化版的图,它更“清晰”一些——只展示了必要的对象和关系:
仍然很复杂,是吧?虚线表示的是Bar.prototype继承Foo.prototype之后丢失的.constructor属性引用(参见5.2.3节的“回顾‘构造函数’”部分),它们还没有被修复。即使移除这些虚线,这个思维模型在你处理对象关联时仍然非常复杂。
现在我们看看对象关联风格代码的思维模型:
通过比较可以看出,对象关联风格的代码显然更加简洁,因为这种代码只关注一件事:对象之间的关联关系。
其他的“类”技巧都是非常复杂并且令人困惑的。去掉它们之后,事情会变得简单许多(同时保留所有功能)。
6.5 内省
自省就是检查实例的类型。
1.instanceof
function Foo() {
// ...
}
Foo.prototype.something = function(){
// ...
}
var a1 = new Foo();
// 之后
if (a1 instanceof Foo) {
a1.something();
}
因为Foo.prototype(不是Foo!)在a1的[[Prototype]]链上(参见第5章),所以instanceof操作(会令人困惑地)告诉我们a1是Foo“类”的一个实例。知道了这点后,我们就可以认为a1有Foo“类”描述的功能。
从语法角度来说,instanceof似乎是检查a1和Foo的关系,但是实际上它想说的是a1和Foo.prototype(引用的对象)是互相关联的。
2.[[Prototype]]委托互相关联
var Foo = { /* .. */ };
var Bar = Object.create( Foo );
Bar...
var b1 = Object.create( Bar );
使用对象关联时,所有的对象都是通过[[Prototype]]委托互相关联,下面是内省的方法,非常简单:
// 让Foo和Bar互相关联
Foo.isPrototypeOf( Bar ); // true
Object.getPrototypeOf( Bar ) === Foo; // true
// 让b1关联到Foo和Bar
Foo.isPrototypeOf( b1 ); // true
Bar.isPrototypeOf( b1 ); // true
Object.getPrototypeOf( b1 ) === Bar; // true
我们没有使用instanceof,因为它会产生一些和类有关的误解。现在我们想问的问题是“你是我的原型吗?”我们并不需要使用间接的形式,比如Foo.prototype或者繁琐的Foo.prototype.isPrototypeOf(..)。
我觉得和之前的方法比起来,这种方法显然更加简洁并且清晰。再说一次,我们认为JavaScript中对象关联比类风格的代码更加简洁(而且功能相同)。