深入JavaScript系列(六):原型與原型鏈

Logan70發表於2018-12-29

說到JavaScript的原型和原型鏈,相關文章已有不少,但是大都晦澀難懂。本文將換一個角度出發,先理解原型和原型鏈是什麼,有什麼作用,再去分析那些令人頭疼的關係。

一、引用型別皆為物件

原型和原型鏈都是來源於物件而服務於物件的概念,所以我們要先明確一點:

JavaScript中一切引用型別都是物件,物件就是屬性的集合。

Array型別Function型別Object型別Date型別RegExp型別等都是引用型別。

也就是說 陣列是物件、函式是物件、正則是物件、物件還是物件。

深入JavaScript系列(六):原型與原型鏈

二、原型和原型鏈是什麼

上面我們說到物件就是屬性(property)的集合,有人可能要問不是還有方法嗎?其實方法也是一種屬性,因為它也是鍵值對的表現形式,具體見下圖。

深入JavaScript系列(六):原型與原型鏈

可以看到obj上確實多了一個sayHello的屬性,值為一個函式,但是問題來了,obj上面並沒有hasOwnProperty這個方法,為什麼我們可以呼叫呢?這就引出了 原型

每一個物件從被建立開始就和另一個物件關聯,從另一個物件上繼承其屬性,這個另一個物件就是 原型

當訪問一個物件的屬性時,先在物件的本身找,找不到就去物件的原型上找,如果還是找不到,就去物件的原型(原型也是物件,也有它自己的原型)的原型上找,如此繼續,直到找到為止,或者查詢到最頂層的原型物件中也沒有找到,就結束查詢,返回undefined

這條由物件及其原型組成的鏈就叫做原型鏈。

現在我們已經初步理解了原型和原型鏈,到現在大家明白為什麼陣列都可以使用pushslice等方法,函式可以使用callbind等方法了吧,因為在它們的原型鏈上找到了對應的方法。

OK,總結一下

  1. 原型存在的意義就是組成原型鏈:引用型別皆物件,每個物件都有原型,原型也是物件,也有它自己的原型,一層一層,組成原型鏈。
  2. 原型鏈存在的意義就是繼承:訪問物件屬性時,在物件本身找不到,就在原型鏈上一層一層找。說白了就是一個物件可以訪問其他物件的屬性。
  3. 繼承存在的意義就是屬性共享:好處有二:一是程式碼重用,字面意思;二是可擴充套件,不同物件可能繼承相同的屬性,也可以定義只屬於自己的屬性。

三、建立物件

物件的建立方式主要有兩種,一種是new操作符後跟函式呼叫,另一種是字面量表示法。

目前我們現在可以理解為:所有物件都是由new操作符後跟函式呼叫來建立的,字面量表示法只是語法糖(即本質也是new,功能不變,使用更簡潔)。

// new操作符後跟函式呼叫
let obj = new Object()
let arr = new Array()

// 字面量表示法
let obj = { a: 1}
// 等同於
let obj = new Object()
obj.a = 1

let arr = [1,2]
// 等同於
let arr = new Array()
arr[0] = 1
arr[1] = 2
複製程式碼

ObjectArray等稱為建構函式,不要怕這個概念,建構函式和普通函式並沒有什麼不同,只是由於這些函式常被用來跟在new後面建立物件。new後面呼叫一個空函式也會返回一個物件,任何一個函式都可以當做建構函式

所以建構函式更合理的理解應該是函式的構造呼叫

NumberStringBooleanArrayObjectFunctionDateRegExpError這些都是函式,而且是原生建構函式,在執行時會自動出現在執行環境中。

建構函式是為了建立特定型別的物件,這些通過同一建構函式建立的物件有相同原型,共享某些方法。舉個例子,所有的陣列都可以呼叫push方法,因為它們有相同原型。

我們來自己實現一個建構函式:

// 慣例,建構函式應以大寫字母開頭
function Person(name) {
  // 函式內this指向構造的物件
  // 構造一個name屬性
  this.name = name
  // 構造一個sayName方法
  this.sayName = function() {
    console.log(this.name)
  }
}

// 使用自定義建構函式Person建立物件
let person = new Person('logan')
person.sayName() // 輸出:logan
複製程式碼

總結一下建構函式用來建立物件,同一建構函式建立的物件,其原型相同。

