深入理解原型和繼承
- 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.prototype
,obj.__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