JavaScript夯實基礎系列(四):原型

白馬笑西風發表於2019-03-18

  在JavaScript中有六種資料型別:number、string、boolean、null、undefined以及物件,ES6加入了一種新的資料型別symbol。其中物件稱為引用型別,其他資料型別稱為基礎型別。在物件導向程式設計的語言中,物件一般是由類例項化出來的,但是在JavaScript中並沒有類,物件是以與類完全不同的設計模式產生的。

一、建立物件

  最常用來建立的方式是通過物件字面量的方式,簡單便捷。但是該方式為單例模式,如果建立類似的物件會產生過多重複的程式碼,如下程式碼所示:

var person1 = {
    name: 'LiLei',
    age: 18,
    sayName: function () {
        console.log(this.name)
    }
}
var person2 = {
    name: 'HanMeiMei',
    age: 18,
    sayName: function () {
        console.log(this.name)
    }
}
person1.sayName() // LiLei
person2.sayName() // HanMeiMei
複製程式碼

  使用工廠模式能夠避免產生重複程式碼,但是工廠模式的弊端在於不能很好的將產生的物件分類。如下程式碼所示:

function person (name, age) {
    var obj = new Object()
    obj.name = name
    obj.age = age
    obj.sayName = function () {
        console.log(this.name)
    }
    return obj
}
        
var person1 = person('LiLei',18)
var person2 = person('HanMeiMei',18)

person1.sayName() // LiLei
person2.sayName() // HanMeiMei
複製程式碼

1、建構函式模式

  建構函式模式能夠將例項化出來的物件很好的分類,如下程式碼所示:

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

var person1 = new Person('LiLei',18)
var person2 = new Person('HanMeiMei',18)

person1.sayName() // LiLei
person2.sayName() // HanMeiMei
複製程式碼

  首先要說明的是,在JavaScript中並不存在建構函式的語法,有的僅僅是函式的構造呼叫。建構函式與普通的函式沒有什麼不同,當函式通過關鍵字new來呼叫時才稱為建構函式。用來作為建構函式呼叫的函式名以大寫字母開頭也是一種書寫規範,JavaScript語言層面沒有這種約束。通過new關鍵字來呼叫建構函式經歷了以下四個階段:

1、創造一個空物件。
2、物件的[[proto]]屬性指向建構函式prototype屬性指向的物件。
3、以新建的空物件為執行上下文,通過建構函式內部的程式碼初始化空物件。
4、如果建構函式有返回值且是物件,則返回該物件。否則,返回新建的物件。

  對比工廠模式來說,建構函式模式能夠清楚的將物件分類;對比單例模式來說,建構函式不會產生大量重複的程式碼。但是也不是完全沒有產生重複程式碼,如上程式碼所示:person1物件和person2物件是可以共享一個sayName方法的。只使用建構函式模式,每個物件都有各自新的方法,彼此之間不能共享,為了克服這種缺陷,原型模式應運而生。

2、原型模式

  每一個函式在建立時會擁有一個prototype屬性(Function.bind()方法返回的函式除外),該屬性指向同時建立的該函式的原型物件,在這個原型物件中擁有一個不可列舉的屬性constructor,該屬性指向原型對應的函式。
  通過建構函式生成的例項物件擁有一個指向該建構函式原型物件的的指標,在ES5中被稱為[[proto]],ES6中稱為__proto__。在通過建構函式建立物件的過程中,實質上就做了兩件事:1、將例項物件的[[proto]]屬性指向建構函式prototype屬性指向的物件;2、將例項物件作為建構函式的執行上下文,執行建構函式完成初始化。一旦例項物件構建完畢,跟原有的建構函式不再有什麼關係,即使可以將例項物件以建構函式名稱分類也是通過原型物件來判定的。
  在例項物件中查詢屬性時,先在物件自身上查詢,如果沒有找到會通過[[proto]]來在原型鏈上查詢。
  建構函式、原型物件、例項物件三者的關係如下圖所示:

JavaScript夯實基礎系列(四):原型

  原型模式本質上就是共享,所有[[proto]]指標指向原型物件的例項物件,都可以訪問該原型物件上的屬性。例項物件上如果有跟原型物件同名的屬性,就會形成“遮蔽”,訪問該屬性時就會訪問例項物件上的值,而不是原型物件上的值。不能通過例項物件來修改原型上的屬性值,但是這個性質就跟ES6中定義常量的關鍵字const一樣,不可以改變的是這個屬性的地址,如果原型物件上的屬性是引用型別的話,引用型別的地址不可以改變,引用指向的物件卻可以通過例項物件來改變。如下程式碼所示:

