你不知道的JavaScript《三》对象与原型链

11/9/2020 JavaScript

# 类型

在了解对象之前,先了解下JavaScript的类型,在JavaScript中一共有6种主要语言类型:

  • string
  • number
  • boolean
  • null
  • undefined
  • object

其中前面5种为基本类型,object为复杂类型。ES6又新增了一种语言类型Symbol,表示唯一。正常使用typeof可以输出变量的类型,但JavaScript存在一个bug,对null 进行typeof 会返回 object,然而null却不属于对象。(原理:不同的对象底层都表示为二进制,在JavaScript二进制前三位为0的话被判定为object类型,而null的二进制全是0,所以被当成对象)

# 内置对象

JavaScript中一些对象的子类型称为内置对象,内置对象从表现形式来讲很像其他语言的类型或类:

  • String
  • Number
  • Boolean
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

这些函数通常当做构造函数被new调用,返回新对象。当调用内置对象的一些属性或方法时,通常需要对字面量进行“装箱”和“拆箱”的操作。比如String的length属性和字符串相关方法,都属于内置对象而不是字符串字面量。

var strObject = new String( "I am a string" );
typeof strObject; // "object" 返回对象
strObject instanceof String; // true String实例

// 自动装箱 length属性 方法只有在String上有
// strPrimitive属于字面量

var strPrimitive = "I am a string";
console.log( strPrimitive.length ); // 13
console.log( strPrimitive.charAt( 3 ) ); // "m"
1
2
3
4
5
6
7
8
9
10

# 属性描述符

从ES5开始,对象的属性都具备了属性描述符。一个对象的属性通常可以包含value、writable(可写)、enumerable(可枚举)、configurable(可配置)的描述。

  1. **Writable:**决定是否可以修改属性的值
var myObject = {};
Object.defineProperty( myObject, "a", {
value: 2,
writable: false, // 不可写、相当于空set
configurable: true,
enumerable: true
} );

myObject.a = 3;// 严格模式下报错 TypeError
myObject.a; //还是 2
1
2
3
4
5
6
7
8
9
10

​ **2.Configurable:**只要属性是可配置的,就可以使用 defineProperty() 方法来修改属性描述符

var myObject = {
	a:2
};
Object.defineProperty( myObject, "a", {
	value: 4,
	writable: true,
	configurable: false, // 不可配置
	enumerable: true
} );
//	修改属性描述符
Object.defineProperty( myObject, "a", {
	value: 6,
	writable: true,
	configurable: true,
	enumerable: true
} ); 
// 报错 TypeError
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

把 configurable 修改成 false ,将无法修改、删除对象的属性,而且它是单向操作,无法撤销。不过它有个例外:可以修改writable的状态由true改为false,也仅此而已。

3. 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
("b" in myObject); // true
myObject.hasOwnProperty( "b" ); // true
// .......
for (var k in myObject) {
	console.log( k, myObject[k] );
}
// "a"  2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

in 操作符会检查属性是否在对象及其 [[Prototype]] 原型链中。相比之下, hasOwnProperty(..) 只会检查属性是否在 myObject 对象中,不会检查原型 [[Prototype]] 链。

​ **4.value:**对象的值,这边不多介绍。

补充:

  • 当你要定义一对象属性常量时,可以使用writable:false和configurable:false,达到不可修改、删除、重定义的效果。
var myObject = {};
Object.defineProperty( myObject, "CONST_NUMBER", {
	value: 42,
	writable: false,
	configurable: false
} );
1
2
3
4
5
6
  • 禁止拓展对象:object.preventExtensions()
var myObject = {
	a:2
};
Object.preventExtensions( myObject );
myObject.b = 3;
myObject.b; // undefined 严格模式下报错
1
2
3
4
5
6
  • 密封对象:object.seal(...),这个方法相当于在一个现有对象调用object.preventExtensions()并把所有属性标记为configurable:false。密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(但可以修改属性的值)
var myObject = {
	a:2
};
Object.seal( myObject );//密封对象 还可以修改对象属性
1
2
3
4
  • 冻结对象:Object.freeze() ,它会创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal() 并把所有“数据访问”属性标记为 writable:false,这样就无法修改它们的值。它是应用在对象上的级别最高的不可变性,它会禁止对于对象本身及其任意 直接属性的修改。
var myObject = {
	a:2
};
Object.freeze( myObject );//冻结对象 无法修改对象属性
1
2
3
4

# 原型

​ JavaScript 中的对象有一个特殊的 [Prototype] 内置属性,其实就是对于其他对象的引用。当试图引用对象的属性时会触发Get操作,第一步是检查对象本身是否有这个属性,如果没有就到对象的原型链上查找,直到找到或者到达原型链顶端为止。所有普通的 [Prototype] 链最终都会指向内置的 Object.prototype。

