Vue.js设计与实现(2-4)

268 阅读9分钟

响应系统#响应系统的作用与实现

一、响应系统的作用与实现#响应式数据与副作用函数

副作用函数:函数的执行会直接或间接影响其他函数的执行。

// 全局变量
let val = 1

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

响应式数据:当 val 的值变化时,effect 函数自动执行,则:val 为响应式数据。

二、响应系统的作用与实现#响应式数据的基本实现

工作原理:拦截数据的读取和设置操作

  • 读取:把副作用函数存储到一个“桶”里 image.png
  • 设置:把副作用函数从“桶”里取出并执行 image.png

基本实现:

  • ES2015之前:通过 Object.defineProperty 函数实现 —— Vue.js 3
  • 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())
            return true
        },
    })
    

测试代码:

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

// 执行副作用函数,触发读取操作
effect()

// 1秒后修改响应式数据
setTimeout(() => {
    obj.text = 'hello vue3'
}, 1000)

三、响应系统的作用与实现#设计一个完善的响应系统

一个响应系统的工作流程:

  1. 读取操作发生时,将副作用函数存储到“桶”中;
  2. 设置操作发生时,从“桶”中取出副作用函数并执行。

完善点一:响应系统解绑副作用函数名 —— 提供注册副作用函数机制

  • 提供一个用来注册副作用函数的机制:确保有效且正确地将副作用函数收集到“桶”中。
    // 用一个全局变量存储被注册的副作用函数
    let activeEffect
    
    function effect(fn) {
        // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
        activeEffect = fn
        // 执行副作用函数
        fn()
    }
    
  • 举例:注册一个匿名的副作用函数
    // 使用effect注册副作用函数
    effect(
        // 注册一个匿名的副作用函数
        () => {
            document.body.innerText = obj.text
        }
    )
    
  • 调整响应系统的读取操作逻辑,解绑副作用函数名
    // ...
    // 对原始数据的代理
    const obj = new Proxy(data, {
       // 拦截读取操作
       get(target, key) {
           // 将副作用函数添加到存储副作用函数的桶中【解绑副作用函数名】
           if (activeEffect) {
               bucket.add(activeEffect)
           }
           return target[key]
       },
       // ...
    })
    

完善点二:在副作用函数与被操作的目标字段之间建立明确的联系 —— 调整“桶”的数据结构:Set 调整为 WeakMap

  • Set 数据结构:副作用函数与被操作的字段之间没有明确的联系。

    • 读取任意属性时,都会把副作用函数收集到“桶”里。
    • 设置任意属性时,都会把“桶”里的副作用函数取出并执行。
  • WeakMap 数据结构:明确副作用函数与被操作的字段之间的关系。

    const bucket = new WeakMap()
    
    const data = {
        text: 'hello world'
    }
    const obj = new Proxy(data, {
        get(target, key) {
            track(target, key)
    
            return target[key]
        },
        set(target, key, newVal) {
            target[key] = newVal
    
            trigger(target, key)     
        },
    })
    
    // track 函数追踪变化
    const 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)
    }
    
    // trigger 函数触发变化
    const trigger = (target, key) => {
        const depsMap = bucket.get(target)
        if (!depsMap) return
    
        const effects = depsMap.get(key)
        effects && effects.forEach(fn => fn())
    }
    

    image.png WeakMapkey 是弱引用,不影响垃圾回收器的工作。经常用于存储哪些只有当 key 所引用的对象存在时(没有被回收)次啊有价值的信息。

    如果使用 Map 来代替 WeakMap,则即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出

    对代码做适当的封装处理,如 track(target, key)trigger(target, key),可以有效提升代码灵活性。

四、响应系统的作用与实现#分支切换与cleanup

分支切换:代码执行的分支跟随取值变化,如:if 语句,三目运算符,switch 语句等。

分支切换可能会产生遗留的副作用函数。而遗留的副作用函数会导致不必要的更新。

解决思路

  • 每次副作用函数执行时,先把它从所有与之关联的依赖集合中删除。
  • 当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数。

要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它 —— 重新设计副作用函数。

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

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

然后在 track 函数中通过 activeEffect.deps.push(deps) 来完成 effectFn.deps 数组对依赖集合的收集。

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

cleanup 函数接收副作用函数作为参数,遍历副作用函数的 effectFn.deps 数组,将该副作用函数从依赖集合中移除,最后重置 effectFn.deps 数组。

语言规范:在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么该值会重新被访问。

解决办法:构造另外一个 Set 集合并遍历它。

const set = new Set([1])

const newSet = new Set(set)
newSet.forEach(item => {
    set.delete(1)
    set.add(1)
    console.log(item, '遍历中...')
})

五、响应系统的作用与实现#嵌套的effect与effect栈

用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着:同一时刻,activeEffect 所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且永远不会恢复到原来的值。这时,如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数。

解决方案:提供一个副作用函数栈 effectStack —— 在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。

let activeEffect

// effect 栈
const effectStack = []

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn

        // 在调用副作用函数之前,将当前副作用函数压入栈中
        effectStack.push(effectFn)
        
        fn()

        // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }
    effectFn.deps = []
    effectFn()
}

当副作用函数发生嵌套时,栈底存储的是外层副作用函数,栈顶存储的是内层副作用函数。

六、响应系统的作用与实现#避免无限递归循环

trigger 触发执行前添加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行 —— effectFn !== activeEffect

七、响应系统的作用与实现#调度执行

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

可调度性是响应系统非常重要的特性。

let activeEffect

const effectStack = []

// 为 effect 函数设计一个选项参数 options,允许用户指定调度器
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 = []
    effectFn()
}

trigger 函数中触发副作用函数重新执行时,优先判断该副作用函数是否存在调度器,如果存在,则直接调用调度器函数,并把当前副作用函数作为参数传递过去,由用户自己控制如何执行;否则保留之前的行为,直接执行副作用函数。

// ...
if (effectFn.options.scheduler) {
    effectFn.options.scheduler(effectFn)
} else {
    effectFn()
}
// ...

八、响应系统的作用与实现#计算属性computed与lazy

function computed(getter) {
    // 值缓存
    let value
    // 是否需要重新计算标识
    let dirty = true
    
    const effectFn = effect(getter, {
        // 懒计算
        lazy: true,
        // 调度器重置计算标识:getter 函数中所依赖的响应式数据变化时执行
        scheduler() {
            if (!dirty) {
                dirty = true
                trigger(obj, 'value')
            }
        }
    })
    
    const obj = {
        get value() {
            if (dirty) {
                value = effectFn()
                dirty = false
            }
            track(obj, 'value')
            
            return value
        }
    }
    
    return obj
}
  • 懒计算:真正需要的时候,才进行计算并得到值。
  • 值缓存:值变化,重新计算,否则读取缓存的值。

九、响应系统的作用与实现#watch的实现原理

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

实际上,watch 的实现本质上就是利用了 effect 以及 options.scheduler 选项。

// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
    let getter
    
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }
    
    let oldValue, newValue
    const effectFn = effect(
        // 触发读取操作,建立联系 —— 递归读取,代替硬编码
        () => getter(),
        {
            lazy: true,
            // 当数据变化时,会执行 scheduler 调度函数
            scheduler() {
                newValue = effectFn()
                cb(newValue, oldValue)
                oldValue = newValue
            }
        }
    )
    oldValue = effectFn()
}

// 递归读取:支持任意属性发生变化都能触发回调函数执行
function traverse(value, 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
}

watch 的本质是对 effect 的二次封装。

十、响应系统的作用与实现#立即执行的watch与回调执行时机

立即执行

默认情况下,一个 watch 的回调只会在响应式数据发生变化时才执行。

Vue.js 中,可以通过选项参数 immediate 来指向是否需要立即执行。

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(),
        {
            lazy: true,
            scheduler: job
        }
    )
    
    if (options.immediate) {
        // 立即执行回调函数
        job()
    } else {
        oldValue = effectFn()
    }
}

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

执行时机

flush 指定调度函数的执行时机。

  • pre:组件更新前。
  • post:组件更新后。

    调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行。

    // ...
        scheduler: () => {
            if (options.flush === 'post') {
                // 异步延迟执行
                const p = Promise.resolve()
                p.then(job)
            } else {
                job()
            }
        }
    // ...
    
  • sync:同步执行。

十一、过期的副作用

watchonInvalidate 标识过期与否:过期-废弃,未过期-取用。