Vue3 源码解析(一):响应式数据和副作用函数、计算属性原理、侦听器原理

212 阅读29分钟

1 响应式数据和副作用函数

副作用函数

  • 传统副作用函数定义:某个函数执行,会对其他函数或者变量产生影响,这就是副作用函数。例如你在这个函数里面修改了全局变量,会对其他使用这个变量的函数产生影响
  • Vue3中的副作用函数定义:
    • 首先是该函数访问响应式数据
    • 其次是被响应式API如effect computed watch中的一个包装,他就是副作用函数,也是依赖。响应式数据变化,关联的副作用函数会重新执行。
    • Vue3中扩展了副作用函数的概念,这个函数即便仅仅是访问响应式数据,没有执行修改的操作,但被包装注册后也成了副作用函数。

例子:

如下effect函数执行会读取document.body.innerText并赋值为data.text变量的值,会对other函数的执行产生影响,effect函数就是传统意义上的副作用函数,other不算作副作用函数

let data = {
    text: '我是text文本'
}
function effect () {
    document.body.innerText = data.text
}
function other () {
    console.log(document.body.innerText, 'document.body.innerText');
}
effect()
other()

响应式数据:数据变化,视图也要发生改变。在Vue3中,数据变化,会把所有与之关联的副作用函数拿出来重新执行一遍

例子:

如下代码,当修改data.text的值后,如果effect函数能够重新执行,我们把effect函数的执行类比于DOM的更新,那么data就是响应式数据

let data = {
    text: '我是text文本'
}
function effect () {
    document.body.innerText = data.text
}
function other () {
    console.log(document.body.innerText, 'document.body.innerText');
}
effect()
other()
+data.text = '我是修改后的text文本'

2. 响应式系统的基本实现

实现最基础的响应式系统:

  • 在effect函数执行时,会触发obj.text字段的读取操作
  • 在obj.text值修改时,会触发obj.text字段的设置操作

借助代理对象Proxy执行拦截该对象的访问和设置行为

// 拦截的数据
let data = {
    text: '我是text文本'
}
// 副作用函数
function effect () {
    document.body.innerText = obj.text
}

// 利用Set桶,存储副作用函数
const bucket = new Set()
const obj = new Proxy(data, {
    get(target, key) {
        // 访问数据时,把副作用函数存储到桶里面
        bucket.add(effect)
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        // 修改数据时,把副作用函数从桶里面拿出来执行
        bucket.forEach(fn => fn())
        // 返回true,表示设置操作成功
        return true
    }
})
effect()
// 控制台进行如下设置后,页面的数据修改了,表示成功
obj.text = '我是修改后的text文本'

此时如果在控制台修改obj.text的值,会触发副作用函数更新DOM的值

image.png

3 设计一个完善的响应式系统

目标:我们希望副作用函数哪怕是一个匿名函数,也能够被正确的收集,本节将实现一个注册副作用函数的机制。

let data = {
    text: '我是text文本'
}
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect函数改造为包装函数
function effect (fn) {
    // 存储fn给activeEffect
    activeEffect = fn
    // 执行副作用函数
    fn()
}
const bucket = new Set()
const obj = new Proxy(data, {
    get(target, key) {
        // 副作用函数加到桶里面去 => 不依赖具体的函数名 
        if (activeEffect) {
            bucket.add(activeEffect)
        }
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        // 但无论obj的哪个属性触发,都会导致这里的副作用函数执行
        bucket.forEach(fn => fn())
        return true
    }
})
effect(() => {
    console.log('effect runs');
    document.body.innerText = obj.text
})

如上代码,重新定义effect函数,他变成一个包装函数。在effect函数里,我们创建了一个activeEffect变量存储被注册的副作用函数。

调用effect函数,会把副作用函数存到activeEffect变量中,然后执行fn也就是这个副作用函数,将document.body.innerText值赋值为obj.text。

image.png

此时会触发obj.text的get函数,进而将副作用函数收集到桶里面去,此时activeEffect函数里面存的已经是副作用函数了,直接收集,如下图 image.png

但是存在问题:如果给一个新的不存在的属性进行赋值操作,副作用函数依然会执行。

原因是,设计的代码中,无论读取的是哪个属性,都会把副作用函数收集到桶里面,无论修改的是哪个值,都会把桶里面的副作用函数拿出来再执行一遍。

image.png

4. 完善响应式系统

使用WeakMap代替Set作为桶的数据结构,改造如下:

const bucket = new WeakMap()
const obj = new Proxy(data, {
    get(target, key) {
        // 没有副作用函数,直接return
        if (!activeEffect) {
            return target[key]
        }
        // 根据对象去拿到对应map
        let depsMap = bucket.get(target)
        // 没有对应desMap则初始化
        if (!depsMap) {
            bucket.set(target, (depsMap = new Map()))
        }
        // 根据key属性去拿到对应Set集合
        let deps = depsMap.get(key)
        // 没有对应set则初始化
        if (!deps) {
            depsMap.set(key, (deps = new Set()))
        }
        deps.add(activeEffect)
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        let depsMap = bucket.get(target)
        if (!depsMap) return 
        // 根据属性拿到副作用函数集合
        let effects = depsMap.get(key)
        // 执行副作用函数
        effects && effects.forEach(fn => fn())
    }
})

