Gerden Blog logo
|
|

Vue 3 响应式原理新手简单教程

10分钟学会Vue 3的响应式原理,带你拨开重重抽象,简单易懂!

Vue 3 的响应式变量使得开发者得以简单高效地开发响应式界面。在日常开发的学习之外,Vue 3 响应式原理的设计同样非常巧妙优雅。本文将带你逐层拨开Vue 3响应式原理的运行逻辑。

1. 响应式的本质

日常开发中,响应式变量的定义和使用我们都已经相当熟练了。当一个响应式变量发生变化时,涉及该变量的组件渲染函数、watchEffect() 回调以及 computed() 内部的计算等都要重新执行。我们称这些因响应式变量发生变化而出现执行的运算为副作用,而响应式变量则是这些副作用的依赖

这套依赖-副作用的响应式系统是怎么工作的呢?其实原理相当简单。举个例子:每个依赖(被副作用读取的响应式属性)都是一个零件生产商,而副作用(例如组件的渲染副作用)则是零件加工厂。当零件发生变化时,零件生产商(依赖)就需要通知每个零件加工厂(副作用)来获取最新的零件,这样一来所有需要该零件的加工厂都能及时用最新的零件进行加工。

这就是响应式的本质:依赖与副作用深度绑定,当依赖发生变化,通知所有订阅了该依赖的副作用重新执行,从而实现响应式。从思想上看,这套机制与观察者模式和发布—订阅模式非常相似:响应式属性记录订阅它的副作用,并在值发生变化时通知这些副作用。

2. 如何实现响应式系统

响应式的原理搞清楚了,我们现在还有几个问题:

  1. 如何存储依赖和副作用呢?
  2. 副作用如何订阅依赖?
  3. 依赖发生变化的时候,如何去通知副作用?
  4. ref和reactive如何实现?

为了突出核心原理,下面将实现一个最小版响应式系统。它省略了 Vue 中的新旧值比较、嵌套对象转换、依赖清理、副作用调度、批量更新、数组与集合处理等机制,因此用于理解设计思路,而非还原完整源码。

2.1 存储依赖和副作用

  • 一个依赖通常有多个订阅它的副作用。对此,我们可以用一个Set来存储这些副作用Set<effect>
  • 为了让依赖和订阅它的副作用关联起来,我们使用一个Map来存储他们的关系Map<Key, Set<effect>>
  • Vue的响应式变量都是基于对象封装的,每个响应式变量都是对象的一个属性。因此我们还需要一个全局的Map来存储所有这些对象。我们使用WeakMap<Target, Map<Key, Set<effect>>>来存储所有这些对象。

最终,我们就通过一个WeakMap存储了响应式系统里面的依赖和副作用。下面我们来看看副作用是如何订阅依赖的。

2.2 副作用订阅依赖

前面我们说过,副作用通过订阅依赖,在依赖发生变化的时候重新执行,实现响应式。

实现副作用订阅,我们需要一个函数用于追踪。当某个依赖被访问时,我们需要将该副作用存储到这个依赖的副作用集合中。

下面是追踪函数的基础实现:

我们用一个全局变量表示我们追踪到的副作用。(如何追踪后面会讲,这里先假设我们已经知道这个副作用需要订阅依赖)

let activeEffect = null // 未追踪到任何副作用时为null

下面定义track()函数:将副作用activeEffect存入依赖的副作用集合Set

const targetMap = new WeakMap() // 全局映射,存储依赖与订阅的容器,见2.1

// target 和 key 共同确定一个响应式属性,也就是一个依赖
function track(target, key) {
  // 如果当前没有活跃的副作用,停止
  if (!activeEffect) return 

  // 寻找该对象中存储了所有依赖的映射
  let depsMap = targetMap.get(target) 
  // 这里depsMap的类型是Map<Key, Set<effect>>, 见2.1

  // 假如还没有映射,则创建
  if (!depsMap) { 
    targetMap.set(target, new Map())
    depsMap = targetMap.get(target)
  }

  // 寻找该依赖的副作用集合
  let deps = depsMap.get(key) 
  // deps的类型是Set<effect>, 见2.1

  // 假如还没有映射,则创建
  if (!deps) { 
    depsMap.set(key, new Set())
    deps = depsMap.get(key)
  }

  // 收集副作用,完成订阅
  deps.add(activeEffect) 
}

