# 响应式数据与副作用函数

响应系统也是 Vue.js 的重要组成部分。

# 响应式数据与副作用函数

# 副作用函数

副作用函数指的是会产生副作用的函数

//直接或者间接的影响其他函数的执行结果
function effect() {
    document.body.innerText = 'hello vue3'
}

effect 函数执行时,它会设置 body 的文本内容,但除了 effect 函数之外的任何函数都可以读取或设置 body 的文本内容。

// 全局变量
let val = 1

function effect() {
    val = 2 // 修改全局变量,产生副作用
}

一个函数修改了全局变量,这就是一个副作用。

# 响应式数据

响应式数据指的是数据变化时,依赖它的函数都会自动更新。

const obj = {
    text: 'hello world'
}

function effect() {
    // effect 函数的执行会读取 obj.text
    document.body.innerText = obj.text
}

obj.text = 'hello vue3' // 修改 obj.text 的值,同时希望副作用函数会重新执行

# 响应式数据的基本实现

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

当读取字段 obj.text 时,我们可以把副作用函数 effect 存储到一个“桶”里

响应式

接着,当设置 obj.text 时,再把副作用函数 effect 从“桶”里取出并执行即可

# 实现响应式数据

在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue.js 3 所采用的方式。

// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = {
    text: 'hello world'
}
// 对原始数据的代理
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 将副作用函数 effect 添加到存储副作用函数的桶中
        bucket.add(effect)
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        bucket.forEach(fn => fn())
        // 返回 true 代表设置操作成功
        return true
    }
})

// 副作用函数
function effect() {
    document.body.innerText = obj.text
}
// 执行副作用函数,触发读取
effect()
// 1 秒后修改响应式数据
setTimeout(() => {
    obj.text = 'hello vue3'
}, 1000)

上述只是实现了最基本的响应式数据,实际上还有很多细节需要考虑,简单的实现了响应式数据的基本实现和工作原理。

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

# 修改副作用函数的封装

effect 函数接收一个参数 fn,即要注册的副作用函数,这样可以用一个函数来注册多个副作用函数。

// 存储副作用函数的桶
const bucket = new Set()

// 原始数据
const data = {
    text: 'hello world'
}

// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
    // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
    activeEffect = fn
    // 执行副作用函数
    fn()
}

const obj = new Proxy(data, {
    get(target, key) {
        // 将 activeEffect 中存储的副作用函数收集到“桶”中
        if (activeEffect) { // 新增
            bucket.add(activeEffect) // 新增
        } // 新增
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        bucket.forEach(fn => fn())
        return true
    }
})

effect(
    // 一个匿名的副作用函数
    () => {
        document.body.innerText = obj.text
    }
)

setTimeout(() => {
    obj.text2 = 'hello vue3'
}, 1000)

上述代码中,如果在 setTimeout 中修改了其他的变量值,在匿名函数中并没有读取到,所以不应该触发副作用函数的执行。但是实际上,却执行了,这是不正确的。因为我们并没有在副作用函数和属性之间建立联系。

# 不同属性之间和副作用函数的联系

在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构

effect(function effectFn() {
    document.body.innerText = obj.text
})
// 被操作(读取)的代理对象 obj;
// 被操作(读取)的字段名 text;
// 使用 effect 函数注册的副作用函数 effectFn。

那么三者的联系应该如下关系

target
  └── key
       └── effectFn

如果有两个副作用函数同时读取同一个对象的属性值:

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

 target
  └── text
        └── effectFn1
        └── effectFn2

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

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

 target
    └── text1
          └── effectFn
    └── text2
           └── effectFn

如果在不同的副作用函数中读取了两个不同对象的不同属性:

 effect(function effectFn1() {
  obj1.text1
 })
 effect(function effectFn2() {
  obj2.text2
 })

target1
    └── text1
         └── effectFn1
target2
    └── text2
         └── effectFn2

用树型架构建立起来之后,单个字段之前更新,副作用函数不会被重复执行。

# 实现响应式系统的完整流程

需要更改 SetWeakMap (opens new window),以便存储多个副作用函数。

// 存储副作用函数的桶
const bucket = new WeakMap()

