徹底弄懂JS原型與繼承

陳煜侖發表於2018-11-20

本文由淺到深,循序漸進的將原型與繼承的抽象概念形象化,且每個知識點都搭配相應的例子,儘可能的將其通俗化,而且本文最大的優點就是:長(為了更詳細嘛)。

一、原型

首先,我們先說說原型,但說到原型就得從函式說起,因為原型物件就是指函式所擁有的prototype屬性(所以下文有時說原型,有時說prototype,它們都是指原型)。

1.1 函式

說到函式,我們得先有個概念:函式也是物件,和物件一樣擁有屬性,例如:

function F(a, b) {
    return a * b;
}

F.length   // 2  指函式引數的個數
F.constructor   // function Function() { [native code] }
typeof F.prototype  // "object"
複製程式碼

從上面我們可以看出函式和物件一樣擁有屬性,我們重點說的就是prototype這個原型屬性。

prototype也是一個物件,為了更形象的理解,我個人是把上述理解為這樣的:

// F這個函式物件裡有個prototype物件屬性
F = {
    prototype: {}
}
複製程式碼

下面我們就說說這個prototype物件屬性。

1.2 prototype物件的屬性

prototype是一個物件,裡面有個預設屬性constructor,預設指向當前函式,我們依舊使用F這個函式來說明:

F = {
    prototype: {
        constructor: F    // 指向當前函式
    }
}
複製程式碼

既然prototype是個物件,那我們也同樣可以給它新增屬性,例如:

F.prototype.name = 'BetterMan';

// 那F就變成如下:
F = {
    prototype: {
        constructor: F,
        name: 'BetterMan'
    }
}
複製程式碼

prototype就先鋪墊到這,下面我們來說說物件,然後再把它們串起來。

1.3 建立物件

建立物件有很多種方式,本文針對的是原型,所以就說說使用建構函式建立物件這種方式。上面的F函式其實就是一個建構函式(建構函式預設名稱首字母大寫便於區分),所以我們用它來建立物件。

let f = new F();
console.log(f)  // {}
複製程式碼

這時得到了一個“空”物件,下面我們過一遍建構函式建立物件的過程:

  1. 建立一個新物件;
  2. 將建構函式的作用域賦給新物件,即把this指向新物件(同時還有一個過程,新物件的__proto__屬性指向建構函式的ptototype屬性,後面會解釋這塊)。
  3. 執行函式內程式碼,即為新物件新增屬性。
  4. 返回新物件(不需要寫,預設返回this,this就是指新物件)。

下面我們修改一下F建構函式:

function F(age) {
    this.age = age;
}
複製程式碼

再用F來建立一個例項物件:

let f1 = new F(18);  // 18歲,別來無恙
console.log(f1); // {age: 18}
複製程式碼

其實我們就得到了一個f1物件,裡面有一個age屬性,但真的只有age屬性嗎?上面我們講到建構函式建立物件的過程,這裡的新建物件,然後給物件新增屬性,然後返回新物件,我們都是看得到的,還有一個過程,就是新物件的__proto__屬性指向建構函式的ptototype屬性。

我們列印一下看看:

console.log(f1.__proto__);  // {constructor: F}
複製程式碼

這不就是F建構函式的prototype物件嗎?這個指向過程也就相當於f1.__proto__ === F.prototype,理解這個很重要!

__proto__我們可稱為隱式原型(不是所有瀏覽器都支援這個屬性,所以谷歌搞起),這個就厲害了,既然它指向了建構函式的原型,那我們獲取到它也就能獲取到建構函式的原型了(但一般我們不用這個方法獲取原型,後面會介紹其他方法)。

前面我們說了建構函式的prototype物件中的constructor屬性是指向自身函式的,那我們用__proto__來驗證一下:

console.log(f1.__proto__.constructor);  // F(age) {this.age = age;}
// 因為f1.__proto__ === F.prototype,所以上述就是指F.prototype.constructor
複製程式碼

嗯,不錯不錯,看來沒毛病!

目前來說應該還是比較好理解的,那我們再看看:

console.log(f1.constructor);  // F(age) {this.age = age;}
複製程式碼

