我們來深入、系統地詳解 JavaScript 的原型與繼承。這是 JavaScript 中最核心、最獨特的特性之一。
第一部分:核心概念 - 為什麼需要原型?
JavaScript 在誕生之初,被設想為一種簡單的腳本語言,並未打算引入類的概念。為了實現對象之間的屬性和方法共享,從而節省內存並建立繼承關係,設計了基於原型的繼承模型。
第二部分:理解 __proto__ 與 prototype
這是最容易混淆的兩個概念。請記住它們的核心區別:
-
__proto__(讀作 "dunder proto"):- 它是每個對象實例都有的一個內置屬性。
- 它指向創建該實例的構造函數的
prototype對象。 - 它是對象查找屬性和方法的實際鏈條,即原型鏈的鏈接點。
- (注意:
__proto__是一個歷史遺留的訪問器,現代標準中更推薦使用Object.getPrototypeOf(obj)來獲取)
-
prototype:- 它是只有函數才有的一個屬性(除了
Function.prototype.bind()創建的函數)。 - 當這個函數被作為構造函數 (使用
new關鍵字調用) 時,它創建的所有實例的__proto__都將指向這個函數的prototype對象。 - 它的主要用途是存儲可以被所有實例共享的屬性和方法。
- 它是只有函數才有的一個屬性(除了
一句話總結:
prototype是函數的屬性,是一個藍圖。__proto__是對象的屬性,指向這個藍圖。- 實例的
__proto__=== 其構造函數的prototype。
第三部分:圖解原型鏈
讓我們通過代碼來理解。
// 1. 創建一個構造函數
function Person(name) {
this.name = name;
}
// 2. 在構造函數的 prototype 上添加方法
Person.prototype.sayHello = function() {
console.log(`Hello, I'm ${this.name}`);
};
// 3. 使用 new 創建實例
const alice = new Person('Alice');
const bob = new Person('Bob');
// 4. 訪問屬性和方法
alice.sayHello(); // "Hello, I'm Alice"
bob.sayHello(); // "Hello, I'm Bob"
console.log(alice.sayHello === bob.sayHello); // true! 方法被共享了
屬性查找過程(原型鏈機制):
當訪問 alice.sayHello() 時,JavaScript 引擎會:
- 首先檢查
alice對象本身是否有sayHello屬性。沒有。 - 然後通過
alice.__proto__找到Person.prototype,檢查它是否有sayHello。找到了!於是執行它。 - 如果
Person.prototype上也沒有,引擎會繼續通過Person.prototype.__proto__找到Object.prototype進行查找。 - 如果直到
Object.prototype(其__proto__為null) 都沒找到,則返回undefined。
第四部分:實現繼承的幾種方式
1. 原型鏈繼承 (直接繼承)
function Parent() {
this.names = ['kevin', 'daisy'];
}
Parent.prototype.getName = function () { return this.names; };
function Child() {}
// 關鍵:讓 Child 的原型指向 Parent 的實例
Child.prototype = new Parent();
const child1 = new Child();
child1.names.push('yayu');
console.log(child1.getName()); // ['kevin', 'daisy', 'yayu']
const child2 = new Child();
console.log(child2.getName()); // ['kevin', 'daisy', 'yayu']
// 問題!child2 的 names 也被修改了,因為所有實例共享了同一個 Parent 實例的屬性。
缺點:
- 引用類型的屬性被所有實例共享。
- 創建子類實例時,無法向父類構造函數傳參。
2. 構造函數繼承 (經典繼承)
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.sayName = function() { console.log(this.name); };
function Child(name, age) {
// 關鍵:在子類構造函數中"執行"父類構造函數,並綁定子類的this
Parent.call(this, name); // 相當於 this.Parent(name)
this.age = age;
}
const child1 = new Child('Alice', 10);
child1.colors.push('green');
console.log(child1.colors); // ['red', 'blue', 'green']
console.log(child1.name); // 'Alice'
const child2 = new Child('Bob', 12);
console.log(child2.colors); // ['red', 'blue'] // 互不影響
// child1.sayName(); // 報錯!無法繼承父類原型上的方法
優點:解決了引用類型共享問題和傳參問題。
缺點:方法都在構造函數中定義,無法實現函數複用。並且,無法繼承父類原型上的屬性和方法。
3. 組合繼承 (最常用)
結合了原型鏈繼承和構造函數繼承的優點。
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.sayName = function() { console.log(this.name); };
function Child(name, age) {
// 1. 繼承屬性
Parent.call(this, name); // 第二次調用 Parent
this.age = age;
}
// 2. 繼承方法
Child.prototype = new Parent(); // 第一次調用 Parent
// 修正 constructor 指向
Child.prototype.constructor = Child;
Child.prototype.sayAge = function() { console.log(this.age); };
const child1 = new Child('Alice', 10);
child1.colors.push('green');
console.log(child1.colors); // ['red', 'blue', 'green']
child1.sayName(); // 'Alice'
child1.sayAge(); // 10
const child2 = new Child('Bob', 12);
console.log(child2.colors); // ['red', 'blue']
child2.sayName(); // 'Bob'
child2.sayAge(); // 12
優點:融合優點,是 JavaScript 中最常用的繼承模式。
缺點:調用了兩次父類構造函數,生成了兩份屬性(一份在實例上,一份在 Child.prototype 上)。
4. 寄生組合式繼承 (最理想)
這是解決組合繼承缺點的最完美方式。
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.sayName = function() { console.log(this.name); };
function Child(name, age) {
// 只調用一次 Parent 構造函數
Parent.call(this, name);
this.age = age;
}
// 關鍵步驟:創建一個空的函數對象,將其原型指向 Parent.prototype
// 避免了調用 new Parent(),從而不會初始化父類的屬性到原型上
function F() {}
F.prototype = Parent.prototype;
// 將 Child 的原型指向這個空函數的實例
Child.prototype = new F();
// 修正 constructor
Child.prototype.constructor = Child;
// 添加子類原型方法
Child.prototype.sayAge = function() { console.log(this.age); };
const child = new Child('Alice', 10);
現代簡化寫法 (使用 Object.create):
// ... Parent 和 Child 構造函數同上 ...
// 替換上面的 F 函數部分
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
// ...
優點:
- 只調用一次父類構造函數。
- 避免了在子類原型上創建不必要的、多餘的屬性。
- 原型鏈保持不變。
這是 ES6 的 class extends 繼承在底層實現的原理。
第五部分:ES6 的 Class 語法糖
ES6 引入了 class 關鍵字,讓原型繼承的寫法更加清晰、更像傳統面嚮對象語言。
class Parent {
constructor(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
// 方法自動被添加到 Parent.prototype 上
sayName() {
console.log(this.name);
}
}
class Child extends Parent { // 使用 extends 實現繼承
constructor(name, age) {
super(name); // 相當於 Parent.call(this, name),必須在 this 前調用
this.age = age;
}
sayAge() {
console.log(this.age);
}
}
const child = new Child('Alice', 10);
child.sayName(); // 'Alice'
child.sayAge(); // 10
console.log(child instanceof Child); // true
console.log(child instanceof Parent); // true
重要提示:
class本質只是一個語法糖,它的底層實現仍然是基於原型的寄生組合式繼承。typeof Parent的結果是"function"。類本身就是函數。- 類中定義的方法都是不可枚舉的 (
Object.keys(Parent.prototype)拿不到sayName),這與 ES5 的行為不同。
總結
- 原型是 JavaScript 實現繼承和共享特性的根本機制。
- 理解
__proto__(實例的原型鏈鏈接) 和prototype(構造函數的原型對象) 的區別至關重要。 - 原型鏈是屬性/方法查找的路徑,它構成了繼承的基礎。
- 繼承方式從有問題的原型鏈繼承,發展到功能完備但效率不高的組合繼承,最終到完美的寄生組合式繼承。
- ES6 的 Class 是原型繼承的語法糖,它讓代碼更易寫易讀,但其本質並未改變。
掌握原型與繼承,是真正理解 JavaScript 對象模型和麪向對象編程的關鍵。