JavaScript 物件 – 屬性描述子(Property Descriptor)
簡介
在 JavaScript 中,物件的每一個屬性都不只是「鍵」與「值」的簡單配對。ECMAScript 5 之後,屬性背後還隱藏著一組 屬性描述子(property descriptor),它決定了屬性是否可寫、是否可列舉、是否可配置,甚至可以自訂 getter / setter。
掌握屬性描述子不僅能讓你更精確地控制物件行為,還能在建構 API、實作資料封裝、或是建立不可變資料結構時發揮關鍵作用。對於剛接觸 JavaScript 的同學來說,了解這些底層機制可以避免許多不易察覺的錯誤;對於已有開發經驗的開發者,則是提升程式品質與可維護性的必備工具。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後帶你看看實務上會在哪些情境使用屬性描述子,幫助你在日常開發中活用這項功能。
核心概念
1. 什麼是屬性描述子?
屬性描述子是一個普通的 JavaScript 物件,用來描述另一個物件屬性的屬性(metadata)。依屬性類型的不同,描述子分為兩大類:
| 類型 | 主要欄位 | 說明 |
|---|---|---|
| 資料屬性 (Data Property) | value, writable, enumerable, configurable |
直接儲存資料的屬性。 |
| 存取屬性 (Accessor Property) | get, set, enumerable, configurable |
透過 getter / setter 取得或設定值。 |
註:
enumerable控制屬性是否會在for...in、Object.keys()等列舉操作中出現;configurable則決定屬性是否可以被重新定義或刪除。
2. 取得與設定描述子
Object.getOwnPropertyDescriptor(obj, prop)
取得 obj 上 prop 的完整描述子(只會返回該屬性自己的描述子,不會往原型鏈查找)。Object.defineProperty(obj, prop, descriptor)
依 descriptor 重新定義或新增 prop。若未明確指定某個欄位,則會使用預設值(value: undefined,writable: false,enumerable: false,configurable: false)。Object.defineProperties(obj, descriptors)
同時定義多個屬性,descriptors 為屬性名稱 → 描述子的映射。
3. 預設屬性描述子
當你用一般方式(obj.prop = 1)建立屬性時,JavaScript 會自動為它套用以下描述子:
{
value: 1,
writable: true,
enumerable: true,
configurable: true
}
如果想要讓屬性變成「唯讀」或「不可列舉」等,就必須自行使用 Object.defineProperty。
程式碼範例
範例 1️⃣:將屬性設為唯讀(writable: false)
const user = {};
Object.defineProperty(user, 'id', {
value: 1001, // 初始值
writable: false, // 之後不能改寫
enumerable: true, // 仍會被列舉
configurable: true // 仍可刪除或重新定義
});
console.log(user.id); // 1001
user.id = 2002; // 嘗試改寫
console.log(user.id); // 仍是 1001,改寫失敗且不會拋錯(非嚴格模式)
重點:在嚴格模式(
'use strict')下,對唯讀屬性賦值會拋出TypeError,有助於早期偵測錯誤。
範例 2️⃣:建立不可列舉的私有屬性
const secret = {};
Object.defineProperty(secret, '_token', {
value: 'abc123',
writable: true,
enumerable: false, // 不會出現在 for...in 或 Object.keys()
configurable: false // 之後無法再刪除或重新定義
});
console.log(Object.keys(secret)); // [],_token 被隱藏
console.log(secret._token); // abc123,仍可直接存取
這樣的屬性常用於「隱藏」實作細節或暫時不想讓外部程式碼看到。
範例 3️⃣:使用存取屬性(getter / setter)實作資料驗證
const product = {};
Object.defineProperty(product, 'price', {
// 只提供 getter,讓外部只能讀取
get() {
return this._price;
},
// setter 內部做驗證
set(val) {
if (typeof val !== 'number' || val < 0) {
throw new TypeError('Price 必須是非負數字');
}
this._price = val;
},
enumerable: true,
configurable: true
});
product.price = 250; // 合法
console.log(product.price); // 250
// product.price = -10; // 會拋出 TypeError
透過存取屬性,你可以在 讀取 或 寫入 時加入自訂邏輯,實作類似「屬性封裝」的效果。
範例 4️⃣:一次定義多個屬性(Object.defineProperties)
const config = {};
Object.defineProperties(config, {
host: {
value: 'localhost',
writable: true,
enumerable: true,
configurable: true
},
port: {
value: 8080,
writable: false, // 埠號不允許變更
enumerable: true,
configurable: false
},
debug: {
get() { return this._debug; },
set(v) { this._debug = !!v; },
enumerable: true,
configurable: true
}
});
config.debug = 1; // 轉成 true
console.log(config); // { host: 'localhost', port: 8080, debug: true }
一次寫多個屬性可以讓程式碼更整潔,也避免多次呼叫 defineProperty 的開銷。
範例 5️⃣:凍結(freeze)與封印(seal)物件的關係
const obj = { a: 1 };
Object.freeze(obj); // 所有屬性變為 writable:false, configurable:false,且不可新增
// 嘗試修改會失敗(非嚴格模式)或拋錯(嚴格模式)
obj.a = 2; // 不會改變
obj.b = 3; // 不會新增屬性
console.log(Object.isFrozen(obj)); // true
Object.freeze 內部其實是對每個屬性呼叫 defineProperty,把 writable 與 configurable 設為 false,因此了解描述子有助於理解這些高階 API 的實作原理。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
忘記 enumerable |
預設 enumerable: false,導致屬性不會在 for...in 中出現,常讓人誤以為屬性不存在。 |
使用 Object.defineProperty 時,明確設定 enumerable: true(若需要列舉)。 |
configurable 設為 false 後無法修改描述子 |
一旦把屬性設定為不可配置,之後再想改 writable、enumerable 或改成存取屬性都會失敗。 |
只在確定不需要再變更時才設為 false,或先完成所有設定後再凍結。 |
| 在嚴格模式下寫入唯讀屬性會拋錯 | 初學者常在非嚴格模式測試,忽略了嚴格模式的錯誤行為。 | 在開發階段就使用 'use strict',讓錯誤更早顯現。 |
使用 this 時忘記綁定正確的上下文 |
在 getter/setter 中使用 this,若屬性被取出再重新賦值,this 可能指向不同物件。 |
確保 getter/setter 定義於正確的物件,或使用箭頭函式(注意箭頭函式不會有自己的 this)。 |
| 過度使用存取屬性造成效能下降 | 每次讀寫都會觸發函式呼叫,對大量資料操作可能較慢。 | 僅在需要驗證、延遲計算或封裝時使用,否則使用普通資料屬性。 |
最佳實踐:
- 預設使用普通資料屬性,只有在真的需要控制可寫性、列舉或封裝時才切換到描述子。
- 在 API 設計上,盡量把公用屬性設為
enumerable: true,內部或敏感資訊設為enumerable: false。 - 使用
Object.defineProperties一次性定義多個屬性,可提升可讀性與效能。 - 在模組化開發時,將不可變的設定物件
Object.freeze,確保全域設定不會被意外改寫。 - 結合 TypeScript 時,利用
readonly修飾符與 JavaScript 的writable:false互補,雙重保護資料不被修改。
實際應用場景
1. 建立不可變的設定檔
在大型前端專案中,常有全域設定(如 API 根網址、環境變數)需要在程式執行期間保持不變。使用 Object.freeze 或手動將每個屬性設為 writable:false、configurable:false,可以防止開發者或第三方套件意外改寫。
const ENV = {
API_BASE: 'https://api.example.com',
VERSION: '1.0.0'
};
Object.freeze(ENV);
// ENV.API_BASE = 'https://malicious.com'; // 在嚴格模式下會拋錯
2. 實作「私有屬性」的封裝
ES6 之前,沒有原生私有屬性的概念。利用 enumerable:false 搭配 Symbol 或前綴 _,可以在物件列舉時隱藏內部資料,減少外部誤用。
const secretKey = Symbol('secretKey');
const account = {};
Object.defineProperty(account, secretKey, {
value: 'my-secret',
writable: false,
enumerable: false,
configurable: false
});
外部只能透過公開的 API 取得資訊,增加安全性。
3. 動態計算屬性(惰性求值)
在大型資料模型中,某些屬性可能只有在需要時才計算。使用 getter 可以延遲計算,避免不必要的效能開銷。
const heavy = {};
Object.defineProperty(heavy, 'expensiveResult', {
get() {
console.log('計算中...');
// 假設這是一個耗時的運算
return Math.random() * 1000;
},
enumerable: true,
configurable: true
});
console.log(heavy.expensiveResult); // 第一次呼叫才會計算
console.log(heavy.expensiveResult); // 每次存取都會重新計算
如果希望只計算一次,可在 getter 內自行把結果寫回實體屬性,再刪除 getter。
4. 框架/庫的 API 防護
許多 UI 框架(如 Vue、React)會在內部使用存取屬性監控狀態變化。開發者若自行在元件上添加屬性,若不小心把框架已使用的屬性 configurable:false,就會導致錯誤。了解描述子能避免與框架衝突。
5. 兼容舊版瀏覽器的 Polyfill
在實作 Object.assign、Array.from 等 ES6+ API 時,需要使用 Object.defineProperty 來正確設定新屬性的 enumerable 與 configurable,確保 polyfill 行為與原生一致。
總結
屬性描述子是 JavaScript 物件系統的核心機制,透過 value / writable / enumerable / configurable(資料屬性)以及 get / set(存取屬性)讓開發者可以精細控制屬性的行為。
- 取得與設定:
Object.getOwnPropertyDescriptor、Object.defineProperty、Object.defineProperties - 常見用途:唯讀屬性、隱藏私有欄位、資料驗證、惰性求值、不可變設定
- 陷阱:忘記設定
enumerable、過早將configurable設為false、嚴格模式下的寫入錯誤 - 最佳實踐:僅在需要時使用描述子、一次性定義多屬性、凍結不可變資料、配合 TypeScript 加強型別安全
掌握這些概念後,你不只能寫出更安全、更可預測的程式碼,還能在設計大型框架或庫時,提供更完善的 API 保護與封裝能力。從今天開始,試著在你的專案裡加入幾個屬性描述子的練習,體驗它帶來的細緻控制與程式品質的提升吧!