JavaScript高级程序设计 面向对象的程序设计

ECMAScript虽然是一种面向对象的语言,但是他没有类的概念。所以他的对象也与其他语言中的对象有所不同。

ECMA-262定义对象:一组没有特定顺序的值。

6.1 理解对象

创建对象的方法:

1. 最简单直接的方式——Object构造函数

var person = new Object();
person.name = "Nicholas";
person. age = 29;
person.job = "Software Engineer";

person.sayName = function(){
    alert(this.name);
}

2. 对象字面量的方式

var person = {
    name = "Nicholas";
    age = 29;
    job = "Software Engineer";

    sayName = function(){
        alert(this.name);
    }
};     //注意这个分号

两种方法定义的对象是一样的,有着相同的属性和方法。这些属性创建时都带有一些特征值,JS通过这些值来定义他们的行为

6.1.1 属性的类型

特性:只有内部才用,描述了属性的各种特征。这些特性是为了实现JS引擎用的,因此不能直接访问他们。为表示特性是内部值,将他们放在两对方括号中

1. 数据属性

数据属性包含一个数据值的位置(引用类型)。在这个位置可以读取和写入值。有4个描述其行为的特征。

[[Configurable]] 能否通过delete删除或重定义属性/能否修改属性的特征/能否将属性修改为访问器属性 默认值为true
[[Enumerable]] 能否通过for-in循环返回属性 默认值为true
[[Writable]] 能否修改属性的值 默认值为true
[[Value]]

属性的数据值,这是读/写的位置 默认值为undefined

*这里的默认值针对的是对象字面量的定义方式,Object.defineProperties()方法定义方式默认为false。

修改属性默认的特性:Object.defineProperty()方法

该方法接收三个参数:属性所在的对象、属性的名字、一个描述符对象(属性是上面四个特征中的几个)

PS1:一旦把属性定义为不可配置,就不能再把它变为可配置了。此时再调用Object.defineProperty()方法除了修改writable外都会导致错误。

PS2:调用Object.defineProperty()方法时如果不指定,前三个特征的默认值都是false。

2. 访问器属性

访问器属性不包含数据值。他们包含一对getter和setter函数(不过都不是必须的)。

读取访问器属性时,会调用getter函数(负责返回有效的值);在写入访问器属性时,会调用setter函数(传入新值,负责决定如何处理数据)。

访问器属性有4个特征:

[[Configurable]] 能否通过delete删除或重定义属性/能否修改属性的特征/能否将属性修改为访问器属性 默认值为true
[[Enumerable]] 能否通过for-in循环返回属性 默认值为true
[[Get]] 读取属性时调用的函数 默认值为undefined
[[Set]] 写入属性时调用的函数 默认值为undefined

访问器属性的作用:用于设置一个属性会引起其他属性变化的情况。(get函数收到改变后的属性值,set针对这个值对其他属性值进行相应的改变)

PS1:_属性名(前面加下划线)是一种记号,表示只能通过对象方法访问的属性。

PS2:访问器属性不能直接定义,只能通过Object.defineProperty()方法来定义。

PS3:只设定getter意味着属性是不能写的,尝试写入属性会被忽略(严格模式下会抛出错误)。未设置getter意味着是不能读的(非严格模式返回undefined,严格模式下抛出错误)。

PS4:在Object.defineProperty()前使用_defineGetter_()和_defineSetter_()函数修改getter和setter特征(接收两个参数,修改的属性字符串形式和修改后的函数)。

PS5:在不支持Object.defineProperty()的浏览器中,无法修改[[Configurable]]和[[Enumerable]]。

6.1.2 定义多个属性

Object.defineProperties()方法,通过描述符一次定义多个属性(可以有数据属性也可以有访问器属性)。

接收两个对象属性,第一个对象是要添加/修改属性的对象;第二个对象是要添加/修改的属性。

PS:通过Object.defineProperties()定义的对象,凡是布尔值的特征默认值都是false。

6.1.3 读取属性的特征

Object.getOwnPropertyDescriptor()方法:取得给定属性的描述符。

接收两个参数,属性所在的对象,要读取的属性名称。返回值是一个对象。

6.2 创建对象

Object构造函数和对象字面量创建对象的缺点:使用一个接口创建很多对象时,会产生大量的重复代码。

6.2.1 工厂模式

抽象了创建具体对象的过程,用函数来封装以特定接口创建对象的细节。

function createPerson(name, age, job){
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function(){
        alert(this.name);
    };
    return o;
}

var person1 = createPerson("Nicholas", 29, "software engineer");
var person2 = createPerson("Grey", 27, "Doctor");

缺点:未解决对象识别的问题(即无法识别出一个对象的类型)。

6.2.2 构造函数模式

构造函数可以用来创建特定类型的对象。可以创建自定义的构造函数,从而自定义对象类型的属性和方法。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function(){
        alert(this.name);
    };
}

var person1 = new Person("Nicholas", 29, "software engineer");   //注意这里要使用new操作符
var person2 = new Person("Grey", 27, "Doctor");

构造函数模式和工厂模式的区别:

  • 没有显式的创建对象
  • 直接将属性和方法赋给了this对象
  • 没有return语句
  • 构造函数首字母大写而非构造函数首字母小写(以作区分)

调用构造函数创建实例的步骤:

  1. 创建一个新对象(new操作符)
  2. 把构造函数的作用域赋给这个对象(this就指向了这个新对象)
  3. 执行构造函数中的代码(为这个新对象添加属性)
  4. 返回新对象

自定义构造函数的优点:将实例标识为一种特定的类型(如上面的例子,实例的constructor属性指向Person),并且通过instance of操作符可知,创建的对象既是Object的实例也是Person的实例。

1. 将构造函数当作函数

构造函数和非构造函数唯一的区别就是调用方法不同。

下面展示不使用new操作符调用的情况(和普通函数没有区别)。

//作为构造函数使用
var person = new Person("Nicholas", 29, "software engineer"); 
person.sayName();  //"Nicholas"

//作为普通函数调用
Person("Grey", 27, "Doctor");
window.sayName();  //"Grey"

//在另一个对象的作用域中调用
var o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName();  //"Kristen" 

当在全局作用域调用一个函数时,this对象总指向Global对象(在浏览器中是window对象)。因此在函数调用后可以通过window对象调用sayName()方法。

也可以使用call()或者apply()在某个特殊对象的作用域中调用Person()函数。

2. 构造函数的问题

主要问题:每个方法都要在每个实例上重新创建一遍。(不同实例上的同名函数是不相等的)

alert(person1.sayName == person2.sayName);  //false

这样做没有必要,在有this的情况下,不用在执行代码前就把函数绑定到特定的对象上。

=>通过把函数定义转移到构造函数外来解决这个问题?

将函数变为全局函数,在构造函数内设置指针(this.sayName = sayName)指向同一个函数。看似问题得到了解决,但这样做使得“全局”名不副实,并且自定义类型没有封装性可言。

6.2.3 原型模式

函数的prototype(原型)属性:指向包含特定类型所有实例共享的属性和方法的对象的指针。是调用构造函数创建的实例的原型对象。 

优点:让所有实例共享它所包含的属性和方法。

function Person(){
}

Person.ptototype.name = name;
Person.ptototype.age = age;
Person.ptototype.job = job;
Person.ptototype.sayName = function(){
        alert(this.name);
};

var person1 = new Person("Nicholas", 29, "software engineer");  
var person2 = new Person("Grey", 27, "Doctor");

alert(person1.sayName == person2.sayName);  //true

仍然可以通过调用构造函数来创建实例,但新对象的属性和方法是所有实例共享的。

1. 理解原型对象

 prototype属性是连接构造函数和原型对象的。  函数 => (prototype) => 原型对象 => (constructor) => 函数(闭环)

Person.prototype.constructor;  //Person

原型对象默认只会取得constructor属性,其余方法都继承自Object。

