JavaScript 語言核心(15)函式 prototype 特性


隱藏諸多細節的建構式 中看過一個例子:
function toString() {
    return '[' + this.name + ',' + this.age + ']';
}

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.toString = toString;
}
這可以解決重複建立函式實例的問題,但在全域範圍(物件)上多了個 toString 名稱,雖然可以如下避免這個問題:
function Person(name, age) {
    this.name = name;
    this.age = age;
    this.toString = function() {
        return '[' + this.name + ', ' + this.age + ']';
    };
}
Person 函式中使用了函式實字建立了函式實例,並指定給 toString 特性,不過每次呼叫建構式時,都會建立一次函式實例。
如果你知道函式在定義時,都有個 prototype 特性,則可以如下:
function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.toString = function() {
    return '[' + this.name + ', ' + this.age + ']';
};

var p1 = new Person('Justin', 35);
var p2 = new Person('Momor', 32);

console.log(p1.toString());   // [Justin, 35]
console.log(p2.toString());   // [Momor, 32]
使用 new 關鍵字時,JavaScript 會先建立一個空物件,接著設定物件的原型為函式的 prototype 特性所參考的物件,然後呼叫建構式並將所建立的空物件設為 this
JavaScript 在尋找特性名稱時,會先在實例上找尋有無特性,以上例而言,p1 上會有 nameage 特性,所以可以直接取得對應的值。如果物件上沒有該特性,會到物件的原型上去尋找,以上例而言,p1 上沒有 toString 特性,所以會到 p1 的原型上尋找,而 p1 的原型物件此時也就是 Person.prototype 參考的物件,這個物件上有 toString 特性,所以可以 找到 toString 所參考的函式並執行。
如果使用 ECMAScript 5,可以透過 Object.getPrototypeOf 來取得實例的原型物件。例如:
function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.toString = function() {
    return '[' + this.name + ', ' + this.age + ']';
};

var p = new Person('Justin', 35);

console.log(Person.prototype === Object.getPrototypeOf(p));   // true
Node.js、Rhino、Nashorn 中,物件都有個「非標準」特性 __proto__,可以設定或取得實例建立時被設定的原型物件,因此,若要用模擬的方式來說明 new Person('Justin', 35) 時作了什麼事,大概像是這樣:
function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.toString = function() {
    return '[' + this.name + ', ' + this.age + ']';
};

var p = {};
p.__proto__ = Person.prototype;
Person.call(p, 'Justin', 35);

console.log(p.toString());         // [Justin,35]
console.log(p instanceof Person);  // true
要注意的是,只有在查找特性,而物件上不具該特性時才會使用原型,如果你對物件設定某個特性,是直接在物件上設定了特性,而不是對原型設定了特性。例如:
function Some() {}
Some.prototype.data = 10;

var s = new Some();
console.log(s.data);                 // 10

s.data = 20;
console.log(s.data);                 // 20
console.log(Some.prototype.data);    // 10
在上例中可以看到,你對 s 參考的物件設定了 data 特性,但並不影響 Some.prototype.data 的值。
你可以在任何時間點對函式的 prototype 新增特性,由於原型查找的機制,透過函式而建構的所有實例,都可以找到該特性,即使實例建立之後,特性才被添加到原型中。例如:
function Some() {}

var s = new Some();
console.log(s.data);       // undefined

Some.prototype.data = 10;
console.log(s.data);       // 10
先前在談建構式時有提過,每個透過 new 建構的物件,都會有個 constructor 特性,參考至當初建構它的函式。事實上,每個函式實例建立時,都會在函式實例上以空物件建立 prototype,然而在空物件上設定 constructor 特性,也因此每個 new 建構的物件,都可以找到 constructor 特性。例如:
function Some() {}
console.log(Some.prototype.constructor);  // [Function: Some]
每個函式實例,其 prototype 特性預設參考至 Object 的實例,根據原型尋找原則,查找特性時若 prototype 上找不到,由於 prototypeObject 實例,也就是 prototype 的原型物件預設是參考至 Object.prototype,所以又會到 Object.prototype 上尋找,如果找到就使用,如果沒有找到就是 undefined,這就是 JavaScript 的原型鏈尋找特性機制。
例如:
Object.prototype.xyz = 10;

function Some() {}

var s = new Some();
console.log(s.xyz); // 10

console.log(Object.getPrototypeOf(s) === Some.prototype);          // true

var protoOfS = Object.getPrototypeOf(s);
console.log(Object.getPrototypeOf(protoOfS) === Object.prototype); // true
實例的原型物件,預設就是建構式的 prototype 參考的物件。雖然 Some 實例或Some.prototype 都沒有定義 xyz,但根據原型鏈查找,最後在 Object.prototype 可以找到 xyz(並不建議在 Object.prototype 上添加特性,因為這會影響所有JavaScript 的實例,這邊只是為了示範原型鏈查找)。
你也可以使用 isPrototypeOf 來確定物件是否為另一物件的原型。例如:
var arr = [];
console.log(Array.prototype.isPrototypeOf(arr));              // true
console.log(Function.prototype.isPrototypeOf(Array));         // true
console.log(Object.prototype.isPrototypeOf(Array.prototype)); // true
for in 在列舉物件特性時,會循著原型鏈一路找出所有可列舉特性。
如果要建立一個實例,想要令其循著某個原型鏈查找,例如,想要建立一個類似陣列的物件,但要其可循著 Array 原型鏈查找,以利用 Array 定義的特性,若使用非標準 __proto__ 特性的話,可以如下:
var arrayLike = {
    '0' : 10,
    '1' : 20,
    '2' : 30,
    length : 3
};

arrayLike.__proto__ = [];

arrayLike.map(function(elem) {
              return elem * 10
          })
          .forEach(function(elem) {
              console.log(elem);
          });
不過,__proto__ 終究是非標準特性,然而,即使是 ECMAScript 5 也只有提供 Object.getPrototypeOf,沒有可設置原型物件的方式,在《JavaScript: The Good Parts》的〈3.5. Prototype〉中提出可自定義一個 Object.beget 函式來解決這個問題:
Object.beget = function (o) {
    var F = function () {};
    F.prototype = o;
    return new F();
};

var arrayLike = Object.beget(Array.prototype);
arrayLike[0] = 10;
arrayLike[1] = 20;
arrayLike[2] = 30;
arrayLike.length = 3;

arrayLike.map(function(elem) {
              return elem * 10
          })
          .forEach(function(elem) {
              console.log(elem);
          });
ECMAScript 5 中包括了一個 Object.create 函式,可達到相同的目的:
var arrayLike = Object.create(Array.prototype, {
    '0'    : {value : 10},
    '1'    : {value : 20},
    '2'    : {value : 30},
    length : {value : 3}
});

arrayLike.map(function(elem) {
              return elem * 10
          })
          .forEach(function(elem) {
              console.log(elem);
          });
Object.create 第一個參數接受原型物件,第二個參數接受描述器(Descriptor),其內部大致是做了以下這些事(Ben Newman 寫的範例):
Object.create = function(proto, props) {
    var ctor = function(ps) {
        if(ps) {
            Object.defineProperties( this, ps );
        }
    };
    ctor.prototype = proto;
    return new ctor(props);
};
因此,作為一個有趣的練習,先前有個範例使用了 p.__proto__ = Person.prototype 這段程式碼,以下將之改為使用 Object.create,這樣就避免了使用非標準的 __proto__
function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.toString = function() {
    return '[' + this.name + ', ' + this.age + ']';
};

var p = Object.create(Person.prototype);
Person.call(p, 'Justin', 35);

console.log(p.toString());         // [Justin,35]
console.log(p instanceof Person);  // true