一次搞懂 Vue3 响应式原理

1,294 阅读12分钟

我们都知道 Vue3 的响应式原理主要基于 JavaScript 语言中的 Proxy 对象。与 Vue 2 中使用的 Object.defineProperty 不同,Proxy 允许 Vue 在访问或修改一个对象或数组时进行各种拦截和处理。最近系统学习了 Vue3 响应式的实现原理,记录一波。

源码调试: 下载完源码后,pnpm 安装完依赖后,通过添加 "dev:sourcemap": "node scripts/dev.js --sourcemap" 命令。

image.png

vue 源码中已经有很多可以调试使用的例子,目录在 packages/vue/examples 下面,可通过 VSCode 插件 LiveServer 起服务配合上面提到的 pnpm run dev:sourcemap进行代码调试。

具体可以参考 如何调试Vue3源码? - CherishTheYouth - 博客园

前言

无论 reactive、ref、computed 还是 watch 实现响应式的核心是 依赖收集 和 触发依赖。在使用数据的时候收集依赖,在数据发生变化的时候触发这些依赖,如完成页面更新。从这两方面入手会更好的理解响应式原理。

reactive

image.png

reactive 函数源码位置在 packages/reactivity/src/reactive.ts 。

reactive 通过在 Proxy get 方法中收集依赖,在 set 方法中触发依赖。实现数据变化,页面随之更新的效果。整个响应式数据存在变量 targetMap 中,以 msg 变量为例说明,在源码中将 targetMap 打印出来。

<script src="../../dist/vue.global.js"></script>

<div id="demo">
  <p @click="changeMsg">{{msg.count}}</p>
  <p>{{msg.content}}</p>
</div>

<script>
  const { createApp, ref, reactive, } = Vue
  createApp({
    setup(){
      const obj = {
        count: 1,
        content: 'hello world'
      }
      const msg = reactive(obj)
      const changeMsg = ()=>{
        msg.count++
      }

      return{
        msg,
        changeMsg,
      }
    }
  }).mount('#demo')
</script>

image.png

image.png 数据 { count: 1, content: 'hello world' } 存储在 targetMap 中,每个 key 都有对应的 dep(Set类型)存放依赖 effect 函数。例子的 effect 函数是 componentUpdateFn 更新组件的函数。

源码分析

createApp 函数在 packages/runtime-dom/src/index.ts 目录下,createApp 会返回一个 app 对象,app 对象含有 mount 方法。createApp 的调用逻辑如下:

createApp  --> ensureRenderer().createApp(...args) --> createRenderer() 
---> baseCreateRenderer()

baseCreateRenderer 返回一个对象

// packages/runtime-core/src/renderer.ts
function baseCreateRenderer(...){
  return {
    render,
    hydrate,
    createApp: createAppAPI(render, hydrate)
  }
}

从而调用 createAppAPI 方法,

// packages/runtime-core/src/apiCreateApp.ts
function createAppAPI(render, hydrate){
  const app: App = (context.app = {
  ...
  mixin(){}
  component(){}
  direactive(){}
  mount(){
    render()
  }
  })
  return app
}

返回的 app 对象拥有 mount 方法,调用 mount 方法,会调用传入的 render 函数,也就是 baseCreateRenderer内部闭包的 render 函数,而 render 函数调用过程如下:

 render() --> patch() -->  processComponent()  -->  mountComponent()  -->  setupRenderEffect()

setupRenderEffect 函数执行,会实例化一个 effect, 并且会执行 effect 的 run 函数,run 函数执行的关键在于通过 activeEffect = this 将 activeEffect 绑定为当前 effect。

在 Vue 3 中,reactive 函数使用 Proxy 对象代理数据的 get 和 set 方法。在 get 方法中,会执行 track 函数来收集依赖,并将当前活跃的副作用函数 activeEffect 添加到 targetMap 中。而在 set 方法中,会执行 trigger 函数来触发依赖,当数据发生变化时,会触发 targetMap 中相应的依赖函数。这样,通过 Proxy 的代理机制,Vue 能够追踪数据的访问和修改,并在数据变化时自动更新相关的依赖函数。

换句话说,首先会触发 new ReactiveEffect 实例化 effect,并将数据更新的副作用函数赋值给当前活跃的副作用函数 activeEffect。在这个过程中,副作用函数负责处理数据的变动和更新。紧接着,通过 Proxy 函数的 get 方法对数据对象进行依赖收集,将数据对象与副作用函数关联起来,形成依赖关系。这样一来,当数据发生变化时,就会触发 Proxy 函数的 set 方法,从而触发与变化数据相关的依赖函数(trigger)。这些依赖函数包括视图的更新或其他业务逻辑处理,从而实现响应式系统。

简化调试

在调试源码的过程中,使用 effect 函数可以简化代码并更清晰地理解源码的逻辑。

effect 函数是 Vue 3 提供的一个工具函数,用于创建一个副作用函数,并在副作用函数执行期间进行依赖收集。它接受一个函数作为参数,这个函数就是我们要创建的副作用函数。