四、__proto__prototype

萬物逃不開真香定律,初步瞭解了相關知識,我們也要試著來理解一下這些頭疼的單詞,並且看一下指來指去的箭頭了。

上面總結過,每個物件都有原型,那麼我們怎麼獲取到一個物件的原型呢?那就是物件的__proto__屬性,指向物件的原型。

上面也總結過,引用型別皆物件,所以引用型別都有__proto__屬性,物件有__proto__屬性,函式有__proto__屬性,陣列也有__proto__屬性,只要是引用型別,就有__proto__屬性,都指向它們各自的原型物件。

深入JavaScript系列(六):原型與原型鏈

__proto__屬性雖然在ECMAScript 6語言規範中標準化,但是不推薦被使用,現在更推薦使用Object.getPrototypeOfObject.getPrototypeOf(obj)也可以獲取到obj物件的原型。本文中使用__proto__只是為了便於理解。

Object.getPrototypeOf(person) === person.__proto__ // true
複製程式碼

上面說過,建構函式是為了建立特定型別的物件,那如果我想讓Person這個建構函式建立的物件都共享一個方法,總不能像下面這樣吧:

錯誤示範

// 呼叫建構函式Person建立一個新物件personA
let personA = new Person('張三')
// 在personA的原型上新增一個方法,以供之後Person建立的物件所共享
personA.__proto__.eat = function() {
    console.log('吃東西')
}
let personB = new Person('李四')
personB.eat() // 輸出:吃東西
複製程式碼

但是每次要修改一類物件的原型物件,都去建立一個新的物件例項,然後訪問其原型物件並新增or修改屬性總覺得多此一舉。既然建構函式建立的物件例項的原型物件都是同一個,那麼建構函式和其構造出的物件例項的原型物件之間有聯絡就完美了。

深入JavaScript系列(六):原型與原型鏈

這個聯絡就是prototype。每個函式擁有prototype屬性,指向使用new操作符和該函式建立的物件例項的原型物件。

Person.prototype === person.__proto__ // true
複製程式碼

深入JavaScript系列(六):原型與原型鏈

看到這裡我們就明白了,如果想讓Person建立出的物件例項共享屬性,應該這樣寫:

正確示範

Person.prototype.drink = function() {
    console.log('喝東西')
}

let personA = new Person('張三')
personB.drink() // 輸出:喝東西
複製程式碼

OK,慣例,總結一下

  1. 物件有__proto__屬性,函式有__proto__屬性,陣列也有__proto__屬性,只要是引用型別,就有__proto__屬性,指向其原型。
  2. 只有函式有prototype屬性,只有函式有prototype屬性,只有函式有prototype屬性,指向new操作符加呼叫該函式建立的物件例項的原型物件。

五、原型鏈頂層

原型鏈之所以叫原型鏈,而不叫原型環,說明它是有始有終的,那麼原型鏈的頂層是什麼呢?

拿我們的person物件來看,它的原型物件,很簡單

// 1. person的原型物件
person.__proto__ === Person.prototype
複製程式碼

接著往上找,Person.prototype也是一個普通物件,可以理解為Object建構函式建立的,所以得出下面結論,

// 2. Person.prototype的原型物件
Person.prototype.__proto__ === Object.prototype
複製程式碼

Object.prototype也是一個物件,那麼它的原型呢?這裡比較特殊,切記!!!

Object.prototype.__proto__ === null
複製程式碼

我們就可以換個方式描述下 原型鏈 :由物件的__proto__屬性串連起來的直到Object.prototype.__proto__(為null)的鏈就是原型鏈。

在上面內容的基礎之上,我們來模擬一下js引擎讀取物件屬性:

function getProperty(obj, propName) {
    // 在物件本身查詢
    if (obj.hasOwnProperty(propName)) {
        return obj[propName]
    } else if (obj.__proto__ !== null) {
    // 如果物件有原型,則在原型上遞迴查詢
        return getProperty(obj.__proto__, propName)
    } else {
    // 直到找到Object.prototype,Object.prototype.__proto__為null,返回undefined
        return undefined
    }
}
複製程式碼

六、constructor

回憶一下之前的描述,建構函式都有一個prototype屬性,指向使用這個建構函式建立的物件例項的原型物件

