javaScript設計模式 : Proxy(代理模式)

 


很多人聽過「代理模式」,但到 JavaScript 裡常卡在「到底什麼時候該用 Proxy?」其實答案很生活化:想加日誌又不想改一堆檔、想把輸入驗證關到邊界、想讓昂貴資源延後建立,或希望對某個函式加上快取、重試與節流——這些都是 Proxy 擅長的場景。

本文不走炫技路線,而是用一系列小而完整的例子,示範 Proxy 如何在不影響原本 API 的前提下,加上你需要的控制點。你會看到每個 trap 的直覺用途、Reflect 的角色、何時用可撤銷代理、以及與 Decorator/Adapter/Facade 的差別。最後附上常見雷點清單與修正做法,幫你避開 this 綁定、陣列索引、不可寫屬性等細節坑洞。希望本篇文章可以幫助需要的您。


目錄

{tocify} $title={目錄} 


代理(Proxy)設計模式是什麼?

Proxy(代理)是一種結構型設計模式:在客戶端與實際目標物件之間放入一個「替身」,攔截請求、加料或改道,再轉發給真實物件。

典型用途:

        存取控制/權限管理(Protection Proxy)

        惰性載入/延遲初始化(Virtual Proxy)

        快取與去抖/節流(Cache/Rate-limit Proxy)

        紀錄、監控、稽核(Logging/Audit Proxy)

        失敗重試與容錯(Resiliency Proxy)

一句話總結:在不改變原物件程式碼的前提下,於「呼叫路徑」上插入可組態的控制點。


ES6 Proxy 與設計模式的對照關係

ES6 提供內建 new Proxy(target, handler),讓你以語言層級攔截屬性讀寫、函式呼叫、in、delete、new 等操作。

這正是實作「代理模式」的理想工具。

        target:被代理的原物件(或函式)。

        handler:一組攔截器(traps)方法,如 get、set、apply、construct…。

        Reflect:與 Object 近似的標準工具箱,提供對應的原語操作(如 Reflect.get、Reflect.set),可用來安全地委派到原行為並遵守語言不變式。

設計模式層面的「代理」≠ 語言層面的 Proxy 物件,但兩者可自然對接:用 ES6 Proxy 來實作設計模式中的代理。


Handler 與 Reflect:常用 trap 快速圖鑑

Trap 觸發時機 常見用途 推薦委派
get(target, prop, receiver) 物件屬性讀取 日誌、權限、惰性載入、響應式追蹤 Reflect.get
set(target, prop, value, receiver) 物件屬性寫入 驗證、型別守衛、髒值標記 Reflect.set
(回傳 true/false
has(target, prop) prop in obj 隱藏欄位、存取白名單 Reflect.has
deleteProperty(target, prop) delete obj[prop] 禁刪/軟刪除 Reflect
.deleteProperty
ownKeys(target) Object.keys
/entries
/getOwnPropertyNames
欄位過濾、隱藏內部屬性 Reflect.ownKeys
getOwnPropertyDescriptor
defineProperty
讀/設屬性描述 嚴格控制可列舉性、可寫性 對應 Reflect.*
apply(target, thisArg, args) 函式呼叫 記錄、快取、重試、限流 Reflect.apply
construct(target, args, newTarget) new 呼叫 建構流程加料、DI 注入 Reflect.construct


原則:在自訂邏輯後盡量用 Reflect.* 委派,既正確又可避免觸發語言不變式錯誤。


七種實務場景與可用範例

1.    觀測與記錄(Logging / Debug Proxy)

在不改動既有物件的情況下,追蹤誰讀了什麼、寫了什麼。

function createLoggerProxy(obj, name = 'obj') {
  return new Proxy(obj, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);
      console.debug(`[GET] ${name}.${String(prop)} ->`, value);
      return value;
    },
    set(target, prop, value, receiver) {
      console.debug(`[SET] ${name}.${String(prop)} =`, value);
      return Reflect.set(target, prop, value, receiver);
    }
  });
}

// 使用
const user = createLoggerProxy({ id: 1, role: 'editor' }, 'user');
user.role;          // console: [GET] user.role -> "editor"
user.role = 'admin' // console: [SET] user.role = "admin"


要點:別忘了回傳 Reflect.set 的布林結果;嚴格模式下若回傳 false 會丟 TypeError。


2.    權限控制(Protection Proxy)

只允許特定角色讀寫特定欄位,否則拋錯或回傳遮罩值。

