Web瞎搗鼓——JavaScript繼承與原型鏈

原先準備寫HTML<meta>標籤,在整理資料的時候發現網上好多標榜“全網最全”,最後發現確實也就那麼點事兒。於是開始籌備另一篇關於CSS實戰和Hack相關的東西,材料準備到一半又發現大都與IE低版本有關。說到底,這些所謂的兼容問題其實就是瀏覽器Bug,特別IE低版本藉着系統的優勢自搞一套與標準相悖的東西。現在Web應用迅猛發展,瀏覽器爲了競爭市場份額不斷改進,對CSS的兼容處理已有了長足的進步。雖說如此,在手機瀏覽器方面,還是有廠商靠着系統屏障搞事情,做過手機應用的朋友自然明白。不過,悲哀的是在這個HTML5+CSS3的新標準時代我們還是不得不做一些低版本的兼容處理。抵制這些脫離時代的瀏覽器從我做起,作爲程序員我還是決定把重心放在JavaScript上,至於CSS,現代瀏覽器已經幫了我們很多。

從標題看,重點應該是原型鏈,關於這個主題,隨便百度一下,前十頁就能找出基本不重樣的文章。況且,我之前在文章用自己的方式寫過,爲什麼我還要單獨寫一篇這個題材呢?JavaScript比較難理解的有就這麼幾個:閉包、原型鏈、事件循環。原型鏈不難懂,無非就是一層一層的關係鏈,直到沒有爲止,我幹了這麼些年的前端也沒發現它對我有啥影響。之前還在猶豫,要不要寫?怎麼寫?和其它內容又來一篇大鍋燴?前些天,我看class關鍵字的時候突然有個疑問:它和構造函數寫出來的繼承有差別嗎?

於是,我查了一下繼承的方式,原來五花八門的寫法還蠻多,且優點缺點各不相同。之前也看過,並沒深入去理解,實際工作中幾乎用不到。這次特別注意了各種寫法,就牽扯到原型鏈的問題,而對於我這種平時繼承都很少用到的人自然也不會關心。在研究的過程中,我發現自己之前的有些理解不太準確,後文會提到。再加上查閱一些文章後,又有了許多不同的看法,於是這篇文章並沒有被捨棄。

面向對象

繼承是面向對象編程的三大特徵之一。JavaScript沒有類的概念,JavaScript之父認爲增加了類的概念就會讓這門腳本語言變得複雜,變得更像面向對象的語言。爲了讓JavaScript更容易入手,於是設計了原型鏈,即便是ES6的class關鍵字也只不過是一個語法糖而已。在介紹繼承之前,先提一下面向對象的三大特徵:封裝、繼承和多態。

封裝

封裝是面向對象的重要原則,就是把對象的行爲和特徵結合成一個整體,並將一些內部實現細節儘可能隱藏起來,對外暴露可訪問的接口。對於一般的JavaScript代碼來說,其實沒有什麼實現細節能藏得住(當然,可以經過加密等特殊處理後變得無法解讀,這也讓其變得患得患失)。

單純的從封裝來說,在日常工作中使用算是比較頻繁的,好的封裝事半功倍,差的封裝反而搞得不好維護。稍微好一點的頂多就是代碼不夠健壯容易出狀況,差得不行的就封裝之人用過一次再無人問津,不乏一些對封裝理解不夠胡亂封裝一氣的人。

繼承

繼承就是子類繼承父類的行爲和特徵,使得子類在擁有獨立的行爲特徵的同時也具有父類的部分行爲特徵。爲什麼只能說是部分?JavaScript相對難理解一點,對於它來說差別只在於私有和公共的差別:要麼誰也拿不到,要麼都可以拿到。而對於面向對象的語言來說,父類的行爲會被限制,並不是所有的成員都能被實例對象訪問到。

