JavaScript 編程:5.面向對象的程序設計

面向對象的程序設計

理解對象

在 ECMA-262 中,對象的定義:無序屬性的集合,其屬性可以包含基本值、對象或者函數。

我們可以把 ECMAScript 的對象想象成散列表/字典:無非就是一組鍵值對,其中值可以是數據或函數。

每個對象都是基于一個引用類型創建的。

屬性類型

ECMA-262 第 5 版在定義只有內部才用的特性(attribute)時,描述了屬性(property)的各種特征。ECMA-262 定義這些特性是為了實現 JavaScript 引擎用的,因此在 JavaScript 中不能直接訪問它們。ECMAScript 中有兩種屬性:數據屬性訪問器屬性

1. 數據屬性

數據屬性包含一個數據值的位置。在這個位置可以讀取和寫入值。

數據屬性 描述
[[Configurable]] 表示能否通過 delete 刪除屬性從而重新定義屬性,能否修改屬性的特性,或者能否把屬性修改為訪問器屬性。默認值為 true
[[Enumerable]] 表示能否通過 for-in 循環返回屬性。默認值為 true
[[Writable]] 表示能否修改屬性的值。默認值為 true
[[Value]] 包含這個屬性的數據值。默認值為 undefined

要修改屬性默認的特性,必須使用 ECMAScript 5 的 Object.defineProperty() 方法。

這個方法接收三個參數:屬性所在的對象、屬性的名字和一個描述符對象。

其中,描述符(descriptor)對象的屬性必須是:configurableenumerablewritablevalue。設置其中的一或多個值,可以修改對應的特性值:

var person = {};

// 修改屬性默認的特性
Object.defineProperty(person, "name", {
    writable: false,
    value: "Nicholas"
});

alert(person.name); //Nicholas
person.name = "Greg";
alert(person.name); //Nicholas
  • 一旦把屬性定義為不可配置的(configurable = false),就不能再把它變回可配置了。也就是說,可以多次調用 Object.defineProperty() 方法修改同一個屬性,但在把 configurable 特性設置為 false 之后就會有限制了。
  • 在調用 Object.defineProperty() 方法時,如果不指定 configurableenumerable、和 writable 特性的默認值都是 false

2. 訪問器屬性

訪問器屬性不包含數據值,它們包含一對 gettersetter 函數(不過,這兩個函數都不是必需的)。在讀取訪問器屬性時,會調用 getter 函數,這個函數負責返回有效的值。在寫入訪問器屬性時,會調用 setter 函數并傳入新值,這個函數負責決定如何處理數據。訪問器屬性有如下 4 個特性:

訪問器屬性 描述
[[Configurable]] 表示能否通過 delete 刪除屬性從而重新定義屬性,能否修改屬性的特性,或者能否把屬性修改為數據屬性。對于直接在對象上定義的屬性,這個特性的默認值為 true
[[Enumerable]] 表示能否通過 for-in 循環返回屬性。對于直接在對象上定義的屬性,這 5 個特性的默認值為 true
[[Get]] 在讀取屬性時調用的函數。默認值為 undefined
[[Set]] 在寫入屬性時調用的函數。默認值為 undefined

訪問器屬性不能直接定義,必須使用 Object.defineProperty() 來定義。

var book = {
    _year: 2018, // _表示只能通過對象方法訪問的屬性
    edition: 1
};

Object.defineProperty(book, "year", {
    // getter 函數返回_year 的值,
    get: function() {
        return this._year;
    },
    // setter 函數通過計算來確定正確的版本。
    set: function(newValue) {
        if (newValue > 2018) {
            this._year = newValue;
            this.edition += newValue - 2018;
        }
    }
});

book.year = 2020;
console.log(book.edition); // 3

定義多個屬性

Object.defineProperties() 方法可以同時為對象定義多個屬性。
這個方法接收兩個對象參數:

  • 參數一:要添加和修改其屬性的對象;
  • 參數二:該屬性與第一個對象中要添加或修改的屬性一一對應。
var book = {};
        
Object.defineProperties(book, {
    // 數據屬性
    _year: {
        value: 2004
    },
    // 數據屬性
    edition: {
        value: 1
    },
    // 訪問器屬性
    year: {            
        get: function(){
            return this._year;
        },

        set: function(newValue){
            if (newValue > 2004) {
                this._year = newValue;
                this.edition += newValue - 2004;
            }                  
        }            
    }        
});

