ES2015 Proxy 实用代码示例 & 详解

1,826 阅读5分钟

基本用法

Proxy 是通过包装对象,用拦截的方式来修改某些操作的默认行为,比如获取属性值。我们可以为需要拦截的对象提供一个带有 traps 的函数对象,如果对象操作没有定义 trap 将会指向原始的对象操作上。

const handler = {
  get(target, prop) {
    const val = target[prop]
    console.log(`property ${prop} = ${val}`)
    return val
  }
}

const p = new Proxy({a: 1}, handler)

console.log(p.a)
// property a = 1
// 1
console.log(p.b)
// property b = undefined
// 1

上面代码中当要获取 p.a 值时,handler.get 这个 trap 就会被调用,相当于我们劫持了 p.a 中的 . 操作符,于是就不能再去访问原始对象,我们在劫持中就可以做例如验证、封装、访问控制等各种操作了。

默认值

当我们请求一个对象中不存在的 key 时,我们可以从对象中提供一个默认值,不过要注意, in 操作符仍然能知道这个 key 是否在对象中存在。

function defaultProp(defaultObj){
  const handler = {
    get(obj, prop) {
      return Reflect.get(obj, prop) || defaultObj[prop];
    }
  };
  return new Proxy({}, handler);
}

function test(){
  const exist = 'name' in o ? 'in' : 'not in'
  console.log(`name = "${o.name}", name ${exist} o`)
}

const o = defaultProp({ name: 'default name'})


test() // name = "default name", name not in o

// 替换默认值
o.name = 'abcdef'
test() // name = "abcdef", name in o


// 删除name属性后 使用默认值
delete o.name
test() // name = "default name", name not in o

隐藏私有属性

Proxy 也有利于限制属性的访问,比如隐藏以下划线开头的的属性,让他们真正私有化。

先上代码,后面解释:

// filter参数作为一个过滤函数出入
function priavateProp(obj, filter){
  const handler = {
    get(obj, prop) {
      if(!filter(prop)){
        let val = Reflect.get(obj, prop)
        if(typeof val === 'function'){
          val = val.bind(obj)
        }
        return val
      }
    },
    set(obj, prop, val) {
      if(filter(prop)){
        throw new Error(`cannot set property ${prop}`)
      }
      return Reflect.set(obj, prop, val)
    },
    has(obj, prop) {
      return filter(prop) ? false : Reflect.has(obj, prop)
    },
    ownKeys(obj) {
      return Reflect.ownKeys(obj).filter( prop => !filter(prop))
    }
  }

  return new Proxy(obj, handler)
}

上面代码使用了4个 trap 函数来实现,参数 filter 函数是用来过滤私有属性的过滤器。has 函数因为在 Proxy 中用来监听对象 in 的使用,所以成了过滤私有属性的核心。ownKeys 过滤私有属性,让 Object.keys(p) 返回其他所有属性。

// 私有属性过滤器 
// 规则:以 _ 为开头的属性都是私有属性
function filter(prop){
  return prop.indexOf('_') === 0
}

const o = {
  _private: 'private property',
  name: 'public name',
  say(){
    // 内部访问私有属性
    console.log(this._private)
  }
}

const p = priavateProp(o, filter)

console.log(p)  // Proxy {_private: "private property", name: "public name"}

p._private  // undefined
JSON.stringify(p) // "{"name":"public name"}"

// 只能内部访问私有属性
p.say()  //  private property

console.log('_private' in p)   // false

// 不能遍历到私有属性 
Object.keys(p)  // ["name", "say"]

// 私有属性不能赋值
p._private = '000'  // Uncaught Error: cannot set property _private

注意,因为在 Proxy 上调用方法, this 默认指向的是 Proxy 代理 ,而不是原始对象,因此方法将无法访问私有属性,所以通过将方法绑定到 get 函数中来解决。

枚举 Enum

js 中可用对象操作或者 Object.freeze 的方式来实现枚举,但有时候会出现类型安全的问题,因此很不健壮。

通过 Proxy ,我们创建一个键值对象,通过阻止修改其值来保证其健壮性,同时比 Object.freeze 更安全。(虽然 Object.freeze 可以阻止内容被修改,但不会抛出错误,所以会隐藏潜在的 bug)

我们先实现一个 createEnum ,然后在和其他枚举的方式做下对比

