JavaScript 的物件導向(OO)

lenlch發表於2018-11-28

在物件導向程式設計的語言中,都有類的概念,可以基於這個類建立無數個擁有相同屬性和方法的物件。在js中,是沒有類的概念的,所以會略有所不同。

物件: {} 這就是一個物件,對,沒錯,就是這麼簡單。我們可以將物件想象成一個雜湊表,無非就是一些鍵值對,值可以是資料或函式。每一個物件都是基於引用型別建立的,可以是原生(基本、引用)型別,也可以是自定義型別。

基本型別(按值訪問): Number, String, Undefined, Unll, Boolean, Symbol.

引用型別(按引用訪問): Object, Function, Array, Date, RegExp...

  1. 在最早的時候,我們都是這樣建立物件的

最早

const obj = new Object()
obj.name = 'len'
obj.age = 23
obj.sayName = function() {
    console.log(this.name)
}

obj.sayName() // len
複製程式碼

A few years later...

字面量

const obj = {
    name: 'len',
    age: 23,
    sayName: function() {
        console.log(this.name)
    }
}
複製程式碼

這樣建立的兩個物件是一樣的,都有相同的屬性和方法。這些屬性在建立的時候,都會帶有一些特徵值。

屬性分兩種:1. 資料屬性 2. 訪問器屬性

資料屬性

configuration: 是否通過delete操作刪除從而重新定義。 預設 true
enumeration: 是否能通過for...in 迴圈返回屬性。 預設 true
writable:  能否修改屬性的值。 預設 true
value:  屬性的值。 預設 undefined
複製程式碼

我們可以這樣設定一個資料屬性的屬性特徵值

let obj = {}
Object.defineProperty(obj, 'name', {
    configuration: true,
    enumeration: false,
    writable: false,
    value: 'len'
})

obj.name // len
obj.name = 'lance'
obj.name // len 因為writable為false,賦值會被忽略,在嚴格模式下會報錯
複製程式碼

tips: 當一個屬性的configuration的特徵值為設定為false的時候,也就是不能配置的時候,就不能再變回可配置的了,

let obj = {}
Object.defineProperty(obj, 'name', {
    configuration: false,
    value: 'len'
})

obj.name // len
delete obj.name
obj.name // len 因為configuration為false,delete會被忽略,嚴格模式下會報錯

// 這時候我們再這樣,除了修改writable以外,其他的都會導致報錯
Object.defineProperty(obj, 'name', {
    configuration: true
})

呼叫的時候,如果不指定這些特徵值,預設為false
複製程式碼

訪問器屬性

configuration: 是否可配置 預設 true
enumeration: 是否可列舉 預設 true
get: 讀取屬性的時候呼叫 預設 undefined
set: 設定屬性的時候呼叫 預設 undefined
複製程式碼

訪問器屬性不能直接定義,必須使用Object.defineProperty()來定義。請看:

var obj = {
  _year: 2018,
  count: 1
}

Object.defineProperty(obj, 'year', {
  get: function() {
      console.log('get')
      return this._year
  },
  set: function(newValue) {
      console.log('set')
      if (newValue > 2018) {
        this._year = newValue
        this.count += newValue - 2018
      }
  }
})

console.log(obj.year) // get 2018

obj.year = 2019 // set

console.log(obj.year) // get 2019

console.log(obj.count) // 2
複製程式碼

_year前面的下劃線是一種常用的幾號,表示只能通過物件方法訪問屬性。而訪問器屬性包含getter、setter, getter返回 _year值, setter設定 _year的值。不一定非要同時指定getter和setter,只指定getter表示屬性是隻讀的。

我們可以用Object.defineProperties()一下定義多個屬性

var obj = {}

Object.defineProperties(obj, {
    _year: {
        value: 2018
    },
    count: {
        value: 1
    },
    year: {
        get: functin() {
            return this._year
        },
        set: function(newValue) {
            if (newValue > 2018) {
                this._year = newValue
                this.count += newValue - 2018
            }
           
        }
    }
})
複製程式碼