连接实例和原型对象的指针:[[Prototype]](指向函数的原型对象)

PS:实例和构造函数并没有直接的联系。

实例只有一个属性[[Prototype]],通过这个指向实例对象的指针来获得属性和方法。

两个重要的方法:

  • isPrototypeOf():检验原型对象是否属于该实例
alert(Person.prototype.isPrototypeOf(person1));  //true
  • Object.getPrototypeOf():返回实例的原型(常用于继承)
alert(Object.getPrototypeOf(person1) == Person.prototype);  //true
alert(Object.getPrototypeOf(person1).name);  //"Nicholas"

PS1:在对象中搜索属性的顺序是:先在实例中搜索,再在原型对象中搜索。

PS2:在实例中定义原型中的同名属性并不能修改原型,只是覆盖了原型中的属性(因为搜索时先找的是实例的属性)。并且通过设置这个同名属性值为null也无法恢复指向原型的连接。只能使用delete操作符完全删除属性。

PS3:可以通过hasOwnProperty()方法检验一个熟悉是否在实例中(在原型的属性会返回false)。

PS4:Object.getOwnPropertyDescriptor()方法只能用于实例属性。要取得原型属性的描述符必须直接在原型对象上调用。

2. 原型与in操作符

in操作符:在通过对象能够访问属性时返回true。(无论是在实例中还是在原型中)

hasOwnProperty()方法:检验属性是否在实例中。

结合in和hasOwnProperty()可以确定属性究竟在原型中还是在实例中。

//检验属性是否在原型中
function hasPrototypeProperty(object, name){    
    return !object.hasOwnProperty(name) && (name in object);
}

如果是实例中覆盖了原型的,也会返回false。

for-in循环:返回能通过对象访问的、可枚举([[Enumerable]])的属性。(实例中&原型中的)

Object.keys()方法:接受一个对象作为参数,返回一个包含可枚举属性的字符串数组。

Object.getOwnPropertyNames()方法:返回所有实例属性,无论是否可枚举(包括constructor)。

3. 更简单的原型语法

原型模式的缺点:每定义一个属性/方法就要写一遍prototype,比较麻烦。也为了视觉上的封装性。  => 使用对象字面量的方式来重写整个原型对象。

function Person(){
}

Person.prototype = {
    name: "Nicholas";
    age: 29;
    job: "Software Engineer";
    sayName: function(){
        alert(this.name);
    }
};

但是这种方式有一个缺点:由于重写了原型对象,constructor不再指向他的构造函数,而是指向Object。即使instanceof仍能返回正确的结果

var friend = new Person();

alert(friend instanceof Object);  //true
alert(friend instanceof Person);  //true

alert(friend.constructor == Person);  //false
alert(friend.constructor == Object);  //true

如果直接设置constructor属性指向对应的构造函数,[[Enumerable]]属性会被默认为true(即可枚举)。此时可以使用Object.defineProperty()来更改[[Enumerable]]的值。

4. 原型的动态性

对原型对象的任何修改都能够立刻从实例中反映出来(即使是先创建了实例再修改原型)。

var friend = new Person();
Person.prototype.sayHi = function(){
    alert("hi");
};
friend.sayHi();  //"hi"

可以看到即使在创建实例之后创建的原型方法,在实例中依旧可以访问。这是由于原型和实例之间的松散连接方法

在调用方法时先在实例中搜索,搜索不到再在原型中搜索(这里使用的是指向原型的指针而不是一个副本,所以具有动态性)。

 

但是,重写原型对象是不一样的情况。由于在调用构造函数时会给实例添加一个指向最初原型的指针([[Prototype]]),把原型直接改为另一个对象会切断他和实例的联系。

function Person(){
}

var friend = new Person();

Person.prototype = {
    constructor: Person,
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",
    sayName: function(){
        alert(this.name);
    }
};

friend.sayName();   //error

重写原型对象后相当于创建了一个新的对象,这个对象的constructor虽然指向了Person,但是之前创建的实例的原型仍是最初的原型对象。所以这里是调用不了sayName方法的。

