搞懂 JavaScript 繼承原理
摘要:理解 JS 繼承。
- 原文: 搞懂 JavaScript 繼承原理
- 作者: 前端小智
Fundebug經授權轉載,版權歸原作者所有。
在理解繼承之前,需要知道 js 的三個東西:
-
- 什麼是 JS 原型鏈
-
- this 的值到底是什麼
-
- JS 的 new 到底是幹什麼的
1. 什麼是 JS 原型鏈?
我們知道 JS 有物件,比如
var obj = { name: "obj" };
我們通過控制檯把 obj 打印出來:
我們會發現 obj 已經有幾個屬性(方法)了。 那麼問題來了:valueOf / toString / constructor 是怎麼來?我們並沒有給 obj.valueOf 賦值呀。
上面這個圖有點難懂,我手畫一個示意圖:
我們發現控制檯打出來的結果是:
- obj 本身有一個屬性 name (這是我們給它加的)
- obj 還有一個屬性叫做 proto (它是一個物件)
- obj 還有一個屬性,包括 valueOf, toString, constructor 等
- obj. proto 其實也有一個叫做 proto 的屬性(console.log 沒有顯示),值為 null
現在回到我們的問題:obj 為什麼會擁有 valueOf / toString / constructor 這幾個屬性?
答案: 這跟proto 有關 。
當我們「讀取」 obj.toString 時,JS 引擎會做下面的事情:
- 看看 obj 物件本身有沒有 toString 屬性。沒有就走到下一步。
- 看看 obj. proto 物件有沒有 toString 屬性, 發現 obj. proto 有 toString 屬性, 於是找到了,所以 obj.toString 實際就是第 2 步中找到的 obj. proto .toString。
- 如果 obj. proto 沒有,那麼瀏覽器會繼續檢視 obj. proto . proto
- 如果 obj. proto . proto 也沒有,那麼瀏覽器會繼續檢視 obj. proto . proto . proto
- 直到找到 toString 或者 proto 為 null。
上面的過程,就是「讀」屬性的「搜尋過程」。而這個「搜尋過程」,是連著由 proto 組成的鏈子一直走的。 這個鏈子,就叫做「原型鏈」。
共享原型鏈
現在我們還有另一個物件
var obj2 = { name: "obj2" };
如圖:
那麼 obj.toString 和 obj2.toString 其實是同一東西, 也就是 obj2. proto .toString。
說白了,我們改其中的一個 proto .toString ,那麼另外一個其實也會變!
差異化
如果我們想讓 obj.toString 和 obj2.toString 的行為不同怎麼做呢?
直接賦值就好了:
obj.toString = function() { return "新的 toString 方法"; };
小結
- [讀]屬性時會沿著原型鏈搜尋
- [新增]屬性時不會去看原型鏈
2. this 的值到底是什麼
你可能遇到過這樣的 JS 面試題:
var obj = { foo: function() { console.log(this); } }; var bar = obj.foo; obj.foo(); // 打印出的 this 是 obj bar(); // 打印出的 this 是 window
請解釋最後兩行函式的值為什麼不一樣。
函式呼叫
JS(ES5)裡面有三種函式呼叫形式:
func(p1, p2); obj.child.method(p1, p2); func.call(context, p1, p2); // 先不講 apply
一般,初學者都知道前兩種形式,而且認為前兩種形式「優於」第三種形式。
我們方方老師大姥說了,你一定要記住,第三種呼叫形式,才是正常呼叫形式:
func.call(context, p1, p2);
其他兩種都是語法糖,可以等價地變為 call 形式:
func(p1, p2)等價於 func.call(undefined, p1, p2);
obj.child.method(p1, p2) 等價於 obj.child.method.call(obj.child, p1, p2);
至此我們的函式呼叫只有一種形式:
func.call(context, p1, p2);
這樣,this 就好解釋了this 就是上面 context。
this 是你 call 一個函式時傳的 context,由於你從來不用 call 形式的函式呼叫,所以你一直不知道。
先看 func(p1, p2) 中的 this 如何確定:
當你寫下面程式碼時; function func() { console.log(this); } func(); 等價於; function func() { console.log(this); } func.call(undefined); // 可以簡寫為 func.call()
按理說打印出來的 this 應該就是 undefined 了吧,但是瀏覽器裡有一條規則:
如果你傳的 context 就 null 或者 undefined,那麼 window 物件就是預設的 context(嚴格模式下預設 context 是 undefined)
因此上面的列印結果是 window。如果你希望這裡的 this 不是 window,很簡單:
func.call(obj); // 那麼裡面的 this 就是 obj 物件了
回到題目:
var obj = { foo: function() { console.log(this); } }; var bar = obj.foo; obj.foo(); // 轉換為 obj.foo.call(obj),this 就是 obj bar(); // 轉換為 bar.call() // 由於沒有傳 context // 所以 this 就是 undefined // 最後瀏覽器給你一個預設的 this —— window 物件
[ ] 語法
function fn() { console.log(this); } var arr = [fn, fn2]; arr[0](); // 這裡面的 this 又是什麼呢?
我們可以把 arr 0 想象為 arr.0( ),雖然後者的語法錯了,但是形式與轉換程式碼裡的 obj.child.method(p1, p2) 對應上了,於是就可以愉快的轉換了:
arr[0]();
假想為 arr.0()
然後轉換為 arr.0.call(arr)
那麼裡面的 this 就是 arr 了 :)
小結:
- this 就是你 call 一個函式時,傳入的第一個引數。
- 如果你的函式呼叫不是 call 形式, 請將其轉換為 call 形式
碼部署後可能存在的 BUG 沒法實時知道,事後為了解決這些 BUG,花了大量的時間進行 log 除錯,這邊順便給大家推薦一個好用的 BUG 監控工具Fundebug。
3. JS 的 new 到底是幹什麼的?
我們宣告一個士兵,具有如下屬性:
var 士兵 = { ID: 1, // 用於區分每個士兵 兵種: "美國大兵", 攻擊力: 5, 生命值: 42, 行走: function() { /*走倆步的程式碼*/ }, 奔跑: function() { /*狂奔的程式碼*/ }, 死亡: function() { /*Go die*/ }, 攻擊: function() { /*糊他熊臉*/ }, 防禦: function() { /*護臉*/ } };
我們製造一個士兵, 只需要這樣:
兵營.製造(士兵);
如果需要製造 100 個士兵怎麼辦呢?
迴圈 100 次吧: var 士兵們 = [] var 士兵 for(var i=0; i<100; i++){ 士兵 = { ID: i, // ID 不能重複 兵種:"美國大兵", 攻擊力:5, 生命值:42, 行走:function(){ /*走倆步的程式碼*/}, 奔跑:function(){ /*狂奔的程式碼*/}, 死亡:function(){ /*Go die*/}, 攻擊:function(){ /*糊他熊臉*/}, 防禦:function(){ /*護臉*/} } 士兵們.push(士兵) } 兵營.批量製造(士兵們)
哎呀,看起來好簡單
質疑
上面的程式碼存在一個問題:浪費了很多記憶體
- 行走、奔跑、死亡、攻擊、防禦這五個動作對於每個士兵其實是一樣的,只需要各自引用同一個函式就可以了,沒必要重複建立 100 個行走、100 個奔跑……
- 這些士兵的兵種和攻擊力都是一樣的,沒必要建立 100 次。
- 只有 ID 和生命值需要建立 100 次,因為每個士兵有自己的 ID 和生命值。
改進
通過第一節可以知道 ,我們可以通過原型鏈來解決重複建立的問題:我們先建立一個「士兵原型」,然後讓「士兵」的 proto 指向「士兵原型」。
var 士兵原型 = { 兵種:"美國大兵", 攻擊力:5, 行走:function(){ /*走倆步的程式碼*/}, 奔跑:function(){ /*狂奔的程式碼*/}, 死亡:function(){ /*Go die*/}, 攻擊:function(){ /*糊他熊臉*/}, 防禦:function(){ /*護臉*/} } var 士兵們 = [] var 士兵 for(var i=0; i<100; i++){ 士兵 = { ID: i, // ID 不能重複 生命值:42 } /*實際工作中不要這樣寫,因為 __proto__ 不是標準屬性*/ 士兵.__proto__ = 士兵原型 士兵們.push(士兵) } 兵營.批量製造(士兵們)
優雅?
有人指出建立一個士兵的程式碼分散在兩個地方很不優雅,於是我們用一個函式把這兩部分聯絡起來:
function 士兵(ID){ var 臨時物件 = {}; 臨時物件.__proto__ = 士兵.原型; 臨時物件.ID = ID; 臨時物件.生命值 = 42; return 臨時物件; } 士兵.原型 = { 兵種:"美國大兵", 攻擊力:5, 行走:function(){ /*走倆步的程式碼*/}, 奔跑:function(){ /*狂奔的程式碼*/}, 死亡:function(){ /*Go die*/}, 攻擊:function(){ /*糊他熊臉*/}, 防禦:function(){ /*護臉*/} } // 儲存為檔案:士兵.js 然後就可以愉快地引用「士兵」來建立士兵了: var 士兵們 = [] for(var i=0; i<100; i++){ 士兵們.push(士兵(i)) } 兵營.批量製造(士兵們)
JS 之父看到大家都這麼搞,覺得何必呢,我給你們個糖吃,於是 JS 之父建立了 new 關鍵字,可以讓我們少寫幾行程式碼:
只要你在士兵前面使用 new 關鍵字,那麼可以少做四件事情:
- 不用建立臨時物件,因為 new 會幫你做(你使用「this」就可以訪問到臨時物件);
- 不用繫結原型,因為 new 會幫你做(new 為了知道原型在哪,所以指定原型的名字 prototype);
- 不用 return 臨時物件,因為 new 會幫你做;
- 不要給原型想名字了,因為 new 指定名字為 prototype。
這一次用 new 來寫
function 士兵(ID){ this.ID = ID this.生命值 = 42 } 士兵.prototype = { 兵種:"美國大兵", 攻擊力:5, 行走:function(){ /*走倆步的程式碼*/}, 奔跑:function(){ /*狂奔的程式碼*/}, 死亡:function(){ /*Go die*/}, 攻擊:function(){ /*糊他熊臉*/}, 防禦:function(){ /*護臉*/} } // 儲存為檔案:士兵.js 然後是建立士兵(加了一個 new 關鍵字): var 士兵們 = [] for(var i=0; i<100; i++){ 士兵們.push(new 士兵(i)) } 兵營.批量製造(士兵們)
new 的作用,就是省那麼幾行程式碼。(也就是所謂的語法糖)
注意 constructor 屬性
new 操作為了記錄「臨時物件是由哪個函式建立的」,所以預先給「士兵.prototype」加了一個 constructor 屬性:
士兵.prototype = { constructor: 士兵 };
如果你重新對「士兵.prototype」賦值,那麼這個 constructor 屬性就沒了,所以你應該這麼寫:
士兵.prototype.兵種 = "美國大兵"; 士兵.prototype.攻擊力 = 5; 士兵.prototype.行走 = function() { /*走倆步的程式碼*/ }; 士兵.prototype.奔跑 = function() { /*狂奔的程式碼*/ }; 士兵.prototype.死亡 = function() { /*Go die*/ }; 士兵.prototype.攻擊 = function() { /*糊他熊臉*/ }; 士兵.prototype.防禦 = function() { /*護臉*/ };
或者你也可以自己給 constructor 重新賦值:
士兵.prototype = { constructor: 士兵, 兵種: "美國大兵", 攻擊力: 5, 行走: function() { /*走倆步的程式碼*/ }, 奔跑: function() { /*狂奔的程式碼*/ }, 死亡: function() { /*Go die*/ }, 攻擊: function() { /*糊他熊臉*/ }, 防禦: function() { /*護臉*/ } };
四、繼承
繼承的本質就是上面的講的原型鏈
1)藉助建構函式實現繼承
function Parent1() { this.name = "parent1"; } Parent1.prototype.say = function() {}; function Child1() { Parent1.call(this); this.type = "child"; } console.log(new Child1());
列印結果:
這個主要是借用 call 來改變 this 的指向,通過 call 呼叫 Parent ,此時 Parent 中的 this 是指 Child1。有個缺點,從列印結果看出 Child1 並沒有 say 方法,所以這種只能繼承父類的例項屬性和方法,不能繼承原型屬性/方法。
2)藉助原型鏈實現繼承
/** * 藉助原型鏈實現繼承 */ function Parent2() { this.name = "parent2"; this.play = [1, 2, 3]; } function Child2() { this.type = "child2"; } Child2.prototype = new Parent2(); console.log(new Child2()); var s1 = new Child2(); var s2 = new Child2();
列印:
通過一講的,我們知道要共享莫些屬性,需要 物件. proto = 父親物件的.prototype,但實際上我們是不能直接 操作 proto ,這時我們可以借用 new 來做,所以
Child2.prototype = new Parent2(); <=> Child2.prototype. proto = Parent2.prototype; 這樣我們藉助 new 這個語法糖,就可以實現原型鏈繼承。但這裡有個總是,如列印結果,我們給 s1.play 新增一個值 ,s2 也跟著改了。所以這個是原型鏈繼承的缺點,原因是 s1. pro 和 s2. pro 指向同一個地址即 父類的 prototype。
3)組合方式實現繼承
/** * 組合方式 */ function Parent3() { this.name = "parent3"; this.play = [1, 2, 3]; } Parent3.prototype.say = function() {}; function Child3() { Parent3.call(this); this.type = "child3"; } Child3.prototype = new Parent3(); var s3 = new Child3(); var s4 = new Child3(); s3.play.push(4); console.log(new Child3()); console.log(s3.play, s4.play);
列印:
將 1 和 2 兩種方式組合起來,就可以解決 1 和 2 存在問題,這種方式為組合繼承。這種方式有點缺點就是我例項一個物件的時, 父類 new 了兩次,一次是 var s3 = new Child3()對應 Child3.prototype = new Parent3()還要 new 一次。
4)組合繼承的優化 1
function Parent4() { this.name = "parent4"; this.play = [1, 2, 3]; } Parent4.prototype.say = function() {}; function Child4() { Parent4.call(this); this.type = "child4"; } Child4.prototype = Parent4.prototype; var s5 = new Child4(); var s6 = new Child4();
這邊主要為 Child4.prototype = Parent4.prototype, 因為我們通過建構函式就可以拿到所有屬性和例項的方法,那麼現在我想繼承父類的原型物件,所以你直接賦值給我就行,不用在去 new 一次父類。其實這種方法還是有問題的,如果我在控制檯列印以下兩句:
從列印可以看出,此時我是沒有辦法區分一個物件 是直接 由它的子類例項化還是父類呢?我們還有一個方法判斷來判斷物件是否是類的例項,那就是用 constructor,我在控制檯列印以下內容:
咦,你會發現它指向的是父類 ,這顯然不是我們想要的結果, 上面講過我們 prototype 裡面有一個 constructor, 而我們此時子類的 prototype 指向是 父類的 prototye ,而父類 prototype 裡面的 contructor 當然是父類自己的,這個就是產生該問題的原因。
組合繼承的優化 2
/** * 組合繼承的優化2 */ function Parent5() { this.name = "parent4"; this.play = [1, 2, 3]; } Parent5.prototype.say = function() {}; function Child5() { Parent5.call(this); this.type = "child4"; } Child5.prototype = Object.create(Parent5.prototype);
這裡主要使用 Object.create() ,它的作用是將物件繼承到 proto 屬性上。舉個例子:
var test = Object.create({ x: 123, y: 345 }); console.log(test); //{} console.log(test.x); //123 console.log(test.__proto__.x); //3 console.log(test.__proto__.x === test.x); //true
那大家可能說這樣解決了嗎,其實沒有解決,因為這時 Child5.prototype 還是沒有自己的 constructor,它要找的話還是向自己的原型物件上找最後還是找到 Parent5.prototype, constructor 還是 Parent5 ,所以要給 Child5.prototype 寫自己的 constructor:
Child5.prototype = Object.create(Parent5.prototype); Child5.prototype.constructor = Child5;
參考
關於Fundebug
Fundebug專注於JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了9億+錯誤事件,付費客戶有Google、360、金山軟體、百姓網等眾多品牌企業。歡迎大家免費試用!