本章重點#

  • 使用 getter 和 setter 控制物件屬性的存取
  • 透過 proxy 控制物件存取
  • 使用 proxy 處理橫切關注點 (cross-cutting concerns)

8.1 使用 Getter 和 Setter 控制屬性存取#

JavaScript 物件是動態的屬性集合。在許多場景下(驗證屬性值、記錄日誌、在 UI 顯示資料),我們需要能監控和控制物件的變動。

為什麼需要 Getter/Setter?#

以一個 Ninja 建構函式為例,直接修改 skillLevel 屬性存在以下風險:

  • 可能被賦予錯誤型別的值(如 ninja.skillLevel = "high"
  • 無法記錄屬性的變更歷史
  • 無法在屬性變動時自動更新 UI

透過 getter 和 setter,可以將屬性存取包裝為方法呼叫,在讀取和寫入時執行額外邏輯。

8.1.1 定義 Getter 和 Setter#

Getter 和 setter 可以透過兩種方式定義:

  • object literalES6 class 中使用 getset 關鍵字
  • 使用 Object.defineProperty 方法

Object literal 語法:

const ninjaCollection = {
  ninjas: ["Yoshi", "Kuma", "Hattori"],
  get firstNinja() {
    report("Getting firstNinja");
    return this.ninjas[0];
  },
  set firstNinja(value) {
    report("Setting firstNinja");
    this.ninjas[0] = value;
  },
};

// 像普通屬性一樣存取,但會觸發 getter/setter
ninjaCollection.firstNinja; // 觸發 get
ninjaCollection.firstNinja = "Hachi"; // 觸發 set

ES6 class 語法:

class NinjaCollection {
  constructor() {
    this.ninjas = ["Yoshi", "Kuma", "Hattori"];
  }
  get firstNinja() {
    return this.ninjas[0];
  }
  set firstNinja(value) {
    this.ninjas[0] = value;
  }
}

Getter 不接受任何參數;Setter 接受一個參數(賦值表達式的右側值)。存取 getter/setter 屬性時,JavaScript 引擎會自動建立執行上下文並執行對應方法,過程與一般函式呼叫相同。

Figure 8.3: 透過 getter 方法存取屬性時,會隱式呼叫對應的 getter

使用 Object.defineProperty

當需要控制私有物件屬性時,Object.defineProperty 特別有用,因為 getter/setter 方法可以與私有變數建立在同一個函式作用域中,形成閉包:

function Ninja() {
  let _skillLevel = 0;

  Object.defineProperty(this, "skillLevel", {
    get: () => {
      report("The get method is called");
      return _skillLevel;
    },
    set: (value) => {
      report("The set method is called");
      _skillLevel = value;
    },
  });
}

const ninja = new Ninja();
assert(typeof ninja._skillLevel === "undefined"); // 無法直接存取
assert(ninja.skillLevel === 0); // 透過 getter 存取
ninja.skillLevel = 10; // 透過 setter 設值

不需要同時定義 getter 和 setter。若只定義 getter 而嘗試寫入:在非嚴格模式下會被靜默忽略;在嚴格模式下會拋出 TypeError。

8.1.2 使用 Setter 驗證屬性值#

Setter 可以在賦值時驗證傳入值,防止無效資料:

function Ninja() {
  let _skillLevel = 0;

  Object.defineProperty(this, "skillLevel", {
    get: () => _skillLevel,
    set: (value) => {
      if (!Number.isInteger(value)) {
        throw new TypeError("Skill level should be a number");
      }
      _skillLevel = value;
    },
  });
}

const ninja = new Ninja();
ninja.skillLevel = 10; // 正常
ninja.skillLevel = "Great"; // 拋出 TypeError

雖然驗證會增加額外開銷,但這是安全使用動態語言所必須付出的代價。相同原則也可應用於追蹤值變更歷史、執行日誌記錄、提供變更通知等。

8.1.3 使用 Getter 和 Setter 定義計算屬性#

Getter 和 setter 可以定義計算屬性 (computed properties),其值不直接儲存,而是依據其他屬性動態計算:

const shogun = {
  name: "Yoshiaki",
  clan: "Ashikaga",
  get fullTitle() {
    return this.name + " " + this.clan;
  },
  set fullTitle(value) {
    const segments = value.split(" ");
    this.name = segments[0];
    this.clan = segments[1];
  },
};

assert(shogun.fullTitle === "Yoshiaki Ashikaga");

shogun.fullTitle = "Ieyasu Tokugawa";
assert(shogun.name === "Ieyasu");
assert(shogun.clan === "Tokugawa");

當某個值完全取決於物件的內部狀態時,使用計算屬性比 getFullTitle() 方法更能提升程式碼的概念清晰度。

8.2 使用 Proxy 控制存取#

Proxy 是一個代理物件 (surrogate),透過它可以控制對另一個物件的存取。你可以將 proxy 視為 getter/setter 的泛化版本:getter/setter 只能控制單一屬性的存取,而 proxy 可以通用地攔截所有對物件的互動,包括方法呼叫。

const emperor = { name: "Komei" };
const representative = new Proxy(emperor, {
  get: (target, key) => {
    report("Reading " + key + " through a proxy");
    return key in target ? target[key] : "Don't bother the emperor!";
  },
  set: (target, key, value) => {
    report("Writing " + key + " through a proxy");
    target[key] = value;
  },
});

Figure 8.5: 執行 proxy 範例程式碼的輸出結果

Proxy 建構函式接受兩個參數:

  1. target — 被代理的原始物件
  2. handler — 定義 traps(陷阱函式)的物件,當特定操作在 proxy 上執行時會觸發

常見的內建 traps:

  • get / set — 讀取/寫入屬性時觸發
  • apply — 呼叫函式時觸發;construct — 使用 new 運算子時觸發
  • enumeratefor-in 語句時觸發
  • getPrototypeOf / setPrototypeOf — 取得/設定 prototype 時觸發

有些操作無法被 trap 攔截:相等比較(== / ===)、instanceoftypeof。這是因為這些操作的行為不應由使用者自訂函式決定。

8.2.1 使用 Proxy 實現日誌記錄 (Logging)#

傳統做法是在每個屬性的 getter/setter 中加入日誌程式碼,這會讓領域邏輯與日誌程式碼混在一起。使用 proxy 可以優雅地分離:

function makeLoggable(target) {
  return new Proxy(target, {
    get: (target, property) => {
      report("Reading " + property);
      return target[property];
    },
    set: (target, property, value) => {
      report("Writing value " + value + " to " + property);
      target[property] = value;
    },
  });
}

let ninja = { name: "Yoshi" };
ninja = makeLoggable(ninja);

ninja.name; // 自動記錄:Reading name
ninja.weapon = "sword"; // 自動記錄:Writing value sword to weapon

日誌邏輯集中在一處定義,可重複用於任意數量的物件,不需要為每個屬性分別加入日誌程式碼。

8.2.2 使用 Proxy 測量效能 (Performance)#

透過 apply trap,可以在不修改原始函式程式碼的情況下測量函式執行時間:

function isPrime(number) {
  if (number < 2) {
    return false;
  }
  for (let i = 2; i < number; i++) {
    if (number % i === 0) {
      return false;
    }
  }
  return true;
}

isPrime = new Proxy(isPrime, {
  apply: (target, thisArg, args) => {
    console.time("isPrime");
    const result = target.apply(thisArg, args);
    console.timeEnd("isPrime");
    return result;
  },
});

isPrime(1299827); // 呼叫方式不變,但自動測量效能

8.2.3 使用 Proxy 自動填充屬性 (Autopopulating Properties)#

Proxy 可以在存取不存在的屬性時自動建立它,避免 null 例外:

function Folder() {
  return new Proxy(
    {},
    {
      get: (target, property) => {
        report("Reading " + property);
        if (!(property in target)) {
          target[property] = new Folder(); // 自動建立
        }
        return target[property];
      },
    }
  );
}

const rootFolder = new Folder();
// 不會拋出例外,中間層級會自動建立
rootFolder.ninjasDir.firstNinjaDir.ninjaFile = "yoshi.txt";

8.2.4 使用 Proxy 實現負數陣列索引#

許多語言(Python、Ruby、Perl)支援負數索引從陣列尾部存取元素。JavaScript 原生不支援,但可以透過 proxy 模擬:

function createNegativeArrayProxy(array) {
  if (!Array.isArray(array)) {
    throw new TypeError("Expected an array");
  }
  return new Proxy(array, {
    get: (target, index) => {
      index = +index;
      return target[index < 0 ? target.length + index : index];
    },
    set: (target, index, val) => {
      index = +index;
      return (target[index < 0 ? target.length + index : index] = val);
    },
  });
}

const ninjas = ["Yoshi", "Kuma", "Hattori"];
const proxiedNinjas = createNegativeArrayProxy(ninjas);

assert(proxiedNinjas[-1] === "Hattori"); // 從尾部存取
assert(proxiedNinjas[-3] === "Yoshi");

8.2.5 Proxy 的效能代價#

Proxy 會為所有操作增加一層間接層,這會顯著影響效能

  • 在 Chrome 中,proxy 大約慢 50 倍
  • 在 Firefox 中,大約慢 20 倍

建議謹慎使用 proxy。可以在非效能敏感的程式碼中使用,但避免在頻繁執行的程式碼中使用。務必進行效能測試。

8.3 本章總結#

  • 透過 getter、setter 和 proxy 可以監控和控制物件的存取
  • Accessor 屬性可透過 Object.defineProperty 或 object literal / ES6 class 中的 get/set 語法定義
  • get 方法在讀取屬性時被隱式呼叫,set 方法在寫入時被隱式呼叫
  • Getter 可用於定義計算屬性,Setter 可用於資料驗證和日誌記錄
  • Proxy 是 ES6 新增的物件類型,用於控制對其他物件的存取
    • 透過定義 traps 來攔截特定操作(讀取、寫入、函式呼叫等)
    • 無法攔截相等比較、instanceoftypeof
  • Proxy 的實用場景包括:日誌記錄、效能測量、資料驗證、自動填充屬性、負數陣列索引
  • Proxy 效能開銷大,應謹慎使用並進行效能測試