[vue源码笔记]computed和watch的异同@vue2.x/vue3.0

273 阅读9分钟

前言:经过前面三篇笔记的讨论,vue2.x/3.0的响应式以及数据驱动有了大致的了解,那具体到渲染过程中呢?vue中的computed,watch具体有哪些区别和共性呢?这将是本篇笔记的主题

computed

2.x版本

computed画像:

  1. 基于data(后续2.x版本的状态将被成为data)进行计算得出的值
  2. data发生变更,将自动进行重新计算得出新的值
  3. 具有懒惰特性,只有在被使用的时候才会重新进行计算
  4. 具有缓存特性,data发生变更后将只进行一次计算,后续被使用直接返回缓存值 基于以上"画像",可以知道computed在vue数据驱动系统中身负两种角色: 订阅者和依赖,对于data来说,computed为订阅者,而对于render函数(页面渲染的方法)来说其为依赖
    为了便于理解,这里再贴一下前面笔记定义过的Watcher类代码
class Watcher {
    constructor(getter) {
        this.id = uid++
        this.getter = getter
        this.newDeps = [] // 存储新一轮的dep
        this.newDepIds = new Set()
        this.deps = [] // 存储老一轮的dep
        this.depIds = new Set()
        this.value = this.get()
    }
    get() {
        window.Deptarget = this
        this.value = this.getter()
        window.Deptarget = null
        this.cleanUpDeps()
    }
    update() {
        this.run()
    }
    run() {
        this.get()
    }
    cleanUpDeps() {
        // 清除不再依赖的dep,将newDeps赋值给deps,清空newDeps
        let i = this.deps.length
        while(i--) {
            if (!this.newDepIds.has(this.deps[i].id)) {
                this.deps[i].removeSub(this)
            }
        }
        let temp = this.depIds
        this.depIds = this.newDepIds
        this.newDepIds = temp
        this.newDepIds.clear()
        temp = this.deps
        this.deps = this.newDeps
        this.newDeps = temp
        this.newDeps.length = 0
    }
    // 新增addDep方法,进行依赖收集
    addDep(dep) {
        // 如果新依赖列表不包含dep,则将其加入新dep列表
        if (!this.newDepIds.has(dep.id)) {
            this.newDepIds.add(dep.id)
            this.newDeps.push(dep)
            // 如果老dep列表中也不包含dep,则立即将当前订阅者加入dep.subs列表中
            if (!this.depIds.has(dep.id)) {
                dep.addSub(this)
            }
        }
    }
}

computed的实现代码:(初版)

// data (伪代码)
count: 1
// 用户定义的computed函数
function double() {
    return this.count * 2
}
function initComputed(vm, key, getter) {
    // 创建computed-watcher
    var watcher = new Watcher(getter)
    defineComputed(vm, key, watcher)
}
function defineComputed(vm, key, watcher) {
    Object.defineProperty(vm, key, {
        get() {
            return watcher.value
        }
        set() {}
    })
}

上面的代码只实现了computed画像的1,2两条,但是懒惰和缓存没有
如果要实现懒惰特性,在创建computed-watcher的时候就不能直接执行getter函数,所以需要在实例化Watcher的时候增加一个options参数标识Watcher的一些行为

第一次改造,使之满足初始化懒惰

class Watcher {
    constructor(getter, options) {
        this.lazy = options.lazy
        this.value = this.lazy ? undefined : this.get() // 如果懒惰Watcher则在实例化的过程中不再执行this.get
    }
    get() {}
}
function initComputed(vm, key, getter) {
    // 创建computed-watcher
    var watcher = new Watcher(getter, {lazy: true})
    defineComputed(vm, key, watcher)
}

Watcher经过上面改造在initComputed时computed-getter不会进行计算,但是当引用computed的时候直接返回watcher.value为undefined显然不合适

第二次改造,使之满足引用的时候能够更新计算

function defineComputed(vm, key, watcher) {
    Object.defineProperty(vm, key, {
        get() {
            return watcher.get() // 每次引用的时候重新进行计算
        }
        set() {}
    })
}

