深刻 JavaScript 中的對象以及繼承原理

ES6引入了一個很甜的語法糖就是 class, class 能夠幫助開發者迴歸到 Java 時代的面向對象編程而不是 ES5 中被詬病的面向原型編程. 我也在工做的業務代碼中大量的使用 class, 不多去觸及 prototype 了.javascript

兩面性:java

  1. class 語法糖的本質仍是prototype, 因此應該回歸到寫 prototype 上.typescript

  2. 既然有 class 的寫法了, 並且繼承上也相比原型好寫, 好理解許多, 因此應該向前看, 摒棄掉之前的 prototype 寫法.編程

睿智而理性的讀者, 你的理解是其中之一仍是兩者兼備? 個人見解是: 語法糖存在即合理, 語法糖不只僅是更高層級的封裝, 它能夠避免寫出沒有語法糖時候的 bug, 可是語法糖不是語言自己的特性, 因此也必定要理解背後的成因, 加上原型是 JavaScript 裏面特別特別重要的知識點, 不能不去深究. 你能夠不用, 但不能不懂.數組

好了, 來看看 ES5 的面向對象編程函數

什麼是對象

ECMA的官方解釋: 無序屬性的集合, 屬性可包括基本值, 函數, 對象

注意無序二字, 可理解爲包含必定屬性或方法的鍵值對. 是的, 本質上, 對象就是包含鍵值對映射的集合, 鍵是屬性的名字, 值就是屬性性能

屬性的類型

  1. 數據屬性this

    • configurable
      • 表示是否能夠被配置(除了 enumerable 和 writable 以外), 包含但不限於屬性屬性轉化爲訪問器屬性, 主要是用於 delete 的限制
    • enumerable
      • 是否能夠被 for in 或 Object.keys 到
    • value
      • 屬性具體的值, 默認 undefined
    • writable
      • 可修改 value
  2. 訪問器屬性prototype

    • configurable
      • 表示是否能夠被配置(除了 enumerable 和 writable 以外), 包含但不限於屬性屬性轉化爲訪問器屬性, 主要是用於 delete 的限制
    • enumerable
      • 是否能夠被 for in 或 Object.keys 到
    • get
      • 獲取屬性的值, 默認 undefined
    • set
      • 設置屬性的值, 默認 undefined

注意, 第二個訪問器屬性就是被著名的 React 和 Vue 實現數據響應的原理之一. 再注意, 以上全部的 bool 屬性在沒有進行配置的時候都默認爲 false.code

如何實現這二者的轉化呢

在 configurable 爲 true 的狀況下, 凡是包含 value 或 writable 的會默認爲數據屬性, 會將原有的 get 和 set 屬性刪除, 反之若是設置了 get 或 set, 那麼就會認爲爲訪問器屬性, 將 value 和 writable 刪除

const  o = {}
Object.defineProperty(o, 'name', {
    configurable: true,
    enumerable: false, // 可不寫, 默認爲 false
    value: 'lorry',
    writable: false // 可不寫, 默認爲 false
})
console.log(o)//{name: "lorry"}
o.name = 'jiang'// 不會改變, 由於 writable 爲 false
console.log(o)//{name: "lorry"}
Object.keys(o)// []
// 轉化爲訪問器屬性
o['_name'] = 'lorry'; // 設置私有屬性
Object.defineProperty(o, 'name', {
    get: function(){return this._name},
    set: function(newName){this._name = newName},
    configurable: false,
    enumerable: true
})
console.log(o); // {_name: "lorry"}
o.name = 'setted jiang'
console.log(o.name); // setted jiang
Object.keys(o); // ["_name", "name"]

其餘的方式

除了Object.defineProperty以外, 還有其餘的跟對象屬性相關的原生方法

  • Object.defineProperties(o, {attr1:{}, attr2:{}}), 批量設置一個對象多個屬性

  • Object.getOwnPropertyDescriptor(o, attrName), 獲取對象某個屬性的配置

  • Object.getOwnPropertyDescriptors(o), 獲取對象全部屬性的配置

對象的建立

工廠模式

function createObject(name) {
    var o = new Object();
    o.name = name;
    o.sayName = function() {console.log(this.name)};
    return o;
}

const p1 = createObject('lorry')

優勢: 簡單直觀 缺點: 沒法進行對象識別, 沒有 instanceof 能夠去追溯.

構造函數模式

function Person(name) {
    this.name = name;
    this.sayName = function() {console.log(this.name)};
}
const p1 = new Person('lorry')

