你懂 JavaScript 嗎?#17 物件(Object)

你所不知道的 JS

關於物件,本文會提到

語法(Syntax)

建立物件有兩種方式

簡單來說,兩者主要的差別是新增屬性時,字面值可在物件建立時一次全部加入,但建構形式必須在物件建立後一筆一筆新增。

型別(Type)

JavaScript 的資料型態有七種-字串(string)、數字(number)、布林值(boolean)、null、undefined、物件(object)與 symbol。其中函式(function)和陣列(array)、日期(date)皆為物件的一種,function 是可呼叫的物件,而 array 是結構較嚴謹的物件。

typeof

關於型別就會想到型別檢測,想到型別檢測就不得不提一下 typeof 的議題…

typeof 'Hello World!'; // 'string'
typeof true; // 'boolean'
typeof 1234567; // 'number'
typeof null; // 'object'
typeof undefined; // 'undefined'
typeof { name: 'Jack' }; // 'object'
typeof Symbol(); // 'symbol'
typeof function () {}; // 'function'
typeof [1, 2, 3]; // 'object'
typeof NaN; // 'number'

這裡會看到幾個有趣的(奇怪的)地方…

每次看到 typeof 都很想問 JavaScript 的作者…

你的良心不會痛嗎?

記這麼多東西,都累到要倒地不起了(哭

另外,如果想知道這個物件到底是屬於哪個子型別,則可使用 Object.prototype.toString 來檢視 [[Class]] 這個內部屬性。

Object.prototype.toString.call([1, 2, 3]); // "[object Array]"
Object.prototype.toString.call({ name: 'Jack' }); // "[object Object]"
Object.prototype.toString.call(function sayHi() {}); // "[object Function]"
Object.prototype.toString.call(/helloworld/i); // "[object RegExp]"
Object.prototype.toString.call(new Date()); // "[object Date]"

不過,這個方法其實是借用建構形式其實就是物件包裹器的概念,而能使用物件型別值內的 [[Class]] 屬性來辨別這個值是屬於物件的哪個子分類。

內建物件(Built-in Objects)

內建物件指的是使用內建函式所建立的物件,這些物件都屬於物件子型別的一種,除了上面提到的陣列、函式與日期外,這裡列出物件所有的子型別:String、Number、Boolean、Object、Function、 Array、Date、RegExp、Error,它們的用途是給予開發者取得屬性或方法的使用,也就是我們常聽到的原生功能。因此,當使用建構式建立字串、布林或數字等值時,建立的其實不是基本型別值而是物件,因此可用 instanceof 來檢查是由哪個建構式建立,也就是來判斷是否為指定的物件型別。

如下,使用字串字面值宣告了一個字串 str,當我們使用 str 進行 .length 的操作以取得其長度時,JavaScript 就會將這個字串基本型別的值強制轉型成對應的物件子型別,也就是上面提到的 String。

const str = 'Hello World!';
str instanceof String; // false
str.length; // 12

const strObj = new String('Hello World!');
strObj instanceof String; // true
strObj.length; // 12

注意

更多關於原生功能的資訊,可參考這裡

內容(Contents)

物件的內容是由屬性組成的,而屬性是由 key-value pair 構成,value 可為任意資料型別的值,並且值是以參考(reference)的方式(存位置)儲存。

如何存取物件的屬性呢?有兩種方式

  1. 特性存取(property access),意即 .
  2. 鍵值存取(key access),意即 [ ]

其中,特性存取 . 必須符合識別字的規範,簡單說就是只能是字母、數字、$(錢字號)或 _(底線),並且不能以數字開頭,之後可加上 a-z、A-Z、$_ 和數字 0-9,可為關鍵字或保留字。

讓我們來看一些疑難雜症吧!

Q1:如果屬性名稱是特殊字符或動態產生的,該怎麼存取它的值呢?

若要使用一些包含特殊字符或動態產生的字串作為屬性名稱,就必須使用鍵值存取 [ ] 的方式。

包含特殊字符的屬性名稱。

const obj = {
  '!!12345!!': 'Hello World',
};

obj.!!12345!! // Uncaught SyntaxError: Unexpected token !
obj['!!12345!!'] // "Hello World"

ES6 新增動態產生的字串作為屬性名稱功能,讓 key 的值可經由運算得出。

const prefix = 'fresh-';

const fruits = {
  [prefix + 'apple']: 100,
  [prefix + 'orange']: 60,
};

fruits['fresh-apple']; // 100
fruits['fresh-orange']; // 60

Q2:屬性真的只能是字串嗎?可以是數字、物件等其他型別的值嗎?

屬性名稱只能是字串,若不是字串則會被強制轉為字串。

如下,obj[obj] 的 key 值被強制轉為字串 '[object Object]',同理,obj[999] 的 key 值 999 也被轉為字串 '999' 了。

const obj = { Qoo: '有種果汁真好喝' };
obj[obj] = '喝的時候酷兒';
obj[999] = '喝完臉紅紅!';

obj['[object Object]']; // '喝的時候酷兒'
obj['999']; // '喝完臉紅紅!'

(2020/01/17 更新)

相較於物件的屬性名稱(或稱鍵值 key)一定要是字串,Map 允許鍵值可為任何資料型別,無論是字串、數字、布林或物件甚至是 NaN(備註)都是可以的。

如下所示,將 Map 依序存入不同資料別型的鍵值與其對應的字串值。

const map = new Map();

map.set('1', 'str1'); // 鍵值是字串
map.set(1, 'num1'); // 鍵值是是數字
map.set(true, 'bool1'); // 鍵值是布林
map.set({ a: 1 }, 'obj1'); // 鍵值是物件

執行以下程式碼,會發現鍵值數字 1 並沒有被轉為字串 1,因此會得到不同的結果。

map.get(1); // 得到 'num1'
map.get('1'); // 得到 'str1'

[備註] 在 Map 中,由於是依照演算法 SameValueZero 來比對鍵值,因此 NaN === NaN 成立,NaN 可當鍵值。


函式(Function)vs 方法(Method)

闢謠…澄清…!!??

在其他語言中,屬於某個物件的函式稱為方法,但在 JavaScript 中,函式並不會特別屬於某個物件,物件充其量也只是儲存對某個函式的參考而已,並非真的「屬於」這個物件,因此,在 JavaScript 中,函式與方法是同義的,並沒有區別。除了在 ES6 新增的 super 參考,super 與 class 一起使用時 super 會靜態綁定函式,經由這樣所綁定的函式就比較接近一般在其他語言所看到的方法了。

陣列(Array)

陣列使用非負整數作為索引,注意…

const array = [1, 2, 3];
array.length; // 3
array[3] = 4;
array.length; // 4
array['foo'] = 'bar';
array.length; // 4, 陣列的長度不變!
const array = [1, 2, 3];
array['3'] = 'foo';
array; // [1, 2, 3, "foo"]

複製物件

複製

複製物件的方式分為淺拷貝與深拷貝兩種。

為什麼要探討淺拷貝與深拷貝呢?這是根本於基本型別值是傳值而物件是傳參考的緣故,既然物件是傳參考,就要考慮是把整份資料都複製一份,還是複製參考就好?淺拷貝是複製參考,深拷貝是把整份資料都複製一份,常用於考慮物件資料是否要共用的狀況。

淺拷貝(Shallow Copy)

除了基本的資料型別中純值(非物件)的資料會真的複製另外一份值之外,其他的都只是複製一份參考而已。例如:Object.assign 在處理超過一層的物件時就只能做到淺拷貝,只有一層的話是可以做到深拷貝的。

深拷貝(Deep Copy)

複製整個物件,通常會使用 JSON-safe 的物件,先經由序列化為 JSON 字串後再剖析回物件。

const newObj = JSON.parse(JSON.stringify(oldObj));

範例如下。

單層物件時,Object.assign 與「先序列化再剖析」的方法都可以做到完全的拷貝,也就是深拷貝,由於物件的比對的是比較儲存位置,因此當比較拷貝結果時,兩者是不相等的。

const simpleObj = {
  a: 1,
  b: 2,
};

const newSimpleObj = Object.assign({}, simpleObj);
newSimpleObj === simpleObj; // false

const newSimpleObj2 = JSON.parse(JSON.stringify(simpleObj));
newSimpleObj2 === simpleObj; // false

那麼,物件再多層的狀況下,又是怎樣的狀況呢?如下,由於 Object.assign 只能做單層的拷貝,因此第二層開始就只是複製參考而已,儲存位置不變,故為 true;而「先序列化再剖析」的方法則是完整地把整個資料複製起來,存到另一個地方,因此儲存位置不同,得到 false。

const obj = {
  a: 1,
  b: {
    c: 2,
    d: 3,
  },
};

const newObj = Object.assign({}, obj);
newObj.b === obj.b; // true

const newObj2 = JSON.parse(JSON.stringify(obj));
newObj2.b === obj.b; // false

這篇文章將淺拷貝與深拷貝寫得生動有趣、清楚明暸,歡迎閱讀。

屬性描述器(Property Descriptor)

屬性描述器可用來檢視屬性的特徵,例如:可否寫入(writable)、可否配置(configurable)與可否列舉(enumerable)。

例如,檢視 object.a 這個屬性的特徵。

const obj = {
  name: 'Apple',
};

Object.getOwnPropertyDescriptor(obj, 'name');

得到結果。

{
  value: "Apple",
  writable: true,
  enumerable: true,
  configurable: true,
}

使用 defineProperty 定義物件的屬性與特性。通常使用這種方法的目的是…

範例如下,為物件 obj 定義一個新的屬性 name,並設定其特徵值。

const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: true,
  configurable: true,
  enumerable: true,
});

