/JavaScript

JavaScript | 關於 Object ,一口氣全說完

前言

原諒我用醜字做開版圖,首先讓筆者先恭喜一下自己從教召地獄中脫離,距離上一次才經過了一年半,在工作才剛換的時候,這通知實在是來的有點快和無奈,不過這次在出發前隨手將「 Speaking JavaScript 」放進行李,想說重新讀一遍 JavaScript 中的 Object 知識,做下筆記,才讓這七天的精實(無聊)生活沒那麼無趣。


Object

本篇文章的內容都是從「 Speak JavaScript 」書中整理,會分成幾個部分講解:

  1. Object (物件)
  2. this(物件執行環境)
  3. Property attributes(特性屬性)
  4. Prototypal inheritance (原型繼承)
  5. Constructor (建構器)
  6. Constructor inheritance (建構器間的繼承)

那以上如果還有推薦多說明的部分,歡迎再留言告訴我!

Object 物件

在 JavaScript 中宣告一個 Object (物件)時使用首尾大括號建立範圍, Object 內的 Property (特性)為一個 key (鍵)搭配一個 value (值),並以逗號區隔每個 Property:

//宣告一個 Object 物件,搭配首尾大括號
let mary = {
    //每個 Property 間使用逗號區隔
    name: 'Mary',
    sayHello: function(){
        console.log(`Hello ${this.name}`)
    },
}

Property 的 value 可以是任何的型態值,包括 string 、 integer 、 function 又或是另一個 Object ,此外, Object 支援了在最後一個 Property 後加上逗號,即使它是最後,這個寫法可以讓之後要調整 Property 的時候比較不會出現錯誤,因為不必在意誰要擺在最後。

使用 Object 中的 Property 方式有兩種:

  1. . 點號運算子,只要在 Object 名稱後使用 . 並指定 Property 的 key ,即可讀取該特性的值。
  2. [] 中括號,在中括號裡可以是一個述句,會經由運算,並將值轉為字串後尋找對應的 key 值。

以下為兩種方式的使用範例:

//使用 . 點號運算子
console.log(mary.name)  
//印出 Mary

//使用 [] 中刮號運算子
const propertyNameA = 'say'
const propertyNameB = 'Hello'
mary[propertyNameA + propertyNameB]()  
//取出 function 後執行,印出 Hello Mary

. 點號運算子應該沒有問題,而下方 [] 中刮號運算子中分別將 sayHello 存到兩個變數,再在 [] 內運算兩個變數加起來的值,得到 sayHello 這個 key 並取出 Function 值執行。

若要為 Object 異動或新增一個 property ,也是以 . 號運算子加上 Property 的 key ,在指定新值:

//異動 Property 的值
mary.name = `${mary.name} Louise`

//新增一個 Property 
mary.friends = ['Jane','Leda']

刪除的話可以使用 delete 加上 Object 的 Property 執行動作:

//賦予一個新的 Property 
mary.age = 18

//刪除該 Property 
delete mary.age
//成功時回傳 true

//該特性消失,在使用時回傳 undefuned
console.log(mary.age)
//undefined

請注意!這與直接指定該 Property 的值為 undefined 不同, delete 會讓 Property 從 Object 中消失。

另外,如果要判斷一個變數是否為 Object 可以使用 typeof 方法:

但是要注意,除了真正的 Object 外, null 、 Array 也都會回傳 Object ,因此在判斷時,記得先處理掉變數型態可能是 null 或Array 的可能性。

this 執行環境

以 Object mary 來看,在 Object 中建立的 Function 都會個隱含的參數 this 可以使用,而 this 的值則指向 Object 本身,因此上方的 this.name 其實就等於 mary.name

不過這裡會有個隱藏的陷阱,當為 mary 這個物件加上印出所有 friend 的 Function :

mary.getFriends = function(){
    //取得 friends ,並用 forEach 將每個 friend 個別列出
    this.friends.forEach(function(friend){
        console.log(`${this.name}'s friend has ${friend}`)
    })
}

雖然執行後能夠列出 Object 中 friends 的資料,但會發現在 forEach 內的 Function 無法用 this 得到 mary 的 name Property :

forEach 內的 Function 無法取得 Object 的 Property name

