這章終於來到原型基礎物件導向的另一個核心概念:「繼承
(Inheritance)」。
繼承主要的目的,就是為了讓一個物件能夠使用另一個物件的屬性和方法,如此能夠提高程式碼的複用性,有效減少重複的程式碼。
前面已經提過,JS 是原型基礎物件導向(Prototype-based OOP)語言,繼承的方式是架構原型鍊,讓下層物件能夠繼承上層物件的屬性和方法。
在 JS 中,手動綁定原型關係的方式,是將一個物件指為另一個物件的原型。
以下為自訂原型繼承的範例:
function Shape() { }
Shape.prototype.duplicate = function () {
console.log("duplicate")
}
function Circle(radius) {
this.radius = radius
}
// 讓 Shape 成為 Circle 的原型
Circle.prototype = Object.create(Shape.prototype)
const c = new Circle(1);
c.duplicate(); // duplicate
在上面的程式碼中,我們使用 Object.create
手動將 Shape.prototype
指定為 Circle.prototype
的原型,兩者在原型鍊上達成繼承關係,於是 Circle
實例化後的物件 c
便能夠使用 duplicate
方法。
在這裡有個微妙的細節,要繼承給下層物件的屬性和方法,必須定義在 prototype
上,否則追溯原型鍊尋找屬性時無法被取得。
如以下程式碼,猜猜看最後幾行分別會印出什麼?
function Shape() {
this.duplicate1 = function () {
console.log("duplicate1")
}
}
Shape.duplicate2 = function () {
console.log("duplicate2")
}
Shape.prototype.duplicate3 = function () {
console.log("duplicate3")
}
function Circle(radius) {
this.radius = radius
}
// 讓 Shape 成為 Circle 的原型
Circle.prototype = Object.create(Shape.prototype)
const s = new Shape();
const c = new Circle(1);
s.duplicate1();
s.duplicate2();
s.duplicate3();
c.duplicate1();
c.duplicate2();
c.duplicate3();
解答如下:
s.duplicate1(); // duplicate1
s.duplicate2(); // TypeError: s.duplicate2 is not a function
s.duplicate3(); // duplicate3
c.duplicate1(); // TypeError: c.duplicate1 is not a function
c.duplicate2(); // TypeError: c.duplicate2 is not a function
c.duplicate3(); // duplicate3
如前面所說,只有定義在 prototype
上面的 duplicate3
能夠順利被下層從原型鍊上取得。
duplicate1
則被定義在 Shape
內部,因此 Shape
的實例 s
能夠取得,但 Circle
的實例物件 c
卻無法取得,因為 duplicate1
並不在原型鍊上,因此無法找到這個方法,程式返回錯誤。
而 duplicate2
則是直接定義在 Shape
上,自然也要從 Shape
上呼叫,因此必須寫出 Shape.duplicate2()
才能順利打印出 duplicate2
。
以上驗證了,實例化物件雖然能夠使用 constructor
屬性找到原始的建構子函式,但兩者之間實際上還是倚靠 prototype
連結起來的。實例化物件並非直接取得建構子函式,而是建構子函式創造了這類型物件的 prototype
之後,藉由 prototype
將物件串聯在原型鍊上。
constructor
自訂原型時另外要注意的一點,在於手動替物件綁定原型後,會出現 constructor
丟失的問題,需要一併手動綁上:
function Shape() { }
Shape.prototype.duplicate = function () {
console.log("duplicate in prototype")
}
function Circle(radius) {
this.radius = radius
}
const c1 = new Circle(1);
console.log(c1.__proto__.constructor.name) // Circle
console.log(c1.__proto__.__proto__.constructor.name) // Object
// 讓 Shape 成為 Circle 的原型
Circle.prototype = Object.create(Shape.prototype)
const c2 = new Circle(1);
// 因為重新指定 __proto__ 原本的 constructor 丟失
console.log(c2.__proto__.constructor.name) // Shape
console.log(c2.__proto__.__proto__.constructor.name) // Shape
// Circle.prototype 已被覆寫,constructor 需要手動指定
Circle.prototype.constructor = Circle;
const c3 = new Circle(1);
console.log(c3.__proto__.constructor.name) // Circle
console.log(c3.__proto__.__proto__.constructor.name) // Shape
__proto__
屬性已廢棄,被強烈建議只用於偵錯時檢視,不要使用Circle.__proto__ = Object.create(Shape.prototype)
等賦值操作。
從上面 Circle
的三個實例化可以看到,在未綁定 prototype
前, c1
從原型鍊上找到的 constructor
是 Circle
,再往上一層是 Object
。
而將原型指向 Shape
後,由於 Circle
原本的 prototype
已被覆蓋,於是兩層物件的 constructor
都變成了 Shape
。此時就需要一併手動綁定 Circle.prototype.constructor
,將其重新指向 Circle
。
總結來說,使用自訂原型繼承時有三個要點:
Object.create
創造以另一個物件為原型的新物件,並將這個新物件指定為下層物件的 prototype
prototype
被覆蓋,原本附於 prototype
上的 constructor
也一並丟失,需要手動綁回prototype
上,否則無法在原型鍊中取得除了直接將屬性和方法定義在 prototype
以外,還有另一種方法可以讓下層建構子繼承屬性,即是在下層建構子中使用 call
函式:
function Shape(color) {
this.color = color;
}
function Circle(radius, color) {
// super constructor
Shape.call(this, color);
this.radius = radius
}
Circle.prototype = Object.create(Shape.prototype)
Circle.prototype.constructor = Circle;
const c = new Circle(1, "red");
console.log(c.color) // red
如果程式中有複數地方需要用到繼承,我們同樣可以將繼承行為包裝成函式:
function extend(Child, Parent) {
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child;
}
function Shape() { }
Shape.prototype.duplicate = function () {
console.log("duplicate")
}
function Circle(radius) {
this.radius = radius
}
extend(Circle, Shape);
function Square(size) {
this.size = size
}
extend(Square, Shape);
const c = new Circle(1)
const s = new Square(2);
c.duplicate(); // duplicate
s.duplicate(); // duplicate
像上面這樣,定義了 extend
以後,每次要綁定繼承時只要呼叫 extend
函式就好。
在 JS 中,我們同樣能夠修改新增內建物件擁有的屬性或方法:
Array.prototype.shuffle = function () {
console.log("shuffle");
}
const arr = []
arr.shuffle() // shuffle
但這樣的行為應該是要避免的,因為新增的屬性有可能會和某個引入的套件或框架方法重複,造成除錯困難;而修改既有方法,可能在後續維護時沒注意到原本的方法已被修改,導致程式出現非預期的結果。
「不要改變不屬於你的物件」是撰寫 JS 時應遵循的一個守則。