跳转到内容

响应性深度解析

Vue 最显著的特征之一是其无侵入的响应性系统。组件状态由响应式 JavaScript 对象组成。当您修改它们时,视图会更新。这使得状态管理变得简单直观,但了解其工作原理也很重要,以避免一些常见的问题。在本节中,我们将深入研究 Vue 响应性系统的底层细节。

什么是响应性?

这个词在编程中经常出现,但人们说这个词时到底意味着什么?响应性是一种编程范式,它允许我们以声明式的方式适应变化。人们通常展示的典范例子是一个 Excel 电子表格,因为它是一个很好的例子

ABC
0
1
1
2
2
3

在这里,单元格 A2 通过公式 = A0 + A1 定义(您可以通过点击 A2 来查看或编辑公式),因此电子表格给出了 3,这并不令人惊讶。但是,如果您更新 A0 或 A1,您会注意到 A2 会自动更新。

JavaScript 通常不这样做。如果我们用类似的东西在 JavaScript 中编写

js
let A0 = 1
let A1 = 2
let A2 = A0 + A1

console.log(A2) // 3

A0 = 2
console.log(A2) // Still 3

当我们修改 A0 时,A2 不会自动更改。

那么我们如何在 JavaScript 中这样做?首先,为了重新运行更新 A2 的代码,让我们将其包裹在一个函数中

js
let A2

function update() {
  A2 = A0 + A1
}

然后,我们需要定义一些术语

  • update() 函数产生一个 副作用 或简称为 效果,因为它修改了程序的状态。

  • A0A1 被认为是效果 依赖项,因为它们的值被用来执行效果。效果被称为其依赖项的 订阅者

我们需要一个魔法函数,它可以在A0A1依赖项)发生变化时调用update()效果)。

js
whenDepsChange(update)

这个whenDepsChange()函数有以下任务:

  1. 跟踪何时读取一个变量。例如,在评估表达式A0 + A1时,会读取A0A1

  2. 如果变量在当前有运行的效果时被读取,将那个效果变成该变量的订阅者。例如,因为A0A1在执行update()时被读取,所以第一次调用后update()变成了A0A1的订阅者。

  3. 检测变量何时被修改。例如,当A0被分配一个新值时,通知所有订阅其效果的重新运行。

Vue中的响应式是如何工作的

我们实际上无法跟踪像示例中那样的本地变量的读取和写入。在纯JavaScript中根本没有任何机制可以做到这一点。但我们可以拦截对象属性的读取和写入。

在JavaScript中有两种拦截属性访问的方式:getter / setter代理。Vue 2由于浏览器支持限制,仅使用getter / setter。在Vue 3中,代理用于响应式对象,getter / setter用于refs。以下是一些伪代码,说明了它们是如何工作的

js
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)
    }
  })
}

function ref(value) {
  const refObject = {
    get value() {
      track(refObject, 'value')
      return value
    },
    set value(newValue) {
      value = newValue
      trigger(refObject, 'value')
    }
  }
  return refObject
}

提示

此处和下面的代码片段旨在以最简单的方式解释核心概念,因此省略了许多细节,并忽略了边缘情况。

这解释了我们已在基础知识部分讨论的响应式对象的一些限制

  • 当你将响应式对象的属性分配或解构到本地变量中时,访问或分配到该变量是非响应式的,因为它不再触发源对象上的get / set代理陷阱。注意这种“断开”仅影响变量绑定 - 如果该变量指向非原始值(如对象),则修改对象仍将是响应式的。

  • reactive()返回的代理虽然行为与原始对象相同,但如果我们使用===运算符与原始对象进行比较,它们具有不同的身份。

track()内部,我们检查是否有一个正在运行的效果。如果有,我们查找被跟踪属性的订阅者效果(存储在Set中),并将效果添加到Set中。

js
// This will be set right before an effect is about
// to be run. We'll deal with this later.
let activeEffect