// 原始数据
const data = {
    text: 'hello world'
}
// 对原始数据的代理
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {

        // 没有 activeEffect,直接 return
        if (!activeEffect) return target[key]
        // 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key -->effects
        let depsMap = bucket.get(target)
        //如果不存在 depsMap,则创建一个新的 Map 并与 target 建立联系
        if (!depsMap) {
            bucket.set(target, (depsMap = new Map()))
        }
        // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
        // 里面存储着所有与当前 key 相关联的副作用函数:effects
        let deps = depsMap.get(key)
        // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
        if (!deps) {
            depsMap.set(key, (deps = new Set()))
        }
        // 最后将当前激活的副作用函数添加到“桶”里
        deps.add(activeEffect)
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        const depsMap = bucket.get(target)
        if (!depsMap) return
        const effects = depsMap.get(key)
        effects && effects.forEach(fn => fn())
    }
})

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect

function effect(fn) {
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = fn
    // 执行副作用函数
    fn()
}

effect(() => {
    console.log('effect run')
    document.body.innerText = obj.text
})

setTimeout(() => {
    obj.text = 'hello vue3'
}, 1000)

构建数据结构的方式,我们分别使用了WeakMap、Map 和 Set

  • WeakMap 由 target --> Map 构成
  • Map 由 key --> Set 构成。

WeakMap 的键是原始对象 target,WeakMap 的值是一个Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set

响应式

采用 WeakMap 的原因,WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。据这个特性可知,一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了

const map = new Map();
const weakmap = new WeakMap();
(function() {
    const foo = {
        foo: 1
    };
    const bar = {
        bar: 2
    };
    map.set(foo, 1);
    weakmap.set(bar, 2);
})()

最后把 getset 方法做一些封装处理

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)
    }
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
    // 没有 activeEffect,直接 return
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
}

# 分支切换与 cleanup

# 分支切换的问题

在 effectFn 函数内部存在一个三元表达式,根据字段 obj.ok值的不同会执行不同的代码分支。当字段 obj.ok 的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。

 const data = {
     ok: true,
     text: 'hello world'
 }
 const obj = new Proxy(data, {
     /* ... */
 })

 effect(function effectFn() {
     document.body.innerText = obj.ok ? obj.text : 'not'
 })

分支切换的副作用函数如下

data
    └── ok
         └── effectFn
    └── text
        └── effectFn
  • 如果 obj.ok 的值从 true 变为 false,那么 obj.text 的值应该就不会被读取。
  • 如果再修改obj.text 的值,effectFn 应该不会再被执行。但是实际上,effectFn 还是会被执行。

# 断开副作用函数与响应式数据之间的关系

我们需要做到每次副作用函数执行前,将其从相关联的依赖集合中移除。

修改 effect 函数, 在内部定义 effectFn 函数,并添加一个 effectFn.deps 到依赖集合中。该属性是数组,用来存储所有包含该副作用函数的依赖项

// 用一个全局变量存储被注册的副作用函数
let activeEffect

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

function track(target, key) {
    // 没有 activeEffect,直接 return
    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)
    // deps 就是一个与当前副作用函数存在联系的依赖集合
    // 将其添加到 activeEffect.deps 数组中
    activeEffect.deps.push(deps) // 新增
}

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    // effects && effects.forEach(fn => fn()) // 问题出在这句代码 这里会导致重复执行
    const effectsToRun = new Set(effects) // 新增
    effectsToRun.forEach(effectFn => effectFn()) // 新增
}

function cleanup(effectFn) {
    // 遍历 effectFn.deps 数组
    for (let i = 0; i < effectFn.deps.length; i++) {
        // deps 是依赖集合
        const deps = effectFn.deps[i]
        // 将 effectFn 从依赖集合中移除
        deps.delete(effectFn)
    }
    // 最后需要重置 effectFn.deps 数组
    effectFn.deps.length = 0
}

effect(() => {
    console.log('effect run')
    document.body.innerText = obj.ok ? obj.text : 'not'
})

# 嵌套的 effect 与 effect 栈

嵌套的 effect 指的是一个 effect 函数内部调用了另一个 effect 函数。

effect(function effectFn1() {
    effect(function effectFn2() {
        /* ... */
    })
    /* ... */
})