5. 原生对象的原型

原生的引用类型都是采用原型模式创建的。

6. 原型对象的问题

  1. 省略了传递初始化参数的环节,使得所有实例默认情况下都将取得同样的属性值。
  2. 共享带来的问题:引用类型的属性,修改其中一个会造成所有实例都发生变化(基本类型可以通过覆盖的方式解决该问题)。

6.2.4 组合使用构造函数模式和原型模式

构造函数模式用于定义实例属性,原型模式用于定义方法和共享的属性

优点:最大限度节省内存,还支持向构造函数传递参数,集两种模式之长。这是目前使用最广泛、认同度最高的一种创建自定义类型的方式。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.friend = ["Shelby", "Court"];
}

Person.ptototype = {
    constructor: Person,
    sayName = function(){
           alert(this.name); 
    }
};

var person1 = new Person("Nicholas", 29, "software engineer");  
var person2 = new Person("Grey", 27, "Doctor");

person1.friends.push("Van");
alert(person1.friends);  //"Shelby", "Court", "Van"
alert(person2.friends);  //"Shelby", "Court"

alert(person1.friends === person2.friends);  //false
alert(person1.sayName === person2.sayName);  //true

6.2.5 动态原型模式

把所有信息都封装在构造函数中,仅在必要的情况下在构造函数中初始化原型。即通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型。

在初次调用构造函数时执行if语句中的部分,此后原型就完成了初始化。这里做出的修改会立即在所有实例中得到反映。

PS:if检查的可以是初始化后应该存在的任何属性和方法,无需对每一个属性和方法都进行检查。

function Person(name, age, job){
    //属性,构造函数模式
    this.name = name;
    this.age = age;
    this.job = job;
    
    //方法,原型模式
    if (typeof this.sayName != function){  //也可以使用instanceof操作符
        Person.prototype.sayName = function(){
            alert(this.name);
        }; 
    }
}

使用动态原型模式时,不能用对象字面量重写原型。

6.2.6 寄生构造函数模式

基本思想:创建一个封装对象代码,并返回新创建的对象的函数。

除了使用new操作符外,这个模式和工厂模式是一模一样的。这个模式可以在特殊情况下为对象创建构造函数。比如想创建一个拥有特殊方法的数组(因为不能直接修改Array)。

PS:返回的对象和构造函数或者构造函数的原型属性之间没有关系。即构造函数返回的对象和在构造函数外部创建没有什么不同。所以不能依赖instanceof操作符来确定对象类型。

6.2.7 稳妥构造函数模式

稳妥对象:没有公共属性,其方法也不引用this的对象。

稳妥对象适合用于一些安全的环境里(在这种环境中会禁止使用this和new)或者防止数据被其他应用程序改动时使用。

稳妥函数与寄生构造函数的区别:

  • 创建对象不引用this;
  • 不使用new操作符调用构造函数

除了构造函数中的方法外,不能通过其他方法访问构造函数中的原始数据(这些都是私有变量和函数)。同样,稳妥构造函数创建的对象与构造函数之间也没有什么关系。

6.3 继承

  • 接口继承:只继承方法签名。由于函数没有签名,故ECMAScript无法实现接口继承。
  • 实现继承:主要依靠原型链来实现。

6.3.1 原型链

原型链是实现继承的主要方法。

原型对象成为另一个类型的实例,此时的原型对象(1号原型)将包含一个指向另一个原型(2号原型)的指针([[Prototype]]),另一个原型(2)也将包含一个指向另一个构造函数的指针(constructor)。这样就构成了原型链(1继承自2)。

function SuperType(){
    this.property = true;
}

SuperType.prototype.getSuperValue = function(){
    return this.property;
};

function SubType(){
    this.subproperty = false;
}

//继承SuperType
SubType.prototype = new SuperType();  //SubType的原型改写为SuperType的实例

SubType.prototype.getSubValue = function(){   
    return this.subproperty;
};

var instance = new SubType();
alert(instance.getSuperValue());  //true