這個原型物件中預設有一個constructor屬性,指回該建構函式。

Person.prototype.constructor === Person // true
複製程式碼

之所以開頭不說,是因為這個屬性對我們理解原型及原型鏈並無太大幫助,反而容易混淆。

深入JavaScript系列(六):原型與原型鏈

七、函式物件的原型鏈

之前提到過引用型別皆物件,函式也是物件,那麼函式物件的原型鏈是怎麼樣的呢?

物件都是被建構函式建立的,函式物件的建構函式就是Function,注意這裡F是大寫。

let fn = function() {}
// 函式(包括原生建構函式)的原型物件為Function.prototype
fn.__proto__ === Function.prototype // true
Array.__proto__ === Function.prototype // true
Object.__proto__ === Function.prototype // true
複製程式碼

Function.prototype也是一個普通物件,所以Function.prototype.__proto__ === Object.prototype

這裡有一個特例,Function__proto__屬性指向Function.prototype

總結一下:函式都是由Function原生建構函式建立的,所以函式的__proto__屬性指向Functionprototype屬性

八、小試牛刀

真香警告!

深入JavaScript系列(六):原型與原型鏈

有點亂?沒事,我們先將之前的知識都總結一下,然後慢慢分析此圖:

知識點

  1. 引用型別都是物件,每個物件都有原型物件。
  2. 物件都是由建構函式建立,物件的__proto__屬性指向其原型物件,建構函式的prototype屬性指向其建立的物件例項的原型物件,所以物件的__proto__屬性等於建立它的建構函式的prototype屬性。
  3. 所有通過字面量表示法建立的普通物件的建構函式為Object
  4. 所有原型物件都是普通物件,建構函式為Object
  5. 所有函式的建構函式是Function
  6. Object.prototype沒有原型物件

OK,我們根據以上六點總結來分析上圖,先從左上角的f1f2入手:

// f1、f2都是通過new Foo()建立的物件,建構函式為Foo,所以有
f1.__proto__ === Foo.prototype
// Foo.prototype為普通物件,建構函式為Object,所以有
Foo.prototype.__proto === Object.prototype
// Object.prototype沒有原型物件
Object.prototype.__proto__ === null
複製程式碼

然後對建構函式Foo下手:

// Foo是個函式物件,建構函式為Function
Foo.__proto__ === Function.prototype
// Function.prototype為普通物件,建構函式為Object,所以有
Function.prototype.__proto__ === Object.prototype
複製程式碼

接著對原生建構函式Object建立的o1o2下手:

// o1、o2建構函式為Object
o1.__proto__ === Object.prototype
複製程式碼

最後對原生建構函式ObjectFunction下手:

// 原生建構函式也是函式物件,其建構函式為Function
Object.__proto__ === Function.prototype
// 特例
Function.__proto__ === Function.prototype
複製程式碼

分析完畢,也沒有想象中那麼複雜是吧。

如果有內容引起不適,建議從頭看一遍,或者去看看參考文章內的文章。

九、舉一反三

1. instanceof操作符

平常我們判斷一個變數的型別會使用typeof運算子,但是引用型別並不適用,除了函式物件會返回function外,其他都返回object。我們想要知道一個物件的具體型別,就需要使用到instanceof

let fn = function() {}
let arr = []
fn instanceof Function // true
arr instanceof Array // true
fn instanceof Object // true
arr instanceof Object // true
複製程式碼

為什麼fn instanceof Objectarr instanceof Object都返回true呢?我們來看一下MDN上對於instanceof運算子的描述:

instanceof運算子用於測試建構函式的prototype屬性是否出現在物件的原型鏈中的任何位置

也就是說instanceof操作符左邊是一個物件,右邊是一個建構函式,在左邊物件的原型鏈上查詢,知道找到右邊建構函式的prototype屬性就返回true,或者查詢到頂層null(也就是Object.prototype.__proto__),就返回false。 我們模擬實現一下:

function instanceOf(obj, Constructor) { // obj 表示左邊的物件,Constructor表示右邊的建構函式
    let rightP = Constructor.prototype // 取建構函式顯示原型
    let leftP = obj.__proto__ // 取物件隱式原型
    // 到達原型鏈頂層還未找到則返回false
    if (leftP === null) {
        return false
    }
    // 物件例項的隱式原型等於建構函式顯示原型則返回true
    if (leftP === rightP) {
        return true
    }
    // 查詢原型鏈上一層
    return instanceOf(obj.__proto__, Constructor)
}
複製程式碼

