原生JS專欄 - 原型/原型鏈, 五種繼承模式

原生js專欄 - 原型/原型鏈, 繼承模式

目錄:

  • 原型

  • 原型鏈

  • 繼承

    • 原型鏈繼承

    • 借用構造函數

    • 共享原型

    • 聖盃模式

    • ES6 class

原型

原型這個東西呢, 我們可以把他理解爲祖先, 拿我們人類來說, 我們的祖先生來就是有鼻子有眼, 有細胞, 所以我們繼承了祖先的一些特質, 祖先也就是我們的原型, 而在程序中, 我們可以在創建對象的時候給對象設置原型從而讓對象具有一些先天的特質, 這就是js中的原型, 我們來看看原型的基本概念

定義: 原型是Function對象的一個方法, 它定義了構造函數構造出來的對象的公共祖先, 通過構造函數構造出來的對象會繼承該公共祖先(原型)的屬性和方法, 原型也是一個對象, 在函數一聲明的時候, 它身上就有一個屬性叫做prototype, prototype就是原型

我們來屢屢這關係, 構造函數的原型就是構造函數構造出來的對象的爹(爹和祖先你隨便想, 差不太離), 看個實例

// 構造函數Person, 它被我們創建的時候系統就給它加上了一個屬性叫做prototype
// 這個prototype 就是原型, 然後只要是這個構造函數構造出來的對象, 他們的爹就是
// 這個prototype, 所以這個prototype上有些什麼, Person構造出來的對象就有什麼
// 如果我們不設置prototype, 則默認爲一個{}
Person.prototype = {
    // 在原型中定義了一個lastName值爲curry,這個時候所有由Person構造函數構造出來
    // 的對象身上都有lastName屬性, 值爲curry, 就好像你一出生你的姓就已經註定好了跟着你爹姓的感覺
    lastName: 'Curry', 
}

Person.prototype.sayHi = function() {
    console.log('hello, 你好')
}
function Person() {};
    
var fstPerson = new Person();
var secPerson = new Person();
console.log(fstPerson.lastName); // 毋庸置疑, 輸出curry
console.log(secPerson.lastName); // 輸出curry, 既然都是爹了那兩個兒子勢必都繼承這個lastName
fstPerson.sayHi(); // 輸出hello, 你好, 因爲方法和屬性都可以繼承

小提示

如果構造函數自身有屬性和原型上的屬性重合了, 那麼跟作用域鏈的關係一致, 可近的來


// 爹流傳下來的姓是Curry
Person.prototype.lastName = 'Curry';

function Person() {
    // Person不高興了, 直接自立門戶, 就不跟着爹姓
    this.lastName = 'Kate';
}

var person = new Person();
console.log(person.name); // Kate, 可近的來 

小提示

我們可以通過原型來提取公有祖先

比如我現在有一個車間, 專門用來生產車子, 車子的主人和顏色都是可以選配的,但是高度長度品牌是固定的


function Car(owner, color) {
    this.owner = owner;
    this.color = color;
    this.lang = 4900;
    this.height = 1400;
    this.brand = 'BMW';
}

我們知道構造函數new的原理是, 在內部進行隱式三步

var this = Object.create(該構造函數的原型);
然後執行你寫的代碼this.xxx = xxx;
return this; //最後把this返回出去 

既然隱式三步是這樣, 那代表即使我在Car車間中的lang, height, brand不會變化, 每new一次他們也會跟着this.xxx = xxx一次, 這樣好像不太好, 而且每一個new出來的對象上的這些屬性值都一模一樣, 是公共屬性,這種公共的玩意每次都創建一邊感覺有點怪怪的, 所以我們用原型來抽取這些公共屬性


Car.prototype = {
    lang: 4900,
    height: 1400,
    brand: 'BMW'
}
function Car(owner, color) {
    this.owner = owner;
    this.color = color; 
}

var fstCar = new Car('loki', 'black');
var secCar = new Car('thor', 'blue');
console.log(fstCar.lang); // 4900
console.log(secCar.brand); // BMW

重點