book.year = 2005;
alert(book.edition);   //2

讀取屬性的特性

ECMAScript 5 的 Object.getOwnPropertyDescriptor() 方法,可以獲取給定屬性的描述符。
這個方法接收兩個參數:屬性所在的對象和要讀取其描述符的屬性名稱。

返回值是一個對象:
如果是訪問器屬性,這個對象的屬性有 configurableenumerablegetset;
如果是數據屬性,這個對象的屬性有 configurableenumerablewritablevalue

var book = {};

// 同時定義多個屬性
Object.defineProperties(book, {
    // 定義數據屬性
    _year: {
        value: 2016
    },
    edition: {
        value: 1
    },
    // 定義訪問器屬性
    year: {
        get: function () {
            return this._year;
        },
        set: function () {
            if (newValue > 2018) {
                this._year = newValue;
                this.edition += newValue - 2018;
            }
        }
    }
});

// 獲取數據屬性
var descriptor = Object.getOwnPropertyDescriptor(book, "_year");
console.log(descriptor.value); // 2016
console.log(descriptor.configurable); // false
console.log(typeof descriptor.get); // undefined

// 獲取訪問器屬性
var descriptor = Object.getOwnPropertyDescriptor(book, "year");
console.log(descriptor.value); // undefined
console.log(descriptor.enumerable); // false
console.log(typeof descriptor.get); // function

創建對象

創建對象的兩種方式:

  1. 構造函數模式
var person = new Object();
person.name = "Tom";
person.age = 29;
person.job = "Software Engineer";

person.sayName = function() {
    alert(this.name); // this.name 將被解析為 person.name
};
  1. 字面量模式
var person = {
    name: "Tom",
    age : 29,
    job:"Software Engineer",

    sayName: function(){
        alert(this.name);
    }
};

缺點:使用同一個接口創建很多對象,會產生大量的重復代碼。

工廠模式

工廠模式抽象了創建具體對象的過程,考慮到在 ECMAScript 中無法創建類,開發人員就發明了一種函數,用函數來封裝以特定接口創建對象的細節

// 把創建對象的所有過程封裝在一個函數中
function createPerson(name, age, job) {

  // 把構造函數放在函數體中,并返回這個構造函數創建的實例對象
  var person = new Object();
  person.name = name;
  person.age = age;
  person.job = job;
  
  person.sayName = function() {
    console.log(this.name);
  };

  return person;
}

var person1 = createPerson('Andy', 25, 'Software Engineer');
var person2 = createPerson('Grey', 24, 'Teacher');

工廠模式雖然解決了創建多個相似對象的問題,但卻沒有解決對象識別的問題(無法識別一個對象的類型)。

構造函數模式

ECMAScript 中的構造函數可用來創建特定類型的對象。
原生構造函數:Object、Array ...

創建自定義的構造函數,從而定義自定義對象類型的屬性和方法:

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

var person1 = new Person('Andy', 25, 'Software Engineer');
var person2 = new Person('Grey', 24, 'Teacher');

// 檢測對象的類型
console.log(person1 instanceof Object); // true,因為所有對象均繼承自 Object
console.log(person1 instanceof Person); // true

構造函數模式&工廠模式的區別

  • 沒有顯式地創建對象;
  • 直接將屬性和方法賦給了 this 對象;
  • 沒有 return 語句。

?? 構造函數始終都應該以一個大寫字母開頭,而非構造函數則應該以一個小寫字母開頭。

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

// 把函數定義轉移到構造函數外部
// 所有對象都共享同一個全局的 sayName 函數
function sayName(){
    alert(this.name);
}

var person1 = new Person("Andy", 23, "Software Engineer");
var person2 = new Person("Bob", 35, "Army");

將構造函數當作函數

  • 構造函數與其他函數的唯一區別,就在于調用它們的方式不同。
  • 任何函數,只要通過 new 操作符來調用,那它就可以作為構造函數;
  • 任何函數,如果不通過 new 操作符來調用,那它跟普通函數也不會有什么兩樣。
// 當作構造函數使用
// 使用 new 操作符創建一個新對象
var person = new Person("Nicholas", 29, "Software Engineer");
person.sayName(); //"Nicholas"

// 作為普通函數使用,屬性和方法都被會添加給 window 對象
// 當在全局作用域中調用一個函數時,this 對象總是指向 Global 對象(在瀏覽器中就是 window 對象)。
Person("Greg", 27, "Doctor"); // 添加到 window
window.sayName(); //"Greg"

// 在另一個對象的作用域中調用 Person() 函數
// 在對象 o 的作用域中調用,因此調用后 o 就擁有了所有屬性和 sayName() 方法。
var o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName(); //"Kristen"

構造函數的問題

每個方法都要在每個實例上重新創建一遍。(對象無法共用同名函數)
ECMAScript 中的函數是對象,因此每定義一個函數,也就是實例化了一個對象。因此,不同實例上的同名函數是不相等的

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    // 每個 Person 實例都包含一個不同的 Function 實例
    this.sayName = new Function("alert(this.name)"); // 與聲明函數在邏輯上是等價的 
}

// 不同實例上的同名函數是不相等的
alert(person1.sayName == person2.sayName);  //false

原型模式

prototype(原型)屬性
該屬性指向函數的原型對象。原型對象中包含 constructor (構造函數)屬性、共享的屬性和方法。

  • 每個函數都有一個 prototype (原型)屬性,這個屬性是一個指針,指向一個對象,而這個對象的用途是包含可以由特定類型的所有實例共享的屬性和方法。
  • prototype 就是「通過調用構造函數而創建的那個實例對象」的原型對象 。使用原型對象的好處是讓所有對象實例共享它所包含的屬性和方法。
// 構造函數變成了空函數
function Person(){
}

// 將 sayName() 方法和所有屬性直接添加到了 Person 的 prototype 屬性中。
Person.prototype.name = "andy";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
    alert(this.name);
};

var person1 = new Person();
person1.sayName(); // "andy"

var person2 = new Person();
person2.sayName(); // "andy"

alert(person1.sayName == person2.sayName); // true,所有實例共享同一個方法

// ??????
// isPrototypeOf():判斷實例對象與原型對象之間的關系
// 因為它們內部都有一個指向 Person.prototype 的指針,因此都返回了 true。
alert(Person.prototype.isPrototypeOf(person1)); //Person 是不是 person1 實例的原型?true
alert(Person.prototype.isPrototypeOf(person2)); //true

// ??????
// ES 5 新增,Object.getPrototypeOf():返回 [[Prototype]] 的值。
alert(Object.getPrototypeOf(person1) == Person.prototype); // true
alert(Object.getPrototypeOf(person1).name); // andy

理解原型對象

無論什么時候,只要創建了一個新函數,就會根據一組特定的規則為該函數創建一個 prototype 屬性,這個屬性指向函數的原型對象。

在默認情況下,所有原型對象都會自動獲得一個 constructor (構造函數)屬性,這個屬性包含一個指向 prototype 屬性所在函數的指針(即:原型對象中的 constructor 屬性是一個指針,這個指針指回了 prototype 屬性所在的函數)。

原型對象示意圖

圖中:

  • Person.prototype 指向了原型對象。
  • Person.prototype.constructor 又指回了 Person 的構造函數。
  • 實例的內部屬性 [[Prototype]] 指針僅指向原型對象,而不指向構造函數。

每當代碼讀取某個對象的某個屬性時,都會執行一次搜索,目標是具有給定名字的屬性。搜索首先從對象實例本身開始。如果在實例中找到了具有給定名字的屬性,則返回該屬性的值;如果沒有找到,則繼續搜索指針指向的原型對象,在原型對象中查找具有給定名字的屬性。如果在原型對象中找到了這個屬性,則返回該屬性的值。

當為對象實例添加一個屬性時,這個屬性就會屏蔽原型對象中保存的同名屬性;換句話說,添加這個屬性只會阻止我們訪問原型中的那個屬性,但不會修改那個屬性。即使將這個屬性設置為 null,也只會在實例中設置這個屬性,而不會恢復其指向原型的連接。不過,使用 delete 操作符則可以完全刪除實例屬性,從而讓我們能夠重新訪問原型中的屬性:

function Person(){
}

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

var person1 = new Person();
var person2 = new Person();

person1.name = "Greg";
alert(person1.name);   //"Greg" ——來自實例
alert(person2.name);   //"Nicholas" ——來自原型

delete person1.name;   // delete 刪除實例屬性
alert(person1.name);   //"Nicholas" ——來自原型

使用 hasOwnProperty() 方法可以檢測一個屬性是存在于實例中(返回 true),還是存在于原型中(返回 false)。

function Person(){
}

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

