读《Vue.js设计与实现》第四章·响应系统的作用与实现

4. 响应系统的作用与实现

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

本节理解如下概念:

  • 什么是副作用函数?
  • 什么是响应式数据

副作用函数:副作用函数执行会对其他函数产生影响,如下例子:

effect函数执行会读取了document.body.innerText并赋值为data.text变量的值。此时再去执行other函数同样会访问document.body.innerText的值。effect函数执行对other函数产生了影响 ,这就是副作用函数

js 复制代码
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的值后,如果effect函数能够重新执行,data就是响应式数据

diff 复制代码
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文本'

4.2 响应式数据的基本实现

本节目标:实现最基础的一个响应式系统:

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

所以我们能拦截该对象的访问和设置行为,借助代理对象Proxy执行,区别于Vue2的Object.defineProperty()

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

// 桶,存储副作用函数
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文本'

如上代码,我们创建了一个Set数据结构。Proxy构造函数中,参数1是原始数据,参数2是一个对象,对象有get访问器和set设置器两个函数。在访问数据时触发get,把副作用函数添加到桶里面;在设置数据时触发set,把副作用函数从桶里面拿出来执行。

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

如上代码存在问题:副作用函数名称是硬编码,下面我们将会讨论如何执行

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

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

js 复制代码
let data = {
    text: '我是text文本'
}
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
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变量,初始值为undefined,用来存储被注册的副作用函数。

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

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

但是存在问题:如果给一个新的属性进行赋值操作,如下代码,副作用函数在2s后会再执行一次。原因是,设计的代码中,无论读取的是哪个属性,都会把副作用函数收集到桶里面,无论修改的是哪个值,都会把桶里面的副作用函数拿出来再执行一遍。

js 复制代码
setTimeout(() => {
    obj.notExist = '333'
}, 2000)

4.3.2 进一步完善数据结构

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

js 复制代码
effect(
    function effectFn() {
        console.log('effect runs');
        document.body.innerText = obj.text
    }
)

对应关系

arduino 复制代码
obj
  -text
      -effectFn

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

js 复制代码
effect(
    function effectFn1() {
        obj.text
    }
)
effect(
    function effectFn2() {
        obj.text
    }
)

对应关系

arduino 复制代码
obj
  -text
      -effectFn1
      -effectFn2

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

js 复制代码
effect(
    function effectFn1() {
        obj.text1
        obj.text2
    }
)

对应关系

markdown 复制代码
obj
  -text1
      -effectFn1
  -text2
      -effectFn1

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

js 复制代码
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的target是原始数据对象值,WeakMap的值是Map数据结构,Map数据结构的key是对象的key属性,Map数据结构的value值是Set数据结构,是副作用函数集合。

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

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

diff 复制代码
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())
}

4.4 分支切换

假设副作用函数如下,当obj.ok为true时,body的innerText值设置为obj.text,否则设置为'no'。这个就是分支切换的表现

diff 复制代码
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.ok为true,此时副作用函数里面读取了obj.ok和obj.text,那么桶中,副作用函数会被收集到ok和text各自的Map中。我们希望当obj.ok为false时,此时不读取obj.text的值了,那么当修改obj.text的值,副作用函数不执行,但是我们之前的代码不能实现这个效果,obj.text修改后,副作用函数还是会执行,为什么?因为目前代码中,副作用函数一旦收集进去了,就没删掉,

js 复制代码
obj
    text
        effectFn ==>这里本来应该要删掉的
    ok
        effectFn

如何删呢?副作用函数执行时,先把所有与他关联的依赖集合都删掉。副作用函数执行完,会再次触发get里面的track函数,会再次绑定最新的依赖关系。如何找到这个所有与他关联的依赖集合呢?这个依赖集合就在track函数中,就是下面的deps

diff 复制代码
function track (target, key) {
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()))
    }
+   就是这个deps  
+   let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    // 把当前激活的副作用函数添加到依赖集合deps中
    deps.add(activeEffect)
}

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

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

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

diff 复制代码
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数组给置空。

js 复制代码
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里面从桶里面拿出来的,所以这一步是在清空桶里面的数据。

effectFn.deps.length = 0这个是在操作副作用函数自己身上的deps属性。当桶数据处理好后,这里的留着就没啥用了

如下图:

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

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

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

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

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

如下:

diff 复制代码
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()
    })
}

4.5 嵌套的effect和effect栈

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

js 复制代码
// Bar组件
const Bar = {
    render () {}
}
// Foo组件里面嵌套Bar
const Foo = {
    render () {
        return Bar
    }
}

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

js 复制代码
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值时:

js 复制代码
obj.foo = 'change'

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

原因如下:

js 复制代码
effect(
    function effectFn1 () {
        console.log('effectFn1执行了');
        effect(
            function effectFn2 () {
                debugger
                console.log('effectFn2执行了');
                temp2 = obj.bar
            }
        )
        temp1 = obj.foo
    }
)
js 复制代码
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对应的副作用函数

修改方案如下:

diff 复制代码
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打印的就是正确的:

代码执行图示如下:

4.6 如何避免无限递归

如下执行,会导致栈溢出

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

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

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

diff 复制代码
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()
    })
}

4.7.1 调度执行-时机

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

如下代码中:

js 复制代码
effect(() => {
    console.log(obj.foo, 'obj.foo');
})
obj.foo++
console.log('结束了');

打印结果是:

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

js 复制代码
1
'结束了'
2

改成这样就能实现

js 复制代码
effect(() => {
    console.log(obj.foo, 'obj.foo');
})
console.log('结束了');
obj.foo++

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

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

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

        }
    }
)

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

diff 复制代码
+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函数,在这里我们就要判断是否传递了调度器函数

diff 复制代码
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执行的时机就会延迟

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

4.7.2 调度执行的次数

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

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

js 复制代码
effect(
    () => {
        console.log(obj.foo, 'obj.foo');
    },
)
obj.foo++
obj.foo++

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

如下

js 复制代码
// 定义一个任务队列
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
    })
}

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

js 复制代码
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

最终打印的效果如下:

4.8 计算属性与lazy

通过之前的知识点,我们能够实现Vue.js中非常重要且有特色的能力--计算属性。

先介绍懒执行的effect,即lazy的effect。我们实现的effect函数会立即执行,如下:

js 复制代码
effect(
    () => {
        console.log(obj.foo, 'obj.foo');
    }
)

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

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

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

diff 复制代码
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函数返回了一个函数,我们能够拿到他,手动执行。

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

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

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

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

diff 复制代码
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值:

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

如下实现一个computed函数:

js 复制代码
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函数,得到副作用函数的返回值

js 复制代码
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

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

diff 复制代码
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属性都只会有第一次调用。

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

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

diff 复制代码
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

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

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

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

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

我们之前讲过嵌套的effect栈,那种情况是effectFn1里面嵌套了effectFn2函数,effectFn2函数会把effectFn1给覆盖了,activeEffect变量里面始终存的是effectFn2对应的副作用函数。

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

第一,访问sumRes.value,打印这句

第二,访问getter,触发effect

第三步,读取obj.foo和obj.bar,依次给他们收集依赖函数,依赖函数就是() => obj.foo + obj.bar对应的副作用函数

如何解决呢?

diff 复制代码
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属性对应的副作用函数拿出来挨个执行一遍。这样就实现了我们的需求了

4.9 watch侦听器实现原理

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

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

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

js 复制代码
watch(obj, () => {
    console.log('数据变化了');
})

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

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

js 复制代码
// 最简单的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
}

当我把数据结构改为

js 复制代码
let data = {
    foo: 1,
    bar: 2,
    b: 3
}

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

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

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

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

修改如下:

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

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

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

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

修改如下:

diff 复制代码
// 支持自定义函数
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

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

4.10 立即执行的watch和回调函数执行的时机

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

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

实现如下:

diff 复制代码
// 支持自定义函数
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行为一致

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

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

修改如下:

diff 复制代码
// 支持自定义函数
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的第二个回调函数,证明实现了延迟执行

如果没有post的值,则本质上和flush的sync的值一样。如果flush的值是pre,代表是组件更新前。

4.11 过期的副作用

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

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

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

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

js 复制代码
watch(() => obj.bar, async (newVal, oldVal, onInvalidate) => {
    let expired = false

    onInvalidate(() => {
        expired = true
    })

    const res = await request({url: '/path/to/request'})
    if (!expired) {
        finalData = res
    }
})

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

js 复制代码
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
    }
})

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

diff 复制代码
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作为回调的第三个参数传出去给用户使用

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

diff 复制代码
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

相关推荐
ahhdfjfdf1 分钟前
最全的`Map` 和 `WeakMap`的区别
前端
JYeontu6 分钟前
实现一个带@功能的输入框组件
前端·javascript·vue.js
一颗奇趣蛋22 分钟前
vue-router的query和params的区别(附实际用法)
前端·vue.js
孤城28627 分钟前
MAC电脑常用操作
前端·macos·快捷键·新手·电脑使用
木亦Sam28 分钟前
Vue DevTools逆向工程:自己实现一个组件热更新调试器
前端
酷酷的阿云28 分钟前
动画与过渡效果:UnoCSS内置动画库的实战应用
前端·css·typescript
dleei28 分钟前
使用docker创建gitlab仓库
前端·docker·gitlab
勤劳的代码小蜜蜂29 分钟前
大文件上传:告别传统传输瓶颈,让数据流转更高效
前端
前端大卫29 分钟前
Echarts 饼图的创新绘制技巧(附 Demo 和源码)
前端·javascript·echarts
wiedereshen30 分钟前
Vue学习记录(十) --- Vue3综合应用
前端