你不懂JS:this與對象原型 第五章:原型(Prototype)

官方中文版原文鏈接

感謝社區(qū)中各位的大力支持,譯者再次奉上一點點福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運大獎:點擊這里領(lǐng)取

在第三,四章中,我們幾次提到了[[Prototype]]鏈,但我們沒有討論它到底是什么?,F(xiàn)在我們就詳細講解一下原型(prototype)。

注意: 所有模擬類拷貝行為的企圖,也就是我們在前面第四章描述的內(nèi)容,稱為各種種類的“mixin”,和我們要在本章中講解的[[Prototype]]鏈機制完全不同。

[[Prototype]]

JavaScript中的對象有一個內(nèi)部屬性,在語言規(guī)范中稱為[[Prototype]],它只是一個其他對象的引用。幾乎所有的對象在被創(chuàng)建時,它的這個屬性都被賦予了一個非null值。

注意: 我們馬上就會看到,一個對象擁有一個空的[[Prototype]]鏈接是 可能 的,雖然這有些不尋常。

考慮下面的代碼:

var myObject = {
    a: 2
};

myObject.a; // 2

[[Prototype]]引用有什么用?在第三章中,我們講解了[[Get]]操作,它會在你引用一個對象上的屬性時被調(diào)用,比如myObject.a。對于默認的[[Get]]操作來說,第一步就是檢查對象本身是否擁有一個a屬性,如果有,就使用它。

注意: ES6的代理(Proxy)超出了我們要在本書內(nèi)討論的范圍(將會在本系列的后續(xù)書目中涵蓋?。?,但是如果加入Proxy,我們在這里討論的關(guān)于普通[[Get]][[Put]]的行為都是不被采用的。

但是如果myObject 存在a屬性時,我們就將注意力轉(zhuǎn)向?qū)ο蟮?code>[[Prototype]]鏈。

如果默認的[[Get]]操作不能直接在對象上找到被請求的屬性,那么會沿著對象的[[Prototype]] 繼續(xù)處理。

var anotherObject = {
    a: 2
};

// 創(chuàng)建一個鏈接到`anotherObject`的對象
var myObject = Object.create( anotherObject );

myObject.a; // 2

注意: 我們馬上就會解釋Object.create(..)是做什么,如何做的。眼下先假設(shè),它創(chuàng)建了一個對象,這個對象帶有一個鏈到指定的對象的[[Prototype]]鏈接,這個鏈接就是我們要講解的。

那么,我們現(xiàn)在讓myObject``[[Prototype]]鏈到了anotherObject。雖然很明顯myObject.a實際上不存在,但是無論如何屬性訪問成功了(在anotherObject中找到了),而且確實找到了值2。

但是,如果在anotherObject上也沒有找到a,而且如果它的[[Prototype]]鏈不為空,就沿著它繼續(xù)查找。

這個處理持續(xù)進行,直到找到名稱匹配的屬性,或者[[Prototype]]鏈終結(jié)。如果在鏈條的末尾都沒有找到匹配的屬性,那么[[Get]]操作的返回結(jié)果為undefined。

和這種[[Prototype]]鏈查詢處理相似,如果你使用for..in循環(huán)迭代一個對象,所有在它的鏈條上可以到達的(并且是enumerable——見第三章)屬性都會被枚舉。如果你使用in操作符來測試一個屬性在一個對象上的存在性,in將會檢查對象的整個鏈條(不管 可枚舉性)。

var anotherObject = {
    a: 2
};

// 創(chuàng)建一個鏈接到`anotherObject`的對象
var myObject = Object.create( anotherObject );

for (var k in myObject) {
    console.log("found: " + k);
}
// 找到: a

("a" in myObject); // true

所以,當你以各種方式進行屬性查詢時,[[Prototype]]鏈就會一個鏈接一個鏈接地被查詢。一旦找到屬性或者鏈條終結(jié),這種查詢會就會停止。

Object.prototype

但是[[Prototype]]鏈到底在 哪里 “終結(jié)”?

每個 普通[[Prototype]]鏈的最頂端,是內(nèi)建的Object.prototype。這個對象包含各種在整個JS中被使用的共通工具,因為JavaScript中所有普通(內(nèi)建,而非被宿主環(huán)境擴展的)的對象都“衍生自”(也就是,使它們的[[Prototype]]頂端為)Object.prototype對象。

你會在這里發(fā)現(xiàn)一些你可能很熟悉的工具,比如.toString().valueOf()。在第三章中,我們介紹了另一個:.hasOwnProperty(..)。還有另外一個你可能不太熟悉,但我們將在這一章里討論的Object.prototype上的函數(shù)是.isPrototypeOf(..)

設(shè)置與遮蔽屬性

回到第三章,我們提到過在對象上設(shè)置屬性要比僅僅在對象上添加新屬性或改變既存屬性的值更加微妙?,F(xiàn)在我們將更完整地重溫這個話題。

myObject.foo = "bar";

如果myObject對象已直接經(jīng)擁有了普通的名為foo的數(shù)據(jù)訪問器屬性,那么這個賦值就和改變既存屬性的值一樣簡單。

如果foo還沒有直接存在于myObject,[[Prototype]]就會被遍歷,就像[[Get]]操作那樣。如果在鏈條的任何地方都沒有找到foo,那么就會像我們期望的那樣,屬性foo就以指定的值被直接添加到myObject上。

然而,如果foo已經(jīng)存在于鏈條更高層的某處,myObject.foo = "bar"賦值就可能會發(fā)生微妙的(也許令人詫異的)行為。我們一會兒就詳細講解。

如果屬性名foo同時存在于myObject本身和從myObject開始的[[Prototype]]鏈的更高層,這樣的情況稱為 遮蔽。直接存在于myObject上的foo屬性會 遮蔽 任何出現(xiàn)在鏈條高層的foo屬性,因為myObject.foo查詢總是在尋找鏈條最底層的foo屬性。

