【JavaScript】對象、原型、原型鏈迷惑行爲大賞

​在如今的業務開發中,應該不多人在寫原生JavaScript了,你們都一股腦地撲在各個框架上。原本,這些框架對於業務和開發者來講是一種福音,減小了各類各樣的開發痛點,可是帶來的負面問題就是對於開發者來講,愈來愈依賴框架,離原生JavaScript愈來愈遠,對基礎知識的記憶和理解慢慢地模糊、淡忘。javascript

而原型、原型鏈就是其中之一。前端

更使人感到煎熬的是,平時開發已經幾乎用不到了,可是面試中幾乎每一個面試官都會問到。結果即是在面試以前臨時抱佛腳地看一看書或者是網上找幾篇文章複習一下,可是因爲平時用得太少了,這樣的複習也只是走馬觀花,面試官稍微多問幾句就背不住了。java

基於上述緣由,加上對象、原型、原型鏈自己在JavaScript中就有一些讓人比較難理解的點,今天咱們來看看這些內容的迷惑行爲。面試

對象

要說原型和原型鏈,就得先說說對象。ECMA-262把對象定義成「無序屬性的集合,其屬性能夠包含基本值、對象或者函數」,咱們能夠很容易把對象理解成散列表,其實就是一組鍵值對,鍵是字符串或Symbol,值是數據或者函數。瀏覽器

而對象有以下特色:babel

  • 惟一標識性:內容相同也不是同一個對象
  • 有狀態:同一個對象不一樣時刻可能有不一樣的內容
  • 有行爲:對象的狀態,可能由於行爲而變遷

惟一標識性很好理解,你們都知道在JavaScript中,辨認兩個引用類型是否相同是看它們的內存地址是否相同,所以任意聲明兩個內容相同的變量,它們的內存地址確定是不一樣的。框架

而狀態和行爲,在JavaScript中,都被稱爲屬性。由於對象具備高度的動態性,咱們在任什麼時候候都能改變屬性的值,同時,咱們能夠在對象的函數屬性中修改對象的值屬性或者是在聲明對象以後再增長屬性:函數

var obj = {
  a: 1,
  b () {
    this.a = 2
  }
}
obj.b() // obj.a的值被改爲2
​
obj.c = 3
console.log(obj.c) // 3

以前說到,咱們能夠把JavaScript對象的內容理解成簡單的鍵值對,可是當JavaScript引擎處理對象的時候卻不是這麼的簡單,相信你們都知道對象有兩種屬性:數據屬性和訪問器屬性。this

數據屬性

數據屬性有四種特性:spa

  • [[configurable]]:可否配置當前屬性,包括可否用delete刪除、可否修改屬性的特性、可否改爲訪問器屬性等
  • [[enumerable]]:可否經過for-in遍歷當前屬性
  • [[writable]]:可否修改當前屬性的值
  • [[value]]:保存當前屬性的值

在初始化一個對象時,前三個特性的值,默認爲true。

訪問器屬性

訪問器屬性也有四種特性:

  • [[configurable]]:同數據屬性的configurable,默認爲true
  • [[enumerable]]:同數據屬性的enumerable,默認爲true
  • [[getter]]:函數或undefined,取對象的屬性的值時調用
  • [[setter]]:函數或undefined,設置對象的屬性的值時調用

咱們能夠經過Object.getOwnPropertyDescriptor(o)或Object.getOwnPropertyDescriptors(o, key)獲取對象的屬性描述。當咱們用字面量聲明一個對象時,默認地使用的是數據屬性。同時,也能夠經過訪問器屬性聲明變量:

var o = {
  get a () {
    return 1
  }
}
​
o.a // 1

能夠看到,不管用哪一種方式聲明變量,其結果都是同樣的。所以在JavaScript運行時,對象能夠看作是屬性的集合。

而在其它語言中,是用「類」的方式來描述對象的。但是JavaScript中卻並無「類」的概念,即使ES6提供了class關鍵字來聲明類,但其實也只是語法糖罷了。

在前人的探索下,JavaScript基於本身的動態對象系統模型,設計了本身的原型系統用來模擬類的行爲。

建立對象

在平時的開發中,最多見的建立對象的方式就是使用對象字面量,可是這就帶來了一個問題,若是要建立重複的、相同內容的對象,就須要在多個地方寫重複的、內容相同的代碼,這會使得代碼難以維護。
在JavaScript中,有如下幾種常見的建立對象的方式

工廠函數模式

所謂工廠函數,"就是指這些內建函數都是類對象,當你調用他們時,其實是建立了一個類實例",可是JavaScript中沒有真正的類,可是能夠用對象代替:

function createPerson (name, age, job) {
  var o = new Object()
  
  o.name = name
  o.age = age
  o.job = job
  o.sayName = function () {
    console.log(this.name)
  }
}
​
var person1 = createPerson('Alex', 20, 'Programmer')
var person2 = createPerson('Bob', 25, 'Product Manager')