var anotherObject = {
	a:2
};
// 创建一个关联到 anotherObject 的对象
var myObject = Object.create( anotherObject );
myObject.a; // 2
1
2
3
4
5
6

比如上面的代码,myObject 对象的 [Prototype] 关联到了 anotherObject。显然 myObject.a 并不存在, 但是尽管如此,属性访问仍然成功地(在 anotherObject 中)找到了值 2。

# 构造函数

当我们调用构造函数创建对象时,原型自动链接到构造的类型prototype上。 如下new Foo() 时会创建 a,a 内部 的 [Prototype] 链接关联到 Foo.prototype 指向的那个对象。prototype就是原型对象。

function Foo() {
	// ...
}
var a = new Foo();
Object.getPrototypeOf( a ) === Foo.prototype; // true
a.constructor === Foo; // true
a.__proto__ === Foo.prototype; // true 隐式原型等于构造函数的显示原型
1
2
3
4
5
6
7

Foo.prototype默认有一个公有且不可枚举的constructor属性,关联对象的构造函数,比如上面的Foo。(注意:constructor是原型上的属性,并不在对象中)当我们看到关键字new时,很容易联想到面向对象的语言构造类实例。Foo()的调用方法也很像初始化类实例。但我们要注意JavaScript并没有类的概念,更多的是“伪类”。ES引入class,更多的也只是委托模式的语法糖。而且构造函数本身也不算真正的构造函数,只是因为函数调用前面加上new关键字后,new会劫持普通函数并用构造对象的形式调用它。比如:

function NothingSpecial() {
	console.log( "Don't mind me!" );
}
var a = new NothingSpecial();
// "Don't mind me!"
a; // NothingSpecial{}   a 为对象
1
2
3
4
5
6

# "面向类"

function Foo(name) {
	this.name = name;
}
Foo.prototype.myName = function() {
	return this.name;
};
var a = new Foo( "a" );
var b = new Foo( "b" );
a.myName(); // "a"
b.myName(); // "b"

1
2
3
4
5
6
7
8
9
10
11

上面的代码很好地模拟了JavaScript“面向对象”的写法,利用原型上的属性来创造公有,上面a、b都没有myName属性,但通过原型链上的方法输出了“自己的名字”。但是 有点缺陷new 生成的对象同时生成.prototype和.constructor的引用,容易修改引用上的内容从而影响整个原型。所以我们通常使用object.create()来创建关联原型的新对象,这也体现了两种设计模式:面向对象关联和面向原型的不同,下面我们看更直接的对比:

# 原型风格

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

子类 Bar 继承了父类 Foo,然后生成了 b1 和 b2 两个实例。b1 委托了 Bar.prototype,调用bar原型的speak。bar委托了 Foo.prototype 调用identify。

# 对象关联风格

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();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

这段代码中我们同样利用 [Prototype] 把 b1 委托给 Bar 并把 Bar 委托给 Foo,唯一不同的是不使用new 而采用object.create来构造关联。对象关联相对简洁,下面一张图描述它们之间的关系:

原型关联模型

image-20201110211324573

对象关联模型

image-20201110211547661

# 属性屏蔽

当给一个对象添加属性或者修改属性,因为原型链而使情况变得复杂,特别是用“=”来赋值。下面我们来讲解:

myObject.foo = "test";//为myobject 的foo属性 赋值test
1

会有以下情况:

  1. 如果 myObject 对象中包含名为 foo 的普通数据访问属性,这条赋值语句只会修改已有的属性值。
  2. 如果 foo 不是直接存在于 myObject 中,[[Prototype]] 链就会被遍历,类似 [[Get]] 操作。 如果原型链上找不到 foo,foo 就会被直接添加到 myObject 上。
  3. 如果属性名 foo 既出现在 myObject 中也出现在 myObject 的 [Prototype] 链上层,那 么就会发生屏蔽。myObject 中包含的 foo 属性会屏蔽原型链上层的所有 foo 属性,因为 myObject.foo 总是会选择原型链中最底层的 foo 属性。
  4. 如果 foo 只存在于原型链上层,会有3种情况:
    • 如果在 [Prototype] 链上层存在名为 foo 的普通数据访问属性并且没有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo 的新属性,它是屏蔽属性。
    • 如果在 [[Prototype]] 链上层存在 foo,但是它被标记为只读(writable:false),那么 无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
    • 如果在 [[Prototype]] 链上层存在 foo 并且它是一个 setter(设置属性的方法),那就一定会调用这个 setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这 个 setter。

所以,我们通常使用 Object.defineProperty()来为对象添加属性,而不使用 =操作符赋值 。