萬物皆空之 JavaScript 原型

ULIVZ發表於2018-02-26

本文首發於個人 Github,歡迎 issue / fxxk。

前言

ES6 的第一個版本釋出於 156 月,而本文最早創作於 16 年,那也是筆者從事前端的早期。在那個時候,ES6 的眾多特性仍處於 stage 階段,也遠沒有現在這麼普及,為了更輕鬆地寫JavaScript,筆者曾花費了整整一天,仔細理解了一下原型——這個對於一個成熟的JavaScript開發者必須要跨越的大山。

ES6帶來了太多的語法糖,其中箭頭函式掩蓋了 this 的神妙,而 class 也掩蓋了本文要長篇談論的 原型

最近,我重寫了這篇文章,通過本文,你將可以學到:

  • 如何用 ES5 模擬類;
  • 理解 prototype__proto__
  • 理解原型鏈和原型繼承;
  • 更深入地瞭解 JavaScript 這門語言。

引入:普通物件與函式物件

JavaScript 中,一直有這麼一種說法,萬物皆物件。事實上,在 JavaScript 中,物件也是有區別的,我們可以將其劃分為 普通物件函式物件ObjectFunction 便是 JavaScript 自帶的兩個典型的 函式物件。而函式物件就是一個純函式,所謂的 函式物件,其實就是使用 JavaScript模擬類

那麼,究竟什麼是普通物件,什麼又是函式物件呢?請看下方的例子:

首先,我們分別建立了三個 FunctionObject 的例項:

function fn1() {}
const fn2 = function() {}
const fn3 = new Function('language', 'console.log(language)')

const ob1 = {}
const ob2 = new Object()
const ob3 = new fn1()
複製程式碼

列印以下結果,可以得到:

console.log(typeof Object); // function
console.log(typeof Function); // function
console.log(typeof ob1); // object
console.log(typeof ob2); // object
console.log(typeof ob3); // object
console.log(typeof fn1); // function
console.log(typeof fn2); // function
console.log(typeof fn3); // function
複製程式碼

在上述的例子中,ob1ob2ob3 為普通物件(均為 Object 的例項),而 fn1fn2fn3 均是 Function 的例項,稱之為 函式物件

如何區分呢?其實記住這句話就行了:

  • 所有Function的例項都是函式物件,而其他的都是普通物件

說到這裡,細心的同學會發表一個疑問,一開始,我們已經提到,ObjectFunction 均是 函式物件,而這裡我們又說:所有Function的例項都是函式物件,難道 Function 也是 Function 的例項?

先保留這個疑問。接下來,對這一節的內容做個總結:

image_1b4867lll1fqfiqt14o17gccjb1m.png-58.3kB

從圖中可以看出,物件本身的實現還是要依靠建構函式。那 原型鏈 到底是用來幹嘛的呢?

眾所周知,作為一門物件導向(Object Oriented)的語言,必定具有以下特徵:

  • 物件唯一性
  • 抽象性
  • 繼承性
  • 多型性

而原型鏈最大的目的, 就是為了實現繼承


進階:prototype 和 __proto__

原型鏈究竟是如何實現繼承的呢?首先,我們要引入介紹兩兄弟:prototype__proto__,這是在 JavaScript 中無處不在的兩個變數(如果你經常除錯的話),然而,這兩個變數並不是在所有的物件上都存在,先看一張表:

物件型別 prototype __proto__
普通物件(NO)
函式物件(FO)

首先,我們先給出以下結論:

  1. 只有 函式物件 具有 prototype 這個屬性;
  2. prototype__proto__ 都是 JavaScript 在定義一個函式或物件時自動建立的 預定義屬性

接下來,我們驗證上述的兩個結論:

function fn() {}
console.log(typeof fn.__proto__); // function
console.log(typeof fn.prototype); // object

const ob = {}
console.log(typeof ob.__proto__); // function
console.log(typeof ob.prototype); // undefined,哇!果然普通物件沒有 prototype
複製程式碼

既然是語言層面的預置屬性,那麼兩者究竟有何區別呢?我們依然從結論出發,給出以下兩個結論:

  1. prototype 被例項的 __proto__ 所指向(被動)
  2. __proto__ 指向建構函式的 prototype(主動)

哇,也就是說以下程式碼成立:

console.log(fn.__proto__ === Function.prototype); // true
console.log(ob.__proto__ === Object.prototype); // true
複製程式碼

看起來很酷,結論瞬間被證明,感覺是不是很爽,那麼問題來了:既然 fn 是一個函式物件,那麼 fn.prototype.__proto__ 到底等於什麼?

這是我嘗試去解決這個問題的過程:

  1. 首先用 typeof 得到 fn.prototype 的型別:"object"
  2. 哇,既然是 "object",那 fn.prototype 豈不是 Object 的例項?根據上述的結論,快速地寫出驗證程式碼:
console.log(fn.prototype.__proto__ === Object.prototype) // true
複製程式碼

接下來,如果要你快速地寫出,在建立一個函式時,JavaScript對該函式原型的初始化程式碼,你是不是也能快速地寫出:

// 實際程式碼
function fn1() {}

// JavaScript 自動執行
fn1.protptype = {
    constructor: fn1,
    __proto__: Object.prototype
}

fn1.__proto__ = Function.prototype
複製程式碼

到這裡,你是否有一絲恍然大悟的感覺?此外,因為普通物件就是通過 函式物件 例項化(new)得到的,而一個例項不可能再次進行例項化,也就不會讓另一個物件的 __proto__ 指向它的 prototype, 因此本節一開始提到的 普通物件沒有 prototype 屬性 的這個結論似乎非常好理解了。從上述的分析,我們還可以看出,fn1.protptype 就是一個普通物件,它也不存在 protptype 屬性。

再回顧一下上一節,我們還遺留一個疑問:

  • 難道 Function 也是 Function 的例項?

是時候去掉應該讓它成立了。那麼此刻,please show me your code!

檢視答案
console.log(Function.__proto__ === Function.prototype) // true
複製程式碼

重點:原型鏈

上一節我們詳解了 prototype__proto__,實際上,這兩兄弟主要就是為了構造原型鏈而存在的。

先上一段程式碼:

const Person = function(name, age) {
    this.name = name
    this.age = age
} /* 1 */

Person.prototype.getName = function() {
    return this.name
} /* 2 */

Person.prototype.getAge = function() {
    return this.age
} /* 3 */

const ulivz = new Person('ulivz', 24); /* 4 */

console.log(ulivz) /* 5 */
console.log(ulivz.getName(), ulivz.getAge()) /* 6 */
複製程式碼

解釋一下執行細節:

  1. 執行 1,建立了一個建構函式 Person,要注意,前面已經提到,此時 Person.prototype 已經被自動建立,它包含 constructor__proto__這兩個屬性;
  2. 執行2,給物件 Person.prototype 增加了一個方法 getName()
  3. 執行3,給物件 Person.prototype 增加了一個方法 getAge()
  4. 執行4, 由建構函式 Person 建立了一個例項 ulivz,值得注意的是,一個建構函式在例項化時,一定會自動執行該建構函式。
  5. 在瀏覽器得到 5 的輸出,即 ulivz 應該是:
{
     name: 'ulivz',
     age: 24
     __proto__: Object // 實際上就是 `Person.prototype`
}
複製程式碼

結合上一節的經驗,以下等式成立:

    console.log(ulivz.__proto__ == Person.prototype)  // true
複製程式碼
  1. 執行6的時候,由於在 ulivz 中找不到 getName()getAge() 這兩個方法,就會繼續朝著原型鏈向上查詢,也就是通過 __proto__ 向上查詢,於是,很快在 ulviz.__proto__ 中,即 Person.prototype 中找到了這兩個方法,於是停止查詢並執行得到結果。

這便是 JavaScript 的原型繼承。準確的說,JavaScript 的原型繼承是通過 __proto__ 並藉助 prototype 來實現的。

於是,我們可以作如下總結:

  1. 函式物件的 __proto__ 指向 Function.prototype;(複習)
  2. 函式物件的 prototype 指向 instance.__proto__;(複習)
  3. 普通物件的 __proto__ 指向 Object.prototype;(複習)
  4. 普通物件沒有 prototype 屬性;(複習)
  5. 在訪問一個物件的某個屬性/方法時,若在當前物件上找不到,則會嘗試訪問 ob.__proto__, 也就是訪問該物件的建構函式的原型 obCtr.prototype,若仍找不到,會繼續查詢 obCtr.prototype.__proto__,像依次查詢下去。若在某一刻,找到了該屬性,則會立刻返回值並停止對原型鏈的搜尋,若找不到,則返回 undefined

為了檢驗你對上述的理解,請分析下述兩個問題:

  1. 以下程式碼的輸出結果是?
console.log(ulivz.__proto__ === Function.prototype)
複製程式碼
檢視結果 false

  1. Person.__proto__Person.prototype.__proto__ 分別指向何處?
檢視分析

前面已經提到,在 JavaScript 中萬物皆物件。Person 很明顯是 Function 的例項,因此,Person.__proto__ 指向 Function.prototype

console.log(Person.__proto__ === Function.prototype)  // true
複製程式碼

因為 Person.prototype 是一個普通物件,因此 Person.prototype.__proto__ 指向Object.prototype

console.log(Person.prototype.__proto__ === Object.prototype)  // true
複製程式碼

為了驗證 Person.__proto__ 所在的原型鏈中沒有 Object,以及 Person.prototype.__proto__ 所在的原型鏈中沒有 Function, 結合以下語句驗證:

console.log(Person.__proto__ === Object.prototype) // false
console.log(Person.prototype.__proto__ == Function.prototype) // false
複製程式碼

終極:原型鏈圖

上一節,我們實際上還遺留了一個疑問:

  • 原型鏈如果一個搜尋下去,如果找不到,那何時停止呢?也就是說,原型鏈的盡頭是哪裡?

我們可以快速地利用以下程式碼驗證:

function Person() {}
const ulivz = new Person()
console.log(ulivz.name) 
複製程式碼

很顯然,上述輸出 undefined。下面簡述查詢過程:

ulivz                // 是一個物件,可以繼續 
ulivz['name']           // 不存在,繼續查詢 
ulivz.__proto__            // 是一個物件,可以繼續
ulivz.__proto__['name']        // 不存在,繼續查詢
ulivz.__proto__.__proto__          // 是一個物件,可以繼續
ulivz.__proto__.__proto__['name']     // 不存在, 繼續查詢
ulivz.__proto__.__proto__.__proto__       // null !!!! 停止查詢,返回 undefined
複製程式碼

哇,原來路的盡頭是一場空。

最後,再回過頭來看看上一節的那演示程式碼:

const Person = function(name, age) {
    this.name = name
    this.age = age
} /* 1 */

Person.prototype.getName = function() {
    return this.name
} /* 2 */

Person.prototype.getAge = function() {
    return this.age
} /* 3 */

const ulivz = new Person('ulivz', 24); /* 4 */

console.log(ulivz) /* 5 */
console.log(ulivz.getName(), ulivz.getAge()) /* 6 */
複製程式碼

我們來畫一個原型鏈圖,或者說,將其整個原型鏈圖畫出來?請看下圖:

原型鏈.png-41.2kB

PS:手賤把chl(我的中文名縮寫)改成了 ulivz(Github名),所以這張圖中的chl實際上就是ulivz,畫這張圖的時候, 我還在用windows = =

畫完這張圖,基本上所有之前的疑問都可以解答了。

與其說萬物皆物件, 萬物皆空似乎更形象。


調料:constructor

前面已經有所提及,但只有原型物件才具有 constructor 這個屬性,constructor用來指向引用它的函式物件。

Person.prototype.constructor === Person //true
console.log(Person.prototype.constructor.prototype.constructor === Person) //true
複製程式碼

這是一種迴圈引用。當然你也可以在上一節的原型鏈圖中畫上去,這裡就不贅述了。


補充: JavaScript中的6大內建(函式)物件的原型繼承

通過前文的論述,結合相應的程式碼驗證,整理出以下原型鏈圖:

image_1b496ie7el7m1rvltoi17he1b459.png-52.6kB

由此可見,我們更加強化了這兩個觀點:

  1. 任何內建函式物件(類)本身的 __proto__ 都指向 Function 的原型物件;
  2. 除了 Oject 的原型物件的 __proto__ 指向 null,其他所有內建函式物件的原型物件的 __proto__ 都指向 object

為了減少讀者敲程式碼的時間,特給出驗證程式碼,希望能夠促進你的理解。

Array:

    console.log(arr.__proto__)
    console.log(arr.__proto__ == Array.prototype)   // true 
    console.log(Array.prototype.__proto__== Object.prototype)  // true 
    console.log(Object.prototype.__proto__== null)  // true 
複製程式碼

RegExp:

    var reg = new RegExp;
    console.log(reg.__proto__)
    console.log(reg.__proto__ == RegExp.prototype)  // true 
    console.log(RegExp.prototype.__proto__== Object.prototype)  // true 
複製程式碼

Date:

    var date = new Date;
    console.log(date.__proto__)
    console.log(date.__proto__ == Date.prototype)  // true 
    console.log(Date.prototype.__proto__== Object.prototype)  // true 
複製程式碼

Boolean:

    var boo = new Boolean;
    console.log(boo.__proto__)
    console.log(boo.__proto__ == Boolean.prototype) // true 
    console.log(Boolean.prototype.__proto__== Object.prototype) // true 
複製程式碼

Number:

    var num = new Number;
    console.log(num.__proto__)
    console.log(num.__proto__ == Number.prototype)  // true 
    console.log(Number.prototype.__proto__== Object.prototype)  // true 
複製程式碼

String:

    var str = new String;
    console.log(str.__proto__)
    console.log(str.__proto__ == String.prototype)  // true 
    console.log(String.prototype.__proto__== Object.prototype)  // true 
複製程式碼

總結

來幾句短總結:

  1. A 通過new建立了B,則 B.__proto__ = A.prototype
  2. __proto__是原型鏈查詢的起點;
  3. 執行B.a,若在B中找不到a,則會在B.__proto__中,也就是A.prototype中查詢,若A.prototype中仍然沒有,則會繼續向上查詢,最終,一定會找到Object.prototype,倘若還找不到,因為Object.prototype.__proto__指向null,因此會返回undefined
  4. 為什麼萬物皆空,還是那句話,原型鏈的頂端,一定有Object.prototype.__proto__ ——> null

最後,給你留下一個疑問:

  • 如何用 JavaScript 實現類的繼承呢?

請看我的原型系列的下一篇《深入JavaScript繼承原理》 分曉。

以上,全文終。

注:此外本文屬於個人總結,部分表達可能會有疏漏之處,如果您發現本文有所欠缺,為避免誤人子弟,請放心大膽地在評論中指出,或者給我提 issue,感謝~

相關文章