如上代码中,WeakMap的key来存储target原始数据对象值,WeakMap的值是Map数据类型,而Map的key是对象的key属性Map的value值是Set数据结构,是副作用函数集合,也就是这个key对应的所有副作用函数。

为什么用WeakMap作为桶的数据结构呢?首先,WeakMap的一大特点是其键是弱引用,一旦别的地方没有引用这个key,则会被垃圾回收机制回收。这个Key是target原始对象,也是响应式数据,一旦没有别的地方使用这个响应式数据,垃圾回收机制就会回收他,能够防止内存溢出

如下,我们将get和set函数里面的逻辑进一步封装到track和trigger函数中,代码如下:

const obj = new Proxy(data, {
    get(target, key) {
        // 将副作用函数activeEffect添加到存储桶里面
+        track(target, key)
        return target[key]
    },
    set(target, key, newVal) {
        // 修改属性值执行对应的副作用函数
+        target[key] = newVal
        trigger(target, key)
    }
})
let btn1 = document.getElementById('btn1')
let btn2 = document.getElementById('btn2')
effect(() => {
    console.log('effect runs');
    // 按钮btn1读取obj.a的值,只有当修改obj.a的值时,effect函数才会再次执行
    btn1.innerText = obj.a
})
// 在get拦截函数内调用track函数追踪变化
+function track (target, key) {
    if (!activeEffect) {
        return target[key]
    }
    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)
}
// 在set拦截函数内调用trigger函数触发变化
+function trigger (target, key) {
    let depsMap = bucket.get(target)
    if (!depsMap) return 
    let effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
}

这样设计的原因是为了对象的每个key都有自己的副作用函数集合,见下面的示例:

如下代码中有三个成员。obj数据对象,text属性,副作用函数effectFn:

effect(
    function effectFn() {
        console.log('effect runs');
        document.body.innerText = obj.text
    }
)

对应关系

obj
  -text
      -effectFn

如果是一个对象的某个属性,对应2个副作用函数

effect(
    function effectFn1() {
        obj.text
    }
)
effect(
    function effectFn2() {
        obj.text
    }
)

对应关系

obj
  -text
      -effectFn1
      -effectFn2

如果是一个副作用函数读取了同一个对象的两个不同的属性

effect(
    function effectFn1() {
        obj.text1
        obj.text2
    }
)

对应关系

obj
  -text1
      -effectFn1
  -text2
      -effectFn1

5. 分支切换

分支切换的定义:分支切换可以理解为条件判断。当某个副作用函数访问了响应式数据A,但是因为存在条件判断后可能就不访问A,此时无论A数据如何变化,都不应该触发这个依赖/副作用函数

例子:

假设副作用函数如下,当obj.ok为true时,body的innerText值设置为obj.text,否则设置为'no'。这个就是分支切换的表现。当obj.ok为false时,无论obj.text如何变化,都不能执行下面这个副作用函数。但是目前还是会执行,原因就是目前代码中,副作用函数一旦收集进去了,就没删掉

let data = {
+    ok: true,
    text: 'hello world'
}
effect(() => {
    console.log('effect runs');
    // 分支切换:obj.ok值为true,执行obj.text;否则都显示no,这个叫分支切换
    // 正常来看,如果obj.ok改为false,那么之后,无论obj.text的值无论如何变,都不应该触发effect副作用函数,目前是会触发,是一种冗余的副作用函数
+    document.body.innerText = obj.ok ? obj.text : 'no'
})
obj
    text
        effectFn ==>这里本来应该要删掉的
    ok
        effectFn

需要改造effect函数,里面增加effectFn函数,并给这个函数增加了deps属性存储依赖集合列表。下面的fn才是真正的副作用函数,effectFn是我们进行包装的副作用函数

function effect (fn) {
    // effectFn执行时,将其设置为当前激活的副作用函数
+    const effectFn = () => {
+        // 调用 cleanup 函数完成清除工作
+        cleanup(effectFn)
        activeEffect = effectFn
+        fn()
    }
    // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
+    effectFn.deps = []
+    effectFn()
} 

track依赖函数中,把依赖集合push到activeEffect.deps中

function track (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中
    deps.add(activeEffect)
+    activeEffect.deps.push(deps)
}

注意,cleanUp函数首先,遍历effectFn.deps里面的所有依赖集合,把集合里面的对应的effectFn给删掉,再把effectFn.deps数组给置空。

function cleanup (effectFn) {
    for (let i = 0; i < effectFn.deps.length; i++) {
        const deps = effectFn.deps[i]
        deps.delete(effectFn)
    }
    // 最后需要重置effectFn.deps的数组
    effectFn.deps.length = 0
}

这里我产生了一些疑问:为什么不直接effectFn.deps.length = 0就可以了,还要进行遍历呢?因为deps.delete(effectFn)这里的deps的数据来自effectFn.deps[i],而这个effect.deps数据是在track里面从桶里面拿出来的,所以这一步是在清空桶里面的数据。

如下图: image.png

此时运行代码,能够避免副作用函数遗留的问题。

6. 避免Set无限递归

目前会存在无限循环执行的问题,原因是先执行cleanup函数清空了桶里面的副作用函数,把set里面的清空后,又执行副作用函数,又给桶里面的set数据加回去了,此时forEach遍历Set还没执行完又有新的数据,立马又执行了一遍:

image.png

示例: 这样的也会造成无限递归

let set = new Set()
set.add(1)
set.forEach(item => {
    set.delete(1)
    set.add(1)
    console.log('遍历中');
    debugger
})

解决方案:以旧Set为数据源,创建新的newSet数据结构,遍历这个newSet

let set = new Set()
set.add(1)
const newSet = new Set([...set])
newSet.forEach(item => {
    set.delete(1)
    set.add(1)
    console.log('遍历中set');
})

如下:

function trigger (target, key) {
    let depsMap = bucket.get(target)
    if (!depsMap) return 
    let effects = depsMap.get(key)
+    const effectsToRun = new Set(effects)
    // 防止无限递归;fn执行时会清空依赖,再把依赖加进去,这样就只会遍历effectsToRun的依赖,而effects的就删掉了
    // effectsToRun && effectsToRun.forEach(fn => fn())
+    effectsToRun && effectsToRun.forEach(fn => {
        console.log(effects, 'effects');
        fn()
    })
}

7. 嵌套的effect和effect栈

实际开发中存在许多嵌套的场景,比如组件如下:

// Bar组件
const Bar = {
    render () {}
}
// Foo组件里面嵌套Bar
const Foo = {
    render () {
        return Bar
    }
}

我们之前的设计无法支持effect嵌套:

let temp1, temp2
effect(
    function effectFn1 () {
        console.log('effectFn1执行了');
        effect(
            function effectFn2 () {
                console.log('effectFn2执行了');
                temp2 = obj.bar
            }
        )
        temp1 = obj.foo
    }
)

如上代码,在effectFn1函数里嵌套了effectFn2函数,effectFn2函数里面读取了obj.bar的值, effectFn2执行后紧接着,effectFn1会去读取obj.foo值,我们希望当修改obj.foo值时,触发effectFn1函数并间接触发effectFn2;而当修改obj.bar值时,只触发effectFn2函数

当我们修改obj.foo值时:

obj.foo = 'change'

打印如下,红色方框的地方只打印了effectFn2,按理来说应该先打印effectFn1,然后再打印effectFn2。 image.png

原因如下:

function effect (fn) {
    // effectFn执行时,将其设置为当前激活的副作用函数
    const effectFn = () => {
        // 调用 cleanup 函数完成清除工作
        cleanup(effectFn)
        activeEffect = effectFn
        fn()
    }
    // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
    effectFn.deps = []
    effectFn()
} 

如上代码执行过程中:

  1. effect函数执行:传入effectFn1到effect函数
  2. effect函数:activeEffect = effectFn这步是把当前函数赋值给全局变量activeEffect,此时是effectFn1对应的函数
  3. fn()执行:因为effectFn2嵌套在effectFn1中,所以effectFn2传递到effect函数中去执行
  4. effect函数执行:此时就把effectFn2对应的effectFn赋值给activeEffect全局变量,执行fn,读取obj.bar值
  5. 回到effectFn1函数:此时去读取obj.foo值,在get里面收集依赖时,deps.add(activeEffect)这句代码把依赖存到桶里面去,activeEffect里面是effectFn2对应的副作用函数,所以访问obj.foo执行的是effectFn2对应的副作用函数

修改方案如下:

let activeEffect;
// 增加`effectStack`数组
+let effectStack = []
function effect (fn) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        // 将当前effectFn函数放到effectStack栈中
+        effectStack.push(effectFn)
        fn()
        // 将当前副作用函数从栈中弹出
+        effectStack.pop()
        // 将activeEffect还原为之前的值
+        activeEffect = effectStack[effectStack.length - 1]
    }
    // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
    effectFn.deps = []
    effectFn()
} 

如上代码中:

  • 当effectFn1函数执行时,会把effectFn1对应的副作用函数压到栈顶
  • 紧接着effectFn2函数执行时,把effectFn2的副作用函数压到栈顶,读取bar,此时activeEffect就是effectFn2对应的副作用函数,存储到桶中。
  • 当effectFn2函数执行完毕,此时会把该函数从栈顶弹出,activeEffect重新赋值为effectFn1对应的副作用函数。
  • 然后去读取obj.foo,会把其对应的副作用函数存到桶里面去。这样就不会发生互相嵌套影响了

这个时候修改obj.foo打印的就是正确的:

image.png

代码执行图示如下:

image.png

image.png

image.png

image.png

image.png

8 避免栈溢出

如下执行,会导致栈溢出

effect(
    function effectFn1 () {
        // 这样执行会造成栈溢出
        obj.foo = obj.foo + 1
    }
)

原因是:该副作用函数执行,会执行track里面的读取操作,读取obj.foo的值,还会对obj.foo进行赋值,触发trigger,在trigger里面会把副作用函数拿出来再执行。但是当前副作用函数还没执行完毕呢,又执行一次副作用函数,就会造成无限递归

如下代码中,增加判断,如果要执行的副作用函数和activeEffect是一个函数,则不执行。

function trigger (target, key) {
    let depsMap = bucket.get(target)
    if (!depsMap) return 
    let effects = depsMap.get(key)
    const effectsToRun = new Set(effects)
    // 防止无限递归;fn执行时会清空依赖,再把依赖加进去,这样就只会遍历effectsToRun的依赖,而effects的就删掉了
    // effectsToRun && effectsToRun.forEach(fn => fn())
    effectsToRun && effectsToRun.forEach(fn => {
+        if (fn === activeEffect) {
            return
        }
        fn()
    })
}

9. 计算属性

9.1 计算属性简洁版

  1. 计算属性定义:计算属性的值依赖其他的响应式数据,当依赖的数据值更新时,计算属性会重新计算,计算后会自动进行缓存
  2. 计算属性的实现依赖响应式数据副作用函数 3.lazy:内部声明了一个getter访问器,值是value,当访问计算属性.value时才会执行副作用函数
  3. dirty:通过dirty变量控制缓存,只有为true时才能执行副作用函数重新计算,执行完毕后立刻改为false;当依赖的数据变化时会触发Proxy的setter拦截器,进行将dirty改为true,此时就会再次计算
function computed (getter) {
    // 用来缓存上一次计算的值
    let value;
    // dirty标志
    let dirty = true
    const effectFn = effect(getter, {
        lazy: true,
        scheduler () {
            dirty = true
            // 这里是个闭包,所以能够访问到computed函数作用域内所有变量
            trigger(obj, 'value')
        }
    })

    const obj = {
        get value () {
            // 只有dirty为脏时才计算值,并将得到的值缓存在value中
            if (dirty) {
                console.log('get执行了');
                value = effectFn()
                // 将dirty值设置为false,下一次访问只用缓存中的值
                dirty = false
            }
            console.log(obj, 'obj');
            track(obj, 'value')
            return value
        }
    }

    return obj
}

9.2 调度执行的时机

可调度性是响应式系统非常重要的特性。可调度性是指,trigger函数触发执行副作用函数时,我们有能力决定副作用函数执行的时机、次数以及方式

例子:

effect(() => {
    console.log(obj.foo, 'obj.foo');
})
obj.foo++
console.log('结束了');

打印结果是:

image.png

如果我们希望打印的结果是:

1
'结束了'
2

改成这样就能实现

effect(() => {
    console.log(obj.foo, 'obj.foo');
})
console.log('结束了');
obj.foo++

但是如果我们希望不改变代码呢,就需要让这个响应系统支持调度:

如下给effect函数传入一个调度器:

effect(
    () => {
        console.log(obj.foo, 'obj.foo');
    },
    {
        // scheduler调度器函数
+        scheduler (fn) {

        }
    }
)

将options挂载到对应的副作用函数上:

+function effect (fn, options) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        effectStack.push(effectFn)
        debugger
        fn()
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }
    debugger
    // 把options挂载到effectFn上
+    effectFn.options = options
    effectFn.deps = []
    effectFn()
}

在修改obj.foo++时,会触发trigger函数,在这里我们就要判断是否传递了调度器函数

function trigger (target, key) {
    let depsMap = bucket.get(target)
    if (!depsMap) return 
    let effects = depsMap.get(key)
    const effectsToRun = new Set(effects)
    debugger
    effectsToRun && effectsToRun.forEach(effectFn => {
        debugger
        // 如果有调度器配置则执行
+        if (effectFn.options.scheduler) {
+            effectFn.options.scheduler(effectFn)
        } else {
        // 若没有,则还是像原来一样直接执行副作用函数
            effectFn()
        }
    })
}

在调度器函数里面,我们把fn传递到setTimeout函数里面,开启宏任务执行,这样obj.foo执行的时机就会延迟

effect(
    () => {
        console.log(obj.foo, 'obj.foo');
    },
    {
        // scheduler调度器函数
        scheduler (fn) {
+            setTimeout(fn)
        }
    }
)

9.3 调度执行的次数

学会这节就能理解,为什么vue当中对响应式数据连续做多次的更新操作,但是最终只会触发一次更新。

如下代码中,对obj.foo执行两次自增操作,控制台会打印 1 2 3

effect(
    () => {
        console.log(obj.foo, 'obj.foo');
    },
)
obj.foo++
obj.foo++

image.png

我们希望自增2次,但是控制会只会打印1和3,中间不管执行了几次都不管

// 定义一个任务队列
const jobQuene = new Set()
// 使用Promise.resolve()创建一个promise实例,借助他,将一个任务添加到微任务队列
const p = Promise.resolve()

// 标志,是否正在刷新队列
let isFlushing = false
// 刷新函数
function flushJob () {
    // 如果队列正在刷新,则什么都不做
    if (isFlushing) return
    // 设置为true,代表正在刷新
    isFlushing = true
    // 在微任务队列中刷新jobQuene队列
    p.then(() => {
        jobQuene.forEach(job => {
            job()
        })
    }).finally(() => {
        isFlushing = false
    })
}

在副作用函数中,增加如下代码

effect(
    () => {
        console.log(obj.foo, 'obj.foo');
    },
    {
        scheduler (fn) {
            // 每次调度时,将副作用函数添加到队列中
            jobQuene.add(fn)
            // 调用flushJob刷新队列
            flushJob()
        }
    }
)

