第五章: 原型(Prototype)

特別說明,為便于查閱,文章轉自https://github.com/getify/You-Dont-Know-JS

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

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

[[Prototype]]

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

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

考慮下面的代碼:

var myObject = {
    a: 2
};

myObject.a; // 2

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

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

但是如果 myObject 存在 a 屬性時,我們就將注意力轉向對象的 [[Prototype]] 鏈。

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

var anotherObject = {
    a: 2
};

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

myObject.a; // 2

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

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

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

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

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

var anotherObject = {
    a: 2
};

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

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

("a" in myObject); // true

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

Object.prototype

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

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

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

設置與遮蔽屬性

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

myObject.foo = "bar";

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

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

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

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

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

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

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

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

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

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

遮蔽甚至會以微妙的方式隱含地發生,所以要想避免它必須小心。考慮這段代碼:

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。結果就是在 [[Prototype]] 上進行 a[[Get]] 查詢,從 anotherObject.a 得到當前的值 2,將這個值遞增1,然后將值 3[[Put]] 賦值到 myObject 上的新遮蔽屬性 a 上。噢!

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

“類”

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

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

實際上,在所有語言中,JavaScript 幾乎是獨一無二的,也許是唯一的可以被稱為“面向對象”的語言,因為可以根本沒有類而直接創建對象的語言很少,而 JavaScript 就是其中之一。

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

“類”函數

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

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

function Foo() {
    // ...
}

Foo.prototype; // { }

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

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

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

讓我們描繪一下:

function Foo() {
    // ...
}

var a = new Foo();

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

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

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

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

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

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

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

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

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

名稱的意義何在?

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

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

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

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

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

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

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

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

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

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

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

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

"構造器"(Constructors)

讓我們回到早先的代碼:

function Foo() {
    // ...
}

var a = new Foo();

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

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

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

function Foo() {
    // ...
}

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

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

Foo.prototype 對象默認地(就在代碼段中第一行中聲明的地方!)得到一個公有的,稱為 .constructor 的不可枚舉(見第三章)屬性,而且這個屬性回頭指向這個對象關聯的函數(這里是 Foo)。另外,我們看到被“構造器”調用 new Foo() 創建的對象 a 看起來 也擁有一個稱為 .constructor 的屬性,也相似地指向“創建它的函數”。

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

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

注意: 這個慣例是如此強大,以至于如果你在一個小寫字母名稱的方法上使用 new 調用,或并沒有在一個大寫字母開頭的函數上使用 new,許多 JS 語法檢查器將會報告錯誤。這是因為我們如此努力地想要在 JavaScript 中將(假的)“面向類” 搞對,所以我們建立了這些語法規則來確保我們使用了大寫字母,即便對 JS 引擎來講,大寫字母根本沒有 任何意義

構造器還是調用?

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

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

舉個例子:

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

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

a; // {}

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

換句話說,在 JavaScrip t中,更合適的說法是,“構造器”是在前面 new 關鍵字調用的任何函數

函數不是構造器,但是當且僅當 new 被使用時,函數調用是一個“構造器調用”。

機制

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

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

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 上;參照第二章關于 this 綁定的內容)上添加了 .name 屬性,和類的實例包裝數據值很相似。

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

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

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

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

復活“構造器”

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

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

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

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

考慮這段代碼:

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

Foo.prototype = { /* .. */ }; // 創建一個新的 prototype 對象

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

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

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

誤解,消除。

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

舉例來說:

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

Foo.prototype = { /* .. */ }; // 創建一個新的 prototype 對象

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

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

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

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

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

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

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

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

“(原型)繼承”

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

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

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

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

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

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

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

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

// 注意!現在 `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(..) 憑空 創建 了一個“新”對象,并將這個新對象內部的 [[Prototype]] 鏈接到你指定的對象上(在這里是 Foo.prototype)。

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

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

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

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

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

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

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

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

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

讓我們一對一地比較一下 ES6 之前和 ES6 標準的技術如何處理將 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+ 的方式更易讀。但兩種方式可能都只是語法表面現象。

考察“類”關系

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

考慮下面的代碼:

function Foo() {
    // ...
}

Foo.prototype.blah = ...;

var a = new Foo();

那么我們如何自省 a 來找到它的“祖先”(委托鏈)呢?一種方式是接受“類”的困惑:

a instanceof Foo; // true

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

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

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

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

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

// 用來檢查 `o1` 是否關聯到(委托至)`o2` 的幫助函數
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(..) 內部,我們借用一個一次性的函數 F,重新對它的 .prototype 賦值,使它隨意地指向某個對象 o2,之后問 o1 是否是 F 的“一個實例”。很明顯,o1 實際上不是繼承或遺傳自 F,甚至不是由 F 構建的,所以顯而易見這種做法是愚蠢且讓人困惑的。這個問題歸根結底是將類的語義強加于 JavaScript 的尷尬,在這個例子中是由 instanceof 的間接語義揭露的。

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

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

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

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

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

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

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

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

Object.getPrototypeOf( a );

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

對象鏈接

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

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

創建鏈接

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

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

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

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

var bar = Object.create( foo );

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

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

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

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

填補 Object.create()

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

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

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

Object.create(..) 的這種用法是目前最常見的用法,因為它的這一部分是 可以 填補的。ES5 標準的內建 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(..) 的第二個參數通過聲明每個新屬性的 屬性描述符(見第三章)指定了要添加在新對象上的屬性。因為在 ES5 之前的環境中填補屬性描述符是不可能的,所以 Object.create(..) 的這個附加功能無法填補。

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

有些開發者采取嚴格得多的觀點,也就是除非能夠被 完全 填補,否則沒有函數應該被填補。因為 Object.create(..) 是可以部分填補的工具之一,所以這種較狹窄的觀點會說,如果你需要在 ES5 之前的環境中使用 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 之前的環境下在你的代碼中使用它。我將選擇權留給你。

鏈接作為候補?

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

考慮下面的代碼:

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

var myObject = Object.create( anotherObject );

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

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

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

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

這里不要錯過一個重要的細節。

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

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

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

var myObject = Object.create( anotherObject );

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

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

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

換句話說,如果委托是一個內部實現細節,而非在你的 API 結構設計中簡單地暴露出來,那么它將傾向于減少意外/困惑。我們會在下一章中詳細解釋 委托

復習

當試圖在一個對象上進行屬性訪問,而對象又沒有該屬性時,對象內部的 [[Prototype]] 鏈接定義了 [[Get]] 操作(見第三章)下一步應當到哪里尋找它。這種對象到對象的串行鏈接定義了對象的“原形鏈”(和嵌套的作用域鏈有些相似),在解析屬性時發揮作用。

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

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

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

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

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

相反,“委托”是一個更確切的術語,因為這些關系不是 拷貝 而是委托 鏈接

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容