Skip to content

前言

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

副作用的实现

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

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

js
const obj = { msg: 'hello world' }

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

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

js
const obj = { msg: 'hello world' }

const buckets = []

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

buckets.push(effect)

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

js
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函数如下:

js
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对象,现修改代码如下:

js
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函数:

js
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

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

js
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,从而收集副作用函数。所以如果我们中副作用函数执行前先把之前收集的副作用清空,然后再执行副作用函数把副作用收集回来,就可以保证我们所持有的集合中的副作用函数一定是最新的,现修改代码如下:

js
//...省略部分代码

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还没有结束,循环更新中又加入了新收集的副作用所以更新继续,导入进入死循环,类似于:

js
const set = new Set([1])

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

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

js
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++指令,所以为了避免这种情况,修改代码如下:

js
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函数修改如下:

js
// ...省略部分代码

// 用一个栈记录嵌套的副作用函数
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

假设有这样一段代码:

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

console.log('update')

newObj.msg = 'hello vue3'

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

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

newObj.msg = 'hello vue3'

console.log('update')

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

js
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函数修改如下:

js
// ...省略部分代码

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函数暴露出来,使得可以外部调用副作用函数。

js
// ...省略部分代码

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函数如下:

js
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具有缓存。 修改代码如下:

js
//...省略部分代码

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函数:

js
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源码中的设计思路和解决方案。