var person1 = new Person();
var person2 = new Person();

alert(person1.hasOwnProperty("name"));  //false

person1.name = "Greg"; // 設置實例屬性
alert(person1.name);   //"Greg" ——來自實例
alert(person1.hasOwnProperty("name"));  //true

alert(person2.name);   //"Nicholas" ——來自原型
alert(person2.hasOwnProperty("name"));  //false

delete person1.name;   // delete 刪除實例屬性
alert(person1.name);   //"Nicholas" ——來自原型
alert(person1.hasOwnProperty("name"));  //false

原型與 in 操作符

  • 在單獨使用時,in 操作符會在通過對象能夠訪問給定屬性時返回 true,無論該屬性存在于實例中還是原型中。
  • 只要 in 操作符返回 truehasOwnProperty() 返回 false,就可以確定屬性是原型中的屬性
function Person(){
}

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

var person1 = new Person();
var person2 = new Person();

// hasOwnProperty():實例屬性存在于原型中,返回 false
console.log(person1.hasOwnProperty("name")); // false

// in:不判斷存在于原型中還是實例中,只要有就返回 true
console.log("name" in person1); // true

// 只要 in 操作符返回 true 而 hasOwnProperty() 返回 false,就可以確定屬性是原型中的屬性。
function hasPrototypeProperty(object, name) {
    return !object.hasOwnProperty(name) && (name in object);
}

枚舉對象屬性

  • 在使用 for-in 循環時,返回的是所有能夠通過對象訪問的、可枚舉的(enumerated)屬性,其中既包括存在于實例中的屬性,也包括存在于原型中的屬性。
  • 要取得對象上所有可枚舉的實例屬性,可以使用 ECMAScript 5 的 Object.keys() 方法。這個方法接收一個對象作為參數,返回一個包含所有可枚舉屬性的字符串數組。
  • 如果你想要得到所有實例屬性,無論它是否可枚舉,都可以使用 Object.getOwnPropertyNames() 方法。
function Person(){
}

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

// 原型屬性
var keys = Object.keys(Person.prototype);
console.log(keys); //[ 'name', 'age', 'job', 'sayName' ]

var person1 = new Person();
person1.name = "Andy";
person1.age = 24;

// 實例屬性
var person1Key = Object.keys(person1);
console.log(person1Key); //[ 'name', 'age' ]

更簡單的原型語法

用一個包含所有屬性和方法的對象字面量來重寫整個原型對象。

function Person(){
}

// 本質上完全重寫了默認的 prototype 對象
Person.prototype = {
    name : "Andy",
    age : 28,
    job : "Doctor",
    sayName : function () {
        alert(this.name);
    }
};

// 上面 constructor 屬性不再指向 Person。
// 默認情況下,每創建一個函數,就會同時創建它的 prototype 對象,這個對象也會自動獲得 constructor 屬性。
// 而這里使用的語法,本質上完全重寫了默認的 prototype 對象,因此 constructor 屬性也就變成了新對象的 constructor 屬性(指向 Object 構造函數),不再指向 Person 函數。

// 解決方案
// 注意,以這種方式重設 constructor 屬性會導致它的 [[Enumerable]] 特性被設置為 true。
// 默認情況下,原生的 constructor 屬性是不可枚舉的
function Person(){
}

Person.prototype = {
    constructor : Person, // 將它設置為適當的值
    name : "Andy",
    age : 28,
    job : "Doctor",
    sayName : function () {
        alert(this.name);
    }
};

原型的動態性

  • 對原型對象所做的任何修改都能夠立即從實例上反映出來——即使是先創建了實例后修改原型也照樣如此。
  • 實例中的指針僅指向原型,而不指向構造函數。
  • 調用構造函數時會為實例添加一個指向最初原型的 [[Prototype]] 指針,而把原型修改為另外一個對象就等于切斷了構造函數與最初原型之間的聯系。
function Person() {

}
var friend = new Person();

Person.prototype = {
  constructor: Person,
  name: "Tom",
  age: 24,
  job: "Software Engineer",
  sayName: function () {
      console.log(this.name);
  }
};

friend.sayName(); // TypeError: friend.sayName is not a function
重寫整個原型對象

原生對象的原型

  • 原型模式的重要性不僅體現在創建自定義類型方面,就連所有原生的引用類型,都是采用這種模式創建的。
  • 所有原生引用類型(Object、Array、String,等等)都在其構造函數的原型上定義了方法。
// 給基本包裝類型 String 添加了一個名為 startsWith() 的方法
String.prototype.startsWith = function (text) {
    return this.indexOf(text) == 0;
};

var msg = "Hello world!";
alert(msg.startsWith("Hello"));   //true

不推薦在產品化的程序中修改原生對象的原型。

原型對象的問題

  1. 無法為構造函數傳遞初始化參數,默認情況下,所有實例的初始化屬性值相同。
  2. 所有屬性、方法均共享。(多個實例共享同一個屬性的問題!)
function Person(){
}

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

var person1 = new Person();
var person2 = new Person();

// 修改 person1.friends 引用的數組,向數組中添加了一個字符串。
// 由于 friends 數組存在于 Person.prototype 而非 person1 中,
// 所以修改也會通過 person2.friends (與 person1.friends 指向同一個數組))反映出來
person1.friends.push("Van");

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

組合使用構造函數模式和原型模式

構造函數模式用于定義實例屬性,而原型模式用于定義方法和共享的屬性。
構造函數與原型混成的模式,是目前在 ECMAScript 中使用最廣泛、認同度最高的一種創建自定義類型的方法。

// 構造函數中定義實例屬性
function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["Andy", "Bob"];
}

// 原型中定義所有實例共享的屬性和方法
Person.prototype = {
    constructor: Person,
    sayName: function () {
        alert(this.name);
    }
};

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

// 修改 person1.friends 并不會影響到 person2.friends
person1.friends.push("Van");
console.log(person1.friends); //[ 'Andy', 'Bob', 'Van' ]
console.log(person2.friends); //[ 'Andy', 'Bob' ]
console.log(person1.friends === person2.friends); // false
console.log(person1.sayName === person2.sayName); // true

動態原型模式

動態原型模式:把所有信息都封裝在了構造函數中,而通過在構造函數中初始化原型(僅在必要的情況下),又保持了同時使用構造函數和原型的優點。換句話說,可以通過檢查某個應該存在的方法是否有效,來決定是否需要初始化原型。

解釋:使用「組合使用構造函數模式和原型模式」的方式創建一個自定義對象時,構造函數的代碼和原型的代碼是獨立開來的。因此,為了解決這個“代碼分散”的問題,通過在一個構造函數中加入 if 表達式進行判斷,可以實現將所有代碼匯集在一個方法中的目的。

// 構造函數
function Person(name, age, job) {

    // 屬性
    this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["Andy", "Bob"];

    // 方法
    // 只在 sayName() 方法不存在的情況下,才會將它添加到原型中
    if(typeof this.sayName != "function"){
        Person.prototype.sayName = function(){
            alert(this.name);
        }
    }
}

var friend = new Person("Andy", 24, "Doctor");
friend.sayName();

??

使用動態原型模式時,不能使用對象字面量重寫原型。如果在已經創建了實例的情況下重寫原型,那么就會切斷現有實例與新原型之間的聯系。

寄生構造函數模式(不推薦使用)

寄生構造函數模式:創建一個函數,該函數的作用僅僅是封裝創建對象的代碼,然后再返回新創建的對象。

// Person 函數創建了一個新對象,并以相應的屬性和方法初始化該對象,然后又返回了這個對象
function Person(name, age, job){
    var object = new Object();
    object.name = name;
    object.age = age;
    object.job = job;
    object.sayName = function(){
        alert(this.name);
    }
    return object;
}

// 除了使用 new 操作符并把使用的包裝函數叫做構造函數之外,這個模式跟工廠模式其實是一模一樣的。
var friend = new Person("Andy", 24, "Doctor");
friend.sayName();

關于寄生構造函數模式,有一點需要說明:首先,返回的對象與構造函數或者與構造函數的原型屬性之間沒有關系;也就是說,構造函數返回的對象與在構造函數外部創建的對象沒有什么不同。為此,不能依賴 instanceof 操作符來確定對象類型。由于存在上述問題,我們建議在可以使用其他模式的情況下,不要使用這種模式。

穩妥構造函數模式

所謂穩妥對象,指的是沒有公共屬性,而且其方法也不引用 this 的對象。
穩妥對象最適合在一些安全的環境中(這些環境中會禁止使用 thisnew),或者在防止數據被其他應用程序(如 Mashup 程序)改動時使用。
穩妥構造函數遵循與寄生構造函數類似的模式,但有兩點不同:

  1. 新創建對象的實例方法不引用 this
  2. 不使用 new 操作符調用構造函數。
function Person(name, age, job){

    // 創建要返回的對象
    var object = new Object();

    // 定義私有變量和函數
    object.name = name;
    object.age = age;
    object.job = job;

    // 添加方法
    object.sayName = function(){
        alert(name);
    }
    // 返回對象
    return object;
}

var friend = Person("Andy", 24, "Doctor");
// 除了調用 sayName() 方法外,沒有別的方式可以訪問其數據成員。
friend.sayName();

穩妥構造函數模式提供的這種安全性,使得它非常適合在某些安全執行環境——例如,ADsafe(www.adsafe.org)和 Caja(http://code.google.com/p/google-caja/)提供的環境下使用。

繼承

許多 OO 語言都支持兩種繼承方式:

  1. 接口繼承:只繼承方法簽名;
  2. 實現繼承:繼承實際的方法。

由于函數沒有簽名,在 ECMAScript 中無法實現接口繼承。ECMAScript 只支持實現繼承,而且其實現繼承主要是依靠原型鏈來實現的。

原型鏈

基本思想:利用原型讓一個引用類型繼承另一個引用類型的屬性和方法。

構造函數、原型和實例的關系:每個構造函數都有一個原型對象,原型對象包含一個指向構造函數的指針,而實例包含一個指向原型對象的內部指針。

假如我們讓原型對象等于另一個類型的實例,結果會怎么樣呢?顯然,此時的原型對象將包含一個指向另一個原型的指針,相應地,另一個原型中也包含著一個指向另一個構造函數的指針。假如另一個原型又是另一個類型的實例,那么上述關系依然成立,如此層層遞進,就構成了實例與原型的鏈條。

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

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

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

/* 
 * SubType 繼承了 SyperType
 * 繼承是通過創建 SuperType 的實例,并將該實例賦給 SubType.prototype 實現的。
 * 這里 SubType 重寫了 prototype 屬性, [[prototype]] 的 constructor 屬性指向 SuperType。
 * 即:讓 SubType 的原型對象等于 SuperType 的實例
 */
SubType.prototype = new SuperType();

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

var instance = new SubType();
console.log(instance.getSuperValue()); // true
繼承關系示意圖

說明:
instance 實例指向 SubType 的原型,SubType 的原型又指向 SuperType 的原型。

默認的原型

所有函數的默認原型都是 Object 的實例,因此默認原型都會包含一個內部指針,指向 Object.prototype
這也正是所有自定義類型都會繼承 toString()valueOf() 等默認方法的根本原因。

確定原型和實例的關系

1. instanceof 操作符

通過 instanceof 操作符來測試實例與原型鏈中出現過的構造函數,結果就會返回 true

// instance 是 Object、SuperType 或 SubType 中任何一個類型的實例
alert(instance instanceof Object); // true
alert(instance instanceof SuperType); // true
alert(instance instanceof SubType); // true
2. isPrototypeOf() 方法

只要是原型鏈中出現過的原型,都可以說是該原型鏈所派生的實例的原型,因此 isPrototypeOf() 方法也會返回 true

alert(Object.prototype.isPrototypeOf(instance)); // true
alert(SuperType.prototype.isPrototypeOf(instance)); // true
alert(SubType.prototype.isPrototypeOf(instance)); // true

謹慎地定義方法

子類型有時候需要重寫超類型中的某個方法,或者需要添加超類型中不存在的某個方法。但不管怎樣,給原型添加方法的代碼一定要放在替換原型的語句之后

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

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

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

// SubType 繼承了 SuperType
SubType.prototype = new SuperType();

// SubType 添加新方法
SubType.prototype.getSubValue = function (){
    return this.subproperty;
};

// SubType 重寫超類型中的方法
SubType.prototype.getSuperValue = function (){
    return false;
};

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

在通過原型鏈實現繼承時,不能使用對象字面量創建原型方法。因為這樣做就會重寫原型鏈:

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

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

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

// SubType 繼承了 SuperType
SubType.prototype = new SuperType();

// 使用字面量添加新方法,會導致上一行代碼無效
SubType.prototype = {

    // SubType 的新方法
    getSubValue : function (){
        return this.subproperty;
    },

    // SubType 的新方法
    someOtherMethod : function (){
        return false;
    }
};

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

原型鏈的問題

  • 問題一:在通過原型來實現繼承時,原型實際上會變成另一個類型的實例。于是,原先的實例屬性也就順理成章地變成了現在的原型屬性了。
// 包含引用類型值的原型屬性會被所有實例共享
function SuperType(){
  this.colors = ["red","green","blue"];
}

function SubType(){
}

// 繼承了 SuperType
// 相當于 SubType.prototype 變成了 SuperType 的一個實例
SubType.prototype = new SuperType();

var instance1 = new SubType();
instance1.colors.push("black"); 
console.log(instance1.colors); //[ 'red', 'green', 'blue', 'black' ]

var instance2 = new SubType();
console.log(instance2.colors); // [ 'red', 'green', 'blue', 'black' ]
  • 問題二:在創建子類型的實例時,不能向超類型的構造函數中傳遞參數。

??????

實踐中很少會單獨使用原型鏈。

借用構造函數

基本思想:在子類型構造函數的內部調用超類型構造函數。

在子類型構造函數的內部調用超類型構造函數。這樣就可以做到每個實例都具有自己的屬性,同時還能保證只使用構造函數模式來定義類型。

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

// SubType
function SubType() {
    // 繼承了 SuperTyoe
    // 在(未來將要)新創建的 SubType 實例的環境下調用了 SuperType 構造函數。
    SuperType.call(this);
}

var instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); //[ 'red', 'blue', 'green', 'black' ]