obj.name; // 'Apple'

Writable

屬性的值是否「可被寫入」。

例如,設定 name 這個屬性是不可寫入的,因此當嘗試更新這個值的時候,發現無法更新,並且在 strict mode 之下會丟出 TypeError 的錯誤訊息。

const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: false, // 不可寫入!
  configurable: true,
  enumerable: true,
});

obj.name; // 'Apple'
obj.name = 'Grape';
obj.name; // 'Apple',屬性 name 的值無法被變更!

Configurable

屬性是否是「可配置的」,意即當 configurable 為 false 的時候,無法再使用 defineProperty 更新特徵的值,否則會丟出 TypeError。但有一個例外,當 configurable 為 false 的時候,writable 仍可由 true 改為 false,但不能從 false 改為 true。

當 configurable 為 false 的時候,無法再使用 defineProperty 更新特徵的值,否則會丟出 TypeError。

const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: true,
  configurable: false,
  enumerable: true,
});

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: true,
  configurable: true, // false -> true
  enumerable: true,
});

// Uncaught TypeError: Cannot redefine property: name

當 configurable 為 false 的時候,writable 仍可由 true 改為 false,但不能從 false 改為 true。

const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: true,
  configurable: false,
  enumerable: true,
});

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: false,
  configurable: false,
  enumerable: true,
});

