本文 AI 產出,尚未審核

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...inObject.keys() 等列舉操作中出現;configurable 則決定屬性是否可以被重新定義或刪除。

2. 取得與設定描述子

  • Object.getOwnPropertyDescriptor(obj, prop)
    取得 objprop 的完整描述子(只會返回該屬性自己的描述子,不會往原型鏈查找)。

  • 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,把 writableconfigurable 設為 false,因此了解描述子有助於理解這些高階 API 的實作原理。


常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記 enumerable 預設 enumerable: false,導致屬性不會在 for...in 中出現,常讓人誤以為屬性不存在。 使用 Object.defineProperty 時,明確設定 enumerable: true(若需要列舉)。
configurable 設為 false 後無法修改描述子 一旦把屬性設定為不可配置,之後再想改 writableenumerable 或改成存取屬性都會失敗。 只在確定不需要再變更時才設為 false,或先完成所有設定後再凍結。
在嚴格模式下寫入唯讀屬性會拋錯 初學者常在非嚴格模式測試,忽略了嚴格模式的錯誤行為。 在開發階段就使用 'use strict',讓錯誤更早顯現。
使用 this 時忘記綁定正確的上下文 在 getter/setter 中使用 this,若屬性被取出再重新賦值,this 可能指向不同物件。 確保 getter/setter 定義於正確的物件,或使用箭頭函式(注意箭頭函式不會有自己的 this)。
過度使用存取屬性造成效能下降 每次讀寫都會觸發函式呼叫,對大量資料操作可能較慢。 僅在需要驗證、延遲計算或封裝時使用,否則使用普通資料屬性。

最佳實踐

  1. 預設使用普通資料屬性,只有在真的需要控制可寫性、列舉或封裝時才切換到描述子。
  2. 在 API 設計上,盡量把公用屬性設為 enumerable: true,內部或敏感資訊設為 enumerable: false
  3. 使用 Object.defineProperties 一次性定義多個屬性,可提升可讀性與效能。
  4. 在模組化開發時,將不可變的設定物件 Object.freeze,確保全域設定不會被意外改寫。
  5. 結合 TypeScript 時,利用 readonly 修飾符與 JavaScript 的 writable:false 互補,雙重保護資料不被修改。

實際應用場景

1. 建立不可變的設定檔

在大型前端專案中,常有全域設定(如 API 根網址、環境變數)需要在程式執行期間保持不變。使用 Object.freeze 或手動將每個屬性設為 writable:falseconfigurable: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.assignArray.from 等 ES6+ API 時,需要使用 Object.defineProperty 來正確設定新屬性的 enumerableconfigurable,確保 polyfill 行為與原生一致。


總結

屬性描述子是 JavaScript 物件系統的核心機制,透過 value / writable / enumerable / configurable(資料屬性)以及 get / set(存取屬性)讓開發者可以精細控制屬性的行為

  • 取得與設定Object.getOwnPropertyDescriptorObject.definePropertyObject.defineProperties
  • 常見用途:唯讀屬性、隱藏私有欄位、資料驗證、惰性求值、不可變設定
  • 陷阱:忘記設定 enumerable、過早將 configurable 設為 false、嚴格模式下的寫入錯誤
  • 最佳實踐:僅在需要時使用描述子、一次性定義多屬性、凍結不可變資料、配合 TypeScript 加強型別安全

掌握這些概念後,你不只能寫出更安全、更可預測的程式碼,還能在設計大型框架或庫時,提供更完善的 API 保護與封裝能力。從今天開始,試著在你的專案裡加入幾個屬性描述子的練習,體驗它帶來的細緻控制與程式品質的提升吧!