跳至主要內容

Vue3 响应系统

wzCoding大约 13 分钟VueVue3Vue3 响应系统

Vue3 响应系统

响应系统是 Vue 的重要组成部分,它用于实现数据响应式和依赖收集。在 Vue3 中,响应系统是基于Proxy实现的,Proxy API 提供了更加全面的数据拦截操作,这使得 Vue3 中的响应系统更加高效和灵活。

副作用函数

在介绍响应式数据之前,我们先来了解一个概念:副作用函数(side effect function)。副作用函数是指那些除了返回值之外,还会对系统的状态产生影响或与外部进行交互的函数。这些影响包括但不限于:

  • 改变全局变量或者静态变量的值
  • 修改函数的输入参数
  • 进行输入/输出操作,例如读写文件或者在控制台上打印信息
  • 调用其他副作用函数
const obj = { text: 'Hello, World!' }
function effect() {
  document.body.innerText = obj.text
}

effect()  //会在body中写入'Hello, World!'

响应式原理

响应式是指当数据发生变化时,能够自动触发相关副作用函数执行的机制。

基本实现

Vue3 采用了 Proxy API 来实现响应式数据。通过 Proxy 创建一个代理对象,该代理对象会拦截对目标对象的访问和修改操作,并在这些操作发生时触发相应的副作用函数执行。

const obj = { text: 'Hello, World!' }
const track = (target, key)=> { console.log(`tracked deps ${target}`) }
const trigger = (target,key,value)=> { console.log(`the value of ${key} has changed to: ${value}`) }
const proxy = new Proxy(obj, {
  get(target, key) {
    //在这里收集依赖
    track(target, key)
    return target[key]
  },
  set(target, key, value) {
    target[key] = value
    //在这里触发依赖
    trigger(target,key,value)
    return true
  }  
})

proxy.text  
// 'Hello, World!'
// 输出:tracked deps { text: 'Hello, World!' }
proxy.text = 'Hello, Vue3!'
// 输出:the value of text has changed to: Hello, Vue3!

上面的示例简单的实现了响应式数据,当修改代理对象时,会触发相应的副作用函数执行。在Vue3中,真实的响应式数据的实现要复杂得多,需要考虑更多的场景和边界条件以及性能优化,但基本原理还是类似的。

收集依赖

在上面的示例中,我们了解了响应式数据的基本实现原理。但是,上面的示例并不完善,它还缺少了一个重要的功能:依赖收集。依赖收集是指在读取响应式数据时,自动收集相关的副作用函数。

由于响应式数据本身可以是一个简单的对象,也可以是由多个对象嵌套形成的复杂对象,所以最直接的方式便是将响应式数据本身作为键来创建集合,它的值便是和对应属性相关的副作用函数(一个属性可以对应多个副作用函数)的集合,这样便在响应式数据与副作用函数之间建立了联系,以便于之后在修改数据时能够精确的找到副作用函数并执行。

以下是简单的代码实现:

const data = { value:100 }

//使用 Symbol 来作为键,确保不会与响应式数据的其他属性发生冲突
const ITERATE_KEY = Symbol()
//设置 data 的代理对象,转变为响应式数据
const proxyData = new Proxy(data,{
    //读取属性
    get(target,key,receiver){
       //在这里收集依赖
       track(target,key)
       return Reflect.get(target,key,receiver)
    },
    //设置属性
    set(target,key,value,receiver){
       //获取旧值
       const oldValue = target[key]
       //判断操作的类型
       const type = Object.prototype.hasOwnProperty.call(target,key) ? 'SET' : 'ADD'
       const res = Reflect.set(target,key,value,receiver)
       //判断属性值是否发生变化
       if(oldValue !== value){
          //在这里触发依赖
          trigger(target,key,type)
       }
      
       return res
    },
    //遍历对象属性
    ownKeys(target){
      //在这里收集依赖
      track(target, ITERATE_KEY)
      return Reflect.ownKeys(target)
    },
    //判断属性是否存在
    has(target,key){
      //在这里收集依赖
      track(target,key)
      return Reflect.has(target,key)
    },
    //删除属性
    deleteProperty(target,key){
      //判断代理对象是否存在该属性
      const hasKey = Object.prototype.hasOwnProperty.call(target,key)
      const res = Reflect.deleteProperty(target,key)
      if(hasKey && res){
        //当成功删除属性时,在这里触发依赖
        trigger(target,key,'DELETE')
      }
      return res
    }
})

