輕松理解JS中的面向對象,順便搞懂prototype和__proto__
這篇文章主要講一下JS中面向對象以及 __proto__,ptototype和constructor,這幾個概念都是相關的,所以一起講了。
在講這個之前我們先來說說類,了解面向對象的朋友應該都知道,如果我要定義一個通用的類型我可以使用類(class)。比如在java中我們可以這樣定義一個類:
- public class Puppy{
- int puppyAge;
- public Puppy(age){
- puppyAge = age;
- }
- public void say() {
- System.out.println("汪汪汪");
- }
- }
上述代碼我們定義了一個Puppy類,這個類有一個屬性是puppyAge,也就是小狗的年齡,然后有一個構造函數Puppy(),這個構造函數接收一個參數,可以設置小狗的年齡,另外還有一個說話的函數say。這是一個通用的類,當我們需要一個兩歲的小狗實例是直接這樣寫,這個實例同時具有父類的方法:
- Puppy myPuppy = new Puppy( 2 );
- myPuppy.say(); // 汪汪汪
但是早期的JS沒有class關鍵字啊(以下說JS沒有class關鍵字都是指ES6之前的JS,主要幫助大家理解概念),JS為了支持面向對象,使用了一種比較曲折的方式,這也是導致大家迷惑的地方,其實我們將這種方式跟一般的面向對象類比起來就很清晰了。下面我們來看看JS為了支持面向對象需要解決哪些問題,都用了什么曲折的方式來解決。
沒有class,用函數代替
首先JS連class關鍵字都沒有,怎么辦呢?用函數代替,JS中最不缺的就是函數,函數不僅能夠執行普通功能,還能當class使用。比如我們要用JS建一個小狗的類怎么寫呢?直接寫一個函數就行:
- function Puppy() {}
這個函數可以直接用new關鍵字生成實例:
- const myPuppy = new Puppy();
這樣我們也有了一個小狗實例,但是我們沒有構造函數,不能設置小狗年齡啊。
函數本身就是構造函數
當做類用的函數本身也是一個函數,而且他就是默認的構造函數。我們想讓Puppy函數能夠設置實例的年齡,只要讓他接收參數就行了。
- function Puppy(age) {
- this.puppyAge = age;
- }
- // 實例化時可以傳年齡參數了
- const myPuppy = new Puppy(2);
注意上面代碼的this,被作為類使用的函數里面this總是指向實例化對象,也就是myPuppy。這么設計的目的就是讓使用者可以通過構造函數給實例對象設置屬性,這時候console出來看myPuppy.puppyAge就是2。
- console.log(myPuppy.puppyAge); // 輸出是 2
實例方法用prototype
上面我們實現了類和構造函數,但是類方法呢?Java版小狗還可以“汪汪汪”叫呢,JS版怎么辦呢?JS給出的解決方案是給方法添加一個prototype屬性,掛載在這上面的方法,在實例化的時候會給到實例對象。我們想要myPuppy能說話,就需要往Puppy.prototype添加說話的方法。
- Puppy.prototype.say = function() {
- console.log("汪汪汪");
- }
使用new關鍵字產生的實例都有類的prototype上的屬性和方法,我們在Puppy.prototype上添加了say方法,myPuppy就可以說話了,我么來試一下:
- myPuppy.say(); // 汪汪汪
實例方法查找用__proto__
那myPuppy怎么就能夠調用say方法了呢,我們把他打印出來看下,這個對象上并沒有say啊,這是從哪里來的呢?
這就該__proto__上場了,當你訪問一個對象上沒有的屬性時,比如myPuppy.say,對象會去__proto__查找。__proto__的值就等于父類的prototype, myPuppy.__proto__指向了Puppy.prototype。
如果你訪問的屬性在Puppy.prototype也不存在,那又會繼續往Puppy.prototype.__proto__上找,這時候其實就找到了Object.prototype了,Object.prototype再往上找就沒有了,也就是null,這其實就是原型鏈。
constructor
我們說的constructor一般指類的prototype.constructor。prototype.constructor是prototype上的一個保留屬性,這個屬性就指向類函數本身,用于指示當前類的構造函數。
既然prototype.constructor是指向構造函數的一個指針,那我們是不是可以通過它來修改構造函數呢?我們來試試就知道了。我們先修改下這個函數,然后新建一個實例看看效果:
- function Puppy(age) {
- this.puppyAge = age;
- }
- Puppy.prototype.constructor = function myConstructor(age) {
- this.puppyAge = age + 1;
- }
- const myPuppy2 = new Puppy(2);
- console.log(myPuppy2.puppyAge); // 輸出是2
上例說明,我們修改prototype.constructor只是修改了這個指針而已,并沒有修改真正的構造函數。
可能有的朋友會說我打印myPuppy2.constructor也有值啊,那constructor是不是也是對象本身的一個屬性呢?其實不是的,之所以你能打印出這個值,是因為你打印的時候,發現myPuppy2本身并不具有這個屬性,又去原型鏈上找了,找到了prototype.constructor。我們可以用hasOwnProperty看一下就知道了:
上面我們其實已經說清楚了prototype,__proto__,constructor幾者之間的關系,下面畫一張圖來更直觀的看下:
靜態方法
我們知道很多面向對象有靜態方法這個概念,比如Java直接是加一個static關鍵字就能將一個方法定義為靜態方法。JS中定義一個靜態方法更簡單,直接將它作為類函數的屬性就行:
- Puppy.statciFunc = function() { // statciFunc就是一個靜態方法
- console.log('我是靜態方法,this拿不到實例對象');
- }
- Puppy.statciFunc(); // 直接通過類名調用
靜態方法和實例方法最主要的區別就是實例方法可以訪問到實例,可以對實例進行操作,而靜態方法一般用于跟實例無關的操作。這兩種方法在jQuery中有大量應用,在jQuery中$(selector)其實拿到的就是實例對象,通過$(selector)進行操作的方法就是實例方法。比如$(selector).append(),這會往這個實例DOM添加新元素,他需要這個DOM實例才知道怎么操作,將append作為一個實例方法,他里面的this就會指向這個實例,就可以通過this操作DOM實例。那什么方法適合作為靜態方法呢?比如$.ajax,這里的ajax跟DOM實例沒關系,不需要這個this,可以直接掛載在$上作為靜態方法。
繼承
面向對象怎么能沒有繼承呢,根據前面所講的知識,我們其實已經能夠自己寫一個繼承了。所謂繼承不就是子類能夠繼承父類的屬性和方法嗎?換句話說就是子類能夠找到父類的prototype,最簡單的方法就是子類原型的__proto__指向父類原型就行了。
- function Parent() {}
- function Child() {}
- Child.prototype.__proto__ = Parent.prototype;
- const obj = new Child();
- console.log(obj instanceof Child ); // true
- console.log(obj instanceof Parent ); // true
上述繼承方法只是讓Child訪問到了Parent原型鏈,但是沒有執行Parent的構造函數:
- function Parent() {
- this.parentAge = 50;
- }
- function Child() {}
- Child.prototype.__proto__ = Parent.prototype;
- const obj = new Child();
- console.log(obj.parentAge); // undefined
為了解決這個問題,我們不能單純的修改Child.prototype.__proto__指向,還需要用new執行下Parent的構造函數:
- function Parent() {
- this.parentAge = 50;
- }
- function Child() {}
- Child.prototype.__proto__ = new Parent();
- const obj = new Child();
- console.log(obj.parentAge); // 50
上述方法會多一個__proto__層級,可以換成修改Child.prototype的指向來解決,注意將Child.prototype.constructor重置回來:
- function Parent() {
- this.parentAge = 50;
- }
- function Child() {}
- Child.prototype = new Parent();
- ChildChild.prototype.constructor = Child; // 注意重置constructor
- const obj = new Child();
- console.log(obj.parentAge); // 50
當然還有很多其他的繼承方式,他們的原理都差不多,只是實現方式不一樣,核心都是讓子類擁有父類的方法和屬性,感興趣的朋友可以自行查閱。
自己實現一個new
結合上面講的,我們知道new其實就是生成了一個對象,這個對象能夠訪問類的原型,知道了原理,我們就可以自己實現一個new了。
- function myNew(func, ...args) {
- const obj = {}; // 新建一個空對象
- const result = func.call(obj, ...args); // 執行構造函數
- obj.__proto__ = func.prototype; // 設置原型鏈
- // 注意如果原構造函數有Object類型的返回值,包括Functoin, Array, Date, RegExg, Error
- // 那么應該返回這個返回值
- const isObject = typeof result === 'object' && result !== null;
- const isFunction = typeof result === 'function';
- if(isObject || isFunction) {
- return result;
- }
- // 原構造函數沒有Object類型的返回值,返回我們的新對象
- return obj;
- }
- function Puppy(age) {
- this.puppyAge = age;
- }
- Puppy.prototype.say = function() {
- console.log("汪汪汪");
- }
- const myPuppy3 = myNew(Puppy, 2);
- console.log(myPuppy3.puppyAge); // 2
- console.log(myPuppy3.say()); // 汪汪汪
自己實現一個instanceof
知道了原理,其實我們也知道了instanceof是干啥的。instanceof不就是檢查一個對象是不是某個類的實例嗎?換句話說就是檢查一個對象的的原型鏈上有沒有這個類的prototype,知道了這個我們就可以自己實現一個了:
- function myInstanceof(targetObj, targetClass) {
- // 參數檢查
- if(!targetObj || !targetClass || !targetObj.__proto__ || !targetClass.prototype){
- return false;
- }
- let current = targetObj;
- while(current) { // 一直往原型鏈上面找
- if(current.__proto__ === targetClass.prototype) {
- return true; // 找到了返回true
- }
- currentcurrent = current.__proto__;
- }
- return false; // 沒找到返回false
- }
- // 用我們前面的繼承實驗下
- function Parent() {}
- function Child() {}
- Child.prototype.__proto__ = Parent.prototype;
- const obj = new Child();
- console.log(myInstanceof(obj, Child) ); // true
- console.log(myInstanceof(obj, Parent) ); // true
- console.log(myInstanceof({}, Parent) ); // false
ES6的class
最后還是提一嘴ES6的class,其實ES6的class就是前面說的函數類的語法糖,比如我們的Puppy用ES6的class寫就是這樣:
- class Puppy {
- // 構造函數
- constructor(age) {
- this.puppyAge = age;
- }
- // 實例方法
- say() {
- console.log("汪汪汪")
- }
- // 靜態方法
- static statciFunc() {
- console.log('我是靜態方法,this拿不到實例對象');
- }
- }
- const myPuppy = new Puppy(2);
- console.log(myPuppy.puppyAge); // 2
- console.log(myPuppy.say()); // 汪汪汪
- console.log(Puppy.statciFunc()); // 我是靜態方法,this拿不到實例對象
使用class可以讓我們的代碼看起來更像標準的面向對象,構造函數,實例方法,靜態方法都有明確的標識。但是他本質只是改變了一種寫法,所以可以看做是一種語法糖,如果你去看babel編譯后的代碼,你會發現他其實也是把class編譯成了我們前面的函數類,extends關鍵字也是使用我們前面的原型繼承的方式實現的。
總結
最后來個總結,其實前面小節的標題就是核心了,我們再來總結下:
- JS中的函數可以作為函數使用,也可以作為類使用
- 作為類使用的函數實例化時需要使用new
- 為了讓函數具有類的功能,函數都具有prototype屬性。
- 為了讓實例化出來的對象能夠訪問到prototype上的屬性和方法,實例對象的__proto__指向了類的prototype。所以prototype是函數的屬性,不是對象的。對象擁有的是__proto__,是用來查找prototype的。
5. prototype.constructor指向的是構造函數,也就是類函數本身。改變這個指針并不能改變構造函數。
6. 對象本身并沒有constructor屬性,你訪問到的是原型鏈上的prototype.constructor。
7. 函數本身也是對象,也具有__proto__,他指向的是JS內置對象Function的原型Function.prototype。所以你才能調用func.call,func.apply這些方法,你調用的其實是Function.prototype.call和Function.prototype.apply。
8. prototype本身也是對象,所以他也有__proto__,指向了他父級的prototype。__proto__和prototype的這種鏈式指向構成了JS的原型鏈。原型鏈的最終指向是Object的原型。Object上面原型鏈是null,即Object.prototype.__proto__ === null。
9. 另外要注意的是Function.__proto__ === Function.prototype,這是因為JS中所有函數的原型都是Function.prototype,也就是說所有函數都是Function的實例。Function本身也是可以作為函數使用的----Function(),所以他也是Function的一個實例。類似的還有Object,Array等,他們也可以作為函數使用:Object(), Array()。所以他們本身的原型也是Function.prototype,即Object.__proto__ === Function.prototype。換句話說,這些可以new的內置對象其實都是一個類,就像我們的Puppy類一樣。
10. ES6的class其實是函數類的一種語法糖,書寫起來更清晰,但原理是一樣的。
再來看一下完整圖:
文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。