JavaScript物件導向詳解(原理)

村上春樹發表於2018-08-26

概述

JavaScript中的物件導向是基於原型鏈來實現的,這不同於其他語言複製拷貝的方式。我覺得原型鏈的好處是節約記憶體,提高效能,缺點可能就是不那麼容易理解。

下面我們就來循序漸進的通過原型鏈,來理解JavaScript中的物件導向。

物件導向的概念是為了解決什麼問題?

如果我們想建立一個具有一定功能的集合,在JavaScript中我們可以這樣寫:

var Animal = {
    name: 'kitty',
    sleep: function(){
        console.log(this.name + " is sleeping");
    }
};
複製程式碼

即通過物件的形式,將一些屬性(如name)或方法(如sleep)包裝在一起,這樣就形成了邏輯上的集合。

但是如果我們想再建立一個類似的物件呢?最直接的方式是這樣:

var Animal = {
    name: 'kate',
    sleep: function(){
        console.log(this.name + " is sleeping");
    }
};
複製程式碼

也就是重新再寫一個物件,修改其中的部分屬性。這種方式無疑是非常僵硬的,因此可以通過這種“工廠函式“的方法:

function createAnimal(name){
    var obj = {}
    obj.name = name;
    obj.sleep = function(){
        console.log(this.name + " is sleeping");
    }
    return obj;
}
複製程式碼

可以看到,”工廠函式“的作用,即在內部建立一個空的物件,然後將屬性和方法填充進去,最後再返回這個物件。

這樣看起來似乎沒什麼問題,但是仔細想想,其實我們只不過是想按照一定的模式建立一個物件,邏輯上來講,我們要做的應該只是提供模式,而新建一個空物件,返回一個空物件的操作其實沒有必要由我們來完成,因此JavaScript就提出了一個new關鍵字。這個關鍵字的用法很簡單:

function Animal(name){
    this.name = name;
    this.sleep = function(){
        console.log(this.name + " is sleeping");
    }
}

var kitty = new Animal("kitty");

//如果我們在瀏覽器控制檯列印kitty,得到的是這樣的一個物件:
{
 name: "kitty"
 sleep: function sleep()
}
複製程式碼

即在呼叫Animal函式前,加上一個new關鍵字,告訴編譯器,這個函式是一個”建構函式“,我們應該呼叫這個函式,並且根據函式所指定的規則,包裝一個物件,並返回這個物件。

tips:後面在瞭解的原型鏈後我們會自己實現一個new

JavaScript中的原型鏈

前面我們已經知道了為什麼需要物件導向,下面我們就來看看JavaScript語言處理物件導向的巧妙之處——原型鏈

先來看一張圖

原型鏈

先來從上往下講解一下這張圖:

  • 函式function)都有一個prototype屬性,指向一個原型物件
  • 原型物件都有一個constructor屬性,指向這個原型物件對應的建構函式
  • 普通物件都有一個__protor__(ES並沒有規定這個屬性的標準名稱,而瀏覽器大多都用__proto__訪問這個屬性),指向這個普通物件原型物件

我們還是拿上面的那個例子來看看:

function Animal(name){
    this.name = name;
    this.sleep = function(){
        console.log(this.name + " is sleeping");
    }
}
var kitty = new Animal("kitty");
複製程式碼

我們先在瀏覽器中列印出kitty:

kitty物件

可以看到kitty即一個物件,這個物件直接包含name屬性和sleep方法。可以看到還有一個灰色的屬性:<prototype>物件,這就是前面我們提到的__proto__,(完全是同一個東西,只不過火狐瀏覽器這樣顯示罷了,為了保持統一,我們在本文就叫它__proto__)。

我們展開這個__proto__看看它的組成:

__proto__

很簡單嘛,也就是一個包含constructor屬性的物件,這個constructor屬性指向kitty的建構函式Animal。

注意,在這裡,__proto__所指向的物件也有一個<prototype>,這是理所當然的,因為這個屬性所有的物件都有(甚至函式也有,因為函式其實也是一種物件)

我們再在瀏覽器中列印出Animal這個函式:

建構函式

可以看到這個函式有一個prototype屬性,我們已經可以看到這個prototype屬性是一個物件(即原型物件)。我們再展開這個prototype屬性看看:

prototype

可以看到目前這個原型物件非常簡單,就是一個constructor,指向了Animal這個函式。

那麼如果我們想原型物件中新增一些東西呢?像這樣:

Animal.prototype.eat = function (food){
    console.log("eat " + food);
}
複製程式碼

加上這段程式碼,我們重新整理一下頁面,再看看這時的原型物件是什麼:

eat

果然,eat 方法被新增到了Animal的原型物件中。我們在kitty中呼叫這個方法試試看:

kitty.eat("shxt");
//列印結果:eat shxt
複製程式碼

好,到此為止,我們已經大致瞭解了原型鏈的存在形式:即通過原型物件來連結,下面我們總結一下:

  • 建立一個函式的時候,會建立這個函式對應的原型物件,原型物件的constructor指向這個函式。
  • 函式的prototype屬性,以及函式(通過new)建立的例項物件的__proto__屬性,都指向同一個原型物件。

而在原型鏈的作用在於:

比如上面我們在Animal的原型物件上定義了一個eat方法。我們有一個Animal的例項kitty,我們列印出kitty:

kitty物件

發現kitty物件中並沒有eat方法,那麼它是怎麼呼叫到eat的呢?沒錯,我們可以看到kitty物件的原型物件中有eat這個方法。所以在原型鏈機制中,物件的方法和屬性的呼叫過程是這樣的:

  1. 先在自己當前物件尋找這個屬性或方法,如果找到了就直接用
  2. 如果找不到,就去當前物件的__proto__屬性指向的原型物件中尋找,如果找到了,就使用
  3. 如果還找不到,就在當前原型物件的__proto__屬性指向的上一級原型物件尋找
  4. 如果沒有更上一級的原型物件,那麼__proto__屬性會指向Object的原型物件(這個物件就沒有__proto__屬性了)
  5. 如果在Object的原型物件中還找不到,那麼就返回undefined

Object的原型物件長這樣:

Object的原型物件

是不是看著很熟悉,裡面有很多我們常用的方法。

那就順便再把function的原型物件放出來:

function的原型物件

是不是更熟悉了?而且可以看到function的原型物件的__proto__屬性,指向Object的原型物件。

所謂原型鏈,就是一個物件的__proto__指向一個原型物件,而這個原型物件的__proto__又指向另一個原型物件,依次串聯下去。

目前我們談論的原型鏈都是很短的,要想凸顯原型鏈的威力,就要講講繼承了。

JavaScript中的繼承

所謂繼承就是,在一個父類的基礎上,建立一個子類,子類擁有父類的屬性和方法,也有自己的屬性和方法。

在JavaScript中,實現繼承主要思想是:

拿到子類的建構函式的原型物件,將其__proto__屬性指向父類建構函式的原型物件。

這樣子類建構函式生成的例項物件就可以先訪問到子類的原型物件,再訪問到父類的原型物件,就完成了繼承。

具體的實現方式呢,有多種:

一、實現子類建構函式,在子類建構函式上修改原型鏈

這種是最傳統的做法,步驟是:

  • 建立一個子類建構函式
  • 在這個建構函式內呼叫父類的建構函式
  • 將子類建構函式的原型物件的__proto__屬性指向父類建構函式的原型物件

這樣,當子類建立的例項中尋找屬性或方法時,先找到子類的原型物件,找不到的話就去子類原型物件的__proto__(即父類的原型物件)找,這樣就實現了繼承。

我們來實現一個例子:

function Animal(name) {
    this.name = name;
    this.sleep = function () {
        console.log(this.name + " is sleeping");
    }
}
Animal.prototype.eat = function (food) {
    console.log(this.name + " is eating " + food);
}

function Cat(name, color) {
    Animal.call(this, name); //獲得Animal內部定義的屬性和方法
    this.color = color;
}
//Object.create()方法作用是,建立一個空的物件,將這個物件的__proto__屬性指向引數
Cat.prototype = Object.create(Animal.prototype); //獲得Animal原型物件上的方法和屬性
//這裡暫時先不考慮原型物件的constructor屬性的指向是否正確
//我們列印出來看看Cat建構函式
console.log(Cat);

