likes
comments
collection
share

第一章 Vue3响应式核心effect函数实现

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

前言

Vue是一个数据驱动的框架。其特点是MVVM的架构,即model(数据模型)影响view(视图),view也会反作用于model,而完成model与view之间传导的机制就是响应式系统,本章将会以代码的形式向大家介绍响应式系统的设计与实现原理。(PS:本章的代码内容与Vue3的源码存在出入,将会是源码的最小实现,详细源码的梳理过程可见第二章。)

副作用的实现

function effect(){
    document.body.innerHtml = 'hello world'
}
effect()

上述effect函数的执行会使得页面内容变成‘hello world',如果有另一个foo函数访问了当前body的内容,那么effect函数的执行就会对foo函数的结果造成影响,这种影响我们就称之为“副作用”,而造成副作用的函数,我们就称为副作用函数,可见effect函数就是一个副作用函数。 现在我们将effect函数中的数据源抽离出来成如下代码:

const obj = {msg: 'hello world'}

function effect() {
    document.body.innerHtml = obj.msg
}

现在页面中的内容就由obj.msg所控制。假如我们可以完成当obj.msg发生改变时,再执行一次effect函数,那么页面中的内容就完全由obj.msg所驱动。为此,我们需要声明一个“桶”变量来存储我们的副作用函数,当obj.msg发生变化时,我们就将“桶”中的副作用函数全部取出执行。见如下代码:

const obj = {msg: 'hello world'}

const buckets = []

function effect() {
    document.body.innerHtml = obj.msg
}

buckets.push(effect)

以上代码完成了我们对副作用函数的收集目的,但是它无法完成我们“当数据发生改变时取出副作用函数执行的需求”。为了实现这个目的,我们需要劫持数据,即我们需要获得对一个对象的getter和setter操作,在es6中我们有Proxy可以实现这一机制,这里对Proxy的内容就不再介绍了,修改代码如下:

const obj = {msg: 'hello world'}

const buckets = []

const newObj = new Proxy(obj, {
    get(target, key) {
        buckets.push(effect)
    },
    set(target, key, newValue) {
        target[key] = newValue
        buckets.forEach(effect => effect())
        return true
    }
})

function effect() {
    document.body.innerHtml = newObj.msg
}

effect()

由上述可见,当effect函数执行时就会访问代理对象的msg属性,所以收集副作用函数的代码也可以简化到代理对象的get中。 我们目前的代码还有一个问题就是我们当前的effect函数是一个具名函数,buckets只能收集我们的effect函数,而响应式系统中的副作用应该是有许多许多,所以我们的响应式系统应该是一个通用的代码,我们要修改effect函数如下:

let activeEffect

const buckets = []

const obj = {msg:'hello world'}

const newObj = new Proxy(obj, {
    get(target, key) {
        buckets.push(activeEffect)
    },
    set(target, key, newValue) {
        target[key]=newValue
        buckets.forEach(fn => fn())
    }
})

function effect(fn) {
    activeEffect = fn
    fn()
} 

effect(() => console.log(newObj.msg))

这里我们声明了activeEffect变量来传递副作用函数,并且把副作用函数由之前的document.body.innerHtml=obj.msg改成console.log(obj.msg)使得看上去更加通俗。这里我们的effect函数已经不在是之前的副作用函数,而是一个注册副作用函数的函数,这样我们就完成了对多个匿名副作用函数的收集工作。

TargetsMap

我们尝试着去改变newObj.msg的值会发现,console.log函数又执行了。显然它满足了我们响应式更新的需求,但是当执行newObj.value = ''时,会发现副作用函数也执行了,这是因为我们的buckets并没有与newObj的属性之间建立关联关系,newObj的set一旦执行都会触发更新,于是我们需要建立一个对象属性与副作用函数之间的一对多的关系,便因此引入了targetsMap对象,现修改代码如下:

let activeEffect

const obj = {msg:'hello world'}

const targetsMap = new WeakMap()

