likes
comments
collection
share

vuejs设计与实现-响应系统的设计与实现

作者站长头像
站长
· 阅读数 10

响应式数据与副作用函数

副作用函数: 函数fn的执行会直接或间接影响其他函数的执行, fn就产生了副作用.

let val = 1
function effect(){
    val = 2
}
// 修改了全局变量, 对外部产生了影响, 所以 effect 是副作用函数
effect()

响应式数据: 当值发生变化时, 如果副作用函数会自动重新执行, 那么obj就是响应式数据

const obj = { text: 'hello world' }
function effect(){
    document.body.innerText = obj.text
}
// 修改obj.text的值同时希望副作用函数会重新执行

obj.text = 'hello vue3'

响应式数据的基本实现

我们可以拦截一个对象的读取和设置操作, 当读取字段obj.text时, 把副作用函数effect存储到一个“桶”里. 当设置obj.text时, 再把副作用函数从“桶”中取出执行. 以上就是一个响应式数据.

  • 当副作用函数effect执行时, 会触发obj.text读取操作
  • 当修改obj.text的值时, 会触发obj.text设置操作
// 存储副作用的桶
const bucket = new Set()

const data = { text: 'hello world' }

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())
        return true
    }
})

以上是简单的实现, 通过proxy代理拦截读取和设置操作.虽然有很多缺陷(硬编码), 但是执行也会得到期望的结果. 而vue2中通过Object.defineProperty实现.

function effect(){
    document.body.innerText = obj.text
}
// 触发读取操作, 将副作用存入桶中
effect()

obj.text = 'hello world'

设计一个完善的响应系统

优化副作用函数的注册机制

前面硬编码副作用函数的名字(effect), 然而副作用函数的名字一旦改变整个逻辑就无法工作, 所以需要提供注册副作用函数的机制.

// 全局变量  存储被注册的副作用函数
let activeEffect
// 重新定义effect  用于注册副作用函数
function effect(fn){
    activeEffect = fn
    // 默认执行副作用函数, 进行读取操作
    fn()
}
// 收集通过 effect 方法注册的副作用函数
const obj = new Proxy(data, {
    get(target, key){
        // 收集缓存的副作用函数
        if(activeEffect){
            bucket.add(activeEffect)
        }
        return target[key]
    },
    // set(target, key, newVal){ ... }
})


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

副作用函数与被操作的目标字段需要建立明确的关系

我们优化了副作用函数的注册机制, 但是在尝试设置不存在的字段obj.notRxist时, 副作用函数同样会执行. 是因为只要出发 set 就会执行中的副作用函数. 因此需要重新设计的结构, 使副作用函数与字段值建立关系(树形结构图).

vuejs设计与实现-响应系统的设计与实现

// 使用 WeakMap 实现存储副作用的 桶

// bucket(WeakMap)  target -> depsMap
// depsMap (Map)  target 的 key -> Set
// deps (Set)  副作用函数组成的Set
const bucket = new WeakMap() 

const obj = new Proxy(data, {
    get(target, key){
        // weakMap: target ->  (depsMap: (key -> Set(effects)))
        if(!activeEffect) return target[key]
        
        let depsMap = bucket.get(target)
        if(!depsMap) {
            bucket.set(target, depsMap = new Map())
        }
        // key 的依赖集合
        let deps = depsMap.get(key)
        if(!deps) {
            depsMap.set(key, deps = new Set())
        }
        deps.add(activeEffect)
        
        return target[key]
    },
    set(target, key, newVal){ 
        // 根据target从桶中取得depsMap, 根据key从map中副作用函数
        target[key] = newVal
        
        const depsMap = bucket.get(target)
        if(!depsMap) return 
        
        const effects = depsMap.get(key)
        effects && effects.forEach(fn => fn())
    }
})

Set数据结构存储的副作用函数集合就是key依赖集合. 同时使用weakMap方便当用户的代理对target没有引用时回收. 通过tracktrigger封装代理对象的读取拦截操作.

// 读取操作, 追踪变化
fcuntion track(target, key){
    if(!activeEffect) return target[key]

    let depsMap = bucket.get(target)
    if(!depsMap) {
        bucket.set(target, depsMap = new Map())
    }
    // key 的依赖集合
    let deps = depsMap.get(key)
    if(!deps) {
        depsMap.set(key, deps = new Set())
    }
    deps.add(activeEffect)
}

// 触发操作, 执行副作用函数
fcuntion trigger(target, key){     
    const depsMap = bucket.get(target)
    if(!depsMap) return 

    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
}

分支切换与cleanup

在三元表达式中, 当obj.ok发生变化时代码执行的分支会发生变化. 分支切换可能会产生遗留的副作用函数, 发生不必要的更新.

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

effect(function effectFn(){
    console.log(obj.ok ? obj.text: 'nothing')
})
  • obj.oktrue时, effectFnobj.okobj.text所对应的依赖收集, 此时两个值的变化都会触发副作用函数执行.
  • obj.okfalse时, 理论上obj.text的变化不应该再触发副作用函数. 但是由于之前收集了依赖, 所以obj.text会触发不必要的更新.

理想状态下, 此时effectFn不应被obj.text所对应的依赖收集. 需要在副作用函数执行时从所有的依赖集合中删除, 执行完毕后再重新建立联系.

要将副作用函数从所有与之关联的依赖集合中删除, 就要知道哪些依赖集合中包含它. 因此可以重新设计副作用函数注册机制.

let activeEffect
function effect(fn){
   const effectFn = () => {
       // 将副作用函数从依赖集合中清除
       cleanup(effectFn)
       activeEffect = effectFn
       // 传入的副作用函数, 通过 obj.text 触发再次收集依赖
       fn()
   }
   // 定义 deps 存储所有与该副作用函数关联的依赖集合
   effectFn.deps = []
   effectFn()
}

// 修改, 将关联的依赖集合收集起来
fcuntion 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)
    // 将依赖集合push到副作用函数的deps属性中, 从而实现 cleanup.
    activeEffect.deps.push(deps)
}
// 获取, 通过依赖执行对应的副作用函数
fcuntion trigger(target, key){     
    const depsMap = bucket.get(target)
    if(!depsMap) return 

    const effects = depsMap.get(key)
    // 需要构建一个新的Set<effects>进行遍历
    // 因为遍历effects会执行集合中的副作用函数 , 调用 cleanup 从集合中删除当前执行的副作用函数
    // 但同时又重新被收集, 会导致Set集合无限执行遍历
    const effectsToRun = new Set(effects)
    effectsToRun.forEach(fn => fn())
    // effects && effects.forEach(fn => fn())
}

// cleanup 用于清除副作用函数的 effectFn.deps
function cleanup(effectFn){
    for(let i = 0; i < effectFn.deps.length; i++){
        // deps 依赖集合 Set实例
        const deps = effectFn.deps[i]
        // 移除方便重新收集依赖
        deps.delete(effectFn)
    }
    // 重置effectFn.deps数组
    effectFn.deps.length = 0
}

嵌套的effect与effect栈

effect是可以发生嵌套的, vuejs的渲染函数就是在一个effect中执行的, 因此需要支持嵌套的情况.

activeEffect作为全局变量同一时刻只能存储一个副作用函数. 当发生嵌套时, 内层副作用函数会覆盖原来activeEffect的值. 这时如果有响应式数据进行依赖收集(即使在外层副作用函数读取), 只能收集到内层副作用函数. 所以我们需要一个副作用函数栈effectStack, 当副作用函数执行时压入栈中, 执行完毕从栈中弹出. 保证activeEffect始终指向栈顶的副作用函数.

let activeEffect
const effectStack = []

function effect(fn){
   const effectFn = () => {
       cleanup(effectFn)
       activeEffect = effectFn
       // 执行前压入栈中
       effectStack.push(effectFn)
       fn()
       // 执行完毕后弹出, 并还原 activeEffect 
       effectStack.pop()
       activeEffect = effectStack[effectStack.length - 1]
   }
   // 定义 deps 存储所有与该副作用函数关联的依赖集合
   effectFn.deps = []
   effectFn()
}

避免无限递归循环

自增操作既读取也会设置obj.foo的值. 先触发track操作收集依赖, 随后赋值触发trigger操作, 触发副作用函数执行. 但此时副作用函数正在执行中, 就会无限递归地调用自己.

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

effect(() => {
    obj.foo++
})

因此如果trigger函数触发执行的副作用函数与当前正在执行的副作用函数相同, 则不触发执行

fcuntion 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(fn => fn())
}

调度执行

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

function effect(fn, options = {}){
   const effectFn = () => {
       cleanup(effectFn)
       activeEffect = effectFn
       effectStack.push(effectFn)
       fn()
       effectStack.pop()
       activeEffect = effectStack[effectStack.length - 1]
   }
   // 挂载options属性
   effectFn.options = options
   effectFn.deps = []
   effectFn()
}

// 
fcuntion 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(fn => {
        // 如果 scheduler 存在就调用, 并将副作用函数作为参数传递
        if(fn.options.scheduler){
            fn.options.scheduler(fn)
        }else {
            fn()
        }
    })
}

// 调度器
const jobQueue = new Set()

const p = Promise.resolve()

let isFlushing = false
function flushJob(){
    if(isFlushing) return
    isFlushing = true
    
    p.then(() => {
        jobQueue.forEach(job => job())
    }).finally(() => {
        isFlushing = false
    })
}

// 
effect(() => {
    console.log(obj.text)
}, {

    scheduler(fn){
        jobQueue.add(fn)
        flushJob()
    }
})

// 连续两次自增操作, 会连续执行调度函数
// 同一个副作用函数会被 jobQueue.add 添加两次(本身Set去重能力). 
// flushJob 也会执行两次, 由于 isFlushing 标识, 只会对 jobQueue(只存储了一个副作用函数) 进行一次遍历执行 (一个事件循环内只执行一次)
obj.text = '123'
obj.text = '234'

vuejs中连续修改响应式数据但只会触发一次更新, 思路与之相同. 将实际更新操作放在微任务中执行(任务队列是Set结构), 因此同步地修改响应式数据最终只会触发一次更新.

计算属性computed与lazy

lazy 属性为true时将副作用函数返回, 允许我们手动执行副作用函数.

// 对effect进行改造
function effect(fn, options = {}){
    // effectFn 对副作用函数fn进行包装
   const effectFn = () => {
       cleanup(effectFn)
       activeEffect = effectFn
       effectStack.push(effectFn)
       // 保存真正的副作用函数结果
       const res = fn()
       effectStack.pop()
       activeEffect = effectStack[effectStack.length - 1]
       // 作为effectFn副作用函数的返回值
       return res
   }
   // 挂载options属性
   effectFn.options = options
   effectFn.deps = []
   
   if(options.lazy) {
       effectFn()
   }
   // 将副作用函数返回
   return effectFn
}

computed计算属性对effect做了一层包装, computed接收一个getter方法作为副作用函数, 并返回一个对象(其value是访问器属性). 只有读取value的值时才会执行副作用函数并将其结果返回. 同时对其进行缓存, 只有当getter方法依赖的响应式数据变化时才会重新计算.

function computed(getter){
    let value, dirty = true
    const effectFn = effect(getter, { 
        lazy: true,
        scheduler(){
            if(!dirty) {
                dirty = true
                // 变化时trigger触发响应
                trigger(obj, 'value')
            }
            
        }
    })
    
    const obj = {
        get value(){
            // 缓存计算属性, 只有dirty为真时进行计算
            if(dirty) {
                // 通过返回值获取执行副作用函数结果
                value = effectFn()
                dirty = false
            }
            // 读取value时操作进行track追踪
            track(obj, 'value')
            return value
        }
    }
    // 返回对象, 其value是一个访问器属性
    return obj
}


// 使用计算属性 
const sumRes = computed(() => obj.foo + obj.bar)

// 本质是effect嵌套, 外层的effect并不会被内层effect的响应式数据收集, 所以需要在获取计算属性进行track, 计算属性变化时进行trigger
effect(() => {
    // 读取计算数学的值
    console.log(sumRes.value)
})
// obj.foo变化时, sumRes重新计算, 同时也会执行副作用函数进行log打印
obj.foo++

watch的实现原理

watch的实现本质上是利用了effectoptions.scheduler选项, 观测一个响应式数据, 当数据变化时通知并执行相应的回调函数.

function watch(source, cb){
    let getter
    if(type of souce === 'function'){
        getter = source
    } else {
        // 递归读取, 每个属性变化时都能触发回调函数
        getter = () => traverse(source)
    }
    
    let oldV, newV
    const effectFn = effect(
        () => getter, 
        {
            lazy: true,
            scheduler(){
                newV = effectFn()
                cb(newV, oldV)
                oldV = newV
            }
        }
    )
    oldV = effectFn()
}

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

watch是对effect的二次封装. 默认情况下回调只会在响应式数据变化时执行, 当immediatetrue时会立即执行一次回调.

watch(obj, () => {
    console.log('obj变化了')
}, {
    immediate: true
})

过期的副作用

function watch(source, cb, options = {}){
    let getter
    if(type of souce === 'function'){
        getter = source
    } else {
        // 递归读取, 每个属性变化时都能触发回调函数
        getter = () => traverse(source)
    }
    
    let oldV, newV
    let cleanup
    function onInvalidate(fn){
        cleanup = fn
    }
    
    const job = () => {
        newV = effectFn()
        
        if(cleanup){ cleanup() }
        
        cb(newV, oldV, onInvalidate)
    }
    
    const effectFn = effect(
        () => getter(), 
        {
            lazy: true,
            scheduler(){
                if(options.flush === 'post') {
                    Promise.resolve().then(job)
                } else {
                    job()
                }
            }
        }
    )
    
    if(options.immediate){
        job()
    } else {
        oldV = effectFn()
    }
    
}

watch(obj, async(newV, oldV, onInvalidate) => {
    let expired = false
    
    onInvalidate(() => {
        expired = true
    })
    
    const res = await fetch('/path/to/request');
    
    if(!expired) {
        finalData = res
    }
})

总结

  • 响应式数据的实现依赖对getset的拦截, 在副作用函数与响应式数据建立关系.
  • weakMapMap配合, 在响应式数据与副作用函数之间建立精确的关系.weakMap<target, Map<key, Set<effectFn>>>
  • 副作用函数重新执行前,清除上次的响应联系; 重新执行后, 再重新建立联系. 从而解决冗余副作用的问题.
  • 嵌套的副作用函数. 使用副作用函数栈来存储不同的副作用函数, 避免嵌套时响应式数据与副作用函数的联系发生错乱.
  • 可调度性指trigger触发副作用函数执行的时机、次数以及方式. 通过scheduler选项完成任务调度工作. 还可以通过调度器实现任务去重.
  • 计算属性是一个懒执行的副作用函数. 当读取计算属性的值时只需手动执行副作用函数.
  • watch利用了副作用函数执行时的可调度性. watch会创建一个effect, 当依赖的响应式数据发生变化时便会执行scheduler, 在scheduler中执行用户注册的回调.
  • 过期的副作用函数会导致竞态问题. 所以vuejswatch的回调设计了onInvalidate参数, 用来注册过期回调.