vue3学习小札之(四):深入响应式系统

367 阅读9分钟

引子

可以前往专栏阅读该系列其他文章:传送门

本文章只结合官网内容,介绍 vue3 中响应式系统的设计思路,不涉及过多的源码解读。
后续会单独出一篇文章,分析 vue3 中的响应式源码:)

什么是响应性

官网:本质上,响应性是一种可以使我们声明式地处理变化的编程范式。
用人话说,就是:先声明好对于数据会进行什么样的操作,然后当数据输入或者有改变的时候,基于改变,会做出反应

但是,js 默认并不是响应性的,用一段代码说明一下:

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

console.log(A2) // 3

A0 = 2
console.log(A2) // 仍然是 3

// 只有再执行一边,才能更新 A2 的值
A2 = A0 + A1
console.log(A2) // 4

那么,为了能够实现响应性
首先,我们需要将 A0 A1 这两个数据的处理,包装成一个函数:

let A2

function update() {
  // `A0` 和 `A1` 被视为这个作用的**依赖** (dependency)
  A2 = A0 + A1
}

这个 update() 函数会产生一个副作用,或者就简称为作用 (effect),因为它会更改程序里的状态。
包装成函数,目的就是为了需要更新 A2 的时候,调用一次函数即可。
A0 和 A1 被视为这个作用的依赖 (dependency),因为它们的值被用来执行这个作用。因此副作用函数( update 函数)也可以说是依赖的订阅者 (subscriber)

理解完依赖、订阅者后,还不够。
此时当依赖改变,订阅者并不能感知到它的依赖改变了。

所以我们还需要一个“魔法函数”

// 该函数会存储副作用函数,必要时调用它
whenDepsChange(update)

能够在 A0 或 A1 (这两个依赖) 变化时调用 update() (产生作用)。

接下来,分析一下这个魔法函数需要承担的任务,主要有三个:

当一个变量被读取时进行追踪。
例如我们执行了表达式 A0 + A1 的计算,则 A0 和 A1 都被读取到,并基于他们建立追踪。

如果一个变量在当前运行的副作用中被读取了,就将该副作用设为此变量的一个订阅者。
例如由于 A0 和 A1 在 update() 执行时被访问到了,则 update() 需要在第一次调用之后成为 A0 和 A1 的订阅者,将依赖和订阅者建立联系。
目的是为了能够及时,准确的根据依赖的改变,通知订阅者执行副作用。

探测一个变量的变化。
例如当我们给 A0 赋了一个新的值后,应该通知其所有订阅了的副作用重新执行。
因为一个变量,可以是多个订阅者的依赖,所以魔法函数需要能够管理一个变量所对应的所有订阅者

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

理解完依赖、订阅者、观察者,这三者的关系后,就可以很好得理解 vue 的响应式工作原理了。

首先,我们要知道,像上一章中的例子,js 其实是无法直接实现的,因为 A0 A1 这两个依赖,都是局部变量,而 js 没有任何一种方法能够直接追踪局部变量的读写的。

但是,我们是可以追踪对象属性的读写的。
在 JavaScript 中有两种劫持 property 访问的方式:getter / setters 和 Proxies
该系列第一篇文章,在介绍基础知识的时候,提到过 vue2 实现响应式的大致过程,就是使用 getter / setters 进行了对象属性的拦截。
而在 Vue 3 中则使用了 Proxy 来创建响应式对象,但也不全是使用的 Proxy,ref 的响应式变量则是使用的 getter / setter。
伪代码展示:

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
}

从这段伪代码,我们可以看出响应式对象 reactive 和响应式变量 ref 的实现区别。
这也解释了在基础篇中我们提到的响应式对象的限制
当你将一个响应性对象的属性解构为一个局部变量时,响应性就会“断开连接”,因为对局部变量的访问不再触发 get / set 代理捕获;
从 reactive() 返回的代理尽管行为上表现得像原始对象,但我们通过使用 === 运算符还是能够比较出它们的不同

接下来,详细介绍一下内部的两个函数:
用来追踪依赖的 track() 
用来触发副作用的 trigger() 

get 内部会调用 track()

首次访问时建立依赖订阅者函数之间的联系。

// 这会在一个副作用就要运行之前被设置
// 我们会在后面处理它
let activeEffect

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

会检查当前是否有正在运行的副作用,如果有,就调用getSubscribersForProperty函数,
该函数,
会查找到一个存储了所有追踪了该属性的订阅者的 Set(即,dep),然后将当前这个副作用作为新订阅者添加到该 Set 中;
如果在第一次追踪时没有找到对相应属性订阅的副作用集合,它将会在这里新建集合

