「JavaScript裏的面向對象」— 5.原型模式

本文原文來源:《Object-Oriented JavaScript》By Stoyan Stefanov
本文翻譯來源:赤石俊哉 原創翻譯
版權申明: 如果您是原文的原作者並且不希望此文被公開,可以聯繫作者刪除。本文翻譯由 赤石俊哉 翻譯整理,您可以用於學習目的,但是禁止轉載

第五章 原型模式(Prototype)

在這一章節中你將會學習使用“函數(function)”對象中的prototype屬性。在JavaScript的學習過程中,理解prototype的工作原理是很重要的一個部分。畢竟,JavaScript被分類爲是一個基於原型模式對象模型的語言。其實原型模式並不難,但是它是一種新的觀念而且往往需要花些時間去理解。它是JavaScript中的一部分(閉包是另一部分),一旦你“get“了他們,他們就會變得很容易理解也是很有意義的。在本書的剩餘部分中,強烈建議多打多試這些示例。那樣會更加容易地學習和記住這些觀念。
本章中將會討論以下話題:

  • 每一個函數都有一個prototype屬性,而且它包含了一個對象。
  • 向prototype中添加屬性。
  • 使用向prototype中添加的屬性。
  • 函數自身屬性以及原型屬性的區別。
  • 每一個對象保存在prototype中的私密鏈接——__proto__
  • 方法:isPrototypeOf(),hasOwnProperty(),propertyIsEnumerable()
  • 如何加強內建對象,比如數組(array)和字符串(string)。

原型屬性

JavaScript中的函數是對象,而且包含了方法和屬性。包括我們常見的一些的方法,像apply()call()等,常見的屬性,像lengthconstructor等。還有一個屬性就是prototype

當你定義了一個簡單的函數foo()之後,你可以像其他對象一樣,直接訪問這個函數的屬性:

>>> function foo(a, b){return a * b;}
>>> foo.length
2

>>> foo.constructor
Function()

prototype這個屬性在你定義函數的時候就創建好了。他的初始值是一個空對象。

>>> typeof foo.prototype
"object"

你可以使用屬性和方法來擴充這個空對象。他們不會對foo()函數本身產生任何影響。他們只會在當你使用foo()作爲構造函數的時候被使用。

使用原型模式添加方法和屬性

在前面的章節中,已經學習過了如何定義一個構建新對象時使用的構造函數。最主要的思想是在函數中調用new時,訪問this變量,它包含了構建函數返回的對象。擴張(添加方法和屬性)this對象是在對象被創建時添加功能的一種方法。
讓我們來看個例子,Gadget()構造方法中使用this來添加兩個屬性和一個方法到它創建的對象裏。

function Gadget(name, color){
    this.name = name;
    this.color = color;
    this.whatAreYou = function(){
        return 'I am a ' + this.color + ' ' + this.name;
    }
}

向構造函數的prototype中添加方法和屬性是在對象被創建的時候爲對象添加功能的另一種方式。接下來再添加兩個屬性pricerating和一個getInfo()方法。因爲prototype包含一個對象,所以你可以像這樣添加:

Gadget.prototype.price = 100;
Gadget.prototype.rating = 3;
Gadget.prototype.getInfo = function(){
    return 'Rating: ' + this.rating + ', Price: ' + this.price;
};

你也可以通過另一種方式達到同樣的目的,就是完全覆蓋掉原型屬性,將它換成你選擇的對象:

Gadget.prototype = {
    price: 100,
    rating: 3,
    getInfo: function() {
        return `Rating: ` + this.rating + ', Price:' + this.price;
    }
};

使用原型屬性的方法和屬性

你添加到構造函數的原型屬性中的所有方法和屬性你都是可以直接在使用這個構造函數構造新對象之後,直接使用的。比如,如果你使用Gadget()構建函數,創建了一個newtoy對象,你可以直接訪問已經定義的所有方法和屬性。

>>> var newtoy = new Gadget('webcam', 'black');
>>> newtoy.name;
"webcam"

>>> newtoy.color;
"black"

>>> newtoy.whatAreYou();
"I am a black webcam"

>>> newtoy.price;
100

>>> newtoy.rating;
3

>>> newtoy.getInfo();
"Rating:3, Price: 100"

