原型 (Prototype) 是什麼
要了解 JavaScript 的原型,要了解三個 ECMAScript 相關條目:
- 物件 (object):由一群性質 (properties) 和一個原型 (prototype) 所組成
- 建構子 (constructor):用來建立物件的函式 (function)
- 原型 (prototype):物件間共享的性質。用建構子建立物件時,建立出來的物件會自動共享建構子的原型
由此可知,原型是物件的特殊性質,使用建構子建立物件 (使用 new
) 時會自動共享建構子的原型。
共享原型
二元空間的點 (point) 時常用來當做物件的題材。我們在這裡建立 Point
物件:
let Point = function (x, y) {
// The private fields
var _x;
var _y;
// Property `x` of the object.
Object.defineProperty(this, "x", {
get: function () {
return _x;
},
set: function (value) {
_x = value;
}
});
// Property `y` of the object.
Object.defineProperty(this, "y", {
get: function () {
return _y;
},
set: function (value) {
_y = value;
}
});
// The public method `toString` of `this`
this.toString = function () {
return `(${this.x}, ${this.y})`;
};
// The public method `distanceTo` of `this`
this.distanceTo = function (p) {
return Math.sqrt(Math.pow(this.x - p.x, 2) + Math.pow(this.y - p.y, 2));
};
// Init the fields of the object.
this.x = x;
this.y = y;
return this;
};
Point
是一個建構子 (constructor),包含 x
、y
等屬性 (property) 以及 toString
、distanceTo
等方法 (methods)。嚴格地說,這些方法也是屬性,只是其值為函式物件。
使用方式如下:
let p = new Point(3, 4);
assert(p.toString() === "(3, 4)");
assert(p.distanceTo(new Point(0, 0)) === 5);
// Home-made `assert`
function assert(cond, msg) {
if (!cond) {
if (msg) {
throw msg;
}
throw "Assertion failed";
}
}
相信讀者對這樣的物件不會陌生,很多線上教材也會有類似的範例。不過,這樣的例子在語法上比較易讀,卻沒有充分利用到原型的好處。我們利用修改原型的方式來改寫上述範例:
let Point = function (x, y) {
// Define property `x` and `y` here.
// Init the fields of the object.
this.x = x;
this.y = y;
return this;
};
// The public method `toString` of `Point`
Point.prototype.toString = function () {
return `(${this.x}, ${this.y})`;
};
// The public method `distanceTo` of `Point`
Point.prototype.distanceTo = function (p) {
return Math.sqrt(Math.pow(this.x - p.x, 2) + Math.pow(this.y - p.y, 2));
};
這時候程式仍可正確運行。這牽涉到物件 p
查找其屬性 (property) 的順序:
p --> p.__proto__ --> Point.prototype
以 toString
為例,在我們第二個實作中,p
本身沒有定義 toString
,這時候會去查找 p.__proto__
,即 p
的原型;但 p.__proto__
也沒有定義這個函式,就會去查找 Point.prototype
,這時候就找到了。
以本節的例子來說,將方法寫在原型裡,就不需在每次製作物件時重新製作一份相同的函式,多多少少可以節約一些記憶體。
原型繼承
藉由操作原型,也可以達成繼承的特性。我們現在建立一個 Person
物件:
let Person = function (name, age) {
var _age = 0.0;
Object.defineProperty(this, "age", {
get: function () {
return _age;
},
set: function (value) {
if (value <= 0) {
throw "Invalid age";
}
_age = value;
}
});
// Init the object.
this.name = name;
this.age = age;
return this;
};
接著,建立 Employee
物件,該物件會繼承 Person
的建構函式:
let Employee = function (name, age, salary) {
// Inherit the constructor of `Person`.
Person.call(this, name, age);
var _salary = 0.0;
Object.defineProperty(this, "salary", {
get: function () {
return _salary;
},
set: function (value) {
if (value <= 0.0) {
throw "Invalid salary";
}
_salary = value;
}
});
// Init the object.
this.salary = salary;
return this;
};
除了繼承 Person
的建構子外,也要繼承其原型鍵:
// Inherit the prototype of `Person`
Employee.prototype = Object.create(Person.prototype);
但這時候 Employee
的建構子會改成 Person
的建構子,故要進行修正:
// Resume the constructor of `Employee`
Employee.prototype.constructor = Employee;
用 instanceof
確認兩者的繼承關係:
let e = new Employee("Michelle", 30, 1000);
assert(e instanceof Employee);
assert(e instanceof Person);
// Home-made `assert`
確認 e
物件的屬性 (property) 皆可用:
let e = new Employee("Michelle", 30, 1000);
assert(e.name === "Michelle");
assert(e.age === 30);
assert(e.salary === 1000);
// Home-made `assert`
本範例的物件間查找關係如下:
e --> e.__proto__ --> Employee.prototype --> Person.prototype
由本節範例可知,JavaScript 藉由原型鏈達到繼承的特性。
改寫或擴充內建物件
內建的物件也有原型,利用這項特性,我們可以改寫或擴充內建物件的行為。但改寫內建物件的原型會對整個程式碼產生廣泛而深遠的影響,應儘量避免這種手法。
反之,如果內建物件缺乏某個新特性,我們可以用 polyfill 來補足,像是這個在 MDN 上可見的 Object.create 的 polyfill:
if (typeof Object.create !== "function") {
Object.create = function (proto, propertiesObject) {
if (typeof proto !== 'object' && typeof proto !== 'function') {
throw new TypeError('Object prototype may only be an Object: ' + proto);
} else if (proto === null) {
throw new Error("This browser's implementation of Object.create is a shim and doesn't support 'null' as the first argument.");
}
if (typeof propertiesObject != 'undefined') {
throw new Error("This browser's implementation of Object.create is a shim and doesn't support a second argument.");
}
function F() {}
F.prototype = proto;
return new F();
};
}
同樣地,我們可以藉由修改內建物件的原型來擴充該物件的行為。擴充內建物件的原型在表面上影響沒改寫原型來得大,但也會造成程式過度依賴這些特殊行為,應該要謹慎使用並輔以充份的文件說明。