正如我們被暗示的那樣,在myObject上的foo遮蔽沒有看起來那么簡單。我們現(xiàn)在來考察myObject.foo = "bar"賦值的三種場景,當foo 不直接存在myObject,但 存在myObject[[Prototype]]鏈的更高層:

  1. 如果一個普通的名為foo的數(shù)據(jù)訪問屬性在[[Prototype]]鏈的高層某處被找到,而且沒有被標記為只讀(writable:false,那么一個名為foo的新屬性就直接添加到myObject上,形成一個 遮蔽屬性。
  2. 如果一個foo[[Prototype]]鏈的高層某處被找到,但是它被標記為 只讀(writable:false ,那么設(shè)置既存屬性和在myObject上創(chuàng)建遮蔽屬性都是 不允許 的。如果代碼運行在strict mode下,一個錯誤會被拋出。否則,這個設(shè)置屬性值的操作會被無聲地忽略。不論怎樣,沒有發(fā)生遮蔽
  3. 如果一個foo[[Prototype]]鏈的高層某處被找到,而且它是一個setter(見第三章),那么這個setter總是被調(diào)用。沒有foo會被添加到(也就是遮蔽在)myObject上,這個foosetter也不會被重定義。

大多數(shù)開發(fā)者認為,如果一個屬性已經(jīng)存在于[[Prototype]]鏈的高層,那么對它的賦值([[Put]])將總是造成遮蔽。但如你所見,這僅在剛才描述的三中場景中的一種(第一種)中是對的。

如果你想在第二和第三種情況中遮蔽foo,那你就不能使用=賦值,而必須使用Object.defineProperty(..)(見第三章)將foo添加到myObject。

注意: 第二種情況可能是三種情況中最讓人詫異的了。只讀 屬性的存在會阻止同名屬性在[[Prototype]]鏈的低層被創(chuàng)建(遮蔽)。這個限制的主要原因是為了增強類繼承屬性的幻覺。如果你想象位于鏈條高層的foo被繼承(拷貝)至myObject, 那么在myObject上強制foo屬性不可寫就有道理。但如果你將幻覺和現(xiàn)實分開,而且認識到 實際上 沒有這樣的繼承拷貝發(fā)生(見第四,五章),那么僅因為某些其他的對象上擁有不可寫的foo,而導致myObject不能擁有foo屬性就有些不自然。而且更奇怪的是,這個限制僅限于=賦值,當使用Object.defineProperty(..)時不被強制。

如果你需要在方法間進行委托,方法 的遮蔽會導致難看的 顯式假想多態(tài)(見第四章)。一般來說,遮蔽與它帶來的好處相比太過復雜和微妙了,所以你應當盡量避免它。第六章介紹另一種設(shè)計模式,它提倡干凈而且不鼓勵遮蔽。

遮蔽甚至會以微妙的方式隱含地發(fā)生,所以要想避免它必須小心??紤]這段代碼:

var anotherObject = {
    a: 2
};

var myObject = Object.create( anotherObject );

anotherObject.a; // 2
myObject.a; // 2

anotherObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "a" ); // false

myObject.a++; // 噢,隱式遮蔽!

anotherObject.a; // 2
myObject.a; // 3

myObject.hasOwnProperty( "a" ); // true

雖然看起來myObject.a++應當(通過委托)查詢并 原地 遞增anotherObject.a屬性,但是++操作符相當于myObject.a = myObject.a + 1。結(jié)果就是在[[Prototype]]上進行a[[Get]]查詢,從anotherObject.a得到當前的值2,將這個值遞增1,然后將值3[[Put]]賦值到myObject上的新遮蔽屬性a上。噢!

修改你的委托屬性時要非常小心。如果你想遞增anotherObject.a, 那么唯一正確的方法是anotherObject.a++

“類”

現(xiàn)在你可能會想知道:“為什么 一個對象需要鏈到另一個對象?”真正的好處是什么?這是一個很恰當?shù)膯栴},但在我們能夠完全理解和體味它是什么和如何有用之前,我們必須首先理解[[Prototype]] 不是 什么。

正如我們在第四章講解的,在JavaScript中,對于對象來說沒有抽象模式/藍圖,即沒有面向類的語言中那樣的稱為類的東西。JavaScript 只有 對象。

實際上,在所有語言中,JavaScript 幾乎是獨一無二的,也許是唯一的可以被稱為“面向?qū)ο蟆钡恼Z言,因為可以根本沒有類而直接創(chuàng)建對象的語言很少,而JavaScript就是其中之一。

在JavaScript中,類不能(因為根本不存在)描述對象可以做什么。對象直接定義它自己的行為。這里 僅有 對象

“類”函數(shù)

在JavaScript中有一種奇異的行為被無恥地濫用了許多年來 山寨 成某些 看起來 像“類”的東西。我們來仔細看看這種方式。

“某種程度的類”這種奇特的行為取決于函數(shù)的一個奇怪的性質(zhì):所有的函數(shù)默認都會得到一個公有的,不可枚舉的屬性,稱為prototype,它可以指向任意的對象。

function Foo() {
    // ...
}

Foo.prototype; // { }

這個對象經(jīng)常被稱為“Foo的原型”,因為我們通過一個不幸地被命名為Foo.prototype的屬性引用來訪問它。然而,我們馬上會看到,這個術(shù)語命中注定地將我們搞糊涂。為了取代它,我將它稱為“以前被認為是Foo的原型的對象”。只是開個玩笑?!耙粋€被隨意標記為‘Foo點兒原型’的對象”,怎么樣?

不管我們怎么稱呼它,這個對象到底是什么?

解釋它的最直接的方法是,每個由調(diào)用new Foo()(見第二章)而創(chuàng)建的對象將最終(有些隨意地)被[[Prototype]]鏈接到這個“Foo點兒原型”對象。

讓我們描繪一下:

function Foo() {
    // ...
}

var a = new Foo();

Object.getPrototypeOf( a ) === Foo.prototype; // true

當通過調(diào)用new Foo()創(chuàng)建a時,會發(fā)生的事情之一(見第二章了解所有 四個 步驟)是,a得到一個內(nèi)部[[Prototype]]鏈接,此鏈接鏈到Foo.prototype所指向的對象。

停一會來思考一下這句話的含義。

