深入理解原型和繼承

  • 2019 年 11 月 7 日
  • 筆記

這幾天在掘金上閱讀到了一篇關於原型的文章,角度較之前看到的幾篇部落格都不一樣,頓時感覺我對於原型的知識點還沒有完全吃透。鑒於本篇文章很可能會進行不定期的修訂和拓展,故在此附上更新日誌,以簡單記錄我在學習上的認知更新。

2019.2.24

  • js繼承的幾種實現方式

2019.2.19

  • 完善關於constructor屬性的介紹
  • 比對new和Object.create()的本質區別
  • 糾正隱式原型的錯誤寫法(之前沒看仔細,一直寫錯,今天報錯才發現)

1.創建對象的方法

在了解原型鏈之前,首先先了解一下創建對象的幾種方式,為後面做個鋪墊。介紹以下三種。

程式碼:

    <script type="text/javascript">      // 第一種方式:字面量      var o1 = {name: 'o1'}      var o2 = new Object({name: 'o2'})      // 第二種方式:構造函數      var M = function (name) { this.name = name; }      var o3 = new M('o3')      // 第三種方式:Object.create      var p = {name: 'p'}      var o4 = Object.create(p)        console.log(o1)      console.log(o2)      console.log(o3)      console.log(o4)        </script>

列印結果:

2.構造函數、實例、原型、原型鏈

先來一張圖簡單了解一下

2.1 原型、實例、構造函數

首先是程式碼

    var M = function (name) { this.name = name; }      var o3 = new M('o3')
  • 實例就是通過new一個構造函數生成的對象。在本例中o3就是實例,M就是構造函數。
  • 每個函數都有prorotype屬性,每個對象都有proto 屬性(隱式原型,讀作dunder prototype)
  • 從上圖中可以知道,實例的protpo指向原型對象。
  • 從上圖中可以知道,實例的構造函數的prototype也是指向原型對象。
  • 原型對象的construor指向構造函數。 再來通過下面這個圖來理解一下

2.2 原型鏈

簡單理解就是原型組成的鏈,實例的proto就是原型,而原型也是一個對象,也有proto屬性,它會指向另一個原型…………就這樣可以一直通過proto向上找,這就是原型鏈,當向上找找到Object這個構造函數的原型(即null)時,這條原型鏈就算到頭了。也就是說,原型鏈的盡頭是null 。

2.3 原型的作用

原型的存在是為了幫助實現繼承。我們先來思考一個問題:假如現在通過一個構造函數創建了多個實例,想要給它們添加同一個方法,該怎麼做呢?

1.給每個實例去添加。太過麻煩,並不是一個明智的選擇; 2.在構造函數的內部添加方法。這樣做的話在每次用構造函數創建實例時都會大量產生方法的副本,這些方法副本功能一樣,實際卻是不同的。這會影響性能,且不利於程式碼復用; 3.這時,就該用上原型了。只要給構造函數的原型添加一個方法,那麼構造函數的所有實例便都有了這個方法。接著上面的例子繼續演示:

    function M(name) {this.name = name;}      var o3 = new M('o3')      var o5 = new M('o5')      M.prototype.say=furnction(){      console.log('hello world')}       o3.say()       o5.say()         console.log(o3.say()==o5.say());   //  true

列印結果

按照JS引擎的分析方式,在訪問一個實例的方法時,首先在實例本身中找,如果找到了就說明其構造函數先前是有定義這個方法的(this);如果沒找到就去實例的原型中找,還沒找到就再沿著原型鏈往上找,直到找到。當然,不止方法,屬性也是可以繼承自原型的。

那麼怎麼判斷屬性是實例本身具有的還是繼承的?對實例用 hasOwnProperty( )方法即可。那麼實例為何有這個方法?同樣是繼承來的。 由於所有的對象的原型鏈都會找到追溯到Object.prototype,因此所有的對象都會有Object.prototype的方法,其中就包括 hasOwnProperty( )方法 。

2.4 訪問原型

可以用obj.prototypeobj.__proto__,或者obj.getPrototypeOf()。這裡重點說後面兩個。 __proto__屬性在 ES6 時才被標準化,以確保 Web 瀏覽器的兼容性,但是不推薦使用,更不推薦通過這種方式修改實例的原型,除了標準化的原因之外還有性能問題。 為了更好的支援,推薦使用 Object.getPrototypeOf()。