注意:資料描述符和存取描述符不能混用,也就是writable或者value和get,set不能混用等...

使用字面量建立物件的缺點: 重複程式碼太多,無法複用

工廠函式

function person(name, age) {
    var obj = new Object()
    obj.name = name
    obj.age = age
    obj.sayName = function() {
        return this.name
    }
    return obj
}

var p1 = person('len', 23)
p1.name // len
p1.age // 23
p1.sayName() // len
var p2 = person('lance', 23)
p2.name // lance
p2.age // 23
p2.sayName() // lance
複製程式碼

優點: 解決了字面量重複程式碼,無法複用的問題

缺點: 不能確定物件的型別。

什麼叫不能確定物件的型別。來,我們看下這個

console.log(p1 instanceof person) // false
console.log(p1 instanceof Object) // true
複製程式碼

所有的例項物件都是Object,自然而言就沒法確定了

建構函式

// 建構函式一般首字母大寫
function Person(name, age) {
    this.name = name
    this.age = age
    this.sayName = function() {
        return this.name
    }
}

let p1 = new Person('len', 23)
console.log(p1.name) // len
console.log(p1.age) // 23
console.log(p1.sayName()) // len

let p2 = new Person('lance', 23)
console.log(p2.name) // lance
console.log(p2.age) // 23
console.log(p2.sayName()) // lance
複製程式碼

和工廠函式的不同

  1. 沒有顯示的建立物件
  2. 沒有將屬性和方法賦值給this物件
  3. 沒有return語句
  4. 生成例項物件的時候使用了new關鍵字

正是使用了new關鍵字 ,所以才沒有做上述的操作,可想而知,都是new在底層幫我們實現了上述功能

new內部所做的事情

  1. 生成一個新的物件
  2. 將建構函式的作用域賦給新物件(this 就指向了新物件)
  3. 執行建構函式中的程式碼 (給新物件新增了屬性和方法)
  4. 返回新物件

手動實現new

function createObj() {
    // 1. 生成新物件
    let obj = new Object()
    let cons = [].unsift.call(arguments)
    // 3. 執行建構函式中的程式碼
    obj.__proto__ = cons.prototype
    // 2. 將建構函式的作用域賦給新物件
    const res = cons.apply(obj, arguments)
    return typeof res === 'object' ? res : obj
}
複製程式碼

建構函式的呼叫方式

  1. 當做建構函式使用 let p = new Person()
  2. 當做普通函式使用 let p = Person() 這時候如果在瀏覽器中 this指向的就是window
  3. 在另一個物件的作用域中呼叫 let o = new Object() / Person.call(o)

優點: 解決了不知道物件型別的問題

console.log(person1.constructor == Person) //true
console.log(person2.constructor == Person) //true

console.log(p1 instanceof Person) // true
console.log(p1 instanceof Object) // true

console.log(p2 instanceof Person) // true
console.log(p2 instanceof Object) // true
複製程式碼

這樣就知道了p1,p2都是Person的例項物件

function Human() {}

let h = new Human()

console.log(h instanceof Human) // true

這樣h就是Human的例項了,這樣就做到了區分
複製程式碼

缺點: 每個方法都要在每個實力上重新建立一遍, 請看

// 從邏輯角度講, 是可以這麼定義的

function Person(name, age) {
    this.name = name
    this.age = age
    
    this.sayName = new Function() {
        console.log(this.name)
    }
}
複製程式碼

每個Person例項都包含一個不同的Fuction例項以顯示name屬性。 以這種方法建立函式,會導致不同的作用域鏈和識別符號解析,但建立Function新例項的機制還是一樣的。因此,不同實力上的同名函式是不相等的, 一下程式碼是可以證明的。

console.log(p1.sayName === p2.sayName) // true

因此,為了解決這個問題,我們可以使用原型模式

原型模式

function Person() {
    
}

Person.prototype.name = 'len'
Person.prototype.age = 23
Person.prototype.sayName = function() {
    return this.name
}