function Student () {}
Student.prototype.score = 60
Student.prototype.course = ['語文','數學']

let LiLei = new Student()
let HanMeiMei = new Student()

console.log(LiLei.score) // 60
console.log(HanMeiMei.score) // 60

LiLei.score = 90
console.log(LiLei.score) // 90
console.log(HanMeiMei.score) // 60

LiLei.course.push('英語')
console.log(LiLei.course) // ['語文','數學','英語']
console.log(HanMeiMei.course) // ['語文','數學','英語']
複製程式碼

  原型模式基本上不單獨使用,因為原型模式產生的物件都一樣,不能像建構函式那樣通過傳遞引數來完成各自的初始化。羅素說過:“參差多型才是幸福的源泉”,物件導向程式設計很大程度上是模擬現實世界,所以這句話在程式設計世界裡同樣適用。

3、建構函式與原型模式組合使用

  組合使用建構函式和原型模式是建立自定義物件最常見的方式。通過建構函式傳參使物件擁有例項屬性,通過原型物件來使同一型別的物件共享屬性。一般來說,引用物件屬於例項屬性,原型物件上沒有除了函式以外的引用物件,防止一個物件修改原型物件上的引用物件影響其它物件。如下程式碼所示:

function Student (name,age) {
    this.name = name
    this.age = age
}
Student.prototype.score = 60

let LiLei = new Student('LiLei',18)
let HanMeiMei = new Student('HanMeiMei',16)

console.log(LiLei.name) // LiLei
console.log(LiLei.age) // 18
console.log(LiLei.score) // 60
console.log(HanMeiMei.name) // HanMeiMei
console.log(HanMeiMei.age) // 16
console.log(HanMeiMei.score) // 60
複製程式碼

4、修改原型物件

  在向建構函式的原型中新增屬性或者函式時,逐個新增有時候顯得比較繁瑣,因此很多時候是直接更改建構函式的原型物件。如下程式碼所示:

function Student (name,age) {
    this.name = name
    this.age = age
}

Student.prototype = {
    score: 60,
    sayName: function () {
        console.log(this.name)
    },
    sayAge: function () {
        console.log(this.age)
    }
}

let LiLei = new Student('LiLei',18)
LiLei.sayName() // LiLei
複製程式碼

  在這裡有兩點需要注意,第一點是這種方式廢棄建構函式原有的原型物件,新建一個物件來作為建構函式的原型物件。這就導致了一個問題:如果在新增新原型物件之前就通過建構函式建立物件,那麼建立的物件的[[proto]]屬性指向的依然是老的原型物件,新原型物件上的一切屬性與該物件無關。第二點是函式建立時自動生成的原型物件中有一個不可列舉的屬性constructor,該屬性是一個指向函式的指標。在新建立的物件中沒有這個屬性,如果要用到該屬性來判定物件的類別,那麼可以自行新增。如下程式碼所示:

function Student (name,age) {
    this.name = name
    this.age = age
}
Student.prototype.score = 80
let HanMeiMei = new Student('HanMeiMei',16)

Student.prototype = {
    score: 60,
    sayName: function () {
        console.log(this.name)
    }
}
Object.defineProperty(Student.prototype,'constructor',{
    value: Student,
    enumerable: false
})

let LiLei = new Student('LiLei',18)
console.log(LiLei.score) // 60
LiLei.sayName() // LiLei

console.log(HanMeiMei.score) // 80
HanMeiMei.sayName() // TypeError
複製程式碼

二、繼承

  繼承是物件導向程式設計中的一個重要概念,在JavaScript中繼承是通過原型鏈來實現的。

1、原型繼承

  原型繼承的思路是子物件的原型是父物件的例項,幾乎所有的物件都繼承自Object,這裡說幾乎是因為可以通過Object.create()方法來建立不繼承自任何物件的物件。

function Person () {
    this.name = 'person'
}
Person.prototype.sayName = function () {
    console.log(this.name)
}

function Student () {
    this.id = '001'
}
Student.prototype = new Person()
Student.prototype.sayId = function () {
    console.log(this.id)
}

