物件、原型鏈、類、繼承【上】

嘉禾生2310發表於2019-10-10

概述

JavaScript,或者說ECMAScript 具有物件導向語言的一些特點,但它不是一門純粹的面嚮物件語言,因為它也包含著函數語言程式設計的一些東西。事實上,現在很多的物件導向的語言,比如Java,也開始實現一些函式式的新特性。總之,所有的程式語言都在隨著應用場景的變化而不斷進化。

這篇文章儘可能的將ECMAScript這門語言中關於物件導向的實現表述完全。好了,我們先從物件開始吧!

物件

物件的定義:無序屬性的集合,其屬性可以包含基本值、物件、或者函式。可以看做一個雜湊。

物件的建立:每個物件都是基於一個引用型別建立的,這個引用型別可以是原生型別,也可以是自定義的型別。

物件的屬性型別

物件有屬性(property),屬性有特性(attribute),特性一般表示形式為雙方括號(如[[attribute]])。

物件有兩種屬性(property):資料屬性訪問器屬性

型別:資料屬性

資料屬性包含一個資料值的位置。在這個位置可以讀/寫值。資料屬性有四個特性([[attribute]]):

  • [[Configurable]] 表示能否通過delete刪除屬性,能否修改屬性的特性值,能否把屬性修改為訪問器屬性。直接在物件上定義的屬性的預設值為true
  • [[Enumerable]] 表示能否通過for-in迴圈返回屬性。直接在物件上定義的屬性的預設值為true
  • [[Writable]] 表示能否修改屬性的值。直接在物件上定義的屬性的預設值為true
  • [[Value]] 屬性的資料值。此處用來儲存。預設值為undefined

defineProperty方法可以配置屬性的特性。(IE9+)

var obj = {}
Object.defineProperty(obj, 'x', {
    configurable: true,
    enumerable: true,
    writable: true,
    value: 123
})
obj; // {x: 123}
複製程式碼

型別:訪問器屬性

訪問器屬性沒有資料值([[Value]]),所以也沒有([[Writable]]),但是多了[[Get]][[Set]]。也叫gettersetter。用於讀、寫對應屬性的值。

  • [[Configurable]] 表示能否通過delete刪除屬性,能否修改屬性的特性值,能否把屬性修改為訪問器屬性。直接在物件上定義的屬性的預設值為true
  • [[Enumerable]] 表示能否通過for-in迴圈返回屬性。直接在物件上定義的屬性的預設值為true
  • [[Get]] 讀取屬性時呼叫的函式。預設值是undefined
  • [[Set]] 寫入屬性時呼叫的函式,引數為寫入值,預設值是undefined

訪問器屬性不能直接定義,必須使用defineProperty來定義。

var book = {
    _page: 2
}
Object.defineProperty(book, 'page', {
    get: function () {
        console.log('你呼叫了get方法')
        return this._page
    },
    set: function (val) {
    console.log('你呼叫了set方法')
        this._page = val
    }
})
book.page; // 你呼叫了get方法
book.page = 3 // 你呼叫了set方法
複製程式碼

defineProperty是ES5新加的方法,在此之前,對於gettersetter,瀏覽器內部有自己的實現。

var book = {
    _page: 2
}
book.__defineGetter__('page', function () {
    return this._page
})
book.__defineSetter__('page', function (val) {
    this._page = val
})
複製程式碼

定義多個屬性

defineProperties()(ES5,IE9+)可以同時定義多個屬性。

var book = {}
Object.defineProperties(book, {
    _page: {
        value: 2
    },
    author: {
        value: 'JiaHeSheng'
    },
    page: {
        get: function () {
            return this._page
        },
        set: function (val) {
            this._page = val
        }
    }
})
book;
/*
{
  author: "JiaHeSheng"
  page: 2
  _page: 2
  get page: ƒ ()
  set page: ƒ (val)
}
*/
複製程式碼

讀取屬性的特性

如何檢視物件某個屬性的特性呢?ES5提供了getOwnPropertyDescriptor方法, 它返回一個屬性所擁有的特性組成的物件。

var obj = { x: 456 }
Object.getOwnPropertyDescriptor(obj, 'x')
/*
{
  configurable: true
  enumerable: true
  value: 456
  writable: true
}
*/
複製程式碼

建立物件