function authorizeProxy(obj, role, rules) {
  // rules: { read: Set<string>, write: Set<string> }
  return new Proxy(obj, {
    get(target, prop, receiver) {
      if (!rules.read.has(prop)) {
        throw new Error(`角色 ${role} 無讀取欄位「${String(prop)}」的權限`);
      }
      return Reflect.get(target, prop, receiver);
    },
    set(target, prop, value, receiver) {
      if (!rules.write.has(prop)) {
        throw new Error(`角色 ${role} 無寫入欄位「${String(prop)}」的權限`);
      }
      return Reflect.set(target, prop, value, receiver);
    },
    ownKeys(target) {
      // 列舉時只顯示允許讀取的欄位
      return [...Reflect.ownKeys(target)].filter(k => rules.read.has(k));
    }
  });
}

// 使用
const profile = { id: 1, email: 'a@x.com', salary: 90000 };
const editorRules = { read: new Set(['id', 'email']), write: new Set(['email']) };
const editorView = authorizeProxy(profile, 'editor', editorRules);

console.log(Object.keys(editorView)); // ["id","email"]
editorView.salary; // Error: 無讀取權限


3.    惰性載入(Virtual Proxy)

直到需要時才初始化昂貴資源(例如大型資料或遠端連線)。

function lazyInitProxy(factory) {
  let instance = null;
  function ensure() {
    if (!instance) instance = factory();
    return instance;
  }
  return new Proxy({}, {
    get(_, prop) {
      return ensure()[prop];
    },
    set(_, prop, val) {
      return Reflect.set(ensure(), prop, val);
    },
    has(_, prop) {
      return Reflect.has(ensure(), prop);
    }
  });
}

// 使用
const db = lazyInitProxy(() => {
  console.log('真正連線資料庫…(只會一次)');
  return { query(sql){ return `run: ${sql}` } };
});

console.log('尚未觸發連線');
db.query('SELECT 1'); // 第一次讀取才建立


4.    函式快取(Memoization / Cache Proxy)

以 apply trap 快速為函式加上參數快取。

function memoize(fn) {
  const cache = new Map();
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      const key = JSON.stringify(args);
      if (cache.has(key)) return cache.get(key);
      const result = Reflect.apply(target, thisArg, args);
      cache.set(key, result);
      return result;
    }
  });
}

// 使用
const slowFib = n => n < 2 ? n : slowFib(n-1) + slowFib(n-2);
const fastFib = memoize(slowFib);
console.log(fastFib(40)); // 明顯快很多(相同輸入重複呼叫時)


生產環境請補上:快取大小上限、TTL、或改用 WeakMap 搭配物件型參數。


5.    API 包裝:重試與節流(Resiliency / Rate Limit Proxy)

為 fetch 加上延遲、重試與退避,集中治理呼叫策略。

function resilientFetchProxy(fetchImpl = fetch, { retries = 2, baseDelay = 200 } = {}) {
  return new Proxy(fetchImpl, {
    async apply(target, thisArg, [url, options = {}]) {
      let attempt = 0;
      while (true) {
        try {
          const res = await Reflect.apply(target, thisArg, [url, options]);
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          return res;
        } catch (e) {
          if (attempt++ >= retries) throw e;
          const delay = baseDelay * (2 ** (attempt - 1));
          await new Promise(r => setTimeout(r, delay));
        }
      }
    }
  });
}

// 使用
// const fetchSafe = resilientFetchProxy(fetch, { retries: 3, baseDelay: 250 });
// const resp = await fetchSafe('/api/data');


6.    寫入驗證(Validation Proxy)

集中欄位驗證,維持資料一致性。

function validatorProxy(obj, schema) {
  // schema: { key: (value) => boolean }
  return new Proxy(obj, {
    set(target, prop, value, receiver) {
      if (prop in schema && !schema[prop](value)) {
        throw new TypeError(`欄位 ${String(prop)} 驗證失敗:${value}`);
      }
      return Reflect.set(target, prop, value, receiver);
    }
  });
}

// 使用
const article = validatorProxy({ title: '', tags: [] }, {
  title: v => typeof v === 'string' && v.length >= 3,
  tags: v => Array.isArray(v) && v.every(t => typeof t === 'string')
});

article.title = 'Proxy 模式指南'; // OK
article.tags = ['js', 'design'];   // OK
// article.title = 1; // 丟 TypeError


7.    迷你「響應式」資料(Reactive-ish Proxy)

利用 get 收集依賴、set 觸發更新,示範原理(簡化版)。