原因是在 forEach 內執行的 Function 並不是建立在 mary 中,因此他之中的 this 就不會指向 Object 本身,而是代表全域的 Window ,更多關於 this 可以參考「淺談 JavaScript 頭號難題 this:絕對不完整,但保證好懂」,這篇文章講得非常清楚!

當遇到 this 指向錯誤,而無法取得 Object 內的 Property 時,有下列三種解法:

1.在使用 forEach 的情況下,它本身支援指定執行的 this 做為參數:

mary.getFriends = function(){
    //將目前的 this 指定給 forEach 的第二個參數
    this.friends.forEach(function(friend){
        console.log(`${this.name}'s friend has ${friend}`)
    },this)
}

2.將 this 指定給另一個變數:

mary.getFriends = function(){
    
    //將指向 Object 的 this 給 that
    const that = this
    
    this.friends.forEach(function(friend){
        //在 forEach 內直接使用 that
        console.log(`${that.name}'s friend has ${friend}`)
    })
}

3.在執行 Function 時使用 callapplybind 指定 this ,以下先簡單介紹這三個的差別及用法:

使用 call 時,只需將要指定的 this 放入第一個參數,其餘從第二個開始傳入 Function 中,例如:

const mary = {
    name: 'Mary',
}

function writeSpeak(talk){
    console.log(`${this.name} say ${talk}`)
}

//使用 call 對 writeSpeak 指定 this
writeSpeak.call(mary,'Hello')
//印出 Mary say Hello

apply 的使用方式和 call 相同,只是在傳入要執行的 Function 需要的參數時,需將所有的參數放置於一個陣列中:

const mary = {
    name: 'Mary',
}

function writeSpeak(talk){
    console.log(`${this.name} say ${talk}`)
}

//使用 apply 需要將傳入的參數都放進一個陣列中
writeSpeak.apply(mary,['Hello'])

bindcall 傳入參數的方式相同,但 bind 並不會馬上執行 Function ,而是會回傳一個新的 Function :

const mary = {
    name: 'Mary',
}

function writeSpeak(talk){
    console.log(`${this.name} say ${talk}`)
}

//bind 會回傳一個新的 Function
const marySayHello = writeSpeak.bind(mary,'Hello')

//需另外執行
marySayHello()
//印出 Mary say Hello

//也可以不在一開始就給參數
const marySay = writeSpeak.bind(mary)

//執行時再給參數
marySay('Good bye')
//印出 Mary say Good bye

稍微暸解 callapplybind 的使用方法後,便可以依照情況修改 Function ,解決上方抓不到 this 的問題:

mary.getFriends = function(){
    //為 forEach 內的 Function 使用 bind 來指定 this
    this.friends.forEach(function(friend){
        console.log(`${this.name}'s friend has ${friend}`)
    }.bind(this))
}

經過 bind 指定 this , Function 內就抓得到 mary 的 Property 了:

使用 bind 綁定 forEach 的 this

Property attributes 特性屬性

上方說明 Object 的時候有提到, Property 是由一個 key 及 value 定義的,但是嚴格來說, value 只是這個 Property key 的一個屬性而已,除了 value 外還有其他三個沒再露面的 Property attributes ,以下跟著 value 一同列出:

  1. value :代表此 Property 的值,如同上方的 Mary 及 Function 等,預設是 undefined
  2. writable :代表此 Property 可否重新指定值,預設為 false
  3. enumerable :代表此 Property 的可列舉性,受到影響的會有 Object.keys()for(key in obj) 等,預設為 false
  4. configurable : 代表此 Property 的可控制性,能夠讓物件的 Property 無法被刪除,預設為 false

將上方四個 attributes 寫成描述器的樣子:

使用 Object.defineProperty() 可以在定義一個新的 Property 時藉由描述器指定 Property attributes ,在定義 Property attributes 的時候,如果物件中有相同的 Property Key 那就只會去更新該 Property 的屬性,沒有才是定義一個新的:

let mary = {}

//分別傳入 Object 名稱 、 Property 名稱 、 Property attributes 描述器
Object.defineProperty(mary,'name',{
    value: 'Mary',
    writable: true,
    enumerable: true,
    configurable: true,
})

console.log(mary.name)
// Mary

定義完成後,當想確認某 Property 的 attributes 會需要 Object.getOwnPropertyDescriptor(Object,Property) ,這個 Function 會回傳該 Property 的 attributes 描述器:

//第一個參數指定 Object ,第二個參數為 Property 的 Key
Object.getOwnPropertyDescriptor(mary,'name')

//會得到以下 Property 的描述內容
/*
{ 
value: "Mary", 
writable: false, 
enumerable: false, 
configurable: false
}
*/

下方透過將 value 以外的屬性都改為 false 確認使用上的差別:

//將 mary 的 name Property attributes 都設為 false
Object.defineProperty(mary,'name',{
    value: 'Mary',
    writable: false,
    enumerable: false,
    configurable: false,
})

console.log(mary.name)
//印出 Mary

//writable 為 false 將無法再次指定值
mary.name = 'Jack'
console.log(mary.name)
//仍然印出 Mary

//enumerable 為 false 將無法被 Object.keys 列出
console.log(Object.keys(mary))
//列出空陣列 []

//configurable 為 false 將無法刪除
delete mary.name
//回傳 false

console.log(mary.name)
//仍然印出 Mary

以下為實際執行狀況:

當一個 Property 不可修改、列舉、控制時的操作狀態

另外在 enumerablefalse 的狀態下,還是可以使用其他方式列出不可列舉的 Property :

1.使用 for(key in obj) 對剛剛的 mary 下迴圈,會把 enumerablefalse 的資料給列出:

for(key in mary){
    console.log(key)
}
//會將 name 列出

2.使用 Function Object.getOwnPropertyNames(obj) ,可以列出物件中所有「自有特性」:

Object.getOwnPropertyNames(mary)
//會得到陣列 ['name']

最後,如果只是單純不想讓變數被改變為另一個 Object 的話,也能使用 ES6 提供的宣告語句 const ,雖然它無法阻止 Property 被改變或刪除,但是如果使用情況不需那麼嚴謹的話,還是可以考慮它:

// 用 const 宣告一個 
const mary = {
  name: 'Mary',
}

console.log(mary.name) // Mary

mary.name = 'Jack'
console.log(mary.name) // Jack

mary.age = 20
console.log(mary.age) // 20

// 重新指定會出錯: Assignment to constant variable.
mary = {
  name: 'Jack',
}

Prototypal inheritance 原型繼承

一個物件裡除了上方稍微提到的「自有特性」外,還有由原型繼承而來的「繼承特性」,原型繼承的意思是當兩個物件間有相同或類似的 Function 時,可以將它取出放置 Prototype (原型)中,讓兩個物件在建立時以 Object.create(prototype) 繼承自該 Prototype ,便可以不必在建立物件時做多餘的定義:

//建立一個 Prototype object
const personPrototype = {
    sayHello: function(){
        console.log(`Hello ${this.name}`)
    }
}

//透過 Object.create 建立兩個個繼承自 personPrototype 的 object
const mary = Object.create(personPrototype)
const kitty = Object.create(personPrototype)

//為兩個物件建立「自有特性」
mary.name = 'Mary'
kitty.name = 'Kitty'

//使用從 personPrototype 繼承而來的 Function
mary.sayHello()
//印出 Hello Mary

kitty.sayHello()
//印出 Hello Kitty

上方的例子在建立 marykitty 時,都使用 Object.create 讓他們繼承自 personPrototype ,這時候就可以說 personPrototypemarykitty 的 Prototype。

那如果現在有三個 Object 分別為 abcb 繼承自 a ,而 c 又繼承自 b ,這種情況就可以說這三個 Object 在同一個原型關係上,也被稱為 Prototype chain (原型鏈)。

再接著說下去之前,先來提提在物件中呼叫 Property 的流程,當 obj.key 取出一個 Property 時,會先確認當前的 Object 是否擁有相同 key 的 Property ,如果沒有,便會由當前物件向繼承的 Object 中繼續尋找,一直到找到為止,而在找到該 Property 的那個 Object 也被稱為 Home Object。

既然知道在呼叫 Property 時會以 Home Object 為主,那當為某個 Object 新增一個與 Prototype 同名的 property 時,該 Object 便會成為呼叫 Property 的 Home Object ,當然這個操作也不會使 Prototype 中的同名 Property 受影響,只是它不是該 Property 的 Home Object 而已。

繼承後,也有 Function 可以檢查 mary 的 Prototype 是否為 personPrototype ,或是直接取得 kitty 的 Prototype 為何:

//確認 personPrototype 是否為 mary 的 prototype
personPrototype.isPrototypeOf(mary)
//回傳 true

