ES6 中的 Proxy 的一些奇淫技巧

前言

老早之前就使用它处理一些业务,不过后来很少接触复杂的业务,几乎忘了它的存在,正好进来因为 Vue3 特性让我想起来这个对象,花点时间深入了解这个对象使用和一些方法的技巧。

语法

Proxy 翻译中文叫 代理 ,联想可以认为它可以在 JavaScript 给代理人进行一些操作。

Proxy 对象用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)。

在语法上,也没有很多复杂的东西:

const proxy = new Proxy(target, handler)

Target: 要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。

handler: 处理器对象,作为代理配置,包涵 traps(陷阱对象) 对象,也就是拦截操作的方法。常见的陷阱对象有:getset

没有配置任何陷阱对象代理函数如下:

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.testtarget 获取返回值
  • 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:属性名称
  • receiverProxy 或者继承 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 本身,但 handlerset 方法也有可能在原型链上,或以其他方式被间接地调用(因此不一定是 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 来比,不仅仅编码量少,并且表单对象和表单条件完全隔离,代码健壮性和复用性非常的强。

这边看起来比较简单,有时间再看稍微复杂的例子吧。

参考文档

Specification Proxy

MDN Proxy 文档

MDN JSPropertyDesciptor 参考文档

MDN in 运算符文档

MDN Reflect 文档

MDN 元编程文档