2.5 原型、構造函數、實例、Function、Object的關係

前面我們給出了一幅圖簡單梳理了一下關係,但想追本溯源,光靠那張圖是不夠的。下面我們給出另一張更詳細的圖。請先記住,Function和Object 是特殊的構造函數。

首先從構造函數Foo(或任意一個普通構造函數)出發,它創建了實例f1和f2等,而實例的__proto__指向了Foo.prototype這個原型,該原型的__proto__向上再次指向其他構造函數的原型,一直向上,最終指向Object這個構造函數的原型,即Object.prototype。而Object.prototype的 __proto__指向了null,這時我們說到達了原型鏈的終點null。回過頭看,該原型又被Object構造函數的實例的__proto__指向,而函數的實例就是我們通常通過字面量創建的那些對象,也即是圖中的o1,o2。那麼,普通構造函數(這裡指Foo)和特殊構造函數Object又來自於哪裡?答案是,來自於另一個特殊構造函數Function。

實際上,所有的函數都是由Function函數創建的實例,而構造函數當然也是函數,所以也來自於Function。從圖中可以看到,實例Foo的__proto__和實例Object的__proto__ 都指向了 Function的prototype,即Function.prototype 。

既然所有的函數都是由Function函數創建的實例,那麼Function又是怎麼來的?答案是,Function自己創造了自己。它既作為創造其他實例函數的構造函數而存在,也作為實例函數而存在,所以可以在圖上看到作為實例的Function的__proto__ 指向了作為構造函數的Function的prototype,

Function.__proto__ ===Function.prototype

正如我們前面所說的,Function.prototype的__proto__也像其他構造函數.prototype的__proto__一樣,最終指向Object.porototype,而Object.porototype的__proto__最終指向null,原型鏈結束。

可以發現,經過簡單梳理,這幾者的關係沒有我們想像的那麼複雜。一句話,看懂這幅圖就夠了。

3.instanceof的原理

instanceof 沿著 實例—> proto —> …….. 這條線來找,同時沿著 實例的構造函數的prototype—>proto —> …….. 這條線來找,如果兩條線能找到同一個引用,即同一個對象,那麼就返回true。如果找到終點還未重合,則返回false。如下圖,很顯然 f1 instanceof Object 成立

注意:正因為 instanceof 的原理如上所述,因此實例的instanceof在比較的時候,與原型鏈上向上找的的構造函數相比都是true。

繼續上面的程式碼

那怎麼判斷實例是由哪個構造函數生成的呢?這時候就要用到constructor了。 實例的原型的構造函數, obj.proto.constructor

4.constructor屬性

4.1 定義:

構造函數的prototype屬性指向它的原型對象,在原型對象中有一個constructor屬性,指向該構造函數。值類型(除了null和undefined,這兩者不具有這個屬性)的constructor是只讀的,不可修改,引用類型的constructor是可修改的,例如5.2提到的修復指向。

4.2 修復constructor的指向:

為了實現從父類到子類方法的繼承,一般會重寫構造函數的原型,如:

function Person(){      .........  }  function Student(){      .........  }  Student.prototype = new Person()  var student = new Obj()

這將使得實例student具有構造函數Person的方法,但同時也會導致constructor的指向出現問題,造成繼承鏈的紊亂,因此為了修復這個錯誤指向,需要顯式指定obj.prototype.constructor = obj 。拿下面例子說明:

未重寫原型對象之前,實例化了一個dog;第6行重寫了原型對象,使其指向另一個實例(等式右邊是字面量,因此可以看作是由Object構造函數實例化出來的一個對象),之後實例化了一個cat。

查看dog和cat的constructor:

    console.log(dog.constructor);        //function Animal()      console.log(cat.constructor);         //function Object()
    dog.say();   //wan      cat.say();    //miao

首先,構造函數沒有constructor屬性,這導致了它構造的實例也沒有constructor屬性,所以,實例將沿著原型鏈(注意,構造函數不算在原型鏈里)向上追溯對應的原型對象的constructor屬性。dog.constructor可以指向原來的構造函數,說明原來的原型對象還存在;而cat.constructor 指向另一個構造函數,是因為Animal( )的原型被重寫,並且作為Object( )構造函數的一個實例而存在,那麼由cat實例出發,向上進行constructor屬性追溯的時候,最終會找到Object( ) 構造函數。同樣的,正因為原型重寫前後創建的實例分別對應了初始原型和新的原型,所以我們可以對舊實例調用初始原型的方法、對新實例調用新的原型的方法,放在本例子中,就表現為dog依然可以調用say( )方法發出wan,而cat也可以調用say( )方法發出miao 。

