【翻譯】JavaScript原型繼承工作原理

於明昊發表於2013-09-18

譯者按:這篇文章原是出自著名的前端博主阮一峰一篇關於JS原型繼承的文章:Javascript繼承機制的設計思想中的引用。這篇文章對於原型繼承講解詳細,令人讀之有撥雲見日之感,作者在文章裡說:

我一直很難理解Javascript語言的繼承機制。

它沒有“子類”和“父類”的概念,也沒有“類”(class)和“例項”(instance)的區分,全靠一種很奇特的“原型鏈”(prototype chain)模式,來實現繼承。

我花了很多時間,學習這個部分,還做了很多筆記。但是都屬於強行記憶,無法從根本上理解。

直到昨天,我讀到法國程式設計師Vjeux的解釋,才恍然大悟,完全明白了Javascript為什麼這樣設計。

不過說來慚愧,本人物件導向學的不是很好,對於傳統的繼承思想其實理解的並不深刻,所以JS的在程式語言裡特立獨行的原型繼承對我來說就是第一個深入理解的繼承思想。我也特別想知道這篇令作者恍然大悟的文章究竟講的如何,於是特意翻譯一下,以饗讀者。


原文連結:Javascript – How Prototypal Inheritance really works

JavaScript採用原型繼承這事兒是眾所皆知的,但由於它預設只提供了一個實現的例項,也就是 new 運算子,因此對於它的解釋總是令人困惑。這篇文章旨在闡明什麼是原型繼承以及在JavaScript中究竟如何使用原型繼承。

原型繼承的定義

當你閱讀關於JS原型繼承的解釋時,你時常會看到以下這段文字:

當查詢一個物件的屬性時,JavaScript 會向上遍歷原型鏈,直到找到給定名稱的屬性為止。——出自JavaScript祕密花園

大多數JavaScript的實現用 __proto__ 屬性來表示一個物件的原型鏈。在這篇文章裡我們將看到 __proto__prototype 的區別何在。

注:__proto__ 是一個不應在你程式碼中出現的非正規的用法,這裡僅僅用它來解釋JavaScript原型繼承的工作原理。

以下程式碼展示了JS引擎如何查詢屬性:

function getProperty(obj, prop) {
    if (obj.hasOwnProperty(prop))
        return obj[prop]

    else if (obj.__proto__ !== null)
        return getProperty(obj.__proto__, prop)

    else
        return undefined
}

讓我們舉一個常見的例子:二維點,擁有二維座標 x y ,同似擁有一個 print 方法。

用之前我們說過的原型繼承的定義,我們建立一個物件 Point ,擁有三個屬性:xyprint 。為了能建立一個新的二維點,我們需要建立一個新的物件,讓他其中的 __proto__ 屬性指向 Point

var Point = {
    x: 0,
    y: 0,
    print: function () { console.log(this.x, this.y); }
};

var p = {x: 10, y: 20, __proto__: Point};
p.print(); // 10 20

JavaScript怪異的原型繼承

令人困惑的是,每個教授原型繼承的人都不會給出上面那樣的程式碼,反而會給出下面這樣的程式碼:

function Point(x, y) {
    this.x = x;
    this.y = y;
}
Point.prototype = {
    print: function () { console.log(this.x, this.y); }
};

var p = new Point(10, 20);
p.print(); // 10 20

這和說好的不一樣啊,這裡 Point 變成了函式,然後還有個什麼 prototype 的屬性,而且有了 new 運算子。這他喵的是什麼情況?

new 運算子是如何工作的

造物者 Brendan Eich 想讓JS和傳統的物件導向的程式語言差不太多,如Java和C++。在這些語言裡,我們採用 new 運算子來給類例項化一個新的物件。所以他在JS裡寫了一個 new 運算子。

  • C++裡有用來初始化例項屬性的建構函式概念,因此 new 運算子必須針對函式。
  • 我們需要將物件的方法放到一個地方去,既然我們在用原型語言,我們就把它放到函式的原型屬性中去。

new 運算子接受一個函式 F 及其引數:new F(arguments...)。這一過程分為三步:

  1. 建立類的例項。這步是把一個空的物件的 __proto__ 屬性設定為 F.prototype
  2. 初始化例項。函式 F 被傳入引數並呼叫,關鍵字 this 被設定為該例項。
  3. 返回例項。

現在我們知道了 new 是怎麼工作的,我們可以用JS程式碼實現一下:

function New (f) {
    var n = { '__proto__': f.prototype }; /*第一步*/
    return function () {
        f.apply(n, arguments);            /*第二步*/
        return n;                         /*第三步*/
    };
}

一個小小的例子來看一下他的工作狀況:

function Point(x, y) {
    this.x = x;
    this.y = y;
}
Point.prototype = {
    print: function () { console.log(this.x, this.y); }
};

var p1 = new Point(10, 20);
p1.print(); // 10 20
console.log(p1 instanceof Point); // true

var p2 = New (Point)(10, 20);
p2.print(); // 10 20
console.log(p2 instanceof Point); // true

JavaScript中真正的原型繼承

JS的ECMA規範只允許我們採用 new 運算子來進行原型繼承。但是大宗師 Douglas Crockford 卻發現了一種可以利用 new 來實現真正的原型繼承的方式!他寫下了 Object.create 函式如下:

Object.create = function (parent) {
    function F() {}
    F.prototype = parent;
    return new F();
};

這看起來蠻奇怪的,但卻是相當的簡潔:它建立了新的物件,並將其原型設定為你想設定的任意值。如果我們允許使用 __proto__ ,那我們也可以這樣寫:

Object.create = function (parent) {
    return { '__proto__': parent };
};

下面這段程式碼就是讓我們的 Point 採用真正的原型繼承:

var Point = {
    x: 0,
    y: 0,
    print: function () { console.log(this.x, this.y); }
};

var p = Object.create(Point);
p.x = 10;
p.y = 20;
p.print(); // 10 20

結論

我們已經瞭解了JS原型繼承是什麼,以及JS如何用特定的方式來實現之。然而使用真正的原型繼承(如 Object.create 以及 __proto__)還是存在以下缺點:

  • 標準性差:__proto__ 不是一個標準用法,甚至是一個不贊成使用的用法。同時原生態的 Object.create 和道爺寫的原版也不盡相同。
  • 優化性差: 不論是原生的還是自定義的 Object.create ,其效能都遠沒有 new 的優化程度高,前者要比後者慢高達10倍。

附圖,ECMA中的原型繼承解釋圖:

enter image description here


譯者的理解

實際上我覺得這篇文章所說還是十分的晦澀,沒有阮一峰的原文寫的深入淺出。這裡還是推薦要了解原型繼承和JS物件導向程式設計的讀者閱讀阮一峰的四篇博文:

這裡簡單的說一下我的理解:

  1. 為什麼不推薦使用原型鏈 __proto__ 的寫法?因為這樣會對下游的子類開放對整個原型鏈的操作許可權,也就是在子類中就可以對由上游父類產生的原型鏈進行修改,這一點是十分危險的。比如:

    var P = new function(){}
    P.prototype.name = 'parent'
    var a = new P()
    a.name // 'parent'
    a.__proto__ === P.prototype //true
    a.__proto__.name = 'child'
    P.prototype.name // 被修改為'child'
    
  2. 原型繼承到底繼承了什麼?實際上就是繼承了建構函式原型鏈兩個東西。其中建構函式繼承是用 apply() 實現的,這就意味著僅僅是把父類裡面的屬性複製了一遍,對其進行任何的更改,都不會影響其他的例項,在繼承之後對父類進行任何更改也不會影響其子類。而對於原型鏈的繼承,則是表示子類和父類共用原型鏈上的屬性和方法,對於原型鏈的更改只能從上游源頭進行修改,當然子類可以重寫父類的方法,不過這樣做實際上就是先給子類增添一個重名的方法,而導致JS引擎先呼叫此方法而不去呼叫原型鏈的方法。

  3. 大宗師的程式碼到底幹了什麼?道爺的程式碼主要是把剛才的建構函式繼承都拋棄了,把所有父類的屬性全部放在原型鏈上,讓子類直接引用。這樣是相當的節省記憶體的,這時子類如果想更改某一屬性,則就會遵循2中所說的重寫的方法。而且道爺這樣寫,父類就可以是任意的物件,而不必要非是函式,理解起來比JS晦澀的原生繼承好的多。

相關文章