摘要: 理解JS繼承讨盒。
- 原文:搞懂 JavaScript 繼承原理
- 作者:前端小智
Fundebug經(jīng)授權(quán)轉(zhuǎn)載围辙,版權(quán)歸原作者所有峦剔。
在理解繼承之前翅阵,需要知道 js 的三個東西:
- 什么是 JS 原型鏈
- this 的值到底是什么
- JS 的 new 到底是干什么的
1. 什么是 JS 原型鏈赎败?
我們知道 JS 有對象秕衙,比如
var obj = { name: "obj" };
我們通過控制臺把 obj 打印出來:
我們會發(fā)現(xiàn) obj 已經(jīng)有幾個屬性(方法)了。那么問題來了:valueOf / toString / constructor 是怎么來僵刮?我們并沒有給 obj.valueOf 賦值呀据忘。
上面這個圖有點難懂,我手畫一個示意圖:
我們發(fā)現(xiàn)控制臺打出來的結(jié)果是:
- obj 本身有一個屬性 name (這是我們給它加的)
- obj 還有一個屬性叫做 proto(它是一個對象)
- obj 還有一個屬性妓笙,包括 valueOf, toString, constructor 等
- obj.proto其實也有一個叫做proto的屬性(console.log 沒有顯示)若河,值為 null
現(xiàn)在回到我們的問題:obj 為什么會擁有 valueOf / toString / constructor 這幾個屬性?
答案: 這跟 proto有關(guān) 寞宫。
當我們「讀取」 obj.toString 時萧福,JS 引擎會做下面的事情:
- 看看 obj 對象本身有沒有 toString 屬性。沒有就走到下一步辈赋。
- 看看 obj.proto 對象有沒有 toString 屬性鲫忍, 發(fā)現(xiàn) obj.proto 有 toString 屬性, 于是找到了钥屈,所以 obj.toString 實際就是第 2 步中找到的 obj.proto.toString悟民。
- 如果 obj.proto沒有,那么瀏覽器會繼續(xù)查看 obj.proto.proto
- 如果 obj.proto.proto也沒有篷就,那么瀏覽器會繼續(xù)查看 obj.proto.proto.proto
- 直到找到 toString 或者 proto 為 null射亏。
上面的過程,就是「讀」屬性的「搜索過程」竭业。而這個「搜索過程」智润,是連著由 proto 組成的鏈子一直走的。這個鏈子未辆,就叫做「原型鏈」窟绷。
共享原型鏈
現(xiàn)在我們還有另一個對象
var obj2 = { name: "obj2" };
如圖:
那么 obj.toString 和 obj2.toString 其實是同一東西, 也就是 obj2.proto.toString咐柜。
說白了兼蜈,我們改其中的一個 proto.toString ,那么另外一個其實也會變!
差異化
如果我們想讓 obj.toString 和 obj2.toString 的行為不同怎么做呢拙友?
直接賦值就好了:
obj.toString = function() {
return "新的 toString 方法";
};
小結(jié)
- [讀]屬性時會沿著原型鏈搜索
- [新增]屬性時不會去看原型鏈
2. this 的值到底是什么
你可能遇到過這樣的 JS 面試題:
var obj = {
foo: function() {
console.log(this);
}
};
var bar = obj.foo;
obj.foo(); // 打印出的 this 是 obj
bar(); // 打印出的 this 是 window
請解釋最后兩行函數(shù)的值為什么不一樣为狸。
函數(shù)調(diào)用
JS(ES5)里面有三種函數(shù)調(diào)用形式:
func(p1, p2);
obj.child.method(p1, p2);
func.call(context, p1, p2); // 先不講 apply
一般,初學(xué)者都知道前兩種形式遗契,而且認為前兩種形式「優(yōu)于」第三種形式钥平。
我們方方老師大姥說了,你一定要記住,第三種調(diào)用形式涉瘾,才是正常調(diào)用形式:
func.call(context, p1, p2);
其他兩種都是語法糖,可以等價地變?yōu)?call 形式:
func(p1, p2)等價于 func.call(undefined, p1, p2);
obj.child.method(p1, p2) 等價于 obj.child.method.call(obj.child, p1, p2);
至此我們的函數(shù)調(diào)用只有一種形式:
func.call(context, p1, p2);
這樣捷兰,this 就好解釋了 this 就是上面 context立叛。
this 是你 call 一個函數(shù)時傳的 context,由于你從來不用 call 形式的函數(shù)調(diào)用贡茅,所以你一直不知道秘蛇。
先看 func(p1, p2) 中的 this 如何確定:
當你寫下面代碼時;
function func() {
console.log(this);
}
func();
等價于;
function func() {
console.log(this);
}
func.call(undefined); // 可以簡寫為 func.call()
按理說打印出來的 this 應(yīng)該就是 undefined 了吧,但是瀏覽器里有一條規(guī)則:
如果你傳的 context 就 null 或者 undefined顶考,那么 window 對象就是默認的 context(嚴格模式下默認 context 是 undefined)
因此上面的打印結(jié)果是 window赁还。如果你希望這里的 this 不是 window,很簡單:
func.call(obj); // 那么里面的 this 就是 obj 對象了
回到題目:
var obj = {
foo: function() {
console.log(this);
}
};
var bar = obj.foo;
obj.foo(); // 轉(zhuǎn)換為 obj.foo.call(obj)驹沿,this 就是 obj
bar();
// 轉(zhuǎn)換為 bar.call()
// 由于沒有傳 context
// 所以 this 就是 undefined
// 最后瀏覽器給你一個默認的 this —— window 對象
[ ] 語法
function fn() {
console.log(this);
}
var arr = [fn, fn2];
arr[0](); // 這里面的 this 又是什么呢艘策?
我們可以把 arr0 想象為 arr.0( ),雖然后者的語法錯了渊季,但是形式與轉(zhuǎn)換代碼里的 obj.child.method(p1, p2) 對應(yīng)上了朋蔫,于是就可以愉快的轉(zhuǎn)換了:
arr[0]();
假想為 arr.0()
然后轉(zhuǎn)換為 arr.0.call(arr)
那么里面的 this 就是 arr 了 :)
小結(jié):
- this 就是你 call 一個函數(shù)時,傳入的第一個參數(shù)却汉。
- 如果你的函數(shù)調(diào)用不是 call 形式驯妄, 請將其轉(zhuǎn)換為 call 形式
碼部署后可能存在的BUG沒法實時知道,事后為了解決這些BUG合砂,花了大量的時間進行l(wèi)og 調(diào)試青扔,這邊順便給大家推薦一個好用的BUG監(jiān)控工具 Fundebug。
3. JS 的 new 到底是干什么的翩伪?
我們聲明一個士兵微猖,具有如下屬性:
var 士兵 = {
ID: 1, // 用于區(qū)分每個士兵
兵種: "美國大兵",
攻擊力: 5,
生命值: 42,
行走: function() {
/*走倆步的代碼*/
},
奔跑: function() {
/*狂奔的代碼*/
},
死亡: function() {
/*Go die*/
},
攻擊: function() {
/*糊他熊臉*/
},
防御: function() {
/*護臉*/
}
};
我們制造一個士兵, 只需要這樣:
兵營.制造(士兵);
如果需要制造 100 個士兵怎么辦呢幻工?
循環(huán) 100 次吧:
var 士兵們 = []
var 士兵
for(var i=0; i<100; i++){
士兵 = {
ID: i, // ID 不能重復(fù)
兵種:"美國大兵",
攻擊力:5,
生命值:42,
行走:function(){ /*走倆步的代碼*/}励两,
奔跑:function(){ /*狂奔的代碼*/ },
死亡:function(){ /*Go die*/ },
攻擊:function(){ /*糊他熊臉*/ },
防御:function(){ /*護臉*/ }
}
士兵們.push(士兵)
}
兵營.批量制造(士兵們)
哎呀,看起來好簡單
質(zhì)疑
上面的代碼存在一個問題:浪費了很多內(nèi)存
- 行走囊颅、奔跑当悔、死亡、攻擊踢代、防御這五個動作對于每個士兵其實是一樣的盲憎,只需要各自引用同一個函數(shù)就可以了,沒必要重復(fù)創(chuàng)建 100 個行走胳挎、100 個奔跑……
- 這些士兵的兵種和攻擊力都是一樣的饼疙,沒必要創(chuàng)建 100 次。
- 只有 ID 和生命值需要創(chuàng)建 100 次慕爬,因為每個士兵有自己的 ID 和生命值窑眯。
改進
通過第一節(jié)可以知道 屏积,我們可以通過原型鏈來解決重復(fù)創(chuàng)建的問題:我們先創(chuàng)建一個「士兵原型」,然后讓「士兵」的 proto 指向「士兵原型」磅甩。
var 士兵原型 = {
兵種:"美國大兵",
攻擊力:5,
行走:function(){ /*走倆步的代碼*/}炊林,
奔跑:function(){ /*狂奔的代碼*/ },
死亡:function(){ /*Go die*/ },
攻擊:function(){ /*糊他熊臉*/ },
防御:function(){ /*護臉*/ }
}
var 士兵們 = []
var 士兵
for(var i=0; i<100; i++){
士兵 = {
ID: i, // ID 不能重復(fù)
生命值:42
}
/*實際工作中不要這樣寫,因為 __proto__ 不是標準屬性*/
士兵.__proto__ = 士兵原型
士兵們.push(士兵)
}
兵營.批量制造(士兵們)
優(yōu)雅卷要?
有人指出創(chuàng)建一個士兵的代碼分散在兩個地方很不優(yōu)雅渣聚,于是我們用一個函數(shù)把這兩部分聯(lián)系起來:
function 士兵(ID){
var 臨時對象 = {};
臨時對象.__proto__ = 士兵.原型;
臨時對象.ID = ID;
臨時對象.生命值 = 42;
return 臨時對象;
}
士兵.原型 = {
兵種:"美國大兵",
攻擊力:5,
行走:function(){ /*走倆步的代碼*/},
奔跑:function(){ /*狂奔的代碼*/ },
死亡:function(){ /*Go die*/ },
攻擊:function(){ /*糊他熊臉*/ },
防御:function(){ /*護臉*/ }
}
// 保存為文件:士兵.js
然后就可以愉快地引用「士兵」來創(chuàng)建士兵了:
var 士兵們 = []
for(var i=0; i<100; i++){
士兵們.push(士兵(i))
}
兵營.批量制造(士兵們)
JS 之父看到大家都這么搞僧叉,覺得何必呢奕枝,我給你們個糖吃,于是 JS 之父創(chuàng)建了 new 關(guān)鍵字瓶堕,可以讓我們少寫幾行代碼:
只要你在士兵前面使用 new 關(guān)鍵字隘道,那么可以少做四件事情:
- 不用創(chuàng)建臨時對象,因為 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
然后是創(chuàng)建士兵(加了一個 new 關(guān)鍵字):
var 士兵們 = []
for(var i=0; i<100; i++){
士兵們.push(new 士兵(i))
}
兵營.批量制造(士兵們)
new 的作用缩幸,就是省那么幾行代碼。(也就是所謂的語法糖)
注意 constructor 屬性
new 操作為了記錄「臨時對象是由哪個函數(shù)創(chuàng)建的」竞思,所以預(yù)先給「士兵.prototype」加了一個 constructor 屬性:
士兵.prototype = {
constructor: 士兵
};
如果你重新對「士兵.prototype」賦值表谊,那么這個 constructor 屬性就沒了,所以你應(yīng)該這么寫:
士兵.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() {
/*護臉*/
}
};
四盖喷、繼承
繼承的本質(zhì)就是上面的講的原型鏈
1)借助構(gòu)造函數(shù)實現(xiàn)繼承
function Parent1() {
this.name = "parent1";
}
Parent1.prototype.say = function() {};
function Child1() {
Parent1.call(this);
this.type = "child";
}
console.log(new Child1());
打印結(jié)果:
這個主要是借用 call 來改變 this 的指向爆办,通過 call 調(diào)用 Parent ,此時 Parent 中的 this 是指 Child1课梳。有個缺點距辆,從打印結(jié)果看出 Child1 并沒有 say 方法,所以這種只能繼承父類的實例屬性和方法暮刃,不能繼承原型屬性/方法跨算。
2)借助原型鏈實現(xiàn)繼承
/**
* 借助原型鏈實現(xiàn)繼承
*/
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 這個語法糖揍庄,就可以實現(xiàn)原型鏈繼承王污。但這里有個總是硝桩,如打印結(jié)果,我們給 s1.play 新增一個值 椭豫,s2 也跟著改了捌朴。所以這個是原型鏈繼承的缺點津函,原因是 s1.pro 和 s2.pro指向同一個地址即 父類的 prototype蛉幸。
3)組合方式實現(xiàn)繼承
/**
* 組合方式
*/
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()對應(yīng) Child3.prototype = new Parent3()還要 new 一次匹层。
4)組合繼承的優(yōu)化 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, 因為我們通過構(gòu)造函數(shù)就可以拿到所有屬性和實例的方法锌蓄,那么現(xiàn)在我想繼承父類的原型對象升筏,所以你直接賦值給我就行,不用在去 new 一次父類瘸爽。其實這種方法還是有問題的您访,如果我在控制臺打印以下兩句:
從打印可以看出,此時我是沒有辦法區(qū)分一個對象 是直接 由它的子類實例化還是父類呢剪决?我們還有一個方法判斷來判斷對象是否是類的實例灵汪,那就是用 constructor,我在控制臺打印以下內(nèi)容:
咦,你會發(fā)現(xiàn)它指向的是父類 柑潦,這顯然不是我們想要的結(jié)果享言, 上面講過我們 prototype 里面有一個 constructor, 而我們此時子類的 prototype 指向是 父類的 prototye ,而父類 prototype 里面的 contructor 當然是父類自己的,這個就是產(chǎn)生該問題的原因渗鬼。
組合繼承的優(yōu)化 2
/**
* 組合繼承的優(yōu)化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;
參考
關(guān)于Fundebug
Fundebug專注于JavaScript多糠、微信小程序、微信小游戲浩考、支付寶小程序夹孔、React Native、Node.js和Java線上應(yīng)用實時BUG監(jiān)控。 自從2016年雙十一正式上線搭伤,F(xiàn)undebug累計處理了10億+錯誤事件只怎,付費客戶有Google、360怜俐、金山軟件身堡、百姓網(wǎng)等眾多品牌企業(yè)。歡迎大家免費試用拍鲤!