有一點很重要的是,原型屬性是”活的“,在JavaScript中對象的傳遞是通過引用來進行的。爲此,原型類型不是直接複製到新對象中的。這意味着什麼呢?這意味着,我們可以在任何時間修改任何對象的原型屬性(甚至你都可以在新建對象之後進行修改),它們都是生效的。
讓我們繼續來看一個例子,添加下面的方法到原型屬性裏面:

Gadget.prototype.get = function(what){
    return this[what];
};

儘管我們在定義get()方法之前已經生成了newtoy對象,然而newtoy依舊可以訪問這個新的方法:

>>> newtoy.get('price');
100

>>> newtoy.get('color');
"black"

“函數自身屬性”與“原型屬性”的對比

在上面的getInfo()例子中,使用了this來從內部指向對象本身,使用Gadget.prototype也可以達到一樣的目的:

Gadget.prototype.getInfo = function(){
    return 'Rating: ' + Gadget.prototype.rating + ', Price: ' + Gadget.prototype.price;
};

這有啥不一樣呢?在回答這個問題之前,我們先來測試一下看看原型屬性是怎麼工作的吧。
讓我們再拿出我們的newtoy對象:

>>> var newtoy = new Gadget('webcam', 'black');

當你嘗試訪問newtoy的一個屬性,使用表達式newtoy.name,JavaScript引擎將會瀏覽對象的所有屬性,尋找一個叫作name,如果找到它,它的值就會被返回。

>>> newtoy.name
'webcam'

什麼?你想嘗試着訪問rating屬性?JavaScript引擎會檢查newtoy中的所有屬性,然後沒有找到一個叫作rating的。然後腳本引擎就會鑑別出,構造函數中的原型屬性曾經嘗試着創建這個對象(就像你使用newtoy.constructor.prototype的時候一樣)。如果屬性在原型屬性中找到了這個屬性,就會使用原型屬性中的這個屬性。

>>> newtoy.rating
3

這和你直接訪問原型屬性一樣。每一個對象都有一個構造函數的屬性,它是對創建該對象使用的構造函數的引用。所以,在這個例子中:

>>> newtoy.constructor
Gadget(name, color)

>>> newtoy.constructor.prototype.rating
3

現在,讓我們再來看看第一步,每一個對象都有一個構造函數。原型屬性是一個對象,所以,它也應該也有一個構造函數。進而它的構造函數又有一個原型屬性……

>>> newtoy.constructor.prototype.constructor
Gadget(name, color)
>>> newtoy.constructor.prototype.constructor.prototype
Object price=100 rating=3

這個循環將會持續下去,具體有多長取決於這個原型屬性鏈有多長。但是最後會終結於一個內建的Object()對象。它是最外層父類。在這個例子中,如果你嘗試着使用newtoy.toString(),而newtoy他沒有自己的toString()方法,而且它的原型屬性對象裏也沒有,他就會一直往上找,最後會調用Object對象的toString()方法。

>>> newtoy.toString()
"[object Object]"

使用函數自身的屬性覆蓋原型屬性的屬性

如上面所演示的,如果你的對象沒有一個確切的自己的屬性,可以使用一個原型鏈上層的對象。如果對象和原型屬性裏面有相同名字的屬性,自身的屬性會被優先使用。
接下來我們來模擬一個屬性同時存在於自身屬性和原型屬性中:

function Gadget(name){
    this.name = name;
}

>>> Gadget.prototype.name = 'foo';
"foo"

創建一個新對象,訪問它的name屬性,它會給你對象自身的name屬性。

>>> var toy = new Gadget('camera');
>>> toy.name;
"camera"

如果你刪除這個屬性,那麼原型屬性中使用相同名字的屬性就會“表現出來”:

>>> delete toy.name;
true

>>> toy.name;
"foo"

當然,你可以重新創建它的自身屬性:

>>> toy.name = 'camera';
>>> toy.name;
"camera"

遍歷屬性

如果你希望列出一個對象的所有屬性,你可以使用一個for-in循環。在第二章節中,學習瞭如何遍歷一個數組裏面的所有元素:

var a = [1, 2, 3];
for (var i in a)
{
    console.log(a[i]);
}

數組是一個對象,所以可以推導出for-in遍歷對象的時候:

var o = {p1: 1, p2: 2};
for (var i in o) {
    console.log(i + '=' + o[i]);
}

這將會產生:

p1=1
p2=2

需要知道的幾個細節:

  • 不是所有的屬性都在for-in循環中顯示出來。比如,數組的length,以及constructor屬性就不會被顯示出來。被顯示出來的屬性叫做可枚舉的。你可以使用每個對象都能提供的propertyIsEnumerable()方法來檢查一個屬性是不是可枚舉的。
  • 原型鏈中原型屬性如果是可枚舉的,也會被顯示出來。你可以使用hasOwnProperty()方法來檢查一個屬性是自身屬性還是原型屬性。
  • propertyIsEnumerable()將會對所有原型屬性中的屬性返回false,儘管他們會在for-in循環中顯示出來,也是可枚舉的。

爲了看看這些函數的效果,我們使用一個簡化版本的Gadget()

function Gadget(name, color)
{
    this.name = name;
    this.color = color;
    this.someMethod = function(){
        return 1;
    }
}
Gadget.prototype.price = 100;
Gadget.prototype.rating = 3;

創建一個新的對象:

var newtoy = new Gadget('webcam', 'black');

如果你使用for-in循環,你可以看到對象的所有屬性,包括那些原型屬性的:

for (var prop in newtoy){
    console.log(prop + ' = ' + newtoy[prop];
}

這個結果也包含對象的方法(那是因爲方法是正好類型是函數的屬性):

name = webcam
color = black
someMethod = function(){ return 1;}
price = 100
rating = 3

如果你想區分對象自身屬性和原型屬性的屬性,使用hasOwnProperty(),試試這個:

>>> newtoy.hasOwnProperty('name')
true

>>> newtoy.hasOwnProperty('price')
false

讓我們再來循環一次,但是這次只顯示自身的屬性:

for (var prop in newtoy){
    if (newtoy.hasOwnProperty(prop)){
        console.log(prop + '=' + newtoy[prop]);
    }
}

結果:

name=webcam
color=black
someMethod=function(){return 1;}

接下來讓我們試試propertyIsEnumerable()。如果自身屬性不是內置的屬性,這個函數就會返回true:

>>> newtoy.propertyIsEnumerable('name')
true

>>> newtoy.propertyIsEnumerable('constructor')
false

任何從原型鏈上來的屬性都是不可枚舉的:

>>> newtoy.propertyIsEnumerable('price')
false

注意,雖然如果你獲取了包含在原型屬性中對象,並且調用了它的propertyIsEnumerable(),這個屬性是可以枚舉的。

>>> newtoy.constructor.prototype.propertyIsEnumberable('price')
true

isPrototypeOf()

每一個對象都有isPrototypeOf()方法。這個方法會告訴你指定的對象是誰的原型屬性。
我們先寫一個簡單的對象monkey

var monkey = {
    hair: true,
    feeds: 'bananas',
    breathes: 'air'
};

接下來,讓我們建立一個Human()構造函數,然後設定它的prototype屬性指向monkey

function Human(name){
    this.name = name;
}
Human.prototype = monkey;

如果你創建一個叫作georgeHuman對象,然後問它:“monkeygeorge的原型屬性嗎?”,你就會得到true

>>> var george = new Human('George');
>>> monkey.isPrototypeOf(george)
true

祕密的__proto__鏈接

如你所知的,當你嘗試訪問一個不存在與當前對象的屬性時,它會查詢原型屬性的屬性。
讓我們繼續使用monkey對象作爲Human()構造函數的原型屬性。

var monkey = {
    feeds: 'bananas',
    breathes: 'air'
};
function Human() {}
Human.prototype = monkey;

接下來創建一個developer對象,然後給他一些屬性:

var developer = new Human();
developer.feeds = 'pizza';
developer.hacks = 'JavaScript';

現在,我們來做些查詢吧。hacksdeveloper的屬性:

>>> developer.hacks
"JavaScript"

feeds可以在對象中被找到:

>>> developer.feeds
"pizza"

breathes不存在於developer對象中,由於有一個祕密的鏈接指向原型類型對象,所以轉而查找原型類型。

>>> developer.breathes
"air"

可以從developer對象中得到原型屬性對象呢?當然,可以啦。使用constructor作爲中間對象,就像developer.constructor.prototype指向monkey一樣。但是這並不是十分可靠的。因爲constructor大多時候用於提供信息的用途,而且是可以隨時被覆蓋修改的。你甚至可以用一個不是對象的東西覆蓋掉它。這樣做絲毫不會影響到原型鏈的功能。

讓我們看一些字符串的構造屬性:

>>> developer.constructor = 'junk'
"junk"

看上去,prototype已經亂成一團了:

>>> typeof developer.constructor.prototype
"undefined"

但是事實卻並非如此,因爲開發者仍然呼吸着“空氣”(developerbreathes屬性仍然是air):

>>> developer.breathes
"air"

這表示原型屬性的祕密鏈接仍然存在。在火狐瀏覽器中公開的這個祕密鏈接是__proto__屬性(proto前後各加兩個下劃線)。

>>> developer._proto__
Object feeds = bananas breathes=air

你可以在學習的過程中使用這個祕密鏈接,但是實際編碼中不推薦使用。因爲它不存在於Internet Explorer中,所以你的代碼將會變得難以移植。打個比方,如果你使用monkey創建了一堆對象,而且你現在想在所有的對象中更改一些東西。你可以修改monkey,而且所有的實例都會繼承這些變化。

>>> monkey.test = 1
1

>>> developer.test
1

__proto__不是等效於prototype__proto__是實例的一個屬性,儘管prototype是構造函數的一個屬性。

>>> typeof developer.__proto__
"object"

>>> typeof developer.prototype
"undefined"

再次強調,你可以在Debug或者是學習的時候使用__proto__,其他時候不要。

擴充內建對象

內建的一些對象像構造函數Array,String,甚至是ObjectFunction()都可以通過他們的原型屬性來進行擴充。打個比方,你就可以向Array原型屬性中添加新方法,而且它們可以在所有的數組中被使用。讓我們來試試。
在PHP中,有一個函數叫做in_array(),它會告訴你如果數組中是否存在某個值。在JavaScript中,沒有inArray()這樣的函數,所以我們可以實現它,並添加到Array.prototype中。

Array.prototype.inArray = function(needle) {
    for (var i = 0, len = this.length; i < len; i++) {
        if (this[i] === needle) {
            return true;
        }
    }   
    return false; 
}

現在,所有的數組就都有新的方法了。讓我試試:

>>> var a = ['red', 'green', 'blue']; 
>>> a.inArray('red');
true

>>> a.inArray('yellow');
false

真是簡單快捷!讓我再來做一個。想象一下你的程序可能經常需要反轉字符串吧,或許你會認爲字符串對象應該有一個內建的reverse()方法,畢竟數組有reverse()方法。你可以輕鬆地添加reverse()方法給String的原型屬性。瀏覽Array.prototype.reverse()(這和第四章末尾的練習相似)。

String.prototype.reverse = function() {
    return Array.prototype.reverse.apply(this.split('')).join('');
}

這個代碼使用split()使用字符串生成了一個數組,然後調用了這個數組上的reverse()方法,生成了一個反轉的數組。然後再使用join()將反轉的數組變回了字符串。讓我們試試新的方法:

>>> "Stoyan".reverse();
"nayotS"

擴充內建對象——討論

通過原型屬性來擴充內建對象是一項強力的技術,而且你可以用它來將JavaScript塑造成你想要的樣子。你在使用這種強有力的方法之前都要徹底地思考清楚你的想法。
看看一個叫做Prototype的JavaScript庫,它的作者太愛這個方法了,以至於連庫的名字都叫這個了。使用這個庫,你可以使用一些JavaScript方法,讓使用JavaScript如Ruby語言一樣靈活。
YUI(雅虎用戶界面)庫是另一個比較流行的JavaScript庫。它的作者則是明確地反對這個領域。他們不會以任何方式更改內建對象。不管你用的是什麼庫,修改核心對象都只會迷惑庫的使用者,而且造成意料之外的錯誤。
事實是,JavaScript發生了變化,瀏覽器也帶來了支持更多功能的新版本。現在你認爲需要擴充到原型屬性的缺失的功能,也許在明天就變成了內建的方法。因此你的方法可能就不被需要了。但是如果你使用這種方法已經寫了很多代碼而且你的方法又有些不同於內建的新內建實現呢?
最起碼來說你能做的是,在實現一個方法之前先去檢查一下它是否存在。我們的上一個例子就應該像這樣:

if (!String.prototype.reverse) {
  String.prototype.reverse = function() {   
    return Array.prototype.reverse.apply(this.split('')).join(''); 
  } 
}

一些原型屬性的陷阱

在處理原型屬性的時候,這兩個現象是需要考慮在內的:

  • prototype.constructor是不可靠的。

創建一個簡單的構建函數和兩個對象:

>>> function Dog(){ this.tail = true; }
>>> var benji = new Dog();
>>> var rusty = new Dog();

甚至在創建了對象之後,你仍可以向原型屬性添加屬性,而且對象會使用新的屬性。讓我們插進方法say()

>>> Dog.prototype.say = function(){ return 'Woof!';}

兩個對象都會使用新的方法:

>>> benji.say();
"Woof!"

>>> rusty.say();
"Woof!"

到此爲止,如果你詢問你的對象,用來創建他們的構建函數是什麼,他們還會正確地彙報:

>>> benji.constructor;
Dog();

>>> rusty.constructor;
Dog();

一個有趣的現象是如果你問原型屬性的構造函數是什麼,你仍然會得到Dog(),他不算太準確。原型屬性是Object()創建的一個普通對象而已。使用Dog()構造的不含任何屬性的對象。

>>> benji.constructor.prototype.constructor
Dog()

>>> typeof benji.constructor.prototype.tail
"undefined"

現在我們用一個全新的對象完全覆蓋原型屬性對象:

>>> Dog.prototype = {paws: 4, hair: true};

這證明我們的舊對象不能訪問新原型屬性的屬性。他們仍保持着與舊原型屬性對象的祕密鏈接。

>>> typeof benji.paws
"undefined"

>>> benji.say()
"Woof!"

>>> typeof benji.__proto__.say
"function"

>>> typeof benji.__proto__.paws
"undefined"

你再創建新的對象,將會使用更新後的原型屬性:

>>> var lucy = new Dog();
>>> lucy.say()
TypeError: lucy.say is not a function

>>> lucy.paws
4

指向新原型屬性的私密鏈接__proto__

>>> typeof lucy.__proto__.say
"undefined"

>>> typeof lucy.__proto__.paws
"number"

新對象的構建函數屬性不再被正確地匯報出來了。本來應該指向Dog(),但是卻指向了Object()

>>> lucy.constructor
Object()
>>> benji.constructor
Dog()

最難區分的部分是當你查找構造函數的原型屬性時:

>>> typeof lucy.constructor.prototype.paws
"undefined"
>>> typeof benji.constructor.prototype.paws
"number"

下面的語句將會修復上面所有的意料之外的現象:

>>> Dog.prototype = {paws: 4, hair: true};
>>> Dog.prototype.constructor = Dog;

當你覆蓋原型屬性,推薦重置constructor屬性。

總結

讓我們來總結一下這一章節中學習的幾個要點。

  • 所有的函數都有一個叫作prototype的屬性,初始情況下,它包含一個空白的對象。
  • 你可以向原型屬性中添加屬性和方法。你甚至可以將它完全替換成你選擇的對象。
  • 當你使用構造函數穿件對象(使用new),這個對象會有一個祕密鏈接指向它的原型屬性,而且可以把原型屬性的屬性當成自己的來用。
  • 相比原型屬性的屬性,同名自身的屬性擁有更高的優先級。
  • 使用hasOwnProperty()方法來區分自身屬性和原型屬性的屬性。
  • 存在一個原型鏈:如果你的對象foo沒有屬性bar,當你使用foo.bar的時候,JavaScript會從它的原型屬性中去尋找bar屬性。如果沒有找到,它會繼續在原型屬性的原型屬性中找,然後是原型屬性的原型屬性的原型屬性,而且一步一步向上,直到最高層父類Object
  • 你可以擴充內建構造函數。所有的對象都可以應用你的擴充。申明Array.prototype.flip,而後所有的數組都會馬上擁有一個flip()方法。[1,2,3].flip()。在擴充方法和屬性之前,檢查是否存在,爲你的代碼添加未來的保證。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章