JavaScript 中的 ES6 Proxy
吾輩博客的原文地址: https://blog.rxliuli.com/p/c3...
場景
就算只是扮演,也會成爲真實的自我的一部分。對人類的精神來說,真實和虛假其實並沒有明顯的界限。入戲太深不是一件好事,但對於你來說並不成立,因爲戲中的你纔是真正符合你的身份的你。如今的你是真實的,就算一開始你只是在模仿着這種形象,現在的你也已經成爲了這種形象。無論如何,你也不可能再回到過去了。
Proxy
代理,在 JavaScript 似乎很陌生,卻又在生活中無處不在。或許有人在學習 ES6 的時候有所涉獵,但卻並未真正瞭解它的使用場景,平時在寫業務代碼時也不會用到這個特性。
相比於文縐縐的定義內容,想必我們更希望瞭解它的使用場景,使其在真正的生產環境發揮強大的作用,而不僅僅是作爲一個新的特性 -- 然後,實際中完全沒有用到!
- 爲函數添加特定的功能
- 代理對象的訪問
- 作爲膠水橋接不同結構的對象
- 監視對象的變化
- 還有更多。。。
如果你還沒有了解過 Proxy
特性,可以先去 MDN Proxy 上查看基本概念及使用。
爲函數添加特定的功能
下面是一個爲異步函數自動添加超時功能的高階函數,我們來看一下它有什麼問題
/**
* 爲異步函數添加自動超時功能
* @param timeout 超時時間
* @param action 異步函數
* @returns 包裝後的異步函數
*/
function asyncTimeout(timeout, action) {
return function(...args) {
return Promise.race([
Reflect.apply(action, this, args),
wait(timeout).then(Promise.reject),
])
}
}
一般而言,上面的代碼足以勝任,但問題就在這裏,不一般的情況 -- 函數上面包含自定義屬性呢?
衆所周知,JavaScript 中的函數是一等公民,即函數可以被傳遞,被返回,以及,被添加屬性!
例如下面這個簡單的函數 get
,其上有着 _name
這個屬性
const get = async i => i
get._name = 'get'
一旦使用上面的 asyncTimeout
函數包裹之後,問題便會出現,返回的函數中 _name
屬性不見了。這是當然的,畢竟實際上返回的是一個匿名函數。那麼,如何才能讓返回的函數能夠擁有傳入函數參數上的所有自定義屬性呢?
一種方式是複製參數函數上的所有屬性,但這點實現起來其實並不容易,真的不容易,不信你可以看看 Lodash 的 clone 函數。那麼,有沒有一種更簡單的方式呢?答案就是 Proxy
,它可以代理對象的指定操作,除此之外,其他的一切都指向原對象。
下面是 Proxy
實現的 asyncTimeout
函數
/**
* 爲異步函數添加自動超時功能
* @param timeout 超時時間
* @param action 異步函數
* @returns 包裝後的異步函數
*/
function asyncTimeout(timeout, action) {
return new Proxy(action, {
apply(_, _this, args) {
return Promise.race([
Reflect.apply(_, _this, args),
wait(timeout).then(Promise.reject),
])
},
})
}
測試一下,是可以正常調用與訪問其上的屬性的
;(async () => {
console.log(await get(1))
console.log(get._name)
})()
好了,這便是吾輩最常用的一種方式了 -- 封裝高階函數,爲函數添加某些功能。
代理對象的訪問
下面是一段代碼,用以在頁面上展示從後臺獲取的數據,如果字段沒有值則默認展示 ''
模擬一個獲取列表的異步請求
async function list() {
// 此處僅爲構造列表
class Person {
constructor({ id, name, age, sex, address } = {}) {
this.id = id
this.name = name
this.age = age
this.sex = sex
this.address = address
}
}
return [
new Person({ id: 1, name: '琉璃' }),
new Person({ id: 2, age: 17 }),
new Person({ id: 3, sex: false }),
new Person({ id: 4, address: '幻想鄉' }),
]
}
嘗試直接通過解構爲屬性賦予默認值,並在默認值實現這個功能
;(async () => {
// 爲所有爲賦值屬性都賦予默認值 ''
const persons = (await list()).map(
({ id = '', name = '', age = '', sex = '', address = '' }) => ({
id,
name,
age,
sex,
address,
}),
)
console.log(persons)
})()
下面讓我們寫得更通用一些
function warp(obj) {
const result = obj
for (const k of Reflect.ownKeys(obj)) {
const v = Reflect.get(obj, k)
result[k] = v === undefined ? '' : v
}
return obj
}
;(async () => {
// 爲所有爲賦值屬性都賦予默認值 ''
const persons = (await list()).map(warp)
console.log(persons)
})()
暫且先看一下這裏的 warp
函數有什麼問題?
這裏是答案的分割線
- 所有屬性需要預定義,不能運行時決定
- 沒有指向原對象,後續的修改會造成麻煩
吾輩先解釋一下這兩個問題
- 所有屬性需要預定義,不能運行時決定
如果調用了list[0].a
會發生什麼呢?是的,依舊會是undefined
,因爲Reflect.ownKeys
也不能找到沒有定義的屬性(真*undefined
),因此導致訪問未定義的屬性仍然會是undefined
而非期望的默認值。 - 沒有指向原對象,後續的修改會造成麻煩
如果我們此時修改對象的一個屬性,那麼會影響到原本的屬性麼?不會,因爲warp
返回的對象已經是全新的了,和原對象沒有什麼聯繫。所以,當你修改時當然不會影響到原對象。
Pass: 我們當然可以直接修改原對象,但這很明顯不太符合我們的期望:顯示時展示默認值''
-- 這並不意味着我們願意在其他操作時需要''
,否則我們還要再轉換一遍。(例如發送編輯後的數據到後臺)
這個時候 Proxy
也可以派上用場,使用 Proxy
實現 warp
函數
function warp(obj) {
const result = new Proxy(obj, {
get(_, k) {
const v = Reflect.get(_, k)
if (v !== undefined) {
return v
}
return ''
},
})
return result
}
現在,上面的那兩個問題都解決了!
注: 知名的 GitHub 庫 immer 就使用了該特性實現了不可變狀態樹。
作爲膠水橋接不同結構的對象
通過上面的例子我們可以知道,即便是未定義的屬性,Proxy
也能進行代理。這意味着,我們可以通過 Proxy
抹平相似對象之間結構的差異,以相同的方式處理類似的對象。
Pass: 不同公司的項目中的同一個實體的結構不一定完全相同,但基本上類似,只是字段名不同罷了。所以使用Proxy
實現膠水橋接不同結構的對象方便我們在不同公司使用我們的工具庫!
嘛,開個玩笑,其實在同一個公司中不同的實體也會有類似的結構,也會需要相同的操作,最常見的應該是樹結構數據。例如下面的菜單實體和系統權限實體就很相似,也需要相同的操作 -- 樹 <=> 列表 相互轉換。
思考一下如何在同一個函數中處理這兩種樹節點結構
/**
* 系統菜單
*/
class SysMenu {
/**
* 構造函數
* @param {Number} id 菜單 id
* @param {String} name 顯示的名稱
* @param {Number} parent 父級菜單 id
*/
constructor(id, name, parent) {
this.id = id
this.name = name
this.parent = parent
}
}
/**
* 系統權限
*/
class SysPermission {
/**
* 構造函數
* @param {String} uid 系統唯一 uuid
* @param {String} label 顯示的菜單名
* @param {String} parentId 父級權限 uid
*/
constructor(uid, label, parentId) {
this.uid = uid
this.label = label
this.parentId = parentId
}
}
下面讓我們使用 Proxy
來抹平訪問它們之間的差異
const sysMenuProxy = { parentId: 'parent' }
const sysMenu = new Proxy(new SysMenu(1, 'rx', 0), {
get(_, k) {
if (Reflect.has(sysMenuProxy, k)) {
return Reflect.get(_, Reflect.get(sysMenuProxy, k))
}
return Reflect.get(_, k)
},
})
console.log(sysMenu.id, sysMenu.name, sysMenu.parentId) // 1 'rx' 0
const sysPermissionProxy = { id: 'uid', name: 'label' }
const sysPermission = new Proxy(new SysPermission(1, 'rx', 0), {
get(_, k) {
if (Reflect.has(sysPermissionProxy, k)) {
return Reflect.get(_, Reflect.get(sysPermissionProxy, k))
}
return Reflect.get(_, k)
},
})
console.log(sysPermission.id, sysPermission.name, sysPermission.parentId) // 1 'rx' 0
看起來似乎有點繁瑣,讓我們封裝一下
/**
* 橋接對象不存在的字段
* @param {Object} map 代理的字段映射 Map
* @returns {Function} 轉換一個對象爲代理對象
*/
function bridge(map) {
/**
* 爲對象添加代理的函數
* @param {Object} obj 任何對象
* @returns {Proxy} 代理後的對象
*/
return function(obj) {
return new Proxy(obj, {
get(target, k) {
// 如果遇到被代理的屬性則返回真實的屬性
if (Reflect.has(map, k)) {
return Reflect.get(target, Reflect.get(map, k))
}
return Reflect.get(target, k)
},
set(target, k, v) {
// 如果遇到被代理的屬性則設置真實的屬性
if (Reflect.has(map, k)) {
Reflect.set(target, Reflect.get(map, k), v)
return true
}
Reflect.set(target, k, v)
return true
},
})
}
}
現在,我們可以用更簡單的方式來做代理了。
const sysMenu = bridge({
parentId: 'parent',
})(new SysMenu(1, 'rx', 0))
console.log(sysMenu.id, sysMenu.name, sysMenu.parentId) // 1 'rx' 0
const sysPermission = bridge({
id: 'uid',
name: 'label',
})(new SysPermission(1, 'rx', 0))
console.log(sysPermission.id, sysPermission.name, sysPermission.parentId) // 1 'rx' 0
如果想看 JavaScirpt 如何處理樹結構數據話,可以參考吾輩的 JavaScript 處理樹數據結構
監視對象的變化
接下來,我們想想,平時是否有需要監視對象的變化,然後進行某些處理呢?
例如監視用戶複選框選中項列表的變化並更新對應的需要發送到後臺的 id
拼接字符串。
// 模擬頁面的複選框列表
const hobbyMap = new Map()
.set(1, '小說')
.set(2, '動畫')
.set(3, '電影')
.set(4, '遊戲')
const user = {
id: 1,
// 保存興趣 id 的列表
hobbySet: new Set(),
// 發送到後臺的興趣 id 拼接後的字符串,以都好進行分割
hobby: '',
}
function onClick(id) {
user.hobbySet.has(id) ? user.hobbySet.delete(id) : user.hobbySet.add(id)
}
// 模擬兩次點擊
onClick(1)
onClick(2)
console.log(user.hobby) // ''
下面使用 Proxy
來完成 hobbySet
屬性改變後 hobby
自動更新的操作
/**
* 深度監聽指定對象屬性的變化
* 注:指定對象不能是原始類型,即不可變類型,而且對象本身的引用不能改變,最好使用 const 進行聲明
* @param object 需要監視的對象
* @param callback 當代理對象發生改變時的回調函數,回調函數有三個參數,分別是對象,修改的 key,修改的 v
* @returns 返回源對象的一個代理
*/
function watchObject(object, callback) {
const handler = {
get(_, k) {
try {
// 注意: 這裏很關鍵,它爲對象的字段也添加了代理
return new Proxy(v, Reflect.get(_, k))
} catch (err) {
return Reflect.get(_, k)
}
},
set(_, k, v) {
callback(_, k, v)
return Reflect.set(_, k, v)
},
}
return new Proxy(object, handler)
}
// 模擬頁面的複選框列表
const hobbyMap = new Map()
.set(1, '小說')
.set(2, '動畫')
.set(3, '電影')
.set(4, '遊戲')
const user = {
id: 1,
// 保存興趣 id 的列表
hobbySet: new Set(),
// 發送到後臺的興趣 id 拼接後的字符串,以都好進行分割
hobby: '',
}
const proxy = watchObject(user, (_, k, v) => {
if (k === 'hobbySet') {
_.hobby = [..._.hobbySet].join(',')
}
})
function onClick(id) {
proxy.hobbySet = proxy.hobbySet.has(id)
? proxy.hobbySet.delete(id)
: proxy.hobbySet.add(id)
}
// 模擬兩次點擊
onClick(1)
onClick(2)
// 現在,user.hobby 的值將會自動更新
console.log(user.hobby) // 1,2
當然,這裏實現的 watchObject
函數還非常非常非常簡陋,如果有需要可以進行更深度/強大的監聽,可以嘗試自行實現一下啦!
缺點
說完了這些 Proxy
的使用場景,下面稍微來說一下它的缺點
- 運行環境必須要 ES6 支持
這是一個不大不小的問題,現代的瀏覽器基本上都支持 ES6,但如果泥萌公司技術棧非常老舊的話(例如支持 IE6),還是安心吃土吧 #笑 #這種公司不離職等着老死 -
不能直接代理一些需要 this 的對象
這個問題就比較麻煩了,任何需要 this 的對象,代理之後的行爲可能會發生變化。例如Set
對象const proxy = new Proxy(new Set([]), {}) proxy.add(1) // Method Set.prototype.add called on incompatible receiver [object Object]
是不是很奇怪,解決方案是把所有的
get
操作屬性值爲function
的函數都手動綁定this
const proxy = new Proxy(new Set([]), { get(_, k) { const v = Reflect.get(_, k) // 遇到 Function 都手動綁定一下 this if (v instanceof Function) { return v.bind(_) } return v }, }) proxy.add(1)
總結
Proxy
是個很強大的特性,能夠讓我們實現一些曾經難以實現的功能(所以這就是你不支持 ES5 的理由?#打),就連 Vue3+ 都開始使用 Proxy
實現了,你還有什麼理由在乎上古時期的 IE 而不用呢?(v^_^)v