初步瞭解原型鏈與繼承

原型相關的內容是 JavaScript 中的重要概念, 它相似於經典面嚮對象語言中的類, 但又不徹底相同, 原型的主要做用是實現對象的繼承。這篇文章將討論 JavaScript 中的原型、原型鏈、利用原型鏈完成繼承、ES6中的 class 語法糖在內的幾個內容.函數

1. 原型和原型鏈

1. 原型和原型鏈

JavaScript 中每一個對象都有原型的引用, 當查找一個對象的屬性時, 若是在對象自己上找不到, 就會去他所連接到的原型上查找, 它連接到的原型上也會連接到其餘的原型, 這樣就造成了所謂的原型鏈, 沿着原型鏈一路尋找下去, 若是找到頭仍是沒有這個屬性, 則認爲這個對象上不存在此屬性。ui

例以下面的代碼中建立了3個對象, 每一個對象都有本身特有的屬性, 並且在每一個對象上找不到其餘對象的屬性:this

// demo. 1 - in
let obj1 = { prop1: 1 };
let obj2 = { prop2: 2 };
let obj3 = { prop3: 3 };

// // 判斷對象是否是有各自的屬性
console.log(obj1.prop1, obj2.prop2, obj3.prop3);  // 1 2 3

// 判斷對象是否是有其餘對象的屬性
console.log(obj1.prop2, obj1.prop3); // undefined undefined
複製代碼

上面的代碼能夠看出一個對象上只有本身定義的屬性, 沒有其餘對象的屬性。spa

經過內置的方法 Object.setPrototypeOf(A, B) 能夠把對象 A 的原型設成對象 B, 也就是說對象 B 成了對象 A 的原型。 此後若是要查找對象 A 的某個屬性時, 若是在A上找不到, 就會去對象B中尋找。prototype

例以下面的代碼經過 Object.setPrototypeOf 方法將 對象2 設成 對象1 的原型, 將 對象3 設成 對象2 的原型, 而後去訪問 對象1 和 對象2 原本沒有的屬性:3d

// demo.2 - setprototypeof
let obj1 = { prop1: 1 };
let obj2 = { prop2: 2 };
let obj3 = { prop3: 3 };

// 將 對象2 設成 對象1 的原型, 將 對象3 設成 對象2 的原型
Object.setPrototypeOf(obj1, obj2); 
Object.setPrototypeOf(obj2, obj3);

// 如今檢查 對象1 上面是否有屬性 prop2 和 prop3
console.log(obj1.prop2, obj1.prop3); // 2 3

// 檢查 對象2 上是否是有屬性 prop3
console.log(obj2.prop3); // 3
複製代碼

經過上面的代碼發現將一個對象設爲原型後, 就能夠訪問這個原型對象上的屬性了。上面的這段代碼, 將 obj2 設置成了 obj1 的原型, 將 obj3 設置成了 obj2 的原型, 這樣就產生了一條原型鏈: obj1 -> obj2 -> obj3。經過圖片能夠更加具象的理解這個問題: code

從圖中能夠看到,Obj3的原型屬性仍然指向了其餘方向,這說明Obj3也是有原型的,不只如此,除非手動的將一個對象的原型指向null,不然除了一個對象以外,全部對象都有本身的原型。那麼這個沒有原型的對象是誰呢?這個問題的答案和下面的問題的答案同樣:就是Object.prototype,也就是Object對象的原型對象。這要結合下面的問題來講明。cdn

那麼,上面圖中查找的過程有沒有終點呢?答案是確定的,終點是上面提到的 Object對象的原型對象,也就是Object.prototype,這是屬性查找的終點, 若是在Object.prototype仍找不到但願的屬性,則對象就被認爲不擁有這個屬性。對象

這說明了一個重要的問題:全部對象的原型最終都會連接到Object.prototype,不管中間通過多少其餘的原型對象。blog

從前有句話叫: 順着網線去打你; 如今能夠叫: 順着原型鏈去找你。

1.2 當要找的屬性存在於自身時

上面說, 當查找一個對象的屬性時, 會在對象上查找, 若是找不到就順着原型連接着找, 知道找到或者原型鏈到頭了爲止。

