Vue3响应系统的实现原理

383 阅读32分钟

概要

Vue3框架的响应系统是其核心特性之一,文章将会简单介绍其背景,然后阐述响应系统的实现思路并给出基础版本代码。接下来会在基础版本代码之上逐渐迭代,达到完善形态。

文章的主要代码来自于《Vue.js 设计与实现》一书。但是不同的是,这里给出的代码是TypeScript版本,而非书中的JavaScript。通过TypeScript类型机制,以便实现对代码更好的理解。

背景

Vue3设计响应系统的主要作用是实现数据视图自动同步,能简化开发过程和提升应用的性能。具体来说有两大好处:

  • 自动化视图更新:当响应式数据发生变化时,相关的视图会自动更新
  • 组件颗粒度级别的视图更新:精确追踪每个组件对响应式数据的依赖关系,从而只更新受影响的组件部分,避免不必要的全局重新渲染

响应式数据的基础实现

响应式数据的目标

正如在使用Vue3框架时,修改响应式数据后,视图会自动更新。基础版响应式数据的实现目标是:修改了某个对象的属性后,使用了该属性值的副作用函数会自动执行。

以下面代码为例,当修改obj.text的值后,我们希望effect函数会自动执行。

const obj = {
    text: 'hello world'
}

const effect = () => {
    const text = obj.text
    console.log('effect1', text)
}

// 修改obj.text后,希望effect能执行
obj.text = 'foo'

实现思路

要实现上述逻辑,需要做到两件事情:

  1. 知道对象的属性值对应哪些副作用函数
  2. 对象的属性值更新后,触发其对应的副作用函数的执行

通过ES6Prxoy创建对象的代理能实现这一点:

  1. 当对象的属性值副作用函数访问,就在Prxoyget中收集副作用函数
  2. 当对象的属性值发生修改,就在Prxoyset触发该属性收集的所有副作用函数

可以用这段简短的代码来辅助理解

// 存储副作用函数的桶
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())
    }
})

// 副作用函数
function effect () {
    const text = obj.text
    console.log(text)
}
effect()

// 修改 obj.text 的值,会触发 effect 的执行
obj.text = 'foo'

这段代码是响应式数据的最基础实现逻辑,目前还存在两个问题:

  1. 代码bucket.add(effect)是通过函数名去获取副作用函数,这种硬编码方式很不灵活。
  2. 存储副作用函数bucket,直接存入了所有副作用函数。但我们要做到对象的属性与其对应的副作用函数精细映射,否则这会导致一个属性值变化后,与该属性的无关的副作用函数也会执行。

具体实现

通过如下措施解决上述两个问题:

  1. 通过一个全局变量记录当前的副作用函数,解决硬编码方式获取副作用函数的问题。
  2. 建立更精细的bucket数据结构,实现对象的属性到其对应的副作用函数的映射。即建立对象映射属性属性映射副作用函数的数据结构。细节如下:
    • WeakMap作为bucketWeakMapkey是对象,value是一个MapMapkey对象的属性value是一个存储副作用函数Set
    • TypeScript伪代码描述bucket的数据类型:
WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>

接下来是实现响应式数据的运行逻辑:

  1. 通过一个叫做effect的函数来注册副作用函数,副作用函数effect函数的参数。在effect内部,有一个全局变量activeEffect会记录传入effect副作用函数,并且副作用函数会被调用,从而触发Prxoyget
  2. Prxoyget方法的前两个参数分别是目标对象被获取的属性名,再加上记录当前副作用函数的变量activeEffect,就能在get方法中更新bucket的数据,从而建立对象->属性名->副作用函数的映射关系。
  3. 当修改Prxoy实例的属性,会触发Prxoyset方法,该方法的前两个参数分别是目标对象被获取的属性名,通过bucket记录的映射关系,配合前两个参数,就能获取并执行该属性对应的副作用函数

完整代码

下面是具体实现,也是理解Vue3响应系统的关键。虽然看起来有100多行,但是核心代码不到20行

/**
 * @desc 基础版响应式数据的实现
 */

/**
 * @Type 属性值为任意类型的对象
 */
interface AnyObj {
    [prop: string | symbol]: any
}

/**
 * @Type 副作用函数
 */
type EffectFn = () => any

/**
 * 用一个WeakMap记录响应式对象的属性变化后, 需要执行的副作用函数
 * 具体映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
 */
const bucket: WeakMap<AnyObj, Map<string | symbol, Set<EffectFn>>> = new WeakMap()

// 通过全局变量, 记录当前的副作用函数
let activeEffect: EffectFn | undefined

/**
 * 注册副作用函数
 */
const effect = (fn: EffectFn) => {
    activeEffect = fn
    fn()
}

// 响应式对象的原始值
const data: AnyObj = {
    ok: true,
    text: 'hello world'
}

// 将原始数据转换为响应式对象
const obj = new Proxy(data, {
    // 在get中收集对象属性对应的副作用函数
    get(target, key) {
        track(target, key)
        return target[key]
    },

    // 在set中触发对象属性对应的副作用函数
    set(target, key, newVal) {
        target[key] = newVal
        trigger(target, key)
        return true
    }
})

