六、vue响应式原理:computed和watch简单实现及竞态问题处理

556 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

上一篇文章我们了解并简单实现了调度器和懒执行,这两个配置正是实现vue特色api computed和watch的关键

computed

computed是我们开发中最常用的api之一,我们经常会把他跟methods放在一起比较,其中比较明显的区别就是computed会维护一个缓存变量,在依赖的响应式数据不改变的情况下只会执行一次对应函数并缓存结果,同时它是惰性的,只有在使用computed的时候才会执行对应的getter函数。那么它是怎么实现的呢

首先要有一个变量(value)用来存储getter函数执行后的结果,然后还要有一个变量(dirty)用来判断是否需要重新执行函数,dirty的改变由依赖的响应式数据是否改变决定下面根据这个思路进行简单实现:

function computed(getter) {
  // 声明存储变量和决定是否需要重新执行effectFn的变量 dirty为true表示需要执行
  let value,
      dirty = true
  // 利用lazy配置拿到effectFn
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      // 响应式数据改变判断dirty值,改为true表示需要重新计算
      if (!dirty) {
        dirty = true
      }
    }
  })
  // 定义一个对象并返回 其中有一个get属性用来获取value值
  const obj = {
    get value() {
      // dirty为true时才会计算 并存储value
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }

  return obj
}

下面我们来测试一下

// 原始数据
const data = { num1: 1, num2: 2 }
// 省略部分代码 dataProxy是proxy对象

const sum = computed(() => {
  console.log('执行')
  return dataProxy.num1 + dataProxy.num2
})
console.log(sum.value)
console.log(sum.value)
dataProxy.num1 = 3
console.log(sum.value)
console.log(sum.value)
// 执行结果
// 执行
// 3
// 3
// 执行
// 5
// 5

可以看见,我们想要的缓存效果已经实现,只有在第一次访问value属性时执行getter函数,如果相关响应式数据改变才会再次执行,重新计算

computed的依赖收集

上面已经实现了computed的基础功能,但是我们应该考虑到,如果一个副作用函数访问了computed的value属性,当computed的value值改变时,这个副作用函数是不是也应该符合响应式系统要再次执行一次。如下

const sum = computed(() => {
  console.log('执行')
  return dataProxy.num1 + dataProxy.num2
})
function render() {
  console.log(sum.value)
}
effect(render)
dataProxy.num1 = 3
// 执行
// 3

可以看到,num1改变的时候,render并没有重新执行。原因是当前我们的响应式系统是在proxy的get、set方法中收集和触发执行对应的副作用函数,而computed返回的obj对象并不是一个proxy对象,所以并不能触发我们所编写的收集执行逻辑。

所以我们需要手动调用收集和执行的函数,分别为track和trigger函数:

function computed() {
  // 省略部分代码
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
        // 响应式数据改变时执行收集的依赖函数
        trigger(obj, 'value')
      }
    }
  })
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      // 在访问value属性时收集依赖函数
      track(obj, 'value')
      return value
    }
  }
}

上面代码我们在调用value属性的时候收集依赖,在调度器内判读如果响应式数据值发生改变时触发收集的依赖,这样就可以完成依赖的收集与触发了

watch

watch同样是vue的一个特色且非常好用的api,有了上述computed的实现,相信对于watch的实现大家应该都会有一些思路和想法。

默认情况下,watch也是惰性的,只有当被侦听的源发生变化时才执行回调。可以被侦听的源包括单个或者多个响应式数据或者一个getter函数,这里我们实现单个响应式数据和getter函数的情况,多个响应式数据的实现读者可以自行拓展。

思路:watch应该维护两个变量,分别为改变前的值和改变后的值(oldValue和newValue),由于是懒执行,所以也应该使用lazy配置,如果监听源是一个对象,我们应该保证他的左右属性改变都应该能被监听到

首先处理监听源是对象时,保证所有属性都能收集到依赖。这个很简单,只需要保证每个属性都被读取即可

function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  seen.add(value)
  for (const k in value) {
    traverse(value[k], seen)
  }

  return value
}

seen的集合是为了防止循环引用造成死循环