2.3 通知副作用

当依赖的值发生变化,我们需要一个触发函数来依次通知订阅该依赖的副作用重新执行。

下面定义trigger()函数,用于通知副作用并重新执行

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const effects = depsMap.get(key)
  if (!effects) return

  for (const effect of effects) {
    effect()
  }
}

track()trigger()两个函数共同实现了响应式系统的订阅通知功能,构成了Vue的响应式基础。接下来,我们需要在副作用读写依赖的时候相应地触发这两个函数。refreactive的封装就是为了实现这个目的。

2.4 ref 和 reactive 的实现

普通 ref 通过 .value 提供响应式读写入口。要实现响应式,我们需要在副作用读取value的时候触发track(),让副作用订阅依赖。还需要在副作用修改value的值的时候触发trigger(),通知其他副作用重新执行。要实现这个效果,我们只需要使用JavaScript的访问器属性即可。访问器属性可以在属性被读取或修改时执行对应的 getter 或 setter。JavaScript 无法直接拦截普通局部变量的读写,因此ref 将值包装在对象中,通过.value的 getter/setter 拦截访问和修改。闭包中的变量用于保存内部值。

简易ref实现

// 此处的myValue是闭包变量
function ref(myValue) { 
    const obj = {
        get value() { 
            // 外部通过.value访问时自动触发
            // 触发track,收集副作用
            track(obj, 'value')  
            // 将依赖的值返回
            return myValue 	     
        },
        set value(newValue) { 	 
           // 外部修改.value的值时触发
           // 修改依赖的值
           myValue = newValue
           // 触发trigger,通知所有订阅的副作用重新执行
           trigger(obj, 'value') 
           return
        }
    }
    return obj
}

需要注意:暴露在外的 obj.value 是访问入口,内部的 myValue 才是真实存储

接下来我们看看reactive的实现。reactive() 接收一个对象,并返回该对象的响应式代理。与ref的实现同理,我们需要拦截JavaScript对象的读写,而Proxy是一个很合适的选择。

简易reactive实现

function reactive(obj) {
  return new Proxy(obj, {
    // 拦截读取
    get(target, key, receiver) { 
      // 触发track,收集副作用
      track(target, key)  
      // 将依赖的值返回
      return Reflect.get(target, key, receiver) 
    },
    // 拦截修改
    set(target, key, newValue, receiver) { 
      // 修改依赖的值
      const result = Reflect.set(target, key, newValue, receiver) 
      // 触发trigger,通知所有订阅的副作用重新执行
      trigger(target, key) 
      return result
    }
  })
}

reactive的实现相对简单,道理与ref是一样的,就不再赘述了。

现在,track() + trigger() + ref/reactive 的路线终于连通了。我们还剩下最后一个问题:如何让activeEffect与当前活跃副作用绑定?

2.5 获取当前活跃的副作用

还记得我们前面使用了一个全局变量来记录副作用:

let activeEffect = null

这个变量在track()函数中使用(见2.1),用于给依赖收集副作用。那么我们回答前面一直没有讨论的问题,这个activeEffect是如何锁定副作用的?

假设我们有一个函数update()用到了一个依赖A

let B = 0
function update() {
    B = A.value + 1
}

当依赖A的值发生变化时,update()需要重新执行。因此我们需要将update()封装为一个副作用并订阅依赖A

要完成这个工作,我们需要定义一个函数,whenDepsChange(update)

function whenDepsChange(update) {
  function effect() {
    activeEffect = effect
    update()
    activeEffect = null
  }
  effect()
}

为了将 update() 注册为响应式副作用,vue会显式调用 whenDepsChange(update)。首先将activeEffect赋值为当前的effect,然后执行update()重点来了:由于update()的执行,意味着依赖A会被读取,从而触发track(),那么track()便可以将此时activeEffect的值——effect进行收集,从而实现了副作用的订阅。update()A的订阅便完成了。

至此,vue 3 的响应式系统基础框架就搭建好了。

3. 总结

ref 通过访问器属性拦截 .value 的读写,reactive 通过 Proxy 拦截对象属性的读写;whenDepsChange() 则负责注册并执行响应式副作用,使 track() 能够收集当前正在运行的副作用,trigger()用于在依赖被修改后通知所有订阅了该依赖的副作用。环环相扣,十分优雅。

Comments

No comments yet.