【翻】Reactivity in Depth

为了提高英语水平和保持技术成长,开始按计划翻译一些短篇和博客,有问题欢迎讨论👻
原文:Reactivity in Depth

正文

Vue 最与众不同的功能之一就是其难以察觉的响应式系统。Vue的组件状态都是由javascript对象组成。当你修改它们时,视图就会更新。这就使状态管理变得简单直观,但如果能了解它是如何工作的也可以避免一些常见的问题。在本节中,我们会深入探讨Vue响应式系统的一些底层细节。

What is Reactivity?

近来,这个术语在编程的讨论中出现频率很高,但当人们说这个词时是什么意思呢?响应式本质上是一种编程范式,它允许我们以声明的方式处理变化。一个经常拿来展示的例子就是Excel表格

A B C
0 1
1 2
2 3

这里的单元格A2是通过公式 A0 + A1 定义的(你可以点击A2查看或编辑公式),所以得出结果为3。没有问题,但如果更新A0或者A1,你会发现A2也会自动更新。

当然javascript通常不是这样工作。如果我们用javascript写一个相似的逻辑:

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的代码,我们可以用一个函数来封装它:

let A2

function update() {
  A2 = A0 + A1
}

然后,我们需要定义几个术语:

  • 首先update()函数会产生副作用,简称“effect”,因为它会修改程序的状态。
  • A0A1是 effect 的依赖项,因为它们的值是用于执行effect。可以说,effect是其依赖项的订阅者

我们需要一个魔法函数,可以在A0A1发生变化时调用update()

whenDepsChange(update)

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

  1. 追踪变量何时被读取。比如,在计算表达式A0 + A1时,A0A1都会被读取。
  2. 如果一个变量在当前运行的effect中被读取,则该effect就为该变量的订阅者。比如,由于A0A1update()执行时被读取,因此update()在第一次调用后就成为了A0A1的订阅者
  3. 当变量发生变化时进行检测。比如,当A0被修改为一个新值时,通知其所有订阅了的effect重新执行。

How Reactivity Works in Vue

我们无法像示例中那样追踪局部变量的读写。在原生的javascript中没有这样的机制。但我们可以做到追踪对象属性的读写。

在javascript中有两种拦截属性访问的方法:getter/settersproxies。由于浏览器支持的限制,Vue2中只能使用getter/setters。在Vue3中,proxy用于响应式对象,getter/setter用于refs。下面是一些说明它们如何工作的伪代码:

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
}

TIP: 这里和下面的代码片段是为了以最简单的形式解释核心概念,因此省略了许多细节,也忽略了边界情况。

以上就解释了我在基础部分讨论过的响应式对象的一些局限性:

  • 当你将一个响应式对象的属性赋值或解构到一个本地变量时,该变量的访问和赋值就不是响应式的了,因为它不再触发源对象上的get/set的代理。请注意,这种断开只影响变量绑定,如果变量指向一个非原始值,那么该对象的更改仍是响应式的。
  • 使用proxy返回的响应式对象,虽然与原始对象无异,但如果我们使用 === 操作符将其对比式,还是能够比较出不同

track()中,我们会检查当前是否有正在运行的effect。如果有,我们会查找到一个存储所有追踪了该属性的订阅的Set,并将其添加到Set中:

// 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)
  }
}

effect订阅会以这种数据结构WeakMap<target, Map<key, Set<effect>>存储在全局。如果某个属性没有找到effect Set,说明是首次追踪,则会创建一个effect Set。简而言之,这就是getSubscribersForProperty()函数的作用。为简单起见,我们跳过其细节。

trigger()中,我们再次查找该属性的effect订阅。但这次我们会执行它们:

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

现在我们回到whenDepsChange()函数中:

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

它把原始的update函数封装在一个effect中,然后该effect会在实际更新之前将自己设置为activeEffect。这样就能在更新过程中调用track()来定位当前的active effect。

在这里,我们已经创建了一个可以自动追踪其依赖关系的effect,并在依赖关系发生变化时重新运行。我们称之为响应式副作用。

Vue提供一个可以创建响应式副作用的API:watchEffect()。事实上,你可能已经注意到它的工作原理与示例中的whenDepsChange()非常相似。现在,我们可以使用实际的Vue API运行最初的示例:

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并不是最有趣的用例,事实上,使用计算属性会使它更具有声明性:

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!我们可以执行一个简单的响应式渲染例子:

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。这会在渲染机制一节中讨论。

Runtime vs. Compile-time Reactivity

Vue的响应式系统主要基于运行时:跟踪和触发都是代码直接在浏览器运行时进行的。运行时的优点是无需构建步骤即可运行,而且边界情况较少。另一方面,这也使它受到javascript语法的制约,导致需要 refs这样的值容器

像一些框架,如Svelte,选择在编译阶段实现响应式来克服这些限制,它通过分析和转换代码来模拟响应式。编译阶段允许框架改变javascript本身的语义,比如,隐式注入代码来执行依赖性分析,并在访问本地定义的变量时触发。这样做的缺点是,这种转换需要一个构建步骤,而改变javascript语义本质上就是创造一种看起来像javascript但编译成另一种语言的语言。

Vue团队曾通过了一项名为 Reactivity Transform 的实验性功能探索过这个方向,但我们最后还是因为这个原因,认为他不适合这个项目。