Vue3 Computed和watch原理

前言

本篇文章是《Vue.js设计与实现》第 4 章 响应系统的作用与实现笔记,其中的代码和图片部分来源于本书,用于记录学习收获并且分享。


在之前的文章Vue3响应式基本原理中我们实现了一个基本的副作用函数,现在在此基础上讨论如何实现computedwatch

一、副作用函数的可调度执行

为了实现computedwatch,我们需要对普通的副作用函数进行改造,使其支持可调度执行 。 可调度执行指的是 trigger函数被触发使得副作用函数重新执行时,可以指定其执行的时机次数和方式。

我们可以为effect函数添加一个参数options用于配置调度器,并将其挂载到副作用函数上。 改造后的effect函数如下:

js 复制代码
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  // 新增:将 options 挂在到 effectFn 上
  effectFn.options = options
  effectFn.deps = []
  effectFn()
}

trigger触发时运行副作用函数时,查看副作用函数上是否有options以及其中的scheduler调度器配置,有的话就运行调度器,并将副作用函数作为调度器的参数传入,从而可以在调度器内部控制副作用函数的执行。

js 复制代码
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 => {
      //这里判断调度器是否存在,存在就将副作用函数作为参数调用调度器
    if (effectFn.options.scheduler) {  //新增
      effectFn.options.scheduler(effectFn)  //新增
    } else {
      effectFn()
    }
  })
}

经过改造之后我们就可以通过如下方式使用options来配置调度器。

js 复制代码
effect(
    ()=>{
        ...
    },
    // options
    {
        //调度器配置函数
        scheduler(fn){
            ...
        }
    }
)

二、实现computed

从功能上看computed的功能和effect类似,都是在响应式数据变化的时候会触发运行,但computed却有一些特性effect不具备,这就需要使用options实现:

1.懒计算

computed并不会在其依赖的响应式数据产生变化的时候立即响应,而是 在需要使用到computed值得时候才会去响应。 为了实现懒计算的效果:我们通过配置options来实现:

js 复制代码
effect(
       ()=>{}
        ,{
            lazy:true
        }
    )

通过传入lazy:true,并在effect中进行判断lazy的值为true的时候就不立即执行.

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

  return effectFn
}

上面的操作 仅仅只是阻止了副作用函数的立即执行 。 我们如何在需要的时候去手动执行副作用函数并获取结果呢?
我们需要对副作用函数进行修改,使其能够返回副作用函数,从而可以自定义其执行方式

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

  return effectFn
}

这样我们只要手动执行effect就能拿到执行结果

js 复制代码
const effectFunc = effect(
       ()=>{}
        ,{
            lazy:true
        }
    )
effectFunc()

接下来我们只需要组合这些调整即可得到一个懒执行的副作用函数

js 复制代码
function computed(getter) {
  let value
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
  })
  
  const obj = {
    get value() {
      return effectFn();
    }
  }

  return obj
}

const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value)

2.缓存数据

计算属性的另一个特征是缓存数据: 计算属性依赖的值如果没有发生变化,则在执行的情况下直接返回返回上一次计算好的值,为了实现这一功能,我们需要:

  • 对上一次执行的值进行缓存,存于value
  • 增加一个标识dirty,用于确定是否需要去重新计算,true则需要重新计算
js 复制代码
function computed(getter) {
  let value
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
      }
    }
  })
  
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }

  return obj
}

lazytrue的情况下,若调度器scheduler被触发意味着依赖的数据发生变化,此时将dirty设置为 true。当下一次再访问sumRes.value时会effectFn()重新计算,并缓存给数据value.

3.特殊情况:computed被另一个副作用函数嵌套:

当在副作用函数中读取计算属性值时:

js 复制代码
const sumRes = computed(() => obj.foo + obj.bar)

effect(() => {
  console.log(sumRes.value)
})

obj.foo++

该代码并不会使得effect重新执行,因为此时发生了副作用函数的嵌套,此时外层的副作用函数是不会被内层副作用函数的响应式数据所收集的。为了解决这个问题,我们在computed中手动去调用tracktrigger方法,使得computed的返回值和外层的副作用函数建立联系。

js 复制代码
function computed(getter) {
  let value
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
        trigger(obj, 'value') //新增
      }
    }
  })
  
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      track(obj, 'value') //新增
      return value
    }
  }

  return obj
}

三、实现watch

watch可以监听具体响应式数据的变化,类似于一个回调函数,而effect则是其中的响应式数据发生变化时会使其本身重新执行。
watch的特点及实现如下:

1.可侦听响应式数据和getter函数

如果需要监听一个响应式数据,则直接在调度器中执行watch的回调函数cb即可