額,這什麼鬼?難道例項物件f1還有個constructor屬性和建構函式原型的constructor一樣都是指向建構函式?這就有點意思了。

其實不是,應該是說f1的神祕屬性__proto__指向了F.prototype,這相當於一個指向引用,如果要形象點的話可以把它理解為把F.prototype的屬性"共享"到了f1身上,但這是動態的"共享",如果後面F.prototype改變的話,f1所"共享"到的屬性也會跟著改變。理解這個很重要!重要的事情說三遍!重要的事情說三遍!重要的事情說三遍!

那我們再把程式碼"形象化":

F = {
    prototype: {
        constructor: F
    }
};

f1 = {
    age: 18,
    __proto__: {    // 既然我們已經把這個形象化為"共享"屬性了,那就再形象一點
        constructor: F
    }
}

// 更形象化:
f1 = {
    age: 18,  // 這個是f1物件自身屬性
    constructor: F  // 這個是從原型上"共享"的屬性
}
複製程式碼

既然我們說的是動態"共享"屬性,那我們改一改建構函式的prototype屬性看看f1會不會跟著改變:

// 沒改之前
console.log(f1.name);  // undefined

// 修改之後
F.prototype.name = 'BetterMan';
console.log(f1);   // {age: 18}
console.log(f1.name);  // 'BetterMan'
複製程式碼

A(讀A第二調)……,看來和想的一毛一樣啊,但是f1上面沒看到name屬性,那就是說我們只是可以從建構函式的原型上拿到name屬性,而不是把name變為例項物件的自身屬性。說到這裡就得提提物件自身屬性和原型屬性(從原型上得來的屬性)了。

1.4 物件自身屬性和原型屬性

我們所建立的例項物件f1,有自身屬性age,還有從原型上找到的屬性name,我們可以使用hasOwnProperty方法檢測一下:

console.log(f1.hasOwnProperty('age'));  // true 說明是自身屬性
console.log(f1.hasOwnProperty('name')); // false 說明不是自身屬性
複製程式碼

那既然是物件屬性,應該就可以新增和刪除吧?我們試試:

delete f1.age;
console.log(f1.age); // undefined

delete f1.name;
console.log(f1.name); // 'BetterMan'
複製程式碼

額,age屬性刪除成功了,但好像name沒什麼反應,比較堅挺,這就說明了f1物件可以掌控自身的屬性,愛刪刪愛加加,但name屬性是從原型上得到的,是別人的屬性,你可沒有權利去修改。

其實我們在訪問物件的name屬性時,js引擎會依次查詢f1物件上的所有屬性,但是找不到這個屬性,然後就會去建立f1例項物件的建構函式的原型上找(這就歸功於神祕屬性__proto__了,是它把例項物件和建構函式的原型聯絡了起來),然後找到了(如果再找不到的話,還會往上找,這就涉及到原型鏈了,後面我們會說到)。而找age屬性時直接就在f1上找到了,就不用再去其他地方找了。

到現在大家應該對原型有了個大概的理解了吧,但它有什麼用呢? 用處大大的,可以說我們無時無刻都在使用它,下面我們繼續。

二、繼承

講了原型,那肯定是離不開繼承這個話題的,說到繼承就很熱鬧了,什麼原型模式繼承、建構函式模式繼承、物件模式繼承、屬性拷貝模式繼承、多重繼承、寄生式繼承、組合繼承、寄生組合式繼承……這什麼鬼?這麼多,看著是不是很頭疼?

我個人就把它們分為原型方式、建構函式方式、物件方式這三個方式,然後其他的繼承方式都是基於這三個方式的組合,當然這只是我個人的理解哈,下面我們開始。

2.1 原型鏈

說到繼承,肯定得說原型鏈,因為原型鏈是繼承的主要方法。