上面已經展示了很多對於原型屬性的查, 我們來看看原型的增刪改

  • 原型屬性的增刪改

    構造函數實例不允許增刪改原型上的任何屬性和方法, 如果想要增刪改原型, 必須使用構造函數進行修改

    Person.prototype = {
        lastName: 'Curry'
    }
    function Person() {}
    
    var person = new Person();
    console.log(person.lastName); // 勢必輸出Curry吧 
    
    Person.prototype.lastName = 'Kate'; // 進行修改 
    var secPerson = new Person();
    console.log(secPerson.lastName); // 變成Kate
    console.log(secPerson); // {}
    
    // 如果我們強行在實例身上修改會出現什麼後果呢
    
    Person.prototype = {
        lastName: 'Curry'
    }
    function Person() {}
    
    var person = new Person();
    console.log(person.lastName); // 勢必輸出Curry吧 
    
    var secPerson = new Person();
    secPerson.lastName = 'Kate'
    console.log(secPerson.lastName); // Kate
    console.log(secPerson); // {lastName: 'Kate'}
    
    // 我們發現如果你強行更改實例的lastName, 則會在實例上添加一個lastName屬性, 
    // 原型不會改變, 從此訪問也是可近的來
    

    增刪和改的操作是一致…

    小提示

    我們是不能在實例中修改構造函數原型的值沒錯, 但是指的是構造函數中原型屬性的地址不能改, 如果原型中某個屬性值是個對象, 那麼我們就可以操作了, 引用值只要地址不更改, 其中的數據怎麼變化都無所謂

    Person.prototype = {
        address: {
            province: '湖南'
        }
    }
    function Person() {}
    
    var person = new Person();
    
    console.log(person.address.province); // 湖南
    person.address.province = '廣東';
    console.log(person.address.province); // 廣東
    console.log(person); // {}
    

重點

其實吧, 這個prototype確實是函數一被創建就有的, 他的初始值也是類似於一個空對象, 但是卻不是空對象, 在他身上已經被初始化了兩個屬性, 分別是constructor和__proto__,來看個實例

function Person() {}; 
console.log(Person.prototype); //輸出 {constructor: constructor: ƒ foo(), __proto__: Object}

輸出結果如下圖

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-QV0RsNFy-1583719207903)('..')]

也就是說prototype這哥們一出生就自帶了constructor和__proto__屬性, 我們仔細看會發現這兩屬性的顏色是淺紫色, 淺紫色的屬性代表系統內置屬性, 那麼這兩個內置屬性是來幹嘛的呢

  • constructor

    構造器, 訪問該屬性會返回構造該對象的構造函數, 如下

    function Person() {};
    
    var person = new Person();
    
    console.log(person.constructor); // ƒ Person() {}
    // person的構造函數就是Person, 所以他返回Person
    
    

    這個屬性的功能主要也就是我們在開發中, 構造函數寫的多了, new出來的實例也多了, 哪一天都迷失自己了, 不知道哪個實例是誰生的了, 我們就訪問這個constructor屬性來查看該實例的構造函數

    小提示

    prototype上的constructor屬性是可以被修改的, 所以我們沒事不要瞎整瞎改, 一改就會讓構造函數構造出來的實例連自個是誰造出來的都搞錯了, 簡稱六親不認, 所以咱儘量不要修改constructor的值

    function Person() {};
    
    Person.prototype.constructor = 'hello? '
    
    var person = new Person();
    console.log(person.constructor); // 輸出hello?
        
    // constructor本質上就是對象上的一個屬性, 所以屬性值可以爲任何值
    
  • __proto__

__proto__這個熟悉只有一個作用: 存儲原型

我們先來說說實例上的__proto__屬性

當我們用構造函數new出一個實例的時候, 內部會進行隱式三段, 而這個隱式三段的第一步就會設定原型

var this = Object.create(構造函數的原型)

create完畢以後這個構造函數的原型總要有地方放吧, 所以乾脆把構造函數實例的__proto__屬性存入了構造函數的原型


function Person() {};

var person = new Person();

console.log(person.__proto__ === Person.prototype); // 輸出true

重點

這個__proto__有什麼用, 當在一個對象身上查找他沒有的屬性的時候, 他會通過__proto__屬性往他的原型上找, 如果他的原型也沒有的話, 會繼續通過他原型(他原型也是對象, 所以也有__proto__屬性)的__proto__屬性繼續往上找, 知道找到最頂頭都沒有就返回undefined, 由這些__proto__組合在一起的一層套一層的關係我們稱作原型鏈, 而查找的方式叫做訪問原型鏈, 關於原型鏈這裏先提一嘴, 後面詳細說

var obj = {}; // 這個obj上啥都沒有

console.log(obj.toString); // 我查找obj上的這個屬性 輸出ƒ toString() { [native code] }

小提示

同樣 這個__proto__屬性也是可以進行手動更改的, 手動更改會造成原型鏈混亂, 不建議更改

Person.prototype = {
    name: 'loki'
}

function Person() {};

var person = new Person();

console.log(person.name); // loki

person.__proto__ = {name: 'thor'};

console.log(person.name); // thor

原型鏈

OK, 到了原型鏈, 我們先來看個栗子


Person.prototype = {
    lastName: 'thor'
}
function Person() {}; 

var person = new Person();

console.log(person.name); // 輸出thor是跑不掉的吧
console.log(person.constructor); // 輸出Person構造函數也是跑不掉的吧
console.log(person.toString); // ??? 輸出了toString方法

上方例子, 輸出person.name是因爲person.__proto__指向了Person.prototype, 輸出constructor是因爲Person.prototype中本來就存在了constructor指向構造函數Person本身, 輸出toString也能輸出是爲何呢?

function Person() {};

var person = new Person();

console.log(person); // {__proto__}
console.log(Person.prototype); // {constructor: f Person() {}, __proto__};

我們嘗試着輸出person實例和Person.prototype, 發現Person.prototype上也有個__proto__, 我們說過__proto__的作用只有一條, 就是存儲prototype原型, 那麼也就是說, Person.prototype也有原型嘍, 還真是, 我們將Person.prototype.__proto__打開發現裏面存在toString方法

重點

上面這種通過__proto__將不同的對象聯繫在一起形成的隱形鏈條叫做原型鏈

// 以上方的Person構造函數的簡單鏈條表示關係
person.__proto__  -> Person.prototype, Person.prototype.__proto__ -> Object.prototype

我們可以自己也來寫一條原型鏈


// 爺爺
Grand.prototype = {
    lastName: 'Curry'
}

console.log(Grand.prototype.__proto__ === Object.prototype); // true

function Grand() {}

// 我們要讓Father連接Grand
Father.prototype = new Grand();
function Father() {}

// 讓Son連接Father
Son.prototype = new Father();
function Son() {}

var son = new Son();
console.log(son.lastName); // 輸出Curry

小提示

無特殊情況, Object.prototype是所有對象的最終原型, 唯一的特殊情況如下, 特殊情況就是Object.create,該方法是用來創建對象的, 該方法可以接收一個參數, 用來指定被創建對象的原型, 原型可以是一個對象或者是null

var obj = Object.create(null);
console.log(obj.toString); // 報錯 obj.toString is not a function

繼承(聖盃模式)

在js的歷史長河中, 由於他不像其他靜態語言一樣與生俱來就有class和extends, 所以導致js的繼承走的挺坎坷的, 從過去到現在也出現過了好幾種繼承模式, 也是一種漫長的演變和完善的過程

  • 原型鏈繼承

  • 借用構造函數進行繼承

  • 共享原型

  • 聖盃模式

  • Es6 class

我有一個需求, 子類需要繼承父類的原型屬性, 我分別用幾種不同的方法來展現結果進行對比(借用構造函數實現繼承呢, 其實不算真正意義的繼承, 算是借屍還魂, 所以在這個方法上我會舉另外一個例子)

  • 傳統形式 => 原型鏈繼承

        Father.prototype = {
            lastName: 'Curry'
        }
        function Father() {
            this.age = 40;
        }
    
        var father = new Father();
    
        Son.prototype = father;
        function Son() {}
    
        var son = new Son();
    
        console.log(son.lastName); // 輸出Curry毋庸置疑
        console.log(son.age); // 輸出40
    

    我們其實可以看出, 這個age屬性我們是不想繼承的, 但是沒辦法, 跑不掉的, 只要使用原型鏈繼承的方式就會出現這種問題, 它會繼承過多的沒用的屬性

  • 借用構造函數進行繼承

    需求是, 我有一個車間工廠, 專門用來生產車, 然後有另一個保時捷車間工廠生產保時捷, 然後我用某種方式來讓保時捷工廠可以借用車間工廠的功能, 如下

    function Car(lang, height) {
        this.lang = lang;
        this.height = height;
    }
    
    
    function Porsche(owner, color, lang, height) {
        // 我保時捷車間沒有控制寬高的方法, 怎麼辦呢
        // 我找別人借用一下這個方法實現我的功能, 
        Car.call(this, lang, height);
        this.owner = owner;
        this.color = color;
    }
    
    var porsche = new Porsche('loki', 'black', 4900, 1400);
    console.log(porsche);// 輸出 {lang: 4900, height: 1400, owner: 'loki', color: 'black'}
    

    上面這種使用call和apply實現借用他人構造函數實現我的功能的方法叫做借用構造函數繼承, 這種繼承的方式最大的短板就是每次都要多走一個函數, 對性能消耗比較大, 所以幾乎沒什麼人會使用, 瞭解就好

  • 共享原型

    後來人們想到, 既然我通過原型鏈繼承會繼承過多的父類的屬性, 那麼我的prototype就不指向父類的實例了, 我直接指向父類的prototype, 子類和父類共享一個原型地址, 不就不會繼承過多屬性了嗎

    Father.prototype = {
        lastName: 'Curry'
    }
    function Father() {
        this.age = 40;
    } 
    
    Son.prototype = Father.prototype; // 我們將子類的原型地址直接設置成父類的原型地址
    function Son() {}
    
    var son = new Son();
    console.log(son.lastName); // 輸出Curry
    console.log(son.age); // 輸出undefined
    
    // 我們會發現一些奇怪的事情, 由於父類和子類指向同一個原型地址, 所以導致
    // 子類和父類中任意一個人對該原型進行修改, 另一個都會被影響
    
    Son.prototype.habits = ['smoke', 'soccer']; // 兒子有些愛好抽菸和踢球
    var father = new Father();
    console.log(father.habits); // ['smoke', 'soccer']
    
    

    共享原型雖然不會繼承父類過多的其他屬性, 但是也造成了新的問題, 那就是因爲父類和子類的原型指向同一個地址, 所以父類和子類任意一個對構造函數進行更改, 另一個都會被影響

  • 聖盃模式

    後來, 我們的開拓者前輩們研究出來了最終的繼承方式 - 聖盃模式, 他不會有上面任意一種方式的弊端, 也同時在工作中被大規模的應用, 直接看個實例

    // 提取出來的公共聖盃模式方法, 可以實現target對origin的繼承
    var grailInherit = (function() {
        var F = function() {}
        /*target: 需要繼承的目標, origin: 被繼承的目標*/
        return function(target, origin) {
            F.prototype = origin.prototype
            target.prototype = new F();
            target.constructor = target;
            target.superFather = origin;
        }
    } ())
    
    Father.prototype = {
        lastName: 'loki'
    }
    function Father() {
        this.age = 40;
    }
    
    function Son() {}
    
    grailInherit(Son, Father);
    
    Son.prototype.habits = ['smoke', 'soccer'];
    
    var father = new Father();
    var son = new Son();
    
    console.log(father.habits); // undefined
    console.log(son.lastName); // loki
    console.log(son.habits); // ['smoke', 'soccer']
    console.log(son.age); // undefined
    

    重點

    我們利用了另外一個構造函數F, 我們讓F的prototype 直接共享Father的prototype, 這樣導致new 出來的F實例 不會繼承過多的Father的屬性, 然後我們將Son.prototype指向該F實例, 所以F實例很乾淨沒有任何多餘的自己的屬性, 所以導致Son也很乾淨沒有繼承到F的過多屬性, 同時因爲F實例的__proto__指向了Father的prototype, Son的實例__proto__指向了F實例, 所以Son實例是可以讀取到Father身上的lastName的, 同時Son的原型進行任何修改跟Father都無關

  • ES6的繼承

    在ES6中, 官方推出了class類和extends繼承關鍵字, 用來幫助我們更好的實現繼承, 這裏不做太多的解釋, 後面在ES6的文章中會具體寫到, 可以先混個臉熟

    class Father {
        constructor() {
            this.age = 40; // 所有值錢寫在構造函數內部的this.xxx = xxx的代碼寫入constructor中
        }
        // 所有原型屬性和方法直接寫在class大括號中
        lastName = 'Curry'
    
        // 所有靜態屬性和方法需要用static關鍵字聲明, 也是直接寫在class大括號中
        static isStatis = true
    }
    
    
    class Son extends Father {
        // 只要是繼承的操作, constructor中必須執行super, 用來執行父級的構造函數, 必須填寫,super是一個關鍵字, 不同的地方作用不同 
        constructor() {
            super();
        }
    }
    
    const son = new Son();
    
    console.log(son.lastName); // Curry
    console.log(son.age); // undefined
    

    class其實跟Java, C的class也不太一樣, js的class類本質上就是構造函數的語法糖而已, 至於原理部分, ES6專欄見

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章