为了提高英语水平和保持技术成长,开始按计划翻译一些短篇和博客,有问题欢迎讨论👻
原文: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”,因为它会修改程序的状态。 A0
和A1
是 effect 的依赖项,因为它们的值是用于执行effect。可以说,effect是其依赖项的订阅者
我们需要一个魔法函数,可以在A0
或A1
发生变化时调用update()
whenDepsChange(update)
这个whenDepsChange()
函数有以下任务:
- 追踪变量何时被读取。比如,在计算表达式
A0 + A1
时,A0
和A1
都会被读取。 - 如果一个变量在当前运行的effect中被读取,则该effect就为该变量的订阅者。比如,由于
A0
和A1
在update()
执行时被读取,因此update()
在第一次调用后就成为了A0
和A1
的订阅者 - 当变量发生变化时进行检测。比如,当
A0
被修改为一个新值时,通知其所有订阅了的effect重新执行。
How Reactivity Works in Vue
我们无法像示例中那样追踪局部变量的读写。在原生的javascript中没有这样的机制。但我们可以做到追踪对象属性的读写。
在javascript中有两种拦截属性访问的方法:getter
/setters
和proxies
。由于浏览器支持的限制,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 的实验性功能探索过这个方向,但我们最后还是因为这个原因,认为他不适合这个项目。