从原理和源码理解Vue3的响应式机制

1,187 阅读7分钟

我正在参加「掘金·启航计划」

前言

本文试图从原理和源码层面重新理解Vue3的响应式机制,实现对该问题的清晰化阐述,摆脱各种名词和细节的迷雾,从整体上把握这个框架。本文的主体思路参考Vue官方文档: how-reactivity-works-in-vue,不过在文档外进一步通过源码分析进行了验证。为方便大家亲自实践,文章最后附上Vue3源码调试的流程供大家参考。

原理:响应式只有两个东西

从MVVM框架的设计初心来看,除了作为框架的VM之外,两侧分别是M和V,M对应的是数据,V对应的是视图,所谓数据驱动就是视图随着数据的变化而更新。进一步扩展,数据可以理解为状态,视图可以扩展为一切回调操作,于是MVVM就可以理解为:当状态变化时自动执行对应的回调操作,这就是响应式的核心。

分析上面那句话,响应式要求两个东西:响应式状态和响应式操作。响应式状态要求能够具备回调操作的绑定和调用功能,而响应式操作则作为被绑定的对象等待执行。

在Vue3当中,响应式状态叫做响应式对象(ReactiveObjects),响应式操作叫做响应式副作用(ReactiveEffect),响应式对象对于操作的绑定叫做跟踪(Track),对于操作的调用叫做触发(Trigger)

副作用:严格意义上,副作用是相对于纯函数的一个概念。如果一个函数和外界产生了交互,如调用接口、DOM操作、修改全局变量等,该函数就被称作副作用。考虑到实际使用中大部分函数都是副作用,我们可以将所有状态绑定的回调都称作副作用,即状态变化时要做的额外操作。

为什么响应式非要是对象呢?因为JavaScript中只能干预对象属性的读写过程。Vue3利用了JS提供的两种干预方式:对象的getter和setter方法Proxy代理功能,基于第一种方式,Vue3提供了ref,基于第二种方式,Vue3提供了reactive

由于副作用(effect)只是一个函数,接下来要讨论的是响应式对象如何在对象属性的读写过程中绑定和触发副作用?

首先全局会有两个变量,一个是activeEffect,指向当前正在执行的effect函数,另一个是effectsMap,保存每个对象每个属性所绑定的effect函数。

对于effect来说,它需要在执行前将自己赋值给activeEffect,然后执行函数,执行完毕后取消activeEffect和自己的指向关系。之所以要绑定activeEffect,是为了方便响应式对象在绑定时找到正在执行的effect函数。普通的effect会被封装为ReactiveEffect对象实现统一的activeEffect绑定和解绑行为。

对于响应式对象来说,它需要在getter中添加track绑定逻辑,在setter中添加trigger触发行为。track就是将当前的activeEffect添加到全局的effectsMap中保存下来,trigger就是从effectsMap中获取当前属性的所有effects操作并依次执行。

image.png

核心的代码实现如下:

image.png

在Vue3中,响应式对象包括ref()reactive()两种形式,响应式副作用包括页面渲染watch()watchEffect()等,因而可以这么说,Vue3中的响应式只有两个东西:响应式对象和响应式副作用,两者通过绑定和触发进行交互,这么理解就很清晰了。

源码:Vue3真的是这么设计的吗?

接下来,我们就进入到Vue3的源码,看看响应式部分的主体设计是否符合我们的结论。为方便大家理解,这里我把Vue3源码中相关的逻辑抽取出来画了一张图,下面就照着这张图梳理一下。

image.png

图中右侧的一列是一些全局变量以及公共引用的函数,首先activeEffecttargetMap用于标识当前执行的Effect和存储对象属性和Effect绑定关系。createDep()用于首次新建一个集合保存Effect。trackEffectstriggerEffects用于绑定Effect和触发Effect。ReactiveEffect用于封装副作用。

图中左侧有三块,前两块分别对应了两类响应式对象reactiveref的实现原理,第三块则是三种响应式副作用的实现原理,分别是:watch,watchEffect和页面渲染。角色都到齐了,接下来我们把他们之间的关系串一下。

reactive()基于Proxy实现,源码中创建了一个mutableHandler进行代理配置。在get中从targetMap中获取已有的Effect数据,然后通过trackEffects往里面追加activeEffect函数。在set中同样从tragetMap中获取已有的Effect数据,然后通过triggerEffects遍历执行。

ref()基于对象的Getter和Setter实现,源码中创建了一个类RefImpl进行封装,在getter中通过trackRefValue()->trackEffects()实现Effects的追加,在setter中通过triggerRefValue()->triggerEffects()实现Effect的触发。不同于reactiverefdep绑定在自己身上,并没有放在全局的tagetMap,这也比较合理,因为ref通常用于基础类型值的封装,只有一个属性value,并不需要一个Map来保存关系。

接下来介绍三种响应式副作用的创建原理,他们的共同点在于最终都是通过new ReactiveEffect()effect.run()来实现Effect创建和绑定,而这个run()的过程就和上述讨论的一样,通过赋值activeEffect来实现。

首先我们分析watch()watchEffect(),他们最大的不同在于watch不会立即执行副作用,watchEffect会立即执行。在源码中,两种都通过doWatch()实现,doWatch根据入参的不同区分要执行的是watch还是watchEffectdoWatch的源码比较复杂,这里我用了伪代码来代替,其中的关键在于doWatch会在传入的副作用基础上自行创建一个新的effect,如果是watch逻辑,那么effect就是()=>source.value,只用于激活绑定,如果是watchEffect逻辑,那么effect就等于source,直接执行传入的副作用。

这个source就是doWatch的第一个参数,如果watch来传就是要绑定的对象,如果watchEffect来传就直接是副作用函数。

那对于watch来说,effect既然是()=>source.value,那传入的副作用怎么执行呢?这就要说道ReactiveEffect的第二个参数scheduler了,doWatch将传入的副作用暂存在scheduler里面,当triggerEffects()执行的时候,会优先执行effect.scheduler()

最后再谈谈页面渲染,MVVM最显著的特性就是数据驱动页面渲染,其实页面渲染也是一个函数,在Vue3中这个函数是patch(),作用是比对Vnode并生成DOM。既然页面渲染也是个函数,那就跟watchwatchEffect没什么两样,同样可以通过new ReactiveEffect()绑定到响应式对象上。

Vue3在创建页面过程中,首先会创建一个渲染器,渲染器内部最核心的就是patch()。渲染器创建的过程中会将patch()createVNode()打包在componentUpdateFn()作为一个副作用进行类似watchEffect的绑定,核心逻辑在setupRenderEffect()之中,这样当数据发生变化的时候就会重新生成VNode,重新渲染页面。

通过分析源码,我们可以确认Vue3的响应式部分的确是围绕着响应式对象响应式副作用这两个系统来进行架构的。

如何调试Vue3源码?

基本思路:打包源代码,在测试HTML中引用打包文件,使用浏览器进行调试

// 1. 克隆源码
git clone https://github.com/vuejs/core.git
// 2. 安装依赖
npm install pnpm -g
cd core && pnpm install
// 3. 源码打包
pnpm dev
// 4. 创建测试页面
mkdir packages/vue/my-test
vim packages/vue/my-test/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app">counter: {{counter.count}}</div>
    <!-- 引用vue打包文件 -->
    <script src="../dist/vue.global.js"></script>
    <!-- 编写测试代码引用要测试的功能 -->
    <script>
      const app = Vue.createApp({
        setup() {
          const counter = Vue.reactive({ count: 0 })
          return {
            counter
          }
        }
      })
      app.mount('#app')
    </script>
  </body>
</html>
// 5. 在vue源码需要测试的部分打上debugger(保持pnpm dev运行,会watch并重新打包)

image.png

// 6. 打开测试页面开始调试

image.png