这里的SuperType继承了SubType,继承的方法是将SuperType的实例作为SubType的原型(本质是重写原型对象)。现在SuperType中的所有属性和方法都存在在SubType的原型中了。在确认继承关系后,给SubType的原型又添加了一个方法(getSubValue),这样就在继承的基础上添加了新的方法。

 

  • 可以看到instance指向SubType的原型,而SubType的原型又指向SuperType的原型。getSuperValue方法虽然在SuperType的原型中,通过instance仍能够进行调用(这表示通过原型链实现了继承)。
  • SubType的原型作为SuperType的实例,拥有了property属性。
  • instance.constructor现在指向SuperType。因为SubType.prototype中的constructor被重写了(实际上是指向了另一个对象——SuperType的原型,而这个原型对象的constructor。属性指向的是SuperType)。

1. 别忘记默认的原型

所有引用类型都默认继承Object,而这个继承也是通过原型链实现的。(即上面例子里的SuperType的原型中有一个指针[[Prototype]]指向Object.prototype)

2. 确定原型和实例的关系

(这两种方法前面都提到过了)

  • instanceof操作符(构造函数和实例的关系)
alert(instance instanceof Object);  //true
alert(instance instanceof SuperType);  //true
alert(instance instanceof SubType);  //true
  • isPrototypeOf()方法(原型对象和实例的关系)
alert(Object.prototype.isPrototypeOf(instance));  //true
alertSuperType.prototype.isPrototypeOf(instance));  //true
alert(SubType.prototype.isPrototypeOf(instance));  //true

3. 谨慎地定义方法

PS1:给原型添加方法的代码一定要放在替换原型的语句之后。(因为继承实际上是将原型改为另一个原型的实例,如果在这之前就写好了原型方法,这里也会被改掉)

PS2:通过原型链实现继承时,不能通过对象字面量(默认继承自Object)创建原型方法(这样SubType和SuperType就没有关系了)。这样会重写原型链的。

4. 原型链的问题

  1. 包含引用类型值的原型会带来问题。这一点和前面说到的原型模式一样。由于包含引用类型值的原型属性会被所有实例共享,当SuperType的构造函数中有一个引用类型时(如数组),他的每一个实例都会包含这个数组属性,当SubType通过原型链继承之后也会拥有这个属性。但当SubType的一个实例修改了这个属性后,所有实例的该属性都会发生改变。
  2. 在创建子类型的实例时,没有办法在不影响所有对象实例的情况下给超类型的构造函数传递参数。 => 原型链在实际中一般不单独使用

6.3.2 借用构造函数(伪造对象/经典继承)

 在子类型构造函数的内部调用超类型构造函数。通过apply()和call()方法可以在将来新创建的对象上执行构造函数。

function SuperType(){
    this.colors = ["red", "blue", "green"];
}

function SubType(){
    //继承了SuperType
    SuperType.call(this);
}

var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors);  //"red, blue, green, black"

var instance2 = new SubType();
alert(instance2.colors);  //"red, blue, green"

这种继承方法实际上是通过call()方法(或者apply()也可以)在未来将要创建的SubType实例的环境下调用了SuperType构造函数。这样新对象上执行SuperType()函数中定义的所有对象初始化代码,SubType每个实例都会有自己的colors属性的副本

1. 传递参数

借用构造函数的优势在于:可以在子类型的构造函数中向超类型构造函数传递参数

function SuperType(name){  //这是一个有参数的构造函数
    this.name = name;
}

fiunction SubType(){
    //继承了SuperType,还传递了参数
    SuperType.call(this, "Nicholas");

    //实例属性
    this.age = 29;
}

var instance = new SubType();
alert(instance.name);  //"Nicholas"
alert(instance.age);  //29

 

注意在调用超类的构造函数之后,再添加应该在子类型中定义的属性。

2. 借用构造函数的问题

  1. 无法避免构造函数模式存在的问题——方法都是在构造函数中定义,无复用性可言。
  2. 在超类原型中定义的方法,对子类型来说是不可见的。  => 实际中很少单独使用

