我正在参加「掘金·启航计划」
前言
本文试图从原理和源码层面重新理解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操作并依次执行。
核心的代码实现如下:
在Vue3中,响应式对象包括ref()
和reactive()
两种形式,响应式副作用包括页面渲染
、watch()
和watchEffect()
等,因而可以这么说,Vue3中的响应式只有两个东西:响应式对象和响应式副作用,两者通过绑定和触发进行交互,这么理解就很清晰了。
源码:Vue3真的是这么设计的吗?
接下来,我们就进入到Vue3的源码,看看响应式部分的主体设计是否符合我们的结论。为方便大家理解,这里我把Vue3源码中相关的逻辑抽取出来画了一张图,下面就照着这张图梳理一下。
图中右侧的一列是一些全局变量以及公共引用的函数,首先activeEffect
和targetMap
用于标识当前执行的Effect和存储对象属性和Effect绑定关系。createDep()
用于首次新建一个集合保存Effect。trackEffects
和triggerEffects
用于绑定Effect和触发Effect。ReactiveEffect
用于封装副作用。
图中左侧有三块,前两块分别对应了两类响应式对象reactive
和ref
的实现原理,第三块则是三种响应式副作用的实现原理,分别是: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的触发。不同于reactive
,ref
将dep
绑定在自己身上,并没有放在全局的tagetMap
,这也比较合理,因为ref
通常用于基础类型值的封装,只有一个属性value
,并不需要一个Map
来保存关系。
接下来介绍三种响应式副作用的创建原理,他们的共同点在于最终都是通过new ReactiveEffect()
和effect.run()
来实现Effect创建和绑定,而这个run()
的过程就和上述讨论的一样,通过赋值activeEffect
来实现。
首先我们分析watch()
和watchEffect()
,他们最大的不同在于watch
不会立即执行副作用,watchEffect
会立即执行。在源码中,两种都通过doWatch()
实现,doWatch
根据入参的不同区分要执行的是watch
还是watchEffect
。doWatch
的源码比较复杂,这里我用了伪代码来代替,其中的关键在于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。既然页面渲染也是个函数,那就跟watch
和watchEffect
没什么两样,同样可以通过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并重新打包)
// 6. 打开测试页面开始调试