function createEnum(object){
  const handler = {
    get(obj, prop) {
      if(!(prop in obj)){
        throw new ReferenceError(`unknow ${prop} in this Enum.`)
      }
      return Reflect.get(obj, prop)
    },
    set() {
      throw new TypeError('Enum is readonly.')
    },
    deleteProperty() {
      throw new TypeError('Enum is readonly.')
    }
  }

  return new Proxy(object, handler)
}

下面对比三种方式的枚举操作

  1. 把一个普通对象当做枚举来处理
const anotherValue = 'another'

const objOne = { a: 'a1', b: 'b1' }

objOne.a   // "a1"
objOne.c  // undefined  并没有报错

objOne.a = anotherValue
objOne.a // "a111" 改变了枚举 --- 其实这是正常的对象操作

delete objOne.a  // 正常删除对象属性 同时也删除了枚举值
  1. 使用 Object.freeze 的对象枚举
const anotherValue = 'another'
const objTwo = Object.freeze({ a: 'a2', b: 'b2' })

objTwo.a  // "a2"
objTwo.c   // undefined

if(objTwo.a = anotherValue){  // 能够赋值
  console.log(objTwo.a)  // 但是依然返回的是 "a2"
}

delete objTwo.a  // 不能删除 但也没有抛出错误
  1. 使用 Proxy 包装过的枚举
const objEnum = createEnum({ a: 'a3', b: 'b3' })

objEnum.a  // "a3"

try {
  objEnum.c
}catch(e){
  console.log(e)  // ReferenceError: unknow c in this Enum.
}

try {
  if(objEnum.a = "another") {  
    console.log(objEnum.a)  // 这一行永远不会执行
  }
}catch(e){
  console.log(e)  // TypeError: Enum is readonly.
}

try {
  delete objEnum.a
}catch(e){
  console.log(e)  // TypeError: Enum is readonly.
}

用 Proxy 包装后去处理枚举,代码更健壮,各种操作异常也能抛出。

枚举另一个常用的功能就是根据 value 获取 key 值,虽然我们可以通过原型继承的方式实现,但这里还是用 Proxy 做一层包装,添加一个 key 函数

function createEnum(name, val){
  function key(v){
    const keys = Object.keys(this)
    for(let i=0,l=keys.length;i<l;i++){
      let k = keys[i]
      if(this[k] === v) {
        return `${name}.${k}`
      }
    }
  }
  const handler = {
    get(obj, prop) {
      if(prop == 'key'){
        return key.bind(obj)
      }
      if(!(prop in obj)){
        throw new ReferenceError(`unknow ${prop} in this Enum.`)
      }
      return Reflect.get(obj, prop)
    },
    set() {
      throw new TypeError('Enum is readonly.')
    },
    deleteProperty() {
      throw new TypeError('Enum is readonly.')
    }
  }

  return new Proxy(val, handler)
}

const obj = createEnum('obj', { a: 'a', b: 'b', c: 1 })

obj.key('a')  // "obj.a"
obj.key(1)  // "obj.c"
obj.key('x')  // undefined

追踪对象和数组

这也是观察者模式的一部分
在 Vue.js 中无法监听数组的 length 导致 arr.length = 1 这种数据的改变无法监听,因此 Vue.js 在今年(2017)也会实现一个基于 Proxy 的 Observation

当对象或数组发生变化时,我们通过订阅的事件就可以观察到,同理,我们还可以添加验证的拦截,在数据更改之前先做验证处理。

因此我们把对象和数组也加一层 Proxy 来处理,我们把所有的改变都转发到原对象上,在修改或删除之后添加一个函数当做监听器 :

function track(obj, fn){
  const handler = {
    set(obj, prop, val) {
      const oldVal = obj[prop]
      Reflect.set(obj, prop, val)
      fn(obj, prop, oldVal, val)
    },
    deleteProperty(obj, prop) {
      const oldVal = obj[prop]
      Reflect.deleteProperty(obj, prop)
      fn(obj, prop, oldVal, undefined)
    }
  }

  return new Proxy(obj, handler)
}
  1. 监听对象的变化
const obj = track({a: 'a1', b: 'b1'}, (obj, prop, oldVal, newVal) => {
  console.log(`obj.${prop} changed from ${oldVal} to ${newVal}`)
})
obj.a = 'a2222'  // obj.a changed from a1 to a2222
obj.a = 'xxxxx'  // obj.a changed from a2222 to xxxxx
delete obj.b     // obj.b changed from undefined to undefined
obj.c = 'c1'     // obj.c changed from undefined to c1
  1. 监听数组的变化
