拿Proxy可以做哪些有意思的事兒

Jiasm發表於2018-05-24

Proxy是什麼

首先,我們要清楚,Proxy是什麼意思,這個單詞翻譯過來,就是 代理
可以理解為,有一個很火的明星,開通了一個微博賬號,這個賬號非常活躍,回覆粉絲、到處點贊之類的,但可能並不是真的由本人在維護的。
而是在背後有一個其他人 or 團隊來運營,我們就可以稱他們為代理人,因為他們發表的微博就代表了明星本人的意思。
P.S. 強行舉例子,因為本人不追星,只是猜測可能會有這樣的運營團隊

這個代入到JavaScript當中來,就可以理解為對物件或者函式的代理操作。

JavaScript中的Proxy

Proxy是ES6中提供的新的API,可以用來定義物件各種基本操作的自定義行為
(在文件中被稱為traps,我覺得可以理解為一個針對物件各種行為的鉤子)
拿它可以做很多有意思的事情,在我們需要對一些物件的行為進行控制時將變得非常有效。

Proxy的語法

建立一個Proxy的例項需要傳入兩個引數

  1. target 要被代理的物件,可以是一個object或者function
  2. handlers對該代理物件的各種操作行為處理
let target = {}
let handlers = {} // do nothing
let proxy = new Proxy(target, handlers)

proxy.a = 123

console.log(target.a) // 123
複製程式碼

在第二個引數為空物件的情況下,基本可以理解為是對第一個引數做的一次淺拷貝
(Proxy必須是淺拷貝,如果是深拷貝則會失去了代理的意義)

Traps(各種行為的代理)

就像上邊的示例程式碼一樣,如果沒有定義對應的trap,則不會起任何作用,相當於直接操作了target
當我們寫了某個trap以後,在做對應的動作時,就會觸發我們的回撥函式,由我們來控制被代理物件的行為。

最常用的兩個trap應該就是getset了。
早年JavaScript有著在定義物件時針對某個屬性進行設定gettersetter

let obj = {
  _age: 18,
  get age ()  {
    return `I'm ${this._age} years old`
  },
  set age (val) {
    this._age = Number(val)
  }
}

console.log(obj.age) // I'm 18 years old
obj.age = 19
console.log(obj.age) // I'm 19 years old
複製程式碼