var instance2 = new SubType();
console.log(instance2.colors); //[ 'red', 'blue', 'green' ]

傳遞參數

相對于原型鏈而言,借用構造函數有一個很大的優勢,即可以在子類型構造函數中向超類型構造函數傳遞參數

// SuperType
function SuperType(name) {
    this.name = name;
}

// SubType
function SubType() {
    // 繼承了 SuperType,同時還傳遞了參數
    SuperType.call(this, "Bob");

    // 實例屬性
    this.age = 34;
}

var instance1 = new SubType();
console.log(instance1.name); // Bob
console.log(instance1.age); //34

借用構造函數的問題

如果僅僅是借用構造函數,那么也將無法避免構造函數模式存在的問題——方法都在構造函數中定義,因此函數復用就無從談起了。而且,在超類型的原型中定義的方法,對子類型而言也是不可見的,結果所有類型都只能使用構造函數模式。考慮到這些問題,借用構造函數的技術也是很少單獨使用的。

組合繼承

將原型鏈和借用構造函數的技術組合到一塊,從而發揮二者之長的一種繼承模式。

基本思想:使用原型鏈實現對原型屬性和方法的繼承,而通過借用構造函數來實現對實例屬性的繼承。

// SuperType 組合使用構造函數模式和原型模式
function SuperType(name){
    this.name = name;
    this.colors = ["red","green","blue"];
}

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

// SubType 組合繼承(原型鏈+借用構造函數)
function SubType(name, age){
    // 通過借用構造函數,繼承屬性
    SuperType.call(this, name);

    this.age = age;
}

// 通過原型鏈,繼承方法
// 將 SuperType 的實例賦值給 SubType 的原型,然后又在該新原型上定義了方法 sayAge()。
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function (){
    console.log(this.age);
};

var instance1 = new SubType("Andy", 23);
instance1.colors.push("black"); 
console.log(instance1.colors); //[ 'red', 'green', 'blue', 'black' ]
instance1.sayName(); // Andy
instance1.sayAge(); //23

var instance2 = new SubType("Bob", 24);
console.log(instance2.colors); //[ 'red', 'green', 'blue' ]
instance2.sayName(); // Bob
instance2.sayAge(); //24

原型式繼承

借助原型可以基于已有的對象創建新對象,同時還不必因此創建自定義類型。

function object(o) {
    function F() {} // 先創建一個臨時性的構造函數
    F.prototype = o; // 然后將傳入的對象作為這個構造函數的原型
    return new F(); // 返回這個臨時類型的新實例
}
// 從本質上來說,object() 對傳入其中的對象執行了一次淺復制。


// 前提條件:1.有一個對象可以作為另一個對象的基礎。
var person = {
    name : "Andy",
    friends : ["Shelby", "Court", "Van"]
};

// 2.把基礎對象 person 傳遞給 object() 函數,然后再根據具體需求對得到的對象加以修改
var anotherPerson = object(person);
anotherPerson.name = "Grey";
anotherPerson.friends.push("Rob");

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