function track(target, key) {
  if (activeEffect) {
    const effects = getSubscribersForProperty(target, key)
    effects.add(activeEffect)
  }
}

效果订阅存储在全局的WeakMap<target, Map<key, Set<effect>>>数据结构中。如果找不到属性(首次跟踪)的订阅效果集,它将被创建。简而言之,这就是getSubscribersForProperty()函数所做的工作。为了简单起见,我们将跳过其细节。

trigger()内部,我们再次查找属性的订阅者效果。但这次我们调用它们。

js
function trigger(target, key) {
  const effects = getSubscribersForProperty(target, key)
  effects.forEach((effect) => effect())
}

现在让我们回到whenDepsChange()函数。

js
function whenDepsChange(update) {
  const effect = () => {
    activeEffect = effect
    update()
    activeEffect = null
  }
  effect()
}

它将原始的update函数包裹在一个效果中,在运行实际更新之前将其自身设置为当前活动效果。这使得在更新期间可以调用track()来定位当前活动效果。

到此为止,我们已经创建了一个可以自动跟踪其依赖关系,并在依赖关系更改时重新运行的效果。我们称之为响应式效果

Vue提供了一个API,允许您创建响应式效果:watchEffect()。实际上,您可能已经注意到它的工作方式与示例中的神奇函数whenDepsChange()非常相似。现在我们可以使用实际的Vue API重新编写原始示例

js
import { ref, watchEffect } from 'vue'

const A0 = ref(0)
const A1 = ref(1)
const A2 = ref()

watchEffect(() => {
  // tracks A0 and A1
  A2.value = A0.value + A1.value
})

// triggers the effect
A0.value = 2

使用响应式效果来修改ref并不是最有意思的使用案例——实际上,使用计算属性会使它更加声明性

js
import { ref, computed } from 'vue'

const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)

A0.value = 2

内部,computed使用响应式效果来管理其无效化和重新计算。

那么一个常见且有用的响应式效果示例是什么呢?嗯,更新DOM!我们可以这样实现简单的“响应式渲染”

js
import { ref, watchEffect } from 'vue'

const count = ref(0)

watchEffect(() => {
  document.body.innerHTML = `Count is: ${count.value}`
})

// updates the DOM
count.value++

事实上,这与Vue组件保持状态和DOM同步的方式非常相似——每个组件实例都创建一个响应式效果来渲染和更新DOM。当然,Vue组件使用比innerHTML更有效的方法来更新DOM。这将在渲染机制中进行讨论。

ref()computed()watchEffect() API都是组合式API的一部分。如果您迄今为止只使用过Vue的Options API,您会注意到组合式API更接近Vue的底层响应式系统。实际上,在Vue 3中,Options API是在组合式API之上实现的。组件实例上所有属性访问(this)都会触发响应式跟踪的getter/setter,而像watchcomputed这样的选项会内部调用它们的组合式API等价物。

运行时与编译时响应性

Vue的响应式系统主要是基于运行时的:跟踪和触发都在代码直接在浏览器中运行时执行。运行时响应式的优点是可以不经过构建步骤工作,并且边缘情况较少。另一方面,这使得它受到JavaScript语法限制的限制,从而导致需要像Vue refs这样的值容器。

一些框架,如Svelte,选择在编译时实现响应性来克服这些限制。它分析并转换代码以模拟响应性。编译步骤允许框架改变JavaScript本身的语义——例如,隐式注入代码,在访问本地定义的变量周围执行依赖分析和效果触发。缺点是这种转换需要一个构建步骤,而改变JavaScript语义本质上是在创建一种看起来像JavaScript但实际上编译成其他东西的语言。

Vue团队通过一个名为响应式转换的实验性功能探索了这一方向,但最终我们决定由于这里的原因,这不会适合项目。

反应性调试

Vue的反应性系统能够自动跟踪依赖关系是非常棒的,但在某些情况下,我们可能想要确定正在跟踪的内容,或者是什么导致了组件的重新渲染。

组件调试钩子