副作用订阅将被存储在一个全局的 WeakMap<target, Map<key, Set<effect>>> 数据结构中。
分析一下这个数据结构:
WeakMap 是一个全局变量,存储全量的响应式依赖关系。
target 作为 WeakMap 的 key,为响应式对象。 定义一个 Map 作为 WeakMap 的 value。
Map 的 key,就是响应式对象的 key,
Map 的 value,则是上面提到的订阅者 Set 集合(dep),用来存储响应式对象中 key 所对应的所有订阅者。
总结一下就是:
通过 WeakMap 存储多个响应式对象,
通过 Map 存储单个响应式对象的所有属性,
通过 Set 存储了每个属性的所有订阅者,并起到去重的效果。
所以 track 函数,其实就是在全局构建出一个:
响应式对象中,每个属性和订阅了这个属性的副作用函数之间的关系。起到了依赖收集的作用。

set 内部会调用 trigger()

trigger 函数就相对简单了。
查找到属性的所有订阅副作用,并执行它们:

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

activeEffect

到目前为止,已经基本完成了响应式的依赖收集更新派发,但是还存在一个细节,需要展开讲讲。那就是 track 函数在进行副作用函数保存前的判断:

if (activeEffect)

想要明白这个 activeEffect 是啥,得回到文章开头的那个“魔法函数”

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

我们完善了函数内部实现:
它接收一个 update 函数作为参数,
将原本的 update 函数包装在了一个副作用函数中,
在运行实际的更新之前,这个外部函数会将自己设为当前活跃的副作用。

activeEffect 其实就是一个指向当前活跃的副作用函数的全局变量。

我们带入场景进行理解:

实际开发中,我们通过 reactive 定义了一个响应式对象 person,该对象有两个属性 name 和 age
此时 person 就是一个 Proxy 代理对象,有 getter/setter ,分别实现了 track()trigger()

然后我们将 person.name 和 person.age 分别写到了 vue 的模板中,就会得到两个 effect 函数,计作 updateNameupdateAge,分别用来更新页面中的 name 和 age。

那么在首次渲染时,读取 name 和 age 的值,就会触发 getter 中的 track 进行依赖收集,
前面提到过,每个属性对应一个 Set 用来保存跟它相关的 effect 函数

虽然我们可以通过 key 获取到 Set 集合,但是想要将副作用函数添加进 Set 集合时,vue 的源码无法通过传入的 key 去找到跟这个 key 相关的 effect 函数,总不能让源码中加入 if / else 判断吧,作者又不知道业务代码中的对象会定义成什么 key。

所以为了能够让 effect 函数和 key 对应起来,vue 的作者想到了一个很巧妙的设计,那就是定义了一个全局变量,activeEffect

再回头看“魔法函数”,可以想象一下,现在有两个 effect 函数,然后提前封装好两个“魔法函数”,分别传入 updateName 和 updateAge。

当页面首次渲染,其实就是按顺序触发“魔法函数”,当解析到 person.name 的模板的时候,此时的“魔法函数”是传入 updateName 的那个,当前的全局变量 activeEffect 其实本质是指向了 updateName 这个副作用函数,这样读取 person.name 的值,后续依赖收集的时候,获取到 name 的 Set 集合,只需要将全局变量 activeEffect 添加进去即可

由于代码是同步执行的,当 name 的依赖收集结束了,才会进行 age 的依赖收集,此时的 activeEffect 其实已经被释放,可以被用作指向 updateAge 了。

至此,一个完整的响应式系统就实现了:
构建响应式对象 Proxy
依赖收集(将依赖变量和副作用函数一一对应保存到 Set)
依赖变量更新,通知所有相关的副作用函数进行更新派发

还有一个关于 activeEffect 的小细节
track 内部当 activeEffect 有值时,才会进行后续的依赖收集。
这样做的好处是,当定义了响应式对象,但是如果对某个属性只是 console 了一下的话,并不会进行依赖收集,可以提高性能。
只有定义了“魔法函数”,从“魔法函数”内部触发的属性访问,才会触发依赖收集。

什么是“魔法函数”

上面的例子中有一个很关键的点,就是只有从“魔法函数”中,才会改变 activeEffect 的指向,触发了属性访问,才会进行依赖收集。
这个“魔法函数”其实就是一个响应式副作用,它能自动跟踪其依赖的副作用,它会在任意依赖被改动时重新运行。
比如:
watchEffect()

import { ref, watchEffect } from 'vue'

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

watchEffect(() => {
  // 追踪 A0 和 A1
  A2.value = A0.value + A1.value
})

// 将触发副作用
A0.value = 2

计算属性computed 

import { ref, computed } from 'vue'

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

A0.value = 2

他们的用法跟“魔法函数” whenDepsChange 很相似。

总结

本文介绍了 vue3 中响应式系统的工作原理。
通过响应式副作用,跟踪内部的依赖变量,进行依赖收集,将每个依赖变量和副作用函数对应起来,当依赖变量变更时,通知所有相关的副作用函数重新执行。