function reactive(obj) {
  const depsMap = new Map(); // prop -> Set<effect>

  function track(prop) {
    if (!currentEffect) return;
    let set = depsMap.get(prop);
    if (!set) depsMap.set(prop, (set = new Set()));
    set.add(currentEffect);
  }

  function trigger(prop) {
    const set = depsMap.get(prop);
    if (set) for (const effect of set) effect();
  }

  let currentEffect = null;
  function effect(fn) {
    currentEffect = fn;
    fn();
    currentEffect = null;
  }

  const proxy = new Proxy(obj, {
    get(target, prop, receiver) {
      track(prop);
      return Reflect.get(target, prop, receiver);
    },
    set(target, prop, value, receiver) {
      const ok = Reflect.set(target, prop, value, receiver);
      if (ok) trigger(prop);
      return ok;
    }
  });

  return { state: proxy, effect };
}

// 使用
const { state, effect } = reactive({ count: 0 });
effect(() => console.log('count ->', state.count));
state.count++; // 觸發 effect


與 Decorator/Adapter/Facade 的差異

Proxy:站在呼叫路徑上攔截,控制存取與行為(可無侵入地施作多種橫切關注)。

Decorator:包裹物件以增添功能,介面相同但邏輯更豐富。

Adapter:轉接介面,使不相容的 API 能互通。

Facade:提供統一簡化入口,隱藏系統複雜度,不攔截現有呼叫。

簡表:Proxy=門衛,Decorator=加值服務員,Adapter=轉接頭,Facade=櫃檯。


效能、相容性與記憶體考量

Proxy 會在每次讀寫時進入 trap,高頻路徑需注意開銷。

深層物件若層層包 Proxy,可能導致記憶體增長與調試複雜度。

相容性:現代瀏覽器與 Node.js 皆支援;若要支援非常舊環境(如 IE),需降級策略(但功能受限)。

可用 Proxy.revocable 產生可撤銷代理,避免長期引用導致資源外洩。

const { proxy, revoke } = Proxy.revocable({ x: 1 }, {});
proxy.x;   // 1
revoke();
try { proxy.x } catch (e) { /* TypeError: Revoked */ }


常見錯誤與雷點

1.    忘了使用 Reflect 委派

風險:不遵守語言不變式(如不可寫屬性卻回報成功)。

✅ 作法:trap 內先做自訂邏輯,再呼叫對應 Reflect.*,把結果回傳。


2.    set trap 回傳 false

嚴格模式下會丟 TypeError。

✅ 作法:除非要阻止寫入,否則回傳 Reflect.set(...) 的結果。


3.    this 綁定跑到 Proxy 上

某些方法期望 this 指向 target,本體被換成 proxy 會壞。

✅ 作法:在 get 中對方法做 Function.prototype.bind 綁回 target,或乾脆不要攔截該方法。

get(target, prop, receiver) {
  const v = Reflect.get(target, prop, receiver);
  return typeof v === 'function' ? v.bind(target) : v;
}


4.    instanceof 與身份判等差異

proxy !== target,某些第三方庫做嚴格比對會失敗。

✅ 作法:盡量把 proxy 的外露面縮小在邏輯邊界;必要時在 getPrototypeOf/has 等 trap 上維持行為一致。


5.    陣列索引與長度陷阱

攔截 set 沒處理好,length、稀疏陣列行為可能異常。

✅ 作法:對陣列使用 proxy 時盡量透明委派,少做「魔改」。


6.    不可配置或唯讀屬性的不變式

例如 target 有 configurable:false 且 writable:false 的屬性,get 必須回傳相同值。

✅ 作法:在 get 先讀 Reflect.getOwnPropertyDescriptor 檢查必要不變式。


7.    JSON.stringify、序列化與列舉

ownKeys/getOwnPropertyDescriptor 影響可列舉與序列化結果。

✅ 作法:如非必要,讓這些 trap 直接 Reflect.* 委派。


8.    建立大量短命 Proxy 導致 GC 壓力

✅ 作法:以工廠+快取管理代理(如 WeakMap<target, proxy>)。