在面向類的語言中,可以制造一個類的多個 拷貝(即“實例”),就像從模具中沖壓出某些東西一樣。我們在第四章中看到,這是因為初始化(或者繼承)類的處理意味著,“將行為計劃從這個類拷貝到物理對象中”,對于每個新實例這都會發(fā)生。

但是在JavaScript中,沒有這樣的拷貝處理發(fā)生。你不會創(chuàng)建類的多個實例。你可以創(chuàng)建多個對象,它們的[[Prototype]]連接至一個共通對象。但默認地,沒有拷貝發(fā)生,如此這些對象彼此間最終不會完全分離和切斷關(guān)系,而是 鏈接在一起

new Foo()得到一個新對象(我們叫他a),這個新對象a內(nèi)部地被[[Prototype]]鏈接至Foo.prototype對象。

結(jié)果我們得到兩個對象,彼此鏈接。 如是而已。我們沒有初始化一個對象。當然我們也沒有做任何從一個“類”到一個實體對象拷貝。我們只是讓兩個對象互相鏈接在一起。

事實上,這個使大多數(shù)JS開發(fā)者無法理解的秘密,是因為new Foo()函數(shù)調(diào)用實際上幾乎和建立鏈接的處理沒有任何 直接 關(guān)系。它是某種偶然的副作用。new Foo()是一個間接的,迂回的方法來得到我們想要的:一個被鏈接到另一個對象的對象。

我們能用更直接的方法得到我們想要的嗎?可以! 這位英雄就是Object.create(..)。我們過會兒就談到它。

名稱的意義何在?

在JavaScript中,我們不從一個對象(“類”)向另一個對象(“實例”) 拷貝。我們在對象之間制造 鏈接。對于[[Prototype]]機制,視覺上,箭頭的移動方向是從右至左,由下至上。

[圖片上傳失敗...(image-bbc00e-1515410924843)]

這種機制常被稱為“原型繼承(prototypal inheritance)”(我們很快就用代碼說明),它經(jīng)常被說成是動態(tài)語言版的“類繼承”。這種說法試圖建立在面向類世界中對“繼承”含義的共識上。但是 弄擰意思是:抹平) 了被理解語義,來適應動態(tài)腳本。

先入為主,“繼承”這個詞有很強烈的含義(見第四章)。僅僅在它前面加入“原型”來區(qū)別于JavaScript中 實際上幾乎相反 的行為,使真相在泥濘般的困惑中沉睡了近二十年。

我想說,將“原型”貼在“繼承”之前很大程度上搞反了它的實際意義,就像一只手拿著一個桔子,另一手拿著一個蘋果,而堅持說蘋果是一個“紅色的桔子”。無論我在它前面放什么令人困惑的標簽,那都不會改變一個水果是蘋果而另一個是桔子的 事實。

更好的方法是直白地將蘋果稱為蘋果——使用最準確和最直接的術(shù)語。這樣能更容易地理解它們的相似之處和 許多不同之處,因為我們都對“蘋果”的意義有一個簡單的,共享的理解。

由于用語的模糊和歧義,我相信,對于解釋JavaScript機制真正如何工作來說,“原型繼承”這個標簽(以及試圖錯誤地應用所有面向類的術(shù)語,比如“類”,“構(gòu)造器”,“實例”,“多態(tài)”等)本身帶來的 危害比好處多。

“繼承”意味著 拷貝 操作,而JavaScript不拷貝對象屬性(原生上,默認地)。相反,JS在兩個對象間建立鏈接,一個對象實質(zhì)上可以將對屬性/函數(shù)的訪問 委托 到另一個對象上。對于描述JavaScript對象鏈接機制來說,“委托”是一個準確得多的術(shù)語。

另一個有時被扔到JavaScript旁邊的術(shù)語是“差分繼承”。它的想法是,我們可以用一個對象與一個更泛化的對象的 不同 來描述一個它的行為。比如,你要解釋汽車是一種載具,與其重新描述組成一個一般載具的所有特點,不如只說它有4個輪子。

如果你試著想象,在JS中任何給定的對象都是通過委托可用的所有行為的總和,而且 在你思維中你扁平化 所有的行為到一個有形的 東西 中,那么你就可以(八九不離十地)看到“差分繼承”是如何自圓其說的。

但正如“原型繼承”,“差分繼承”假意使你的思維模型比在語言中物理發(fā)生的事情更重要。它忽視了這樣一個事實:對象B實際上不是一個差異結(jié)構(gòu),而是由一些定義好的特定性質(zhì),與一些沒有任何定義的“漏洞”組成的。正是通過這些“漏洞”(缺少定義),委托可以接管并且動態(tài)地用委托行為“填補”它們。

對象不是像“差分繼承”的思維模型所暗示的那樣,原生默認地,通過拷貝 扁平化到一個單獨的差異對象中。如此,對于描述JavaScript的[[Prototype]]機制如何工作來說,“差分繼承”就不是自然合理。

可以選擇 偏向“差分繼承”這個術(shù)語和思維模型,這是個人口味的問題,但是不能否認這個事實:它 僅僅 符合你思維中的主觀過程,不是引擎的物理行為。

"構(gòu)造器"(Constructors)

讓我們回到早先的代碼:

function Foo() {
    // ...
}

var a = new Foo();

到底是什么導致我們認為Foo是一個“類”?

其一,我們看到了new關(guān)鍵字的使用,就像面向類語言中人們構(gòu)建類的對象那樣。另外,它看起來我們事實上執(zhí)行了一個類的 構(gòu)造器 方法,因為Foo()實際上是個被調(diào)用的方法,就像當你初始化一個真實的類時這個類的構(gòu)造器被調(diào)用的那樣。

為了使“構(gòu)造器”的語義更使人糊涂,被隨意貼上標簽的Foo.prototype對象還有另外一招。考慮這段代碼:

function Foo() {
    // ...
}

Foo.prototype.constructor === Foo; // true

var a = new Foo();
a.constructor === Foo; // true