这样初始化的时候computed-getter不会立刻进行计算,当真正引用到computed的时候才会执行watcher.get()进行重新计算,但是当data发生变更后computed仍会立刻重新计算,要真正使用的时候立刻重新计算

第三次改造,使之满足data变更的时候不立刻重新计算

class Watcher {
    constructor(getter, options) {
        this.lazy = options.lazy
        this.value = this.lazy ? undefined : this.get() // 如果懒惰Watcher则在实例化的过程中不再执行this.get
    }
    get() {}
    update() {
        if (this.lazy) {
            // do nothing
        } else {
            this.run()
        }
    }
}

经过三次改造已经实现了懒惰的特性,即只有在真正引用的时候才进行计算,但是在每次引用的时候都会重新计算,computed变成了被引用才重新计算,依赖更新反而不做任何事情了这不符合其缓存的特性,要实现变更后先不进行计算,等引用后看是否需要重新计算则要在Watcher中引入一个状态先记录依赖是否更新需要重新计算,等引用的时候依据该状态决定是否重新计算

第四次改造,使之满足懒惰和缓存

class Watcher {
    constructor(getter, options) {
        this.lazy = options.lazy
        this.dirty = options.lazy // 懒watcher扩展字段记录是否需要重新计算
        this.value = this.lazy ? undefined : this.get() // 如果懒惰Watcher则在实例化的过程中不再执行this.get
    }
    get() {}
    update() {
        if (this.lazy) {
            this.dirty = true // 懒watcher在变更通知阶段dirty置为true,标识依赖已发生变更需要重新计算,即脏了
        } else {
            this.run()
        }
    }
    evaluate() {
        this.value = this.get()
        this.dirty = false
        
    }
}
function defineComputed(vm, key, watcher) {
    Object.defineProperty(vm, key, {
        get() {
            if (watcher.dirty) {
                watcher.evaluate()
            }
            return watcher.value
        }
        set() {}
    })
}

经过修改已经满足了computed的4个画像,但是仔细思考就会发现一点问题:
便于描述我们称computed的watcher为computed-watcher,称渲染函数的watcher为render-watcher.基于以上改造如果render-watcher的getter方法中没有直接引用data而是引用了computed,当data发生变更的时候会发生什么?只需要简单分析就知道了,这种情况下render-watcher压根接收不到变更通知也就不会进行更新

第五次改造,修复render-watcher简介依赖data的问题

function defineComputed(vm, key, watcher) {
    Object.defineProperty(vm, key, {
        get() {
            if (watcher.dirty) {
                watcher.evaluate()
            }
            if (Deptarget) { // Deptarget是当前订阅者,也是一个Watcher实例
                Deptarget.depend()
            }
            return watcher.value
        }
        set() {}
    })
}

问题点就在于当computed被引用的时候要将其依赖的data和其订阅者之间建立依赖关系,这样就相当于真正的订阅者间接得依赖了computed依赖的依赖
下面贴出Deptarget.depend对应的代码(获取翻看前面笔记)便于理解

class Watcher {
    ...
    depend () {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
}
class Dep {
    ...
    depend () {
    if (Deptarget) {
      Deptarget.addDep(this)
    }
  }
}

2.x版本computed小节

在2.x版本中computed实现的大致流程:
首次渲染

  1. 响应化data
  2. 基于用户自定义computed-getter创建computed-watcher并使用Object.definneProperty将其定义为vm的属性getter,computed-getter中将引用data
  3. render-watcher创建执行renderDeptarget置为render-watcher,同时将render-watcher推入targetStack,render中引用computed触发computed-getter
  4. 触发computed重新计算,Deptarget置为computed-watcher,同时将computed-watcher进行依赖收集 + 计算,建立computeddata的依赖关系
  5. computed计算完成,将computed-watcher出栈
  6. Deptarget置为targetStack栈顶值即为render-watcher
  7. 遍历computed-watcher的依赖项并使之与Deptargetrender-watcher建立依赖关系
  8. 返回computed-watcher.value作为computed的计算值
  9. render执行结束,targetStack出栈,Deptarget置空,第一轮渲染结束 数据变更,重新渲染
  10. data变更,通知订阅者更新(data的订阅者实际上有两类:computed-watcherrender-watcher)
  11. data通知computed-watcher更新,执行watcher.evaluatewatcher.dirty置为true标识依赖变更需要重新计算
  12. data通知render-watcher更新,引用computed,检查watcher.dirty为true需要重新计算,执行watcher.evaluate进行重新计算,计算完毕后将watcher.dirty置为false

3.0版本

经过2.x版本的实现,思路已经比较清晰了,直接尝试上代码:

class ComputedRefImpl {
    constructor(getter) {
        this.dirty = true
        this.effect = createReactiveEffect(getter)
    }
    get value() {
        this._value = this.effect()
        this.dirty = false
        return this._value
    }
    set value() {
        console.warn('computed value is readonly')
    }
}
function computed(getter) {
    return new ComputedRefImpl(getter)
}

回想vue3.0创建computed值得方法:const double = computed(() => count * 2);此时得流程:

  1. render-effect创建并立即执行renderactiveEffectrender-effect
  2. rendercomputed.value发生引用触发其get value
  3. computed-effect执行并将activeEffect置为自己
  4. 执行computed回调函数进行计算,在计算中发生对data的引用触发其getter 进行track依赖收集建立datacomputed之间依赖关系
  5. computeddirty置为false并返回计算结果 问题:
  6. data发生变更通知computed-effect,导致其立即更新不符合懒惰性
  7. data发生变更并不会触发render-effect更新
    针对问题1需要对变更通知做文章,问题2有两种思路一是借鉴2.x的机制在computed被引用的时候额外去建立所依赖datarender-effect之间的依赖关系;还可以将computed作为依赖和订阅者render-effect直接建立依赖关系,当computeddirty的时候通知render-effect进行更新,vue3.0的选择的是第二种方案
    基于以上思路进行改进:
class ComputedRefImpl {
    constructor(getter) {
        this.dirty = true
        this.effect = createReactiveEffect(getter, {
            scheduler: () => {
                if (!this.dirty) {
                    this.dirty = true
                    trigger(this, 'set', 'value') // computed变更,通知所有订阅者
                }
            }
        })
    }
    get value() {
        if (this.dirty) {
            this._value = this.effect()
            this.dirty = false
        }
        track(this, 'get', 'value') // 对引用到computed的effect建立依赖关系
        return this._value
    }
    set value() {
        console.warn('computed value is readonly')
    }
}
function computed(getter) {
    return new ComputedRefImpl(getter)
}
function trigger(target, type, key, newValue) {
    const effects = new Set()
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    function add(effectsToAdd) {
        effectsToAdd.forEach(effect => {
            if (effect !== activeEffect) {
                effects.add(effect)
            }
        })
    }
    if (!depsMap.get(key)) return
    add(depsMap.get(key))
    function run(effect) {
        if (effect.options.scheduler) { // 改进在这里,如果effect本身包含scheduler函数,则调用effect自身的scheduler进行调度
            effect.options.scheduler()
        } else {
            scheduler(effect) // 如果自身不包含调度器,则调用统一调度函数,该函数的定义在上一篇笔记此处省略
        }
    }
    effects.forEach(run)
}

这样当data发生变更后:

  1. 通知computed-effect调用自身调度器computed-effect.options.shceduler
  2. 调度器函数中将dirty置为true,同时通知computed订阅者变更
  3. render-effect接收到变更通知进行更新,调用render
  4. render调用引用computed.value触发get value
  5. 检查computed.dirty如果为false直接返回缓存的value否则执行computed-effect
  6. 执行computed-effect将引用data触发依赖更新

watch

2.x版本

考虑watch的用法:

{
    data() {
        return {
            count: 1
        }
    },
    watch: {
        count(newVal, oldVal) {
            console.log(newVal, oldVal)
        }
    }
}

毫无疑问watch是一个订阅者,应该是一个Watcher实例,尝试实现初始化watch的方法

function initWatch(key/*要watch的key*/, handler/*回调函数*/, vm) {
    const getter = createGetter(key)
    return new Watcher(getter, handler, vm)
}
// 基于key创建getter,访问vm.key,从而触发data的getter
function createGetterByPath(key) {
    // 如果key形如'a.b.c'将被转为['a', 'b', 'c']
    const segments = path.split('.')
    return function(obj) {
        // 如果key形如:'a.b.c'将访问obj.a.b.c
        for (let i = 0; i < segments.length; i++) {
            if (!obj) return
                obj = obj[segments[i]]
            }
        }
        return obj
    }
}
// Watcher要添加一个参数cb,表示getter执行完毕后要执行一个回调,更新机制也要发生变更
class Watcher {
    constructor(getter, cb, vm, options) {
        this.vm = vm
        this.getter = getter
        this.cb = cb
        this.lazy = options.lazy
        this.dirty = this.lazy
        this.value = this.lazy ? undefined : this.get()
    }
    get() { ... }
    run() {
        const oldValue = this.value
        this.value = this.get()
        if (this.cb) {
            this.cb.call(this.vm, this.value, oldValue)
        }
    }
}

watch初始化过程:

  1. 基于要监听的data属性key创建getter函数,该函数将访问vm.key,其作用是能引用到监听的data属性触发其getter
  2. 基于上个步骤创建的getter创建watch-watcher,该watcher的getter为上步创建,回调watch-watcher.cb为用户自定义的watch-handler
  3. 实例化watch-watcher中执行其get方法,将引用data.key触发依赖收集,使得watch-watcher添加到data.key的subs订阅者列表 watch监听过程:
  4. data.key发生变更,通知watch-watcher
  5. watch-watcher.update执行
  6. 获取oldValue,执行watch-watcher.get获取新值newValue,并进行依赖更新
  7. 将newValue, oldValue作为参数执行watch-watcher.cb

3.0版本

考虑3.0中watch的使用:

const count = ref(1)
watch(() => count.value, (val, oldVal) => {
    console.log(val, oldVal)
})

尝试实现watch方法

function watch(
    keyOrFn/*监听对象,可以是key或者函数,为了简化这里只考虑函数型*/, 
    cb/*用户自定义回调*/, 
    instance/*vue对象,从中可以访问data*/
) {
    let oldValue
    const getter = () => keyOrFn.call(instance)
    const runner = createReactiveEffect(getter, {scheduler})
    const job = () => {
        const newValue = runner()
        cb.call(instance, newValue, oldValue
    }
    const scheduler = () => {
        // 调度器其实就是对job的调度执行,的具体实现参看前面日记
        job()
    }
    oldValue = runner()
}

watch初始化过程:

  1. 基于传入getter,将其上下文绑定到instance使其能够访问到data;创建getter
  2. 基于构建的getter创建runner即为watch-effect,主要用于收集依赖
  3. 创建job,该job将在监听的data发生变更后执行
  4. 创建调度器scheduler,用于异步调度job
  5. 初始化oldValue,runnerwatch-effect第一次执行,收集依赖
    watch更新过程:
  6. data更新通知watch-effect,调用watch-effect.shceduler
  7. 执行job,获取新值newValue,引用data更新依赖
  8. 将oldValue、newValue作为参数调用cb即用户自定义的回调

computed和watch有哪些差异和共性

经过前面的分析可以总结出:

  1. 存在目的或者说应用场景不同,computed的唯一作用是用于页面渲染,而且它的方法应该是一个纯函数;watch的作用是监听某些状态变更然后进行某些操作,通常和页面渲染无直接关系,倾向于产生副作用
  2. 在vue内部的角色有差异,computed可以说既是订阅者又是依赖(2.x中并非直接依赖而是作为中间者的身份),watch是订阅者
  3. 更新时机不同,computed只有在render函数调用过程中才会重新计算,watch在data更新后即被添加到更新队列等待执行
  4. computed在一轮依赖变更中只重新计算一次后续被引用将直接返回缓存

思考

  1. computed还能依赖computed吗
  2. watch能监听computed吗