当组件发生嵌套时,例如 Foo 组件渲染了 Bar 组件

 // Bar 组件
 const Bar = {
     render() {
         /* ... */
     },
 }
 // Foo 组件渲染了 Bar 组件
 const Foo = {
     render() {
         return <Bar / > // jsx 语法
     },
 }

 //相当于

 effect(() => {
     Foo.render()
     // 嵌套
     effect(() => {
         Bar.render()
     })
 })
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn)
        // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
        activeEffect = effectFn
        fn()
    }
    // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
    effectFn.deps = []
    // 执行副作用函数
    effectFn()
}

当前的逻辑是不支持嵌套的,用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个。

为了解决这个问题,我们需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。

 // 用一个全局变量存储当前激活的 effect 函数
 let activeEffect
 // effect 栈
 const effectStack = [] // 新增

 function effect(fn) {
     const effectFn = () => {
         cleanup(effectFn)
         // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
         activeEffect = effectFn
         // 在调用副作用函数之前将当前副作用函数压入栈中
         effectStack.push(effectFn) // 新增
         fn()
         // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把还原为之前的值activeEffect 
         effectStack.pop() // 新增
         activeEffect = effectStack[effectStack.length - 1] // 新增
     }
     // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
     effectFn.deps = []
     // 执行副作用函数
     effectFn()
 }

# 避免无限递归循环

const data = {
    foo: 1
}
const obj = new Proxy(data, {
    /*...*/
})
effect(() => obj.foo++)

//  Uncaught RangeError: Maximum call stack size exceeded

在这个语句中,既会读取 obj.foo 的值,又会设置 obj.foo 的值, 在执行中,还没执行完毕,就开始下一次的执行。这样就会导致无限递归循环,最终导致栈溢出。

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    const effectsToRun = new Set()
    effects && effects.forEach(effectFn => {
        // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
        if (effectFn !== activeEffect) { // 新增
            effectsToRun.add(effectFn)
        }
    })
    effectsToRun.forEach(effectFn => effectFn())
    // effects && effects.forEach(effectFn => effectFn())
}

# 调度执行

当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式

const data = {
    foo: 1
}
const obj = new Proxy(data, {
    /* ... */
})
effect(() => {
    console.log(obj.foo)
})
obj.foo++
console.log('结束了')

// 输出为
// 1
// 2
// 结束了

//需要改动为
// 1
// 结束了
// 2

# 控制执行的时机

effect 函数注册副作用函数时,可以传递第二个参数 options。它是一个对象,其中允许指定scheduler 调度函数,同时在 effect 函数内部我们需要把options 选项挂载到对应的副作用函数上

function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn)
        // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
        activeEffect = effectFn
        // 在调用副作用函数之前将当前副作用函数压栈
        effectStack.push(effectFn)
        fn()
        // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }
    // 将 options 挂载到 effectFn 上
    effectFn.options = options // 新增
    // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
    effectFn.deps = []
    // 执行副作用函数
    effectFn()
}

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    const effectsToRun = new Set()
    effects && effects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
        }
    })
    effectsToRun.forEach(effectFn => {
        // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
        if (effectFn.options.scheduler) { // 新增
            effectFn.options.scheduler(effectFn) // 新增
        } else {
            // 否则直接执行副作用函数(之前的默认行为)
            effectFn() // 新增
        }
    })
}

const data = {
    foo: 1
}
const obj = new Proxy(data, {
    /* ... */
})
effect(
    () => {
        console.log(obj.foo)
    },
    // options
    {
        // 调度器 scheduler 是一个函数
        scheduler(fn) {
            // 将副作用函数放到宏任务队列中执行
            setTimeout(fn)
        }
    }
)
obj.foo++
console.log('结束了')

使用调度器就可以控制副作用函数的执行时机。

# 控制执行的次数

const data = {
    foo: 1
}
const obj = new Proxy(data, {
    /* ... */
})
effect(() => {
    console.log(obj.foo)
})
obj.foo++
obj.foo++

//输出
// 1
// 2
// 3

