Skip to content

Vue3 响应性原理之 Proxy & Reflect

之前已经实现了vue3响应性系统中如何记录代码,并在需要的时候可以再次执行它们:Vue3 响应性原理之 track & trigger

然而问题是现在只能手动的调用tracktrigger,现在就来解决这个问题。

为什么 Vue3 要重写数据响应实现

vue的早期版本,使用data选项是会存在一些响应性问题的(看这里)。

这主要是因为受限于当时ES6语法还并未在众多浏览器中普及,vue使用了兼容性更好的Object.definePropetygetter/setter来实现数据响应性。

进入vue3时代,随着ES6语法的普及,vue也使用ProxyReflect重新改写了数据响应的实现。

Proxy 的基本用法

Proxy顾名思义,它可以对一个对象进行代理,允许你拦截对该对象的任何操作。

javascript
const product = { price: 5, quantity: 2 }

const proxiedProduct = new Proxy(product, {
  get(target, key) {  // target === product
    return target[key]
  },
  set(target, key, value) {  // target === product
    target[key] = value
  }
})

console.log(proxiedProduct.quantity)  // 2

proxiedProduct.price = 10

console.log(product.price)  // 10

这里getset中的target参数就是被代理的对象product

使用Proxy相比于Object.definePropety好的其中一点在于,不需要提前声明好所有的key,就可以拦截对目标对象的任何操作,这样就避免了vue之前版本中出现的问题。

Proxy 中 this 指向的问题

把上面的例子升级一下:

javascript
const product = {
  price: 5,
  quantity: 2,
  get total() {
    return this.price * this.quantity
  }
}

const proxiedProduct = new Proxy(product, {
  get(target, key) {  // target === product
    return target[key]
  },
  set(target, key, value) {  // target === product
    target[key] = value
  }
})

const productA = {
  price: 10,
  quantity: 4,
  __proto__: proxiedProduct
}

console.log(productA.total)  // 10

实际上这里预期打印结果的应该是productA.price * productA.quantity = 40,但由于getset中的target永远都是指向product,导致total中的this也总是都指向了product,所以结果变成了product.price * product.quantity = 10

其实getset中还提供了一个额外的参数receiver,它总是指向实际的调用者,在这个例子中指向的就是productA

javascript
const proxiedProduct = new Proxy(product, {
  get(target, key, receiver) {  // receiver === productA
    return target[key]
  },
  set(target, key, value, receiver) {  // receiver === productA
    target[key] = value
  }
})

那把这个receiver传递给totalthis就可以了。

对于一般的函数,我们可以使用call/bind/apply来指定this绑定,但是这里的target[key]是一个getter函数,无法指定this

为了更好的解决上述this指向的问题,需要用到ES6提供的Reflect语法。

Reflect 的基本用法

Reflect对象上挂载了很多静态方法,所谓静态方法,就是和Math.round()这样。

其中比较常用的两个方法就是get()set()

javascript
Reflect.get(product, 'quantity')  // 2
Reflect.set(product, 'price', 10)

它们几乎等同于:

javascript
product['quantity']  // 2
product['price'] = 10

所以上面的例子可以改写为:

javascript
const proxiedProduct = new Proxy(product, {
  get(target, key, receiver) {
    return Reflect.get(target, key)
  },
  set(target, key, value, receiver) {
    Reflect.set(target, key, value)
  }
})

同时Reflect.getReflect.set还可以接收一个额外参数,用于可能存在的settergetterthis的绑定。这样就能很好的解决上述例子中,打印结果不符合预期的问题:

javascript
const product = {
  price: 5,
  quantity: 2,
  get total() {
    return this.price * this.quantity
  }
}

const proxiedProduct = new Proxy(product, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
    // or
    // return Reflect.get(...args)
  },
  set(target, key, value, receiver) {
    Reflect.set(target, key, value, receiver)
    // or
    // Reflect.set(..args)
  }
})

const productA = {
  price: 10,
  quantity: 4,
  __proto__: proxiedProduct
}

console.log(productA.total)  // 40

使用 Reflect 的原因/好处

除了上面说的使用Reflect可以很好的解决this的指向问题之外,还有另外两个好处:

  1. Reflect提供的方法与Proxy提供的拦截器方法一一对应,只要是Proxy上的方法,就能在Reflect上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础,同时代码也更容易阅读和美观。
javascript
const proxiedObj = new Proxy(obj, {
  get(target, key) {
    console.log('get', target, key)
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    console.log('set', target, key, value)
    return Reflect.set(target, key, value)
  },
  deleteProperty(target, key) {
    console.log('delete' + key)
    return Reflect.deleteProperty(target, key)
  },
  has(target, key) {
    console.log('has' + key)
    return Reflect.has(target, key)
  }
})
  1. 使用Reflect有些返回值更加合理。比如Reflect.set(target, key, value, receiver)失败时会返回false,不会因为报错而中断正常的代码逻辑执行。

结合 track & trigger

综上所述,可以封装出成一个reactive函数。

javascript
function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      return Reflect.set(target, key, value, receiver)
    }
  }
  return new Proxy(target, handler)
}

const product = reactive({ price: 5, quantity: 2 })
product.quantity = 4
console.log(product.quantity)  // 4

显而易见,我们已经完全代理/监控了对target的读和写,只需要在handler.gethandler.set中分别调用tracktrigger即可实现目标:

javascript
function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver)
      // call track
      return result
    },
    set(target, key, value, receiver) {
      let oldValue = target[key]
      let result = Reflect.set(target, key, value, receiver)
      if (result && oldValue != value) {
        // call trigger
      } 
      return result
    }
  }
  return new Proxy(target, handler)
}

完整的代码

以下即是vue3reactive的实现:

javascript
const targetMap = new WeakMap()

function track(target, key) {
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }

  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }

  dep.add(effect)
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    return
  }

  let dep = depsMap.get(key)
  if (dep) {
    dep.forEach(effect => {
      effect()
    })
  }
}

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver)
      track(target, key)
      return result
    },
    set(target, key, value, receiver) {
      let oldValue = target[key]
      let result = Reflect.set(target, key, value, receiver)
      if (result && oldValue !== value) {
        trigger(target, key)
      }
      return result
    }
  }
  return new Proxy(target, handler)
}

let product = reactive({ price: 5, quantity: 2 })
let total = 0

let effect = () => {
  total = product.price * product.quantity
}
effect()

console.log(total)  // 10
product.quantity = 3
console.log(total)  // 15

以上例子和思路均来源于官方教程 Vue Mastery