对象可以通过两种形式定义:声明(文字)形式和构造形式。
属性描述符
在ES5之前,JavaScript语言本身并没有提供可以直接检测属性特性的方法,比如判断属性是否是只读。但是从ES5开始,所有的属性都具备了属性描述符。
思考下面的代码:
var myObject = {
a:2
};
Object.getOwnPropertyDescriptor(myObject, "a");
// {
// value: 2,
// writable: true,
// enumerable: true,
// configurable: true
// }
如你所见,这个普通的对象属性对应的属性描述符可不仅仅只是一个2。它还包含另外三个特性:writable(可写)、enumerable(可枚举)和configurable(可配置)。
在创建普通属性时属性描述符会使用默认值,我们也可以使用Object.defineProperty(..)来添加一个新属性或者修改一个已有属性(如果它是configurable)并对特性进行设置。举例来说:
var myObject = {};
Object.defineProperty(myObject, "a", {
value: 2,
writable: true,
configurable: true,
enumerable: true
} );
myObject.a; // 2
我们使用defineProperty(..)给myObject添加了一个普通的属性并显式指定了一些特性。
1. Writable
writable决定是否可以修改属性的值。
思考下面的代码:
var myObject = {};
Object.defineProperty(myObject, "a", {
value: 2,
writable: false, // 不可写!
configurable: true,
enumerable: true
} );
myObject.a = 3;
myObject.a; // 2
如你所见,我们对于属性值的修改静默失败了。如果在严格模式下会抛出一个TypeError。
2.Configurable
只要属性是可配置的,就可以使用defineProperty(..)方法来修改属性描述符:
var myObject = {
a:2
};
myObject.a = 3;
myObject.a; // 3
Object.defineProperty(myObject, "a", {
value: 4,
writable: true,
configurable: false, // 不可配置!
enumerable: true
} );
myObject.a; // 4
myObject.a = 5;
myObject.a; // 5
Object.defineProperty(myObject, "a", {
value: 6,
writable: true,
configurable: true,
enumerable: true
} ); // TypeError
最后一个defineProperty(..)会产生一个TypeError错误,不管是不是处于严格模式,尝试修改一个不可配置的属性描述符都会出错。注意:如你所见,把configurable修改成false是单向操作,无法撤销!
除了无法修改,configurable:false还会禁止删除这个属性。
var myObject = {
a:2
};
myObject.a; // 2
delete myObject.a;
myObject.a; // undefined
Object.defineProperty(myObject, "a", {
value: 2,
writable: true,
configurable: false,
enumerable: true
} );
myObject.a; // 2
delete myObject.a;
myObject.a; // 2
如你所见,最后一个delete语句(静默)失败了,因为属性是不可配置的。
3.Enumerable
这里我们要介绍的最后一个属性描述符是enumerable。
从名字就可以看出,这个描述符控制的是属性是否会出现在对象的属性枚举中,比如说for..in循环。如果把enumerable设置成false,这个属性就不会出现在枚举中,虽然仍然可以正常访问它。相对地,设置成true就会让它出现在枚举中。
var myObject = {};
Object.defineProperty(
myObject,
"a",
// 让a像普通属性一样可以枚举
{ enumerable: true, value: 2 }
);
Object.defineProperty(
myObject,
"b",
// 让b不可枚举
{ enumerable: false, value: 3 }
);
myObject.b; // 3
for (var k in myObject) {
console.log(k, myObject[k]);
}
// "a" 2
可以看到,myObject.b确实存在并且有访问值,但是却不会出现在for..in循环中。原因是“可枚举”就相当于“可以出现在对象属性的遍历中”。
也可以通过另一种方式来区分属性是否可枚举:
myObject.propertyIsEnumerable("a"); // true
myObject.propertyIsEnumerable("b"); // false
propertyIsEnumerable(..)会检查给定的属性名是否直接存在于对象中(而不是在原型链上)并且满足enumerable:true。
Getter和Setter
在ES5中可以使用getter和setter部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上。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
不管是对象文字语法中的get a() { .. },还是defineProperty(..)中的显式定义,二者都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏函数,它的返回值会被当作属性访问的返回值:
var myObject = {
// 给a定义一个getter
get a() {
return 2;
}
};
myObject.a = 3;
myObject.a; // 2
由于我们只定义了a的getter,所以对a的值进行设置时set操作会忽略赋值操作,不会抛出错误。而且即便有合法的setter,由于我们自定义的getter只会返回2,所以set操作是没有意义的。
为了让属性更合理,还应当定义setter,和你期望的一样,setter会覆盖单个属性默认的[[Put]](也被称为赋值)操作。
var myObject = {
// 给a定义一个getter
get a() {
return this.a ;
},
// 给a定义一个setter
set a(val) {
this.a = val * 2;
}
};
myObject.a = 2;
myObject.a; // 4
存在性
如myObject.a的属性访问返回值可能是undefined,但是这个值有可能是属性中存储的undefined,也可能是因为属性不存在所以返回undefined。那么如何区分这两种情况呢?
我们可以在不访问属性值的情况下判断对象中是否存在这个属性:
var myObject = {
a:2
};
("a" in myObject); // true
("b" in myObject); // false
myObject.hasOwnProperty("a"); // true
myObject.hasOwnProperty("b"); // false
Object.keys(myObject); // ["a"]
Object.getOwnPropertyNames(myObject); // ["a"]
in操作符会检查属性是否在对象及其[[Prototype]]原型链中。相比之下,hasOwnProperty(..)只会检查属性是否在myObject对象中,不会检查[[Prototype]]链。
所有的普通对象都可以通过对于Object.prototype的委托来访问hasOwnProperty(..),但是有的对象可能没有连接到Object.prototype(通过Object. create(null)来创建)。在这种情况下,形如myObejct.hasOwnProperty(..)就会失败。
这时可以使用一种更加强硬的方法来进行判断:Object.prototype.hasOwnProperty.call(myObject, "a"),它借用基础的hasOwnProperty(..)方法并把它显式绑定到myObject上。
Object.keys(..)会返回一个数组,包含所有可枚举属性,Object.getOwnPropertyNames(..)会返回一个数组,包含所有属性,无论它们是否可枚举。Object.keys(..)和Object.getOwnPropertyNames(..)都只会查找对象直接包含的属性。
原型
[[Prototype]]
JavaScript中的对象有一个特殊的[[Prototype]]内置属性,其实就是对于其他对象的引用。
[[Prototype]]引用有什么用呢?当你试图引用对象的属性时会触发[[Get]]操作。对于默认的[[Get]]操作来说,如果无法在对象本身找到需要的属性,就会继续访问对象的[[Prototype]]链:
var anotherObject = {
a:2
};
// 创建一个关联到anotherObject的对象
var myObject = Object.create(anotherObject);
myObject.a; // 2
现在myObject对象的[[Prototype]]关联到了anotherObject。显然myObject.a并不存在,但是尽管如此,属性访问仍然成功地(在anotherObject中)找到了值2。
但是,如果anotherObject中也找不到a并且[[Prototype]]链不为空的话,就会继续查找下去。
这个过程会持续到找到匹配的属性名或者查找完整条[[Prototype]]链。如果是后者的话,[[Get]]操作的返回值是undefined。
所有普通的[[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]]链上层,那么就会发生屏蔽。myObject中包含的foo属性会屏蔽原型链上层的所有foo属性,因为myObject.foo总是会选择原型链中最底层的foo属性。
下面我们分析一下如果foo不直接存在于myObject中而是存在于原型链上层时myObject.foo ="bar"会出现的三种情况:
1.如果在[[Prototype]]链上层存在名为foo的普通数据访问属性并且没有被标记为只读(writable:false),那就会直接在myObject中添加一个名为foo的新属性,它是屏蔽属性。
2.如果在[[Prototype]]链上层存在foo,但是它被标记为只读(writable:false),那么无法修改已有属性或者在myObject上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
3.如果在[[Prototype]]链上层存在foo并且它是一个setter,那就一定会调用这个setter。foo不会被添加到myObject,也不会重新定义foo这个setter。
大多数开发者都认为如果向[[Prototype]]链上层已经存在的属性([[Put]])赋值,就一定会触发屏蔽,但是如你所见,三种情况中只有一种(第一种)是这样的。
如果你希望在第二种和第三种情况下也屏蔽foo,那就不能使用=操作符来赋值,而是使用Object.defineProperty(..)来向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++看起来应该(通过委托)查找并增加anotherObject.a属性,但是别忘了++操作相当于myObject.a= myObject.a + 1。因此++操作首先会通过[[Prototype]]查找属性a并从anotherObject.a获取当前属性值2,然后给这个值加1,接着用[[Put]]将值3赋给myObject中新建的屏蔽属性a,
修改委托属性时一定要小心。如果想让anotherObject.a的值增加,唯一的办法是anotherObject.a++。
构造函数
所有的函数默认都会拥有一个名为prototype的公有并且不可枚举的属性,它会指向另一个对象:
function Foo() {
// ...
}
Foo.prototype; // { }
这个对象通常被称为Foo的原型,因为我们通过名为Foo.prototype的属性引用来访问它。
这个对象是在调用new Foo()时创建的,最后会被关联到这个“Foo.prototype”对象上。
我们来验证一下:
function Foo() {
// ...
}
var a = new Foo();
Object.getPrototypeOf(a) === Foo.prototype; // true
new Foo()会生成一个新对象,这个新对象的内部链接[[Prototype]]关联的是Foo.prototype对象。
最后我们得到了两个对象,它们之间互相关联,就是这样。
思考下面的代码:
function Foo() {
// ...
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; // true
Foo.prototype默认有一个公有并且不可枚举的属性.constructor,这个属性引用的是对象关联的函数Foo。此外,我们可以看到通过“构造函数”调用new Foo()创建的对象也有一个.constructor属性,指向“创建这个对象的函数”。
看起来a.constructor === Foo为真意味着a确实有一个指向Foo的.constructor属性,但是事实不是这样。这是一个很不幸的误解。实际上,.constructor引用同样被委托给了Foo.prototype,而Foo.prototype.constructor默认指向Foo。
把.constructor属性指向Foo看作是a对象由Foo“构造”非常容易理解,但这只不过是一种虚假的安全感。a.constructor只是通过默认的[[Prototype]]委托指向Foo,这和“构造”毫无关系。
举例来说,Foo.prototype的.constructor属性只是Foo函数在声明时的默认属性。如果你创建了一个新对象并替换了函数默认的.prototype对象引用,那么新对象并不会自动获得.constructor属性。
思考下面的代码:
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新原型对象
var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!
a1并没有.constructor属性,所以它会委托[[Prototype]]链上的Foo. prototype。但是这个对象也没有.constructor属性,所以它会继续委托,这次会委托给委托链顶端的Object.prototype。这个对象有.constructor属性,指向内置的Object(..)函数。
.constructor并不是一个不可变属性。它是不可枚举的,但是它的值是可写的。此外,你可以给任意[[Prototype]]链中的任意对象添加一个名为constructor的属性或者对其进行修改,你可以任意对其赋值。
a1.constructor是一个非常不可靠并且不安全的引用。通常来说要尽量避免使用这些引用。
原型继承
下面这段代码使用的就是典型的“原型风格”:
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.constructor了
// 如果你需要这个属性的话可能需要手动修复一下它
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”。
声明function Bar() { .. }时,和其他函数一样,Bar会有一个.prototype关联到默认的对象,但是这个对象并不是我们想要的Foo.prototype。因此我们创建了一个新对象并把它关联到我们希望的对象上,直接把原始的关联对象抛弃掉。
注意,下面这两种方式是常见的错误做法,实际上它们都存在一些问题:
// 和你想要的机制不一样!
Bar.prototype = Foo.prototype;
// 基本上满足你的需求,但是可能会产生一些副作用:(
Bar.prototype = new Foo();
Bar.prototype = Foo.prototype并不会创建一个关联到Bar.prototype的新对象,它只是让Bar.prototype直接引用Foo.prototype对象。因此当你执行类似Bar.prototype.myLabel = ...的赋值语句时会直接修改Foo.prototype对象本身。
Bar.prototype = new Foo()的确会创建一个关联到Bar.prototype的新对象。但是它使用了Foo(..)的“构造函数调用”,如果函数Foo有一些副作用(比如写日志、修改状态、注册到其他对象、给this添加数据属性,等等)的话,就会影响到Bar()的“后代”,后果不堪设想。
因此,要创建一个合适的关联对象,我们必须使用Object.create(..)而不是使用具有副作用的Foo(..)。这样做唯一的缺点就是需要创建一个新对象然后把旧对象抛弃掉,不能直接修改已有的默认对象。
如果能有一个标准并且可靠的方法来修改对象的[[Prototype]]关联就好了。在ES6之前,我们只能通过设置.__proto__属性来实现,但是这个方法并不是标准并且无法兼容所有浏览器。ES6添加了辅助函数Object.setPrototypeOf(..),可以用标准并且可靠的方法来修改关联。
我们来对比一下两种把Bar.prototype关联到Foo.prototype的方法:
// 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的“祖先”(委托关联)呢?第一种方法是站在“类”的角度来判断:
a instanceof Foo; // true
instanceof操作符的左操作数是一个普通的对象,右操作数是一个函数。instanceof回答的问题是:在a的整条[[Prototype]]链中是否有指向Foo.prototype的对象。
下面是第二种判断[[Prototype]]反射的方法,它更加简洁:
Foo.prototype.isPrototypeOf(a); // true
isPrototypeOf(..)回答的问题是:在a的整条[[Prototype]]链中是否出现过Foo.prototype。
同样的问题,同样的答案,但是在第二种方法中并不需要间接引用函数(Foo),它的.prototype属性会被自动访问。
我们只需要两个对象就可以判断它们之间的关系。举例来说:
// 非常简单:b是否出现在c的[[Prototype]]链中?
b.isPrototypeOf(c);
我们也可以直接获取一个对象的[[Prototype]]链。在ES5中,标准的方法是:
Object.getPrototypeOf(a);
可以验证一下,这个对象引用是否和我们想的一样:
Object.getPrototypeOf(a) === Foo.prototype; // true
绝大多数(不是所有!)浏览器也支持一种非标准的方法来访问内部[[Prototype]]属性:
a.__proto__ === Foo.prototype; // true
.__proto__看起来很像一个属性,但是实际上它更像一个getter/setter。 .__proto__的实现大致上是这样的:
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,所以和Object.getPrototypeOf(a)结果相同。
对象关联
[[Prototype]]机制就是存在于对象中的一个内部链接,它会引用其他对象。通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。
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)。
Object.create(null)会创建一个拥有空(或者说null)[[Prototype]]链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以instanceof操作符(之前解释过)无法进行判断,因此总是会返回false。这些特殊的空[[Prototype]]对象通常被称作“字典”,它们完全不会受到原型链的干扰,因此非常适合用来存储数据。
Object.create(..)是在ES5中新增的函数,所以在ES5之前的环境中(比如旧IE)如果要支持这个功能的话就需要使用一段简单的polyfill代码,它部分实现了Object. create(..)的功能:
if (! Object.create) {
Object.create = function(o) {
function F(){}
F.prototype = o;
return new F();
};
}
这段polyfill代码使用了一个一次性函数F,我们通过改写它的.prototype属性使其指向想要关联的对象,然后再使用new F()来构造一个新对象进行关联。
总结
JavaScript机制和传统面向类语言中的“类初始化”和“类继承”很相似,但是JavaScript中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的[[Prototype]]链关联的。
JavaScript的真实机制更合适用“委托”的表述,因为对象之间的关系不是复制而是委托。