//取得 kitty 的 prototype Object
Object.getPrototypeOf(kitty)
//回傳 personPrototype

也可以透過 Object 的隱藏屬性 proto 得到 Prototype ,經過繼承後的 Object 都會將原型放在 proto 中,以 mary 為例:


提到這裡,已經可以知道一個 Object 會由 Property 及 Prototype 兩個重要的元素組成,因此當要複製另一個相同的 Object 時,就應該要把 Property 及 Prototype 都在新的 Object 上設定正確,如同下方操作:

const copyObj = originObj => {
    //取得 originObj 的 prototype
    const originPrototype = Object.getPrototypeOf(originObj)
    //以 originPrototype 為原型建立一個物件
    let newObj = Object.create(originPrototype)
    
    //取得所有「自有特性」
    const arrOwnProperties = Object.getOwnPropertyNames(originObj)
    //下迴圈處理每個 prototype
    arrOwnProperties.forEach( property => {
        //取得 Prototype attributes 的描述器
        const prototypeDesc = Object.getOwnPropertyDescriptor(originObj,property)
        //將 prototype 與描述器一同指定給新 Object
        Object.defineProperty(newObj,property,prototypeDesc)
    })
  
    //最後回傳新物件
    return newObj
}

執行結果:

新 Object 的 Property 和 Prototype 都和原本的相同

Constructor 建構器

Constructor (建構器)是一個 Function ,可以利用它來建立一些相似的 Object ,被 Constructor 建立出來的 Object 被稱為它的 Instance (實體), Constructor 在使用時與一般 Function 不同,是藉由 new 來呼叫,也就是說,它只有再藉由 new 的狀態下使用才會是 Constructor 。

下方建立一個 Person 作為 Constructor ,並利用它建立 marykitty 物件:

// Constructor 第一個字母通常大寫
function Person(name){
    //設定 this 的 name property 值為傳進來的參數, this 為建立的物件
    this.name = name
}
//用 new 呼叫 Person 使它成為一個 Constructor ,替 mary 及 kitty 設定 name
const mary = new Person('Mary')
const kitty = new Person('Kitty')

mary.name 
// Mary

//每個 Instance 的 constructor 屬性都會指向 Constructor 本身
kitty.constructor === Person
// true

在上方的例子中,就可以說 marykittyPerson 的 Instance ,也可以發現 Constructor 能協助建立擁有基本資料的 Object ,而要處理這些 Object 的 Function 便能放在 Prototype 中,以達成資料在 Constructor 的 Instance , 方法在 Prototype 的關注點分離原則。

但是藉由 Constructor 建立的 Object 就不會再經過 Object.create ,該如何對那些 Instance 設定 Prototype 呢?答案在 Constructor 中,每一個 Constructor 都擁有 Prototype 的屬性,可以把方法設定在 Prototype 中,如此一來,只要是透過該 Constructor 建立的 Instance ,也都會擁有相同的 Prototype :

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

//設定 Prototype
Person.prototype.sayHello = function(){
    console.log(`Hello ${this.name}`)
}

const mary = new Person('Mary')
const kitty = new Person('Kitty')

mary.sayHello()
//印出 Hello Mary

// Prototype 會指向 constructor 的 prototype
Object.getPrototypeOf(kitty) === Person.prototype
// true

關於 Constructor 的 Prototype 也會有一個屬性為 constructor ,正常來說該屬性的值會指向 Constructor 自身,雖然在設計時,不會有非得使用該屬性的情況,但將它維持原有的樣子是個好習慣,因此在上方的例子中才沒有直接覆蓋 Prototype ,而是使用擴充的方式將 sayHello 定義到 Prototype 中,當然如果要直接覆蓋 Prototype ,也只需把 constructor 的值指向 Constructor 就行了:

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

Person.prototype = {
    //先將 constructor 設置好
    constructor: Person,
    sayHello: function(){
        console.log(`Hello ${this.name}`)
    }
}

Constructor inheritance 建構器間的繼承

Constructor 間的繼承有點類似「父類別」及「子類別」的關係,這裡要思考的是,如何在使用一個 Constructor 的時候去呼叫另一個 Constructor 建立 Property ,聽起來很複雜,但簡單來說,可以利用 call 在 Constructor 建立物件時將 this 指定給另一個 Constructor 設定 Property ,實作如下:

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

