Vue3响应式系统:使用Proxy和Reflect API实现响应式的原理解析

1,274 阅读16分钟

响应式篇核心

Vue3响应式系统是Vue框架中一个重要的更新,它采用了全新的实现方式,使用ES6的Proxy对象和Reflect API来代替了Vue2中的 Object.defineProperty方法。这个新系统不仅提供了更好的性能和更广泛的应用场景, 还使得开发者能够更加灵活地控制组件的渲染和更新。 本文将介绍Vue3响应式系统的实现原理,并从0开始实现一个简易的响应式系统。

  • 基于数据驱动的思想,将数据变化反应到视图上,从而实现自动化视图更新。
  • 基于代理模式的思想,使用Proxy对象进行数据代理,从而实现监听数据的读取和修改操作。

响应式数据与副作用函数

什么是副作用函数呢?
函数执行使某个全局变量、页面内容、页面样式等发生变化,像这样产生副作用的函数被称为副作用函数。

举个例子来看,下面的effect修改了页面中的内容,它就是一个副作用函数

const obj = { text: 'hello world' }
function effect () {
  document.body.innerText = obj.text
}

执行上面的effect方法,会将页面内容设置为obj.text的值 —— 'hello world'。

obj.text = 'hello vue3'

obj.text发生变化时,希望副作用函数effect可以自动执行,如果能实现这个目标,那么就说对象obj就是一个响应式数据。
但目前来看,还做不到这一点,因为obj是一个普通对象,修改它时除了对象本身发生变化,不会有其他响应。


极简的响应式系统_v1

接上面内容思考,那么该怎么样将obj变成响应式对象呢?观察上面effect函数和写入obj.text这两个操作可以发现

  • 副作用函数effect执行时,会触发字段obj.text的读取操作
  • 当修改obj.text的值时,会触发字段obj.text的设置操作

如果可以根据上面结论拦截一个对象的读取和设置操作,事情就变得简单了,读取字段obj.text时,可以把副作用函数effect保存到起来, 当设置obj.text时,再取出副作用函数并执行它。

根据上面的分析实现一个极简的响应式系统:

// 存储副作用的容器(避免重复收集同一个副作用函数)
const bucket = new Set()
function effect () {
  document.body.innerText = obj.text
}
// 原始数据
const data = { text: 'hello world' }
// 创建一个data对象的代理对象
const obj = new Proxy(data, {
  get(target, key) {
    // 将副作用函数添加到容器中
    bucket.add(effect)
    // 返回属性值
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    // 设置属性值
    const res = Reflect.set(target, key, value)
    // 把副作用函数从容器中取出并只执行
    bucket.forEach(fn => fn())
    // 返回res设置操作成功
    return res
  }
})

window.obj = obj

上面加了一段代码window.obj = obj,这样就可以通过浏览器控制台去修改这个响应式对象。

img.png

极简的响应式系统_v1
目前的响应式系统还存在很多的缺陷,比如直接通过名字 effect 来获取副作用函数,这种硬编码方式不灵活。副作用函数的名字可能不是effect,或者可能 是一个匿名函数,因此必须要优化这种硬编码的机制。


极简的响应式系统_v2

回顾一下,上面的例子做了哪些事呢?

  • 当读取操作发生时,将副作用函数收集起来
  • 当设置操作发生时,将副作用函数取出并执行

因此我们可以用一个全局变量存储被注册的副作用函数,而effect用来注册副作用函数

// 存储副作用函数
  const bucket = new Set()
  // 用来保存副作用函数
  let activeEffect
  function effect(fn) {
    activeEffect = fn
    // 执行一次fn,触发它的读取操作
    fn()
    // 依赖收集之后重置为null
    activeEffect = null
  }
  // 原始数据
  const data = { text: 'hello world' }
  // 创建一个data对象的代理对象
  const obj = new Proxy(data, {
    get(target, key) {
      // 将副作用函数添加到容器中
      bucket.add(activeEffect)
      // 返回属性值
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      // 设置属性值
      const res = Reflect.set(target, key, value)
      // 把副作用函数从容器中取出并只执行
      bucket.forEach(fn => fn())
      // 返回res设置操作成功
      return res
    }
  })

再来看一下增强后响应式系统的工作流程:

  • 将副作用函数fn传给effect函数,effect函数会将这个fn保存起来
  • 执行fn,触发副作用函数依赖的响应式对象的get操作,以达到收集依赖的目的
  • 修改响应式对象,触发它的set操作,触发依赖,重新执行收集的副作用函数

可以运行下面的例子来体验一下更新后的响应式系统: 极简的响应式系统_v2

待修复的问题

运行时更新响应式对象出现了报错,fn is not a function,为什么会出现这个错误呢,打断点看一下,依赖集合中多了一个null。 img_1.png 那么这个nul是哪来的呢?结合代码分析一下,向依赖集合中添加副作用函数只会发生在响应数据的get操作中,所以应该是在响应式 系统的工作流程中,对响应式对象做了多余的读取操作。
除了第一次执行effect时对响应式对象做了读取操作,还有哪里呢?bucket.forEach(fn => fn()),就是这段代码,触发依赖的时候 也对响应式对象做了读取操作。
触发依赖时读取了响应式对象,这时触发它的get操作,bucket.add(activeEffect)执行了收集依赖这段代码。 因为effect执行完毕之后将activeEffect设置成了null,因此在触发依赖的过程中依赖集合中多了一个null,导致报错。


极简的响应式系统_v3

上面问题的解决方法很简单,用一个全局变量判断当前是否需要收集依赖。目前的实现只需要在effect函数执行的时候收集依赖, 那么在收集依赖之前将这个全局变量置为true(即需要收集依赖),依赖收集操作完成后再设置为false。

代码实现:

  // 存储副作用函数
  const bucket = new Set()
  // 用来保存副作用函数
  let activeEffect
  // 收集依赖的开关
  let shouldTrack
  function effect(fn) {
    activeEffect = fn
    // 执行fn之前,打开开关
    shouldTrack = true
    // 执行一次fn,触发它的读取操作
    fn()
    // fn执行完毕,关闭开关
    shouldTrack = false
    // 依赖收集之后重置为null
    activeEffect = null
  }
  // 原始数据
  const data = { text: 'hello world' }
  // 创建一个data对象的代理对象
  const obj = new Proxy(data, {
    get(target, key) {
      // 将副作用函数添加到容器中
      if (shouldTrack) {
        bucket.add(activeEffect)
      }
      // 返回属性值
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      // 设置属性值
      const res = Reflect.set(target, key, value)
      // 把副作用函数从容器中取出并只执行
      bucket.forEach(fn => fn())
      // 返回res设置操作成功
      return res
    }
  })

解释一下为什么要在执行fn前/后 打开/关闭收集依赖的开关吧
执行fn是为了触发响应式数据的get操作,也就是为了收集依赖,所以在执行fn之前打开收集依赖的开关; fn执行完依赖也收集完毕,关闭依赖收集的开关。之后触发依赖时再触发响应式对象的get操作,不会做多余的收集依赖操作。上面的问题就解决了。
下面是修复后的响应式系统: 极简的响应式系统_v3

因为创建响应式对象的操作比较常见,可以将其封装为一个函数

const reactive = (target) => {
  return new Proxy(target, {
    get(target, key) {
      // 将副作用函数添加到容器中
      if (shouldTrack) {
        bucket.add(activeEffect)
      }
      // 返回属性值
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      // 设置属性值
      const res = Reflect.set(target, key, value)
      // 把副作用函数从容器中取出并只执行
      bucket.forEach(fn => fn())
      // 返回res设置操作成功
      return res
    }
  })
}

极简的响应式系统_v4

相比开始那种硬编码的响应式,现在已经实现了一个相对成熟的响应式系统。但是还有存在一个很大的缺陷,如果有多个响应式对象呢,该怎么样收集依赖。
在这之前先分析上面代码,整个响应式系统中都存在哪些角色,这些角色之间有哪些关系,再根据结果决定使用哪种数据结构来存放依赖关系。

const obj = { name: '蒙奇·D·路飞', age: 16 }
const proxy = reactive(obj)
effect(() => {
  document.write(`${obj.name}`)
})
obj.name = '罗罗诺亚·索隆'

上面这段代码是一个使用响应式系统的简单案例,该案例中存在三个角色,
1.被代理的对象target
2.被读取/设置的字段名key
3.使用effect函数注册的副作用函数fn(effectFn)

这三个角色可能存在以下五种关系

  • target > - key > - effectFn
    > 一个副作用函数依赖一个响应式对象的一个key;对应代码:
    > effect(() => { document.write(${obj.name}) })

  • target > - key > - effectFn1

    • effectFn2
      > 两个副作用函数依赖同一个响应式对象的同一个key;对应代码:
      > effect(() => { document.write({obj.name}`) })` > `effect(() => { console.log(`{obj.name}) })
  • target > - key1 > - effectFn1

    • key2 > - effectFn2
      > 两个副作用函数依赖同一个响应式对象的不同key;对应代码:
      > effect(() => { document.write({obj.name}`) })` > `effect(() => { document.write(`{obj.age}) })
  • target1 > - key1 > - effectFn1

  • target2 > - key2 > - effectFn2
    > 两个副作用函数依赖不同的响应式对象的key;对应代码:
    > effect(() => { document.write({obj1.name}`) })` > `effect(() => { document.write(`{obj2.age}) })

  • target > - key1 > - effectFn

    • key2 > - effectFn
      > 同一个副作用函数依赖同一个响应式对象的不同key;对应代码:
      > effect(() => { document.write(obj.name{obj.name}{obj.age}) })

根据以上内容,可以得出以下结论:

  • 1.一个应用可能存在多个对象
  • 2.一个对象可能存在多个key
  • 3.一个key可能对应多个副作用函数
映射关系

那么该如何映射这些依赖、key和对象的关系呢?从小到大来说: 一个key可能对应多个副作用函数:用一个Set结构来保存key对应的副作用函数 Set[fn1, fn2]
一个对象可能存在多个key:用Map结构保存这个 key 和 这个key对应的副作用函数集合 Map{ key1: set1, key2: set2 }
一个应用中可能存在多个对象:用WeakMap保存这个对象 target 和 target对应的Map WeakMap{ target1: map1, target2: map2 }

数据结构
WeakMaptarget(原始对象)Map
Map(原始对象的)keySet
Set/副作用函数

看下图表示,更直观一些
image.png

再细说一下为什么使用这些数据结构:
一个key可能对应多个依赖项,我们可以将这些依赖项放入一个数组中,因为在触发依赖时, 一个副作用函数只需要被执行一次,所以要保证这些依赖项是唯一的,因此一个key对应的依赖集合通过Set来保存。 给依赖集合起名为dep
接着,一个对象可能存在多个key,这个key和它的依赖集合也需要有一个映射关系, 我们就可以通过一个Map来保存,以key作为Map的键,以依赖集合(Set)作为Map的值。 给Map起名为keyDeps,它里面保存的是所有key和key的依赖集合
最后,一个应用中可能存在多个响应式对象,这个响应式对象和上一步的Map也需要有一个映射关系,我们可以通过WeakMap 来收集,以对象作为WeakMap的键,以Map作为WeakMap的值。 给WeakMap起名为targetMap,它里面保存的是一个对象和对象下的所有key对应的所有依赖集合


实现最终版本

// 用来保存副作用函数
  let activeEffect
  // 收集依赖的开关
  let shouldTrack
  // 保存原始对象与依赖集合之间的关系
  const targetMap = new WeakMap()

  const reactive = (raw) => {
    return new Proxy(raw, {
      get(target, key) {
        if (shouldTrack) {
          // 收集依赖
          track(target, key)
        }
        // 返回属性值
        return Reflect.get(target, key)
      },
      set(target, key, value) {
        // 设置属性值
        const res = Reflect.set(target, key, value)
        // 触发依赖
        trigger(target, key)
        // 返回res设置操作成功
        return res
      }
    })
  }

  // 收集依赖的操作
  function track (target, key) {
    // 取出原始对象对应的map结构
    let keyDeps = targetMap.get(target)
    if (!keyDeps) {
      keyDeps = new Map()
      targetMap.set(target, keyDeps)
    }
    // 从map中取出key对应的依赖集合
    let deps = keyDeps.get(key)
    if (!deps) {
      deps = new Set()
      keyDeps.set(key, deps)
    }
    // 收集副作用函数到依赖集合中
    deps.add(activeEffect)
  }

  // 触发依赖
  function trigger (target, key) {
    // 与收集依赖时同理
    const keyDeps = targetMap.get(target)
    const deps = keyDeps.get(key)
    // 遍历依赖集合,执行所有副作用函数
    deps.forEach(fn => fn())
  }

  function effect(fn) {
    activeEffect = fn
    // 执行fn之前,打开开关
    shouldTrack = true
    // 执行一次fn,触发它的读取操作
    fn()
    // fn执行完毕,关闭开关
    shouldTrack = false
    // 依赖收集之后重置为null
    activeEffect = null
  }

结合案例看最终实现的响应式系统:
极简的响应式系统_v4

该案例中的五个fn函数覆盖了上面分析三个角色的五种情况:

  • 一个副作用函数依赖一个响应式对象的一个key:任何一个fn
  • 两个副作用函数依赖同一个响应式对象的同一个key:fn1 & fn2
  • 两个副作用函数依赖同一个响应式对象的不同key:fn2 & fn3
  • 两个副作用函数依赖不同的响应式对象的key:fn3 & fn4
  • 同一个副作用函数依赖同一个响应式对象的不同key:fn5

改造响应式系统

最后参考vue3源码的实现,将其改造为class写法更优雅一些,也方便后续功能的实现。

img_2.png

虽然使用typescript实现,但是下面代码没有多少typescript知识点,当作JavaScript来看就行。
下面将收集和触发依赖的具体操作抽离了出来,方便后面实现ref复用。

// 暂存当前的依赖项
let activeEffect
// 是否应该收集依赖,收集依赖的一个开关
let shouldTrack = false
// 保存所有的依赖
let targetMap = new WeakMap()

// 创建一个依赖项
export class ReactiveEffect {
   private _fn: any
   constructor(fn) {
      this._fn = fn
   }
   run () {
      // 保存当前副作用函数,这里this就是下面effect函数返回的依赖对象
      activeEffect = this
      // 否则,打开收集依赖的开关
      shouldTrack = true
      this._fn()
      // fn执行完毕后将开关关闭
      shouldTrack = false
   }
}

function effect (fn) {
  // 根据副作用函数创建一个依赖项
  const _effect = new ReactiveEffect(fn)
  // 调用这个依赖项的run方法
  _effect.run()
}

// 收集依赖
export function track (target, key) {
   if (!isTracking()) return

   // 获取 target 对象对应的依赖,也是一个Map,这个Map保存着这个对象所有key的依赖
   let keyMap = targetMap.get(target)
   // 如果这个对象还没有对应的依赖,则创建并添加到 targetMap 中
   if (!keyMap) {
      keyMap = new Map()
      targetMap.set(target, keyMap)
   }

   // 获取target对象的 key 属性对应的依赖,是一个Set(避免依赖重复收集)
   let deps = keyMap.get(key)
   // 如果这个target对象的 key 属性没有对应的依赖,则创建并添加到 keyMap 中
   if (!deps) {
      deps = new Set()
      keyMap.set(key, deps)
   }
   trackEffect(deps)
}

// 将收集依赖的操作抽离出来
export function trackEffect (deps) {
   // 将这个 effect 实例添加到这个 key 的依赖集合中
   deps.add(activeEffect)
}

// 触发依赖
export function trigger (target, key) {
   // 获取 target 对象的依赖
   const keyMap = targetMap.get(target)
   // 获取target对象的 key 属性对应的依赖
   const deps = keyMap.get(key)
   triggerEffect(deps)
}

// 将触发依赖的操作抽离出来
export function triggerEffect (deps) {
   // 触发所有依赖
   for (const effect of deps) {
      // 如果有调度器方法则执行,否则直接执行run方法
      if (effect.scheduler) {
         effect.scheduler()
      } else {
         effect.run()
      }
   }
}

这是改造后的响应式系统写的案例

代理原始值

因为proxy不能代理原始值,所以对于原始值的处理方式需要做一些修改,但与代理引用类型的思路差不多。
先看看如果不做代理,直接使用原始值会发生什么:

effect(() => {
  document.body.xxx.innerText = 1
})
effect(() => {
  document.body.yyy.innerText = 1
})

上面代码中两个副作用函数为页面中的某个元素设置相同的文本内容 1 ,一个应用中可能出现很多个相同的原始值,把这个原始值的所有依赖 收集起来显然不现实,而且这两个1可能各自有不同的意义。那么应该怎么区分它们呢?
对它们进行包装,使他们成为唯一的数据,互不影响。可以使用对象访问器属性来拦截他们的读取和设置操作。

代码实现:代理原始值的实现过程相对引用类型简单一些,不存在Map的映射关系,一个原始数据只对应一个依赖集合。 可以直接复用实现reactive时收集依赖/触发依赖的操作。

class Ref {
  constructor(value) {
    // 保存传入的参数
    this._value = value
    // 依赖集合
    this.depsList = new Set()
  }
  get value () {
    // 收集依赖
    if (isTracking()) {
      trackEffect(this.depsList)
    }
    return this._value
  }
  set value (value) {
    // 先更新_value
    this._value = value
    // 再触发依赖
    triggerEffect(this.depsList)
  }
}

代理原始值对应的案例在这里


响应丢失问题

ref除了能够用于原始值的响应式方案之外,还可以用来处理响应丢失问题。

先看下什么是响应性丢失:

const data = reactive({
  name: '文斯莫克·山治',
  age: 20
})
const age = data.age
const obj = { ...data }
const box = document.getElementById('box')
effect(() => {
  box.innerText = `${obj.name}${age}岁`
})
setTimeout(() => {
  obj.name = '布鲁克'
  age = 30
}, 2000)

案例在这里

上面代码中先创建一个响应式对象data,再分别使用.和扩展运算符...读取data中的属性,并将读取后的属性渲染到页面中。 接着修改读取的这些属性,发现并没有触发依赖,因为响应性丢失了,而丢失的原因正是.和扩展运算符...导致的。
因为使用这两个运算符读取属性相当于直接给一个变量赋值:

const data = reactive({
  name: '文斯莫克·山治',
  age: 20
})
const age = data.age // 相当于 const age = 20
const obj = { ...data } // 相当于 const obj = { name: '文斯莫克·山治', age: 20 }

ageobj这两个变量并不是响应性的数据。

间接访问响应式数据

那么怎么解决这个问题呢?或者说怎么实现:在副作用函数内,即使通过普通变量来访问属性值,也能够加你响应联系呢?

const data = reactive({
  name: '文斯莫克·山治',
  age: 20
})
let age = {
  get value () {
    return data.age
  },
  set value (val) {
    data.age = val
  }
}
const obj = {
  name: {
    get value () {
      return data.foo
    }
  },
  age: {
    get value () {
      return data.age
    }
  }
}

这个obj对象具有与data同名的属性,且每个属性值都是一个对象,这个对象有一个访问器属性value,读取value属性的值时,实际上 读取的时data对象相应的属性值(age同理,也是一个对象通过value访问器属性简介访问响应式对象的属性)。
案例在这里

上面obj对象的name和age属性以及变量age,他们都有相似的结构,所以可以将简介访问响应式对象的操作抽象出来。

const toRef = (data, key) => {
  return {
    get value () {
      return data[key]
    },
    set value (val) {
      data[key] = val
    }
  }
}

export const toRefs = (data) => {
  const result = {}
  for (const key in data) {
    result[key] = toRef(data, key)
  }
  return result
}

toRef方法将上面简介访问响应式对象的操作抽象出来,返回一个对象,这个对象包含访问器属性value,访问value属性时简介访问响应式对象。 toRefs是对响应式对象的所有属性做toRef的操作。

const age = toRef(data, 'age')
const obj = { ...toRefs(data) }

toRef和toRefs的使用案例

上面案例中所有代码都在这里,这个简易的响应式系统对阅读 vue3源码相信有一定帮助,上面实现如果有什么问题或更好的想法,欢迎交流。