const newObj = new Proxy(obj, {
    get(target, key) {
      if (!activeEffect) return
      // 从targetsMap中获得原对象
      let depsMap = targetsMap.get(target)
      if (!depsMap) {
        targetsMap.set(target, depsMap = new Map()) 
      }
      // 获得原对象属性所对应的副作用函数集合
      let deps = depsMap.get(key)
      if (!deps) {
        depsMap.set(key, deps = new Set())
      }
      deps.add(activeEffect)
    },
    set(target, key, newValue) {
        target[key]=newValue
        const depsMap = targetsMap.get(target)
        if (depsMap) {
          const effects = depsMap.get(key)
          // 从targetsMap中获取原对象所对应的Map,从中获取对应属性的副作用集合
          effects && effects.forEach(effect => effect())
        }
    }
})

function effect(fn) {
    activeEffect = fn
    fn()
} 

effect(() => console.log(newObj.msg))

为了逻辑清晰,封装track和trigger函数:

let activeEffect

const obj = {msg:'hello world'}

const targetsMap = new WeakMap()

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

function trigger(target, key) {
  const depsMap = targetsMap.get(target)
  if (depsMap) {
    const effects = depsMap.get(key)
    effects && effects.forEach(effect => effect())
  }
}

const newObj = new Proxy(obj, {
    get(target, key) {
     track(target, key)
    },
    set(target, key, newValue) {
      target[key]=newValue
      trigger(target, key)
    }
})

function effect(fn) {
    activeEffect = fn
    fn()
} 

effect(() => console.log(newObj.msg))

CleanupEffect

上述代码已经完成了基本的响应式功能,但仍然存在问题,比如如下代码:

const obj = {flag: true, msg:'hello world'}
// 封装reactive函数创建obj的代理对象
const newObj = reactive(obj)

effect(() => {
    console.log('execute')
    if (newObj.flag) {
        console.log(newObj.msg)
    }
})

newObj.flag = false

setTimeout(() => {
    newObj.msg = 'hello vue'
}, 1000);

上述代码执行后会发现延迟一秒后又打印了execute,表示副作用函数又执行了。此时newObj.flag已经为false,不会访问newObj.msg,但是改变msg的值依然触发了副作用执行,这次因为我们初始化时flag为true,将副作用函数也收集到了msg属性所对应的集合中,尽管修改flag为false后,msg的副作用仍然在集合中存在,我们缺乏清除“缓存”副作用的功能。 每当副作用函数执行就会触发get收集副作用,而触发set又会间接的触发get,从而收集副作用函数。所以如果我们中副作用函数执行前先把之前收集的副作用清空,然后再执行副作用函数把副作用收集回来,就可以保证我们所持有的集合中的副作用函数一定是最新的,现修改代码如下:

//...省略部分代码

function track(target, key) {
  if (!activeEffect) return
  let depsMap = targetsMap.get(target)
  if (!depsMap) {
    targetsMap.set(target, depsMap = new Map()) 
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, dep = new Set())
  }
  dep.add(activeEffect)
  // 收集副作用函数的同时,也要副作用函数收集其所在的集合
  activeEffect.deps.push(dep)
}

function cleanup(effectFn) {
  for(let i=0;i<effectFn.deps.length;i++) {
    const dep = effectFn.deps[i]
    // 将副作用在其所在的集合中删除
    dep.delete(effectFn)
  }
  effectFn.deps.length = 0
}

function effect(fn) {
   // 这里在fn中声明deps属性会修改fn,所以需要封装fn成effectFn
  const effectFn = () => {
    activeEffect = effectFn
    cleanup(effectFn)
    fn()
  }
    if (!effectFn.deps) effectFn.deps = []
    effectFn()
} 

//...省略部分代码

执行上述代码会发现进入死循环中,因为我们在触发trigger时会使副作用函数执行,清空之前收集的副作用,然后重新收集,但此时我们的trigger还没有结束,循环更新中又加入了新收集的副作用所以更新继续,导入进入死循环,类似于:

const set = new Set([1])

set.forEach(num => {
    set.delete(1)
    set.add(1)
    console.log('遍历中')
})

所以,需要修改trigger函数代码如下:

function trigger(target, key) {
  const depsMap = targetsMap.get(target)
  if (depsMap) {
    const effects = depsMap.get(key)
    if (effects) {
        const effectsToRun = new Set(effects)
        effectsToRun.forEach(effect => effect())
    }
  }
}