我们可以使用renderTrackedonRenderTrackedrenderTriggeredonRenderTriggered生命周期钩子来调试组件渲染期间使用的依赖关系以及触发更新的依赖关系。这两个钩子都将接收到一个包含有关依赖关系信息的调试事件。建议在回调中放置一个debugger语句以交互式检查依赖关系。

vue
<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'

onRenderTracked((event) => {
  debugger
})

onRenderTriggered((event) => {
  debugger
})
</script>
js
export default {
  renderTracked(event) {
    debugger
  },
  renderTriggered(event) {
    debugger
  }
}

提示

组件调试钩子仅在开发模式下有效。

调试事件对象具有以下类型

ts
type DebuggerEvent = {
  effect: ReactiveEffect
  target: object
  type:
    | TrackOpTypes /* 'get' | 'has' | 'iterate' */
    | TriggerOpTypes /* 'set' | 'add' | 'delete' | 'clear' */
  key: any
  newValue?: any
  oldValue?: any
  oldTarget?: Map<any, any> | Set<any>
}

计算属性调试

我们可以通过将带有onTrackonTrigger回调的第二个选项对象传递给computed()来调试计算属性。

  • onTrack将在响应式属性或ref被跟踪为依赖关系时被调用。
  • onTrigger将在依赖关系的突变触发观察者回调时被调用。

这两个回调都将接收到与组件调试钩子相同的格式的调试事件。

js
const plusOne = computed(() => count.value + 1, {
  onTrack(e) {
    // triggered when count.value is tracked as a dependency
    debugger
  },
  onTrigger(e) {
    // triggered when count.value is mutated
    debugger
  }
})

// access plusOne, should trigger onTrack
console.log(plusOne.value)

// mutate count.value, should trigger onTrigger
count.value++

提示

onTrackonTrigger计算选项仅在开发模式下有效。

观察者调试

类似于computed(),观察者也支持onTrackonTrigger选项。

js
watch(source, callback, {
  onTrack(e) {
    debugger
  },
  onTrigger(e) {
    debugger
  }
})

watchEffect(callback, {
  onTrack(e) {
    debugger
  },
  onTrigger(e) {
    debugger
  }
})

提示

onTrackonTrigger观察者选项仅在开发模式下有效。

与外部状态系统的集成

Vue的反应性系统通过深度转换普通JavaScript对象为响应式代理来工作。在集成外部状态管理系统(例如,如果外部解决方案也使用代理)时,深度转换可能是多余的,有时是不想要的。

将Vue的反应性系统与外部状态管理解决方案集成的总体思路是将外部状态保存在一个shallowRef中。浅引用仅在访问其.value属性时是响应式的 - 内部值保持不变。当外部状态改变时,替换引用值以触发更新。

不可变数据

如果您正在实现撤销/重做功能,您可能希望在每个用户编辑时对应用程序的状态进行快照。然而,如果状态树很大,Vue的可变反应性系统并不适合此目的,因为每次更新时序列化整个状态对象可能会在CPU和内存成本上非常昂贵。

不可变数据结构通过从不修改状态对象来解决这个问题 - 相反,它创建新的对象,这些新对象与旧对象共享相同的、未更改的部分。在JavaScript中,使用不可变数据有多种方式,但我们建议使用与Vue配合的Immer,因为它允许您在使用不可变数据的同时,保留更符合人体工程学的可变语法。

我们可以通过简单的组合式函数将Immer与Vue集成

js
import { produce } from 'immer'
import { shallowRef } from 'vue'

export function useImmer(baseState) {
  const state = shallowRef(baseState)
  const update = (updater) => {
    state.value = produce(state.value, updater)
  }

  return [state, update]
}

在游乐场中尝试

状态机

状态机 是一种描述应用可能处于的所有状态以及所有可能的从一个状态转换到另一个状态的方法的模型。虽然对于简单的组件来说可能有些过度,但它可以帮助使复杂的状态流转更加健壮和易于管理。

JavaScript中最受欢迎的状态机实现之一是 XState。这里有一个与之集成的组合式函数

js
import { createMachine, interpret } from 'xstate'
import { shallowRef } from 'vue'

export function useMachine(options) {
  const machine = createMachine(options)
  const state = shallowRef(machine.initialState)
  const service = interpret(machine)
    .onTransition((newState) => (state.value = newState))
    .start()
  const send = (event) => service.send(event)

  return [state, send]
}

在游乐场中尝试

RxJS

RxJS 是一个用于处理异步事件流的库。VueUse库提供了@vueuse/rxjs插件,用于将RxJS流与Vue的反应性系统连接。

与信号的联系

相当多的其他框架已经引入了类似于Vue的Composition API中的refs的反应性原语,被称为“信号”。

从根本上说,信号与Vue的refs一样,是一种反应性原语。它是一个值容器,在访问时提供依赖跟踪,在变更时触发副作用。这种基于反应性原语的范式在前端世界中并不是一个特别新的概念:它可以追溯到十多年前如 Knockout的可观察对象Meteor Tracker 这样的实现。Vue的Options API和React的状态管理库 MobX 也基于相同的原理,但将原语隐藏在对象属性后面。

尽管这不是成为信号所必需的特征,但今天这个概念经常与通过细粒度订阅执行更新的渲染模型一起讨论。由于使用了Virtual DOM,Vue目前 依赖于编译器以实现类似的优化。然而,我们也在探索一种新的、受Solid启发的编译策略,称为 Vapor Mode,它不依赖于Virtual DOM,并更多地利用Vue内置的反应性系统。

API设计权衡

Preact和Qwik的信号设计非常类似于Vue的 shallowRef:三者都通过 .value 属性提供了一个可变的接口。我们将重点讨论Solid和Angular的信号。

Solid 信号

Solid的 createSignal() API设计强调读写分离。信号作为只读获取器和单独的设置器公开。

js
const [count, setCount] = createSignal(0)

count() // access the value
setCount(1) // update the value

注意如何通过setter之外的方式传递count信号。这确保了只有在setter也被显式暴露的情况下,状态才可能被更改。这种安全保证是否足以证明这种更复杂的语法是有必要的,这取决于项目的需求和个人的喜好——但如果你更喜欢这种API风格,你可以在Vue中轻松地复制它。

js
import { shallowRef, triggerRef } from 'vue'

export function createSignal(value, options) {
  const r = shallowRef(value)
  const get = () => r.value
  const set = (v) => {
    r.value = typeof v === 'function' ? v(r.value) : v
    if (options?.equals === false) triggerRef(r)
  }
  return [get, set]
}

在游乐场中尝试

Angular Signals

Angular通过放弃脏检查并引入其自己的响应性原语实现来经历一些基本变化。Angular信号API看起来是这样的

js
const count = signal(0)

count() // access the value
count.set(1) // set new value
count.update((v) => v + 1) // update based on previous value

同样,我们可以在Vue中轻松复制此API

js
import { shallowRef } from 'vue'

export function signal(initialValue) {
  const r = shallowRef(initialValue)
  const s = () => r.value
  s.set = (value) => {
    r.value = value
  }
  s.update = (updater) => {
    r.value = updater(r.value)
  }
  return s
}

在游乐场中尝试

与Vue的refs相比,Solid和Angular基于getter的API风格在Vue组件中使用时提供了一些有趣的权衡

  • ().value略微简洁,但更新值时更冗长。
  • 没有ref-unwrapping:访问值始终需要()。这使得值访问在所有地方都保持一致。这也意味着你可以将原始信号作为组件属性向下传递。

这些API风格是否适合你,在某种程度上是主观的。我们的目标是在这里展示这些不同API设计之间的潜在相似性和权衡。我们还想展示Vue的灵活性:你并不是真正被锁定在现有的API中。如果有必要,你可以创建自己的响应性原语API来满足更具体的需求。

响应性深度加载完成