下面来实现一下基础的watch

function watch(source, cb) {
  let getter
   // 判断source的类型 保证getter是一个函数
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  // 声明oldValue, newValue变量
  let oldValue, newValue
  const effectFn = effect(getter, {
    scheduler() {
      // 执行effectFn获取新的值
      newValue = effectFn()
      cb(newValue, oldValue)
      // 把新值赋值给旧值 作为下次的旧值
      oldValue = newValue
    },
    lazy: true
  })
  // 首次的旧值
  oldValue = effectFn()
}
// 测试
watch(() => dataProxy.num1, (newVal, oldVal) => {
  console.log(newVal, oldVal)
})

dataProxy.num1++
dataProxy.num1++
// 2 1
// 3 2

可以看到基础的watch实现也非常简单,维护新值和旧值变量,首次旧值直接是effectFn执行的结果,如果响应式数据发生改变,进入到调度器中,新值就为此时effectFn执行的结果,然后调用cb函数,将newValue和oldValue作为参数传过去,然后将oldValue赋值为newValue,作为下次cb函数的旧值。

配置项

我们在开发中还会常用一个配置项immediate,希望立马执行一次回调函数,这个实现也非常简单,我们只需要在watch执行的时候立马执行cb,并将新值计算出来传递过去即可

function watch(source, cb, options = {}) {
  // 省略部分代码
  // 首次的旧值
  // oldValue = effectFn()
  // 将调度器里的代码抽取成一个函数 避免代码重复书写
  const job = () => {
    newValue = effectFn()
    cb(oldValue, newValue)
    oldValue = newValue
  }
  // 如果立即执行则直接调用job 此时oldValue为undefined
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

flush

上面我们写的watch其实在每次响应式数据发生变化的时候都会执行回调函数,这是比较耗费性能的,我们有时希望在一次同步执行结束后得到最后计算结果时才执行一次回调函数。其实我们前面实现过类似的功能,我们只需要利用事件循环机制即可,这里不再给出代码,可参照上一章节 相关代码

竞态问题

什么是竞态问题:举个简单的例子。我们需要获取一组数据并渲染,我们先后发出A、B请求,俩个请求的请求参数不同,我们希望页面最终展示的是后发送的B请求的数据,但是可能因为网络原因或者服务器查询数据的速度不同导致B先请求完成,最后我们页面渲染的就会变成A请求的数据。这就是常见的竞态问题

其实vue3中的watch会在调用回调函数的时候给我们传第三个参数onInvalidate,此参数为一个函数,接收一个函数回调,此回调会在下次回调callback前执行

基于此我们可以这样处理竞态问题

// 使用tab切换来模拟发送多次请求 tabIndex改变即切换
<script>
  import { defineComponent, computed, ref } from 'vue'
  export default defineComponent({
    const list = ref([])
    const tabIndex = ref(0)
    watch(tabIndex, (newVal, oldVal, onInvalidate) => {
      // 使用expired变量记录请求到的值是否过期
       let expired = false
       onInvalidate(() => {
         // 下次执行回调时直接设置为true表示已过期不再使用
         expired = true
       })
      const res = fetch('xxx')
      // 判断是否过期
      if (!expired) {
        list.value = res
      }
    }, { immediate: true })
  })
</script>

可以看到我们在每次执行watch回调的时候都声明一个expired变量,来记录数据是非过期可用,我们需要在下次回调执行时将expired赋值为为true表示已过期,不再使用,这样我们就永远获取到的是最新的请求的数据

onInvalidate实现

onInvalidate实现其实很简单,就是在每次watch回调函数执行之前调用一下即可:

function watch(source, cb, options = {}) {
  // 省略部分代码
  // 声明cleanup变量保存onInvalidate参数函数
  let cleanup
  function onInvalidate(fn) {
    cleanup = fn
  }

  const job = () => {
    newValue = effectFn()
    // cb调用之前判断cleanup是否存在 存在则执行
    if (cleanup) {
      cleanup()
    }
    cb(oldValue, newValue, onInvalidate)
    oldValue = newValue
  }
  // 省略部分代码
}