總結: 重寫原型對象之後,會切斷構造函數與最初原型之間的連接,使新構造的實例對象的原型指向重寫的原型,而先前構造的實例對象的原型還是指向最初原型。在這種情況下,先前的實例對象可以使用最初原型的方法,新的實例對象可以使用重寫的原型的方法。

5. new 和 Object.create():

這裡,讓我們回到文章開頭提到的創建對象的三種方式。重點介紹後兩種。

5.1 new

new一個構造函數時,實際發生的過程是:

var o={};  o.__proto__=M.prototype  M.call(o)
  • 第一步,創建一個空對象o;
  • 第二步,令空對象的proto指向構造函數M的prototype;
  • 第三步,令構造函數M中的this指針指向o,使得o具有M的屬性或方法,如果M無返回值或返回的不是對象,則最後會返回o 。

在這裡要注意下面這個坑:

var Base = function(){  this.a = 2;  };  console.log(Base.a);    

構造函數就好比印鈔機,而它創建的實例就好比鈔票。構造函數中的this.xxxx都是為了實例而準備的屬性和方法,這些this在構造函數內,但並不指向構造函數,而是在new構造函數執行的時候轉而指向新實例。構造函數自身沒有這些屬性和方法,像上面那樣調用Base的a屬性是會報錯的,Base根本沒有a屬性。

手動實現new(方法一):