注意, 凡是構造函數都應該首字母大寫 優勢:

  • 不顯式建立對象(實質仍是有建立新對象)

  • 使用 this 的上下文對象

  • 不用 return(默認隱式建立的新對象)

  • 可以使用 p1 instanceof Person進行對象識別

缺點:

  • 每一個實例都會生成新的屬性和方法, 會形成內存的浪費(在當今性能過剩的年代, 這個實質上不算什麼問題, 只是顯得代碼不是很規範和專業)

原型模式

function Person() {};
Person.prototype.name = 'Lorry';
Person.prototype.sayName = function() {
    console.log(this.name);
}
const p1 = new Person();
const p2 = new Person();
p1.sayName(); // lorry;
console.log(p1.sayName === p2.sayName) // true

能夠看到兩個實例p1 和 p2 共享同一個 name 屬性和 sayName 的方法, 會節省內存.

注意, 在原型上的方法和屬性是不會被 hasOwnProperty()檢測出來的(Object.keys()一樣如此), 可是在in中是有的.好比

p1.hasOwnProperty('name'); // false
Object.keys(p1); // []
'name' in p1; // true

一種更簡單的定義方法

function Person(){};
Person.prototype = {
    name: 'lorry',
    sayName: function(){
        console.log(this.name)
    },
    //ES6
    sayName2() {
        console.log(this.name)
    }
}

這種方式徹底重寫了 prototype, 包括其原有的 constructor 屬性(指向了字面量對象即 Object)

解決辦法就是手動指定一下

Person.prototype = {
    constructor: Person
}

原型對象的問題:

  1. 實例沒法給構造函數傳值

  2. 共享既是優勢也是缺點, 有些屬性但願各個實例各自保持本身的, 就沒法經過此方法實現

組合模式

看到了嗎? 構造函數模式和原型模式實質上是兩個極端, 一個是每一個實例都是各自爲營, 一個是每一個實例都步調一致, 因此, 二者的結合就是更好的解決方案.也是如今最經常使用的方案.

function Person(name) {
    // 每一個實例各有的
    this.name = name
}
// 每一個實例共享的
Person.prototype.sayName = function() {
    console.log(this.name)
}

還有一種動態原型的變體

function Person(name) {
    this.name = name;
    // 只會在構造函數初始化時建立一次
    if (typeof this.sayName !== 'function') {
        Person.prototype.sayName = function() {
            console.log(this.name)
        }
    }
}

寄生構造函數以及穩妥寄生構造函數模式

首先什麼叫寄生? 以前我只知道這個模式叫寄生, 可是不知道爲何叫寄生. 如今個人理解是: 寄生是一種相互獨立的狀態, 就像寄居蟹, 它能夠爬到任何一個的殼中生活.看下面的例子

function Person(name) {
    const o = new Object();
    o.name = name;
    o.sayName = function() {
        console.log(this.name)
    }
    return o;
}
const p1 = new Person('lorry')
const p2 = Person('lorry')
// p1和p2所擁有的屬性和方法是同樣的.

上述代碼中, 殼就是 function Person(name){}這部分, 寄居蟹就是剩餘的部分, 調用 new Person()返回的對象跟 Person 沒有任何原型上的關係(p1 instanceof Person = false).

這樣有什麼好處呢? 私有變量

function Person(name) {
    const o = new Object()
    o.sayName = function() {
        console.log(name)
    }
    return o;
}
const p1 = new Person('lorry')

p1中就保存了一個穩定對象, 除了調用 sayName 以外沒有任何辦法能夠獲取到構造函數中的數據成員.

對象的和繼承

OO 的語言一般有兩種繼承

  1. 接口的繼承, 只繼承方法簽名

  2. 實現的繼承, 繼承實際的方法

ECMA 只支持實現的繼承, 也就是具體的方法, 固然一些JavaScript 的超集, 好比 typescript 能夠支持接口的繼承.

interface A {
    name: string
}

interface B extends A {
    age: number
}

var b: B = {
    name: 'lorry',
    age: 26
}

原型鏈繼承

原理就是將 SubType 的[[ prototype ]] 屬性指向了 SuperType 的prototype, 本質就是重寫了 prototype.

function SuperType() {
    this.property = false;
}
SuperType.prototype.getSuperValue = function() {
    return this.property;
}
function SubType() {
    this.subProperty = true;
}
// 實現了原型繼承, 擁有 SuperType 的全部實例屬性和方法
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function() {
    return this.subProperty;
}
const subIns = new SubType();
console.log(subIns.getSuperValue());