//修改 data 的代理对象 proxyData 属性value的值
proxyData.value = "Hello World"
//执行与 proxyData 相关的副作用函数,这里的匿名函数会作为参数对应 activeEffect
effect(() => { console.log(`the value of data has changed to: ${proxyData.value}`) })

//用来存储与响应式数据相关副作用函数的集合,WeakMap可以保证当响应式数据被垃圾回收时,对应的副作用函数也会被自动清除
const effectsMap = new WeakMap()

//使用全局变量来存储副作用函数,匿名函数也可以存储
let activeEffect = null

function track(target,key,type) {
    //如果没有相应的副作用函数,直接 return
    if(!activeEffect) return
    //获取响应式数据所对应的副作用函数的依赖集合
    let depsMap = effectsMap.get(target)
    if(!depsMap) {
      //创建响应式数据对应的依赖集合
      depsMap = new Map()
      effectsMap.set(target, depsMap)
    }
    //从响应式数据对应的依赖集合中获取当前属性对应的依赖集合
    let deps = depsMap.get(key)
    if(!deps) {
      //创建响应式数据属性对应的依赖集合
      deps = new Set()
      depsMap.set(key, deps)
    }
    //将当前副作用函数添加到依赖集合中
    deps.add(activeEffect)
}

触发依赖

在上面的示例中,我们了解了 Vue3 收集依赖的基本原理,在收集好依赖之后,我们还需要在修改响应式数据时,触发相应的副作用函数。

在触发副作用函数执行时,我们还有必要对修改响应式数据的操作类型进行分类处理,,以避免不必要的重复触发从而造成性能上的损耗。

以下是简单的代码实现:


function trigger (target,key, type) {
  //获取响应式数据所对应的依赖集合
  const depsMap = effectsMap.get(target)
  if(!depsMap) return
  //获取响应式数据属性对应的依赖集合
  const deps = depsMap.get(key)
  if(!deps) return
  //新建一个用于执行副作用函数的依赖集合(因为activeEffect是全局变量,可能会被其他地方修改),这样可以避免副作用函数循环触发,更加安全
  const effectsToRun = new Set()
  //遍历响应式数据属性对应的依赖集合,将需要执行的副作用函数添加到 effectsToRun 集合中
  deps.forEach(effectFn=> {
    //判断当前副作用函数是否已经存在于 effectsToRun 集合中,如果已经存在,则说明当前副作用函数已经被执行过了,无需再次执行
    if(effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  //判断触发副作用函数的操作类型(ADD:新增,DELETE:删除)
  if(type === 'ADD' || type === 'DELETE') {
     //从响应式数据对应的依赖集合中获取 ITERATE_KEY 对应的依赖集合
     const iterateEffects = depsMap.get(ITERATE_KEY)
     //遍历 ITERATE_KEY 对应的依赖集合,将需要执行的副作用函数添加到 effectsToRun 集合中
     iterateEffects && iterateEffects.forEach(effectFn=> {
       //判断当前副作用函数是否已经存在于 effectsToRun 集合中,如果已经存在,则说明当前副作用函数已经被执行过了,无需再次执行
       if(effectFn !== activeEffect) {
         effectsToRun.add(effectFn)
       }
     })
  }
   
  //执行 effectsToRun 集合中的副作用函数
  //遍历依赖集合,执行副作用函数
  effectsToRun.forEach(effectFn=> {
    //判断副作用函数是否有自己的调度配置,按照配置的调度逻辑执行
    if(effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

任务调度执行

副作用函数的调度执行也是 Vue3 响应系统的重要功能之一,它可以使我们能够自己决定副作用函数的执行时机和执行顺序以及执行次数。

在 Vue3 中,副作用函数的执行顺序是按照它们被创建的顺序依次执行的,但是我们可以通过配置副作用函数的调度执行逻辑,来改变副作用函数的执行顺序和时机。

以下是简单的代码实现:

// 创建一个 Set 集合来模拟调度任务队列,存储副作用函数,避免重复
const jobQuene = new Set()
// 创建一个 Promise 将任务添加到微任务队列中,控制调度任务执行
const p = Promise.resolve()
// 创建一个 flush 标识,用于判断是否刷新了队列并执行
let isFlushing = false

function flushJob() {
  //如果 isFlushing = true,表示刷新了调度任务队列且正在调度执行副作用函数,就什么都不干
  if(isFlushing) return
  //将 isFlushing 设置为 true,表示刷新了调度任务队列且正在调度执行副作用函数
  isFlushing = true
  //使用 Promise.resolve() 创建一个微任务,确保在当前宏任务执行完毕后,再执行微任务
  p.then(() => {
    //遍历调度任务队列,执行副作用函数
    jobQuene.forEach(job => job())
  }).finally(() => {
    //将 isFlushing 重置为 false,表示调度任务队列执行副作用函数完成
    isFlushing = false
  })
}

执行副作用函数

在 Vue3 中,与响应式数据相关的副作用函数会被收集到一个集合中,当响应式数据被修改时,会触发相应的副作用函数执行。然而在实际开发中,副作用函数还可能会存在嵌套的情况,这会导致副作用函数的执行变得难以控制,因此就需要设计一个合理的数据结构来避免这种情况。

以下是简单的代码实现:

//使用数组来模拟一个 effect 方法的执行栈,
const effectStack = []

function effect(fn,{ //options
  scheduler(fn){
      //往调度任务队列里面添加当前副作用函数
      jobQuene.add(fn)
      //刷新调度任务队列并执行
      flushJob()
  }
}) {
    //将当前副作用函数赋值给全局变量,表示当前执行的副作用函数
    const effectFn = ()=> {
        //在执行当前传入的副作用函数前,需要先清理掉上一次旧的副作用函数
        cleanup(effectFn)
        //将当前执行的副作用函数赋值给之前定义的全局变量,以便后续使用(判断)
        activeEffect = effectFn
        //模拟函数调用栈,将当前执行的副作用函数压入栈中保存
        effectStack.push(effectFn)
        //执行副作用函数
        fn()
        //执行完副作用函数后,将当前执行的副作用函数弹出栈
        effectStack.pop()
        //将全局变量的值恢复为上一个副作用函数
        activeEffect = effectStack[effectStack.length-1]
    }
    //给 effectFn 添加一个配置对象,用于存储副作用函数的调度执行逻辑
    effectFn.options = options
    //给 effectFn 添加 deps 属性,用于存储与响应式数据相关副作用函数的集合
    effectFn.deps = []
    //执行副作用函数
    effectFn()
}

清除副作用函数

在执行副作用函数时,我们希望每次都能拿到最新的依赖来执行,如此以来我们就需要在每次执行副作用函数之前,先清除上一次的副作用函数。

以下是简单的代码实现:

function cleanup(effectFn) {
  //遍历 effectFn 的 deps 依赖集合(这个 deps 集合中存储的都是与响应式数据相关的副作用函数),清除上一次的依赖
  effectFn.deps.forEach(deps=> {
    // 每个 deps 都是一个依赖集合,从依赖集合中删除旧的 effectFn
    deps.delete(effectFn)
  })
  //删除旧的 effectFn 后对 effectFn 的依赖集合进行重置 
  effectFn.deps.length = 0
}

底层机制

通过上面的几个示例,我们对 Vue3 响应系统的基本实现原理已经有了初步的了解。实际上,Vue3 的响应系统的实现不仅依赖于 Proxy,而且还需要用到 Reflect

Proxy

Proxy 是一个内置对象,它提供了一种机制,允许我们创建一个代理对象,并且可以让我们自由定义在访问或修改该代理对象时的一些处理逻辑(副作用函数),代理对象可以拦截对目标对象的访问和修改操作,并在这些操作发生时触发相应的副作用函数执行。

我们在使用 Proxy 创建代理对象并设置相关的副作用函数时,实际上是使用了 Proxy 对象上所部署的内部方法,这些内部方法如下表所示:

内部方法对应处理函数描述
[[GetPrototypeOf]]getPrototypeOf()获取代理对象的原型对象
[[SetPrototypeOf]]setPrototypeOf(Object)设置代理对象的原型对象
[[IsExtensible]]isExtensible()判断代理对象是否可扩展
[[PreventExtensions]]preventExtensions()阻止代理对象扩展
[[Get]]get(Target,Key,Receiver)获取代理对象属性值
[[Set]]set(Target,Key,Value,Receiver)设置代理对象属性值
[[Delete]]deleteProperty(Target,Key)删除代理对象属性
[[HasProperty]]has(Target,Key)判断代理对象是否有某个属性
[[OwnPropertyKeys]]ownKeys()获取代理对象的所有属性
[[GetOwnProperty]]getOwnPropertyDescriptor(Target,Key)获取代理对象某个属性的属性描述符
[[DefineOwnProperty]]defineProperty(Target,Key,PropertyDescriptor)定义代理对象某个属性
[[Call]]apply(Target,ThisArg,Arguments)设置代理对象的 this 指向
[[Construct]]construct(Target,ArgumentsList,NewTarget)创建代理对象的新实例

由上表可见,Proxy 对象上所部署的内部方法基本上涵盖了代理对象所支持的所有操作,为 Vue3 响应系统的构建提供了较为全面的支持。

Reflect

Reflect 也是一个内置对象,它提供了一组静态方法,这些静态方法与 Proxy 对象上所部署的内部方法相对应,这些静态方法可以用于执行与 Proxy 对象上所部署的内部方法相同的功能,并且可以用于替代 Proxy 对象上所部署的内部方法。

Vue3 使用 Recflect 的主要作用是:

  • 响应系统的稳定性:Reflect 对象的方法大多与 Object 对象的同名方法行为一致,但是 Recflect 方法调用后会返回一个布尔值,表示方法调用执行的成功与否,不会影响代码继续执行,而 Object 上的方法则会在执行失败时抛出错位,阻塞代码执行。

  • 响应数据行为一致性:利用 Reflect 对象方法的第三个参数 receiver,可以指定合适的接收者(或者可以理解为改变源数据 this 的指向),从而保证了代理对象与源数据在行为(方法调用)上的一致性。

Reflect 对象上所部署的静态方法如下表所示:

Reflect方法对应内部方法描述
Reflect.getPrototypeOf()[[GetPrototypeOf]]获取代理对象的原型对象
Reflect.setPrototypeOf()[[SetPrototypeOf]]设置代理对象的原型对象
Reflect.isExtensible()[[IsExtensible]]判断代理对象是否可扩展
Reflect.preventExtensions()[[PreventExtensions]]阻止代理对象扩展
Reflect.get()[[Get]]获取代理对象属性值
Reflect.set()[[Set]]设置代理对象属性值
Reflect.deleteProperty()[[Delete]]删除代理对象属性
Reflect.has()[[HasProperty]]判断代理对象是否有某个属性
Reflect.ownKeys()[[OwnPropertyKeys]]获取代理对象的所有属性
Reflect.getOwnPropertyDescriptor()[[GetOwnProperty]]获取代理对象某个属性的属性描述符
Reflect.defineProperty()[[DefineOwnProperty]]定义代理对象某个属性
Reflect.call()[[Call]]设置代理对象的 this 指向
Reflect.construct()[[Construct]]创建代理对象的新实例

Recflect 对象上的静态方法,方法名称和参数与 Proxy 对象上所部署的内部方法一一对应,并且更加稳定和灵活,同样为 Vue3 响应系统的构建提供了较为全面的支持。

参考信息

ℹ️提示

本页面代码示例仅用于说明 Vue3 响应式数据的基本实现原理,没有考虑更多的场景和边界条件以及性能优化的方法,并非真实Vue3代码。

真实 Vue3 源代码,请参考: