【拆解Vue3】reactive是如何实现的?(上篇)

947 阅读7分钟

我正在参与掘金创作者训练营第5期, 点击了解活动详情

本篇内容基于【拆解Vue3】计算属性是如何实现的?实现。

雏形

还是先来看看Vue官方的描述,再来逐步实现reactive

  • 为 JavaScript 对象创建响应式状态,可以使用 reactive 方法;

reactive的目标就是为了实现JavaScript对象的响应式。在前面我们已经利用proxy初步实现了响应式,但使用的data都较为简单,在本篇中,我们会针对JavaScript对象,来完善我们的响应式设计。

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, newValue) {
      target[key] = newValue
      trigger(target, key)
      return true
    }
  })
}

响应式数据为什么需要Reflect?

我们先用reactive函数封装proxy,这样语法上就与真实的reactive一致了。接下来我们看看针对不同的对象,我们的reactive是否能产生预期的结果。

const data = {
  foo: 1,
  get bar() {
    return this.foo
  }
}
const obj = reactive(data)
effect(() => console.log('worked', obj.bar))

在这个例子中,我们定义了一个data对象,其中包含了一个get,即读取data.bar的值时,返回this.foothis出现的地方总会让人先想到,这个this会有预期的指向吗? 我们的预期是,当读取data.bar时,返回data.foo的值。同样,在effect中,我们读取了obj.bar的值,该副作用函数会被obj.bar收集,但obj.bar返回了this.foo,理应也被obj.foo收集。那按照上述的预期,当修改obj.foo的值时,也应该会有响应式的trigger去触发副作用函数。

QQ截图20220807121132.png

从结果看,我们修改obj.foo时,并没有触发副作用函数effect。这里发生了什么呢?我们来打上断点调试一下看看。

QQ截图20220807122447.png

调试的结果很清楚了,我们实际上只将effect作为obj.bar的副作用函数收集了,但obj.foo没有收集到副作用函数。问题很清楚了,收集副作用函数的逻辑有问题,那就再来看看我们是如何进行副作用函数收集的。

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    ...
  })
}

QQ截图20220807143655.png

根据调试信息我们发现,在收集副作用函数时,targetObject。这意味着,我们在读取obj.bar时,get中的target实际上是原始对象data,返回的target[key]就是data.bar。对于data.bar触发的get bar()来说,它的this指向不是Proxy代理对象,而是原对象data,作用于原对象上的副作用函数当然无法被收集。为了解决这个问题,我们需要指定Proxy内的this指向,使得这些this指向代理对象。

我们可以使用Reflect来指定调用过程中的thisReflect拥有与Proxy相同的方法,并且Reflect可以通过recevier参数,来调整this的指向。为了说明这一点,同样来看一个例子。

const obj = {
    foo: 1,
    get foo() {
        return this.foo
    }
}
const trick = {
    foo: 22
}
console.log(Reflect.get(obj, 'foo', trick))

执行这段代码,结果正如我们预期的那样,输出了22。验证了Reflect的有效性后,我们来更新reactive的代码。

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newValue, receiver) {
      trigger(target, key)
      return Reflect.set(target, key, newValue, receiver)
    }
  })
}

QQ截图20220807164837.png

我们可以发现,使用Reflect后,this指向了receiver这个Proxy代理对象。

QQ截图20220807171616.png

我们在控制台中使用++obj.foo,来触发副作用函数。这里还有一点需要注意,++obj.foo相当于obj.foo = obj.foo + 1,所以先触发了get操作,执行了副作用函数,而set是后被触发的,因此输出的值仍是未进行++操作的旧值。

更完善的响应式

在前面的实现中,我们对于任何set操作,其实都会触发trigger去执行对应的副作用函数。这可能与我们的预期不符,因为set并不一定会改变响应式对象的值,而响应式对象的值不改变,就不应该被触发trigger。参考下面这个例子。

const data = {
  foo: 1,
}
const obj = reactive(data)
effect(() => console.log('worked', obj.foo))

QQ截图20220807175535.png