上述输出2只是一个过渡状态,我们期望最终结果是3,因为副作用函数只执行一次。

 // 定义一个任务队列
 const jobQueue = new Set()
 // 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
 const p = Promise.resolve()
 // 一个标志代表是否正在刷新队列
 let isFlushing = false

 function flushJob() {
     // 如果队列正在刷新,则什么都不做
     if (isFlushing) return
     // 设置为 true,代表正在刷新
     isFlushing = true
     // 在微任务队列中刷新 jobQueue 队列
     p.then(() => {
         jobQueue.forEach(job => job())
     }).finally(() => {
         // 结束后重置 isFlushing
         isFlushing = false
     })
 }
 effect(() => {
     console.log(obj.foo)
 }, {
     scheduler(fn) {
         // 每次调度时,将副作用函数添加到 jobQueue 队列中
         jobQueue.add(fn)
         // 调用 flushJob 刷新队列
         flushJob()
     }
 })
 obj.foo++
 obj.foo++

使用微任务调度器,将副作用函数添加到 jobQueue 队列中,执行 flushJob 函数, 该函数通过开关 isFlushing 控制队列的刷新,确保副作用函数只执行一次。

Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,思路与上文介绍的相同。

# 计算属性 computed 与 lazy

指定 scheduler 调度器来控制副作用函数的执行时机和方式;用 track 函数追踪和收集依赖,用 trigger 函数触发更新 来实现 Vue.js 中的计算属性 computed

# 懒执行

在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性。

function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        effectStack.push(effectFn)
        // 将 fn 的执行结果存储到 res 中
        const res = fn() // 新增
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
        // 将 res 作为 effectFn 的返回值
        return res // 新增
    }
    effectFn.options = options
    effectFn.deps = []
    //如果指定了lazy选项,则不立即执行
    if (!options.lazy) {
        effectFn()
    }
    // 将副作用函数作为返回值返回
    return effectFn
}

function computed(getter) {
    // 把 getter 作为副作用函数,创建一个 lazy 的 effect
    const effectFn = effect(getter, {
        lazy: true
    })
    const obj = {
        // 当读取 value 时才执行 effectFn
        get value() {
            return effectFn()
        }
    }
    return obj
}

定义一个 computed 函数,它接收一个 getter 函数作为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy的 effect。computed 函数的执行会返回一个对象,该对象的value 属性是一个访问器属性,只有当读取 value 的值时,才会执行effectFn 并将其结果作为返回值返回。

# 值缓存

计算属性的值缓存,可以避免不必要的重新计算。

上面的代码多次访问时,都会重复执行 effectFn ,即使值没有变化,这会导致不必要的计算

function computed(getter) {
    // value 用来缓存上一次计算的值
    let value
    // dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
    let dirty = true
    const effectFn = effect(getter, {
        lazy: true
    })
    const obj = {
        get value() {
            // 只有“脏”时才计算值,并将得到的值缓存到 value 中
            if (dirty) {
                value = effectFn()
                // 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
                dirty = false
            }
            return value
        }
    }
    return obj
}

上述逻辑只会在第一次访问时计算值,之后的访问直接使用缓存的值,并将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值。如果修改了依赖的值,我们需要将 dirty 设置为 true,下一次访问时重新计算值。

function computed(getter) {
    let value
    let dirty = true
    const effectFn = effect(getter, {
        lazy: true,
        // 添加调度器,在调度器中将 dirty 重置为 true  
        scheduler() {
            dirty = true //这里将在重置依赖值时取消缓存
        }
    })
    const obj = {
        get value() {
            if (dirty) {
                value = effectFn()
                dirty = false
            }
            return value
        }
    }
    return obj
}

# computed 返回的值 与 依赖项

const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
    // 在该副作用函数中读取 sumRes.value
    console.log(sumRes.value)
})
// 修改 obj.foo 的值
obj.foo++

上述代码修改了 obj.foo 的值,但 sumRes.value 的副作用函数并没有重新执行。

当读取计算属性的值时,我们可以手动调用 track 函数进行追踪;当计算属性依赖的响应式数据发生变化时,我们可以手动调用 trigger 函数触发响应

function computed(getter) {
    let value
    let dirty = true
    const effectFn = effect(getter, {
        lazy: true,
        scheduler() {
            if (!dirty) {
                dirty = true
                // 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
                trigger(obj, 'value')
            }
        }
    })
    const obj = {
        get value() {
            if (dirty) {
                value = effectFn()
                dirty = false
            }
            // 当读取 value 时,手动调用 track 函数进行追踪
            track(obj, 'value')
            return value
        }
    }
    return obj
}

建立这样的关系

computed(obj)
        └── value
            └── effectFn

# watch 的实现原理

观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。

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

// 修改响应数据的值,会导致回调函数执行
obj.foo++

** watch 的本质其实是对 effect 的二次封装以及 options.scheduler 。**

effect(() => {
    console.log(obj.foo)
}, {
    scheduler() {
        // 当 obj.foo 的值变化时,会执行 scheduler 调度函数
    }
})
const data = {
    foo: 1
}
const obj = new Proxy(data, {
    /* ... */
})
watch(obj, () => {
    console.log('数据变化了')
})
obj.foo++

上述代码针对于 obj.foo 的读取操作,只能监听 obj.foo 的值变化,而不能监听 obj 的其他属性的变化,所以需要对 obj 进行递归遍历。

function watch(source, cb) {
    effect(
        // 调用 traverse 递归地读取
        () => traverse(source), {
            scheduler() {
                // 当数据变化时,调用回调函数 cb
                cb()
            }
        }
    )
}
//使用for in 可以 触发 对象属性 getter 的 getter 函数
function traverse(value, seen = new Set()) {
    // 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
    if (typeof value !== 'object' || value === null ||
        seen.has(value)) return
    // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
    seen.add(value)
    // 暂时不考虑数组等其他结构
    // 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
    for (const k in value) {
        traverse(value[k], seen)
    }
    return value
}

** watch 函数监听响应式数据之外,还可以监听一个 getter 函数。**

function watch(source, cb) {
    // 定义 getter
    let getter
    // 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
    if (typeof source === 'function') {
        getter = source
    } else {
        // 否则按照原来的实现调用 traverse 递归地读取
        getter = () => traverse(source)
    }
    effect(
        // 执行 getter
        () => getter(), {
            scheduler() {
                cb()
            }
        }
    )
}

** watch 函数获取新值与旧值**

 watch(
     () => obj.foo,
     (newValue, oldValue) => {
         console.log(newValue, oldValue) // 2, 1
     }
 )

 obj.foo++
 function watch(source, cb) {
     let getter
     if (typeof source === 'function') {
         getter = source
     } else {
         getter = () => traverse(source)
     }
     // 定义旧值与新值
     let oldValue, newValue
     // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到effectFn 中以便后续手动调用
     const effectFn = effect(
         () => getter(), {
             lazy: true,
             scheduler() {
                 // 在 scheduler 中重新执行副作用函数,得到的是新值
                 newValue = effectFn()
                 // 将旧值和新值作为回调函数的参数
                 cb(newValue, oldValue)
                 // 更新旧值,不然下一次会得到错误的旧值
                 oldValue = newValue
             }
         }
     )
     // 手动调用副作用函数,拿到的值就是旧值
     oldValue = effectFn()
 }

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

immediate 来指定回调是否需要立即执行

immediate 选项存在并且为 true 时,回调函数会在该 watch 创建时立刻执行一次

watch(obj, () => {
    console.log('变化了')
}, {
    // 回调函数会在 watch 创建时立即执行一次
    immediate: true
})
function watch(source, cb, options = {}) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }
    let oldValue, newValue
    // 提取 scheduler 调度函数为一个独立的 job 函数
    const job = () => {
        newValue = effectFn()
        cb(newValue, oldValue)
        oldValue = newValue
    }
    const effectFn = effect(
        // 执行 getter
        () => getter(), {
            lazy: true,
            // 使用 job 函数作为调度器函数
            scheduler: job
        }
    )
    if (options.immediate) {
        // 当 immediate 为 true 时立即执行 job,从而触发回调执行
        job()
    } else {
        oldValue = effectFn()
    }
}

指定回调函数的执行时机,例如在 Vue.js 3 中使用 flush 选项来指定

当 flush 的值为 'post' 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行

function watch(source, cb, options = {}) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }

    let oldValue, newValue

    const job = () => {
        newValue = effectFn()
        cb(newValue, oldValue)
        oldValue = newValue
    }

    const effectFn = effect(
        // 执行 getter
        () => getter(), {
            lazy: true,
            scheduler: () => {
                // 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行
                if (options.flush === 'post') {
                    const p = Promise.resolve()
                    p.then(job)
                } else {
                    job()
                }
            }
        }
    )

    if (options.immediate) {
        job()
    } else {
        oldValue = effectFn()
    }
}

# 过期的副作用

watch 监听的响应式数据发生多次变化,会触发多次副作用函数的执行,如果是异步的副作用函数,可能会导致副作用函数的执行时机不确定。

 let finalData
 watch(obj, async () => {
     // 发送并等待网络请求
     const res = await fetch('/path/to/request')
     // 将请求结果赋值给 data
     finalData = res
 })

上述代码中, watch 监听 obj 的变化,并在副作用函数中发送网络请求,并等待请求结果,赋值给 finalData 。如果多次修改 obj 的值,赋值的数据就不能确定是那次的值,因为副作用函数的执行时机不确定。

第一次修改 obj 对象的某个字段 值,这会导致回调函数执行,同时发送了第一次请求 A。随着时间的 推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行 了第二次修改,这会导致发送第二次请求 B。此时请求 A 和请求 B 都 在进行中,有可能B的结果先返回,但是A返回后又会覆盖掉 B 的结果。

watch(obj, async (newValue, oldValue, onInvalidate) => {
    // 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
    let expired = false
    // 调用 onInvalidate() 函数注册一个过期回调
    onInvalidate(() => {
        // 当过期时,将 expired 设置为 true
        expired = true
    })
    // 发送网络请求
    const res = await fetch('/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 oldValue, newValue

     // cleanup 用来存储用户注册的过期回调
     let cleanup
     // 定义 onInvalidate 函数
     function onInvalidate(fn) {
         // 将过期回调存储到 cleanup 中
         cleanup = fn
     }

     const job = () => {
         newValue = effectFn()
         // 在调用回调函数 cb 之前,先调用过期回调
         if (cleanup) {
             cleanup()
         }
         // 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
         cb(newValue, oldValue, onInvalidate)
         oldValue = newValue
     }

     const effectFn = effect(
         // 执行 getter
         () => getter(), {
             lazy: true,
             scheduler: () => {
                 if (options.flush === 'post') {
                     const p = Promise.resolve()
                     p.then(job)
                 } else {
                     job()
                 }
             }
         }
     )

     if (options.immediate) {
         job()
     } else {
         oldValue = effectFn()
     }
 }

逻辑的关键在 job 函数,每次执行回调 cb 之前,先检查是否存在过期回调。

# 完整的代码

// 存储副作用函数的桶
const bucket = new WeakMap()

// 原始数据
const data = {
    foo: 1,
    bar: 2
}
// 对原始数据的代理
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)
    }
})

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

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)

    const effectsToRun = new Set()
    effects && effects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
        }
    })
    effectsToRun.forEach(effectFn => {
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
    // effects && effects.forEach(effectFn => effectFn())
}

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
// effect 栈
const effectStack = []

function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn)
        // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
        activeEffect = effectFn
        // 在调用副作用函数之前将当前副作用函数压栈
        effectStack.push(effectFn)
        const res = fn()
        // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]

        return res
    }
    // 将 options 挂在到 effectFn 上
    effectFn.options = options
    // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
    effectFn.deps = []
    // 执行副作用函数
    if (!options.lazy) {
        effectFn()
    }

    return effectFn
}

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

// =========================

function traverse(value, seen = new Set()) {
    if (typeof value !== 'object' || value === null || seen.has(value)) return
    seen.add(value)
    for (const k in value) {
        traverse(value[k], seen)
    }

    return value
}

function watch(source, cb, options = {}) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }

    let oldValue, newValue

    const job = () => {
        newValue = effectFn()
        cb(oldValue, newValue)
        oldValue = newValue
    }

    const effectFn = effect(
        // 执行 getter
        () => getter(), {
            lazy: true,
            scheduler: () => {
                if (options.flush === 'post') {
                    const p = Promise.resolve()
                    p.then(job)
                } else {
                    job()
                }
            }
        }
    )

    if (options.immediate) {
        job()
    } else {
        oldValue = effectFn()
    }
}

watch(() => obj.foo, (newVal, oldVal) => {
    console.log(newVal, oldVal)
}, {
    immediate: true,
    flush: 'post'
})

setTimeout(() => {
    obj.foo++
}, 1000)