位元詩人 現代 [JavaScript] 程式設計教學:原型 (Prototype)

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

原型 (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),包含 xy 等屬性 (property) 以及 toStringdistanceTo 等方法 (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();
    };
}

同樣地,我們可以藉由修改內建物件的原型來擴充該物件的行為。擴充內建物件的原型在表面上影響沒改寫原型來得大,但也會造成程式過度依賴這些特殊行為,應該要謹慎使用並輔以充份的文件說明。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。