watch的实现原理

本篇为阅读《Vue.js设计与实现》第4章过程总结笔记

  • 所谓watch,其本质就是观测一个响应式数据,当响应式数据发生变化时通知并执行相应的回调函数

例如:

js 复制代码
watch(obj, () => {
    console.log('响应式数据变化了');
})
obj.foo++  // 改变响应式数据的值,会导致回调函数的执行
  • 上一节我们实现的响应式数据与副作用函数之间联系的各个函数
js 复制代码
const data = { foo: 1, bar: 2 };   // 只是一个测试使用的数据

let activeEffect;

let effectStack = [];

const bucket = new WeakMap()
function cleanup(effectFn) {
    for (let i = 0; i < effectFn.deps.length; i++) {
        const deps = effectFn.deps[i]
        deps.delete(effectFn)
    }
    effectFn.deps.length = 0
}

function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        effectStack.push(effectFn)
        // fn()
        const res = fn()  // 将fn的执行结果存储到res中
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
        return res   // 将res作为effectFn的返回值
    }
    effectFn.options = options
    effectFn.deps = []
    if (!options.lazy) {
        effectFn()
    }
    return effectFn
}

function track(target, key) {
    // console.log(target, key);
    if (!activeEffect) return
    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) {
    // console.log(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()
        }
    })
}
const obj = new Proxy(data, {   // 代理对象
    get(target, key) {
        track(target, key)
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        trigger(target, key)
    }
})

const jobQueue = new Set()
const p = Promise.resolve()
let isFlushing = false
function flushJob() {
    if (isFlushing) return
    isFlushing = true
    p.then(() => {
        jobQueue.forEach(job => job())
    }).finally(() => {
        isFlushing = false
    })
} 

实现原理

  1. watch本质上是利用了effect以及option.scheduler选项

    js 复制代码
        effect(() => {
            console.log(obj.foo);  
        },{
            scheduler() {}
        })
    1. 在一个副作用函数effect中访问响应式数据obj.foo,通过前几节知道,这会在副作用函数与响应式数据之间建立联系
    2. 当响应式数据发生变化时,如果副作用函数存在scheduler选项,会触发scheduler调度函数执行,没有该选项则直接触发副作用函数执行
    3. 从该角度看,其实scheduler调度函数相当于一个回调函数,watch就是利用该特点
  2. 实现一个最简单的watch函数

    js 复制代码
        function watch(source, cb) {
            effect(
                () => obj.foo,   // 触发读取操作,从而建立联系
                {
                    scheduler() {
                        cb()     // 当数据发生变化时,执行调度函数
                    }
                }
            )
        }
        watch(obj.foo, () => console.log('foo发生了变化'))    // 控制台打印'foo发生了变化'
        obj.foo++
    1. watch接收两个 参数 source是响应式数据 cb是回调函数
    2. 但是我们实现的该watch函数硬编码了对source.foo的读取操作,所以只能建观测foo的变化
    3. 为了让watch函数具有通用性,封装一个通用的读取操作
  3. 封装读取操作

    js 复制代码
        function traverse(value, seen = new Set()) {
            if (typeof value !== 'object' || value === null || seen.has(value)) return value  // 如果读取的数据是原始值 或已经读取过 则什么都不做
            seen.add(value)   // 将数据添加到seen中 代表遍历地读取过了 避免循环引用导致的死循环
            for (let k in value) {  // 暂时不考虑数组等其他结构  假设value是一个对象 使用 for...in 读取对象中的每一个值,并递归调用traverse进行处理
                traverse(value[k], seen)
            }
            return value
        }
        function watch(source, cb) {
            effect(
                () => traverse(source),   // 调用traverse递归读取
                {
                    scheduler() {
                        cb()
                    }
                }
            )
        }
        watch(obj, () => console.log('obj里面有属性值发生了变化'))  // 控制台打印了2次
        obj.foo++
        obj.bar++
    1. 在watch内部的effect中调用traverse函数进行递归****的读取操作,这样子就能读取一个对象上的任意属性,从而当任意属性变化时都能够触发回调函数
    2. 注意这里watch函数第一个参数应传入obj,传入obj.foo会发生重载错误,要传入obj.foo可以通过以下的getter函数
  4. watch函数除了可以观测响应式数据,还可以接收一个getter函数作为第一个参数

    在getter函数内部,用户可以指定该watch****依赖哪些响应式数据,只有当这些数据发生变化时,才会触发回调函数执行

