JavaScript 中的 ES6 Proxy

本文最后更新于:2022年2月8日 凌晨

场景

就算只是扮演,也会成为真实的自我的一部分。对人类的精神来说,真实和虚假其实并没有明显的界限。入戏太深不是一件好事,但对于你来说并不成立,因为戏中的你才是真正符合你的身份的你。如今的你是真实的,就算一开始你只是在模仿着这种形象,现在的你也已经成为了这种形象。无论如何,你也不可能再回到过去了。

Proxy 代理,在 JavaScript 似乎很陌生,却又在生活中无处不在。或许有人在学习 ES6 的时候有所涉猎,但却并未真正了解它的使用场景,平时在写业务代码时也不会用到这个特性。

相比于文绉绉的定义内容,想必我们更希望了解它的使用场景,使其在真正的生产环境发挥强大的作用,而不仅仅是作为一个新的特性 – 然后,实际中完全没有用到!

  • 为函数添加特定的功能
  • 代理对象的访问
  • 作为胶水桥接不同结构的对象
  • 监视对象的变化
  • 还有更多。。。

如果你还没有了解过 Proxy 特性,可以先去 MDN Proxy 上查看基本概念及使用。

为函数添加特定的功能

下面是一个为异步函数自动添加超时功能的高阶函数,我们来看一下它有什么问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 为异步函数添加自动超时功能
* @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 这个属性

1
2
const get = async (i) => i
get._name = 'get'

一旦使用上面的 asyncTimeout 函数包裹之后,问题便会出现,返回的函数中 _name 属性不见了。这是当然的,毕竟实际上返回的是一个匿名函数。那么,如何才能让返回的函数能够拥有传入函数参数上的所有自定义属性呢?
一种方式是复制参数函数上的所有属性,但这点实现起来其实并不容易,真的不容易,不信你可以看看 Lodash 的 clone 函数。那么,有没有一种更简单的方式呢?答案就是 Proxy,它可以代理对象的指定操作,除此之外,其他的一切都指向原对象。
下面是 Proxy 实现的 asyncTimeout 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 为异步函数添加自动超时功能
* @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),
])
},
})
}

测试一下,是可以正常调用与访问其上的属性的

1
2
3
4
;(async () => {
console.log(await get(1))
console.log(get._name)
})()

好了,这便是吾辈最常用的一种方式了 – 封装高阶函数,为函数添加某些功能

代理对象的访问

下面是一段代码,用以在页面上展示从后台获取的数据,如果字段没有值则默认展示 ''

模拟一个获取列表的异步请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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: '幻想乡' }),
]
}

尝试直接通过解构为属性赋予默认值,并在默认值实现这个功能

1
2
3
4
5
6
7
8
9
10
11
12
13
;(async () => {
// 为所有为赋值属性都赋予默认值 ''
const persons = (await list()).map(
({ id = '', name = '', age = '', sex = '', address = '' }) => ({
id,
name,
age,
sex,
address,
}),
)
console.log(persons)
})()

下面让我们写得更通用一些

1
2
3
4
5
6
7
8
9
10
11
12
13
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 函数有什么问题?


这里是答案的分割线


  • 所有属性需要预定义,不能运行时决定
  • 没有指向原对象,后续的修改会造成麻烦

吾辈先解释一下这两个问题

  1. 所有属性需要预定义,不能运行时决定
    如果调用了 list[0].a 会发生什么呢?是的,依旧会是 undefined,因为 Reflect.ownKeys 也不能找到没有定义的属性(真*undefined),因此导致访问未定义的属性仍然会是 undefined 而非期望的默认值。
  2. 没有指向原对象,后续的修改会造成麻烦
    如果我们此时修改对象的一个属性,那么会影响到原本的属性么?不会,因为 warp 返回的对象已经是全新的了,和原对象没有什么联系。所以,当你修改时当然不会影响到原对象。
    Pass: 我们当然可以直接修改原对象,但这很明显不太符合我们的期望:显示时展示默认值 '' – 这并不意味着我们愿意在其他操作时需要 '',否则我们还要再转换一遍。(例如发送编辑后的数据到后台)

这个时候 Proxy 也可以派上用场,使用 Proxy 实现 warp 函数

1
2
3
4
5
6
7
8
9
10
11
12
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 实现胶水桥接不同结构的对象方便我们在不同公司使用我们的工具库!
嘛,开个玩笑,其实在同一个公司中不同的实体也会有类似的结构,也会需要相同的操作,最常见的应该是树结构数据。例如下面的菜单实体和系统权限实体就很相似,也需要相同的操作 – 树 <=> 列表 相互转换

思考一下如何在同一个函数中处理这两种树节点结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 系统菜单
*/
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 来抹平访问它们之间的差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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

看起来似乎有点繁琐,让我们封装一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 桥接对象不存在的字段
* @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
},
})
}
}

现在,我们可以用更简单的方式来做代理了。

1
2
3
4
5
6
7
8
9
10
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 拼接字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 模拟页面的复选框列表
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 自动更新的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* 深度监听指定对象属性的变化
* 注:指定对象不能是原始类型,即不可变类型,而且对象本身的引用不能改变,最好使用 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 对象

    1
    2
    const proxy = new Proxy(new Set([]), {})
    proxy.add(1) // Method Set.prototype.add called on incompatible receiver [object Object]

    是不是很奇怪,解决方案是把所有的 get 操作属性值为 function 的函数都手动绑定 this

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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)

    默认不能绑定 this 的说法是错误的,参考:内建对象:内部插槽(Internal slot),下面是一个例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class A {
    constructor(public name: string) {}
    hello() {
    return `hello ${this.name}`
    }
    }

    const a = new Proxy(new A('liuli'), {})
    console.log(a.hello()) // hello liuli

总结

Proxy 是个很强大的特性,能够让我们实现一些曾经难以实现的功能(所以这就是你不支持 ES5 的理由?#打),就连 Vue3+ 都开始使用 Proxy 实现了,你还有什么理由在乎上古时期的 IE 而不用呢?(v^_^)v


JavaScript 中的 ES6 Proxy
https://blog.rxliuli.com/p/d576cb4ea0ae4997aac7f137306203a4/
作者
rxliuli
发布于
2020年12月30日
许可协议