我們先來簡單的回顧一下建構函式、原型和例項的關係:每個建構函式都有一個原型物件,原型物件都包含一個指向建構函式的指標(constructor),而例項包含一個指向原型物件的內部指標(__proto__)。那麼,假如我們讓原型物件等於另一個例項物件,結果會怎麼樣呢?顯然,此時的原型物件將包含一個指向另一個原型的指標(__proto__),相應的,另一個原型中也包含著一個指向另一個建構函式的指標(constructor)。那假如另一個原型又是另一個物件例項,那麼上述關係依然成立,如此層層遞進,就構成了例項與原型的鏈條。這就是所謂的原型鏈,如圖:

徹底弄懂JS原型與繼承

到這裡千萬不要亂,一定要理解了這段話再往下看,其實就是把別人的例項物件賦值給了我們的建構函式的原型,這就是第一層,然後如果別人的例項物件的建構函式的原型又是另一個人的例項物件的話,那不是一樣的道理嗎?這就是第二層,那如果再出現個第三者,那又是一層了,這就構成了一個層層連起來的原型鏈。

好了,如果你看到了這裡,說明已經理解了上述"鏈情",那我們就開始搞搞繼承。

2.2 繼承方式

繼承有多重形式,我們一個個來,分別對比一下其中的優缺點。

注:因為多數繼承都依賴於原型及原型鏈,所以當再依賴於其他方式時,我就以這個方式來命名這個繼承方式,這樣看起來就不會那麼複雜。

1. 基於建構函式方式

我們先定義三個建構函式:

// 建構函式A
function A() {
    this.name = 'A';
    this.say = function() {
        return this.name;
    };
};
// 建構函式B
function B() {
    this.name = 'B';
};
// 建構函式C
function C(width, height) {
    this.name = 'C';
    this.width = width;
    this.height = height;
    this.getArea = function() {
        return this.width * this.height;
    };
};
複製程式碼

下面我們試試繼承:

B.prototype = new A();
C.prototype = new B();
複製程式碼

上述是不是有點熟悉,是不是就是前面所提的原型鏈的概念:B建構函式的原型被賦上A建構函式的例項物件,然後C的原型又被賦上B建構函式的例項物件。

然後我們用C建構函式來建立一個例項物件:

let c1 = new C(2, 6);
console.log(c1);   // {name: "C", width: 2, height: 6, getArea: ƒ}
console.log(c1.name);  // 'C'
console.log(c1.getArea()); // 12
console.log(c1.say());  // 'C'
複製程式碼

c1居然有say方法了,可喜可賀,它是怎麼做到的?讓我們來捋捋這個過程:

  • ①首先C新建了一個"空"物件;
  • ②然後this指向這個"空"物件;
  • ③c1.__proto__指向C.prototype;
  • ④給this物件賦值,這樣就有了namewidthheightgetArea這四個自身屬性;
  • ⑤返回this物件,此時我們就得到了c1例項物件;
  • ⑥然後列印console.log(c1)console.log(c1.name)console.log(c1.getArea())都好理解;
  • ⑦接著console.log(c1.say()),這就得去找say方法了,js引擎先在c1身上找,沒找到,然後c1.__proto__這個神祕連結是指向C建構函式的原型的,然後就去C.prototype上找,然後我們是寫有C.prototype = new B()的,也就是說是去B建構函式的例項物件上找,還是沒有,那繼續,又通過new B().__proto__B的原型上找,然後我們是寫有B.prototype = new A();,那就是去A所建立的例項物件上找,沒有,那就又跑去A建構函式的原型上找,OK!找到!

這個過程就相當於這樣: c1 —→ C.prototype —→ new B() —→ B.prototype —→ new A() —→ A.prototype

這就是上述的一個基於建構函式方式的繼承過程,其實就是一個查詢過程,但是大家有沒有發現什麼?

上述方式存在兩個問題:第一個問題就是constructor的指向。

本來B.prototype中的constructor指向好好的,是指向B的,但現在B.prototype完全被new A()給替換了,那現在的B.prototype.constructor是指向誰的?我們看看:

console.log(B.prototype.constructor);  // ƒ A() {}
let b1 = new B();
console.log(b1.constructor);   // ƒ A() {}
複製程式碼

此時我們發現不僅是B.prototype.constructor指向A,連b1也是如此,別忘了b1中的constructor屬性也是由B.prototype所共享的,所以老大(B)改變了,小弟(b1)當然也會跟著動態改變。