因为副作用函数的触发又会引起副作用函数的收集,假设如果有一个指令同时满足对代理对象的属性的访问与修改,那我们的副作用函数将会进入死循环,即newObj.msg++指令,所以为了避免这种情况,修改代码如下:

function trigger(target, key) {
  const depsMap = targetsMap.get(target)
  if (depsMap) {
    const effects = depsMap.get(key)
    if (effects) {
        const effectsToRun = new Set(effects)
        effectsToRun.forEach(effect => {
            if(effect !== activeEffect) effect()
        })
    }
  }
}

Nest

其实当我们在effect函数中传入绘制页面的副作用函数后,页面内容就由我们的代理对象所描绘,而我们知道vue是用vnode来描绘页面的样式的,这些vnode之间又组合成了一个vnode树,每一个vnode都是vnode树中的一个节点,而节点下又有多个分支,每一个分支下又是一个vnode,而每一个vnode又会有一个依据它描绘的页面,所以又对应有一个副作用函数。即副作用函数需要支持嵌套。effect函数修改如下:

// ...省略部分代码

// 用一个栈记录嵌套的副作用函数
const activeEffectStack = []

function effect(fn) {
   // 这里在fn中声明deps属性会修改fn,所以需要封装fn成effectFn
  const effectFn = () => {
   activeEffect = effectFn
    activeEffectStack.push(activeEffect)
    cleanup(effectFn)
    fn()
    activeEffetStack.pop()
    activeEffect = activeEffectStack[activeEffectStack.length - 1]
  }
    if (!effectFn.deps) effectFn.deps = []
    effectFn()
} 

// ...省略部分代码

Scheduler

假设有这样一段代码:

// newObj.msg的值为hello world
effect(() => console.log(newObj.msg))

console.log('update')

newObj.msg = 'hello vue3'

当上述代码执行时我们发现会先打印hello world,再是update,最后是hello vue3。现在如果我们想先打印hello vue3再打印update,需要如下修改:

// newObj.msg的值为hello world
effect(() => console.log(newObj.msg))

newObj.msg = 'hello vue3'

console.log('update')

这里我们为了修改执行顺序改变了代码的位置,这样的代码是不好维护和升级的,我们需要可以自由的决定我们的副作用函数的执行时机,所以引入了scheduler概念,即调用时我们可以这样写:

const isFlush = false

const p = Promise.resolve();

const flushJob = () => {
    if (isFlush) return;
    isFlush = true
    // 放入微任务队列,异步执行副作用函数
    p.then(() => {
        queue.forEach(effect => effect())
    }).finally(() => {
        isFlush = false;
        queue.length = 0
    })
}

const jobQueue = (job) => {
    // 收集副作用函数
    queue.push(job)
     flushJob();
}

// newObj.msg的值为hello world
// 这里的effect函数可以传入第二个参数,即一个含有scheduler的函数,我们要用scheuler函数控制副作用函数的执行
effect(() => console.log(newObj.msg), {
    scheduler(effect) {
        jobQueue(effect);
    }
})

newObj.msg = 'hello vue3'

console.log('update')

effect函数修改如下:

// ...省略部分代码

function effect(fn, options = {}) {
   // 这里在fn中声明deps属性会修改fn,所以需要封装fn成effectFn
  const effectFn = () => {
  activeEffect = effectFn
    activeEffectStack.push(activeEffect)
    cleanup(effectFn)
    fn()
    activeEffetStack.pop()
    activeEffect = activeEffectStack[activeEffectStack.length - 1]
  }
    if (!effectFn.deps) effectFn.deps = []
    effectFn.options = options
    effectFn()
} 

function trigger(target, key) {
  const depsMap = targetsMap.get(target)
  if (depsMap) {
    const effects = depsMap.get(key)
    if (effects) {
        const effectsToRun = new Set(effects)
        effectsToRun.forEach(effect => {
            if (effect === activeEffect) return;
            // 如果调度器存在,则用调度器执行副作用函数
            if (effect.options && effect.options.scheduler) {
                const { scheduler } = effect.options
                scheduler && scheduler(effect)
            } else {
                effect()
            }
        })
    }
  }
}

// ...省略部分代码

这里我们发现调度器的本质是一个回调函数,我们也可以不采用微任务队列的方式,自定义scheduler函数控制副作用函数的执行顺序。