let p1 = new Person()
console.log(p1.name) // len
console.log(p1.age) // 23
console.log(p1.sayName()) // len

let p2 = new Person()
console.log(p2.name) // len
console.log(p2.age) // 23
console.log(p2.sayName()) // len

console.log(p1.sayName === p2.sayName) // true
複製程式碼

上面之所以能列印,是因為原型鏈的關係,例項上沒找到,在原型上找到了

這裡解釋下原型物件,這樣有助於我們理解原型繼承

原型物件:無論什麼時候,我們只要建立了一個新函式,就會根據一組特定的規則為該函式創一個prototype屬性,這個屬性指向函式的原型物件。在預設情況下,所有原型物件會獲得一個constructor屬性,這個屬性包含一個指向prototype屬性所在函式的指標Person.prototype.constructor === Person // true,Person是建構函式,Person.prototype是原型物件。建立建構函式之後,預設只會取得constructor屬性,其他屬性都是從Object繼承而來的。那為什麼當我們獲取p1.name的時候,會從原型上找呢,是因為,在每個例項物件當做,都有一個__proto__屬性指向建構函式的原型物件,也就是Person.prototype,所以才能在原型上找到。又因為Person是從Object繼承而來,所以,Person.prototype.proto === Object.prototype,Object和Object.prototype之前的關係就像p1和Person之間的關係,所以,這樣就形成了原型鏈。

雖然在所有的實現中,我們都無法訪問到__proto__,但可以通過isPrototypeOf()方法來確定物件之間是否存在這種關係

console.log(Person.prototype.isProptotypeOf(p1)) // true
console.log(Person.prototype.isProptotypeOf(p2)) // true
複製程式碼

在es6中增加了一個新方法,叫Object.getPrototypeOf(),該方法返回__proto__的值

console.log(Object.getPrototypeOf(p1) === Person.prototype) // true
console.log(Object.getPrototypeOf(p1).name) // len
複製程式碼

雖然可以通過物件例項訪問儲存在原型中的值,但卻不能通過物件例項重寫原型中的值。

function Person() {}

Person.prototype.name = 'len'

let p1 = new Person()
let p2 = new Person()
p1.name = 'lance'

console.log(p1.name) // lance
console.log(p2.name) // len 還是原型中的值
複製程式碼

我們可以通過hasOwnProperty()來判斷屬性是例項的還是原型的

function Person() {}

Person.prototype.name = 'len'

let p1 = new Person()
let p2 = new Person()

console.log(p1.getOwnProperty('name')) // false
p1.name = 'lance'
console.log(p1.getOwnProperty('name')) // true

delete p1.name // delete 可以完全刪除例項屬性
console.log(p1.getOwnProperty('name')) // false
複製程式碼

我們還可以用in來判斷

function Person() {}

Person.prototype.name = 'len'

let p1 = new Person()

console.log('name' in p1) // true

p1.name = 'lance'

console.log('name' in p1) // true
複製程式碼

所以in操作符不管是例項屬性還是原型上的屬性,只要找到了,就返回true

我們還可以用hasOwnProperty(),只在屬性存在於例項中的時候才返回true, 所以我們可以結合in使用,來判斷,屬性到底是原型的屬性還是例項的屬性

for...in返回的是不管是例項上的還是原型上的,只要是enumeration不為false的所有屬性集合。

在es6中,可以使用Object.keys()來獲取所有可列舉的例項屬性(例項和原型)

還可以使用Object.getOwnPropertyNames,這個獲取的是所有的例項屬性,包括不可列舉的。

更簡單的原型語法

function Person() {}

Person.prototype = {
    name: 'len',
    age: 23,
    sayName: function() {
        return this.name
    }
}
複製程式碼

上面程式碼有個例外,因為我們重寫了Person.prototype,所以constructor不再指向Person,儘管 instanceof操作符還能返回正確的結果,但通過 constructor 已經無法確定物件的型別了,如下所示。

let p1 = new Person();
console.log(p1 instanceof Object) //true
console.log(p1 instanceof Person) //true
console.log(p1.constructor == Person) //false
console.log(p1.constructor == Object) //true
複製程式碼