js 复制代码
function watch(source, cb) {
    let getter   // 定义getter
    if (typeof source === 'function') {   // 如果source是函数 说明用户传入的是getter 直接将source赋值给getter
        getter = source
    } else {
        getter = () => traverse(source)   // 否则按照原来的实现调用 traverse 递归读取
    }
    effect(
        () => getter(),
        {
            scheduler() {
                cb()
            }
        }
    )
}
watch(
    () => obj.foo,
    () => console.log('obj.foo值发生了变化')   // 控制台打印 obj.foo值发生了变化
)
obj.foo++
  • 这样子就实现了自定义的getter功能,同时使得watch函数更加强大
  • 但是发现新的问题,我们的函数还缺少一个非常重要的功能,通常我们使用在Vue.js中的watch函数时,能够在回调函数中拿到变化前后的值
  1. 如何获取到新值和旧值,这需要充分利用effect函数的lazy选项

    js 复制代码
        function watch(source, cb) {
            let getter   // 定义getter
            if (typeof source === 'function') {
                getter = source
            } else {
                getter = () => traverse(source)
            }
            let oldValue, newValue    // 定义旧值与新值
            // 使用effect注册副作用函数时 开启lazy选项 并把返回值存储到effectFn中以便后续手动调用
            const effectFn = effect(
                () => getter(),
                {
                    lazy: true,
                    scheduler() {
                        newValue = effectFn()    // 在scheduler中重新执行副作用函数 得到的值为新值
                        cb(newValue, oldValue)   // 将旧值和新值作为回调函数的参数 执行
                        oldValue = newValue      // !! 更新旧值 不然下一次会得到错误的旧值
                    }
                }
            )
            oldValue = effectFn()  // 手动调用副作用函数 拿到的值为旧值
        }
    1. 最核心的改动是使用lazy选项创建了一个懒执行的effect
    2. 注意代码最下面的部分,我们手动调用effectFn函数得到的返回值就是旧值,即第一次执行得到的值
    3. 当变化发生并触发scheduler调度函数执行时,会重新调用effectFn函数并得到新值
    4. 这样子我们就拿到了旧值和新值,并将它们作为参数传递给回调函数cb就可以
    5. !!!最后,不要忘记使用新值更新旧值oldValue = newValue,否则在下一次变更发生时会得到错误的旧值 oldValue = effectFn()只在最开始调用时执行一次

立即执行的watch与回调执行时机

上一节我们完成了watch的基本实现,本质是对effect的二次封装

默认情况下,一个watch的回调只会在响应式数据发生变化时才执行,但是在Vue.js中可以通过选项参数 immediate 来指定回调是否需要立即执行

javascript 复制代码
watch(obj, () => {
    console.log('变化了');
}, {
    immediate: true   // 回调函数会在watch创建时立即执行一次
})
  • 当 immediate 选项存在并且为true时,回调函数会在该watch创建时立刻执行一次。立即执行与后续执行本质上没有任何区别
  1. 可以把scheduler调度函数封装为一个通用函数,分别在初始化和变更时执行它

    js 复制代码
        function watch(source, cb, options = {}) {
            let getter
            if (typeof source === 'function') {
                getter = source
            } else {
                getter = () => traverse(source)
            }
            let oldValue, newValue
            const job = () => {         // 提取scheduler调度函数为一个独立的job函数
                newValue = effectFn()
                cb(newValue, oldValue)
                oldValue = newValue
            }
            const effectFn = effect(
                () => getter(),
                {
                    lazy: true,
                    scheduler: job     // 使用job函数作为调度器函数
                }
            )
            if (options.immediate) {   // 当 immediate 为 true 时 立即执行job 从而触发回调执行
                job()
            } else {
                oldValue = effectFn()
            }
        }
        watch(obj, (newVal, oldVal) => {
            console.log('watch创建了')
            console.log(newVal, oldVal);    // Proxy(Object){foo: 1,bar: 2} undefined 
        }, {
            immediate: true
        })
    1. 这样子就实现了回调函数的立即执行功能,由于是立即执行的,所以第一次回调执行时没有所谓的旧值,因此 oldValue 为undefined也是符合预期的
    2. 除了指定回调函数为立即执行之外,还可以通过其他选项参数来指定回调函数的执行时机
  2. Vue.js3使用 flush 选项来指定

    js 复制代码
        watch(obj, (oldVal, newVal) => {
          console.log(oldVal, newVal)
        }, {
           // 回调函数会在watch创建时执行一次
          flush: 'pre'   // 还可以指定为 'post'|'sync'
        })
    1. flush本质是在指定调度函数的执行时机,前一节讲解过如何在微任务队列中执行调度函数,这与flush功能相同
    2. 当flush值为post时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待DOM更新结束后再执行,修改scheduler来模拟该效果
    js 复制代码
        scheduler: () => {
            if (options.flush === 'post') {    // 如果flush为 'post' 将其放入微任务队列中执行
                const p = Promise.resolve()
                p.then(job)
            } else {
                job()
            }
        }
    1. 在调度器函数内检测potions.flush的值是否为post,是的话将job函数放到微任务队列中,从而实现异步延迟执行,否则直接执行job函数,这本质上相当于'asyn'的实现机制,即同步执行
    2. 对于'pre'的情况暂时无法模拟,因为设计组件的更新时机('pre'和'post'原本语义指的就是组件更新前和更新后)