Lazy

副作用函数会默认执行一次,通过代理来收集副作用。可以通过在options中传入lazy属性来控制副作用函数不默认执行,但是不默认执行副作用函数的话,副作用就无法收集,所以我们需要在effect函数中把effectFn函数暴露出来,使得可以外部调用副作用函数。

// ...省略部分代码

function effect(fn, options = {}) {
   // 这里在fn中声明deps属性会修改fn,所以需要封装fn成effectFn
  const effectFn = () => {
    activeEffect = effectFn
    activeEffectStack.push(activeEffect)
    cleanup(effectFn)
    fn()
    activeEffetStack.pop()
    activeEffect = activeEffectStack[activeEffectStack.length - 1]
  }
    if (!effectFn.deps) effectFn.deps = []
    effectFn.options = options
    // 有lazy属性就不默认执行
    if (!options.lazy) effectFn()
    // 返回内部的副作用函数给外部手动调用。
    return effectFn;
} 

基于effect函数的懒执行,我们可以简单实现computed函数如下:

function computed(getter) {
    // 副作用懒执行,我们需要在value属性被访问时才执行副作用函数
    const effectFn = effect(getter, {
        lazy: true
    })
    
    return {
        value() {
            // 这里需要返回副作用函数的返回值,所以effect函数也需要修改下
            return effectFn()
        }
    }
}

//...省略部分代码

function effect(fn, options = {}) {
   // 这里在fn中声明deps属性会修改fn,所以需要封装fn成effectFn
  const effectFn = () => {
    activeEffect = effectFn
    activeEffectStack.push(activeEffect)
    cleanup(effectFn)
    const res = fn()
    activeEffetStack.pop()
    activeEffect = activeEffectStack[activeEffectStack.length - 1]
    // 返回fn函数的执行结果
    return res
  }
    
    if (!effectFn.deps) effectFn.deps = []
    effectFn.options = options
    // 有lazy属性就不默认执行
    if (!options.lazy) effectFn()
   
    // 返回内部的副作用函数给外部手动调用。
    return effectFn;
} 
// ...省略部分代码

Computed

computed还有两个特点1.computed返回值本身也是响应式的;2.computed具有缓存。 修改代码如下:

//...省略部分代码

function computed(getter) {
    // 标记是否是"脏"数据
    let dirty = true
    let res
    // 副作用懒执行,我们需要在value属性被访问时才执行副作用函数
    const effectFn = effect(getter, {
        lazy: true,
        // 因为副作用函数的执行会通过scheduler,所以可以在scheduler中改变dirty值,触发更新
        scheduler() {
            dirty = true
            trigger(obj, 'value')
        }
    })
    
    const obj =  {
        get value() {
            track(obj, 'value')
            if (dirty) {
                res = effectFn()
                dirty = false
            }
            return res
        }
    
    return obj
    }
}

//...省略部分代码

Watch

上文中提到scheduler其实本质是一个回调函数,基于此,我们可以简单实现一个watch函数:

function watch(source, callback, options = {}) {
    let oldValue
    let newValue
    let cleanup
    let getter
    
    if (typeof source === 'function') {
        getter = source
    } else {
        // 赋值一个匿名函数访问对象属性 
        getter = () => traverse(source)
    }
    
    function traverse(value, seen = new Set()) {
        if (typeof value !== 'object' || value === null || seen.has(value) ) return; 
        seen.add(value)
        for(let key in value) {
            traverse(value[key], seen)
        }
        return value
    }
    
    // 解决竞态问题
    const onValidate = (fn) => {
        cleanup = fn
    }
    
    const job = () => {
        newValue = effectFn()
        cleanup && cleanup()
        callback(newValue, oldValue, onValidate)
        oldValue = newValue
    }
    
    const effectFn = effect(getter, {
        lazy: true,
        scheduler() {
            // 发生trigger时调用回调函数
            job()
        }
    })
    if (options.immediate) {
        job()
    } else {
        oldValue = effectFn();
    }
}

总结

本章最简的实现了vue3中的响应式原理,虽与源码的实现存在出入,但是描述了响应式中需要考虑的问题与解决方案,后续章节会优化响应式代码与vue3相同,可以在重构的同时思考vue3源码中的设计思路和解决方案。