读《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

相关推荐
10年前端老司机1 小时前
什么!纯前端也能识别图片中的文案、还支持100多个国家的语言
前端·javascript·vue.js
摸鱼仙人~1 小时前
React 性能优化实战指南:从理论到实践的完整攻略
前端·react.js·性能优化
程序员阿超的博客2 小时前
React动态渲染:如何用map循环渲染一个列表(List)
前端·react.js·前端框架
magic 2452 小时前
模拟 AJAX 提交 form 表单及请求头设置详解
前端·javascript·ajax
小小小小宇7 小时前
前端 Service Worker
前端
只喜欢赚钱的棉花没有糖7 小时前
http的缓存问题
前端·javascript·http
小小小小宇7 小时前
请求竞态问题统一封装
前端
loriloy7 小时前
前端资源帖
前端
源码超级联盟7 小时前
display的block和inline-block有什么区别
前端
GISer_Jing8 小时前
前端构建工具(Webpack\Vite\esbuild\Rspack)拆包能力深度解析
前端·webpack·node.js