說到繼承,我看到有一種非構造函數的繼承,也就是Object對象的繼承。我個人認爲,這並構不成繼承。衆所周知,JavaScript的繼承是靠原型鏈來完成,而Object對象的prototype指向null,也就是說它沒有繼承功能。網上給出的關於Object對象的繼承方式有兩種:一種是通過Object.create()或者工廠函數;另一種就是通過拷貝。前一種是函數的能力,只能說是兩個對象變成了父子關係的屬性和方法,似乎並不是對象之間的繼承。後一種,恕我才疏學淺,這就是對象合併,我實在搞不懂這繼承,個人覺得這應該算是mixin。當然,函數的繼承也是原型對象之間的文章,大概是JavaScript沒有類的概念導致我有這樣的認爲。

混合(Mixin )—— 將不同種類的不同的行爲特徵抽象出來賦予某類事物。

繼承(Inheritence)—— 將具有相同行爲特徵抽象成同類事物,某類事物從這個事物上獲取共性。

舉個例子:鳥有會飛這個行爲,有翅膀、羽毛等特徵,具有這些特徵的歸爲鳥類。人有會走路這個行爲,有思維、會創造、能講話等特徵,具有這些特徵的歸爲人類。人們想象中,天使有人類的特徵和行爲,又有鳥類的行爲和特徵。用繼承的方式:她要繼承人類的共性,又要繼承鳥類的共性,這就是多重繼承。然而,天使是純潔的,人類是貪婪的,這並不是想要的結果。那麼,最好的結果就是人類和鳥類只取需要的部分融合進天使的屬性和特徵中,這就是混合。

混合解決的就是多重繼承帶來的不必要的負重。

多態

多態是同一個行爲具有多個不同表現形式或形態的能力。舉個例子來說,不同的動物都有喫這個行爲,但是不同的動物喫不同的食物。那麼在定義類的時候就只是定義喫這個行爲但不能實現具體的行爲,具體行爲由子類自己定義,這就是多態。某一些語言的接口有些類似,比如TypeScript的interface。實際上,拿interface舉例,不如直接拿它的抽象類舉例再合適不過。JavaScript並沒有類的概念,也就沒有多態這個特徵,能實現這個動作但有點多餘。

繼承

在開始介紹繼承方法之前,先看下new關鍵字做了什麼?

function new (fn, ...args) {
  // 1、創建一個空對象,作爲將要返回的對象實例
  let obj  = {}

  // 2、將這個空對象的原型,指向構造函數的prototype屬性
  obj.__proto__ = fn.prototype

  // 3、將這個空對象賦值給函數內部的this關鍵字
  // 4、開始執行構造函數內部的代碼
  const instance = fn.apply(obj, args)

  // 5、返回對象的地址
  return instance instanceof Object ? instance : obj
}

爲什麼要說new關鍵字?一是與上文的對象繼承有關,對象的繼承方式的第一種方法相當於就是在做new的操作。二是與下文的繼承有關,從上面可以看出原型鏈上使用new後之後會有怎樣的結果。

關於繼承的方式,直接上代碼,至於它們的優缺點就省略了,從代碼中也可以看出一二。

// * 標識位置是缺陷所在,未標識的有相對完美,也有莫名其妙,自己去思考
// 繼承方式名有部分是我自己定義的,不要按圖索驥,看實現就好
// 下面的代碼放在一起是執行有問題的,因爲構造函數的原型對象或許在某處已經被更改

// 先創建一個父類
function Job(name) {
  // 屬性
  this.name = name

  // 實例方法
  // 這不是一個好的書寫習慣,除非要訪問私有變量,最好不用這種寫法
  // 爲什麼?因爲每次new都會增加一個副本,我的理解:它其實是一個閉包
  this.work = function() {
    console.log(`${this.name} is working.`)
  }
}

// 原型方法
Job.prototype.info = function() {
  console.log(`His job is ${this.name}.`)
}

// 原型鏈繼承
function Teacher(name) {
  this.name = name
}

Teacher.prototype = new Job()  // *
Teacher.prototype.constructor = Teacher