// 這是可行的!

當 configurable 為 false 的時候,writable 仍可由 true 改為 false,但不能從 false 改為 true。

const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: false,
  configurable: false,
  enumerable: true,
});

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: true,
  configurable: false,
  enumerable: true,
});

// Uncaught TypeError: Cannot redefine property: name

除了是否可更新特徵的設定外,configurable 另一個作用就是是否可被 delete 刪除該屬性。

configurable 設為 false,屬性不可刪除。

const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: true,
  configurable: false,
  enumerable: true,
});

delete obj.name;

obj.name; // 'Apple',name 屬性未被刪除!

configurable 設為 true,屬性可被刪除。

const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: true,
  configurable: true,
  enumerable: true,
});

delete obj.name;

obj.name; // undefined
obj; // {}

Enumerable

特徵是否為「可列舉的」,例如:此物件的特性是否可在 for…in 中被列舉,若設定 enumerable 為 false 表示不會被列舉出來。

如下,(1) 印出 hello 和 name,(2) 由於 name 被設定為不可列舉的,因此只會印出 hello。

const obj = {};
obj.hello = 'world';

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: true,
  configurable: true,
  enumerable: true,
});

for (let prop in obj) {
  console.log(prop); // (1)
}

// hello
// name

Object.defineProperty(obj, 'name', {
  value: 'Apple',
  writable: true,
  configurable: true,
  enumerable: false,
});

for (let prop in obj) {
  console.log(prop); // (2)
}

// hello

Vue

題外話,會用到 defineProperty 這個東西是為了寫雙向綁定的小工具而追 Vue.js 的原始碼的時候玩到的,推薦閱讀這篇文章。

不可變性(Immutability)

如何實作無法被變更的特性或物件?以下會介紹幾種作法,但都只能做到淺層的不可變性(shallow immutability),意即若此物件有指向其他物件的參考,這個被指到的物件的內容就仍是可變的。