首先,在上面的调度器里面,我们定义了一个jobQuene任务队列,其数据结构是Set,因为Set是有自动去重能力的,如果添加了重复的副作用函数进去会无效。紧接着,调用了flushJob函数

来到flushJob函数中,只有当isFlushing是false才会执行,通过p.then(),在一个微任务队列里面去遍历执行jobQuene里面的副作用函数。

当第一次obj.foo第一次自增时,就会把他对应副作用函数添加到jobQuene里面去,并且开始执行flushJob函数,当第二次obj.foo自增时,由于此时jobQuene里面已经添加过一次set函数了,所以再添加即无效。而执行flushJob函数时,也会直接return。在所有同步代码执行完毕来到p.then里面的微任务,执行jobQuene里面的函数,执行完毕后来到finally里面,把isFlushing改为false

最终打印的效果如下:

image.png

9.4 计算属性具体实现

通过之前的知识点,我们能够实现Vue.js中非常重要且有特色的能力--计算属性。先介绍懒执行的effect,即lazy的effect。之前实现的effect函数会立即执行,如下:

effect(
    () => {
        console.log(obj.foo, 'obj.foo');
    }
)

但是某些场景,我们不希望他立即执行,希望在需要的时候才执行,例如计算属性。如下我们在options选项中,增加了lazy配置项为true:

effect(
    () => {
        console.log(obj.foo, 'obj.foo');
    },
    // options
    {
        lazy: true
    }
)

需要对effect函数增加代码,判断options选项中,lazy属性如果为false,才会立即执行effectFn函数。并且这里我们还增加了代码用来返回effectFn

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 = []
+    if (!options.lazy) {
+        effectFn()
+    }
+    return effectFn
} 

如果不立即执行,应该何时执行?effect函数返回了一个函数,我们能够拿到他,手动执行。

const effectFn = effect(
    () => {
        console.log(obj.foo, 'obj.foo');
    },
    // options
    {
        lazy: true
    }
)
// 手动执行这个函数
effectFn()

我们希望不仅仅是手动执行,还希望能够拿到返回值。如下代码中, 通过() => obj.foo + obj.bar我们能够拿到返回值。

const effectFn = effect(
    () => obj.foo + obj.bar,
    // options
    {
        lazy: true
    }
)
const value = effectFn()

需要对effect函数本身做修改,因为fn函数才是真正的副作用函数,也就是() => obj.foo + obj.bar,effectFn是我们包装过后的副作用函数,接受fn()的返回值,并且在最后return。这样上面就能够拿到返回值了

function effect (fn, options) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        effectStack.push(effectFn)
+        const res = fn()
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
+        return res
    }
    // 把options挂载到effectFn上
    effectFn.options = options
    effectFn.deps = []
    if (!options.lazy) {
        effectFn()
    }
    return effectFn
} 

如下能够拿到返回的的value值:

const effectFn = effect(
    () => obj.foo + obj.bar,
    // options
    {
        lazy: true
    }
)
const value = effectFn()
console.log(value, 'value'); // 3

如下实现一个computed函数:

function computed (getter) {
    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, 'sumRes.value');

给computed函数传入getter,其实就是副作用函数,然后将这个getter传递到effect函数中,给effect函数增加lazy的选项,得到effectFn函数,他是我们包装后的副作用函数。

我们再声明一个obj对象,他有一个value访问器属性,返回的是effectFn的执行结果,当我们访问这个对象时,就会调用effectFn函数,得到副作用函数的返回值

let data = {
    foo: 1,
    bar: 2
}
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 sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value, 'sumRes.value'); // 3

image.png

如上,能够拿到返回值是3。但是当前我们访问多次sumRes.value,就会执行多次,即便obj.foo和obj.bar的值没有变化也会重复计算。我们希望能够避免这样的重复计算,修改如下:

function computed (getter) {
    // 用来缓存上一次计算的值
+    let value;
    // dirty标志
+    let dirty = true
    const effectFn = effect(getter, {
    })

    const obj = {
        get value () {
            // 只有dirty为脏时才计算值,并将得到的值缓存在value中
+            if (dirty) {
                console.log('get执行了');
                value = effectFn()
                // 将dirty值设置为false,下一次访问只用缓存中的值
+                dirty = false
            }
            return value
        }
    }

    return obj
}

如上代码中,我们增加变量dirty,只有为true时,可以去执行get里面的effectFn()逻辑。增加了变量value,在第一次就赋值为effectFn()的返回值,之后立刻将dirty改为false。这样多次访问其value属性都只会有第一次调用。

image.png

如上图可以看到,get里面的执行逻辑就只打印了一次。

但是还存在问题,dirty不能一直为false。因为当obj.foo和obj.bar变化时,我们希望能够重新执行get value () {}访问器函数里面的逻辑。修改如下:

function computed (getter) {
    // 用来缓存上一次计算的值
    let value;
    // dirty标志
    let dirty = true
    const effectFn = effect(getter, {
        lazy: true,
+        scheduler () {
+            dirty = true
+        }
    })

    const obj = {
        get value () {
            // 只有dirty为脏时才计算值,并将得到的值缓存在value中
            if (dirty) {
                console.log('get执行了');
                value = effectFn()
                // 将dirty值设置为false,下一次访问只用缓存中的值
                dirty = false
            }
            return value
        }
    }

    return obj
}