就像這段程式碼描述的一樣,我們設定了一個屬性_age,然後又設定了一個get ageset age
然後我們可以直接呼叫obj.age來獲取一個返回值,也可以對其進行賦值。
這麼做有幾個缺點:

  1. 針對每一個要代理的屬性都要編寫對應的gettersetter
  2. 必須還要存在一個儲存真實值的key(如果我們直接在getter裡邊呼叫this.age則會出現堆疊溢位的情況,因為無論何時呼叫this.age進行取值都會觸發getter

Proxy很好的解決了這兩個問題:

let target = { age: 18, name: 'Niko Bellic' }
let handlers = {
  get (target, property) {
    return `${property}: ${target[property]}`
  },
  set (target, property, value) {
    target[property] = value
  }
}
let proxy = new Proxy(target, handlers)

proxy.age = 19
console.log(target.age, proxy.age)   // 19,          age : 19
console.log(target.name, proxy.name) // Niko Bellic, name: Niko Bellic
複製程式碼

我們通過建立getset兩個trap來統一管理所有的操作,可以看到,在修改proxy的同時,target的內容也被修改,而且我們對proxy的行為進行了一些特殊的處理。
而且我們無需額外的用一個key來儲存真實的值,因為我們在trap內部操作的是target物件,而不是proxy物件。

拿Proxy來做些什麼

因為在使用了Proxy後,物件的行為基本上都是可控的,所以我們能拿來做一些之前實現起來比較複雜的事情。
在下邊列出了幾個簡單的適用場景。

解決物件屬性為undefined的問題

在一些層級比較深的物件屬性獲取中,如何處理undefined一直是一個痛苦的過程,如果我們用Proxy可以很好的相容這種情況。

(() => {
  let target = {}
  let handlers = {
    get: (target, property) => {
      target[property] = (property in target) ? target[property] : {}
      if (typeof target[property] === 'object') {
        return new Proxy(target[property], handlers)
      }
      return target[property]
    }
  }
  let proxy = new Proxy(target, handlers)
  console.log('z' in proxy.x.y) // false (其實這一步已經針對`target`建立了一個x.y的屬性)
  proxy.x.y.z = 'hello'
  console.log('z' in proxy.x.y) // true
  console.log(target.x.y.z)     // hello
})()
複製程式碼

我們代理了get,並在裡邊進行邏輯處理,如果我們要進行get的值來自一個不存在的key,則我們會在target中建立對應個這個key,然後返回一個針對這個key的代理物件。
這樣就能夠保證我們的取值操作一定不會丟擲can not get xxx from undefined
但是這會有一個小缺點,就是如果你確實要判斷這個key是否存在只能夠通過in操作符來判斷,而不能夠直接通過get來判斷。

普通函式與建構函式的相容處理

如果我們提供了一個Class物件給其他人,或者說一個ES5版本的建構函式。
如果沒有使用new關鍵字來呼叫的話,Class物件會直接丟擲異常,而ES5中的建構函式this指向則會變為呼叫函式時的作用域。
我們可以使用apply這個trap來相容這種情況:

class Test {
  constructor (a, b) {
    console.log('constructor', a, b)
  }
}

// Test(1, 2) // throw an error
let proxyClass = new Proxy(Test, {
  apply (target, thisArg, argumentsList) {
    // 如果想要禁止使用非new的方式來呼叫函式,直接丟擲異常即可
    // throw new Error(`Function ${target.name} cannot be invoked without 'new'`)
    return new (target.bind(thisArg, ...argumentsList))()
  }
})

proxyClass(1, 2) // constructor 1 2
複製程式碼

我們使用了apply來代理一些行為,在函式呼叫時會被觸發,因為我們明確的知道,代理的是一個Class或建構函式,所以我們直接在apply中使用new關鍵字來呼叫被代理的函式。

以及如果我們想要對函式進行限制,禁止使用new關鍵字來呼叫,可以用另一個trap:construct

function add (a, b) {
  return a + b
}

let proxy = new Proxy(add, {
  construct (target, argumentsList, newTarget) {
    throw new Error(`Function ${target.name} cannot be invoked with 'new'`)
  }
})

proxy(1, 2)     // 3
new proxy(1, 2) // throw an error
複製程式碼

用Proxy來包裝fetch

在前端傳送請求,我們現在經常用到的應該就是fetch了,一個原生提供的API。 我們可以用Proxy來包裝它,使其變得更易用。

let handlers = {
  get (target, property) {
    if (!target.init) {
      // 初始化物件
      ['GET', 'POST'].forEach(method => {
        target[method] = (url, params = {}) => {
          return fetch(url, {
            headers: {
              'content-type': 'application/json'
            },
            mode: 'cors',
            credentials: 'same-origin',
            method,
            ...params
          }).then(response => response.json())
        }
      })
    }

    return target[property]
  }
}
let API = new Proxy({}, handlers)

await API.GET('XXX')
await API.POST('XXX', {
  body: JSON.stringify({name: 1})
})
複製程式碼

GETPOST進行了一層封裝,可以直接通過.GET這種方式來呼叫,並設定一些通用的引數。

實現一個簡易的斷言工具

寫過測試的各位童鞋,應該都會知道斷言這個東西
console.assert就是一個斷言工具,接受兩個引數,如果第一個為false,則會將第二個引數作為Error message丟擲。
我們可以使用Proxy來做一個直接賦值就能實現斷言的工具。

let assert = new Proxy({}, {
  set (target, message, value) {
    if (!value) console.error(message)
  }
})

assert['Isn\'t true'] = false      // Error: Isn't true
assert['Less than 18'] = 18 >= 19  // Error: Less than 18
複製程式碼

統計函式呼叫次數

在做服務端時,我們可以用Proxy代理一些函式,來統計一段時間內呼叫的次數。
在後期做效能分析時可能會能夠用上:

function orginFunction () {}
let proxyFunction = new Proxy(orginFunction, {
  apply (target, thisArg. argumentsList) {
    log(XXX)

    return target.apply(thisArg, argumentsList)
  }
})
複製程式碼

全部的traps

這裡列出了handlers所有可以定義的行為 (traps)

具體的可以檢視MDN-Proxy
裡邊同樣有一些例子

traps description
get 獲取某個key
set 設定某個key
has 使用in操作符判斷某個key是否存在
apply 函式呼叫,僅在代理物件為function時有效
ownKeys 獲取目標物件所有的key
construct 函式通過例項化呼叫,僅在代理物件為function時有效
isExtensible 判斷物件是否可擴充套件,Object.isExtensible的代理
deleteProperty 刪除一個property
defineProperty 定義一個新的property
getPrototypeOf 獲取原型物件
setPrototypeOf 設定原型物件
preventExtensions 設定物件為不可擴充套件
getOwnPropertyDescriptor 獲取一個自有屬性 (不會去原型鏈查詢) 的屬性描述

參考資料

  1. Magic Methods in JavaScript? Meet Proxy!
  2. How to use JavaScript Proxies for Fun and Profit
  3. MDN-Proxy

相關文章