Vue 3 原理剖析:数据响应系统

15,245

这是我的剖析 Vue 3 原理的第一篇文章。这篇将会带着大家学习数据响应相关的内容,并且尽可能的脱离源码来了解原理,降低大家的学习难度。

文章相关资料

Vue 3 目前的状态其实很适合阅读,因为代码量不多,并且核心功能是不会有什么大的变动的。

因此笔者 fork 了目前的源码,并且加以注释。同时为了照顾不怎么熟悉 TS 的人群,笔者也对一些核心的 TS 语法做了解释。

这份注释不是干巴巴的只对一行代码说明是干什么的,而是结合了上下文来讲解它的用处。如果你想读源码但是又怕看不懂的话,可以通过我这个 仓库 来学习。

先导知识

Vue 3 代码的写法有了很大的变化,如果你还不清楚这方面的内容,推荐先阅读 Vue Function-based API RFC

数据响应机制

众所周知,在 Vue 3 中使用了 Proxy 替换了原先的 Object.defineproperty 来实现数据响应。

另外如果你不熟悉 Proxy 的用法,推荐先阅读 文档

我们先来学习下如何使用这个 API 吧。

const value = reactive({ num: 0 })
// 需要注意的一点,这个回调中用到了 value.num
// 那么只有当外部给 value.num 赋值才会触发回调
effect(() => {
  console.log(value.num)
})
value.num = 7

很简单,上述代码就实现了数据的响应式,并且能在数据改变以后执行相应的回调。

reactive 内部的核心代码简化如下:

function reactive(target) {
    if (!isObject(target)) {
        return target
    }
    if (!canObserve(target)) {
        return target
    }
    const handlers = collectionTypes.has(target.constructor)
        ? collectionHandlers
        : baseHandlers
    observed = new Proxy(target, handlers)
    return observed
}

首先判断传入的参数类型是否可以用于观察,目前支持的类型为 Object|Array|Map|Set|WeakMap|WeakSet

接下来判断参数的构造函数,根据类型获得不同的 handlers。这里我们就统一使用 baseHandlers,因为这个已经覆盖 99% 的情况了。只有 Set, Map, WeakMap, WeakSet 才会使用到 collectionHandlers

对于 baseHandlers 来说,最主要的是劫持了 getset 行为,这两个行为同时也能原生劫持数组下标修改值及对象新增属性的行为,这两个行为相关的内容会在下文中说到。

最后就是构造一个 Proxy 对象完成数据的响应式。相比 Object.defineproperty 一开始就要递归遍历整个对象的做法来说,使用 Proxy 性能会好得多。

接下来当我们去使用 value 这个对象的时候,就能劫持到内部的行为。

比如说 console.log(value.num) 就会触发 get 函数;value.num = 2 就会触发 set 函数。

以下是这两个函数的核心剖析:

function get(target: any, key: string | symbol, receiver: any) {
  // 获得结果
  const res = Reflect.get(target, key, receiver)
  track(target, OperationTypes.GET, key)
  // 判断是否为对象,是的话将对象包装成 proxy
  return isObject(res) ? reactive(res) : res
}

对于 get 函数来说,获取值肯定是最核心的一步骤了。接下来是调用 track,这个和 effect 有关,下文再说。最后是判断值的类型,如果是对象的话就继续包装成 Proxy

function set(
  target: any,
  key: string | symbol,
  value: any,
  receiver: any
): boolean {
  const result = Reflect.set(target, key, value, receiver)
  if (是否新增 key) {
    trigger(target, OperationTypes.ADD, key)
  } else if (value !== oldValue) {
    trigger(target, OperationTypes.SET, key)
  }  
  return result
}

对于 set 函数来说,设置值是第一步骤,然后调用 trigger,这也是 effect 中的内容。

简单来说,如果某个 effct 回调中有使用到 value.num,那么这个回调会被收集起来,并在调用 value.num = 2 时触发。

那么怎么收集这些内容呢?这就要说说 targetMap 这个对象了。它用于存储依赖关系,类似以下结构,这个结构会在 effect 文件中被用到

{
  target: {
    key: Dep
  }
}

先来解释下三者到底是什么,这个很重要

  • target 就是被 proxy 的对象
  • key 是对象触发 get 行为以后的属性。比如 counter.num 触发了 get 行为,num 就是 key
  • dep 是回调函数,也就是 effect 中调用了 counter.num 的话,这个回调就是 dep,需要收集起来下次使用

这里笔者把这些内容脱离源码串起来讲一下流程。

const counter = reactive({ num: 0 })
effect(() => {
  console.log(counter.num)
})
counter.num = 7

首先创建一个 Proxy 对象,targetMap 会把这个对象收集起来当做 key。

接下来调用 effect 回调的时候会把这个回调保存起来,用于下面的依赖收集。在调用的过程中会触发 counterget 函数,内部调用了 track 函数,这个函数会使用到 targetMap

这里首先通过 targettargetMap 中取到一个对象,这个对象也就是 target 所有的依赖关系。那么对于 counter.num 来说,num 就是这个对象的 key(这里如果有点模糊的话可以先看下上面的数据结构),值是一个依赖回调的集合,因为 counter.num 可能会被多个地方依赖到。

回调执行完毕以后会把保存的回调销毁掉。

当我们调用 counter.num = 7 时,触发 set 函数,内部调用 trigger 函数,同样会使用到 targetMap

同样通过 target 取到一个对象,然后通过 key 也就是 num 去取出依赖集合,最后遍历这个集合执行里面所有的回调函数。

另外对于 computed 来说,内部也是使用到了 effect,无非它的回调不会在调用 effect 后立即执行,只有当触发 get 行为以后才会执行回调并进行依赖收集,举个例子:

const value = reactive({ num: 0 })
const cValue = computed(() => value.num)
value.num = 1

对于以上代码来说,computed 的回调永远不会执行,只有当使用到了 cValue.value 时才会执行回调,然后接下来的操作就和上面的没区别了。

最后

以上是数据响应核心流程的讲解,内容不多,但是你通读源码以后也就是这样一个流程。

如果你对源码有兴趣的话,就结合我这个 仓库 来对照这篇文章吧。

阅读源码是一个很枯燥的过程,但是收益也是巨大的。如果你在阅读的过程中有任何的问题,都欢迎你在评论区与我交流。

另外写这系列是个很耗时的工程,需要维护代码注释,还得把文章写得尽量让读者看懂,最后还得配上画图,如果你觉得文章看着还行,就请不要吝啬你的点赞。

最后,如果你对源码研究也有兴趣或者有问题想问的,可以进群交流。