我们在代码中,给effect函数的执行增加了一个调度器函数,这样当obj.foo和obj.bar修改时,触发trigger函数,就会把副作用函数拿出来执行,并执行调度器函数,这样dirty就会改为true,下次访问sumRes.value就会执行effectFn了。

如下在控制台调试,修改obj.foo为3,再去访问sumRes.value,拿到的就是5 image.png

如果还在另一个effect函数里面读取了计算属性的值,如下:

const sumRes = computed(() => obj.foo + obj.bar)
+effect(() => {
+    console.log(sumRes.value, 'someRes.value112');
+})

如上代码,新增对effect函数的调用,里面访问sumRes值,希望当obj.foo变化时,effect函数能够重新执行。就像在Vue的模版中读取计算属性值,当其值发生变化时,能重新渲染模版。

在控制台执行obj.foo++,发现并不会有新的打印值:

image.png

这里的嵌套指,在一个副作用函数里访问sumRes.value值,访问时触发computed里的getter函数,这时obj.foo和obj.bar会把computed内部effect函数收集到依赖集合。而外层effect函数不会被收集进去

image.png

解决方式:

function computed (getter) {
    // 用来缓存上一次计算的值
    let value;
    // dirty标志
    let dirty = true
    const effectFn = effect(getter, {
        lazy: true,
        scheduler () {
            dirty = true
            // 这里是个闭包,所以能够访问到obj变量
+            trigger(obj, 'value')
        }
    })

    const obj = {
        get value () {
            // 只有dirty为脏时才计算值,并将得到的值缓存在value中
            if (dirty) {
                console.log('get执行了');
                value = effectFn()
                // 将dirty值设置为false,下一次访问只用缓存中的值
                dirty = false
            }
+            track(obj, 'value')
            return value
        }
    }

    return obj
}

如上代码,当访问计算属性返回值时,执行track(obj, 'value'),把计算属性的返回值obj作为一个对象传递进去,track函数对obj对象的value属性进行追踪,把外层副作用函数添加到其value属性对应的副作用函数集合中。当修改obj.foo或者obj.bar属性时,执行调度器scheduler,触发trigger函数,把obj里面的value属性对应的副作用函数拿出来挨个执行一遍。这样就实现了我们的需求了

image.png

10 watch侦听器实现原理

10.1 侦听器实现原理-简洁版