如下,假設 foo 是不可變的,但 foo.list 指向一個陣列,這個陣列的內容是可變的。

foo.list = [1, 2, 3];
foo.list.push(4);
foo.list; // [1, 2, 3, 4]

物件常數(Object Constant)

使用 defineProperty 設定 writable 為 false 且 configurable 為 false,即可建立一個特性等同於常數的物件屬性,無法被更新、重新定義和刪除。

const obj = {};

Object.defineProperty(obj, 'CONST_PI', {
  value: 3.14,
  writable: false,
  configurable: false,
  enumerable: true,
});

obj.CONST_PI; // 3.14

避免擴充(Prevent Extensions)

使用 Object.preventExtensions 防止物件被加入新屬性。

const obj = {
  name: 'Jack',
};

Object.preventExtensions(obj);

obj.hello = 'world';
obj.hello; // undefined

備註,在嚴格模式下,加入新屬性會丟出 TypeError。

密封(Seal)

使用 Object.seal 來達到密封的作用,意即物件不可再新增屬性、重新配置特徵或刪除屬性,但可能可以修改屬性值。

Object.seal 會做兩件事

const obj = {
  name: 'Jack',
};

Object.seal(obj);

// 嘗試加入新的屬性 hello
obj.hello = 'world';
obj.hello; // undefined

// 嘗試刪除屬性 name
delete obj.name; // false
obj.name; // 'Jack'

// 嘗試重新設定特徵值
Object.defineProperty(obj, 'name', {
  value: 3.14,
  writable: false,
  configurable: true,
  enumerable: true,
});

// TypeError

凍結(Freeze)

凍結(Freeze)

使用 Object.freeze 建立一個已凍結的物件,意即這個物件不能新增屬性、更新屬性的值、刪除屬性和重新配置特徵值。

Object.freeze 會做以下的事情

回顧前面提到的,以上四種解法都只能做到做到淺層的不可變性(shallow immutability),因此若希望能將整個物件(包含屬性參考的物件)都凍結,可遞迴呼叫 Object.freeze,但可能有副作用,像是凍結了共用的物件。

const list = ['apple', 'grape'];

const obj = {
  name: 'Jack',
  favFruits: list,
};

const anotherObj = {
  name: 'Apple',
  favFruits: list,
};

Object.freeze(obj);
Object.freeze(obj.favFruits);

// 共用的物件被凍結!
list.push('orange'); // Uncaught TypeError: Cannot add property 2, object is not extensible

[[Get]]

[[Get]] 的功用是取得屬性值,例如:obj.a 時會呼叫 [[Get]]() 這個函式呼叫,它會先在此物件內尋找是否有符合名稱的屬性,若無就順著原型串鏈繼續尋找,如果都沒有找到,[[Get]] 就會回傳 undefined。注意,這和之前提到的在語彙範疇中查找變數(的名稱是否被定義)是不同的,若在語彙範疇中找不到該變數,會丟出 ReferrenceError。

[[Put]]

[[Put]] 的功用是…

若此屬性不存在,則新增此屬性並設定其值。但若此屬性存在,則做以下的事情…

備註:上面提到的兩個名詞,這裡來做解釋…

取值器(Getter)與設值器(Setter)

物件預設的 [[Get]][[Put]] 掌控了屬性的建立、設定和更新、取得值的方式。若要複寫這兩種預設 [[Get]][[Put]] 行為,可透過取值器與設值器來達成。

方法一,使用物件字面值的方式定義屬性。

const obj = {
  get name() {
    return this._name_;
  },
  set name(val) {
    this._name_ = `Hi, I am ${val}`;
  },
};

obj.name = 'Jack';
obj.name; //'Hi, I am Jack'

方法二,使用 defineProperty 的方式定義屬性。

const obj = {};

Object.defineProperty(obj, 'name', {
  configurable: true,
  enumerable: true,
  get: function name() {
    return this._name_;
  },
  set: function name(val) {
    this._name_ = `Hi, I am ${val}`;
  },
});

obj.name = 'Jack';
obj.name; //'Hi, I am Jack'

存在(Existence)

既然屬性不存在的時候,會回傳 undefined,但若屬性值原本就設定為 undefined 是不是就無法判定這個屬性到底存不存在了?