const teacher = new Teacher('Teacher')
console.log(teacher instanceof Teacher)  // true
console.log(teacher instanceof Job)  // true

// 構造函數繼承
function Doctor(name) {
    Job.call(this, name)  // *
}

const doctor = new Doctor('Doctor')
console.log(doctor instanceof Doctor)  // true
console.log(doctor instanceof Job)  // false

// 原型繼承
function Farmer(name) {
  this.name = name
}

Farmer.prototype = Job.prototype  // *
Farmer.prototype.constructor = Farmer

const farmer = new Farmer('Farmer')
console.log(farmer instanceof Farmer)  // true
console.log(farmer instanceof Job)  // true

// 組合繼承
function Soldier(name) {
  Job.call(this, name)
}
(function() {
  function Super() {}
  Super.prototype = Job.prototype
  Soldier.prototype = new Super()
  Soldier.prototype.constructor = Soldier
})();
const soldier = new Soldier('Soldier')
console.log(soldier instanceof Soldier)  // true
console.log(soldier instanceof Job)  // true

// 拷貝繼承
function deepCopy(p, c) {
  c = c || {}
  for (let k in p) {
    if (typeof p[k] === 'object') {
      c[k] = (p[k].constructor === Array) ? [] : {}
      deepCopy(p[k], c[k])
    } else {
      c[k] = p[k]
    }
  }
  return c
}

function Scientist(name) {
  this.name = name
}

deepCopy(Job.prototype, Scientist.prototype)  // *

const scientist = new Scientist('Scientist')
console.log(scientist instanceof Scientist)  // true
console.log(scientist instanceof Job)  // false

// 工廠函數繼承
function create(obj) {
  function F() {}
  F.prototype = obj
  return new F()
}

const instance = new Job('Programmer')
const programmer = create(instance)
console.log(programmer instanceof Job)  // true

原型鏈

當然,繼承裏面少不了class關鍵字,無論從哪個方面上都是比較完美的。代碼邏輯更加清晰,不再像上面的繼承方式奇形怪狀,程序員可以將重心移到邏輯代碼上。還未部署ES6的瀏覽器確實讓人頭疼,還好可以使用polyfill或者轉換工具來放心使用ES6語法。

class Animal {
  static species = '動物'

  constructor() {}

  static getSpecies() {
    return this.species
  }
}

class Dog extends Animal {
  constructor(name) {
    super()

    this.name = name
  }

  getAction() {
    return `${this.name}啃骨頭`
  }
}

const dog = new Dog('金毛')

class關鍵字並沒有脫離原型鏈,所以以此爲例介紹原型鏈。

在開始原型鏈之前,先了解一下幾個概念(這是我個人理解,不代表就對,要求真須閱讀標準):

constructor:構造函數引用。

prototype:構造函數的原型對象,默認包含__proto__和constructor。

__proto__:實例對象私有屬性,指向它構造函數的原型對象。

下面先把這三個屬性打印出來,看看其中的隱藏的奧義。

console.log(dog.constructor)
// class Dog extends Animal { ...code }
console.log(dog.prototype)
// undefined
console.log(dog.__proto__)
// Animal { constructor: class Dog, getAction: ƒ getAction(), __proto__: Object }

console.log(Dog.constructor)
// ƒ Function() { [native code] }
console.log(Dog.prototype)
// Animal { constructor: class Dog, getAction: ƒ getAction(), __proto__: Object }
console.log(Dog.__proto__)
// class Animal { ...code }

console.log(Animal.constructor)
// ƒ Function() { [native code] }
console.log(Animal.prototype)
// Object { constructor: class Animal, __proto__: Object }
console.log(Animal.__proto__)
// ƒ () { [native code] }

下面觀察一下上面的輸出:dog.__proto__與Dog.prototype是一致的,說明dog.__proto__是指向Dog.prototype的。接下來,要看看原型鏈上Dog和Animal是如何連接的:

console.log(Dog.prototype.__proto__)
// { constructor: class Animal, __proto__: Object }
console.log(Animal.prototype.__proto__)
// { constructor: ƒ Object(), ...code }

很明顯,Dog.prototype.__proto__指向Animal.prototype,Animal.prototype.__proto__指向Object.prototype,Object.prototype.__proto__最終指向null。那麼問題來了,爲什麼去找Dog.prototype.__proto__,而不是Dog.__proto__呢?從prototype的概念去理解,prototype是構造函數的原型對象,而Dog是構造dog的函數。直接打印dog,看看是不是這麼回事:

那麼Dog.__proto__又是怎麼回事?dog的打印中展開constructor: class Dog,Dog.__proto__在constructor這裏,直接打印Dog是看不到這個細節,打印出來的就是代碼本身。

Dog是函數,函數也有原型鏈?先賣個關子,看看Function和Object的情況:

const Func = new Function()
const func = new Func()

console.log(func.constructor)
// ƒ anonymous() {}
console.log(func.prototype)
// undefined
console.log(func.__proto__)
// { constructor: ƒ anonymous( ), __proto__: Object }

console.log(Func.constructor)
// ƒ Function() { [native code] }
console.log(Func.prototype)
// { constructor: ƒ anonymous( ), __proto__: Object }
console.log(Func.__proto__)
// ƒ () { [native code] }


const obj = new Object()

console.log(obj.constructor)
// ƒ Object() { [native code] }
console.log(obj.prototype)
// undefined
console.log(obj.__proto__)
// { constructor: ƒ Object(), ...code }

console.log(Object.constructor)
// ƒ Function() { [native code] }
console.log(Object.prototype)
// { constructor: ƒ Object(), ...code }
console.log(Object.__proto__)
// ƒ () { [native code] }

console.log(Object.prototype.__proto__)
// null

通過function、Function、Object構造的實例對象,沿着原型對象往上找,直到null爲止,這就是原型鏈。

// dog.__proto__ -> Dog.prototype
// Dog.prototype.__proto__ -> Animal.prototype
// Animal.prototype.__proto__ -> Object.prototype
// Object.prototype.__proto__ -> null

// func.__proto__ -> Func.prototype
// Func.prototype.__proto__ -> Object.prototype
// Object.prototype.__proto__ -> null

// obj.__proto__ -> Object.prototype
// Object.prototype.__proto__ -> null

在前面一連串的打印中,我發現一個有意思的問題:function、Function、Object都是函數,又似乎都是對象;它們的constructor都是Function,__proto__都指向一個匿名函數。下面開始我的推理秀:初始的Function沒有任何屬性,當使用new關鍵字的時候,構造函數Function內部經過一系列處理然後返回一個對象。如果是Function關鍵字,則加上prototype屬性變爲函數對象,如果是Object則不加成爲實例對象。那麼function呢?function是字面量,它相當於就是new Function()。

最後

所以,結論呢?

結論就是JavaScript一切皆對象,函數也是。只不過函數擁有了更多的權限,所以它是一等公民。函數之所以不再是傳統意義的函數,是因爲它揹負了很多責任,它要擔當類的使命。

此前寫的文章,我認爲“Function對象相當於加工廠,constructor最終指向它,function負責代言,Object負責發言”“Function創造了Function”。然而,它可能從出場就是對象,並沒有創造,只是在加關鍵字而已,最後讓編譯器來處理它們的關係。

有人認爲,Function構造了Object,Object又構造了Function。我認爲是不對的,從原型鏈關係上看Object的構造函數確實是Function。關於Function爲什麼是一個object結構,其實很好理解。JavaScript沒有類的概念,作者設計了原型鏈這個規則,這個前面已經說過。這種鏈式結構很明顯不符合傳統函數的模式,Function原本就是object,不存在Object構造Function的說法。object就是一種數據類型,Object函數用來構造這種數據,僅此而已。

當然,這是我的個人理解而已,要完全解釋清楚,估計得看JS引擎源碼了,可惜我看不懂。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章