建立物件最簡單的模式就是通過物件字面量進行建立,如var obj = {}。也可以通過Object建構函式,配合new命令進行建立,如var obj = new Object()。但這些都適用於建立單個物件,如果我要批量建立一些「具有某些相同屬性」的物件呢?

工廠模式

通過引數傳入工廠函式,每次都可已生成一個包含特有資訊和相同資訊的新物件。但缺點是,我們並不能通過其生成的例項找到與對應的工廠函式的關聯。

function factoryFoo (name, age) {
    var o = {}
    o.name = name;
    o.age = age;
    o.say = function () {
        console.log(this.name)
    }
    return o
}
var p1 = factoryFoo('Tom', 23)
var p2 = factoryFoo('Jack', 24)
p1.constructor // Object
p1 instanceof factoryFoo // false
複製程式碼

我們可以看到,例項的constructor指向了Object,並且也不能證明p1是工廠函式的例項。

建構函式模式

為了解決工廠函式帶來了問題,我們試著使用建構函式+new來生成例項。

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

var p1 = new Person('Tom', 23)
var p2 = new Person('Jack', 24)

p1.constructor // Person
p2.constructor // Person

p1 instanceof Person // true
p1 instanceof Object // true
複製程式碼

我們可以看到,使用建構函式模式構造出來的物件例項,可以通過其constructor屬性找到它的建構函式。(解決了工廠函式的問題)

另外,與工廠函式相比,少了顯式地建立物件,少了return語句。這是因為使用new操作符,隱式地做了這些事情。

什麼是建構函式

建構函式與普通函式的區別就是,它被new命令用來建立了例項。換言之,沒有被new操作的建構函式就是普通函式。

建構函式的缺點

建構函式也有它的缺點:每個方法都會在例項化的時候被重新創造一遍,即使它們一模一樣。 上例中的say方法就被創造了兩次。

// 建立例項時
this.say = new Function('console.log(this.name)')

// 建立後
p1.say === p2.say // false
複製程式碼

為了解決這個問題,我們可以這樣:

function say () {
    console.log(this.name)
}
function Person (name, age) {
    this.name = name;
    this.age = age;
    this.say = say
}
複製程式碼

但是這又引出了一個新問題:總不能每個方法都這樣全域性定義吧?

new 運算子

new運算子用來建立一個使用者定義的物件型別的例項或具有建構函式的內建物件的例項。

當程式碼 new Foo(...) 執行時,會發生以下事情:

  1. 一個繼承自 Foo.prototype 的新物件被建立。
  2. 使用指定的引數呼叫建構函式 Foo ,並將 this 繫結到新建立的物件。new Foo 等同於 new Foo(),也就是沒有指定引數列表,Foo 不帶任何引數呼叫的情況。
  3. 由建構函式返回的物件就是 new 表示式的結果。如果建構函式沒有顯式返回一個物件,則使用步驟1建立的物件。(一般情況下,建構函式不返回值,但是使用者可以選擇主動返回物件,來覆蓋正常的物件建立步驟)

原型模式

為了解決上面的問題,ECMAScript語言中有了原型(prototype)和原型鏈的概念。

每一個函式上都有一個prototype屬性,這個屬性是一個指標,指向一個物件,這個物件包含了一些屬性和方法,這些屬性和方法可以被所有由這個函式建立的例項所共享。

舉例來說,任意一個函式Personprototype屬性指向物件prototypeObject物件,所有由new Person()建立的例項(p1p2...pn),都會共享prototypeObject的屬性和方法。

function Person (){
}
Person.prototype.age = 34;
Person.prototype.getAge = function () {
    return this.age
}
var p1 = new Person()
var p2 = new Person()
p1.age === p2.age // true
p1.age // 34
p1.getAge === p2.getAge // true
p2.getAge() // 34
複製程式碼

建構函式、例項、原型物件

無論什麼時候,建立一個新函式,新函式就會有prototype屬性,它指向該函式的原型物件。 預設情況下,每個原型物件都有一個屬性constructor,它指向原型所在的函式。 當呼叫這個函式生成出一個例項之後,生成的例項有個隱藏的屬性(不可見,也無法訪問)[[prototype]],它指向原型物件。 幸好,瀏覽器實現了這個屬性:__proto__,通過這個屬性可以訪問原型物件。不過這不是標準實現,不建議在生產環境中使用。

通過上面的示例和描述,我製作了一張圖片,說明「建構函式、例項、原型物件」的關係:

建構函式、例項、原型物件的關係

知道他們的關係之後,我們看下通過哪些方法可以檢視他們關係。

// 證明 p1是 Person 的例項
p1.constructor === Person // true
p1 instanceof Person // true

// 證明 Person.prototype 是 p1 的原型物件
Person.prototype === p1.__proto__ // true
Person.prototype.isPrototypeOf(p1) // true
Object.getPrototypeOf(p1) === Person.prototype // true
複製程式碼

點選檢視Object.prototype.isPrototypeOf()Object.getPrototypeOf的詳細用法。

例項、原型物件上的屬性和方法

如果要讀取例項上的屬性或者方法,就會現在例項物件上搜尋,如果有就返回搜到的值;如果沒有,繼續在例項的原型物件上搜尋,如果搜到,就返回搜到的值,如果沒搜到,就返回undefined。 下面是示例:

function Person () {}
var p1 = new Person();
p1.x // undefined

Person.prototype.x = 'hello'
p1.x // hello 來自原型物件

p1.x = 'world'
p1.x // world 來自例項
Person.prototype.x // hello
複製程式碼

這是抽象出來的搜尋流程圖:

尋找屬性值

我們在程式碼示例中看到,給示例的屬性賦值,並沒有覆蓋原型上對應的屬性值,只是在搜尋時,遮蔽掉了而已。 而這就是使用原型物件的好處:生成的例項,可以共享原型物件的屬性和方法,也可以在自身自定義屬性和方法,即使同名也互不影響,並且優先使用例項上的定義。

繼續看下面的程式碼:

p1.x = null
p1.x // null 來自例項
delete p1.x
p1.x // hello 來自原型物件
複製程式碼

設定屬性值為null,獲取屬性的時候,並不會跳過例項,如果要重新建立與原型物件的連結,可以使用delete刪除例項上的屬性。

那麼如何知道當前獲取的屬性值是在例項還是在原型物件上面定義的呢?ECMAScript提供了hasOwnProperty方法,該方法會忽略掉那些從原型鏈上繼承到的屬性。

p1.x = 'world'
p1.hasOwnProperty('x') // true
delete p1.x
p1.hasOwnProperty('x') // false
Person.prototype.x = 'hello'
p1.hasOwnProperty('x') // false
Person.prototype.hasOwnProperty('x') // true
複製程式碼

原型與in操作符

in操作符會在通過物件能夠訪問給定屬性時返回true,無論該屬性存在於例項還是原型中。

Person.prototype.x = 'hello'
'x' in Person.prototype // true
'x' in p1 // true
p1.x = 'world'
'x' in p1 // true
複製程式碼

組合使用in操作符和hasOwnProperty即可判斷取到的屬性值,是不是存於原型中的。

function hasPrototypeProperty (obj, name) {
    return !obj.hasOwnProperty(name) && (name in obj)
}
Person.prototype.x = 'hello'
hasPrototypeProperty(p1, 'x') // true
p1.x = 'world'
hasPrototypeProperty(p1, 'x') // false
複製程式碼

那麼,如何獲取物件上所有自身的屬性和方法呢?

  • Object.keys。可以獲取物件上所有自身的可列舉屬性和方法名,返回一個名稱列表。

  • Object.getOwnPropertyNames。可以獲取物件上自身的所有屬性和方法名,包括不可列舉的,也返回一個名稱列表。

更簡單的原型語法

上面示例中,我們新增原型屬性,是一個一個在Person.prototype上新增。為了減少不必要的輸入,視覺上也更易讀,我們可以把要新增的屬性和方法,直接封裝成物件,然後改變Person.prototype指向的位置。

function Person () {}
Person.prototype = {
    age: 34,
    getAge: function () {
        return this.age
    }
}
複製程式碼

但是,如果這樣做,Person.prototype.constructor也被重寫,指向了封裝物件的建構函式,也就是Object

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

這時,我們已經無法通過constructor知道原型物件的構造型別了。如果你還記得,工廠模式也存在這個問題。我們可以這樣做:

function Person () {}
Person.prototype = {
    constructor: Person,
    age: 34,
    getAge: function () {
        return this.age
    }
}
複製程式碼

但是,這樣也會有問題。預設的constructor是不可列舉的,這樣顯式的賦值之後,就會變成可列舉的了。

Person.hasOwnProperty('constructor') // true
複製程式碼

如果你很在意這個,可以使用defineProperty,修改constructor屬性為不可列舉。

Object.defineProperty(Person.prototype, 'constructor', {
    enumerable: false,
    value: Person
})
複製程式碼

原型的動態性

因為聯絡原型物件和例項的只是一個指標,而不是一個原型物件的副本,所以原型物件上屬性的任何修改都會在例項上反應出來,無論例項建立是在改動之前或者之後。

function Person () {}
Person.prototype.age = 12
var p1 = new Person()
p1.age // 12
Person.prototype.age = 24
p1.age // 24
var p2 = new Person()
p2.age // 24
複製程式碼

但如果修改了整個原型物件,那情況不一樣了。因為重寫原型物件會切斷建構函式與原先原型物件的聯絡,而例項的指標指向的卻是原來的原型物件。

function Person () {}
Person.prototype = {
    age: 12
}
var p1 = new Person()
p1.age // 12
p1.__proto__ === Person.prototype // true

Person.prototype = {
    age: 24
}
p1.age // 12
Person.prototype.age // 24 
p1.__proto__ === Person.prototype // false
複製程式碼

所以,修改原型物件是把雙刃劍,用得好可以解決問題,用不好就會帶來問題。

原生物件的原型

原生物件(ObjectArrayString等)其實也是建構函式

typeof Object // function
typeof Array // function
typeof String // function
複製程式碼

它們自身擁有一些屬性和方法,它們的原型物件也擁有一些,而原型物件上面的屬性和方法,都會被它們構造的例項所共享。

Object.getOwnPropertyNames(Object).join(',') // "length,name,prototype,assign,getOwnPropertyDescriptor,getOwnPropertyDescriptors,getOwnPropertyNames,getOwnPropertySymbols,is,preventExtensions,seal,create,defineProperties,defineProperty,freeze,getPrototypeOf,setPrototypeOf,isExtensible,isFrozen,isSealed,keys,entries,values"

Object.getOwnPropertyNames(Object.prototype).join(',') // "constructor,__defineGetter__,__defineSetter__,hasOwnProperty,__lookupGetter__,__lookupSetter__,isPrototypeOf,propertyIsEnumerable,toString,valueOf,__proto__,toLocaleString"

Object.getOwnPropertyNames(Object.getPrototypeOf({})).join(',') // "constructor,__defineGetter__,__defineSetter__,hasOwnProperty,__lookupGetter__,__lookupSetter__,isPrototypeOf,propertyIsEnumerable,toString,valueOf,__proto__,toLocaleString"
複製程式碼

既然可以共享,當然也可以修改和新增。

Object.prototype.toString = function () {
    return 'hello world'
}
var a = {}
a.toString() // hello world
複製程式碼

雖然這樣很方便,但是,我們並不推薦這麼做。因為每個原生物件的屬性和方法,都是有規範可尋的,並且這個規範是所有開發人員都認可的。那麼,如果「自定義」了這些屬性和方法,可能在多人協作的專案中引起不必要衝突。並且如果規範更新,也會帶來問題。

原型物件的問題

上面說了使用原型物件的諸多優點,但是原型模式也是有問題的。原型模特的優點是因為它的共享特性,缺點也是。比如,我們在原型物件上定義了一個引用型別的屬性。

function Person () {}
Person.prototype.family = ['father','mother']
var p1 = new Person()
var p2 = new Person()
p1.family.push('girlFriend')

p1.family // ["father", "mother", "girlFriend"]
p2.family // ["father", "mother", "girlFriend"]
複製程式碼

我們在p1family屬性中新增了girlFriend,但是p2.family也新增了,因為他們指向的是同一個陣列。而這,是我們不希望看到的。例項之間需要共享的屬性和方法,自然,也需要自有的屬性和方法。

例項屬性(OwnProperty) 該屬性在例項上,而不是原型上。可以在建構函式內部或者原型方法內部建立。 建議只在建構函式中建立所有的例項屬性,保證變數宣告在一個地方完成。

function Person () {
 this.family = ['father','mother']
}
var p1 = new Person()
var p2 = new Person()
p1.family.push('girlFriend')

p1.family // ["father", "mother", "girlFriend"]
p2.family // ["father", "mother"]
複製程式碼

組合使用建構函式模式和原型模式

如果你還有印象,之前的建構函式模式不就是建立的例項屬性和方法嗎?所以,結合使用這兩種方式,是目前ECMAScript中使用最廣泛、認同度最高的建立自定義型別的方法。

function Person () {
    this.family = ['father','mother']
}
Person.prototype.age = 24
var p1 = new Person()
var p2 = new Person()
p1.family.push('girlFriend')
p1.family // ["father", "mother", "girlFriend"]
p2.family // ["father", "mother"]
p1.age = 25
p2.age // 25 
複製程式碼

以上示例,既有原型物件的共享屬性,也有例項自身的屬性,各得其所。

動態原型模式

但是,上面示例在混合使用兩種模式時,依然是割裂開的,兩種模式並沒有在一個方法中完成。而動態原型模式,正是來解決這個問題。

function Person () {
    this.age = 24
    if(typeof this.getAge !== 'function'){
        Person.prototype.getAge = function () {
            return this.age
        }
    }
}
複製程式碼

可以看到,我們把原型物件模式的定義語句移動到了構建函式中,顯式的將兩種模式統一在了一起。

寄生建構函式模式

之前我們說過,儘量不要改動原生物件,但是如果想在原生物件上增加方法怎麼辦?我們可以在原生物件的基礎上,增加方法,然後生成一個新的物件。這就是寄生建構函式模式。

function ArrayPlus () {
    var plus = []
    plus.pipeStr = function () {
        return this.join('|')
    }
    return plus
}

var plus1 = new ArrayPlus()
plus1.push('red')
plus1.push('black')
plus1.pipeStr() // red|black

plus1.constructor // Array
Object.getPrototypeOf(Object.getPrototypeOf(plus1)) === Array.prototype // true
plus1 instanceof ArrayPlus // false
plus1 instanceof Aarray // true
複製程式碼

但是,生成的例項跟建構函式和原型物件是完全沒有聯絡的,並且也無法通過instanceof確定其型別。所以,在其他模式可用的情況下,不推薦使用這個模式。

穩妥建構函式模式

穩妥物件是指沒有公共屬性,並且其方法也不引用this的物件。適合一些安全的環境。下面的示例中,除了物件提供的方法,是沒有其他途徑獲得物件內部的原始資料的。 當前與寄生建構函式模式一樣,生成的例項跟建構函式和原型物件是完全沒有聯絡的,並且也無法通過instanceof確定其型別。

function Person (age) {
    return {
        getAge: function () {
            return age
        }
    }
}
var p1 = Person(12)
p1.getAge() // 12

Object.getPrototypeOf(p1) === Object.prototype // true
p1 instanceof Person // false
複製程式碼

繼承

通過原型物件和建構函式相結合的模式,我們可以批量的生成物件,這種模式可以稱之為ECMAScript中的「類」;

那如果要批量生成「類」呢?這就要用到「繼承」了。ECMAScript中的繼承主要是依賴原型鏈來實現。

原型鏈

原型鏈的基本思想是利用原型讓一個引用型別繼承另一個引用型別的屬性和方法。實際做法就是:

  1. 讓一個建構函式A的原型物件A.prototype指向另一個建構函式B的例項b1,此時A.prototype === b1,那麼建構函式A的例項a1會擁有建構函式B的原型物件B.prototype的所有屬性和方法。
  2. 如果建構函式B的原型物件B.prototype恰好又指向另一個建構函式C的例項c1,即B.prototype === c1。那麼建構函式B的例項b1會擁有建構函式C的原型物件C.prototype的所有屬性和方法。
  3. 如此層層遞進,建構函式A的例項a1會同時擁有建構函式B的原型物件B.prototype和建構函式C的原型物件C.prototype的所有屬性和方法。

這就是原型鏈的基本概念。程式碼例項如下:

function Grandpa () {}
Grandpa.prototype.sayHello = function () {
    return 'hello'
}
function Father () {}
Father.prototype = new Grandpa()
Father.prototype.sayWorld = function () {
    return 'world'
}
function Son () {}
Son.prototype = new Father()
var son1 = new Son()
son1.sayHello() // hello
son1.sayWorld() // world
複製程式碼

如果你還記得之前的原型搜尋機制(還是下面這張圖),那麼原型鏈其實就是對這種機制的向下擴充。

尋找屬性值

// 呼叫son1例項上的sayWorld方法
son1.sayWorld()
// 先在例項上尋找,沒有
Object.getOwnPropertyNames(son1) // []
// 繼續在例項的原型上尋找,也沒有
Object.getOwnPropertyNames(Son.prototype) // []
// 繼續在例項的原型的原型上尋找,找到了
Object.getOwnPropertyNames(Father.prototype) // ["sayWorld"]

// 同樣的,呼叫son1例項上的sayWorld方法
son1.sayHello() 
// 先在例項上尋找,沒有
Object.getOwnPropertyNames(son1) // []
// 繼續在例項的原型上尋找,也沒有
Object.getOwnPropertyNames(Son.prototype) // []
// 繼續在例項的原型的原型上尋找,沒有
Object.getOwnPropertyNames(Father.prototype) // ["sayWorld"]
// 繼續在例項的原型的原型的原型上尋找,找到了
Object.keys(Grandpa.prototype) // ["constructor", "sayHello"]
複製程式碼

別忘記預設的型別

那原型鏈的盡頭————例項的原型的原型的原型...的原型是誰呢? 所有函式的預設原型都是Object的例項,所以所有自定義型別都繼承了Object.prototype上的屬性和方法。

Object.getPrototypeOf(Son.prototype) === Father.prototype // true
Object.getPrototypeOf(Father.prototype) === Grandpa.prototype // true
Object.getPrototypeOf(Grandpa.prototype) === Object.prototype // true
複製程式碼

Object的原型指向誰呢?

Object.getPrototypeOf(Object.prototype) // null
複製程式碼

Object.getPrototypeOf的返回值是傳入物件繼承的原型物件,所以,如果傳入物件沒有繼承值,那麼就返回null

確定原型與例項的關係

instanceof 操作符。測試例項與原型鏈中的建構函式。

son1 instanceof Son // true
son1 instanceof Father // true
son1 instanceof Grandpa // true
son1 instanceof Object // true
複製程式碼

isPrototypeOf()方法。只要是原型鏈中出現過的原型,都可以說是該原型鏈所派生的例項的原型。

Son.prototype.isPrototypeOf(son1) // true
Father.prototype.isPrototypeOf(son1) // true
Grandpa.prototype.isPrototypeOf(son1) // true
Object.prototype.isPrototypeOf(son1) // true
複製程式碼

謹慎地定義方法

這一塊在講原型的時候也有提及,主要有兩點:在原型鏈末端定義的重名屬性或方法,會遮蔽掉在原型鏈頂端的定義;使用原型覆蓋預設原型物件,要在新增原型的方法之前進行。

function Grandpa() {}
Grandpa.prototype.say = function () {
    return 'grandpa'
}
function Father() {}
Father.prototype = new Grandpa()
Father.prototype.say = function () {
    return 'father'
}
function Son () {}
Son.prototype.age = 12
Son.prototype = new Father()

var son1 = new Son()
son1.say() // father
son1.age // undefined
複製程式碼

另外,使用物件字面量的方式為原型新增方法,也會覆蓋之前的原型物件。

原型鏈的問題

第一個問題之前在講原型的時候也說過,就是如果在原型物件上定義一個引用型別的屬性,可能出現問題。

第二個問題是在建立子型別的例項(son1)時,不能向超型別的建構函式(Grandpa)傳遞引數。

有鑑於此,一般不單獨使用原型鏈。

借用建構函式

使用建構函式,可以解決上面提到的問題一。

function Grandpa () {
    this.family = ['house', 'car']
}
function Father () {
    // 使用call,完成例項屬性繼承
    // 其實就是以當前函式的作用域,替換目標函式作用域,並執行目標函式
    Grandpa.call(this)
    this.age = 26
}
// 工廠模式寫法
// function Father () {
//     var that = new Grandpa()
//     that.age = 26
// 	   return that
// }
var f1 = new Father()
var f2 = new Father()
f1.family.push('money')
f2.family // ['house', 'car']
複製程式碼

可以傳遞引數,解決了問題2

function Grandpa (name) {
    this.name = name
}
function Father (name) {
    Grandpa.call(this, name)
    this.age = 26
}
// 工廠模式寫法
// function Father (name) {
//     var that = new Grandpa(name)
//     that.age = 26
//     return that
// }
var f1 = new Father('jiahesheng')
f1.name // jiahesheng
f1.age // 26
複製程式碼

但是,借用建構函式也有自己的問題。也就是不能複用共享屬性和方法了。

組合繼承

其實就是結合了原型鏈和借用建構函式兩種技術。

function Father (name) {
    this.name = name
}
Father.prototype.sayWorld = function () {
    return 'world'
}
function Son (name) {
    Father.call(this, name)
}
Son.prototype = new Father()
var s1 = new Son('zhu')
var s2 = new Son('sang')
s1.name // 'zhu'
s2.name // sang
s1.sayWorld() // 'world'
s2.sayWorld() // 'world'
複製程式碼

所謂的共享和自有,可以這麼理解: 使用了原型鏈共享了屬性和方法的例項,其實,就是包含了一堆指標,這些指標指向原型物件; 使用了借用建構函式技術擁有了自有的屬性和方法的例項,其實,就是擁有了建構函式屬性和方法的副本。

原型式繼承

DC 最早提出,ECMAScript新增了Object.create方法規範化了這種模式。原型式繼承的主要應用場景,就是返回一個物件,物件的原型指向傳入的物件。

var person  = {
    age: 24
}
// from DC
function object (o) {
    function F() {}
    F.prototype = o
    return new F()
}
var p1 = object(person)
Object.getPrototypeOf(p1) === person // true
// from ECMAScript
var p2 = Object.create(person)
Object.getPrototypeOf(p2) === person // true
複製程式碼

Object.create還支援第二個引數,格式與Object.defineProperties相同

var person = {
    age: 24
}
var p1 = Object.create(person, {
    age: {
        value: 12
    }
})
p1.age  // 12
複製程式碼

Object.create最常用的方法還是建立一個純淨的資料字典(沒有原型物件的物件例項,即例項的原型指向null): Object.create(null)

var p3 = Object.create(null)
p3.__proto__ // undefined
Object.getPrototypeOf(p3) // null
複製程式碼

純淨的資料字典

使用Object.setPrototypeOf也可以實現:

var p4 = {}
Object.setPrototypeOf(p4, null)
p4.__proto__ // undefined
Object.getPrototypeOf(p4) // null
複製程式碼

寄生式繼承

寄生式繼承就是建立一個僅用於封裝繼承過程的函式,該函式在內部增強物件之後,會返回新的物件。寄生式繼承的實際用途在下一節能更好的表示。

寄生組合式繼承

我們先來回顧一下,組合式繼承:

function Father (name) {
    this.name = name || 'default'
}
Father.prototype.sayWorld = function () {
    return 'world'
}
function Son (name) {
    Father.call(this, name)
}
Son.prototype = new Father()
var s1 = new Son('zhu')
var s2 = new Son('sang')
s1.name // 'zhu'
s2.name // 'sang'
s1.sayWorld() // 'world'
s2.sayWorld() // 'world'

Son.prototype.name // 'default'
複製程式碼

它實現了屬性和方法的自有和共享。但是,也帶來了一些問題。

  1. 建構函式Father被呼叫執行了兩次。一次在new Father(),一次在Father.call(this, name)
  2. 因為呼叫了兩次,所以產生了多餘的屬性。Son.prototype = new Father()這個語句後,其實Son.prototype也擁有了name屬性。只是我們在使用name屬性的時候,被例項上的name屬性遮蔽了。

怎麼解決這個問題呢?我們將原型鏈繼承這一步(Son.prototype = new Father())重寫即可!避免呼叫new Father(),避免繼承Father的例項屬性和方法。 我們可以組合使用寄生式繼承和原型式繼承,定義這樣一個函式:

function inheritPrototype (prototypeObj, inheritor) {
    var prototype = Object.create(prototypeObj)
    prototype.constructor = inheritor
    inheritor.prototype = prototype
}
複製程式碼

inheritPrototype方法做了兩件事:恢復了原型物件對建構函式的指標屬性,「淺複製」了原型物件。之前我們也說過,其實原型鏈的共享只是一堆指標的公用,指向的其實還是一個原型物件。所以,「淺複製」剛好用上。

現在我們把這個方法用起來!

function Father (name) {
    this.name = name
}
Father.prototype.sayWorld = function () {
    return 'world'
}
function Son (name) {
    Father.call(this, name)
}
inheritPrototype(Father.prototype, Son)
var s1 = new Son('zhu')
複製程式碼

到此我們實現了最完美的繼承!

ECMAScript 2015

歷盡艱險,我們終於使用ES5實現了「類」和「繼承」,但是這相較於其他的物件導向的語言,看起來很不「規範」,並且實現起來也太麻煩。所以,在ECMAScript 2015版本中,使用classextend 關鍵字,更加「規範」的實現了「類」和「繼承」。

我們下篇文章繼續探討新的規範中的「物件導向」。

參考

《JavaScript高階程式設計第三版》

相關文章