下面根據new的工作原理通過程式碼手動實現一下new運算符

    var new2 = function (func) {      //創建一個空對象,並鏈接到原型      var o = Object.create(func.prototype);      //改變func中的this指向,把func的結果賦給k        var k = func.call(o);               //判斷func是否顯式返回對象      return typeof k === 'object' ? k : o;

驗證

不難看出,我們手動編寫的new2和new運算符的作用是一樣的。

手動實現new(方法二):

考慮到構造函數本身需要傳參,這裡提供第二種手寫new的方法

function new3(){      // 獲得構造函數func(arguments的第一個參數)      var func = [].shift.call(arguments);      // 創建一個空對象,並鏈接到原型      var o = Object.create(func.prototype);      // 改變func中的this指向,把func的結果賦給k       var k = func.call(o,arguments);      // 判斷func是否顯式返回對象      return k instanceof Object ? k : o;  };    function M(){....}  // 使用內置new  var m = new M(....)  // 使用手寫new  var m = new3(M,.....)

這裡要注意數組的shift()方法,它可以刪去數組的第一個元素並返回該元素。但是arguments是類數組的對象,無法直接使用這個方法,所以我們使用[].shift.call(arguments),意思是從參數列表(包括構造函數、構造函數的參數)中刪去並返回第一個參數(構造函數),將其賦給func,之後的arguments將只包含構造函數func的參數。

5.2 Object.create()

Object.create()方法創建一個新對象(實例),並使用現有的對象(參數)作為新創建的對象的proto 也就是說,這個方法可以起到指定原型的作用。

執行Object.create() 時,實際發生的過程是:

Object.create =  function (o) {      var F = function () {};      F.prototype = o;      return new F();  };
  • 第一步,創建空的構造函數;
  • 第二步,令構造函數的prototype指向傳入的對象; 實際上也相當於 令新實例的proto指向傳入的對象
  • 第三步,實例化一個對象並返回

這裡,如果Object.create()接受的參數是null,即var obj = Object.create(null),則obj是真正意義上的空對象,不具有hasOwnProperty(),toString()等方法或屬性。

6 繼承的7種方式

6.1.原型鏈繼承

  • 核心:重寫子類原型,代之以父類的實例
function Person(){      this.age=[6,12,24];  }  function Worker(){}  Worker.prototype = new Person();
  • 缺點: 創建子類實例時,無法向父類構造函數傳參; 對一個子類實例的引用類型屬性的操作將會影響其他子類實例,即引用屬性共享
var worker1 = new Worker()  var worker2 = new Worker()  worker1.age.push(48)  alert(worker1.age)   //[6,12,24,48]  alert(worker2.age)    //[6,12,24,48]

6.2.借用構造函數繼承

又稱為冒充繼承、經典繼承、偽造對象繼承

  • 核心:使用父類的構造函數來增強子類實例,等同於複製父類的實例屬性給子類(不使用原型)
function Person(name){      this.age=[6,12,24];      this.name=name;  }  function Worker(name){      Person.call(this,name);  }  var worker1 = new Worker()  var worker2 = new Worker()  worker1.age.push(48)  alert(worker1.age)   //[6,12,24,48]  alert(worker2.age)    //[6,12,24]
  • 缺點:雖然消除了原型鏈繼承的缺點,但是不利於實現函數復用,每個子類都有父類實例函數的副本,影響性能。

6.3.組合繼承

  • 核心:原型鏈繼承+借用構造函數繼承。即使用原型鏈實現對原型屬性和方法的繼承,通過借用構造函數來實現對實例屬性的繼承.
function Person(){      this.age=[6,12,24];  }  Person.prototype.shout=function(){      alert("Ahhhhhh");  }  function Worker(){      Person.call(this);      ...其餘新增屬性。。。  }  Worker.prototype=new Person()  Worker.prototype.constructor=Worker  //別忘記修正constructor的指向  var worker1 = new Worker()
  • 缺點:很常用的繼承方式,但也有缺點,就是程式碼第11、13行合計調用了兩次父類函數,造成了不必要的消耗。

6.4.原型式繼承

用到了object(),規範化之後即為Object.create()

  • 核心:利用Object.create()對傳入其中的對象進行淺拷貝
var Person = {      age: [6,12,24]  }  var worker1 = Object.create(Person)  var worker2 = Object.create(Person)
  • 缺點:和原型鏈繼承一樣,存在引用屬性共享的問題。
worker1.age.push(48)  alert(worker1.age)   //[6,12,24,48]  alert(worker2.age)   //[6,12,24,48]

原因很好解釋,因為worker1無age屬性,因此向它的原型查找,它的原型恰好就是Person對象。因此實際上是在改動Person的age屬性。

6.5.寄生繼承

  • 核心:創建一個函數用於封裝繼承的過程,在函數內部增強對象,最後將其返回
var Person = {      age: [6,12,24]  }  function createAnother(Person){      var worker0 = Object.create(Person);      worker0.shout = function(){          alert("Ahhhhh");      };      return worker0;  }  var worker1 = createAnother(Person)  worker1.shout()
  • 缺點:和原型鏈繼承一樣,存在引用屬性共享的問題;和經典繼承一樣,無法實現函數復用

6.6.寄生組合繼承

  • 核心:結合寄生式繼承和組合繼承的優點,避免為了指定子類的原型而二次調用父類的構造函數
//封裝函數。功能:在避免二次調用父類函數的前提下令子類原型指向父類實例    function inheritPrototype(subType, superType){     var obj = Object.create(superType.prototype);     subType.prototype = obj;     subType.prototype.constructor = subType;  //修正constructor的指向  }    // 父類初始化實例屬性和原型屬性  function Person(){    this.age=[6,12,24]  }  Person.prototype.shout = function(){    alert("Ahhhhhh");  };    // 借用構造函數傳遞增強子類實例屬性(支援傳參和避免篡改)  function Worker(){    Person.call(this);  }    // 調用函數,令子類原型指向父類實例  inheritPrototype(Worker, Person);
  • 優點:基本完美的繼承方式,無任何缺點,也是目前庫實現的方式。

6.7.extends類繼承

// 父類  class Person {      constructor(name,age) {          this.name = name;          this.age = age;      }      shout() {          alert("Ahhhhhh");      }  }  //子類繼承父類  class Worker extends Person{      constructor(name,age,job){          super(name,age);          this.job = job;      }      work() {          alert("I am working");      }  }
  • 解釋:可以看作是ES6新增的語法糖,使得js中繼承的寫法更趨向於傳統的面向對象語言。super是關鍵字,代表父類構造函數,只有在子類的構造函數中調用super()函數,才能讓父類構造出this給子類去豐富。

參考: http://www.cnblogs.com/wangfupeng1988/p/3978131.html https://www.cnblogs.com/chengzp/p/prototype.html https://juejin.im/post/5c6a9c10f265da2db87b98f3 https://www.cnblogs.com/94pm/p/9113434.html https://segmentfault.com/a/1190000016891009