咱們能夠屢次調用這個函數,每次返回的都是不同的對象,可是,這個方法卻沒法解決對象識別的問題,即怎樣知道一個對象的類型。

構造函數模式

這個模式也熟爲人知:

function Person (name, age, job) {
  this.name = name
  this.age = age
  this.job = job
  this.sayName = function () {
    console.log(this.name)
  }
}
​
var person1 = new Person('Alex', 20, 'Programmer')
var person2 = new Person('Bob', 25, 'Product Manager')

這裏就涉及到了new操做符的執行過程:

  • 建立一個新對象
  • 將當前函數的做用域賦給這個新對象
  • 執行構造函數中的代碼
  • 若是沒有顯式返回一個對象,那麼就返回這個新對象

之前我很難記住這個過程,可是若是先理解了工廠函數的出現緣由、執行原理以及缺點,這時再來理解new操做符就會很簡單了。

由於咱們最終須要一個對象,因此第一步就得先建立一個對象;而後由於在後面的代碼裏咱們會使用到this.xx這樣的代碼去把屬性賦值給this對象,實際上是想把屬性賦值給最終返回的對象,所以就須要綁定做用域,即,將當前函數的做用域賦值給新對象;前兩步是準備工做,作完了以後就能夠執行構造函數中的代碼了;最後,開發者寫的代碼的優先級要高於JS引擎,也就是說若是代碼中return了對象的話,那麼就不須要再返回建立的對象了,不然就把建立的對象返回。

用構造函數建立的對象,可使用instanceof操做符判斷其類型:

console.log(person1 instanceof Person) // true
console.log(person2 instanceof Person) // true

這也是構造函數模式比工廠模式更好的地方。然鵝,構造函數也有本身的問題。你們都能看出來,每次都會對實例對象新增一個sayName方法,但其實這並沒必要要。雖然能夠把方法名寫在構造函數以外:

function Person (name, age, job) {
  this.name = name
  this.age = age
  this.job = job
  this.sayName = sayName
}
​
function sayName () {
  console.log(this.name)
}

但這樣會把sayName變成全局函數,很容易被別處代碼誤用,並且若是有多個類型都有同名函數,那就會有衝突。此時,原型閃亮登場了。

原型模式

原型你們都聽過,也瞭解過,但到底什麼是原型呢?

咱們所熟知的各類語言,都會用各類不一樣的語言特性來抽象對象。使用最普遍和接受度最高的應該是基於類的抽象了,C++、Java等流行語言都是基於類的。在這種抽象方式中,開發者更關注類與類之間的關係。在這類語言中,老是先有類,再從類去實例化一個對象。而類與類之間又會有繼承、組合等關係。
而還有一種就是基於原型,JavaScript就是其中之一。這類語言更提倡關注對象實例的行爲,而後才關注如何將這些對象關聯到使用方式類似的原型對象,而不是將它們分紅類。

原型對象

圖片截取自《JavaScript高級程序設計》

上面這張圖你們確定很熟悉,單純地背下來是沒有意義的。將這張圖和上述的代碼相結合,就會發現理解原型也很簡單。

首先咱們有一個構造函數Person,在建立這個函數的時候,會爲這個函數建立一個prototype屬性,這個屬性指向函數的原型對象。而默認狀況下,原型對象會有一個constructor屬性,其值會指向構造函數Person。在構造函數內部,能夠爲原型對象添加其它屬性和方法。

當調用構造函數實例化一個對象時,這個對象的內部會有一個指針指向構造函數的原型,而這個指針就是[[prototype]],在不少瀏覽器中,支持一個屬性叫__proto__來訪問[[prototype]]。
此時,兩個實例並無屬性和方法,可是卻能夠調用到sayName方法,這是經過查找對象屬性的過程實現的,即如今實例上找,若是沒有,就在實例的原型上找,若是尚未,再往上的原型找,直到找到對應的方法/屬性,若是找不到就返回undefined。這也是多個對象實例共享原型所保存的屬性和方法的基本原理。

function Person () {}
​
Person.prototype.name = 'Alex'
Person.prototype.age = 20
Person.prototype.job = 'Programmer'
​
var p1 = new Person()
var p2 = new Person()
​
p1.name = 'Bob'
console.log(p1.name) // Bob
console.log(p2.name) // Alex

這裏修改了p1.name,可是p2.name仍然是初始化時的值。是由於p1.name是在實例對象上新增了一個屬性,而p2.name仍然訪問的是原型上的屬性。即,在實例上的屬性會屏蔽掉原型上的同名屬性。

原型與in操做符

有兩種方式使用in操做符,直接使用和for-in中使用:

function Person () {}
​
Person.prototype.name = 'Alex'
​
var p = new Person()
​
p.age = 20
​
console.log('name' in p) // true
console.log('age' in p) // true

經過上述代碼能夠看出來,不管屬性是在實例上仍是原型上,in操做符均可以訪問到。

Object有一個原型方法是hasOwnProperty()用來判斷屬性是否存在於實例中。所以經過hasOwnProperty()和in操做符來判斷屬性是否爲原型屬性。

而在for-in循環中,返回的是全部可以經過對象訪問的、可枚舉的屬性,這既包括實例屬性也包括原型屬性:

function Person () {}
​
Person.prototype.name = 'Alex'
​
var p = new Person()
​
Object.defineProperty(p, 'age', {
  configurable: true,
  enumerable: false,
  writable: true,
  value: 20
})
​
p.job = 'Programmer'
​
for (let key in p) {
  console.log(key) // job name
}

能夠看到,若是屬性是不可枚舉了,在for-in中確實不會輸出了。

而若是隻想取得實例上全部的可枚舉屬性,可使用Object.keys():

console.log(Object.keys(p)) // ["job"]

若是想獲取全部實例屬性,不管是否能夠枚舉,可使用Object.getOwnPropertyNames():

​console.log(Object.getOwnPropertyNames(p)) // ["age", "job"]
console.log(Object.getOwnPropertyNames(Person)) // ["length", "name", "arguments", "caller", "prototype"]

更簡單的原型語法

按照上面的方法,若是須要在原型上添加多個屬性或者方法,那麼就要寫多個Person.prototype.xx = yy,這對於追求簡潔代碼的程序猿來講簡直就是災難,所以咱們能夠用對象字面量來代替:

function Person () {}
​
Person.prototype = {
  name: 'Alex',
  age: 20,
  job: 'Programmer',
  sayName: function () { console.log(this.name) }
}

但這又引入了一個問題,那就是Person的原型對象被一個普通對象代替了,結果就是constructor屬性再也不指向Person了。所以咱們須要顯式地把constructor再設置正確,而且要讓constructor不可枚舉:

Object.defineProperty(Person, 'constructor', {
  enumerable: false,
  value: Person
})

原型的動態性

咱們知道,能夠先實例化對象,再向原型上添加屬性或方法,這是由於咱們在調用實例的屬性或方法時,JS引擎先在實例上尋找,若是沒有再在原型上尋找。這很容易理解,可是若是是重寫整個原型對象,狀況又不同了:

function Person () {}
​
var p = new Person()
​
Person.prototype = {
  constructor: Person,
  name: 'Alex',
  sayName: function () {
    console.log(this.name)
  }
}
​
console.log(p.sayName()) // Uncaught TypeError: p.sayName is not a function

這是由於,新的原型對象並非以前的原型對象了,重寫原型對象切斷了現有原型與任何以前已經存在的對象實例之間的聯繫,這些實例使用的仍然是最初的原型。

原型對象的問題

原型對象的問題不易被發現,但倒是很容易踩中這個坑,那就是當有屬性是引用類型時,一個實例對原型屬性的修改會影響到全部實例:

function Person () {}
​
Person.prototype = {
  constructor: Person,
  name: 'Alex',
  age: 20,
  friends: ['Bob', 'Cindy']
}
​
var p1 = new Person()
var p2 = new Person()
​
p1.friends.push('David')
console.log(p1.friends) // ["Bob", "Cindy", "David"]
console.log(p2.friends) // ["Bob", "Cindy", "David"]

構造函數+原型模式

這是最多見的自定義類型方式,構造函數用於定義實例屬性,而原型模式用於定義方法和共享的屬性:

function Person (name, age, job) {
  this.name = name
  this.age = age
  this.job = job
  this.friends = ['Bob', 'Cindy']
}
​
Person.prototype = {
  constructor: Person,
  sayName: function () {
    console.log(this.name)
  }
}
​
var p1 = new Person()
var p2 = new Person()
​
p1.friends.push('David')
console.log(p1.friends) // ["Bob", "Cindy", "David"]
console.log(p2.friends) // ["Bob", "Cindy"]

另外,經常使用的模式還有動態原型模式、寄生構造函數模式和穩妥構造函數模式,具體內容能夠參考《JavaScript高級程序設計》。

繼承

繼承是OOP中你們最喜歡談論的內容之一,通常來講,繼承都兩種方式:接口繼承和實現繼承。而JavaScript中沒有接口繼承須要的方法簽名,所以只能依靠實現繼承。

原型鏈

原型鏈實現起來十分簡單,即,讓原型對象等於另外一個類型的實例。此時,原型對象會包含指向另外一個原型對象的指針,若是以此持續延伸開,那麼咱們看到的就是一條原型對象的鏈條:

function SuperType () {
  this.property = true
}
​
SuperType.prototype.getSuperValue = function () {
  return this.property
}
​
function SubType () {
  this.subProperty = false
}
​
SubType.prototype = new SuperType()
SubType.prototype.getSubValue = function () {
  return this.subProperty
}
​
var instance = new SubType()
console.log(instance.getSuperValue()) // true

用圖示會更清楚一些:

圖截取自《JavaScript高級程序設計》

從圖中能夠看出來,SubType的原型對象指向了SuperType的原型對象。同時,由於全部引用類型都繼承自Object,這讓原型鏈擴展了原型搜索機制,當咱們調用某個實例的屬性或方法時,JS引擎會根據原型對象從當前實例一直往「父級」上找,直到找到Object。

用instanceof能夠很容易地判斷一個實例的原型鏈上是否出現過指定的類型。同時也可使用isPrototypeOf()方法來判斷。

直接替換子類的原型會有兩個問題:

  • 父類的引用類型屬性會被多個子類同時修改
  • 不能向父類傳遞初始化參數

爲了解決第一個問題,能夠在子類的構造函數中手動調用父類的構造函數:

function SuperType (name) {
  this.name = name
}
​
function SubType () {
  SuperType.call(this, 'Alex')
  this.age = 20
}
​
var instance = new SubType()
​
console.log(instance.name) // Alex
console.log(instance.age) // 20

然鵝,構造函數依然會有以前提到過的問題,方法都在構造函數定義,那麼久不存在方法複用了。

因此,像建立對象同樣,繼承也有相似組合模式的組合繼承:

function SuperType (name, friends) {
    this.name = name
    this.friends = friends
}
​
SuperType.prototype.sayName = function () {
    console.log(this.name)
}
​
function SubType (name, age, friends) {
  SuperType.call(this, name, friends)
​
    this.age = age
}
​
SubType.prototype = new SuperType()
SubType.prototype.sayAge = function () {
    console.log(this.age)
}
​
var instance1 = new SubType('Alex', 20, ['Bob', 'Cindy'])
var instance2 = new SubType('Bob', 25, ['David'])
​
console.log(instance1)
console.log(instance2)

此外還有原型式繼承和寄生式繼承,能夠參考《JavaScript高級程序設計》。

上面提到的組合繼承會有一個問題,就是調用了兩次父類的構造函數,從而在原型鏈上多出了一組值爲undefined的name和friends屬性:

能夠經過寄生組合繼承的方式來解決這個問題,其基本思路是:沒必要爲了指定子類的類型而調用父類的構造函數,咱們須要的只是父類的原型對象的一個副本罷了:

function inheritPrototype (SuperType, SubType) {
  var prototype = Object.create(SuperType.prototype)
  
  prototype.constructor = SubType
  SubType.prototype = prototype  
}

第一步是建立一個父類原型對象的副本,第二步是補上constructor的指向,最後一步是將族類的原型對象知道建立的對象。將以前的代碼稍微修改一下:

function SuperType (name, friends) {
  this.name = name
  this.friends = friends
}
​
SuperType.prototype.sayName = function () {
  console.log(this.name)
 }
​
function SubType (name, age, friends) {
  SuperType.call(this, name, friends)
​
  this.age = age
}
​
inheritPrototype(SuperType, SubType)
SubType.prototype.sayAge = function () {
  console.log(this.age)
 }
​
var instance1 = new SubType('Alex', 20, ['Bob', 'Cindy'])
var instance2 = new SubType('Bob', 25, ['David'])
​
console.log(instance1)
console.log(instance2)

圖截取自《JavaScript高級程序設計》

ES6中的類

一直以來,JavaScript中的繼承對於作業務的前端開發工程師來講都比較少接觸,一旦要寫,也是心驚膽戰;並且new和function的搭配也比較怪異,讓function既當普通函數也要當構造函數。可是ES6提供了class關鍵字,讓咱們以更直觀地方式書寫類,也讓function迴歸本身自己的含義。一個簡單的示例來看一下:

class Animal {
  constructor (name) {
    this.name = name
  }
  
  speak () {
    console.log(this.name + ' makes a noise.')
  }
 }
 
 class Dog extends Animal {
   constructor (name) {
     super(name) // call the super class constructor and pass in the name parameter
   }
   
   speak () {
     console.log(this.name + ' barks.')
   }
 }
 
 let d = new Dog('Mitzie')
 d.speak() // Mitzie barks.

比起早期的原型模擬方式,使用 extends 關鍵字自動設置了 constructor,而且會自動調用父類的構造函數,這是一種更少坑的設計。

因此從ES6開始,咱們不須要再去模擬類了,直接使用class關鍵字吧(雖然babel編譯仍是使用的寄生組合繼承模式)。

參考資料:

  • 重學前端 - winter
  • JavaScript高級程序設計

相關文章
相關標籤/搜索