function Employee(name,dept){
    //將 this 指定給 Person 設定 
    Person.call(this,name)
    this.dept = dept
}

const mary = new Employee('Mary','IT')
const kitty = new Employee('Kitty','HR')

//擁有 name Property
console.log(mary.name)
//Mary

//也有 dept
console.log(kitty.dept)
//HR

負責資料面的 Property 會比較好處理,但是方法類的 Prototype 該怎麼做呢?其實就和 Object 的繼承一樣,以父類別的 Prototype 為原型指定給子類別的 Prototype 就行了,但需要注意的是,因為直接指定父類別 Prototype 的關係,所以要重新把子類別的 constructor 屬性值指定給自己:

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

Person.prototype.sayHello = function(){
    console.log(`Hello ${this.name}`)
}

function Employee(name,dept){
    Person.call(this,name)
    this.dept = dept
}

Employee.prototype = Object.create(Person.prototype)
//將 prototype 的 constructor 屬性指回自身
Employee.prototype.constructor = Employee

const mary = new Employee('Mary','IT')
const kitty = new Employee('Kitty','HR')

//繼承自 Person.prototype 的 function
mary.sayHello()
// Hello Mary

最後來了解一下 Super call (超呼叫),它的意思是在子類別的 Function 中,去呼叫父類別的 Function :

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

Person.prototype.sayHello = function(){
    console.log(`Hello ${this.name}`)
}

function Employee(name,dept){
    Person.call(this,name)
    this.dept = dept
}

Employee.prototype = Object.create(Person.prototype)
Employee.prototype.constructor = Employee
Employee.prototype.selfIntroduction = function(){
    //在呼叫時要指定 this ,因為他的執行環境 Person.prototype 中沒有 name Property
    Person.prototype.sayHello.call(this)
    console.log(`In the ${this.dept} department`)
}

const mary = new Employee('Mary','IT')

marry.selfIntroduction()
//會同時列出
/*
Hello Mary
In the IT department
*/

但從 Property 到 Prototype 為止的處理,在 Employee 中都是把 Person 給寫死,在實務上可以將 Person 存在 Employee 的 Property 中,維持更有彈性的寫法:

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

Person.prototype.sayHello = function(){
    console.log(`Hello ${this.name}`)
}

function Employee(name,dept){
    // construcotr 的 prototype 的 constructor 會指會自己
    Employee._super.constructor.call(this,name)
    this.dept = dept
}

Employee.prototype.selfIntroduction = function(){
    //改成從 Property 中去讀
    Employee._super.sayHello.call(this)
    console.log(`In the ${this.dept} department`)
}

const subclass = (subC,superC) => {
    //處理prototype
    let subProto = Object.create(superC.prototype)
    //將原本 subC.prototype 的 Property 定義給 subProto (這裡和複製物件時用的方式相同)
    const arrOwnProperties = Object.getOwnPropertyNames(subC.prototype)
    arrOwnProperties.forEach( property => {
        const prototypeDesc = Object.getOwnPropertyDescriptor(subC.prototype,property)
        Object.defineProperty(subProto,property,prototypeDesc)
    })
    //將處理完後的 prototype 給 subC 的 protoype
    subC.prototype = subProto
    //定義 _super Property 為父類別的 prototype
    subC._super = superC.prototype
}

//設定 Person 和 Employee 為父子類別
subclass(Employee,Person)

const mary = new Employee('Mary','IT')

mary.selfIntroduction()
//會同時列出
/*
Hello Mary
In the IT department
*/

subclass 內做的事情和複製物件有點像,主要是在處理 Prototype 及 Property ,其實在 JavaScript 中,關於物件的使用及繼承,也只需搞懂這兩個屬性就行了!如上方,通過 subclass 便能將父子類別都設定好,透過將父類別的 Prototype 存在子類別的 Property 裡,在使用上也就更有彈性及簡單。


這篇文章從解召後就開始整理在軍中做的筆記撰寫,但是根據當兵定律,只要一回到家就一定會感冒,這兩天筆者貌似也得了流感,因此才拖了兩天才打完,希望這篇文章能對想了解 JavaScript 中 Object 的人有所幫助。

如果文章中有任何問題,或是不理解的地方,都可以留言告訴我!謝謝大家!

參考文章

  1. https://www.tenlong.com.tw/products/9789863478584