在这个例子中,并没有改变obj.foo的值,副作用函数却被触发了。这个问题也好解决,我们只需要判断一下,set的值与原值是否相等,若相等,就不再触发trigger就好了。

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newValue, receiver) {
      const oldValue = target[key]
      if(oldValue !== newValue) {
        trigger(target, key)
      }
      return Reflect.set(target, key, newValue, receiver)
    }
  })
}

这里看到!==又皱起眉头,不禁想到了那个经典面试题NaN !== NaN。显然,我们打的这个补丁,对于NaN无效。

const data = {
  foo: NaN,
}
const obj = reactive(data)
effect(() => console.log('worked', obj.foo))

QQ截图20220807180144.png

为了解决这个边缘case,我们不得不利用NaN === NaN的真值为false的特性,用来判断是否新值与旧值均为NaN,若均为NaN,我们也不需要执行trigger

const bothNaN = (newValue, oldValue) => !(newValue === newValue || oldValue === oldValue)
const isEqual = (newValue, oldValue) => bothNaN(newValue, oldValue) || oldValue === newValue
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newValue, receiver) {
      const oldValue = target[key]
      if(!isEqual(newValue, oldValue)) {
        trigger(target, key)
      }
      return Reflect.set(target, key, newValue, receiver)
    }
  })
}

前面在实现effect的时候,我们考虑到了嵌套的情况,并进行了响应的处理。首次启发,我们考虑一下对象嵌套,我们的代码能否给出预期的结果。我们用原型链来实现这个case。

const obj = {}
const son = reactive(obj)
const preObj = { bar: 1 }
const father = reactive(preObj)
Object.setPrototypeOf(son, father)
effect(() => console.log('worked', son.bar))

这里我们把father置为son的原型,给了一个effect打印son.bar的值。当然,son中并不包含bar,回忆一下原型链的知识,找不到的值会沿着原型链从下到上寻找,所以son会取到fatherbar作为自己的bar值输出。接着,让我们尝试修改son.bar,看看能否按我们的预期执行。

QQ截图20220807183653.png

等等,这里的副作用函数怎么执行了两次?上面我们讲的是普通对象的,在响应式对象上使用原型链,这中间发生了什么?

从执行结果看,sonfather都执行了effect。首先比较清楚的一点是,son上没有bar,就需要去父级获取,son读取了fatherbar,此时也会触发fatherget,从而导致effectfather收集。

收集理清楚了,接下来考虑触发。在执行son.bar = 2时,首先触发的是sonset并触发了trigger执行副作用函数,这是副作用函数第一次被执行。

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newValue, receiver) {
      const oldValue = target[key]
      if(!isEqual(newValue, oldValue)) {
        trigger(target, key)
      }
      console.log(target) // (*)
      return Reflect.set(target, key, newValue, receiver)
    }
  })
}

为了搞清楚每次执行effecttarget是谁,我们在(*)处打印target的值。

QQ截图20220807190353.png

第一次在执行时,我们可以清楚的看到son上没有bar。我们返回了Reflect.set(target, key, newValue, receiver)son上找不到key,就不得不顺着原型链往上找,在father上找到了这个key,从而触发了fatherset

这里一定要注意,此处仅仅是触发了fatherset,并没有修改fatherbar,这是因为自始至终,receiver都是son。我们打印一下看看。

QQ截图20220807191516.png

发现了这点,就好办了,我们只需要通过receiver就能判断到底更新的是不是当前的target。这里要当心,target是原始对象,而receiver是响应式对象,我们不能直接比较。简单起见,我们直接去获取receiver的原始对象与target进行对比。

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key)
      if(key === 'raw') { // (1)
        return target
      }
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newValue, receiver) {
      const oldValue = target[key]
      if(target === receiver.raw && !isEqual(newValue, oldValue)) { // (2)
        trigger(target, key)
      }
      return Reflect.set(target, key, newValue, receiver)
    }
  })
}

我们在(1)处增加了一个属性raw,用来获取响应式对象的原始对象,在set中就可以利用这个属性判断是否是自身的副作用函数被触发了。

QQ截图20220807192908.png

参考资料