您现在的位置是:首页 >技术教程 >4.1 Vue中watch函数的实现原理网站首页技术教程

4.1 Vue中watch函数的实现原理

GoldenFingers 2024-09-12 12:01:04
简介4.1 Vue中watch函数的实现原理

watch函数的基本使用

Vue中的watch作为侦听属性,他的作用为侦听一个数据的变化,若数据变化则触发相应的回调函数。

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

如上述代码所示,如果obj发生变化,则触发回调。当然watch函数并不仅仅是这些功能,这些功能其实副作用函数也能实现,watch函数可以获取变化前后的值,接收getter函数,获取变化前后的值,通过调度器调度回调函数的执行时机等。这些功能都会在下文进行详细的叙述。

watch的基本实现

watch函数的实现实质上就是使用effectscheduler实现的,只是对effect函数的封装。当触发副作用函数时如果scheduler存在就会直接执行scheduler,而我们将回调函数放入scheduler中,执行时就会将scheduler中的回调函数执行,这其实就是watch函数的执行流程,最简单的代码实现如下:

// 接收两个参数,source是响应式数据,cb是回调函数
function watch(source, cb) {

    effect(
        // 触发读取操作,从而建立联系
        () => source.foo,
        {
            scheduler: () => {
                // 当数据变化时,调用cb函数
                cb()
            }
        }
    )
}

可以这样使用

const data = {foo: 1}
const obj = new Proxy(data, {
    get: (target, key, receiver) => {
        track(target, key, receiver)
        return Reflect.get(target, key, receiver)
    },
    set: (target, key, value, receiver) => {
        Reflect.set(target, key, value, receiver)
        trigger(target, key)
        return true
    }
});

watch(obj, () => {console.log('change')})
obj.foo++

这样一个最简单的watch函数就实现了,同时也可以运行结果,但是实际上我们上面代码的watch函数只是针对了obj.foo做侦听,实际上我们可能对整个对象做侦听,所以在访问对象元素做关联的部分需要完善

// 接收两个参数,source是响应式数据,cb是回调函数
function watch(source, cb) {
    effect(
        // 触发读取操作,从而建立联系
        () => traverse(source),
        {
            scheduler: () => {
                // 当数据变化时,调用cb函数
                cb()
            }
        }
    )

    // seen防止交叉引用形成死循环
    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
    }
}

这样就可以对整个响应式对象的所有元素都关联起来,只要发生改动都会触发回调函数。

其实我们在使用的过程中,watch的第一个元素,即侦听的元素除了使用对象的方式传入还可以使用``getter`函数传入,如:

watch(() => obj.foo, () => {console.log('change')})

所以我们在处理的时候需要将传入的两种情况兼容一下,其实也很简单,我们可以统一使用getter的方式来进行处理。

// 接收两个参数,source是响应式数据,cb是回调函数
function watch(source, cb) {
    let getter
    if(typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }

    effect(
        // 触发读取操作,从而建立联系
        () => getter(),
        {
            scheduler: () => {
                // 当数据变化时,调用cb函数
                cb()
            }
        }
    )

    // 省略代码 ......
}

获取watch函数的新值和旧值

获取watch函数的新值和旧值主要是依靠的lazy机制,在effect当中副作用函数传入的是getter函数,所以只要我们每次执行就可以获取到值,使用了lazy标记之后,每次会返回一个待执行的getter,这样在获取新值时就可以直接执行getter函数获取值。

const effectStack = []
function effect(fn, options = {}) {
    const effectFn = () => {
      activeEffect = effectFn
      //先清除再执行,自然就形成了分支切换
      cleanup(effectFn)

      // 用栈记录下当前的辅作用函数  
      effectStack.push(effectFn)

      const res = fn()
      effectStack.pop()
      // 递归出来时改变activeEffect指向
      activeEffect = effectStack[effectStack.length-1]

      return res
    }

    effectFn.options = options
    effectFn.deps = []

    if(options.lazy)
        return effectFn
    effectFn()
}

// 接收两个参数,source是响应式数据,cb是回调函数
function watch(source, cb) {
    let getter
    if(typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }

    let newValue, oldValue
    const effectFn = effect(
        // 触发读取操作,从而建立联系
        () => getter(),
        {
            // 打上lazy标记,在需要获取的地方再执行
            lazy: true,
            scheduler: () => {
                // 调用effectFn获取到改变后的值
                newValue = effectFn()
                // 当数据变化时,调用cb函数
                cb(oldValue, newValue)
                // 回调结束后更新旧值
                oldValue = newValue
            }
        }
    )
    // 第一次手动调用effectFn,相当于直接执行getter,获取到初始值
    oldValue = effectFn()

	// 省略代码 ......
}

watch(() => obj.foo, (oldValue, newValue) => {console.log(oldValue, newValue)})
obj.foo++

watch函数的执行时机

watch函数实质上是对effect函数的封装,上面章节中对effect函数做了调度,但是对于watch函数还没做调度,一般来说watch函数的调度有两种

  1. 立即执行的调度,在创建时就执行(不需要第一次发生修改)。
  2. 设置watch函数的执行时机。

首先要实现的是创建时就立即执行的标记,还是正常使用前面章节中提到的调度器,加入一个immediate字段作为标记,代码如下:

// 接收两个参数,source是响应式数据,cb是回调函数
function watch(source, cb, options = {}) {
    let getter
    if(typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }

    let newValue, oldValue

    // 将调度函数封装成一个独立的job函数
    const job = () => {
        // 调用effectFn获取到改变后的值
        newValue = effectFn()
        // 当数据变化时,调用cb函数
        cb(oldValue, newValue)
        // 回调结束后更新旧值
        oldValue = newValue
    }
    const effectFn = effect(
        // 触发读取操作,从而建立联系
        () => getter(),
        {
            // 打上lazy标记,在需要获取的地方再执行
            lazy: true,
            scheduler: job
        }
    )
    // 当immediate为true的时候立即执行job,从而触发回调
    if(options.immediate) {
        job()
    } else {
        oldValue = effectFn()
    }
    // 第一次手动调用effectFn,相当于直接执行getter,获取到初始值
    oldValue = effectFn()
}

第二种调度是在Vue3中新增的,设置回调函数的执行时机,一般通过flush字段控制,可选择'post'/'pre'/'sync'

例如对post的实现,这表示回调函数会在dom更新之后执行,实现方法是将回调函数放入微任务队列中,这里使用的是promise来实现的

// 接收两个参数,source是响应式数据,cb是回调函数
function watch(source, cb, options = {}) {
    // 省略代码 ......
    
    const effectFn = effect(
        // 触发读取操作,从而建立联系
        () => getter(),
        {
            // 打上lazy标记,在需要获取的地方再执行
            lazy: true,
            scheduler: () => {
                if(options.flush === 'post') {
                    const p = Promise.resolve()
                    p.then(job)
                } else {
                    job()
                }
            }
        }
    )
    // 当immediate为true的时候立即执行job,从而触发回调
    if(options.immediate) {
        job()
    } else {
        oldValue = effectFn()
    }
    
// 省略代码.....
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。