const arr = track([1, 2, 3, 4, 5], (obj, prop, oldVal, newVal) => {
  let val = isNaN(parseInt(prop)) ? `.${prop}` : `[${prop}]`
  const sum = arr.reduce( (p,n) => p + n)

  console.log(`arr${val} changed from ${oldVal} to ${newVal}`)
  console.log(`sum [${arr}] is ${sum}`)
})

arr[4] = 0 
// arr[4] changed from 5 to 0
// sum [1,2,3,4,0] is 10

delete arr[3]
// arr[3] changed from 4 to undefined
// sum [1,2,3,,0] is 6

arr.length = 2
// arr.length changed from 5 to 2
// sum [1,2] is 3

在数组中使用 in

使用 Proxy 可是实现操作符的重载,但也只能对 in of delete new 这几个实现重载

我们劫持 in 操作符来实现 Array.includes 检查值是否存在数组中

function arrIn(arr){
  const handler = {
    has(arr, val) {
      return arr.includes(val)
    }
  }

  return new Proxy(arr, handler)
}

const arr = arrIn(['a', 'b', 'c'])

'a' in arr // true

1 in arr // false

实现单例模式

这里我们通过 construct 这个 trap 劫持 new 操作符,以便每次都返回单例实例

function Sigleton(fn){
  let instance
  const handler = {
    construct() {
      if(!instance){
        instance = new fn()
      }
      return instance
    }
  }

  return new Proxy(fn, handler)
}

function Func() {
  this.value = 'value'
}

// 1.普通的实例化
const f1 = new Func()
const f2 = new Func()

f1.value = 'new value'
f2.value  // "value"  f1 f2 是两个不同的实例


// 2. 用Proxy实现的单例
const p1 = Sigleton(Func)
const p2 = Sigleton(Func)

p1.value = "proxy value"

p2.value // "proxy value"  p1 p2 引用同一个实例对象

数据验证

revocable

Proxy.revocable(obj, prop) 方法可以用来创建一个可撤销的代理对象,返回一个包含了所生成的代理对象本身以及该代理对象的撤销方法的对象。该方法的返回值结构为 {"proxy": proxy, "revoke": revoke}, 其中:

  • proxy 表示新生成的代理对象,和普通的 new Proxy(obj, prop) 没什么区别,只是它可以被撤销掉。
  • revoke 撤销方法,调用的时候不需要加任何参数,就可以撤销掉和它一起生成的那个代理对象。

一旦某个代理对象被撤销,它将变的几乎完全不可用,在它身上执行任何的可代理操作都会抛出 TypeError 异常。

Proxy.revocable的一个使用场景是,目标对象不允许直接访问,必须通过代理访问,一旦访问结束,就收回代理权,不允许再次访问。所以,利用 Proxy.revocable 实现一个验证和可撤销访问的例子

const revocableInfo = Proxy.revocable({}, {
  set(obj, prop, val){
    if(prop === 'name') {
      if(typeof val !== 'string' || val === '') {
        throw new TypeError(' "name" is not valid')
      }
    }else if(prop === 'age') {
      if(typeof val !== 'number' || val > 150 || val <= 0) {
        throw new TypeError(' "age" is not valid')
      }
    }else {
      throw new TypeError(' must be "name" or "age"')
    }
    Reflect.set(obj, prop, val)
  }
})


// 用户填写信息
let userInfo = {}
function setUser(info){
  info.name = "Tom"
  info.age = 10

  // 非法age
  try {
    info.age = -10
  }catch(e) {
    console.log(e)
  }

  // 非法属性
  try {
    info.gender = 'male'
  }catch(e) {
    console.log(e)
  }

  // 存储 info
  userInfo = info
}
// 测试已经被 revoked 的代理
function setUserAgain(){
  try {
    userInfo.name = 'Jerry'
  }catch(e) {
    console.log(e)
  }
}

// 执行写入用户信息
setUser(revocableInfo.proxy)
// 数据验证出错
// TypeError:  "age" is not valid
// TypeError:  must be "name" or "age"


// 代理对象执行撤销方法 收回代理权
revocableInfo.revoke()
setUserAgain()
// TypeError: Cannot perform 'set' on a proxy that has been revoked

某个代理对象一旦被撤销,这个代理对象永远不可能恢复到原来的状态,同时和它关联的目标对象以及处理器对象将有可能被垃圾回收掉。

最后

Proxy 是在目标对象之前加一层劫持,外界对该对象的访问,都必须先通过这层劫持,因此提供了一种机制,可以对外界的访问进行过滤和改写,等同于在语言层面做出修改,所以属于一种 『元编程』

参考文章、代码