如果constructor很重要,我們可以這樣

Person.prototype = {
    constructor: Person,
    name: 'len',
    age: 23,
    sayName: function() {
        return this.name
    }
}
複製程式碼

這樣constructor的enumeration會被設定為true,我們可以通過Object.defineProperty()來設定為false

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

原型的動態性

var p1 = new Person()
// 這裡沒有重寫
Person.prototype.sayHi = function(){
    console.log("hi")
}
p1.sayHi() //"hi"(沒有問題!)

再看這個

function Person(){
}
var p1 = new Person()
// 這裡重寫了
Person.prototype = {
    constructor: Person,
    name : "Nicholas",
    age : 29,
    job : "Software Engineer",
    sayName : function () {
        return this.name
    }
}
p1.sayName() //error
複製程式碼

為什麼這裡報錯了呢?是因為p1指向的原型中不包含以該名字命名的屬性。重寫原型物件切斷了現有原型與任何之前已存在的物件例項之間的聯絡,他們應用的任然是最初的原型。

原型物件的問題,所有的例項和方法都是共享的,當然,你可以重新覆蓋之前的屬性。但是,對引用型別的話,就沒這那麼好受了,請看

function Person() {
}

Person.prototype = {
    name: 'len',
    age: 23,
    loveColors: ['white', 'black', 'red']
}

let p1 = new Person()
let p2 = new Person()

p1.loveColors.push('yellow')

console.log(p1.loveColors) // ['white', 'black', 'red', 'yellow']
console.log(p2.loveColors) // ['white', 'black', 'red', 'yellow']
console.log(p1.loveColors === p1.loveColors) // true
複製程式碼

我們怎樣才能做到引用型別的私有化呢?這就是我們下面要說的。

組合使用建構函式模式和原型模式, 廢話不多說,直接上程式碼

function Person(name, age) {
    this.name = name
    this.age = age
    this.loveColors = ['black', 'white']
}

Person.prototype = {
    constructor: Person,
    sayName: function() {
        return this.name
    }
}

let p1 = new Person()
let p2 = new Person()

p1.loveColors.push('red')

console.log(p1.loveColors) // ['black', 'white', 'red']
console.log(p2.loveColors) // ['black', 'white']

console.log(p1.loveColors === p2.loveColors) // false
console.log(p1.sayName === p2.sayName) // true
複製程式碼

這種方式是目前使用最廣泛、認同度最高的一種建立自定義型別的方法

下面還有兩種模式供參考

動態原型模式

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

這裡只有在sayName不存在的情況下,才會將它新增到原型中。這段程式碼只會在初次呼叫建構函式時才會執行。

唯一需要注意的是:不能使用字面量重寫原型。在已經建立了例項的情況下重寫原型,會切斷現有例項與新原型之間的關係。

寄生建構函式模式

function Person(name, age) {
    let obj = new Object()
    obj.name = name
    obj.age = age
    obj.sayName = function() {
        return this.name
    }
    return obj
}

let p = new Person('len', 23)

console.log(p.sayName()) // len

這種模式其實和工廠函式一模一樣,不同的在於生成例項的時候,這裡使用了new操作符。這個模式可以在特殊的情況下用來為物件建立建構函式。

function SpecialArray(){
    //建立陣列
    var values = new Array();
    //新增值
    values.push.apply(values, arguments);
    //新增方法
    values.toPipedString = function(){
    return this.join("|");
    };
    //返回陣列
    return values;
}
var colors = new SpecialArray("red", "blue", "green");
alert(colors.toPipedString()); //"red|blue|green"
複製程式碼

穩妥建構函式模式

function Person(name, age) {
    var obj = new Object()
    obj.name = name
    obj.age = age
    obj.sayName = function() {
        return this.name
    }
    return obj
}

let p = Person('len', 23)
console.log(p.sayName) // len


這種模式有兩個限制,不能在建構函式內使用this,不能使用new生成例項。比較適用於一些安全的環境中。
複製程式碼

相關文章