6.3.3 组合继承(伪经典继承)

使用原型链实现对原型属性和方法的继承,通过借用构造函数来实现对实例属性的继承。

优点:避免了原型链和借用构造函数的缺陷,融合了优点。是最常用的继承模式。而且instanceof和isPrototypeOf()也能够用于识别组合继承创建的对象

6.3.4 原型式继承

借助原型可以基于已有对象创建新对象,同时还不必因此创建自定义类型。

function object(o){
    function F(){}
    F.prototype = o;
    return new F():
}

在这个object函数内部,首先创建了一个临时的构造函数F,然后将传入的对象作为这个构造函数的原型,最后返回一个临时类型的新实例。

本质上,object()对传入其中的对象执行了一次浅复制

var person = {
    name: "Nicolas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = object(person);
anotherPerson.name = "Grey";
anotherPerson.friends.push("Rob");

var yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

alert(person.friends);  //"Shelby", "Court", "Van", "Rob", "Barbie"

原型式继承需要一个对象(这里的person)作为另一个对象的基础。在这个例子中,person传入object()中,返回了一个新对象,这个新对象将person作为原型。anotherPerson和yetAnptherPerson都共享了person的属性(相当于创建了person的两个副本)。

ECMAScript5规范了原型继承,新增了Object.create()方法。这个方法接收两个参数:用作新对象原型的对象和为新对象定义额外属性的对象(可选)。在只接收一个参数的时候和object()方法行为相同。

原型式继承一般用于想让一个对象与另一个对象保持类似的情况(使用引用类型实终会共享相应的值)。

6.3.5 寄生式继承

创建一个仅用于封装继承过程的函数,该函数在内部用某种方式来增强对象,最后返回这个对象。

缺点:使用寄生式继承给对象添加函数,会由于做不到函数复用而降低效率(与构造函数模式类似)。

6.3.6 寄生组合式继承

组合继承的不足:无论什么情况下都会调用两次超类的构造函数(一次是创建子类型原型的时候,一次是子类型构造函数内部)。子类型最终会包含超类型的所有实例属性,但我们不得不在调用子类型构造函数的时候重写这些属性。

function SuperType(name){
    this.name = name;
    this.color = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
    alert(this.name);
};

function SubType(name, age){
    SuperType.call(this, name);  //第二次调用SuperType()
    this.age = age;  
}

SubType.prototype = new SuperType();  //第一次调用SuperType()

SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
    alert(this.age);
};

可以看到,第一次调用SuperType时,子类型的原型会获得两个属性name和color(因为他是SuperType的实例)。

在调用SubType构造函数创建实例时,又会调用一次SuperType,这次在新对象上创建了实例属性name和color(从而覆盖了原型中两个同名的属性)。

 

可以看到有两组color和name属性,一组在原型中一组在实例中。这就是调用两次SuperType构造函数的结果。

寄生组合式继承:借用构造函数继承属性,原型链的混成形式继承方法。

本质上就是使用寄生式继承来继承超类的原型,然后将结果指定给子类的原型。

寄生组合式继承的基本模式如下:

function inheritPrototype(subType, superType){
    var prototype = object(superType.prototype);  //创建对象
    prototype.constructor = subType;  //增强对象
    subType.prototype = prototype;  //指定对象
}

函数内部,第一部创建超类原型的一个副本,第二步是为创建的副本添加constructor属性(弥补重写原型而失去的默认constructor属性),最后将创建的对象(副本)赋给子类型的原型。这样就可以用这个函数去替代前面为子类型原型赋值的语句了。

这样做的高效率体现在只调用了一次SuperType构造函数,避免了在SubType.prototype上创建多余的属性。在这种情况下原型链保持不变,instanceof和isPrototypeOf()还能够正常使用。

寄生组合式继承是引用类型最理想的继承方式。

 

posted @ 2020-06-09 22:23  HermionePeng  阅读(298)  评论(0编辑  收藏  举报