通过将代码逻辑封装在 effect 函数中,我们可以更直观地看到依赖收集的过程。在副作用函数内部,当访问响应式对象的属性时,Vue3 会自动进行依赖收集,将当前副作用函数与属性建立关联。这样,当属性发生变化时,Vue3 就能够知道哪些副作用函数需要重新执行。

通过使用 effect 函数,我们可以简化源码中的依赖收集逻辑,使其更易于理解和调试。我们只需要关注副作用函数的定义和使用,而不必直接处理依赖收集的细节。这有助于我们更好地理解 Vue 3 的响应式机制,并能够更方便地追踪和调试源码中的逻辑。

使用方式如下:

<script>
  const {
    reactive,
    effect
  } = Vue
  const obj = reactive({
    name: '张三',
    age: 90,
  })

  effect(() => {
    document.querySelector('#p1').innerHTML = obj.age
  })

  effect(() => {
    document.querySelector('#p2').innerHTML = `姓名是:${obj.name}`
  })

  setTimeout(() => {
      obj.name = '李四'
    },
    2000)
</script>

ref

ref 函数源码位置在 packages/reactivity/src/ref.ts,通过源码我们可以知道 ref 函数会返回 RefImpl 类型的数据。

在 Vue 3 中,对于复杂类型(isObject),我们可以直接使用 reactive 函数将其转换为响应式数据,从而实现依赖收集和依赖触发的功能。reactive 函数会使用 Proxy 对象来代理复杂类型的数据,通过拦截其属性的访问和修改来进行依赖收集和触发。

而对于简单数据类型,Vue 3 使用了内部的 getValue 和 setValue 函数来实现依赖收集和依赖触发。当访问简单数据类型的值时,会执行 getValue 函数来进行依赖收集,将当前活跃的副作用函数与该简单数据类型建立关联。而当修改简单数据类型的值时,会执行 setValue 函数来触发依赖,从而重新执行与该简单数据类型相关联的副作用函数。

通过这样的优化,Vue 3 在处理复杂类型和简单数据类型时分别采取了不同的策略,以更高效地进行依赖收集和触发。复杂类型通过 reactive 函数转换为响应式数据,并使用 Proxy 进行依赖收集和触发,而简单数据类型则直接通过内部的 getValue 和 setValue 函数来完成依赖收集和触发的操作。这样的优化可以提高性能,并确保只有真正需要触发的依赖才会被执行,减少不必要的计算和更新。

ref 响应式调试源码示例:

<script>
  const { ref, effect } = Vue

  const obj = ref('张三')

  // 调用 effect 方法
  effect(() => {
    document.querySelector('#app').innerText = obj.value
  })

  setTimeout(() => {
    obj.value = '李四'
  }, 2000);
</script>

computed

computed 函数返回一个只读类型为 ComputedRefImpl 的响应式数据,源码调试示例:

<script>
  const {
    reactive,
    computed,
    effect
  } = Vue

  const obj = reactive({
    name: '张三'
  })

  const computedObj = computed(() => {
    return '姓名:' + obj.name
  })

  const initFn = function () {
    document.querySelector('#app').innerHTML = computedObj.value
  }

  effect(initFn)

  setTimeout(() => {
    obj.name = '李四'
  }, 2000);
</script>

代码执行流程如下图:

image.png

对于 computed 函数,会将传入的 getter 函数传入 new ReactiveEffect 实例化 effect,并在内部维护。

对于 effect(initFn) 函数,会将 initFn 出传入 new ReactiveEffect 实例化 _effect, 并执行 _effect()。也就是执行 initFn 函数,通过访问 computedObj.value 会触发 computedObj 的 get value 方法,get value 方法先执行 trackRefValue(this) 后根据 this._dirty 的状态执行 this.effect.run() 函数。

trackRefValue(this) 会将当前活跃的副作用函数 activeEffect加入到 computedObj 的 dep 里面。而当前活跃的副作用函数 activeEffect根据前面 _effect() 被执行过,所以为 _effect 对象。

紧接着,this._dirty 初始值为 true,所以会执行 this.effect.run(),那么当前活跃的副作用函数 activeEffect改为了 computedObj 内部的 effect。同时 this.effect.run()执行,会触发 this.effect.fn() 执行,即传入的 getter 函数被执行。

getter () => { return '姓名:' + obj.name } 函数执行触发 obj.name 被访问,触发 obj 对象依赖收集,将 computedObj 内部的 effect 存入到 targetMap 中。

上面的流程就是整个依赖收集的过程,上述过程存在两个依赖对象,一个是入口 effect 函数实例化的 _effect,另一个是 computedObj 对象内部的 effect。

_effecteffect
image.pngimage.png

computedObj 对象收集了 _effect 依赖; obj 对象收集了 computedObj 对象内部的 effect 依赖。

依赖触发:2s后执行的 setTimeout 函数会修改 obj.name 的值,从而触发 computedObj 对象内部的 effect 的执行。effect 对象有 scheduler 函数,所以 trigglerEffect 会执行 effect.scheduler 函数。

effect.scheduler 会将 _dirty 置为 false, 同时触发 computedObj 对象的 trigglerEffect,即触发 _effect.run 函数执行。

