理解对象

无序属性的集合,其属性可以包含基本值、对象或者函数。严格来讲,这就相当于说对象是一组没有特定顺序的值。对象的每个属性或方法都有一个名字,而每个名字都映射
到一个值。正因为这样(以及其他将要讨论的原因),我们可以把 ECMAScript 的对象想象成散列表:无非就是一组名值对,其中值可以是数据或函数。

属性类型:

只有内部才用的特性(attribute)时,描述了属性(property)的各种特征。ECMA-262 定义这些特性是为了实现 JavaScript 引擎用的,因此在 JavaScript 中不能直接访问它们。为了表示特性是内部值,该规范把它们放在了两对儿方括号中,例如 [[Enumerable]] 。

  • 数据属性

数据属性包含一个数据值的位置,在这个位置可以读取和写入值。数据属性有4个描述其行为的特性:

  1. [[Configurable]] :表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true 。
  2. [[Enumerable]] :表示能否通过 for-in 循环返回属性。它们的这个特性默认值为 true 。
  3. [[Writable]] :表示能否修改属性的值。它们的这个特性默认值为 true 。
  4. [[Value]] :包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。这个特性的默认值为 undefined 。

要修改默认的属性,可以使用Object.defineProperty方法,该方法接收三个参数:属性所在对象、属性名、属性描述符。

1
2
3
4
5
6
7
var person = {};
Object.defineProperty(person, "name", {
writable: false,
value: "Peter"
})
person.name // Peter
person.name = "Nike" // 严格模式报错

把 configurable 设置为 false ,表示不能从对象中删除属性。如果对这个属性调用 delete ,则在非严格模式下什么也不会发生,而在严格模式下会导致错误。而且,一旦把属性定义为不可配置的,就不能再把它变回可配置了。此时,再调用 Object.defineProperty() 方法修改除 writable 之外的特性,都会导致错误。

在调用 Object.defineProperty() 方法时,如果不指定, configurable 、 enumerable 和
writable 特性的默认值都是 false 。

  • 访问器属性

访问器属性不包含数据值;它们包含一对儿 getter 和 setter 函数(不过,这两个函数都不是必需的)。

  • 定义多个属性

Object.defineProperties()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let a = {};
Object.defineProperties(a, {
name: {
value: 'peter'
},
age: {
value: 25
},
info: {
get: function() {
return name + age;
}
}
});
a.name // peter
a.info // peter25
  • 读取属性的特性
1
2
3
var descriptor = Object.getOwnPropertyDescriptor(a, "name");
alert(descriptor.value); //peter
alert(descriptor.configurable); //false

创建对象

工厂模式
1
2
3
4
5
6
7
8
9
10
function createPerson(name, age, job) {
let o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayname = function() {
return this.name
}
return o;
}
自定义构造函数
1
2
3
4
5
function Person(name, age, job) {
this.name = name;
...
}
let person1 = new Person(name, age, job);

与工厂模式的区别:没有显示的创建对象;直接将属性和方法赋给了this对象;没有return语句。

区别构造函数和普通函数的方法是函数名首字母大写。

new操作符所作的事情:

  • 创建一个新对象
  • 将构造函数的作用域赋给新对象(因为this就指向了这个新对象)
  • 执行构造函数中的代码(为这个对象添加属性)
  • 返回新对象

如果直接调用构造函数,那么this就会指向当前环境;

构造函数存在的缺点:对象之间无法共享函数,以上例每个一对象都会构建一个新的sayname函数。

原型模式