解法是使用 hasOwnProperty,若想進一步確認該屬性是否可在其他物件中找到,可搭配 prop in obj 檢查這個屬性是否存在於原型串鏈中。兩者差異是 prop in obj 會檢查原型串鏈,而 hasOwnProperty 只會檢查該物件。

範例如下。

var obj1 = {
  job: undefined,
};

var obj = Object.create(obj1); // 建立 obj 與 obj1 的連結
obj.name = undefined;

屬性 name 真的存在於 obj 嗎?

obj.name; // undefined
obj.hasOwnProperty('name'); // true

屬性 name 其值雖然為 undefined,但它真的存在於 obj。

屬性 job 真的存在於 obj 嗎?

obj.job; // undefined
obj.hasOwnProperty('job'); // false

'job' in obj; // true,但在原型串鏈中可找到
obj1.hasOwnProperty('job'); // true

屬性 job 其值雖然為 undefined 且不存在於 obj 中,但可在原型串鏈中可找到,因此進一步檢視 obj1,確定為 obj1 的屬性。

屬性 hello 真的存在於 obj 嗎?

obj.hello; // undefined
obj.hasOwnProperty('hello'); // false
'hello' in obj; // false

雖然 hello 的值是 undefined,似乎與前面的例子無異,但使用 hasOwnProperty 檢視,發現不在 obj 物件中,且經由 prop in obj 確認後發現也無法在原型串鏈中找到,因此屬性不存在。

總結…

列舉(Enumeration)

檢視屬性是否可被列舉的方法。

in

in 運算子只會帶出可列舉的屬性。

例如,obj 有兩個屬性 name 和 hello,其中 name 為可列舉的,hello 為不可列舉的。

for (let k in obj) {
  console.log(k, obj[k]);
}

// 'name', 'Jack'

propertyIsEnumerable

propertyIsEnumerable 檢視屬性是否可被列舉。

例如,obj 有兩個屬性 name 和 hello,其中 name 為可列舉的,hello 為不可列舉的。檢視 name 是否為可列舉的,會回傳 true。

obj.propertyIsEnumerable('name'); // true

Object.keys vs Object.getOwnPropertyNames

Object.keysObject.getOwnPropertyNames 都只回傳此物件的屬性,且皆不檢視原型串鏈,兩者差異在於

例如,obj 有兩個屬性 name 和 hello,其中 name 為可列舉的,hello 為不可列舉的。

Object.keys(obj); // ['name']
Object.getOwnPropertyNames(obj); // ['name', 'hello']

迭代(Iteration)

迭代出陣列的值的方法。

forEach

迭代陣列中所有的值。

const list = ['Apple', 'Bob', 'Cathy', 'Doll'];

list.forEach((item, index, array) => {
  console.log(item, index, array);
});

forEach

every

檢查陣列中的每個值是否符合條件,若是則回傳 true。持續進行直到結束,或 callback 中回傳 false 就停止迭代。

const list = [
  {
    name: 'apple',
    count: 20,
  },
  {
    name: 'corn',
    count: 100,
  },
  {
    name: 'grape',
    count: 50,
  },
  {
    name: 'pineapple',
    count: 80,
  },
];

const result = list.every((item, index, array) => {
  console.log(item, index, array);
  return item.count > 50;
});

console.log(`result: ${result}`);

every

some

檢查陣列中的是否有值符合條件,若是則回傳 true。持續進行直到結束,或 callback 中回傳 true 就停止迭代。

const list = [
  {
    name: 'apple',
    count: 20,
  },
  {
    name: 'corn',
    count: 100,
  },
  {
    name: 'grape',
    count: 50,
  },
  {
    name: 'pineapple',
    count: 80,
  },
];

const result = list.some((item, index, array) => {
  console.log(item, index, array);
  return item.count > 50;
});

console.log(`result: ${result}`);

some

若想看更多陣列處理方法,可參考這裡

for...of

使用 ES6 的 for...of 迭代陣列。

const array = [1, 2, 3];

for (let v of array) {
  console.log(v);
}

// 1
// 2
// 3

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

References


同步發表於2019 鐵人賽


You-Dont-Know-JS javascript 你所不知道的JS 2019鐵人賽 你懂JavaScript嗎? 鐵人賽 You-Dont-Know-JS-this-and-Object-Prototypes undefined NaN 你懂 JavaScript 嗎?2019 iT 邦幫忙鐵人賽 系列文