ES6 中的 Proxy 的一些奇淫技巧
前言
老早之前就使用它处理一些业务,不过后来很少接触复杂的业务,几乎忘了它的存在,正好进来因为 Vue3
特性让我想起来这个对象,花点时间深入了解这个对象使用和一些方法的技巧。
语法
Proxy
翻译中文叫 代理
,联想可以认为它可以在 JavaScript
给代理人进行一些操作。
Proxy 对象用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)。
在语法上,也没有很多复杂的东西:
const proxy = new Proxy(target, handler)
Target: 要使用 Proxy
包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
handler: 处理器对象,作为代理配置,包涵 traps
(陷阱对象) 对象,也就是拦截操作的方法。常见的陷阱对象有:get
和 set
。
没有配置任何陷阱对象代理函数如下:
const target = {}
const proxy = new Proxy(target, {})
proxy.test = 100
console.log(target.test) // => 100
console.log(proxy.test) // => 100
for(const key in proxy) {
console.log(key) // => test
}
因为上面没有设置代理配置,也就是陷阱对象,所有的操作都转发给 target
:
- 写入操作
proxy.test
值设置给target
- 读取操作
proxy.test
从target
获取返回值 - 从
proxy
迭代获取的key
属性从target
获取返回值
所以从上面得知,没有任何陷阱对象,proxy
是一个透明包装的 target
对象。如果需要更多的功能需要添陷阱对象。在 JavaScript
对象中,对于对象的大部分操作,都有一个所谓的『内部方法』,例如:用于读取属性的 [[Get]]
的内部方法,用于设置属性的 [[Set]]
的内部方法。但这些方法仅在规范中使用,无法直接去调用它们。代理中的陷阱方法在规范调用方法如下:
内部方法 | 处理方法 | 触发条件 |
---|---|---|
[[Get]] |
get |
读取属性:handler.get() |
[[Set]] |
set |
修改属性:handler.set() |
[[HasProperty]] |
has |
in 运算符操作:handler.has() |
[[Delete]] |
deleteProperty |
删除操作:handler.deleteProperty() |
[[Call]] |
apply |
函数调用:handler.apply() |
[[Construct]] |
construct |
实例化对象:handler.construct() |
[[GetPrototypeOf]] |
getPrototypeOf |
Object.getPrototypeOf |
[[SetPrototypeOf]] |
setPrototypeOf |
Object.setPrototypeOf |
[[IsExtensible]] |
isExtensible |
Object.isExtensible |
[[PreventExtensions] |
preventExtensions |
Object.preventExtensions |
[[DefineOwnProperty]] |
defineProperty |
Object.defineProperty, Object.defineProperties |
[[GetOwnProperty]] |
getOwnPropertyDescriptor |
Object.getOwnPropertyDescriptor |
[[OwnPropertyKeys]] |
ownKeys |
Object.getOwnPropertyNames, Object.getOwnPropertySymbols |
陷阱对象使用
这边讲述上面列出来各个使用方法,为了提高理解这边对部分陷阱对象含有 receiver
参数不进行详细讲解,就放在后面再讲解吧。
get
改属性和 handler.set()
一样是常见的陷阱对象,从上述文档得知语法如下:
get(target, property, receiver)
target
:目标对象,作为第一个 参数传递给新的Proxy
property
:属性名称receiver
:Proxy
或者继承Proxy
的对象
返回值:返回访问任何值
在平时开发项目中,要获取一个对象的属性,如果不存在的属性系统会返回一个 undefined
还需要额外判断等操作给予默认值:
const num1 = [1, 2, 3]
console.log(num1[2]) // => 3
console.log(num1[4]) // => undefined
但如果使用 Proxy
方法将上面进一步提高操作可行性:
let num2 = [1, 2, 3]
num2 = new Proxy(num2, {
get(target, prop) {
if (prop in target) {
return target[prop];
} else {
return 1 // 如果没有找到集合存在的值,返回一个默认值
}
}
})
console.log(num2[2]) // => 3
console.log(num2[4]) // => 1
set
set(target, property, value, receiver)
target
:目标对象,作为第一个 参数传递给新的Proxy
property
:将被设置的属性名或Symbol
value
:新属性值receiver
:最初被调用的对象。通常是Proxy
本身,但handler
的set
方法也有可能在原型链上,或以其他方式被间接地调用(因此不一定是Proxy
本身)
返回值:返回 Boolean
值
这边使用场景的情景做个案例,比如表单验证,填入年龄是 number
类型,需要根据用户输入判断输入类型来决定是否进行下一步操作:
let num3 = []
num3 = new Proxy(num3, {
set(target, prop, value) {
if (typeof value === 'number') {
target[prop] = value
return true
} else {
return false
}
}
})
num3.push(20)
num3.push(100)
console.log(num3.length) // => 2
num3.push('100') // => 报错:TypeError: 'set' on proxy: trap returned falsish for property '2'
对于
set
它必须用有个true
的返回值,否则告知写入失败。
has
has(target, property)
target
:目标对象property
:需要检查是否存在的属性
返回值:Boolean
属性值
一般来说是在 in
运算符触发陷阱对象:
let range = {
start: 1,
end: 10
}
range = new Proxy(range, {
has(target, prop) {
return prop >= target.start && prop <= target.end
}
})
console.log(7 in range) // => true
console.log(20 in range) // => false
apply
apply(target, thisArg, argumentsList)
target
:目标对象thisArg
:被调用时的上下文对象argumentsList
:被调用时的参数数组
返回值:返回任何值
使用 Proxy
实现简单的函数转发功能函数:
function delay(func, ms) {
return new Proxy(func, {
apply(target, thisArg, argumentsList) {
setTimeout(() => target.apply(thisArg, argumentsList), ms)
}
})
}
function sayHello(user) {
console.log(`Hello, ${user}!`)
}
sayHello = delay(sayHello, 5000)
console.log(sayHello.length) // => 1 // Proxy 转发这个操作
sayHello('Jaxson') // => 五秒后出现:Hello, Jaxson!
ownKeys && getOwnPropertyDescriptor
在 Object.keys
, for in
和大部分遍历对象属性的内部方法 [[OwnPropertyKeys]]
来获取属性列表,这些方法都有不同的细节:
Object.getOwnPropertyName(obj)
返回非Symbol
键值Object.getOwnPropertySymbols(obj)
返回Symbol
键值Object.keys/values()
返回可枚举标识的非Symbol
键值for in
遍历具有可枚举标识的非Symbol
键值以及原型链
在下面使用上述进行简单的例子,使用 ownKeys
陷阱对象让 for in
遍历一组用户对象,这个对象包括用户的信息,然后忽略特定的对象属性:
let user = {
name: 'Jaxson',
age: 25,
sex: '男',
_password: '****' // 密码选项不得公开展示
}
user = new Proxy(user, {
ownKeys(target) {
return Object.keys(target).filter(key => !key.startsWith('_'))
}
})
for (const key in user) console.log(key) // => name, age, sex
console.log(Object.keys(user)) // => ['name', 'age', 'sex']
console.log(Object.values(user)) // => ['Jaxson', 25, '男']
如果是对象不包含任何键值, Object.keys
不会列出该键值:
let user = {}
user = new Proxy(user, {
ownKeys(target) {
return ['user1', 'user2', 'user3']
}
})
console.log(Object.keys(user)) // => 返回空
因为 Object.keys
仅返回带有可枚举标识的属性,在遍历对象它为每个属性调用内部属性 [[GetOwnProperty]]
来获取它描述符,如果没有属性,其描述符为空则跳过。
描述符 是描述对象属性的属性 , 对象里目前存在的属性描述符有两种主要形式:数据描述符 和 存取描述符. 可以通过
Object.getOwnPropertyDescriptor()
函数来获取某个对象下指定属性的对应的 描述符 .
如果想要在上面例子返回一个属性,那么需要将它存在带有可枚举标识的对象里或者拦截对 [[GetOwnProperty]]
的调用,而这个陷阱对象就是 getOwnPropertyDescriptor
来处理,并返回一个描述符 enumerable: true
:
let user = {}
user = new Proxy(user, {
ownKeys(target) {
return ['user1', 'user2', 'user3']
},
getOwnPropertyDescriptor(target, prop) {
return {
enumerable: true,
configurable: true
}
}
})
console.log(Object.keys(user)) // => ['user1', 'user2', 'user3']
deleteProperty
继续拿上面的用户对象来作为例子,希望所有带 _
开头的属性对此进行保护:
get
访问属性时报错告知无法访问set
设置属性时报错告知无法设置deleteProperty
删除该属性时报错告知无法删除ownKeys
排除所有的_
开头属性操作
let user = {
name: 'Jaxson',
age: 25,
sex: '男',
_password: '****' // 密码选项不得公开展示并且不可修改
}
user = new Proxy(user, {
get(target, prop) {
if (prop.startsWith('_')) throw new Error('访问失败')
const value = target[prop]
return (typeof value === 'function') ? value.bind(target) : value
},
set(target, prop, value) {
if (prop.startsWith('_')) {
throw new Error('访问失败')
} else {
target[prop] = value
return true
}
},
deleteProperty(target, prop) {
if (prop.startsWith('_')) {
throw new Error('访问失败')
} else {
delete target[prop]
return true
}
},
ownKeys(target) {
return Object.keys(target).filter(key => !key.startsWith('_'))
}
})
user._password // => 报错:Uncaught Error: 访问失败
user._password = 'abc123456' // => 报错:Uncaught Error: 访问失败
delete user._password // => 报错:Uncaught Error: 访问失败
for (const key in user) console.log(key) // => name, age, sex
在上述有一段代码:
return (typeof value === 'function') ? value.bind(target) : value
如果对象包含一个检查这个属性的方法:
let user = {
name: 'Jaxson',
age: 25,
sex: '男',
_password: '****', // 密码选项不得公开展示并且不可修改
checkPassword(value) {
return value === this._password
}
}
所以在访问的时候会触发陷阱 get
对象返回无法访问的错误,所以这边只需要绑定原始对象就不会出现访问错误的错误。
Reflect
Reflect
是一个内置的对象,它提供拦截 JavaScript
操作的方法。这些方法与 proxy handlers
的方法相同。Reflect
不是一个函数对象,因此它是不可构造的。
根据上述可以知道使用 Reflect
可以简化 Proxy
创建,当然也可以认为它是 Proxy
伴侣,之前说的 [[Get]]
、[[Set]]
内部方法仅用于规范,不能直接调用,但使用 Reflect
对象使之可能,因为它的方法是内部方法的最小包装:
操作 | Reflect 调用 | 内部方法 |
---|---|---|
obj[prop] |
Reflect.get(obj, prop) |
[[Get]] |
obj[prop] = value |
Reflect.set(obj, prop, value) |
[[Set]] |
delete obj[prop] |
Reflect.deleteProperty(obj, prop) |
[[Delete]] |
new Obj(value) |
Reflect.construct(Obj, value) |
[[Construct]] |
还有诸多操作方法,就不多举例了,使用它们的方法也很简单:
let user = {}
Reflect.set(user, 'name', 'Jaxson') // => true
console.log(user.name) // => Jaxson
在普通场景下,一般 Proxy
操作足够了:
const user = {
_name: 'Test1',
get name() {
return this._name
}
}
const userProxy = new Proxy(user, {
get(target, porp, receiver) {
return target[porp]
}
})
console.log(userProxy.name) // => Test1
在这边操作的时候建议配合 Reflect
对象更佳,在上面例子稍微复杂化就明白了:
const user = {
_name: 'Test1',
get name() {
return this._name
}
}
const userProxy = new Proxy(user, {
get(target, porp, receiver) {
return target[porp]
}
})
const userTest = {
__proto__: userProxy,
_name: 'Test2'
}
console.log(userTest.name) // => Test1
发现这边输出的内容和预想值不一样,为什么不是 Test2
,然后输出 get
的操作对象的返回值对象却是 user
而不是 userTest
:
- 在寻找
admin.name
的时候,admin
没有自己的属性,就转向原型链它的原型链 - 发现原型链是
userProxy
- 然后读取属性
name
,从get
陷阱对象获取返回对象是原始对象,从而读取到属性 - 对于
target[prop]
调用在this = target
上下文运行代码,所以结果是原始对象this._name
就是在user
对象里
所以解决这个方法就需要陷阱对象的第三个参数:receiver
,它可以正确把 this
传递给返回的对象。在普通对象里可以直接 call/apply
绑定上下文,但这边是不能被调用,而是被使用,所以就需要上面的 Reflect
的静态方法:
const user = {
_name: 'Test1',
get name() {
return this._name
}
}
const userProxy = new Proxy(user, {
get(target, porp, receiver) {
return Reflect.get(target, prop, receiver)
}
})
const userTest = {
__proto__: userProxy,
_name: 'Test2'
}
console.log(userTest.name) // => Test2
实战
根据上面的方法进行实战,就选择最常见的表单验证的场景,例如:
<body>
<form action="http://xxx.com/register" id="registerForm" method="post">
<div class="form-group">
<label for="user">请输入用户名:</label>
<input type="text" class="form-control" id="user" name="userName">
</div>
<div class="form-group">
<label for="pwd">请输入密码:</label>
<input type="password" class="form-control" id="pwd" name="passWord">
</div>
<div class="form-group">
<label for="phone">请输入手机号码:</label>
<input type="tel" class="form-control" id="phone" name="phoneNumber">
</div>
<div class="form-group">
<label for="email">请输入邮箱:</label>
<input type="text" class="form-control" id="email" name="emailAddress">
</div>
<button type="button" class="btn btn-default">Submit</button>
</form>
</body>
针对上面进行不同的表单需要不同的验证,例如用户名长度,密码强度,手机号和邮箱的判断验证等等,如果采用最简单可以像这样:
let registerForm = document.querySelector('#registerForm')
registerForm.addEventListener('submit', function() {
if (registerForm.userName.value === '') {
alert('用户名不能为空!')
return false
}
if (registerForm.userName.length < 6) {
alert('用户名长度不能少于6位!')
return false
}
if (registerForm.passWord.value === '') {
alert('密码不能为空!')
return false
}
if (registerForm.passWord.value.length < 6) {
alert('密码长度不能少于6位!')
return false
}
if (registerForm.phoneNumber.value === '') {
alert('手机号码不能为空!')
return false
}
if (!/^1(3|5|7|8|9)[0-9]{9}$/.test(registerForm.phoneNumber.value)) {
alert('手机号码格式不正确!')
return false
}
if (registerForm.emailAddress.value === '') {
alert('邮箱地址不能为空!')
return false
}
if (!/^\w+([+-.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/.test(registerForm.emailAddress.value)) {
alert('邮箱地址格式不正确!')
return false
}
}, false)
如果随着表单随着维护增多以及复杂化,上面的各种 if-else
判断相信后期可读性非常差,所以这边需要提高代码可读性以及复用性。所以利用 Proxy
重构表单验证。
首先利用 Proxy
拦截一些不符合要求的数据:
function validator(target, validator, errorMsg) {
return new Proxy(target, {
_validator: validator,
set(target, key, value, proxy) {
let errMsg = errorMsg
if (value === '') {
alert(`${errMsg[key]}不能为空!`)
return target[key] = false
}
let va = this._validator[key]
if (!!va(value)) {
return Reflect.set(target, key, value, proxy)
} else {
alert(`${errMsg[key]}格式不正确`)
return target[key] = false
}
}
})
}
负责校验的逻辑代码:
const validators = {
name(value) {
return value.length > 6
},
passwd(value) {
return value.length > 6
},
moblie(value) {
return /^1(3|5|7|8|9)[0-9]{9}$/.test(value)
},
email(value) {
return /^\w+([+-.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/.test(value)
}
}
整合俩者:
const errorMsg = {
name: '用户名',
passwd: '密码',
moblie: '手机号码',
email: '邮箱地址'
}
const vali = validator({}, validators, errorMsg)
let registerForm = document.querySelector('#registerForm')
registerForm.addEventListener('submit', function() {
let validatorNext = function*() {
yield vali.name = registerForm.userName.value
yield vali.passwd = registerForm.passWord.value
yield vali.moblie = registerForm.phoneNumber.value
yield vali.email = registerForm.emailAddress.value
}
let validator = validatorNext()
validator.next();
!vali.name || validator.next(); //上一步的校验通过才执行下一步
!vali.passwd || validator.next();
!vali.moblie || validator.next();
}, false)
所以对比上面的 if-else
来比,不仅仅编码量少,并且表单对象和表单条件完全隔离,代码健壮性和复用性非常的强。
这边看起来比较简单,有时间再看稍微复杂的例子吧。