过期的副作用函数

竞态问题

  1. 举一个竞态问题的例子

    js 复制代码
        let finalData
        watch(obj, async () => {
            const res = await fetch('/path/to/request')   // 发生并等待网络请求
            finalData = res   // 将请求结果赋值给finalData
        })
    1. 我们使用watch观测obj对象的变化,每次obj对象发生变化都会发送网络请求,例如请求接口数据,等待数据请求成功之后,将结果赋值给 finalData变量
    2. 但是假如我们第一次修改obj对象的某个字段,导致回调函数执行,同时发送了第一次请求A,在A返回结果之前,又修改了obj对象的某个字段,导致发送第二次请求B
    3. 此时A和B同时进行中,如果B先于A返回结果,那么会导致最终finalData中存储的是A请求的结果
    4. 但是由于B是后发送的,所以A应该被视为过期的,finalData应存储的是B返回的结果
    5. 我们需要一个让副作用函数过期的手段
  2. 先看Vue.js中的watch函数复现以上场景,查看它是如何解决该问题的

    js 复制代码
        let finalData
        watch(obj, async (newVal, oldVal, onInvalidate) => {
          let expired = false  // 定义一个标志 代表当前副作用函数是否过期 默认为false 代表没有过期
          onInvalidate(() => {  // 调用onInvalidate函数注册一个过期回调
            expired = true  // 当过期是将expired设置为false
          })
          const res = await fetch('')
          if (!expired) {   // 只有当副作用函数的执行没有过期时 才会执行后续操作
            finalData = res
          }
        })
    1. 那么 onInvalidate 的实现原理是怎样的呢
    2. 其实是在watch内部每次检测到变更后,在副作用函数重新执行前,会先调用我们通过onInvalidate函数注册的过期回调
  3. 在watch函数中添加 onInvalidate 过期回调

    js 复制代码
        function watch(source, cb, options = {}) {
            let getter
            if (typeof source === 'function') {
                getter = source
            } else {
                getter = () => traverse(source)
            }
            let oldValue, newValue
            let cleanup   // cleanup用来存储用户注册的过期回调
            function onInvalidate(fn) {
                cleanup = fn   // 将过期回调存储到 cleanup中
            }
            const job = () => {         
                newValue = effectFn()
                if (cleanup) {     
                    cleanup()
                }
                cb(newValue, oldValue, onInvalidate)    // 将onInvalidate作为回调函数的第三个参数 以便用户使用
                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()
            }
        }
        // 还是以上面的例子
        let finalData
        watch(obj, async (newVal, oldVal, onInvalidate) => {
          let expired = false 
          onInvalidate(() => { 
            expired = true 
          })
          const res = await fetch('')
          if (!expired) {   
            finalData = res
          }
        })
        obj.foo++
        setTimeout(() => {
            obj,foo++   // 200ms后做第二次修改
        },200)
    1. 第一次修改是立即执行的,watch的回调函数执行,由于我们在回调函数中调用了 onInvalidate,所以会注册一个过期回调,发送请求A,假设A需要1000ms后才能返回结果
    2. 我们在200ms时又修改了foo的值,又会导致watch的回调函数执行,但是在我们代码实现中,每次执行回调函数前要先检查过期回调是否存在,若存在,优先执行过期回调
    3. 由于watch的函数回调第一次执行的时候,我们已经注册了一个过期回调,在watch的回调函数第二次执行之前,会优先执行之前注册的过期回调,这使得第一次执行的副作用函数内闭包的变量 expired 的值变为 true,即第一次的副作用函数过期了,于是A的结果返回时,其结果会被抛弃

到这里本章节-响应系统 就告一段落~

相关推荐
GIS程序媛—椰子13 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_00119 分钟前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端22 分钟前
Content Security Policy (CSP)
前端·javascript·面试
木舟100926 分钟前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤439136 分钟前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢1 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安1 小时前
前端第二次作业
前端·css·css3
啦啦右一1 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习
半开半落1 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt