1. 程式人生 > >輕鬆理解JS中的面向物件,順便搞懂prototype和__proto__

輕鬆理解JS中的面向物件,順便搞懂prototype和__proto__

這篇文章主要講一下JS中面向物件以及 __proto__ptototypeconstrucator,這幾個概念都是相關的,所以一起講了。

在講這個之前我們先來說說類,瞭解面向物件的朋友應該都知道,如果我要定義一個通用的型別我可以使用類(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.constructorprototype.constructor是prototype上的一個保留屬性,這個屬性就指向類函式本身,用於指示當前類的建構函式。

既然prototype.constructor是指向建構函式的一個指標,那我們是不是可以通過它來修改建構函式呢?我們來試試就知道了。我們先修改下這個函式,然後新建一個例項看看效果:

function Puppy(age) {
  this.puppyAge = age;
}

Puppy.prototype.constructor = function myConstructor(age) {
  this.puppyAge2 = 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就是一個靜態方法
  conlose.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();
Child.prototype.constructor = Child;      // 注意重置constructor

const obj = new Child();
console.log(obj.parentAge);    // 50

當然還有很多其他的繼承方式,他們的原理都差不多,只是實現方式不一樣,核心都是讓子類擁有父類的方法和屬性,感興趣的朋友可以自行查閱。

自己實現一個new

結合上面講的,我們知道new其實就是生成了一個物件,這個物件能夠訪問類的原型,知道了原理,我們就可以自己實現一個new了。

function myNew(func, ...args) {
  const obj = {};     // 新建一個空物件
  func.call(obj, ...args);  // 執行建構函式
  obj.__proto__ = func.prototype;    // 設定原型鏈
  
  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
    }
    
    current = 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

總結

最後來個總結,其實前面小節的標題就是核心了,我們再來總結下:

  1. JS中的函式可以作為函式使用,也可以作為類使用
  2. 作為類使用的函式例項化時需要使用new
  3. 為了讓函式具有類的功能,函式都具有prototype屬性。
  4. 為了讓例項化出來的物件能夠訪問到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.callFunction.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的一個例項。類似的還有ObjectArray等,他們也可以作為函式使用:Object(), Array()。所以他們本身的原型也是Function.prototype,即Object.__proto__ === Function.prototype。換句話說,這些可以new的內建物件其實都是一個類,就像我們的Puppy類一樣。

再來看一下完整圖:

本文首發於掘金社群:https://juejin.im/post/5e50e5b16fb9a07c9a195