vue3响应式原理

95 阅读5分钟

vue3的响应式设计思想和vue2差不多,区别就是vue2的使用Object.defineProperty()实现的,而Vue3是使用Proxy实现的。然后思想都是将响应式数据和其副作用函数进行管理关联,在取值时收集依赖(副作用函数),设置值时触发依赖(副作用函数)。 就是我们在修改属性时,需要试图自动更新,所以此处的副作用函数可以把它理解为一个渲染函数,调用它就会触发试图更新。

如果在这之前对vue2的原理有了解的话,以下代码阅读起来更容易。vue2响应式原理

首先我们需要考虑怎么将响应式数据将副作用函数关联起来,在vue2中是在defineReative()函数中,去const deps = new Dep(),该deps是存在闭包环境中,换句话说就是每个key都对应一个deps,那么在vue3中也要类似的数据结构,才能准确的在我们修改响应式数据中的某个属性时,去触发副作用函数。那么vue3中使用的是 以下数据结构去实现的:

image.png

<body></body>
<script>

// 存储副作用函数的桶
const bucket = new WeakMap()

// 原始数据
const data = { foo: 1 }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

function track(target, key) {
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
  activeEffect.deps.push(deps)
}

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

  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
  // effects && effects.forEach(effectFn => effectFn())
}

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
// effect 栈
const effectStack = []

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压栈
    effectStack.push(effectFn)
    fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}
</script>

代码解读:

首先使用Proxy对原始数据进行劫持,并返回一个代理对象obj,那么在后续我们访问/设置obj上的属性都会触发内部的get,set方法。

在get()方法内部调用了一个tarck()函数,并且返回其获取的值。那么track函数内部干了些啥,通过代码并结合以上的数据结构的图可以看出来将当前副作用函数添加到集合deps中,他是一个Set的数据,内部存储的是当前的key对应的渲染函数。但是最后有一行,

    activeEffect.deps.push(deps)

解决问题的场景:

const data = { ok: true, text: 'hello world' }
...
effect(() => {
    document.body.innerText = obj.ok ? obj.text : 'not'
})
 setTimeout(() => {
    obj.ok = false
    setTimeout(() => {
      obj.text = 'hello vue3'
    }, 1000)
}, 1000)

以上代码在调用effect时,因为这是obj.ok = true ,所以此处会触发obj.ok和obj.text的取值,也就会将当前的effect收集到text,ok的Set中,并且此时它们的set中存储的是同一个副作用函数,

image.png

然后我们紧接着我们修改了obj.ok = false,正常来讲我们期望的时触发当前ok的副作用那个函数,并页面中展示"not",并且我们在后续我们无论怎么修改obj.text的值副作用函数都不会去执行了,因为,当obj.ok = false时,obj.text没有任何意义,就以上看下来obj.text的set中还保存着副作用函数,那么我们在修改值时还是回去调用该函数,

所以为了解决这个问题,我们只需要在调用副作用函数之前,将当前副作用函数从关联属性的set中踢出去,那么track中的最后一句作用就是将当前属性的副作用函数集合都存起来。方便后续将它找他并踢出去。然后踢出当前副作用函数的代码就是在effect中,调用了一个 cleanup(effectFn),该函数就是解决这个问题。

但是还有个问题就是我们在调用副作用函数时,会将当前副作用函数从集合中踢出去,然后又会触发副作用函数内部的属性的取值操作,从而又将当前副作用函数又添加到集合中,因为我们此处遍历,删除,添加都是同一个集合,所以会循环调用,造成栈溢出的问题。大概就是以下代码的意思

const set  = new Set([1])
set.forEach(item=> {
    set.delete(1)
    set.add(1)
    console.log("遍历中")
})

所以为了解决以上问题,我们可以将当前的set拷贝一份,其相关代码就是trigger函数中的effectsToRun,但是看到在拷贝的时候有一个判断条件,就是集合中的副作用函数不是当前正在执行的副作用函数再将他添加到一个新的集合中并去执行他,为什么要这样呢,

effect(()=>{
    obj.foo++
})

以上代码等同于

effect(()=>{
    obj.foo = obj.foo + 1
})

所以我们在调用effect首先会读取obj.foo的值这会触发track操作,将当前的副作用函数添加到集合中,然后又设置值,将其+1赋值给obj.foo,此时会触发trigger操作,即把当前集合中的副作用函数取出来并执行,问题就来了又会出现循环调用问题。所以

 const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())

if (effectFn !== activeEffect) { effectsToRun.add(effectFn) }这个判断条件就是如果触发的副作用函数和当前正在执行的副作用函数时用一个,就不去执行它。

我们在vue2响应式原理中是用栈结构存储当前的watcher的,对应到此处就是当前正在执行的副作用函数,作用就是解决嵌套的问题。所以我们在调用副作用函数时也是用了一个栈结构去存储当前的副作用函数,副作用函数调用完,出栈,栈顶永远是当前正在执行的副作用函数,保证响应式数据就只会收集直接读取其值的副作用函数作为依赖,从而避免发生错乱。

至此,vue3中的响应系统基本功能已完成。