但現在它們為什麼是指向A的呢?因為B.prototype被替換為了new A(),那new A()裡有什麼?我們再把B.prototypenew A()形象化來表示一下:

A = {
    prototype:{
        constructor: A
    }
};

new A() = {
    name: 'A',
    say: function() {
        return this.name;
    },
    constructor: A       // 由__proto__的指向所共享得到的
}

B = {
    prototype:{
        constructor: B
    }
};

// 這時把B.prototype換為new A(),那就變成了這樣:
B = {
    prototype:{
        name: 'A',
        say: function() {
            return this.name;
        },
        constructor: A   // 所以指向就變成了A
    }
};
複製程式碼

所以我們要手動修正B.prototype.constructor的指向,同理C.prototype.constructor的指向也是如此:

B.prototype = new A();
B.prototype.constructor = B;
C.prototype = new B();
C.prototype.constructor = C;
複製程式碼

第一個問題解決了,到第二個問題:效率的問題。

當我們用某一個建構函式建立物件時,其屬性就會被新增到this中去。並且當別新增的屬性實際上是不會隨著例項改變時,這種做法會顯得沒有效率。例如在上面的例項中,A建構函式是這樣定義的:

function A() {
    this.name = 'A';
    this.say = function() {
        return this.name;
    };
};
複製程式碼

這種實現意味著我們用new A()建立的每個例項都會擁有一個全新的name屬性和say屬性,並在記憶體中擁有獨立的儲存空間。所以我們應該考慮把這些屬性放到原型上,讓它們實現共享:

// 建構函式A
function A() {};
A.prototype.name = 'A';
A.prototype.say = function() {
    return this.name;
};

// 建構函式B
function B() {};
B.prototype.name = 'B';

// 建構函式C
function C(width, height) {  // 此處的width和height屬性是隨引數變化的,所以就不需要改為共享屬性
    this.width = width;
    this.height = height;
};
C.prototype.name = 'C';
C.prototype.getArea = function() {
    return this.width * this.height;
};
複製程式碼

這樣一來,建構函式所建立的例項中一些屬性就不再是私有屬性了,而是在原型中能共享的屬性,現在我們來試試:

let test1 = new A();
let test2 = new A();
console.log(test1.say === test2.say);  // true 沒改為共享屬性前,它們是不相等的
複製程式碼

雖然這樣做通常更有效率,但也只是針對例項中不可變屬性而言的,所以在定義建構函式時我們也要考慮哪些屬性適合共享,哪些適合私有(且一定要繼承後再對原prototype進行擴充套件和矯正constructor)。

2. 基於原型的方式

正如上面所做的,處於效率考慮,我們應當儘可能的將一些可重用的屬性和方法新增到原型中去,這樣的話我們僅僅依靠原型就可以完成繼承關係的構建了,由於原型上的屬性都是可重用的,這也意味著從原型上繼承比在例項上繼承要好得多,而且既然需要繼承的屬性都放在了原型上,又何必生成例項降低效率,然後又從所生成的例項中繼承不需要的私有屬性呢?所以我們直接拋棄例項,從原型上繼承:

// 建構函式A
function A() {};
A.prototype.name = 'A';
A.prototype.say = function() {
    return this.name;
};

// 建構函式B
function B() {};
B.prototype = A.prototype;  //  先繼承,再進行constructor矯正和B.prototype的擴充套件
B.prototype.constructor = B; 
B.prototype.name = 'B';

// 建構函式C
function C(width, height) {  // 此處的width和height屬性是隨引數變化的,所以就不需要改為共享屬性
    this.width = width;
    this.height = height;
};
C.prototype = B.prototype;
C.prototype.constructor = C; //  先繼承,再進行constructor矯正和C.prototype的擴充套件
C.prototype.name = 'C';
C.prototype.getArea = function() {
    return this.width * this.height;
};
複製程式碼

嗯,這樣感覺效率高多了,也比較養眼,然後我們試試效果:

let b2 = new B();
console.log(b2.say());  // 'C'
複製程式碼