我们创建的每个函数都有一个 prototype (原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如果按照字面意思来理解,那么 prototype 就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。

1
2
3
4
5
6
function Person() {}
Person.prototype.sayname = function() {};
Person.prototype.name = 'peter';
let person1 = new Person();
let person2 = new Person();
person1.sayname == person2.sayname // true

注意构造函数默认有prototype,而实例没有这个指针,实例中是[[Prototype]],默认不可访问,但是大部分浏览器实现了__proto__来访问这个属性。

另外,在查找属性时,会优先从实例本身查找,如果没有,再沿着原型链查找。

1
2
3
4
5
6
7
8
9
function Person() {}
Person.prototype.name = "peter";
let person1 = new Person();
let person2 = new Person();
person1.name // peter
person2.name // peter
person1.name = 'nike';
person1.name // nike
person2.name // peter

当为对象实例添加一个属性时,这个属性就会屏蔽原型对象中保存的同名属性;换句话说,添加这个属性只会阻止我们访问原型中的那个属性,但不会修改那个属性。即使将这个属性设置为 null ,也只会在实例中设置这个属性,而不会恢复其指向原型的连接。不过,使用 delete 操作符则可以完全删除实例属性,从而让我们能够重新访问原型中的属性。

可以用hasOwnProperty来检测属性是实例属性还是原型属性。

in \ for in \ Object.keys

原型可以是动态的,实例的[[prototype]]指向的是原型对象,可以动态的给原型对象添加属性,并且可以立即在实例上得到展示。但是如果重写构造函数的原型,则不会影响之前新建的实例,因为之前的实例指向的还是之前的那个原型对象。

1
2
3
4
5
6
7
8
9
function Person() {}
let person1 = new Person();
Person.prototype.name = 'peter';
person1.name // 'peter'
Person.prototype = {
constructor: Person,
name: 'nike'
};
person1.name // 'peter'

原生对象的原型上绑定了许多方法,都是通过prototype拿到。也可以动态的给原生对象的原型添加属性(这一点也会导致共享的混乱,所以时常是将需要共享的属性写在原型上,特有的属性写在构造函数中)。

还有一种动态原型模式可以将构造函数与原型写在一起使代码相对聚合:

1
2
3
4
5
6
7
8
fucntion Person() {
// 加if是为了保证只在第一次调用的时候执行,将属性挂载到原型上,后续都不需要再执行
if (typeof this.sayName != 'function') {
Person.prototype.sayName = function() {
return this.name
}
}
}
寄生构造函数模式

通常,在前述的几种模式都不适用的情况下,可以使用寄生(parasitic)构造函数模式。这种模式的基本思想是创建一个函数,该函数的作用仅仅是封装创建对象的代码,然后再返回新创建的对象;但从表面上看,这个函数又很像是典型的构造函数。

1
2
3
4
5
6
7
8
9
function Person(name){
var o = new Object();
o.name = name;
o.sayName = function() {
return this.name
}
return o;
}
let p1 = new Person('peter');

除了使用 new 操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式其实
是一模一样的。构造函数在不返回值的情况下,默认会返回新对象实例。而通过在构造函数的末尾添加一个 return 语句,可以重写调用构造函数时返回的值。

这个模式可以在特殊的情况下用来为对象创建构造函数。假设我们想创建一个具有额外方法的特殊数组。由于不能直接修改 Array 构造函数,因此可以使用这个模式。

1
2
3
4
5
6
7
8
9
function SpecialArray() {
let arr = new Array(...arguments);
arr.toPipeString = function() {
return this.join("|");
}
return arr;
}
let arr = new SpecialArray(1,2,3);
arr.toPipeString(); // 1|2|3
稳妥模式

所谓稳妥对象,指的是没有公共属性,而且其方法也不引用 this 的对象。稳妥对象最适合在一些安全的环境中(这些环境中会禁止使用 this 和 new ),或者在防止数据被其他应用程序(如 Mashup程序)改动时使用。

1
2
3
4
5
6
7
8
9
10
function Person(name) {
let o = new Object();
// 可以定义私有变量和函数
let age = 18;
// 添加方法
o.sayName = function() {
console.log(name)
}
return;
}

注意,在以这种模式创建的对象中,除了使用 sayName() 方法之外,没有其他办法访问 name 的值。可以像下面使用稳妥的 Person 构造函数。


继承

许多 OO 语言都支持两种继承方式:接口继承和实现继承。接口继承只继承方法签名,而实现继承则继承实际的方法。如前所述,由于函数没有签名,在 ECMAScript 中无法实现接口继承。ECMAScript 只支持实现继承,而且其实现继承主要是依靠原型链来实现的。

1、原型链

每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那么让原型对象等于另一个类型的实例,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数
的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念。

1
2
3
4
5
6
7
8
9
10
function Super() {
this.age = 18;
}
Super.prototype.name = 'nike'
function Sub() {}
Sub.prototype = new Super(); // 注意继承的是父类的一个实例
Sub.prototype.age = 19;
Sub.age // 19 可以在替换了原型过后定义自己的属性
Sub.name // 'nike'
Sub.constructor // Funtion Super 注意构造函数已经指向了父类的构造函数

事实上,前面例子中展示的原型链还少一环。我们知道,所有引用类型默认都继承了 Object ,而这个继承也是通过原型链实现的。

如果只使用原型链容易导致的问题是所有实例都共享一个原型对象,如果一个实例对原型就行了修改,那么其他实例也会受到影响:

1
2
3
4
5
6
7
8
9
function Super() {
this.arr = [1]
}
function Sub() {}
Sub.prototype = new Super();
let s1 = new Sub();
let s2 = new Sub();
s1.arr.push(2);
s2.arr // [1,2]
2、借用构造函数

在子类的构造函数中执行父类的构造函数。

1
2
3
4
5
6
7
8
9
10
11
function Super(name) {
this.name = name;
this.arr = [1];
}
function Sub(name) {
Super.call(this, name);
}
let s1 = new Sub('peter');
let s2 = new Sub('nike');
s1.arr.push(2) // 不再互相影响,因为每一个实例都是独立的,没有通过原型关联
s2.arr // [1]

这种方式的优点是可以给继承的父类的构造函数传参数。每一个子类的属性都是独立的,不会互相影响。但每次都会执行父类的构造函数,保持独立的同时浪费了内存,如果需要共享函数也不行。并且也无法继承父类原型上的方法

3、组合继承

组合继承是将原型链与构造函数结合。将需要共享的属性写在原型链上,将需要独立的实例属性写在构造函数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fucntion Super() {
this.arr = [1]; // 将需要个性化的属性写在构造函数中,每次独立生成
}
function Sub() {
Super.call(this);
}
******
Sub.prototype = new Super(); // 这样调用了两次构造函数,不好
Sub.prototype = Super.prototype; // 不用调用构造函数但是父类的原型可能会被改变了
******
Sub.prototype.constuctor = Sub;
// 解决方法: 这个方法约等于Object.create(o)
function object(o) {
function F() {}
F.prototype = o.prototype;
return new F(); // 说白了就是调用一个空对象的构造函数,而不是调用两次父类的构造函数,生成不必要的属性
}
Sub.prototype = object(Super.prototype); // 对父类的原型进行浅复制
Sub.prototype = Object.create(Super.prototype); // 对父类的原型进行浅复制

小结

ECMAScript 支持面向对象(OO)编程,但不使用类或者接口。对象可以在代码执行过程中创建和增强,因此具有动态性而非严格定义的实体。在没有类的情况下,可以采用下列模式创建对象。

  • 工厂模式,使用简单的函数创建对象,为对象添加属性和方法,然后返回对象。这个模式后来被构造函数模式所取代。
  • 构造函数模式,可以创建自定义引用类型,可以像创建内置对象实例一样使用 new 操作符。不过,构造函数模式也有缺点,即它的每个成员都无法得到复用,包括函数。由于函数可以不局限于任何对象(即与对象具有松散耦合的特点),因此没有理由不在多个对象间共享函数。
  • 原型模式,使用构造函数的 prototype 属性来指定那些应该共享的属性和方法。组合使用构造函数模式和原型模式时,使用构造函数定义实例属性,而使用原型定义共享的属性和方法。

JavaScript 主要通过原型链实现继承。原型链的构建是通过将一个类型的实例赋值给另一个构造函数的原型实现的。这样,子类型就能够访问超类型的所有属性和方法,这一点与基于类的继承很相似。
原型链的问题是对象实例共享所有继承的属性和方法,因此不适宜单独使用。解决这个问题的技术是借用构造函数,即在子类型构造函数的内部调用超类型构造函数。这样就可以做到每个实例都具有自己的属性,同时还能保证只使用构造函数模式来定义类型。使用最多的继承模式是组合继承,这种模式使用原型链继承共享的属性和方法,而通过借用构造函数继承实例属性。
此外,还存在下列可供选择的继承模式。

  • 原型式继承,可以在不必预先定义构造函数的情况下实现继承,其本质是执行对给定对象的浅复制。而复制得到的副本还可以得到进一步改造。
  • 寄生式继承,与原型式继承非常相似,也是基于某个对象或某些信息创建一个对象,然后增强对象,最后返回对象。为了解决组合继承模式由于多次调用超类型构造函数而导致的低效率问题,可以将这个模式与组合继承一起使用。
  • 寄生组合式继承,集寄生式继承和组合继承的优点与一身,是实现基于类型继承的最有效方式。