js 复制代码
function wawtch(source,cb){
    effect(
        ()=> cource.foo,
        {
            scheduler(){
                cb()
            }
        }
    )
}

const data = { foo:1 }
const obj = new Proxy(data,{...})

watch(obj,()=>{
    console.log('数据变化了')
})

上面的实现会有一些局限性:只能检测obj.foo的变化,为了能够对整个侦听对象进行观测,我们需要在副作用函数中对所侦听数据的所有的属性都进行读取。

js 复制代码
function watch(source, cb) {
    effect(
            ()=> traverse(source),
            {
                scheduler(){
                    cb()
                }
            }
        )
}

function traverse(value, seen = new Set()) {
    //原始值、null、或者已经读取过的不再处理
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  //保存已经读取过的值
  seen.add(value)
  //递归遍历,能读取到更深层的数据
  for (const k in value) {
    traverse(value[k], seen)
  }

  return value
}

通过增加traverse函数对source进行处理能够保证source里面的数据都被读取从而和副作用函数建立联系。

除了监听数据,watch还可以监听一个getter函数,所以需要在上述实现中增加对source类型的判断,若传入一个函数证明是getter直接使用:

js 复制代码
function watch(source, cb) {
    let getter
    //传入的是getter
    if(typeof source === 'function'){
        getter = source
    }else{
        getter = traverse(source)
    }
    effect(
            ()=> getter
            {
                scheduler(){
                    cb()
                }
            }
        )
}

2.获取新值和旧值

watch还有一个特点就是其可以获取到侦听数据的新值和旧值,如下所示:

js 复制代码
watch(
    ()=>obj.foo,
    (newVal,oldVal)=>{
        ...
    }
)

为了能够获得每一次副作用函数运行的值,我们需要使用之前定义在optionslazy属性,这样就能通过手动调用副作用函数获取运行值。

js 复制代码
function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue
  
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        newValue = effectFn()
        cb(oldValue, newValue)
        oldValue = newValue
      }
    }
  )
    oldValue = effectFn()
}

如上,我们启用了lazy属性并定义了oldValuenewValue,首次运行手动运行副作用函数获取初始值作为oldValue,并在调度器中运行副作用函数,在后续侦测的数据产生变化时更新新旧值,将新旧值作为参数传递给watch的回调cb

3.回调执行时机: immediate

immediate被设为true时,watch回调函数会被立即执行一次

而我们在上面实现的watch的回调函数只会在响应式数据source变化的时候才会执行。

为了在首次创建时就能执行一次回调函数,需要对watch函数进行一些修改:

js 复制代码
function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

  const job = () => {
    newValue = effectFn()
    cb(oldValue, newValue)
    oldValue = newValue
  }

  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: job
    }
  )
  
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

如上,我们将和回调函数有关的逻辑放入一个单独的函数job中,并在watch中新增参数options用于配置immediate。当immediatetrue时,直接运行一次job,从而使得回调函数在source未变化时便执行。

4.回调函数的触发时机: post 和 sync

watch中还有参数flush用于指定调度函数的执行时机,当flushpost则,watch中的副作用函数需要放到微任务队列中,等待DOM更新完毕后再执行。为此需要在调度器中对flush进行判断,当flushpost时,使用Promisejob放入微任务队列。而flushsync时则是同步执行,直接执行job即可。 经完善后的watch如下:

js 复制代码
function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

  const job = () => {
    newValue = effectFn()
    cb(oldValue, newValue)
    oldValue = newValue
  }

  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        if (options.flush === 'post') {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
      }
    }
  )
  
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

总结:

  1. 我们可以在effect上添加options 用于配置副作用函数,并在options中添加scheduler函数,从而使得副作用函数支持可调度执行;
  2. 通过给副作用函数添加lazy选项,使其能够支持懒执行 ,从而能够以手动的方式执行副作用函数,实现了computed 。在配置了副作用函数的scheduler使其支持可调度执行后,通过使用dirty标记响应式数据是否变化,从而实现了数据的缓存 ,只有数据变化时才去重新执行副作用函数;
  3. 通过配置副作用函数的scheduler后,在调度器中添加回调函数 实现watch 监听数据改变后的回调。并添加immediate选项去控制回调函数是否在watch创建时执行 ,添加flush选项去决定回调函数是异步还是同步执行
相关推荐
醉城夜风~8 分钟前
CSS元素显示模式(display)
前端·css
AI大模型-小华1 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子3 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅3 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni4 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学5 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师5 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc885 小时前
微软EDGE浏览器功能学习
前端·学习·edge
Csvn6 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端