let LiLei = new Student()
LiLei.sayId() // 001
LiLei.sayName() // person
console.log(LiLei.toString()) // [object Object]
複製程式碼

  如上程式碼所示,函式Student的原型物件是Person函式的例項,所以物件LiLei可以使用Person原型物件上的屬性。而函式Person的原型物件是Object函式的例項,所以物件LiLei可以使用Object.prototype.toString()方法。如下圖所示:

JavaScript夯實基礎系列(四):原型

  物件查詢屬性的規則是:先在物件自身上查詢,若查詢到則停止,否則沿著原型鏈繼續查詢。即查詢[[proto]]指標指向的物件,查詢到則停止,查詢不到則查詢該物件[[proto]]指標指向的物件,直到Object.prototype為止。
  物件新增屬性的情況卻比較複雜:物件自身有要新增的屬性時,只是修改自身的值;當要新增的屬性不在物件自身上,而在物件原型鏈上能夠找到時分三種情況,以向物件obj上新增屬性add為例:

1、在原型鏈上找到的add屬性是資料屬性且不是隻讀屬性,則add新增到obj上,形成遮蔽
2、在原型鏈上找到的add屬性是資料屬性且是隻讀屬性,則add屬性新增失敗,obj物件自身不會有add屬性。
3、在原型鏈上找到的add屬性是一個setter,這個setter會被呼叫,add屬性新增失敗。

  上述描述的後兩種情況都是屬性新增失敗,這兩種情況下想要強制新增屬性可以用Object.defineProperty()方法。

2、組合繼承

  在使用原型模式建立物件的時候有兩個問題:一是引用型別不適合放在原型物件上,二是沒辦法通過傳參來初始化物件。使用原型繼承依然會存在這兩方面的問題,上述程式碼將父建構函式的例項作為子建構函式的原型,那麼在很多時候子建構函式的原型中擁有引用型別的屬性,而且還不能向父建構函式中傳遞引數。為消除單獨使用原型繼承的弊端,一般都是使用借用建構函式原型繼承的組合來實現繼承的。
  函式通過new來構造呼叫時執行上下文預設是新建立的物件,但是在JavaScript中可以通過apply()和call()方法來給函式任意指定執行上下文,所謂借用建構函式就是使用這條性質,來使用父建構函式完成對子建構函式例項物件的初始化。如下程式碼所示:

function Person (person1,person2) {
    this.friends = []
    this.friends.push(person1)
    this.friends.push(person2)
}

function Student (person1,person2) {
    Person.call(this,person1,person2)
}

let LiLei = new Student('tom','mary')
let HanMeiMei = new Student('tom','mary')
LiLei.friends.push('penny')
console.log(LiLei.friends) // ['tom','mary','penny']
console.log(HanMeiMei.friends) // ['tom','mary']
複製程式碼

  單獨使用借用建構函式可以很方便的定製物件的例項屬性,但是在共享方面卻很欠缺,因此實現繼承一般是將原型繼承借用建構函式組合使用。組合繼承的思路是:通過原型鏈實現原型屬性和方法的繼承,通過借用建構函式實現對例項屬性的繼承。如下程式碼所示:

function Person (name) {
    this.name = name
    this.friends = ['tom','mary']
}

Person.prototype.sayName = function () {
    console.log(this.name)
}

function Student (name,age) {
    Person.call(this,name)
    this.age = age
}

Student.prototype = new Person()
Student.prototype.sayAge = function () {
    console.log(this.age)
}

let LiLei = new Student('LiLei',20)
LiLei.friends.push('pony')
console.log(LiLei.name)
console.log(LiLei.friends)

let HanMeiMei = new Student('HanMeiMei',18)
console.log(HanMeiMei.name)
console.log(HanMeiMei.friends)
複製程式碼

3、繼承的本質

  在優化上述程式碼之前,先說一下JavaScript中繼承的本質。通過new關鍵字對函式進行構造呼叫時,將新物件中[[proto]]指標指向建構函式的prototype指向的物件,然後將例項物件作為執行上下文來執行一遍建構函式。所謂繼承,本質上就是物件的[[proto]]指標指向另一個物件,當查詢物件上的屬性時,先查詢自身,沒找到的話再沿著[[proto]]指標查詢。其實這種方式稱為委託更為合適,物件一部分屬性在物件自身上,另外一部分屬性或者方法委託給了另外一個物件。如果只考慮這種委託關係,不考慮物件自身上的屬性的話可以不經過new關鍵字,直接為新建立的物件指定原型物件。
  在ES5中新增了Object.create()方法體現了繼承實際上是物件委託的本質。該方法建立一個新物件連結到指定的物件,可以接收兩個引數,第一個引數為指定的原型物件,第二個引數是要新增進新物件自身上的屬性。其中該方法接收的第二個引數與Object.definePropertier()方法的第二個引數相同,每個屬性都是通過自己的描述符定義的。如下程式碼所示:

let Person = {
    name: 'any',
    age: 18
}

let LiLei = Object.create(Person,{
    name: {
        value: 'LiLei'
    }
})

console.log(LiLei.name) // LiLei
console.log(LiLei.age) // 18
複製程式碼

  Object.create()方法是對原型式繼承的規範實現。所謂原型式繼承是指不必建立自定義型別,基於已有物件建立新物件。在不相容Object.create()的情況下可以使用如下程式碼部分填補:

if (!Object.create) {
    Object.create = function(o) {
        function F(){}
        F.prototype = o;
        return new F();
    };
}
複製程式碼

  之所以說是部分填補是因為上述方式並沒用辦法使用像Object.create()第二個引數那樣為新物件新增屬性。

4、優化組合繼承

  說完繼承的本質之後來優化一下組合繼承的程式碼。組合繼承的方式通過借用父建構函式來例項化新物件,然後將子建構函式的原型連結到父建構函式的例項物件上,這會導致呼叫兩次父建構函式來重複建立相同的屬性。如下程式碼所示:

function Person (name) {
    this.name = name
    this.friends = ['tom','mary']
}

function Student (name,age) {
    Person.call(this,name)
    this.age = age
}

Student.prototype = new Person()

let LiLei = new Student('LiLei',20)
console.log(LiLei) // {age: 20, friends:[ "tom", "mary" ], name: "LiLei"}
console.log(Student.prototype) // {friends:[ "tom", "mary" ], name: undefined}
複製程式碼

  可以看出例項物件上和原型物件上的屬性有重複,這是將父建構函式例項物件作為原型物件的後遺症。因為查詢物件屬性的時候,物件自身擁有屬性會對原型物件上的屬性形成“遮蔽”,從這個角度來說組合繼承完全可以達到繼承的目的。但是冗餘資料終歸是不好的,可以通過原型式繼承來加以優化:通過借用父建構函式來例項化物件,通過直接將子建構函式的prototype指向父建構函式的原型物件來實現繼承。如下程式碼所示:

function Person (name) {
    this.name = name
    this.friends = ['tom','mary']
}

Person.prototype.score = 60

function Student (name,age) {
    Person.call(this,name)
    this.age = age
}

Student.prototype = Object.create(Person.prototype)

let LiLei = new Student('LiLei',20)
console.log(LiLei) // {age: 20, friends:[ "tom", "mary" ], name: "LiLei"}
console.log(Student.prototype) // {}
複製程式碼

  這種方式被稱為寄生組合式繼承,是實現引用型別繼承的最佳方式。

三、物件的型別

  在JavaScript中檢測資料型別可以使用typeof操作符,如下程式碼所示:

var a;
typeof a;                // "undefined"

a = "hello world";
typeof a;                // "string"

a = 42;
typeof a;                // "number"

a = true;
typeof a;                // "boolean"

a = null;
typeof a;                // "object" -- 當做空物件處理

a = undefined;
typeof a;                // "undefined"

a = { b: "c" };
typeof a;                // "object"
複製程式碼

  在使用typeof關鍵字檢測物件型別時,並不能返回物件的具體型別。對於物件型別的檢測,通常遵循“鴨式辯型”的原則:

像鴨子一樣走路、游泳以及鳴叫的鳥就是鴨子

  在JavaScript中,不關心物件的型別是什麼,而是關心物件能夠做什麼。當然有些時候也需要知道物件的型別,可以通過如下幾種各有缺陷的方式來判定物件型別。

1、constructor

  除了Function.bind()返回的函式之外,所有的函式執行時都有一個prototype屬性,prototype指向該函式的原型物件,在自動生成的原型物件中有一個不可列舉的屬性constructor,constructor指向該函式。在該函式構造呼叫時,建立的例項物件中[[proto]]與建構函式prototype指向相同的原型物件。可以通過這個關係來確定例項物件的型別,如下程式碼所示:

function Person () {
    this.age = 18
}
let LiLei = new Person()
console.log(Person.prototype.constructor === Person) // true
console.log(LiLei.constructor === Person) // true
複製程式碼

  使用constructor屬性來判定物件的型別在多個執行上下文的場景下無法工作,比如在瀏覽器開啟多個視窗來顯示子頁面, 子頁面中的物件是同一型別也沒辦法使用constructor屬性來判定。
  使用constructor屬性來判定物件型別是建立在不變更建構函式預設原型物件的基礎上,但是在很多時候都會改變預設原型的。比如感覺一個一個往函式原型上新增屬性和方法比較繁瑣,一般會通過字面量的方式建立一個新的物件來作為原型物件;還有在實現繼承時將父建構函式的例項物件或者原型物件作為子建構函式的原型時;在這些情況下,需要往新的原型物件上新增一個不可列舉的屬性constructor來指向建構函式。
  一般沒有必要特意去維持這種比較脆弱的關係,在很多時候不經意間就改變了constructor屬性的指向或者改變了原型物件,因此不提倡使用constructor來判定物件的型別。

2、prototype

  原型物件是物件型別的唯一標誌。當且僅當兩個物件繼承自同一原型物件時,這兩個物件才屬於相同型別。
  isPrototypeOf()方法可以判斷物件之間是否有原型關係,用於測試一個物件是否存在於另一個物件的原型鏈上。如下程式碼所示:

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

let LiLei = new Person('LiLei')
      
console.log(Person.prototype.isPrototypeOf(LiLei)) // true
console.log(Object.prototype.isPrototypeOf(LiLei)) // true
複製程式碼

  ES5中新增的了Object.getPrototypeOf()方法,該方法返回指定物件的原型,即內部[[Prototype]]屬性的值。如下程式碼所示:

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

Person.prototype.score = 60

let LiLei = new Person('LiLei')

console.log(Object.getPrototypeOf(LiLei)) // { score:60 }
console.log(Object.getPrototypeOf(LiLei) === Person.prototype) // true
複製程式碼

3、instanceof

  檢測物件和原型物件之間的關係簡單直接,有時希望確定例項物件與建構函式之間的關係,可以通過instanceof運算子來檢測。instanceof運算子用來檢測函式的prototype屬性指向的物件是否存在於引數例項物件的原型鏈上。如下程式碼所示:

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

Person.prototype.score = 60
      
function Animal () {
    this.type = 'dog'
}

function Student (name,age) {
    Person.call(this,name)
    this.age = age
}

Student.prototype = Object.create(Person.prototype)

let LiLei = new Student('LiLei',20)

console.log(LiLei instanceof Student) // true
console.log(LiLei instanceof Person) // true
console.log(LiLei instanceof Object) // true
console.log(LiLei instanceof Animal) // false
複製程式碼

  isPrototypeOf()、instanceof運算子來判斷物件的型別時,只能檢測物件屬不屬於某一型別,而不能通過物件來獲取型別名稱。Object.getPrototypeOf()方法只能獲取物件[[proto]]指標指向的物件。另外這些方法檢測物件型別和constructor運算子一樣,在多個執行上下文的場景下無法工作。

四、總結

  作為物件導向程式語言,JavaScript中沒有類,只有物件。在通過建構函式建立物件時,沒有發生其他物件導向程式語言中從類中“拷貝”的事情,只是建立一個新物件,作為建構函式的執行上下文來執行建構函式而已,物件最終通過原型鏈連結在一起。
  建立物件最常見的方式是採用物件字面量的方式,而建立特定型別的自定義物件最常用的組合使用建構函式和原型模式的方式。使用建構函式新增例項物件的自身屬性,使用原型物件來新增同一型別物件共享的屬性和方法,一般物件的引用型別屬性的新增都是放在建構函式中的。
  JavaScript中的繼承更確切的名稱是委託,通過物件之間的委託來實現方法和屬性的共享以及複用,可以通過Object.create()方法來直接完成這種委託關係。一個物件想要繼承自身擁有引用型別屬性的物件的全部屬性和方法,實現的最佳方式是寄生組合式繼承
  原型物件是物件型別的唯一標誌,想要確定物件的型別,可以通過驗證物件的原型來實現。JavaScript更多關注的是物件能做什麼,而不是物件是什麼型別。
如需轉載,煩請註明出處:www.cnblogs.com/lidengfeng/…

相關文章