watch侦听器的实现原理:

  1. Watch侦听定义:用于观察响应式数据源的变化,并在变化时执行特定的回调函数
  2. 兼容函数和对象:接受参数source,判断是函数还是对象,如果是函数则直接赋值给getter,否则递归遍历整个对象的所有属性`
  3. 首次加载立即执行:Watch函数接受第三个参数options,如果传入了immediate:true的属性,则初始化立即执行一次副作用函数
  4. 控制侦听器的执行时机:通过effect包装函数,传入副作用函数和调度器,回调函数放到调度器里面,当数据修改的时候会触发Proxy的set拦截进而触发调度器执行;如果传了flush: post的配置项进来,则回调要通过Promise.resolve()放到异步任务队列里面去执行
function watch (source, cb, options = {}) {
    let getter
    // 如果是函数,直接赋值给getter
    if (typeof source === 'function') {
        getter = source
    } else {
        // 如果不是函数,则还是原来的逻辑
        getter = () => traverse(source)
    }
    const job = function () {
        // 在effectFn中重新执行副作用函数,得到的是新值
        newVal = effectFn()
        // 把旧值和新值传递给回调函数的参数
        cb(newVal, oldVal)
        // 将新值赋值给旧值,不然下次拿到的是错误的值
        oldVal = newVal
    }
    const effectFn = effect(
        () => getter(), 
        {
            // 当数据变化时,调用回调函数
            scheduler () {
                if (options.flush === 'post') {
                    const p = Promise.resolve()
                    p.then(job)
                    console.log('执行了一次这里');
                } else {
                    job()
                }
            }
        }
    )
    let newVal, oldVal

    if (options.immediate) {
        job()
    } else {
        // 手动调用副作用函数,拿到旧值
        oldVal = effectFn()
    }
}

10.2 简易版侦听器

利用effect函数和scheduler调度器, 能够实现最基本的watch侦听器函数

// 最简单的watch函数实现
function watch (source, cb) {
    effect(
        // 触发读取操作,从而建立联系
        () => source.foo, 
        {
            // 当数据变化时,调用回调函数
            scheduler () {
                cb()
            }
        }
    )
}

如上,执行effect函数,第一个参数是回调函数,该回调会读取source.foo的值,第二个参数传入调度器scheduler,并在里面执行cb回调函数。如果传了调度器scheduler,就不会立即执行副作用函数

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

在控制台给obj.foo++,打印如下: image.png

10.3 监听所有属性

但是现在我们硬编码了source.foo的数据,只能侦听foo属性。现在来封装一个可以监听对象所有属性的方法

// 最简单的watch函数实现
function watch (source, cb) {
    effect(
        // 直接调用traverse函数,传入source对象
        () => traverse(source), 
        {
            // 当数据变化时,调用回调函数
            scheduler () {
                cb()
            }
        }
    )
}
// 递归遍历传入对象的所有属性
function traverse (value, seen = new Set()) {
    // 如果数据是原始数据类型 或者已经侦听过,则return,防止死循环
    if (typeof value !== 'object' || value === null || seen.has(value)) return
    // 将数据添加到seen里面
    seen.add(value)

    // 暂不考虑数组等其他数据结构,假设就是对象
    for (const key in value) {
        traverse(value[key], seen)
    }
    return value
}

当我把数据结构改为

let data = {
    foo: 1,
    bar: 2,
    b: 3
}

如下,我们在控制台调试,修改obj的其他属性也能够触发watch的回调 image.png

只是现在数据结构如果是嵌套的比如 let data = {b: {bb: 1}}这样里面的bb还不能被监听到。这个我们后面再实现

10.4 监听函数

我们现在来解决另一个问题,如果侦听器第一个参数传入的是一个回调函数怎么办

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

修改如下:

// 支持自定义函数
function watch (source, cb) {
+    let getter
+    // 如果是函数,直接赋值给getter
+    if (typeof source === 'function') {
+        getter = source
+    } else {
+        // 如果不是函数,则还是原来的逻辑
+        getter = () => traverse(source)
+    }
+    effect(
+        () => getter(), 
        {
            // 当数据变化时,调用回调函数
            scheduler () {
                cb()
            }
        }
    )
}
watch(() => obj.bar, () => {
    console.log('数据变化了');
})

如上,增加getter变量,如果传入source是一个函数,则直接赋值给getter,如果不是,则还是原来的逻辑。并对obj.bar属性进行侦听,如果是修改obj.foo,则不能执行回调函数。控制台打印结果如下:

image.png

10.5 监听旧值

现在还有一个问题,就是无法获取到新值和旧值,我们使用watch侦听器的时候,第二个回调函数可以接受newVal和oldVal的

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

修改如下:

// 支持自定义函数
function watch (source, cb) {
    let getter
+    let newVal, oldVal
    // 如果是函数,直接赋值给getter
    if (typeof source === 'function') {
        getter = source
    } else {
        // 如果不是函数,则还是原来的逻辑
        getter = () => traverse(source)
    }
    const effectFn = effect(
        () => getter(), 
        {
            // 当数据变化时,调用回调函数
            scheduler () {
                // 在effectFn中重新执行副作用函数,得到的是新值
+                newVal = effectFn()
                // 把旧值和新值传递给回调函数的参数
+                cb(newVal, oldVal)
                // 将新值赋值给旧值,不然下次拿到的是错误的值
+                oldVal = newVal
            }
        }
    )
    // 手动调用副作用函数,拿到旧值
+    oldVal = effectFn()
}

如上,在watch第一次执行时,会执行effectFn函数并拿到oldVal值,在后续修改数据后触发scheduler调度器,里面会再次调用effectFn,这样能拿到最新值,然后把newVal和oldVal传递给cb回调函数,再将newVal赋值给oldVal,不然下次oldVal值就还是初始值是错的。

现在再来调用,能拿到newVal和oldVal

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

image.png

10.6 immediate立即执行与flush: post

在使用Vue的Watch侦听器时,支持传入{immediate: true}参数,这样在watch创建时,立即执行一次:

watch(
    () => obj.bar, 
    (newVal, oldVal) => {
        console.log(newVal, oldVal, 'newVal, oldVal数据变化了');
    }, 
    {
        immediate: true
    }
)

实现如下:

// 支持自定义函数
function watch (source, cb, options = {}) {
    let getter
    // 如果是函数,直接赋值给getter
    if (typeof source === 'function') {
        getter = source
    } else {
        // 如果不是函数,则还是原来的逻辑
        getter = () => traverse(source)
    }
    const effectFn = effect(
        () => getter(), 
        {
            // 当数据变化时,调用回调函数
+            scheduler: job
        }
    )
    let newVal, oldVal
+    const job = function () {
+        // 在effectFn中重新执行副作用函数,得到的是新值
+        newVal = effectFn()
+        // 把旧值和新值传递给回调函数的参数
+        cb(newVal, oldVal)
+        // 将新值赋值给旧值,不然下次拿到的是错误的值
+        oldVal = newVal
+    }
+    if (options.immediate) {
+        job()
    } else {
        // 手动调用副作用函数,拿到旧值
        oldVal = effectFn()
    }

}

如上,将调度器里面的新旧赋值操作提取为job函数,赋值给给到scheduler。

然后在watch初始化执行时,判断是否传递了immediate: true的选项,如果有,则立即执行一次job,否则还是之前的逻辑。

如下,页面初始化就执行了一次watch,其中oldVal是undefined也和Vue的watch行为一致

image.png

在Vue3中还可以使用flush选项,值为post,表示调度函数需要放到微任务队列中,等DOM更新后再执行

watch(() => obj.bar, (newVal, oldVal) => {
    console.log(newVal, oldVal, 'newVal, oldVal数据变化了');
}, {
    immediate: true,
    flush: 'post'
})

修改如下:

// 支持自定义函数
function watch (source, cb, options = {}) {
    let getter
    // 如果是函数,直接赋值给getter
    if (typeof source === 'function') {
        getter = source
    } else {
        // 如果不是函数,则还是原来的逻辑
        getter = () => traverse(source)
    }
    const job = function () {
        // 在effectFn中重新执行副作用函数,得到的是新值
        newVal = effectFn()
        // 把旧值和新值传递给回调函数的参数
        cb(newVal, oldVal)
        // 将新值赋值给旧值,不然下次拿到的是错误的值
        oldVal = newVal
    }
    const effectFn = effect(
        () => getter(), 
        {
            // 当数据变化时,调用回调函数
+            scheduler () {
+                if (options.flush === 'post') {
+                    const p = Promise.resolve()
+                    p.then(job)
+                    console.log('执行了一次这里');
+                } else {
+                    job()
+                }
            }
        }
    )
    let newVal, oldVal

    if (options.immediate) {
        job()
    } else {
        // 手动调用副作用函数,拿到旧值
        oldVal = effectFn()
    }
}

如上修改了调度函数,如果传递了flush的值为post,则将job函数放到.then里面去执行,加入微任务队列。我们查看浏览器打印结果, 发现该语句console.log('执行了一次这里');先执行,然后再是watch的第二个回调函数,证明实现了延迟执行

image.png

如果没有post的值,则本质上和flush的sync的值一样代表同步

10.7 watch竞态问题

如上封装的watch函数还是存在竞态问题, 看如下例子:

let finalData;
watch(() => obj.bar, async (newVal, oldVal) => {
    const res = await request({url: '/path/to/request'})
    finalData = res
})

使用watch监听obj.bar属性,一旦发生变化,会触发回调函数去调用接口拿数据。注意,如果我们连续修改了obj.bar两次,那么回调函数会连续发两次,接口会连续触发第一次和第二次,但是有可能第一次接口返回的数据比第二次要晚,造成finalData存储的是第一次的数据,如下图。

image.png

Vue.js中的watch函数是如何解决这个问题的?

如下代码是Vue3中的Watch的使用方式:Vue.js中的回调函数有第三个参数onInvalidate,他是一个函数,可以传入一个回调函数进去,该回调会在当前副作用函数过期时执行

watch(() => obj.bar, async (newVal, oldVal, onInvalidate) => {
    // 定义一个标志,判断当前副作用函数是否过期,默认为false-没过期
    let expired = false

    onInvalidate(() => {
        // 调用onInvalidate函数,注册一个回调,当前副作用函数过期则将exipred改为true
        expired = true
    })

    const res = await request({url: '/path/to/request'})
    // 只有没有过期才能进行赋值
    if (!expired) {
        finalData = res
    }
})

我们如何去模拟这个功能?实现如下:

function watch (source, cb, options = {}) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }
    let newVal, oldVal
+    let cleanup // 存储用户注册过的过期回调
+    function onInvalidate (fn) {
+        // 过期回调存储在cleanup中
+        cleanup = fn
+    }
    const job = function () {
        newVal = effectFn()
+        // 调用回调函数cb之前,先调用过期回调
+        if (cleanup) {
+            cleanup()
+        }
+        // 将onInvalidate作为回调函数的第三个参数,用户可以调用
+        cb(newVal, oldVal, onInvalidate)
        oldVal = newVal
    }
    const effectFn = effect(
        () => getter(), 
        {
            scheduler () {
                if (options.flush === 'post') {
                    const p = Promise.resolve()
                    p.then(job)
                } else {
                    job()
                }
            }
        }
    )

    if (options.immediate) {
        job()
    } else {
        // 手动调用副作用函数,拿到旧值
        oldVal = effectFn()
    }
}

如上代码,在watch函数中,先定义一个cleanup变量,存储用户注册的过期回调,然后定义一个onInvalidate方法,接受一个过期回调函数,并赋值给cleanup变量,

在job函数里,在调用cb函数之前,先调用过期函数,然后把onInvalidate作为回调的第三个参数传出去给用户使用

我们这样来模拟这个功能:

watch(() => obj.bar, async (newVal, oldVal, onInvalidate) => {
    // 定义一个标志,判断当前副作用函数是否过期,默认为false-没过期
    let expired = false

    onInvalidate(() => {
        // 调用onInvalidate函数,注册一个回调,当前副作用函数过期则将exipred改为true
        expired = true
    })

+    const res = await new Promise((resolve, reject) => {
+        setTimeout(() => {
+            resolve(newVal * 2)
+        }, 1000)
+    })
    // 只有没有过期才能进行赋值
+    if (!expired) {
+        finalData = res
+        console.log(`Updated value: ${res}`);
+    } else {
+        console.log('The effect was invalidated');
+    }
})

+setTimeout(() => {
+    obj.bar++
+}, 200)
+setTimeout(() => {
+    obj.bar++
+}, 600)

如上代码中,我们在200ms和400ms后分别对obj.bar进行了自增操作。而watch函数里面的结果要等到1s之后才会返回。如果过期了则打印'The effect was invalidated',否则打印Updated value: ${res}

控制台打印结果如下,先打印invalidated,紧接着才是Updated

image.png