描述繼承關係: SubType 繼承 SuperType, SuperType 繼承默認的原型 Object. 因此

console.log(subIns instanceof SubType) // true
console.log(subIns instanceof SuperType) // true
console.log(subIns instanceof Object) // true
console.log(Object.prototype.isPrototypeOf(subIns))//true
console.log(SuperType.prototype.isPrototypeOf(subIns))//true
console.log(SubType.prototype.isPrototypeOf(subIns))//true

問題:

  1. 引用類型(好比數組)的原型屬性會被全部實例共享.但實質上之因此在 SuperType 的構造函數中定義屬性就是不但願全部實例共享.

  2. 建立子類的實例時(上例中的 subIns ), 沒法向父類構造函數中傳參.由於繼承不發生在構造函數中

借用構造函數

爲了解決上述的第二個問題, 有了這個構造函數繼承方式

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

function SubType(name) {
    SuperType.call(this, name);
}
const subIns = new SubType('lorry')
subIns.name;// lorry

就跟構造函數的問題同樣, 沒法實現函數的複用.

組合繼承

跟組合建立對象模式同樣, 將借用構造函數和原型鏈繼承的方式組合起來就造成了組合繼承的方式.

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

function SubType(name) {
    // 繼承屬性
    SuperType.call(this, name);
}
// 繼承方法
SubType.prototype = new SuperType()
const subIns = new SubType('lorry')
subIns.sayName() // lorry

注意: 其實在繼承方法的時候也繼承了實例的屬性, 可是在查找原型鏈的時候, 由於實例自己就有其屬性了, 不會再向上到超類中查找, 因此至關於只繼承了方法. 這二者的結合就造成了最經常使用的繼承方式.

原型式繼承

這種方式是臨時建立一個對象, 而後使該對象的原型指向超類, 最後返回該臨時對象的實例. 因此該實例的 [[ prototype ]] 便指向了超類, 即相似對超類進行了一次淺複製.

function object(o) {
    function F(){};
    F.prototype = o;
    return new F();
}
const Person = {
    name: 'lorry',
    friends: ['A', 'B']
}
const anotherPerson = object(Person);

anotherPerson.name = 'Lourance'
anotherPerson.friends.push('C')
console.log(Person.name, Person.friends) // 'lorry', ['A', 'B', 'C']

上述的 object 函數就是 ES5 中的 Object.create()函數不傳第二個參數的狀況, 即Object.create(o)等價於object(o)

寄生式繼承

還記得寄生嗎? 就是那隻寄居蟹.

function createAnother(origin) {
    const clone = object(origin)
    clone.sayName = function() {
        console.log(this.name)
    }
    return clone
}
const anotherPerson = createAnother(Person)
anotherPerson.sayName() // lorry

這種方式有兩個弊端:

  1. 與構造函數相似, 這種方式沒法複用函數.
  2. 寄生的通病不知道其繼承的誰. instanceof 會失效.

寄生組合式繼承

以前說過最經常使用的繼承模式爲組合式繼承, 可是組合式繼承有一個問題就是會重複調用超類兩次, 爲了解決這個問題就可使用寄生組合式繼承.

// 寄生模式
function inheritPrototype(child, parent) {
    const prototype = Object(parent.prototype);
    // 恢復 instanceof 原型鏈的追溯
    prototype.constructor = child;
    child.prototype = prototype;
}
function SuperType(name) {
    this.name = name;
    this.friends = ['A', 'B']
}
SuperType.prototype.sayName = function() {
    console.log(this.name)
}
function SubType(name, age) {
    SuperType.call(this, name);
    this.age = age;
}
inheritPrototype(SubType, SuperType);
const subIns1 = new SubType('lorry', 26);
const subIns2 = new SubType('lorry', 26);
subIns1.sayName(); // lorry
subIns1.friends.push('C');
console.log(subIns1.friends); // ['A', 'B', 'C']
console.log(subIns2.friends); // ['A', 'B']

上述就是最合理的繼承方式, 集寄生式繼承和組合繼承的優勢於一身. YUI 這個庫就採用了上述的繼承方式.

由此, 整個關於對象的內容就說完了.總結一下 建立對象的方式:

  1. 工廠模式
  2. 構造函數模式
  3. 組合模式

繼承的方式:

  1. 原型模式
  2. 寄生式模式
  3. 寄生組合式模式
相關文章
相關標籤/搜索