現在就可以解釋一些比較令人費解的結果了:

fn instanceof Object //true
// 1. fn.__proto__ === Function.prototype
// 2. fn.__proto__.__proto__ === Function.prototype.__proto__ === Object.prototype
arr instanceof Object //true
// 1. arr.__proto__ === Array.prototype
// 2. arr.__proto__.__proto__ === Array.prototype.__proto__ === Object.prototype
Object instanceof Object // true
// 1. Object.__proto__ === Function.prototype
// 2. Object.__proto__.__proto__ === Function.prototype.__proto__ === Object.prototype
Function instanceof Function // true
// Function.__proto__ === Function.prototype
複製程式碼

總結一下:instanceof運算子用於檢查右邊建構函式的prototype屬性是否出現在左邊物件的原型鏈中的任何位置。其實它表示的是一種原型鏈繼承的關係。

2. Object.create

之前說物件的建立方式主要有兩種,一種是new操作符後跟函式呼叫,另一種是字面量表示法。

其實還有第三種就是ES5提供的Object.create()方法,會建立一個新物件,第一個引數接收一個物件,將會作為新建立物件的原型物件,第二個可選引數是屬性描述符(不常用,預設是undefined)。具體請檢視Object.create()

我們來模擬一個簡易版的Object.create

function createObj(proto) {
    function F() {}
    F.prototype = proto
    return new F()
}
複製程式碼

我們平常所說的空物件,其實並不是嚴格意義上的空物件,它的原型物件指向Object.prototype,還可以繼承hasOwnPropertytoStringvalueOf等方法。

如果想要生成一個不繼承任何屬性的物件,可以使用Object.create(null)

如果想要生成一個平常字面量方法生成的物件,需要將其原型物件指向Object.prototype

let obj = Object.create(Object.prototype)
// 等價於
let obj = {}
複製程式碼

3. new操作符

當我們使用new時,做了些什麼?

  1. 建立一個全新物件,並將其__proto__屬性指向建構函式的prototype屬性。
  2. 將建構函式呼叫的this指向這個新物件,並執行建構函式。
  3. 如果建構函式返回物件型別Object(包含Functoin, Array, Date, RegExg, Error等),則正常返回,否則返回這個新的物件。

依然來模擬實現一下:

function newOperator(func, ...args) {
    if (typeof func !== 'function') {
        console.error('第一個引數必須為函式,您傳入的引數為', func)
        return
    }
    // 建立一個全新物件,並將其`__proto__`屬性指向建構函式的`prototype`屬性
    let newObj = Object.create(func.prototype)
    // 將建構函式呼叫的this指向這個新物件,並執行建構函式
    let result = func.apply(newObj, args)
    // 如果建構函式返回物件型別Object,則正常返回,否則返回這個新的物件
    return (result instanceof Object) ? result : newObj
}
複製程式碼

4. Function.__proto__ === Function.prototype

其實這裡完全沒必要去糾結雞生蛋還是蛋生雞的問題,我自己的理解是:Function是原生建構函式,自動出現在執行環境中,所以不存在自己生成自己。之所以Function.__proto__ === Function.prototype,是為了表明Function作為一個原生建構函式,本身也是一個函式物件,僅此而已。

5. 真的是繼承嗎?

前面我們講到每一個物件都會從原型“繼承”屬性,實際上,繼承是一個十分具有迷惑性的說法,引用《你不知道的JavaScript》中的話,就是:

繼承意味著複製操作,然而 JavaScript 預設並不會複製物件的屬性,相反,JavaScript 只是在兩個物件之間建立一個關聯,這樣,一個物件就可以通過委託訪問另一個物件的屬性,所以與其叫繼承,委託的說法反而更準確些。

十、參考文章

深入理解javascript原型和閉包(完結)- 王福朋
JavaScript深入之從原型到原型鏈

系列文章

深入ECMAScript系列目錄地址(持續更新中...)

歡迎前往閱讀系列文章,如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

菜鳥一枚,如果有疑問或者發現錯誤,可以在相應的 issues 進行提問或勘誤,與大家共同進步。

相關文章