4.0 响应系统的作用与实现

338 阅读15分钟

刚刚开始未完待续 。。。

响应系统是 Vuejs 的重要组成部分,在学习响应系统之前要搞明响应式数据和副作用函数具体是什么。然后通过一个基础的响应式数据实现来开启本篇的学习。期间会面临着解决硬编码副作用函数、代码分支切换导致遗留副作用函数、属性自增导致无限递归等问题,还有如何实现副作用函数调度执行,以及计算属性 Computed 和 Watch 函数的实现原理。

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

副作用函数是指函数执行过程中产生的除其预期输出以外的效果。副作用可以包括但不限于以下几种情况:修改输入参数(引用类型)、修改全局变量、I/O 操作等。

在下面的代码显示,在 effect 函数中通过全局的 document 对象提供的 API 修改了 body 的内容文本。但这样的修改会直接影响其它读取 body 内容文本函数的结果。这种现象就是典型的副作用,effect 函数就被称为副作用函数:

function effect() {
    document.body.innerText = 'hello vue3'
}

在下面的代码显示,当第 7 行的 effect 函数执行时会将 body 的内容文本设置为 data.text 的值,当第 9 行执行改变 data.text 的属性时,我们希望 effect 函数可以重新执行:

const data = { text: 'hello world' }

function effect() {
    document.body.innerText = data.text
}

effect()

data.text = 'hello vuejs'

如果一个数据的属性发生改变时可以驱动该属性相关的副作用函数自动重新执行,那么这个数据被称为响应式数据。

4.2 响应式数据的基本实现

将普通数据变成响应式数据的底层基础是要实现对数据读取设置操作的拦截,正如下图所示,当 data.text 被读取时将副作用函数存储到“桶”里,当 data.text 被设置/更新时,在将“桶”里的副作用函数取出并执行。

如何拦截一个对象属性的读取和设置操作,这在 ES2015 之前,只能使用 Object.defineProperty 函数实现,这也是 Vue.js 2 采用的原因和方式。在 ES2015+ 中,可以通过代理对象 Proxy 来实现,Vue.js 3 也是基于此实现了响应系统的重构。

在下面的代码中显示,在一个将普通数据转换为响应式数据的 reactive 函数中返回一个 Proxy 对象,在这个对象的 getter 属性中通过硬编码的方式向“桶”中存储全局中名为 effect 的副作用函数,并在 setter 属性中通过遍历“桶”中的副作用函数并执行。

const bucket = new Set() // 定义用来存储副作用函数的桶,利用 Set 结构去重

// 将一个数据转换为响应式数据
function reactive(data) {
    return new Proxy(data, {
        get: (target, prop) => {
            // 此处通过硬编码形式存储全局中名为 effect 的副作用函数
            bucket.add(effect)
            // 完成 getter 的基础功能,返回属性值
            return target[prop]
        },
        set: (target, prop, newVal) => {
            // 完成 setter 的基础功能,更新属性值
            target[prop] = newVal
            bucket.forEach(fn => fn())
        },
    })
}

在下面的代码中显示,在上一节的代码案例中使用 reactive 函数将普通数据转换为响应式数据,在 1 秒钟后 data.text 属性被修改,观察到 effect 函数重新执行,页面同时渲染为最新的 hello vuejs 内容文本。完成了基本的响应式数据:

const data = reactive({ text: 'hello world' })

function effect() {
    document.body.innerText = data.text
}

effect()

setTimeout(() => {
    data.text = 'hello vuejs'
}, 1000)

补充 :Vue.js 3 中响应系统的一大改进就是从 Object.defineProperty 转向使用 ES6 的 Proxy 对象来实现数据的响应性。其升级优点包括以下几个方面:

  1. 更全面的拦截:Proxy 可以拦截更多的操作类型,如删除属性(deleteProperty)、验证属性是否存在(has)、获取属性(get)、设置属性(set)等。使得 Vue.js 可以更全面的控制响应式数据的变化。
  2. 更简洁的代码:使用 Proxy 可以让代码变得更加简洁易读,不用向使用 Object.defineProperty 为每一个属性都单独添加 gettersetter 属性。
  3. 更好的性能:在创建响应式对象时 Proxy 可以做到非侵入式且完整的代理,不需要递归遍历对象的每一个属性来将它们转换为可响应的状态。这将大大减少初始创建响应式对象时的工作量,也避免了在对象在新增属性后需要重新转换的问题。
  4. 数组的变更检测:Object.defineProperty 在处理数组时存在一定的限制,如无法检测到 splicepush 等方法引起的数组变化。而 Proxy 可以通过拦截 set 操作更好的监听数组内部的变化。

4.3 设计一个完善的响应系统

4.3.1 注册副作用函数机制

在上一节实现的 reactive 函数中,通过副作用函数是通过硬编码的形式存储的,如果副作用函数名称一旦修改reactive 将直接失效。为了使这个响应系统更具备灵活性,所以要引入注册副作用函数机制来解决这个问题。

首要声明一个全局变量activeEffect用来存储被注册的副作用函数,接着将应用侧的定义的 effect 函数取消,在框架侧重新编写为注册副作用函数的函数:

// 申明全局变量用来存储被注册的副作用函数
let activeEffect

// 提供 effect 函数用于注册副作用函数
function effect(fn) {
    // 存储被注册的副作用函数
    activeEffect = fn
    // 执行副作用函数
    fn()
}

接着对reactive进行改造,在 getter 实现中通过判断activeEffect变量是否存在,将其存储的副作用函数存入“桶”,这样就解决的硬编码副作用函数的问题:

const bucket = new Set()

function reactive(data) {
    return new Proxy(data, {
        get: (target, prop) => {
            // 判断 activeEffect 是否存在
            if (activeEffect) {
                // 将被注册的副作用函数存入“桶”
                bucket.add(activeEffect)
            }
            return target[prop]
        },
        set: (target, prop, newVal) => {
            target[prop] = newVal
            bucket.forEach(fn => fn())
        },
    })
}

在下面的代码中显示,原应用侧的副作用函数已通过匿名函数的形式注册到了框架侧的 effect 函数,目前的副作用函数使用的相对更灵活了:

const data = reactive({ text: 'hello world' })

// 调用框架侧的 effect 函数,注册一个匿名函数来执行修改 body 内容文本的代码
effect(() => {
    document.body.innerText = data.text
})

setTimeout(() => {
    data.text = 'hello vuejs'
}, 1000)

4.3.2 建立与副作用函数间的联系

在下面的代码中显示,setTimeout 中为声明data时不存在的属性 ok 设置为 true,按正常情况考虑,data.ok 赋值并不会引起 effect 函数执行,但出乎预期的是effect重新执行了:

const data = reactive({ text: 'hello world' })

effect(() => {
    console.log('effect') // 控制台输出此日志表示 副作用函数已执行
    document.body.innerText = data.text
})

setTimeout(() => {
    data.ok = true;
}, 1000)

造成这种现象的原因是,副作用函数与被操作的目标字段之间没有明确的关联关系。无论读取哪个属性,都会将副作用函数存入“桶”,无论设置哪个属性,都会从“桶”中取出并执行。

在下图中设定了“桶”的最新结构,将目标对象、读取的字段与对应的副作用函数建立起了一个树状结构,可以满足一下常见的需求场景:

  1. 在一个副作用函数中读取一个对象的一个属性;
  2. 在多个副作用函数中读取同一个对象的多个属性;
  3. 在一个副作用函数中读取同一个对象的多个属性;
  4. 在多个副作用函数中读取多个对象中的多个属性;

在下面的代码中显示,Set 结构的“桶”由 WeakMap 替换,通过编写 track 函数和 trigger 函数完成了新结构的“桶”的存取:

const bucket = new WeakMap()

// 负责收集并建立 target、key、effect 之间的联系
function track(target, key) {
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    if (!depsMap) {
        // 对应的 target 没有 Value 则需初始化
        depsMap = new Map()
        bucket.set(target, depsMap)
    }

    let deps = depsMap.get(key)
    if (!deps) {
        // 对应的 key 没有副作用函数 则需初始化
        deps = new Set()
        depsMap.set(key, deps)
    }

    deps.add(activeEffect)
}

// 负责从桶中取出并执行对应的副作用函数
function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    deps && deps.forEach(fn => fn())
}

function reactive(data) {
    return new Proxy(data, {
        get: (target, prop) => {
            track(target, prop)
            return target[prop]
        },
        set: (target, prop, newVal) => {
            target[prop] = newVal
            trigger(target, prop)
        },
    })
}

补充:WeakMap 作为新的“桶”(bucket)结构,主要得益于其键值为弱引用类型,这使得键所引用的对象在没有其他强引用的情况下能够被及时地由垃圾回收机制回收,从而释放内存。而在使用 Map 时,如果 Map 中的键是对象,那么这些对象会受到 Map 的强引用保持,即使应用程序层面不再需要这些对象,只要 Map 存活,这些对象就不会被垃圾回收,这可能会导致不必要的内存占用,甚至在极端情况下引发内存溢出的风险。

4.4 分支切换与 cleanup

4.4.1 遗留副作用函数的产生

“分支切换”通常指的是根据一定的条件执行不同的代码路径。正如下面这段应用侧代码所示,第 4 行代码的三元表达式会根据 data.ok 属性值的变化而执行不同的代码分支,这就是所谓的分支切换:

const data = reactive{ ok: true, text: 'hello world' }

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

分支切换可能会产生遗留的副作用函数。当data.ok为默认值 true 时,data.text 属性会被读取,当匿名的副作用函数执行时就会触发data.okdata.text 两个属性的读取操作,此时“桶”中的存储结构为:

data.ok 的属性值为 false 时,data.text 属性值的修改不应该触发 effectFn 函数的重新执行,因为分支切换后并不会对 data.text 属性读取,重复执行副作用函数没有任何意义,但关联关系依然如上图所以没有变化,这样就产生了遗留的副作用函数。

4.4.2 动态建立合理的关联关系

解决产生遗留副作用函数的关键就是动态的建立一个合理的关联关系:

  1. 每次副作用函数被执行时,将此副作用函数从所有与之关联的依赖集合中移除;
  2. 当副作用函数执行完毕后,重新建立新的连接(data.okfalse 时,由于 data.text 并不会读取,所以自然就断开了关联关系)。

为了实现副作用函数从与之关联的依赖集合中移除,我们按下图的方式引入反向依赖收集的能力:

为了让副作用函数支持依赖收集,首先要对 effect 函数做一些重构,为:

function effect(fn) {
    const effectFn = () => {
        activeEffect = effectFn
        fn()
    }
    // 收集所有依赖此副作用函数的依赖集合
    effectFn.deps = []
    effectFn()
}

function track(target, key) {
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    if (!depsMap) {
        depsMap = new Map()
        bucket.set(target, depsMap)
    }

    let deps = depsMap.get(key)
    if (!deps) {
        deps = new Set()
        depsMap.set(key, deps)
    }

    deps.add(activeEffect)
    // 将 activeEffect 加入 deps 后,在将 deps 反向加入 activeEffect
    // 实现双向收集
    activeEffect.deps.push(deps)
}

接着继续重构 effect,完成关联关系的移除和重新建立:

// 遍历反向收集的依赖,删除依赖中存储的当前副作用函数
function cleanup(effectFn) {
    effectFn.deps.forEach(dep => {
        dep.delete(effectFn)
    })
    effectFn.deps.length = 0
}

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn) // 移除关系
        activeEffect = effectFn
        fn()
    }
    // 收集所有依赖此副作用函数的依赖集合
    effectFn.deps = []
    effectFn()
}

此时执行代码会遇到无限循环执行的问题,需要在 trigger 中使用一个新的 Set 集合套用一下,

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    const newDeps = new Set(deps)
    newDeps && newDeps.forEach(fn => fn())
}

补充:在使用 forEach 遍历 Set 集合期间,每个值通常只会访问一次,如果其中一个值被删除且在 forEach 执行期间被重新添加回 Set 集合,则会再次访问该值。

规范原文:Each value is normally visited only once. However, a value will be revisited if it is deleted after it has been visited and then re-added before the forEach call completes. Values that are deleted after the call to forEach begins and before being visited are not visited unless the value is added again before the forEach call completes. New values added after the call to forEach begins are visited.

4.5 嵌套的 effect 与 effect 栈

实际上 Vue.js 的渲染函数就是在一个 effect 函数中执行的,既然如此,那么当遇到组件嵌套的情况时,对应的 effect 函数同样会发生嵌套,但目前的响应系统还不支持嵌套的 effect

在下面的代码中显示,由于 effectFn1 嵌套 effectFn2 函数,所以当我们修改 data.ok 的属性值执行时期望先输出 effectFn2 hello world 接着输出 effectFn1 true

const data = reactive({ ok: true, text: 'hello world' })

effect(function effectFn1() {
    effect(function effectFn2() {
        console.log('effectFn2', data.text)
    })
    console.log('effectFn1', data.ok)
})

data.ok = false;

但实际上当第 10 行代码执行后却输出了 effectFn2 hello world。造成这个问题的原因就是我们全局声明的 activeEffect 仅支持同一时刻存储一个被注册的副作用函数。当发生嵌套时内层的副作用函数执行覆盖了activeEffect,但又没有提供恢复的机制,造成了与预期不符的现象。

为了解决副作用函数嵌套引起覆盖问题,可以考虑引入栈结构来管理副作用函数。当副作用函数执行时,将当前的副作用函数压入栈,待副作用函数执行完毕后将其从栈中弹出,并让 activeEffect

始终指向栈顶的副作用函数。

const effectStack = []

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn) // 移除关系
        effectStack.push(effectFn) // 将当期的副作用函数压入栈
        activeEffect = effectFn
        fn()
        effectStack.pop() // 执行后弹出
        // effectFn 已经执行完,更新 activeEffect 为栈顶的副作用函数
        activeEffect = effectStack[effectStack.length - 1]
    }
    // 收集所有依赖此副作用函数的依赖集合
    effectFn.deps = []
    effectFn()
}

4.6 避免无限递归循环

在下面的代码中显示,副作用函数中同时发生的 data.ok 属性的获取和设置操作:

const data = reactive({ ok: true })

effect(() => {
    console.log('effect')
    data.ok = !data.ok;
})

看起来一个很平常的操作却在框架侧引发了栈溢出的问题,要解决这个问题也很简单,因为同时发生了获取和设置,那么 track 时收集的副作用函数和 trigger 执行的副作用函数应该就是相同的, 根据这个特点,在 trigger 触发副作用函数执行前进行判断,如果要触发执行的副作用函数与正在执行的副作用函数相同,则过滤掉,不触发执行。

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    const newDeps = new Set(deps)
    newDeps && newDeps.forEach(fn => {
        // 过滤掉与正在执行的副作用函数相同的触发
        if (activeEffect !== fn) {
            fn()
        }
    })
}

4.7 调度执行

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

4.7.1 决定副作用函数的执行方式

在下面这段应用侧的代码所示,effect 第一次执行输出 data.foo1data.foo++ 后,effect 再次执行输出 data.foo2,最后打印 over

const data = reactive({ foo: 1 })

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

data.foo++

console.log('over')

假如现在要求应用侧的代码执行逻辑保持不变,但需要将 data.foo++effect 的执行放到 over 输出的后面。这就要求响应系统支持调度。

首先为 effect 函数设计一个选项参数(options),运行用户在应用侧传递一个 scheduler 函数:

effect(() => {
    console.log(data.foo)
}, {
    scheduler(fn) {
        // 由用户侧决定什么时候执行副作用函数
    }
})

接着重构框架测的 effect 函数,将应用侧传递的选项参数挂载到 effectFn 函数上:

function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn)
        effectStack.push(effectFn)
        activeEffect = effectFn
        fn()
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }
    // 将选项挂在到目标副作用函数
    effectFn.options = options
    effectFn.deps = []
    effectFn()
}

最后重构 trigger 函数,在遍历执行副作用函数时检查是否挂载有调度器选项,有的情况将副作用函数回调给应用侧:

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    const newDeps = new Set(deps)
    newDeps && newDeps.forEach(fn => {
        if (activeEffect !== fn) {
            // 有设置调度器选项时,将副作用函数回调到应用侧
            if (fn.options.scheduler) {
                fn.options.scheduler(fn)
            } else {
                fn()
            }
        }
    })
}

在应用侧我们将回调的副作用函数放入延迟队列中,当渲染主线程将任务执行完毕才轮到延迟队列的任务执行。最后就达到了调整打印顺序的目的:

effect(() => {
    console.log(data.foo)
}, {
    scheduler(fn) {
        setTimeout(fn) // 宏任务将放入延迟队列,微任务将放入微队列
    }
})

4.7.2 决定副作用函数的执行次数

在下面这段应用侧的代码所示,data.foo 会依次打印自增的完成过程1、2、3,如果我们只关心结果不关心过程,那么执行三次但因操作就变得多余,我们希望只输出 1 和 3,而忽略中间的过度状态 2:

const data = reactive({ foo: 1 })

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

data.foo++
data.foo++

首先定义一个 Set 结构的任务队列 jobQueue 和一个用来冲刷任务列表的 flushJob 函数,接着在每次副作用函数调度执行时将副作用函数添加到 jobQueue,再调用flushJob 函数冲刷任务队列:

// 将副作用函数存储任务队列
const jobQueue = new Set()

// 控制 flushJob 函数一个周期内执行一次
let isFlushing = false
// 执行 flushJob 将 jobQueue 队列放入微队列
function flushJob() {
    if (isFlushing) return
    // 修改刷新标识
    isFlushing = true
    Promise.resolve().then(() => {
        // 在微队列中执行任务队列列表
        jobQueue.forEach(job => job())
    }).finally(() => {
        // 恢复刷新标识
        isFlushing = false
    })
}

const data = reactive({ foo: 1 })

effect(() => {
    console.log(data.foo)
}, {
    scheduler(fn) {
        jobQueue.add(fn)
        flushJob()
    }
})

data.foo++
data.foo++