// Proxy的 get 中会调用 track 函数, 从而建立 对象属性 到 副作用函数 的映射
const track = (target: AnyObj, key: string | symbol) => {
    /**
     * 从映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 读取 Map<原始对象的属性, Set<该属性对应的副作用函数>>
     */
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    // 要考虑初始化
    if(!depsMap) {
        depsMap = new Map
        bucket.set(target, depsMap)
    }

    /**
     * 从映射关系: Map<原始对象的属性, Set<该属性对应的副作用函数>>
     * 读取 Set<该属性对应的副作用函数>
     */
    let deps = depsMap.get(key)
    // 要考虑初始化
    if (!deps) {
        deps = new Set()
        depsMap.set(key, deps)
    }
    // 将副作用函数存入 Set<该属性对应的副作用函数>
    deps.add(activeEffect)
}

// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
    /**
     * 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
     */
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
}

// 注册一个的副作用函数
effect(() => {
    const text = obj.text
    console.log('effect1', text)
})

// 注册一个的副作用函数
effect(() => {
    const text = `${obj.text} ---`
    console.log('effect2', text)
})

// 修改响应式数据, 会触发obj.text对应的副作用函数
obj.text = 'foo'

分支切换与cleanup

分支切换带来的问题

我们已经实现了一个较基础的响应式数据,但存在一个缺陷:副作用函数内部可能存在条件判断逻辑,有些在原本会执行的响应式数据属性访问,由于条件判断状态的变化,导致不再会被访问。但由于bucket已经建立了依赖关系,当修改该响应式数据属性后,副作用函数还是会被执行,这个执行是多余的。

可以用这个例子辅助理解,当obj.ok设置为false后,obj.text不会再被读取,按照预期我们不希望修改obj.text的值后,触发该副作用函数。但是由于先前已经建立了依赖关系,会导致副作用函数被执行。

// 注册一个副作用函数
effect(() => {
    // obj.ok的初始值是true,obj.text会被读取,会在bucket中建立obj.text对该副作用函数的依赖
    const text = obj.ok ? obj.text : 'not'
    console.log('effect1', text)
})

obj.ok = false
// obj.flag已经是false, 上述副作用函数的 三元运算 不会进入ture分支从而读取 obj.text
// 但是修改 obj.text, 副作用还是会执行, 这是不必要的
obj.text = 'foo'

解决思路

这就是分支切换带来的问题,解决方案如下:

  1. 除了之前bucket建立的属性副作用函数的映射,还需要建立副作用函数收集依赖数据的集合的映射。换个说法:bucket的数据结构是WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>,要建立副作用函数Set<该属性对应的副作用函数>的映射。
  2. 副作用函数执行前,先清除该副作用函数在所有收集依赖数据的集合中的存在,然后执行副作用函数,这样又能重新收集对副作用函数依赖

具体实现

对之前响应式数据的基础实现的代码稍加修改就能实现:

修改effect函数

  1. 修改effect函数:
    • effect内部用一个叫做effectFn的函数对副作用函数进行封装。并给effectFn添加一个叫做deps属性,其值为数组,数组的元素是收集该副作用函数依赖集合
    • 先遍历effectFn.deps中的依赖集合,这些依赖集合移除对effectFn的收集。这个逻辑封装在需要新增的函数cleanup中。
    • 执行effectFn,触发副作用并重新收集依赖

这么说有点绕,看代码比较好理解:

/**
 * 遍历所有收集了 effectFn 的依赖集合, 在每个依赖集合中都将 effectFn 移除
 */
const cleanup = (effectFn: EffectFn) => {
    for(const deps of effectFn.deps) {
        deps.delete(effectFn)
    }
    // 重置 effectFn.deps 数组长度
    effectFn.deps.length = 0
}

/**
 * 注册副作用函数
 */
const effect = (fn: () => any) => {
    const effectFn = (() => {
        // 清除 effectFn 在所有依赖集合中的存在
        cleanup(effectFn)
        activeEffect = effectFn
        fn()
    }) as unknown as EffectFn

    // effectFn.deps用来存储所有与effectFn相关联的依赖集合
    effectFn.deps = [] as Set<EffectFn>[]

    // 执行副作用函数
    effectFn()
}

修改track函数

2.修改track函数,实现effectFn.deps依赖集合的记录。只需在track函数最后加一行代码:

const track = (target: AnyObj, key: string | symbol) => {
    /**
     * 从映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 读取 Map<原始对象的属性, Set<该属性对应的副作用函数>>
     */
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    // 要考虑初始化
    if(!depsMap) {
        depsMap = new Map
        bucket.set(target, depsMap)
    }

    /**
     * 从映射关系: Map<原始对象的属性, Set<该属性对应的副作用函数>>
     * 读取 Set<该属性对应的副作用函数>
     */
    let deps = depsMap.get(key)
    // 要考虑初始化
    if (!deps) {
        deps = new Set()
        depsMap.set(key, deps)
    }
    // 将副作用函数存入 Set<该属性对应的副作用函数>
    deps.add(activeEffect)

    /**
     * @新增代码 将deps添加到 activeEffect.deps中
     */
    activeEffect.deps.push(deps)
}

新的问题:死循环

这样看来好像已经解决分支切换问题了,但是实际运行代码我们会发现程序进入死循环。问题出在 trigger函数

// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
    /**
     * 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
     */
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
}

trigger中的最后一行代码effects && effects.forEach(fn => fn())是问题所在:

  • forEach里面的fn就是effect函数中的effectFneffectFn会调用cleanup清除自身在effects中的存在,但是effectFn内部接下来会执行副作用函数,导致effects又添加effectFn
  • 总的来说就是:effects中刚被清除effectFn,马上又被添加effectFn,形成了死循环

解决死循环问题

我们通过构造一个新的Set来解决这个问题:

// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
    /**
     * 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
     */
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)

    const effectsToRun = new Set(effects) // 新增
    effectsToRun.forEach(effectFn => effectFn()) // 新增
    // effects && effects.forEach(fn => fn()) 删除
}

完整代码

至此,分支切换问题解决。完整代码如下:

/**
 * @Type 属性值为任意类型的对象
 */
interface AnyObj {
    [prop: string | symbol]: any
}

/**
 * @Type 副作用函数
 */
interface EffectFn extends Function {
    // EffectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
    deps: Set<EffectFn>[];
}


/**
 * 用一个WeakMap记录响应式对象的属性变化后, 需要执行的副作用函数
 * 具体映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
 */
const bucket: WeakMap<AnyObj, Map<string | symbol, Set<EffectFn>>> = new WeakMap()

// 通过全局变量, 记录当前的副作用函数
let activeEffect: EffectFn | undefined

/**
 * 注册副作用函数
 */
const effect = (fn: () => any) => {
    const effectFn = (() => {
        // 清除 effectFn 在所有依赖集合中的存在
        cleanup(effectFn)
        activeEffect = effectFn
        fn()
    }) as unknown as EffectFn

    // effectFn.deps用来存储所有与effectFn相关联的依赖集合
    effectFn.deps = [] as Set<EffectFn>[]

    // 执行副作用函数
    effectFn()
}

/**
 * 遍历所有收集了 effectFn 的依赖集合, 在每个依赖集合中都将 effectFn 移除
 */
const cleanup = (effectFn: EffectFn) => {
    for(const deps of effectFn.deps) {
        deps.delete(effectFn)
    }
    // 重置 effectFn.deps 数组长度
    effectFn.deps.length = 0
}

// 响应式对象的原始值
const data: AnyObj = {
    ok: true,
    text: 'hello world'
}

// 将原始数据转换为响应式对象
const obj = new Proxy(data, {
    // 在get中收集对象属性对应的副作用函数
    get(target, key) {
        track(target, key)
        return target[key]
    },

    // 在set中触发对象属性对应的副作用函数
    set(target, key, newVal) {
        target[key] = newVal
        trigger(target, key)
        return true
    }
})

// Proxy的 get 中会调用 track 函数, 从而建立 对象属性 到 副作用函数 的映射
const track = (target: AnyObj, key: string | symbol) => {
    /**
     * 从映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 读取 Map<原始对象的属性, Set<该属性对应的副作用函数>>
     */
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    // 要考虑初始化
    if(!depsMap) {
        depsMap = new Map
        bucket.set(target, depsMap)
    }

    /**
     * 从映射关系: Map<原始对象的属性, Set<该属性对应的副作用函数>>
     * 读取 Set<该属性对应的副作用函数>
     */
    let deps = depsMap.get(key)
    // 要考虑初始化
    if (!deps) {
        deps = new Set()
        depsMap.set(key, deps)
    }
    // 将副作用函数存入 Set<该属性对应的副作用函数>
    deps.add(activeEffect)

    /**
     * @新增代码 将deps添加到 activeEffect.deps中
     */
    activeEffect.deps.push(deps)
}

// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
    /**
     * 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
     */
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)

    const effectsToRun = new Set(effects) // 新增
    effectsToRun.forEach(effectFn => effectFn()) // 新增
    // effects && effects.forEach(fn => fn()) 删除
}

// 注册一个的副作用函数
effect(() => {
    const text = obj.ok ? obj.text : 'not'
    console.log('effect1', text)
})

obj.ok = false
// obj.flag已经是false, 上述副作用函数的 三元运算 不会进入ture分支从而读取 obj.text
// 修改 obj.text, 副作用不会执
obj.text = 'foo'

嵌套的effect与effect栈

背景

effect可以发生嵌套Vue.js组件的渲染函数render是在一个effect中执行的,组件嵌套意味着effect嵌套。比如一个叫Foo的组件嵌套了一个叫Bar的组件:

<Foo>
    <Bar/>
</Foo>

对应的effect嵌套就是:

effect(() => {
    Foo.render()
    effect(() => {
        Bar.render()
    })
})

理解问题

用下面代码来说明effect嵌套带来的问题:

// 原始数据
const data: AnyObj = {
    foo: true,
    bar: true
}

// 代理对象
const obj = new Proxy(data, { /* ... */ })

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

obj.foo = false

再回顾一下effect函数中的这行代码:activeEffect = effectFn

/**
 * 注册副作用函数
 */
function effect (fn: () => any) {
    const effectFn = (() => {
        // 清除 effectFn 在所有依赖集合中的存在
        cleanup(effectFn)
        activeEffect = effectFn
        fn()
    }) as unknown as EffectFn

    // effectFn.deps用来存储所有与effectFn相关联的依赖集合
    effectFn.deps = [] as Set<EffectFn>[]

    // 执行副作用函数
    effectFn()
}

现在能看出问题了,当上面实例代码执行到temp1 = obj.foo时,变量activeEffect的值还是EffectFn2。这会导致proxyget中,obj.foo收集的副作用函数是EffectFn2,也就是说修改obj.foo的值,会导致EffectFn2的执行。但我们的预期是让EffectFn1执行,因为temp2 = obj.bar对应的副作用函数是EffectFn1

问题解决思路

通过一个叫effectStack来维护activeEffect值的变化:

  • effectFn的内部,执行fn()之前,把activeEffect赋值effectFn的同时,还要把effectFn入栈effectStack
  • effectFn的内部,在执行fn()之后,对effectStack弹出栈顶元素,这样effectStack的栈顶元素就变回调用当前effectFn的外层effectFn了。把activeEffect赋值为新的栈顶元素,这样activeEffect的值就变成了外层effectFn

通过维护effectStack,就解决了effect嵌套导致activeEffect指向不正确的问题。

具体实现

代码中通过// 新增注释标记了改动之处:

// 使用stack维护`activeEffect`值的变化
const effectStack: EffectFn[] = [] // 新增

/**
 * 注册副作用函数
 */
const effect = (fn: () => any) => {
    const effectFn = (() => {
        // 清除 effectFn 在所有依赖集合中的存在
        cleanup(effectFn)
        activeEffect = effectFn
        // 执行 fn() 之前,把 effectFn 入栈 effectStack
        effectStack.push(effectFn) // 新增
        fn()
        // 执行 fn() 之后,将当前 effectFn 出栈,这样栈顶元素就变成了调用当前effectFn的上层effectFn
        effectStack.pop() // 新增
        activeEffect = effectStack[effectStack.length - 1] // 新增
    }) as unknown as EffectFn

    // effectFn.deps用来存储所有与effectFn相关联的依赖集合
    effectFn.deps = [] as Set<EffectFn>[]

    // 执行副作用函数
    effectFn()
}

避免无限递归循环

理解问题

如果副作用函数中存在对同一个响应式对象属性读取赋值,就会发生无限递归循环。 比如这个例子:

const data = {
    foo: 1
}

const obj = new Proxy(data, { /* ... */ })

effect(function effectFn1() {
    obj.foo = obj.foo + 1
})

obj.foo读取会触发track,接着对obj.foo赋值会触发trigger,这就导致effectFn1无限递归调用自己,产生函数的调用栈溢出

解决问题

这个问题很好解决:如果trigger中执行的副作用函数activeEffect相同,就不执行。代码如下:

const trigger = (target: AnyObj, key: string | symbol) => {
    /**
     * 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
     */
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)

    const effectsToRun = new Set(effects)
    effectsToRun.forEach(effectFn => {
        // 如果 trigger 中执行的 副作用函数 与 activeEffect相同,就不执行
        if(effectFn !== activeEffect) { // 新增
            effectFn()
        }
    })
}

调度执行

可调度性是响应式系统的关键特性,可调度指的是在tigger触发副作用函数重新执行时,有能力决定副作用函数的执行时机、次数以及方式。

举例说明

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

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

这段代码的输出顺序是

1
2
'end'

如果我们希望改变打印顺序为1 'end' 2,并且不调整代码。这就需要响应系统支持调度,我们希望这样使用调度:

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

effect(
    () => {
        console.log(obj.foo)
    },
    // options 用于配置effect的选项
    {
        // 指定调度器的实现
        scheduler(fn) {
            setTimeout(fn)
        }
    }
)

obj.foo++
console.log('end')

effect增加第二个参数,用于设置effect的选项,其中scheduler配置项就是调度器。在这个例子中,调度器通过setTimeout实现了obj.foo++的异步打印,从而让打印顺序变为1 'end' 2

设计调度器

明确用法后,实现方案也很明显了:

  1. effect函数增加一个options参数,类型是对象,表示配置项
  2. effect中,把options挂载到副作用函数上,即:effctFn.options = options
  3. trigger中,执行副作用函数时,判断一下这个副作用函数有没有调度器,即effctFn?.options?.scheduler

具体实现

修改effecttrigger

/**
 * 注册副作用函数
 */
const effect = (fn: () => any, options: EffectOptions = {}) => { // 新增代码:添加 options 参数
    const effectFn = (() => {
        // 清除 effectFn 在所有依赖集合中的存在
        cleanup(effectFn)
        activeEffect = effectFn
        // 执行 fn() 之前,把 effectFn 入栈 effectStack
        effectStack.push(effectFn)
        fn()
        // 执行 fn() 之后,将当前 effectFn 出栈,这样栈顶元素就变成了调用当前effectFn的上层effectFn
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }) as unknown as EffectFn

    //
    effectFn.options = options

    // effectFn.deps用来存储所有与effectFn相关联的依赖集合
    effectFn.deps = [] as Set<EffectFn>[]

    // 执行副作用函数
    effectFn()
}

// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
    /**
     * 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
     */
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)

    const effectsToRun = new Set(effects)
    effectsToRun.forEach(effectFn => {
        // 新增:如果 副作用函数存在调度器,就通过调度器执行副作用函数
        if(effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
}

完整代码

/**
 * @desc 基础版响应式数据的实现
 */

/**
 * @Type 属性值为任意类型的对象
 */
interface AnyObj {
    [prop: string | symbol]: any
}

// effect函数的配置项参数
interface EffectOptions {
    // 调度器
    scheduler?: (effectFn: EffectFn) => any
}

/**
 * @Type 副作用函数
 */
interface EffectFn extends Function {
    // EffectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
    deps: Set<EffectFn>[];

    options: EffectOptions
}

/**
 * 用一个WeakMap记录响应式对象的属性变化后, 需要执行的副作用函数
 * 具体映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
 */
const bucket: WeakMap<AnyObj, Map<string | symbol, Set<EffectFn>>> = new WeakMap()

// 通过全局变量, 记录当前的副作用函数
let activeEffect: EffectFn | undefined

// 使用stack维护`activeEffect`值的变化
const effectStack: EffectFn[] = []

/**
 * 注册副作用函数
 */
const effect = (fn: () => any, options: EffectOptions = {}) => { // 新增代码:添加 options 参数
    const effectFn = (() => {
        // 清除 effectFn 在所有依赖集合中的存在
        cleanup(effectFn)
        activeEffect = effectFn
        // 执行 fn() 之前,把 effectFn 入栈 effectStack
        effectStack.push(effectFn)
        fn()
        // 执行 fn() 之后,将当前 effectFn 出栈,这样栈顶元素就变成了调用当前effectFn的上层effectFn
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }) as unknown as EffectFn

    //
    effectFn.options = options

    // effectFn.deps用来存储所有与effectFn相关联的依赖集合
    effectFn.deps = [] as Set<EffectFn>[]

    // 执行副作用函数
    effectFn()
}

/**
 * 遍历所有收集了 effectFn 的依赖集合, 在每个依赖集合中都将 effectFn 移除
 */
const cleanup = (effectFn: EffectFn) => {
    for(const deps of effectFn.deps) {
        deps.delete(effectFn)
    }
    // 重置 effectFn.deps 数组长度
    effectFn.deps.length = 0
}

// 原始值
const data: AnyObj = {foo: 1}

// 将原始数据转换为响应式对象
const obj = new Proxy(data, {
    // 在get中收集对象属性对应的副作用函数
    get(target, key) {
        track(target, key)
        return target[key]
    },

    // 在set中触发对象属性对应的副作用函数
    set(target, key, newVal) {
        target[key] = newVal
        trigger(target, key)
        return true
    }
})

// Proxy的 get 中会调用 track 函数, 从而建立 对象属性 到 副作用函数 的映射
const track = (target: AnyObj, key: string | symbol) => {
    /**
     * 从映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 读取 Map<原始对象的属性, Set<该属性对应的副作用函数>>
     */
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    // 要考虑初始化
    if(!depsMap) {
        depsMap = new Map
        bucket.set(target, depsMap)
    }

    /**
     * 从映射关系: Map<原始对象的属性, Set<该属性对应的副作用函数>>
     * 读取 Set<该属性对应的副作用函数>
     */
    let deps = depsMap.get(key)
    // 要考虑初始化
    if (!deps) {
        deps = new Set()
        depsMap.set(key, deps)
    }
    // 将副作用函数存入 Set<该属性对应的副作用函数>
    deps.add(activeEffect)

    // 将deps添加到 activeEffect.deps中
    activeEffect.deps.push(deps)
}

// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
    /**
     * 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
     */
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)

    const effectsToRun = new Set(effects)
    effectsToRun.forEach(effectFn => {
        // 新增:如果 副作用函数存在调度器,就通过调度器执行副作用函数
        if(effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
}

effect(
    () => {
        console.log(obj.foo)
    },
    // options 用于配置effect的选项
    {
        // 指定调度器的实现
        scheduler(fn) {
            setTimeout(fn)
        }
    }
)

obj.foo++
console.log('end')

调度的意义

Vue3 调度执行机制的主要目的是优化组件更新的性能和响应速度。具体包括以下几个关键目的:

  1. 避免重复更新:合并多次状态变化,确保组件只进行必要的更新,减少不必要的重复渲染。
  2. 批量更新:在同一个事件循环内的多次状态变化,不会立即触发更新,而是等所有变化完成后,再进行统一的批量更新,减少 DOM 操作频率。
  3. 优先级调度:为不同的更新任务设置优先级,确保高优先级任务优先执行,提升用户体验的流畅性。

使用调度避免重复更新

如果一个状态多次更新,我们希望只执行最后一次的副作用函数。以下面代码为例:

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

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

obj.foo++
obj.foo++

obj.foo会执行两次自增,打印结果是:

1
2
3

我们不关心过渡状态,打印2是多余的副作用函数执行,我们期望的打印是:

1
3

为了实现这一目标,基于调度器,利用微任务就能解决,代码如下:

// 定义一个任务队列
const jobQueue:Set<EffectFn> = new Set()
// 利用 Promise.resolve().then 可以将任务添加到微任务队列
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: EffectFn) {
            // 每次调度时,将副作用函数添加到jobQueue
            jobQueue.add(fn)
            // 刷新任务队列
            flushJob()
        }
    }
)

obj.foo++
obj.foo++

computed 与 lazy

effect函数加上lazy功能

这里要实现的目标是,给effectoptions参数添加一个lazy配置项,如果lazytrue,effect函数会不直接调用副作用函数,而是返回副作用函数。代码如下:

const effect = <T>(fn: () => T, options: EffectOptions = {}) => {
    const effectFn = (() => {
        // 清除 effectFn 在所有依赖集合中的存在
        cleanup(effectFn)
        activeEffect = effectFn
        // 执行 fn() 之前,把 effectFn 入栈 effectStack
        effectStack.push(effectFn)
        const res = fn() // @新增
        // fn() 删除
        // 执行 fn() 之后,将当前 effectFn 出栈,这样栈顶元素就变成了调用当前effectFn的上层effectFn
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
        return res // @新增
    }) as unknown as EffectFn

    effectFn.options = options

    // effectFn.deps用来存储所有与effectFn相关联的依赖集合
    effectFn.deps = [] as Set<EffectFn>[]

    // 如果 lazy 为 false, 返回 执行 effectFn()
    // 如果 lazy 为 true, 返回 effectFn
    if(!options.lazy) { // @新增
        // 执行副作用函数
        effectFn()
    } else {
        // 返回 effectFn
        return effectFn
    }
}

值得注意的是,这里不仅增加了lazy对应的if条件语句,effectFn也做了修改:effectFn是对原初副作用函数fn的包装,effectFn需要返回原初副作用函数的返回值。

lazy视作getter

通过lazy,可以把effect函数的返回视作一个getter,这个getter可以返回一些有趣的内容:

// effect 返回的 effectFn 可以视为一个 getter
const effectFn = effect(
    () => obj.foo + obj.bar,
    {
        lazy: true
    }
)

// 获取 getter 的返回值
const value = effectFn()

利用lazy实现computed

利用lazy和对象的get属性访问器,就实现了一个基本的computed

function computed<T>(getter: (...args:  any[] ) => T): {value: T} {
    const effectFn = effect(getter, { lazy: true })
    const obj = {
        get value() {
            return effectFn() as T
        }
    }
    return obj
}

const data = { foo: 1, bar: 2}
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => obj.foo + obj.bar)

console.log(sumRes.value) // 3

接着通过一个dirty标识来实现computed的对结果缓存功能

function computed<T>(getter: (...args:  any[] ) => T): {value: T} {
    // 缓存上一次计算的结果
    let value: T // @新增
    // 使用dirty来标记是否需要重新计算值, true意味"脏",需要重新计算
    let dirty = true // @新增

    const effectFn = effect(getter, { lazy: true })
    const obj = {
        // @重写get
        get value() {
            // "脏"时需要重新计算结果并缓存
            if(dirty) {
                // 缓存结果到dirty
                value = effectFn() as T
                // dirty 设置为 false,下一次访问可以直接使用 value 的缓存值
                dirty = false
            }
            return value
        }
    }
    return obj
}

const data = { foo: 1, bar: 2}
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => {
    console.log('运行计算')
    return obj.foo + obj.bar
})

console.log(sumRes.value) // 3
// 多亏computed的缓存功能,多次读取 sumRes.value,也不会发生重复计算
console.log(sumRes.value) // 3

然而上面的代码有一个问题,考虑这种情况:当obj.foo自增后,sumRes.value应该要变成4,但实际打印输出来是3

const data = { foo: 1, bar: 2}
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => {
    console.log('运行计算')
    return obj.foo + obj.bar
})

console.log(sumRes.value) // 3
obj.foo++
console.log(sumRes.value) // 打印出来依旧为3,预期是4

问题出在obj.foo自增后,dirty的值没有正确重置为true,通过在scheduler中重置dirty解决这个问题:

function computed<T>(getter: (...args:  any[] ) => T): {value: T} {
    // 缓存上一次计算的结果
    let value: T

    // 使用dirty来标记是否需要重新计算值, true意味"脏",需要重新计算
    let dirty = true

    const effectFn = effect(getter, {
        lazy: true,
        // 添加调度器 ,在调度器中重置 dirty 为 true
        scheduler() {
            dirty = true
        }
    })
    const obj = {
        get value() {
            // "脏"时需要重新计算结果并缓存
            if(dirty) {
                // 缓存结果到dirty
                value = effectFn() as T
                // dirty 设置为 false,下一次访问可以直接使用 value 的缓存值
                dirty = false
            }
            return value
        }
    }
    return obj
}

computed的设计,到这里已经快完善了,但还有一个缺陷:如果有另一个effect调用了计算属性的值,当计算属性的值发生变化,effect中的副作用函数不会重新执行。以这段代码为例:

const sumRes = computed(() => {
    return obj.foo + obj.bar
})

effect(() => {
    // 在副作函数中读取计算属性的值
    console.log(sumRes.value)
})

// 修改obj.foo不会导致副作用函数中的console重新执行
obj.foo++

这个问题的本质两个effect的嵌套。但是内层的effect设置了lazy。有两个原因造成了这个问题:

  • 如果是两个没设置lazytrueeffect发生嵌套,内层effect副作用函数执行,不会强行伴随外层的effect副作用函数执行。可以类比这种情况:子组件视图更新,父组件视图不一定要更新。但是计算的情况不太一样,计算属性的值(在上面的例子中对应sumRes.value)会被外层effect的副作用函数使用,而sumRes本身只是一个普通对象,不会收集外层effect副作用函数
  • 计算属性的值发生变化时,由于lazy的原因,如果不主动读取计算属性的值,是不会触发副作用函数的。

所以我们通过手动调用track和trigger来解决这两个问题:

  • get属性访问器中调用track收集外层effect副作用函数的依赖
  • scheduler中调用trigger,触发外层effect副作用函数的执行
function computed<T>(getter: (...args:  any[] ) => T): {value: T} {
    // 缓存上一次计算的结果
    let value: T //

    // 使用dirty来标记是否需要重新计算值, true意味"脏",需要重新计算
    let dirty = true //

    const effectFn = effect(getter, {
        lazy: true,
        // 添加调度器 ,在调度器中重置 dirty 为 true
        scheduler() {
            if(!dirty) {
                dirty = true
                // 计算属性发生变化时,手动调用trigger触发响应
                trigger(obj, 'value') // @新增
            }
        }
    })
    const obj = {
        get value() {
            // "脏"时需要重新计算结果并缓存
            if(dirty) {
                // 缓存结果到dirty
                value = effectFn() as T
                // dirty 设置为 false,下一次访问可以直接使用 value 的缓存值
                dirty = false
            }
            // 当读取 value 时,手动调用 track 函数进行跟踪
            track(obj, 'value') // @新增
            return value
        }
    }
    return obj
}

完整代码

这是解决了上述问题之后,实现computed的完整代码

/**
 * @Type 属性值为任意类型的对象
 */
interface AnyObj {
    [prop: string | symbol]: any
}

// effect函数的配置项参数
interface EffectOptions {
    // 调度器
    scheduler?: (effectFn: EffectFn) => any
    lazy?: boolean
}

/**
 * @Type 副作用函数
 */
interface EffectFn extends Function {
    // EffectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
    deps: Set<EffectFn>[];

    options: EffectOptions
}

/**
 * effect的返回类型
 * 如果 EffectOptions中的 lazy 是 true,返回类型就是EffectFn,否则是 undefined
 */
type EffectReturn<K extends EffectOptions> = K["lazy"] extends true ? EffectFn : undefined;

/**
 * 用一个WeakMap记录响应式对象的属性变化后, 需要执行的副作用函数
 * 具体映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
 */
const bucket: WeakMap<AnyObj, Map<string | symbol, Set<EffectFn>>> = new WeakMap()

// 通过全局变量, 记录当前的副作用函数
let activeEffect: EffectFn | undefined

// 使用stack维护`activeEffect`值的变化
const effectStack: EffectFn[] = []

/**
 * 注册副作用函数
 */
const effect = <T, K extends EffectOptions>(
    fn: () => T, options: Partial<K> = {}
): EffectReturn<K> => {
    const effectFn = (() => {
        // 清除 effectFn 在所有依赖集合中的存在
        cleanup(effectFn)
        activeEffect = effectFn
        // 执行 fn() 之前,把 effectFn 入栈 effectStack
        effectStack.push(effectFn)
        const res = fn() //
        // fn() @删除

        // 执行 fn() 之后,将当前 effectFn 出栈,这样栈顶元素就变成了调用当前effectFn的上层effectFn
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
        return res
    }) as unknown as EffectFn

    effectFn.options = options

    // effectFn.deps用来存储所有与effectFn相关联的依赖集合
    effectFn.deps = [] as Set<EffectFn>[]

    // 如果 lazy 为 false, 返回 执行 effectFn()
    // 如果 lazy 为 true, 返回 effectFn
    if(!options.lazy) {
        // 执行副作用函数
        effectFn()
        return undefined as EffectReturn<K>;
    } else {
        // 返回 effectFn
        return effectFn as EffectReturn<K>
    }
}

/**
 * 遍历所有收集了 effectFn 的依赖集合, 在每个依赖集合中都将 effectFn 移除
 */
const cleanup = (effectFn: EffectFn) => {
    for(const deps of effectFn.deps) {
        deps.delete(effectFn)
    }
    // 重置 effectFn.deps 数组长度
    effectFn.deps.length = 0
}

// 原始值
const data: AnyObj = { foo: 1, bar: 2}

// 将原始数据转换为响应式对象
const obj = new Proxy(data, {
    // 在get中收集对象属性对应的副作用函数
    get(target, key) {
        track(target, key)
        return target[key]
    },

    // 在set中触发对象属性对应的副作用函数
    set(target, key, newVal) {
        target[key] = newVal
        trigger(target, key)
        return true
    }
})

// Proxy的 get 中会调用 track 函数, 从而建立 对象属性 到 副作用函数 的映射
const track = (target: AnyObj, key: string | symbol) => {
    /**
     * 从映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 读取 Map<原始对象的属性, Set<该属性对应的副作用函数>>
     */
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    // 要考虑初始化
    if(!depsMap) {
        depsMap = new Map
        bucket.set(target, depsMap)
    }

    /**
     * 从映射关系: Map<原始对象的属性, Set<该属性对应的副作用函数>>
     * 读取 Set<该属性对应的副作用函数>
     */
    let deps = depsMap.get(key)
    // 要考虑初始化
    if (!deps) {
        deps = new Set()
        depsMap.set(key, deps)
    }
    // 将副作用函数存入 Set<该属性对应的副作用函数>
    deps.add(activeEffect)

    // 将deps添加到 activeEffect.deps中
    activeEffect.deps.push(deps)
}

// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
    /**
     * 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
     * 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
     */
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)

    const effectsToRun = new Set(effects)
    effectsToRun.forEach(effectFn => {
        // 如果 副作用函数 存在 调度器,就通过调度器执行副作用函数
        if(effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
}

// 定义一个任务队列
const jobQueue:Set<EffectFn> = new Set()
// 利用 Promise.resolve().then 可以将任务添加到微任务队列
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
    })
}

function computed<T>(getter: (...args:  any[] ) => T): {value: T} {
    // 缓存上一次计算的结果
    let value: T

    // 使用dirty来标记是否需要重新计算值, true意味"脏",需要重新计算
    let dirty = true //

    const effectFn = effect(getter, {
        lazy: true,
        // 添加调度器 ,在调度器中重置 dirty 为 true
        scheduler() {
            if(!dirty) {
                dirty = true
                // 计算属性发生变化时,手动调用trigger触发响应
                trigger(obj, 'value')
            }
        }
    })
    const obj = {
        get value() {
            // "脏"时需要重新计算结果并缓存
            if(dirty) {
                // 缓存结果到dirty
                value = effectFn() as T
                // dirty 设置为 false,下一次访问可以直接使用 value 的缓存值
                dirty = false
            }
            // 当读取 value 时,手动调用 track 函数进行跟踪
            track(obj, 'value')
            return value
        }
    }
    return obj
}

const sumRes = computed(() => {
    return obj.foo + obj.bar
})

effect(() => {
    // 在副作函数中读取计算属性的值
    console.log(sumRes.value)
})

// 会打印出预期结果 4
obj.foo++

watch的实现原理

初步实现

watch的本质就是观测一个数据,数据发生变化时,通知并执行相应回调函数

这是有两个使用watch的例子,他们的区别在于第一个参数的不同:

  • watch第一个参数是() => obj.foo的情况,watch会监听obj.foo的变化
  • watch第一个参数是obj的情况,watch会深度监听obj的所有属性值
watch(
    () => obj.foo,
    (oldValue, newValue) => {
        console.log(oldValue, newValue)
    }
)

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

watch的实现,直接看代码就能理解了:

// 封装一个能遍历对象属性的函数
function traverse(value: any, seen = new Set()) {
    // 如果要读取的数据是原始值或者已经被读取过,那么什么都不用做
    if(typeof value !== 'object' || value === null || seen.has(value)) return
    // 标记数据的读取状态,避免循环引用导致的死循环
    seen.add(value)
    // 先简单假设value是一个对象
    for (const k in value) {
        traverse(value[k], seen)
    }

    return value
}

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

    // 定义旧值和新值
    let oldValue: any
    let newValue: any

    // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到effectFn 中,以便后续调用
    const effectFn = effect(
        () => getter(),
        {
            lazy: true,
            scheduler() {
                newValue = effectFn()
                cb(newValue, oldValue)
                oldValue = newValue
            }
        }
    )

    // 手动调用副作用函数,effectFn()返回的就是旧值
    oldValue = effectFn()
}

这里有两个关键点:

  • traverse函数:递归遍历对象属性,确保它们被响应式系统跟踪,同时还要注意循环引用问题。
  • watch函数做了两件事情:
    • 处理 source :如果是函数,直接使用;如果是对象,使用traverse函数进行深度遍历。
    • 注册一个lazy副作用函数,并在scheduler中维护oldValuenewValue

更完善的实现

上面的watch实现是最基本的形态,其实还有立即执行watch解决watch的竞态问题需要实现,暂时先到这里。

后续

到这里响应系统的实现已经初步完成了,但实际的实现比这复杂的多,比如拦截和追踪for in循环、对数组的代理、深响应、浅响应,都是要解决的问题。