(⊙o⊙)…不是應該列印出B的嗎?怎麼和我內心的小完美不太一樣?

想必大家應該都看出來了,上面的繼承方式其實就相當於A、B、C全都共享了同一個原型,那就造成了引用問題,在後面對C原型上的name屬性進行了修改,所以此時ABC的原型的name屬性都為'C',此時真的是受制於人啊。

有沒有兩全其美的辦法,我又要效率,又不想受制於人,啪!把這兩個方法結合起來不就行了嗎?!

3. 結合建構函式方式和原型的方式

我既想快,又不想被別人管,搞個第三者來解決怎麼樣?(怎麼感覺聽起來怪怪的)。我們在它們中間使用一個臨時建構函式(所以也可稱為臨時構造法)來做個橋樑,把小弟管大哥的關係斷掉(腿打斷),然後大家又可以高效率的合作:

// 建構函式A
function A() {};
A.prototype.name = 'A';
A.prototype.say = function() {
    return this.name;
};

// 建構函式B
function B() {};
let X = function() {};   // 新建一個"空"屬性的建構函式
X.prototype = A.prototype;  // 將X的原型指向A的原型
B.prototype = new X();  // B的原型指向X建立的例項物件
B.prototype.constructor = B;  // 記得修正指向
B.prototype.name = 'B';       // 擴充套件

// 建構函式C
function C(width, height) {  // 此處的width和height屬性是隨引數變化的,所以就不需要改為共享屬性
    this.width = width;
    this.height = height;
};
// 同上
let Y = function() {};  
Y.prototype = B.prototype;
C.prototype = new Y();
C.prototype.constructor = C;
C.prototype.name = 'C';
C.prototype.getArea = function() {
    return this.width * this.height;
};
複製程式碼

現在試試效果怎麼樣:

let c3 = new C;
console.log(c3.say());  // A
複製程式碼

穩!這樣我們既不是直接繼承例項上的屬性,而是繼承原型所共享的屬性,而且還能通過XY這兩個"空"屬性建構函式來把A和B上的非共享屬性過濾掉(因為new X()比起new A()所生成的例項,因為X是空的,所以不會生成的物件不會存在私有屬性,但是new A()可能會存在私有屬性,既然是私有屬性,所以也就是不需要被繼承,所以new A()會存在效率問題和多出不需要的繼承屬性)。

4. 基於物件的方式

這種基於物件的方式其實包括幾種方式,因為都和物件相關,所以我就統稱為物件方式了,下面一一介紹:

①以接收物件的方式

function create(o) {  // o是所要繼承的父物件
    function F() {};
    F.prototype = o;
    return new F();  // 返回一個例項物件
};
let a = {
    name: 'better'
};
console.log(create(a).name);  // 'better'
複製程式碼

這種方式是接受一個父物件後返回一個例項,進而達到繼承的效果,有沒有點似曾相識的感覺?這不就是低配版的Object.create()嗎?有興趣的可以多去了解了解。所以這個方式其實也應該稱為"原型繼承法",因為也是以修改原型為基礎的,但又和物件相關,所以我就把它歸為物件方式了,這樣比較好分類。

②以拷貝物件屬性的方式

// 直接將父原型的屬性拷貝過來,好處是Child.prototype.constructor沒被重置,但這種方式僅適用於只包含基本資料型別的物件,且父物件會覆蓋子物件的同名屬性
function extend(Child, Parent) {   // Child, Parent都為建構函式
    let c = Child.prototype;
    let p = Parent.prototype;
    for (let i in p) {
        c[i] = p[i];
    }
};
複製程式碼
// 這種直接拷貝屬性的方式簡單粗暴,直接複製傳入的物件屬性,但還是存在引用型別的問題
function extendCopy(p) {   // p是被繼承的物件
    let c = {};
    for (let i in p) {
        c[i] = p[i];
    }
    return c;
};
複製程式碼
// 上面的extendCopy可稱為淺拷貝,沒有解決引用型別的問題,現在我們使用深拷貝,這樣就解決了引用型別屬性的問題,因為不管你有多少引用型別,全都一個個拷過來
function deepCopy(p, c) {  // c和p都是物件
    c = c || {};
    for (let i in p) {
        if (p.hasOwnProperty[i]) {   // 排除繼承屬性
            if (typeof p[i] === 'object') {  // 解決引用型別
                c[i] = Array.isArray(p[i]) ? [] : {};
                deepCopy[p[i], c[i]];
            } else {
                c[i] = p[i];
            }
        }
    }
    return c;
}
複製程式碼