console.log(person.friends); //[ 'Shelby', 'Court', 'Van', 'Rob', 'Barbie' ]
console.log(anotherPerson.friends); //[ 'Shelby', 'Court', 'Van', 'Rob', 'Barbie' ]
console.log(yetAnotherPerson.friends); //[ 'Shelby', 'Court', 'Van', 'Rob', 'Barbie' ]

console.log(anotherPerson.name); //Grey
console.log(yetAnotherPerson.name); //linda

Object.create() 方法

ECMAScript 5 通過新增 Object.create() 方法規范化了原型式繼承。這個方法接收兩個參數:

  • 一個用作新對象原型的對象;
  • 一個為新對象定義額外屬性的對象(可選的);

在傳入一個參數的情況下,Object.create()object() 方法的行為相同。

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

// 接收一個參數
var anotherPerson = Object.create(person);
anotherPerson.name = "Grey";
anotherPerson.friends.push("Rob");

// 接收兩個參數
// 以這種方式指定的任何屬性都會覆蓋原型對象上的同名屬性。
var anotherPerson = Object.create(person ,{
    name:{
        value: "Grey"
    }
});

寄生式繼承

寄生式繼承的思路與寄生構造函數和工廠模式類似,即創建一個僅用于封裝繼承過程的函數,該函數在內部以某種方式來增強對象,最后再像真的是它做了所有工作一樣返回對象。

function createAnother(original) {
    var clone =object(original); // 通過調用函數創建一個新對象
    clone.sayHi = function() {   // 以某種方式來增強這個對象
        console.log("Hi");
    };
    return clone; // 返回這個對象
}

使用寄生式繼承來為對象添加函數,會由于不能做到函數復用而降低效率;這一點與構造函數模式類似。

寄生組合式繼承

組合繼承是 JavaScript 最常用的繼承模式;不過,它也有自己的不足。組合繼承最大的問題就是無論什么情況下,都會調用兩次超類型構造函數

  • 一次是在創建子類型原型的時候;

  • 另一次是在子類型構造函數內部。

沒錯,子類型最終會包含超類型對象的全部實例屬性,但我們不得不在調用子類型構造函數時重寫這些屬性。

// SuperType 組合使用構造函數模式和原型模式
function SuperType(name) {
    this.name = name;
    this.colors = ["red","blue","green"];
}

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

// 當調用 SubType 構造函數時,又會調用一次 SuperType 構造函數,這一次又在新對象上創建了實例屬性 name 和 colors。于是,這兩個屬性就屏蔽了原型中的兩個同名屬性。
function SubType(name, age) {
    SuperType.call(this, name); // 第二次調用 SuperType()

    this.age = age;
}

// 在第一次調用 SuperType 構造函數時, SubType.prototype 會得到兩個屬性:name 和 colors;
// 它們都是 SuperType 的實例屬性,只不過現在位于 SubType 的原型中。
SubType.prototype = new SuperType(); // 第一次調用 SuperType()
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function () {
    console.log(this.age);
};

所謂寄生組合式繼承,即通過借用構造函數來繼承屬性通過原型鏈的混成形式來繼承方法。其背后的基本思路是:不必為了指定子類型的原型而調用超類型的構造函數,我們所需要的無非就是超類型原型的一個副本而已。本質上,就是使用寄生式繼承來繼承超類型的原型,然后再將結果指定給子類型的原型。

function inheritProtoptype(subType, superType) {
    var prototype = object(superType.prototype); // 創建對象
    prototype.constructor = subType; // 增強對象
    subType.prototype = prototype; // 指定對象
}

// 函數內部執行流程:
// 1. 創建超類型原型的一個副本;
// 2. 為創建的副本添加 constructor 屬性,從而彌補因重寫原型而失去的默認的 constructor 屬性。
// 3. 將新創建的對象(即副本)賦值給子類型的原型;

這個例子的高效率體現在它只調用了一次 SuperType 構造函數,并且因此避免了在 SubType.prototype 上面創建不必要的、多余的屬性。與此同時,原型鏈還能保持不變;因此,還能夠正常使用instanceofisPrototypeOf()。開發人員普遍認為寄生組合式繼承是引用類型最理想的繼承范式。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,247評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,520評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,362評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,805評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,541評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,896評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,887評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,062評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,608評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,356評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,555評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,077評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,769評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,175評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,489評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,289評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,516評論 2 379

推薦閱讀更多精彩內容