Foo.prototype對象默認地(就在代碼段中第一行中聲明的地方?。┑玫揭粋€公有的,稱為.constructor的不可枚舉(見第三章)屬性,而且這個屬性回頭指向這個對象關(guān)聯(lián)的函數(shù)(這里是Foo)。另外,我們看到被“構(gòu)造器”調(diào)用new Foo()創(chuàng)建的對象a 看起來 也擁有一個稱為.constructor的屬性,也相似地指向“創(chuàng)建它的函數(shù)”。

注意: 這實際上不是真的。a上沒有.constructor屬性,而a.constructor確實解析成了Foo函數(shù),“constructor”并不像它看起來的那樣實際意味著“被XX創(chuàng)建”。我們很快就會解釋這個奇怪的地方。

哦,是的,另外……根據(jù)JavaScript世界中的慣例,“類”都以大寫字母開頭的單詞命名,所以使用Foo而不是foo強烈地意味著我們打算讓它成為一個“類”。這對你來說太明顯了,對吧???

注意: 這個慣例是如此強大,以至于如果你在一個小寫字母名稱的方法上使用new調(diào)用,或并沒有在一個大寫字母開頭的函數(shù)上使用new,許多JS語法檢查器將會報告錯誤。這是因為我們?nèi)绱伺Φ叵胍贘avaScript中將(假的)“面向類” 搞對,所以我們建立了這些語法規(guī)則來確保我們使用了大寫字母,即便對JS引擎來講,大寫字母根本沒有 任何意義。

構(gòu)造器還是調(diào)用?

上面的代碼的段中,我們試圖認為Foo是一個“構(gòu)造器”,是因為我們用new調(diào)用它,而且我們觀察到它“構(gòu)建”了一個對象。

在現(xiàn)實中,Foo不會比你的程序中的其他任何函數(shù)“更像構(gòu)造器”。函數(shù)自身 不是 構(gòu)造器。但是,當你在普通函數(shù)調(diào)用前面放一個new關(guān)鍵字時,這就將函數(shù)調(diào)用變成了“構(gòu)造器調(diào)用”。事實上,new在某種意義上劫持了普通函數(shù)并將它以另一種方式調(diào)用:構(gòu)建一個對象,外加這個函數(shù)要做的其他任何事

舉個例子:

function NothingSpecial() {
    console.log( "Don't mind me!" );
}

var a = new NothingSpecial();
// "Don't mind me!"

a; // {}

NothingSpecial僅僅是一個普通的函數(shù),但當用new調(diào)用時,幾乎是一種副作用,它會 構(gòu)建 一個對象,并被我們賦值到a。這個 調(diào)用 是一個 構(gòu)造器調(diào)用,但是NothingSpecial本身并不是一個 構(gòu)造器。

換句話說,在JavaScript中,更合適的說法是,“構(gòu)造器”是在前面 new關(guān)鍵字調(diào)用的任何函數(shù)。

函數(shù)不是構(gòu)造器,但是當且僅當new被使用時,函數(shù)調(diào)用是一個“構(gòu)造器調(diào)用”。

機制

僅僅是這些原因使得JavaScript中關(guān)于“類”的討論變得命運多舛嗎?

不全是。 JS開發(fā)者們努力地盡可能的模擬面向類:

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

Foo.prototype.myName = function() {
    return this.name;
};

var a = new Foo( "a" );
var b = new Foo( "b" );

a.myName(); // "a"
b.myName(); // "b"

這段代碼展示了另外兩種“面向類”的花招:

  1. this.name = name:在每個對象(分別在ab上;參照第二章關(guān)于this綁定的內(nèi)容)上添加了.name屬性,和類的實例包裝數(shù)據(jù)值很相似。

  2. Foo.prototype.myName = ...:這也許是更有趣的技術(shù),它在Foo.prototype對象上添加了一個屬性(函數(shù))?,F(xiàn)在,也許讓人驚奇,a.myName()可以工作。但是是如何工作的?

在上面的代碼段中,有很強的傾向認為當ab被創(chuàng)建時,Foo.prototype上的屬性/函數(shù)被 拷貝 到了ab倆個對象上。但是,這沒有發(fā)生。

在本章開頭,我們解釋了[[Prototype]]鏈,和它作為默認的[[Get]]算法的一部分,如何在不能直接在對象上找到屬性引用時提供后備的查詢步驟。

于是,得益于他們被創(chuàng)建的方式,ab都最終擁有一個內(nèi)部的[[Prototype]]鏈接鏈到Foo.prototype。當無法分別在ab中找到myName時,就會在Foo.prototype上找到(通過委托,見第六章)。

復活"構(gòu)造器"

回想我們剛才對.constructor屬性的討論,怎么看起來a.constructor === Foo為true意味著a上實際擁有一個.constructor屬性,指向Foo?不對。

這只是一種不幸的混淆。實際上,.constructor引用也 委托 到了Foo.prototype,它 恰好 有一個指向Foo的默認屬性。

看起來 方便得可怕,一個被Foo構(gòu)建的對象可以訪問指向Foo.constructor屬性。但這只不過是安全感上的錯覺。它是一個歡樂的巧合,幾乎是誤打誤撞,通過默認的[[Prototype]]委托a.constructor 恰好 指向Foo。實際上.construcor意味著“被XX構(gòu)建”這種注定失敗的臆測會以幾種方式來咬到你。

第一,在Foo.prototype上的.constructor屬性僅當Foo函數(shù)被聲明時才出現(xiàn)在對象上。如果你創(chuàng)建一個新對象,并用它替換函數(shù)默認的.prototype對象引用,這個新對象上將不會魔法般地得到.contructor。

考慮這段代碼:

function Foo() { /* .. */ }

Foo.prototype = { /* .. */ }; // 創(chuàng)建一個新的prototype對象

var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!

Object(..)沒有“構(gòu)建”a1,是吧?看起來確實是Foo()“構(gòu)建了”它。許多開發(fā)者認為Foo()在執(zhí)行構(gòu)建,但當你認為“構(gòu)造器”意味著“被XX構(gòu)建”時,一切就都崩塌了,因為如果那樣的話,a1.construcor應當是Foo,但它不是!

發(fā)生了什么?a1沒有.constructor屬性,所以它沿者[[Prototype]]鏈向上委托到了Foo.prototype。但是這個對象也沒有.constructor(默認的Foo.prototype對象就會有?。?,所以它繼續(xù)委托,這次輪到了Object.prototype,委托鏈的最頂端。那個 對象上確實擁有.constructor,它指向內(nèi)建的Object(..)函數(shù)。

誤解,消除。

當然,你可以把.constructor加回到Foo.prototype對象上,但是要做一些手動工作,特別是如果你想要它與原生的行為吻合,并不可枚舉時(見第三章)。

舉例來說:

function Foo() { /* .. */ }

Foo.prototype = { /* .. */ }; // 創(chuàng)建一個新的prototype對象

// 需要正確地“修復”丟失的`.construcor`
// 新對象上的屬性以`Foo.prototype`的形式提供。
// `defineProperty(..)`的內(nèi)容見第三章。
Object.defineProperty( Foo.prototype, "constructor" , {
    enumerable: false,
    writable: true,
    configurable: true,
    value: Foo    // 使`.constructor`指向`Foo`
} );

要修復.constructor要花不少功夫。而且,我們做的一切是為了延續(xù)“構(gòu)造器”意味著“被XX構(gòu)建”的誤解。這是一種昂貴的假象。

事實上,一個對象上的.construcor默認地隨意指向一個函數(shù),而這個函數(shù)反過來擁有一個指向被這個對象稱為.prototype的對象?!皹?gòu)造器”和“原型”這兩個詞僅有松散的默認含義,可能是真的也可能不是真的。最佳方案是提醒你自己,“構(gòu)造器不是意味著被XX構(gòu)建”。

.constructor不是一個魔法般不可變的屬性。它是不可枚舉的(見上面的代碼段),但是它的值是可寫的(可以改變),而且,你可以在[[Prototype]]鏈上的任何對象上添加或覆蓋(有意或無意地)名為constructor的屬性,用你感覺合適的任何值。

根據(jù)[[Get]]算法如何遍歷[[Prototype]]鏈,在任何地方找到的一個.constructor屬性引用解析的結(jié)果可能與你期望的十分不同。

看到它的實際意義有多隨便了嗎?

結(jié)果?某些像a1.constructor這樣隨意的對象屬性引用實際上不能被認為是默認的函數(shù)引用。還有,我們馬上就會看到,通過一個簡單的省略,a1.constructor可以最終指向某些令人詫異,沒道理的地方。

a1.constructor是極其不可靠的,在你的代碼中不應依賴的不安全引用。一般來說,這樣的引用應當盡量避免。

“(原型)繼承”

我們已經(jīng)看到了一些近似的“類”機制駭進JavaScript程序。但是如果我們沒有一種近似的“繼承”,JavaScript的“類”將會更空洞。

實際上,我們已經(jīng)看到了一個常被稱為“原型繼承”的機制如何工作:a可以“繼承自”Foo.prototype,并因此可以訪問myName()函數(shù)。但是我們傳統(tǒng)的想法認為“繼承”是兩個“類”間的關(guān)系,而非“類”與“實例”的關(guān)系。

[圖片上傳失敗...(image-ef6f5-1515410924843)]

回想之前這幅圖,它不僅展示了從對象(也就是“實例”)a1到對象Foo.prototype的委托,而且從Bar.prototypeFoo.prototype,這酷似類繼承的親自概念。酷似,除了方向,箭頭表示的是委托鏈接,而不是拷貝操作。

這里是一段典型的創(chuàng)建這樣的鏈接的“原型風格”代碼:

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

Foo.prototype.myName = function() {
    return this.name;
};

function Bar(name,label) {
    Foo.call( this, name );
    this.label = label;
}

// 這里,我們創(chuàng)建一個新的`Bar.prototype`鏈接鏈到`Foo.prototype`
Bar.prototype = Object.create( Foo.prototype );

// 注意!現(xiàn)在`Bar.prototype.constructor`不存在了,
// 如果你有依賴這個屬性的習慣的話,可以被手動“修復”。

Bar.prototype.myLabel = function() {
    return this.label;
};

var a = new Bar( "a", "obj a" );

a.myName(); // "a"
a.myLabel(); // "obj a"

注意: 要想知道為什么上面代碼中的this指向a,參見第二章。

重要的部分是Bar.prototype = Object.create( Foo.prototype )Object.create(..)憑空 創(chuàng)建 了一個“新”對象,并將這個新對象內(nèi)部的[[Prototype]]鏈接到你指定的對象上(在這里是Foo.prototype)。

換句話說,這一行的意思是:“做一個 新的 鏈接到‘Foo點兒prototype’的‘Bar點兒prototype’對象”。

function Bar() { .. }被聲明時,就像其他函數(shù)一樣,擁有一個鏈到默認對象的.prototype鏈接。但是 那個 對象沒有鏈到我們希望的Foo.prototype。所以,我們創(chuàng)建了一個 對象,鏈到我們希望的地方,并將原來的錯誤鏈接的對象扔掉。

注意: 這里一個常見的誤解/困惑是,下面兩種方法 能工作,但是他們不會如你期望的那樣工作:

// 不會如你期望的那樣工作!
Bar.prototype = Foo.prototype;

// 會如你期望的那樣工作
// 但會帶有你可能不想要的副作用 :(
Bar.prototype = new Foo();

Bar.prototype = Foo.prototype不會創(chuàng)建新對象讓Bar.prototype鏈接。它只是讓Bar.prototype成為Foo.prototype的另一個引用,將Bar直接鏈到Foo鏈著的 同一個對象Foo.prototype。這意味著當你開始賦值時,比如Bar.prototype.myLabel = ...,你修改的 不是一個分離的對象 而是那個被分享的Foo.prototype對象本身,它將影響到所有鏈接到Foo.prototype的對象。這幾乎可以確定不是你想要的。如果這正是你想要的,那么你根本就不需要Bar,你應當僅使用Foo來使你的代碼更簡單。

Bar.prototype = new Foo()確實 創(chuàng)建了一個新的對象,這個新對象也的確鏈接到了我們希望的Foo.prototype。但是,它是用Foo(..)“構(gòu)造器調(diào)用”來這樣做的。如果這個函數(shù)有任何副作用(比如logging,改變狀態(tài),注冊其他對象,this添加數(shù)據(jù)屬性,等等),這些副作用就會在鏈接時發(fā)生(而且很可能是對錯誤的對象?。?,而不是像可能希望的那樣,僅最終在Bar()的“后裔”被創(chuàng)建時發(fā)生。

于是,我們剩下的選擇就是使用Object.create(..)來制造一個新對象,這個對象被正確地鏈接,而且沒有調(diào)用Foo(..)時所產(chǎn)生的副作用。一個輕微的缺點是,我們不得不創(chuàng)建新對象,并把舊的扔掉,而不是修改提供給我們的默認既存對象。

如果有一種標準且可靠地方法來修改既存對象的鏈接就好了。ES6之前,有一個非標準的,而且不是完全對所有瀏覽器通用的方法:通過可以設(shè)置的.__proto__屬性。ES6中增加了Object.setPrototypeOf(..)輔助工具,它提供了標準且可預見的方法。

讓我們一對一地比較ES6之前和ES6標準的技術(shù)如何處理將Bar.prototype鏈接至Foo.prototype

// ES6以前
// 扔掉默認既存的`Bar.prototype`
Bar.prototype = Object.create( Foo.prototype );

// ES6+
// 修改既存的`Bar.prototype`
Object.setPrototypeOf( Bar.prototype, Foo.prototype );

如果忽略Object.create(..)方式在性能上的輕微劣勢(扔掉一個對象,然后被回收),其實它相對短一些而且可能比ES6+的方式更易讀。但兩種方式可能都只是語法表面現(xiàn)象。

考察“類”關(guān)系

如果你有一個對象a并且希望找到它委托至哪個對象呢(如果有的話)?考察一個實例(一個JS對象)的繼承血統(tǒng)(在JS中是委托鏈接),在傳統(tǒng)的面向類環(huán)境中稱為 自省(introspection)(或 反射(reflection))。

考慮下面的代碼:

function Foo() {
    // ...
}

Foo.prototype.blah = ...;

var a = new Foo();

那么我們?nèi)绾巫允?code>a來找到它的“祖先”(委托鏈)呢?一種方式是接受“類”的困惑:

a instanceof Foo; // true

instanceof操作符的左邊操作數(shù)接收一個普通對象,右邊操作數(shù)接收一個 函數(shù)instanceof回答的問題是:a的整個[[Prototype]]鏈中,有沒有出現(xiàn)被那個被Foo.prototype所隨便指向的對象?

不幸的是,這意味著如果你擁有可以用于測試的 函數(shù)Foo,和它帶有的.prototype引用),你只能查詢某些對象(a)的“祖先”。如果你有兩個任意的對象,比如ab,而且你想調(diào)查是否 這些對象 通過[[Prototype]]鏈相互關(guān)聯(lián),單靠instanceof幫不上什么忙。

注意: 如果你使用內(nèi)建的.bind(..)工具來制造一個硬綁定的函數(shù)(見第二章),這個被創(chuàng)建的函數(shù)將不會擁有.prototype屬性。將instanceof與這樣的函數(shù)一起使用時,將會透明地替換為創(chuàng)建這個硬綁定函數(shù)的 目標函數(shù).prototype。

將硬綁定函數(shù)用于“構(gòu)造器調(diào)用”十分罕見,但如果你這么做,它會表現(xiàn)得好像是 目標函數(shù) 被調(diào)用了,這意味著將instanceof與硬綁定函數(shù)一起使用也會參照原版函數(shù)。

下面這段代碼展示了試圖通過“類”的語義和instanceof來推導 兩個對象 間的關(guān)系是多么荒謬:

// 用來檢查`o1`是否關(guān)聯(lián)到(委托至)`o2`的幫助函數(shù)
function isRelatedTo(o1, o2) {
    function F(){}
    F.prototype = o2;
    return o1 instanceof F;
}

var a = {};
var b = Object.create( a );

isRelatedTo( b, a ); // true

isRelatedTo(..)內(nèi)部,我們借用一個一次性的函數(shù)F,重新對它的.prototype賦值,使他隨意地指向某個對象o2,之后問是否o1F的“一個實例”。很明顯,o1實際上不是繼承或遺傳自F,甚至不是由F構(gòu)建的,所以顯而易見這種實踐是愚蠢且讓人困惑的。這個問題歸根結(jié)底是將類的語義強加于JavaScript的尷尬,在這個例子中是由instanceof的間接語義揭露的。

第二種,也是更干凈的方式,[[Prototype]]反射:

Foo.prototype.isPrototypeOf( a ); // true

注意在這種情況下,我們并不真正關(guān)心(甚至 不需要Foo,我們僅需要一個 對象(在我們的例子中就是隨意標志為Foo.prototype)來與另一個 對象 測試。isPrototypeOf(..)回答的問題是:a的整個[[Prototype]]鏈中,Foo.prototype出現(xiàn)過嗎?

同樣的問題,和完全同樣的答案。但是在第二種方式中,我們實際上不需要間接地引用一個.prototype屬性將被自動查詢的 函數(shù)Foo)。

我們 只需要 兩個 對象 來考察它們之間的關(guān)系。比如:

// 簡單地:`b`在`c`的`[[Prototype]]`鏈中出現(xiàn)過嗎?
b.isPrototypeOf( c );

注意,這種方法根本不要求有一個函數(shù)(“類”)。它僅僅使用對象的直接引用bc,來查詢他們的關(guān)系。換句話說,我們上面的isRelatedTo(..)工具是內(nèi)建在語言中的,它的名字叫isPrototypeOf(..)。

我們也可以直接取得一個對象的[[Prototype]]。在ES5中,這么做的標準方法是:

Object.getPrototypeOf( a );

而且你將注意到對象引用是我們期望的:

Object.getPrototypeOf( a ) === Foo.prototype; // true

大多數(shù)瀏覽器(不是全部!)還一種長期支持的,非標準方法可以訪問內(nèi)部的[[Prototype]]

a.__proto__ === Foo.prototype; // true

這個奇怪的.__proto__(直到ES6才標準化!)屬性“魔法般地”取得一個對象內(nèi)部的[[Prototype]]作為引用,如果你想要直接考察(甚至遍歷:.__proto__.__proto__...[[Prototype]]鏈,這個引用十分有用。

和我們早先看到的.constructor一樣,.__proto__實際上不存在于你考察的對象上(在我們的例子中是a)。事實上,它存在于(不可枚舉地;見第二章)內(nèi)建的Object.prototype上,和其他的共通工具在一起(.toString(), .isPrototypeOf(..), 等等)。

而且,.__proto__看起來像一個屬性,但實際上將它看做是一個getter/setter(見第三章)更合適。

大致地,我們可以這樣描述.__proto__實現(xiàn)(見第三章,對象屬性的定義):

Object.defineProperty( Object.prototype, "__proto__", {
    get: function() {
        return Object.getPrototypeOf( this );
    },
    set: function(o) {
        // setPrototypeOf(..) as of ES6
        Object.setPrototypeOf( this, o );
        return o;
    }
} );

所以,當我們訪問a.__proto__(取得它的值)時,就好像調(diào)用a.__proto__()(調(diào)用getter函數(shù))。雖然getter函數(shù)存在于Object.prototype上(參照第二章,this綁定規(guī)則),但這個函數(shù)調(diào)用將a用作它的this,所以它相當于在說Object.getPrototypeOf( a )

.__proto__還是一個可設(shè)置的屬性,就像早先展示過的ES6Object.setPrototypeOf(..)。然而,一般來說你 不應該改變一個既存對象的[[Prototype]]。

在某些允許對Array定義“子類”的框架中,深度地使用了一些非常復雜,高級的技術(shù),但是在一般的編程實踐中經(jīng)常是讓人皺眉頭的,因為這通常導致非常難理解/維護的代碼。

注意: 在ES6中,關(guān)鍵字class將允許某些近似方法,對像Array這樣的內(nèi)建類型“定義子類”。參見附錄A中關(guān)于ES6中加入的class的討論。

僅有一小部分例外(就像前面提到過的),會設(shè)置一個默認函數(shù).prototype對象的[[Prototype]],使它引用其他的對象(Object.prototype之外的對象)。它們會避免將這個默認對象完全替換為一個新的鏈接對象。否則,為了在以后更容易地閱讀你的代碼 最好將對象的[[Prototype]]鏈接作為只讀性質(zhì)對待。

注意: 針對雙下劃線,特別是在像__proto__這樣的屬性中開頭的部分,JavaScript社區(qū)非官方地創(chuàng)造了一個術(shù)語:“dunder”。所以,那些JavaScript的“酷小子”們通常將__proto__讀作“dunder proto”。

對象鏈接

正如我們看到的,[[Prototype]]機制是一個內(nèi)部鏈接,它存在于一個對象上,這個對象引用一些其他的對象。

這種鏈接(主要)在對第一個對象進行屬性/方法引用,但這樣的屬性/方法不存在時實施。在這種情況下,[[Prototype]]鏈接告訴引擎在那個被鏈接的對象上查找這個屬性/方法。接下來,如果這個對象不能滿足查詢,它的[[Prototype]]又會被查找,如此繼續(xù)。這個在對象間的一系列鏈接構(gòu)成了所謂的“原形鏈”。

創(chuàng)建鏈接

我們已經(jīng)徹底揭露了為什么JavaScript的[[Prototype]]機制和 一樣,而且我們也看到了如何在正確的對象間創(chuàng)建 鏈接

[[Prototype]]機制的意義是什么?為什么總是見到JS開發(fā)者們費那么大力氣(模擬類)在他們的代碼中搞亂這些鏈接?

記得我們在本章很靠前的地方說過Object.create(..)是英雄嗎?現(xiàn)在,我們準備好看看為什么了。

var foo = {
    something: function() {
        console.log( "Tell me something good..." );
    }
};

var bar = Object.create( foo );

bar.something(); // Tell me something good...

Object.create(..)創(chuàng)建了一個鏈接到我們指定的對象(foo)上的新對象(bar),這給了我們[[Prototype]]機制的所有力量(委托),而且沒有new函數(shù)作為類和構(gòu)造器調(diào)用產(chǎn)生的任何沒必要的復雜性,搞亂.prototype.constructor 引用,或任何其他的多余的東西。

注意: Object.create(null)創(chuàng)建一個擁有空(也就是null[[Prototype]]鏈接的對象,如此這個對象不能委托到任何地方。因為這樣的對象沒有原形鏈,instancof操作符(前面解釋過)沒有東西可檢查,所以它總返回false。由于他們典型的用途是在屬性中存儲數(shù)據(jù),這種特殊的空[[Prototype]]對象經(jīng)常被稱為“dictionaries(字典)”,這主要是因為它們沒有可能受到在[[Prototype]]鏈上任何委托屬性/函數(shù)的影響,所以它們是純粹的扁平數(shù)據(jù)存儲。

我們不 需要 類來在兩個對象間創(chuàng)建有意義的關(guān)系。我們需要 真正關(guān)心 的唯一問題是對象為了委托而鏈接在一起,而Object.create(..)給我們這種鏈接并且沒有一切關(guān)于類的爛設(shè)計。

填補Object.create()

Object.create(..)在ES5中被加入。你可能需要支持ES5之前的環(huán)境(比如老版本的IE),所以讓我們來看一個Object.create(..)的簡單 部分 填補工具,它甚至能在更老的JS環(huán)境中給我們所需的能力:

if (!Object.create) {
    Object.create = function(o) {
        function F(){}
        F.prototype = o;
        return new F();
    };
}

這個填補工具通過一個一次性的F函數(shù)并覆蓋它的.prototype屬性來指向我們想連接到的對象。之后我們用new F()構(gòu)造器調(diào)用來制造一個將會鏈到我們指定對象上的新對象。

Object.create(..)的這種用法是目前最常見的用法,因為他的這一部分是 可以 填補的。ES5標準的內(nèi)建Object.create(..)還提供了一個附加的功能,它是 不能 被ES5之前的版本填補的。如此,這個功能的使用遠沒有那么常見。為了完整性,讓我么看看這個附加功能:

var anotherObject = {
    a: 2
};

var myObject = Object.create( anotherObject, {
    b: {
        enumerable: false,
        writable: true,
        configurable: false,
        value: 3
    },
    c: {
        enumerable: true,
        writable: false,
        configurable: false,
        value: 4
    }
} );

myObject.hasOwnProperty( "a" ); // false
myObject.hasOwnProperty( "b" ); // true
myObject.hasOwnProperty( "c" ); // true

myObject.a; // 2
myObject.b; // 3
myObject.c; // 4

Object.create(..)的第二個參數(shù)指定了要添加在新對象上的屬性名,通過聲明每個新屬性的 屬性描述符(見第三章)。因為在ES5之前的環(huán)境中填補屬性描述符是不可能的,所以Object.create(..)的這個附加功能無法填補。

因為Object.create(..)的絕大多數(shù)用途都是使用填補安全的功能子集,所以大多數(shù)開發(fā)者在ES5之前的環(huán)境中使用這種 部分填補 也沒有問題。

有些開發(fā)者采取嚴格得多的觀點,也就是除非能夠被 完全 填補,否則沒有函數(shù)應該被填補。因為Object.create(..)可以部分填補的工具之一,這種較狹窄的觀點會說,如果你需要在ES5之前的環(huán)境中使用Object.create(..)的任何功能,你應當使用自定義的工具,而不是填充,而且應當徹底遠離使用Object.create這個名字。你可以定義自己的工具,比如:

function createAndLinkObject(o) {
    function F(){}
    F.prototype = o;
    return new F();
}

var anotherObject = {
    a: 2
};

var myObject = createAndLinkObject( anotherObject );

myObject.a; // 2

我不會分享這種嚴格的觀點。我完全擁護如上面展示的Object.create(..)的常見部分填補,甚至在ES5之前的環(huán)境下在你的代碼中使用它。我將選擇權(quán)留給你。

鏈接作為候補?

也許這么想很吸引人:這些對象間的鏈接 主要 是為了給“缺失”的屬性和方法提供某種候補。雖然這是一個可觀察到的結(jié)果,但是我不認為這是考慮[[Prototype]]的正確方法。

考慮下面的代碼:

var anotherObject = {
    cool: function() {
        console.log( "cool!" );
    }
};

var myObject = Object.create( anotherObject );

myObject.cool(); // "cool!"

得益于[[Prototype]],這段代碼可以工作,但如果你這樣寫是為了 萬一 myObject不能處理某些開發(fā)者可能會調(diào)用的屬性/方法,而讓anotherObject作為一個候補,你的軟件大概會變得有點兒“魔法”并且更難于理解和維護。

這不是說候補在任何情況下都不是一個合適的設(shè)計模式,但它不是一個在JS中很常見的用法,所以如果你發(fā)現(xiàn)自己在這么做,那么你可能想要退一步并重新考慮它是否真的是合適且合理的設(shè)計。

注意: 在ES6中,引入了一個稱為Proxy(代理)的高級功能,它可以提供某種“方法未找到”類型的行為。Proxy超出了本書的范圍,但會在以后的 “你不懂JS” 系列圖書中詳細講解。

這里不要錯過一個重要的細節(jié)。

例如,你打算為一個開發(fā)者設(shè)計軟件,如果即使在myObject上沒有cool()方法時調(diào)用myObject.cool()也能工作,會在你的API設(shè)計上引入一些“魔法”氣息,這可能會使未來維護你的軟件的開發(fā)者很吃驚。

然而你可以在你的API設(shè)計上少用些“魔法”,而仍然利用[[Prototype]]鏈接的力量。

var anotherObject = {
    cool: function() {
        console.log( "cool!" );
    }
};

var myObject = Object.create( anotherObject );

myObject.doCool = function() {
    this.cool(); // internal delegation!
};

myObject.doCool(); // "cool!"

這里,我們調(diào)用myObject.doCool(),它是一個 實際存在于 myObject上的方法,這使我們的API設(shè)計更清晰(沒那么“魔法”)。在它內(nèi)部,我們的實現(xiàn)依照 委托設(shè)計模式(見第六章),利用[[Prototype]]委托到anotherObject.cool()。

換句話說,如果委托是一個內(nèi)部實現(xiàn)細節(jié),而非在你的API結(jié)構(gòu)設(shè)計中簡單地暴露出來,它傾向于減少意外/困惑。我們會在下一章中詳細解釋 委托。

復習

當試圖在一個對象上進行屬性訪問,而對象沒有該屬性時,對象內(nèi)部的[[Prototype]]鏈接定義了[[Get]]操作(見第三章)下一步應當?shù)侥睦飳ふ宜?。這種對象到對象的串行鏈接定義了對象的“原形鏈”(和嵌套的作用域鏈有些相似),在解析屬性時發(fā)揮作用。

所有普通的對象用內(nèi)建的Object.prototype作為原形鏈的頂端(就像作用域查詢的頂端是全局作用域),如果屬性沒能在鏈條的前面任何地方找到,屬性解析就會在這里停止。toString()valueOf(),和其他幾種共同工具都存在于這個Object.prototype對象上,這解釋了語言中所有的對象是如何能夠訪問他們的。

使兩個對象相互鏈接在一起的最常見的方法是將new關(guān)鍵字與函數(shù)調(diào)用一起使用,在它的四個步驟中(見第二章),就會建立一個新對象鏈接到另一個對象。

那個用new調(diào)用的函數(shù)有一個被隨便地命名為.prototype的屬性,這個屬性所引用的對象恰好就是這個新對象鏈接到的“另一個對象”。帶有new的函數(shù)調(diào)用通常被稱為“構(gòu)造器”,盡管實際上它們并沒有像傳統(tǒng)的面相類語言那樣初始化一個類。

雖然這些JavaScript機制看起來和傳統(tǒng)面向類語言的“初始化類”和“類繼承”類似,而在JavaScript中的關(guān)鍵區(qū)別是,沒有拷貝發(fā)生。取而代之的是對象最終通過[[Prototype]]鏈鏈接在一起。

由于各種原因,不光是前面提到的術(shù)語,“繼承”(和“原型繼承”)與所有其他的OO用語,在考慮JavaScript實際如何工作時都沒有道理。

相反,“委托”是一個更確切的術(shù)語,因為這些關(guān)系不是 拷貝 而是委托 鏈接。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容