③拷貝多物件屬性的方式

// 這種方式就可以一次拷貝多個物件屬性,也稱為多重繼承
function multi() {
    let n = {},
    stuff,
    j = 0,
    len = arguments.length;
    for (j = 0; j < len; j++) {
        stuff = arguments[j];
        for (let i in stuff) {
            if (stuff.hasOwnProperty(i)) {
                n[i] = stuff[i];
            }
        }
    }
    return n
};
複製程式碼

④吸收物件屬性並擴充套件的方式

這種方式其實應該叫做"寄生式繼承",這名字乍看很抽象,其實也就那麼回事,所以也把它分到物件方式裡:

// 其實也就是在建立物件的函式中吸收了其它物件的屬性(寄生獸把別人的xx吸走),然後對其擴充套件並返回
let parent = {
    name: 'parent',
    toString: function() {
        return this.name;
    }
};
function raise() {
    let that = create(parent);  // 使用前面我們寫過的create函式
    that.other = 'Once in a blue moon!'; // 今天學的,醜顯唄一下
    return that;
}
複製程式碼

和物件相關的方式是不是有點多?但其實也都是圍繞著物件屬性的,理解這點就好理解了,下面繼續。

5. 建構函式借用法

這個方式其實也可歸為建構函式方式,但比較溜,所以單獨拎出來溜溜(這是最後一個了,我保證)。

我們再把之前定義的老函式A拿出來炒炒:

// 建構函式A
function A() {
    this.name = 'A';
};
A.prototype.say = function() {
    return this.name;
};

// 建構函式D
function D() {
    A.apply(this, arguments);  // 這裡就相當於借用A建構函式把A中屬性建立給了D,即name和say屬性
};
D.prototype = new A();  // 這裡負責拿到A原型上的屬性
D.prototype.name = 'D';  // 繼承後再進行擴充套件
複製程式碼

這樣兩個步驟是不是就把A的自身屬性和原型屬性都搞定了?簡單完美!

等等,看起來好像有點不對,A.apply(this, arguments)已經完美的把A自身屬性變為了D的自身屬性,但是D.prototype = new A()又把A的自身屬性繼承了一次,真是多此一舉,既然我們只是單純的想要原型上的屬性,那直接拷貝不就完事了嗎?

// 建構函式A
function A() {
    this.name = 'A';
};
A.prototype.say = function() {
    return this.name;
};

// 之前定義的屬性拷貝函式
function extend2(Child, Parent) {
    let c = Child.prototype;
    let p = Parent.prototype;
    for (let i in p) {
        c[i] = p[i];
    }
};

// 建構函式D
function D() {
    A.apply(this, arguments);  // 這裡就相當於借用A建構函式把A中屬性建立給了D,即name和say屬性
};
extend2(D, A);  // 這裡就直接把A原型的屬性拷貝給了D原型
D.prototype.name = 'D';  // 繼承後在進行擴充套件

let d1 = new D();
console.log(d1.name);  // 'A'
console.log(d1.__proto__.name)  // undefined 這就說明了name屬性是新建的,而不是繼承得到的
複製程式碼

(⊙o⊙)…,其實還有其它的繼承方法,還是不寫了,怕被打,但其實來來去去就是基於原型、建構函式、物件這幾種方式搞來搞去,我個人就是這麼給它們分類的,畢竟七秒記憶放不下,囧。

最後

寫到這裡,終於嚥下了最後一口氣,呸,鬆了一口氣。也感謝你看到了最後,希望對你有所幫助,有寫得不對的地方還請多多指教,喜歡的就關注一波吧,後續會持續更新。

github原始碼

相關文章