9.    私有欄位(#private)不是一般屬性

Proxy 無法攔截 Class 的 #private 存取。

✅ 作法:改以公開 API 攔截,或改走方法代理而非直接欄位。


10.    循環依賴/再入陷阱

在 trap 裡又觸發會進入同一個 trap。

✅ 作法:注意使用暫停旗標或以 Reflect 直接委派避免重入。


問題集

Q1:JSON.stringify(proxy) 跟 target 一樣嗎?

A:若未改 ownKeys/getOwnPropertyDescriptor,通常一致;否則結果可能不同。保持透明委派最保險。

Q2:為什麼會出現 TypeError: 'set' on proxy returned false?

A:嚴格模式下 set 回傳 false 代表寫入失敗即拋錯。回傳 Reflect.set(...)。

Q3:如何只攔截函式呼叫?

A:把 函式 當 target,使用 apply(與 construct)trap:

const fn = (a, b) => a + b;
const proxied = new Proxy(fn, {
  apply(target, thisArg, args) {
    console.log('call with', args);
    return Reflect.apply(target, thisArg, args);
  }
});
proxied(1, 2);


Q4:Class 的 #private 欄位能攔嗎?

A:不能。那是語法層級的私有,不經由一般屬性路徑。

Q5:和 Decorator(裝飾者)有什麼不同?我也能用 decorator 語法嗎?

A:概念不同。Decorator 通常是包裝行為並回傳新物件或修改定義;Proxy 是攔截存取。JS 的 Stage 裝飾器屬於不同議題,別混為一談。


綜合範例:可拔插的多重代理管線

把「日誌+驗證+快取」組成可重用管線,僅在模組邊界輸出代理。

// 1) 核心服務(勿動)
const Service = {
  add(a, b) { return a + b; },
  find(id) { return { id, name: `item-${id}` }; }
};

// 2) 日誌代理
const withLog = obj => new Proxy(obj, {
  get(t, p, r) {
    const v = Reflect.get(t, p, r);
    if (typeof v === 'function') {
      return function(...args) {
        console.time(`log:${String(p)}`);
        const out = v.apply(this, args);
        console.timeEnd(`log:${String(p)}`);
        return out;
      }
    }
    return v;
  }
});

// 3) 參數驗證代理
const withValidation = obj => new Proxy(obj, {
  get(t, p, r) {
    const v = Reflect.get(t, p, r);
    if (p === 'add') {
      return (a, b) => {
        if (![a,b].every(Number.isFinite)) throw new TypeError('add 需數字');
        return v(a, b);
      }
    }
    return v;
  }
});

// 4) 回傳快取代理(僅示範 find)
const withCache = obj => {
  const cache = new Map();
  return new Proxy(obj, {
    get(t, p, r) {
      const v = Reflect.get(t, p, r);
      if (p === 'find') {
        return (id) => {
          if (cache.has(id)) return cache.get(id);
          const res = v(id);
          cache.set(id, res);
          return res;
        }
      }
      return v;
    }
  });
};

// 5) 組管線
const service = withCache(withValidation(withLog(Service)));

// 使用
service.add(1, 2);
service.find(10);
service.find(10); // 命中快取


SEO 與可維護性角度的小提醒

文字內容避免關鍵字堆疊,但可自然重申:Proxy 模式、JavaScript Proxy、ES6 Proxy 實作、代理設計模式。

程式片段採可複製、可執行的最小示例;命名清楚(withXxx)。

以情境段落標題切分:讀者更容易從搜尋結果進來就定位到解法。


結語

Proxy 把「攔截」變成一等公民:你可以在不改動核心邏輯的前提下,加入日誌、權限、惰性載入、快取、重試與驗證等能力。

關鍵只在兩點:透明委派(Reflect)與邊界治理(只在對外輸出處做代理)。掌握這兩招,你的 JavaScript 代理就夠穩、好測、可維護。


延伸閱讀推薦:

javaScript設計模式 : Factory Method   (工廠方法)

javaScript設計模式 : Abstract Factory(抽象工廠)

javaScript設計模式 : Builder(建造者模式)

javaScript設計模式 : Prototype(原型)

javaScript設計模式 : Singleton (單例模式)

javaScript設計模式 : Adapter(轉接器模式)

javaScript設計模式 : Bridge( 橋接模式 )

javaScript設計模式 : Composite(組合模式)

 javaScript設計模式 : Decorator(裝飾者)

javaScript設計模式 : Facade(外觀模式)

javaScript設計模式 : Chain of Responsibility(責任鏈)

javaScript設計模式 : Command Pattern(命令模式)

javaScript設計模式 : Interpreter(直譯器)

javaScript設計模式 : Iterator(迭代器)

javaScript設計模式 : Mediator(仲裁者)

javaScript設計模式 : Memento(備忘錄)

javaScript設計模式 : Observer( 觀察者 )

javaScript設計模式 : State(狀態模式)

javaScript設計模式 : Strategy(策略模式)

javaScript設計模式 : Template Method (模板方法)

javaScript設計模式 : Visitor(訪問者模式)

張貼留言 (0)
較新的 較舊