//下面我們可以新建一個Cat例項
var kitty = new Cat("kitty","yellow");
console.log(kitty);
//試試使用父類的原型上的方法
kitty.eat("fish"); //console: kitty is eating fish
複製程式碼

你可以複製我的程式碼到瀏覽器的控制檯,看看列印出來的結果是怎樣的。

但是這種方法有一個缺點,即對於某些JavaScript內建物件(如Date),如果例項物件不是由它本身的建構函式生成的,不能訪問其內部的屬性和方法。所以通過這種方法繼承Date類,即便我們修改了原型鏈,但還是不能呼叫Date內的方法。不信?我們來驗證一下:

function MyDate(date) {
    Date.call(this, date);
    this.log = function () {
        console.log("now is " + date);
    }
}
MyDate.prototype = Object.create(Date.prototype);

var time = new MyDate("2018-08-23");
time.getDate(); //console: TypeError: getDate method called on incompatible Object
複製程式碼

雖然我們已經將MyDate的原型物件的屬性指向Date的原型物件了,但是還是不能呼叫Date中的方法。

二、建立父類例項,在父類例項上修改原型鏈

解決上面的問題其實也很簡單,即我們先用Date建構函式生成一個例項物件,然後將這個例項物件改造成子類例項物件,這樣就不會出現上面這種問題。所以這種方式的步驟是這樣的:

  • 用父類建構函式建立例項
  • 將例項的__proto__屬性指向子類的原型物件
  • 將子類原型物件的__proto__指向父類的原型物件

這樣,父類原型物件又被連結到子類上了,而且我們的例項也是通過父類建立出來的,也就不會出現上面的那種限制了。

我們來實現一下:

//先來建立子類的建構函式
function MyDate() {}
MyDate.prototype.log = function () {
    return this.getDate();
}

var time = new Date(); //建立父類例項
Object.setPrototypeOf(time, MyDate.prototype); //例項的__proto__指向子類原型物件
Object.setPrototypeOf(MyDate.prototype, Date.prototype); //子類原型物件指向父類原型物件

console.log(time.log());
複製程式碼

這個方法其實也有缺點,根據MDN文件,Object.setPrototypeOf()方法是十分浪費效能的,所以除非迫不得已,還是少用這種方法。

擴充

關於JavaScript中的物件導向我們已經講完了,下面就來嘗試實現幾個原生的API:new,Object.create()

new

new是一個關鍵字,我們自然不能建立一個關鍵字,我們這裡就建立一個new函式:

function MyNew() {
    //注意,這個實現沒有進行錯誤處理,僅考慮了引數合理的情況,為了理解new已經夠了
    
    var obj = {};
    //獲取建構函式和引數
    var Cons = [].shift.call(arguments); //這個操作有的人可能看的有點暈,
    								  //這麼寫是因為arguments其實不是一個陣列
    								  //它僅有一個length屬性,所以沒有陣列的方法。
    //將例項物件的__proto__屬性指向建構函式的prototype屬性
    obj.__proto__ = Cons.prototype;
    //呼叫建構函式
    Cons.apply(obj, arguments);
    return obj;
}
複製程式碼

我們來加上一些程式碼,測試一下:

function Animal(name) {
    this.name = name;
}
Animal.prototype.eat = function (food) {
    console.log(this.name + " is eating " + food);
}

//測試一下
var kitty = MyNew(Animal, "kitty");
console.log(kitty.name); //console: kitty
kitty.eat("fish"); //console: kitty is eating fish
複製程式碼

Object.create()

Object.prototype.myCreate = function (proto) {
    //注意,這裡也省略了錯誤處理,並且真實的create方法有第二個引數

    function F() {}; //建立一個空的建構函式
    F.prototype = proto; //將該函式的prototype指向屬性proto(傳入的原型物件)
    return new F(); //返回的物件的__proto__根據F建構函式的prototype設定,
    //因此返回一個僅有__proto__屬性(指向傳入的原型物件)的空物件。
}

//測試
console.log(Object.myCreate(Date.prototype)); //在瀏覽器列印出來得到要一個物件
										  //物件的__proto__指向Date原型物件。
複製程式碼

參考資料:

MDN-JavaScript物件

InfoQ * Interview Map 《前端面試指南》

相關文章