也就是說當能在本身身上找到想要的屬性時,就不用去原型鏈上尋找了。例以下面的例子:

let obj1 = { 
    prop1: 1,
    prop2: '呵呵呵' // 增長一個名爲 prop2 的屬性並將值設置成字符創類型
};

// 注意 obj2 的 prop2 屬性值是 2, 數值類型
let obj2 = { 
    prop2: 2  
};

// 將 對象2 設成 對象1 的原型
Object.setPrototypeOf(obj1, obj2); 

// 這個時候 obj2 的 prop2 屬性已經在原型鏈上了
// 實驗看 obj1.prop2 屬性究竟是哪一個
console.log(obj1.prop2);  // '呵呵呵'
複製代碼

上面的代碼將 obj2 設置成 obj1 的原型, 當 obj1 想找 prop2 屬性時, 在自身就找到了, 因此不用去它的原型 obj2 中尋找了。這個實驗說明了在尋找屬性時會從對象自身開始, 向着原型的方向去找, 找到的第一個就拿來直接用。

這種行爲也是利用原型鏈實現繼承的基礎之一。

2. 構造函數的原型

2.1 實例的原型指向的是構造函數的原型

同過上面的部分已經知道對象有本身的原型, 而函數也是對象, 因此函數也有原型, 一樣的, 構造函數也有原型。

構造函數的用法是配合 new 操做符建立一個類的實例,建立出的實例也是對象, 因此也是有原型的, 並且實例的原型就指向構造函數的原型。

還有一點是, 構造函數的原型中有個屬性名爲 constructor, 這是個引用類型的屬性, 它指向構造函數自己。

利用實例的原型指向構造函數的原型能夠實現一種巧妙的繼承方式: 將要建立的實例的不共享的屬性放在構造函數內部, 而公用的屬性定義在構造函數的原型上, 這樣建立出的實例即有各自不一樣的屬性, 還能順着原型鏈找到公用的屬性。如下面的代碼爲例:

// 1. 定義一個構造函數 Person
function Person(name){
    this.name = name;
}
Person.prototype.sayName = function(){
    console.log('個人名字是: ', this.name);
}

// 2. 利用構造函數實例化兩個 Person 的實例
let xm = new Person('小明');
let xh = new Person('小紅');

// 3. 實例調用公有的函數來輸出私有的屬性
xm.sayName(); // 個人名字是: 小明
xh.sayName(); // 個人名字是: 小紅
複製代碼

上面代碼中定義的 Person 構造函數有兩個屬性: namesayName, 並且分別將這兩個屬性定義在了構造函數體內和構造函數的原型上, 這樣經過構造函數 new 出來的實例擁有各自的私有屬性 name, 還可使用公有的屬性 sayName 函數來訪問本身的私有屬性, 經過代碼的運行結果能夠看出這種方法的有效性。

能夠將上面的例子中構造函數、實例與原型之間的關係用圖表示出來:

將 sayName 定義在構造函數的原型中的好處是,產生的每一個實例都會共享這個 sayName, 若是在構造函數自身上定義 sayName 的話, 每一個實例就都會有一個 sayName 函數了, 這樣沒有增長功能, 反而更加浪費內存了。

2.2 改變構造函數的原型指向

上節說到經過構造函數產生的實例的原型指向的也是構造函數的原型, 若是令構造函數的原型指向一個新的對象, 那麼以後再建立的實例的原型會指向哪呢? 是指向老的原型仍是新的對象呢? 答案是以後建立的實例的原型會指向新的對象, 以前建立的實例的原型會指向舊的對象。下面經過代碼來看一下:

function Person(){}
Person.prototype.fn1 = function(){
    console.log('fn1 reporting in old proto');
}

// 建立實例
let oldOne = new Person();

// 修改構造函數的原型指向
Person.prototype = {
    fn2(){  // 新原型對象中定義一個新函數
        console.log('fn2 in new proto');
    }
};

// 建立新實例
let newOne = new Person();

// 在修改原型指向以前建立的實例沒法訪問新原型中的方法
oldOne.fn1(); // fn1 reporting in old proto
oldOne.fn2(); // TypeError: oldOne.fn2 is not a function
console.log(oldOne.fn2);  // undefined

// 修改以後的原型的實例沒法訪問舊原型中的方法
newOne.fn1();  // TypeError: newOne.fn1 is not a function
console.log(newOne.fn1);  // undefined
newOne.fn2();  // fn2 in new proto
複製代碼

從上面的代碼能夠看出在建立一個實例時, 會將實例的原型引用設置成構造函數當前的原型上。這樣新實例沒法訪問舊原型裏的屬性, 舊實例也沒法訪問新原型裏的屬性。

上面的代碼能夠歸納爲下圖:

3. 實現繼承

不少書上都說實現利用原型鏈實現繼承的最佳方案是將一個對象的原型設置成另外一個對象的實例, 即 SubClass.prototype = new SuperClass(), 例如 Student.prototype = new Person()

例以下面的代碼:

// 定義類 Person 的構造函數
function Person(){}
Person.prototype.walk = function(){
    console.log('I am walking freely ...');
}

// 定義 Student 類的構造函數
function Student(){}
Student.prototype = new Person(); // 繼承 Person

let xm = new Student();
xm.walk();  // I am walking freely ... 成功調用繼承來的方法
複製代碼

經過上面的代碼發現, 將 Student 類構造函數的原型設置成 Person 的一個實例, 能夠實現繼承。經過原型鏈能夠更加清晰的看出這種繼承方式的實現原理:

如上圖所示, 令構造函數 Student 的原型指向 Person 的實例, 以後生成的 Student 實例的原型屬性就會自動指向 Person 實例了。並且 Person 有的屬性, Student 都有了。這就是 Student 的實例 xm 能調用它自身沒有的函數 walk 的緣由。

同時因爲舊的原型沒有被引用, 因此會被清理刪除。

可是在圖中還能夠看到這樣實現的繼承有個問題: 就是 Student 原本的原型上有個 constructor 屬性, 如今沒有了。雖然能夠經過原型鏈找到 Person 原型裏的 constructor 屬性, 可是這並非咱們想要的繼承方式。索性這個問題能夠用 Object.defineProperty 來解決, 代碼以下:

Object.defineProperty(Student.prototype, 'constructor', {
    enumerable: false,  // 設置不可枚舉
    value: Student,     // 指向 Student 構造函數
    writable: true      // 可寫
});
複製代碼

這樣, Student 的原型就有了 constructor 屬性, 並且指向了它該指向的地方。

4. class 語法糖

ES6 引入的 class 關鍵字可使繼承的實現更加簡潔, 並且更加像 Java 之類的經典 OO 語言對類的定義。

4.1 利用 class 關鍵字建立一個類

class Person {
    constructor(name){  // 構造函數
        this.name = name;
    }

    sayName(){
        console.log(this.name);
    }
}

let xm = new Person('小明');
xm.sayName();  // '小明'
複製代碼

上面的代碼定義了一個 Person 類, 並利用 constructor 傳入了 name 屬性, 並定義了一個 sayName 函數。經過實驗說明了這段代碼能夠正常執行。

上面的這段代碼的底層仍然是基於原型實現的, 能夠按照文章第 3 部分的內容轉換成以下的代碼:

function Person(name){
    this.name = name;
}

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

let xm = new Person('小明');
xm.sayName();  // '小明'
複製代碼

4.2 利用 class 關鍵字實現繼承

經過文章第3部分能夠看出, 實現繼承是比較麻煩的事情, 可是利用 class 這個語法糖中的 extendssuper 關鍵字能夠很簡潔的實現繼承,例以下面的代碼:

class Person {
    constructor(name){  // 構造函數
        this.name = name;
    }

    sayName(){
        console.log(this.name);
    }
}

class Student extends Person {
    constructor(name, grade){  // 構造函數, 傳入子類的參數
        super(name);  // 利用 super 關鍵字調用父類的構造函數
        this.grade = grade;
    }

    getGrade(){  // 定義子類本身的函數
        console.log(this.grade);
    }
}

let xm = new Student('小明', 99);
xm.sayName();  // '小明', 說明能夠調用父類的方法
xm.getGrade(); // 99, 正常調用子類的方法
複製代碼

參考:
《JavaScript忍者祕籍》
MDN

若有錯誤,感謝指正~

相關文章
相關標籤/搜索