_effect.run 函数执行就回到上面的执行过程,即也就是执行 initFn 函数,然后触发 computedObj 对象的 get Value 函数执行,得到最新的数据。

最佳实践​

Getter 不应有副作用​

计算属性的 getter 应只做计算而没有任何其他的副作用,这一点非常重要,请务必牢记。举例来说,不要在 getter 中做异步请求或者更改 DOM!一个计算属性的声明中描述的是如何根据其他值派生一个值。因此 getter 的职责应该仅为计算和返回该值。在之后的指引中我们会讨论如何使用监听器根据其他响应式状态的变更来创建副作用。

原因:computed 函数本质上是缓存的,它的返回值是根据它所依赖的数据所计算出来的,如果在 computed 函数中进行异步请求,那么这个请求结果将不会被缓存,它的预期行为将会变得很不可预测。

Vue3 的 computed 函数应该是一个纯函数,不应该有任何副作用。此外,在 computed 的 getter 函数中进行异步请求或者更改 DOM,会导致数据状态的不确定性和可读性的下降。

相反,如果你需要进行异步请求或者操作 DOM,可以考虑使用 Vue3 的 watchEffect 函数或者生命周期函数 mounted 和 updated。这些函数中可以使用异步函数或者操作 DOM,但是需要注意避免死循环和不必要的渲染。

总之,Vue3 的 computed 函数是用来计算派生状态的,而不是用来进行副作用操作的。如果你需要进行副作用操作,应该使用其他的函数来实现。

避免直接修改计算属性值​

从计算属性返回的值是派生状态。可以把它看作是一个“临时快照”,每当源状态发生变化时,就会创建一个新的快照。更改快照是没有意义的,因此计算属性的返回值应该被视为只读的,并且永远不应该被更改——应该更新它所依赖的源状态以触发新的计算。

watch

watch 函数源码在 packages/runtime-core/src/apiWatch.ts 中,整体流程比较清晰。主要有两方面:一个是 watch 函数内部会对数据递归进行访问,触发依赖收集;另一方面就是 Scheduler 设计。

在 Vue 3 中,Scheduler(调度器)是负责管理和调度更新的核心机制之一。它是响应式系统的一部分,用于控制组件更新的顺序和时机,以确保性能和响应性的平衡。

Scheduler 的主要职责是协调和调度组件的更新任务。它使用一种称为"调度优先级"的机制来确定哪些任务应该优先执行。调度优先级可以分为以下几个级别:

  1. sync 同步优先级:同步任务会立即执行,不会被延迟。
  2. pre 高优先级:在下一次事件循环之前执行的任务,用于处理一些紧急且重要的更新。
  3. post 低优先级:在下一次事件循环之后执行的任务,用于处理一些不紧急的更新。

通过调度优先级的控制,Scheduler 可以有效地管理组件更新的时机。例如,对于多个连续的数据变更,Scheduler 会将它们合并为一个更新任务,并在适当的时机进行批量处理,以避免不必要的重复计算和渲染。

Scheduler 源码在 packages/runtime-core/src/scheduler.ts 中,

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

function flushJobs(seen?: CountMap) {
  ...
  // 前置
  flushPreFlushCbs(seen)

  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job && job.active !== false) {
      // 同步
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    flushIndex = 0
    queue.length = 0
    // 后置
    flushPostFlushCbs(seen)

  }
}

默认情况下,用户创建的侦听器回调,都会在 Vue 组件更新之前被调用。这意味着你在侦听器回调中访问的 DOM 将是被 Vue 更新之前的状态。

如果想在侦听器回调中能访问被 Vue 更新之后的 DOM,你需要指明 flush: 'post' 选项:

watch(source, callback, { flush: 'post' })
watchEffect(callback, { flush: 'post' })

调度顺序

在Vue 3中,调度器的更新队列中通常包含以下几种函数,并按照一定的执行顺序进行处理:

  1. 渲染函数(Render Functions):渲染函数用于生成组件的虚拟DOM结构。当组件的状态发生变化时,调度器会将需要重新渲染的组件添加到更新队列中。执行顺序通常是先执行父组件的渲染函数,然后再执行子组件的渲染函数。
  2. Watcher 更新函数(Watcher Update Functions):Watcher是Vue中用于观察数据变化的机制。当响应式数据发生变化时,与该数据相关联的Watcher会被添加到更新队列中。执行顺序是在渲染函数之后,按照Watcher的优先级依次执行。
  3. 生命周期钩子函数(Lifecycle Hook Functions):生命周期钩子函数是组件在不同阶段执行的函数,例如beforeCreate、created、beforeMount、mounted、beforeUpdate、updated等。这些钩子函数会被添加到更新队列中,并在合适的时机执行。执行顺序是在Watcher更新函数之后,按照生命周期的先后顺序依次执行。

总结来说,调度器的更新队列中一般包含渲染函数、Watcher更新函数、生命周期钩子函数。它们的执行顺序是先执行渲染函数,然后是Watcher更新函数,接着是生命周期钩子函数。