从Vue3源码深入理解ref响应式
在Vue3中,reactive和ref两个响应式API相信大家用的都比较熟练了,但是在使用时还有众多的响应式API函数,本文就从源码角度,来看下不同API及其实现方式,相信看完本文之后会加深对Vue3响应式的理解。
我们在看源码的时候,由于众多的函数和类,经常会陷入迷茫的状态,不知道这个类或者类下面某个属性是做什么用的;而且随着现在vue3彻底拥抱Typescript,类型的使用也加大了我们读源码时的难度,因为我们还需要对ts的用法有所了解;因此笔者有以下几点小小的建议:
- 剔除无关代码,比如无关的类型系统,注重核心逻辑、注重核心逻辑、注重核心逻辑,重要的事情说三遍。
- 不要纠结于一两个函数或者类的功能,非核心的代码能跳过就跳过吧。
- 看注释,很多注释也会写明函数的作用,帮助我们理解。
- 看名称,名称可以告诉我们这个函数的作用,加上合理的猜测,去揣摩作者的意图。
- 最后实在看不出来也猜不到,还有众多强大AI问答工具,用好AI工具。
- 梳理好主要流程,有时一个参数会流转经过很多的函数,我们必须梳理好每个函数的参数,可以重点关注函数的入参及出参。
- 我们看一个函数的时候经常需要跳转到多个不同的函数,在编辑器上将代码分成多个屏查看,一个屏注重主线逻辑,其他可以看工具函数。
- 结合平时的使用场景看代码,对理解函数的作用会有很大的帮助。
因此我们会看到下面代码,很多地方笔者都标注了省略部分代码,只贴了重点的代码出来;带着下面的疑问我们来看源码:
- 编译和运行有什么区别?
- reactive和ref有什么关系和区别?
- 响应收集是怎么做的?
本文应该是全网最深入的对ref源码解读,文章内容较长,觉得有所收获的记得点赞关注收藏,一键三连。
项目介绍
首先我们要去学习源码,就要对整个项目有一个基本了解,了解每个模块、每个目录的作用,这样对我们后期阅读源码也会有很大的帮助;我们将项目从Github上克隆下来:
1 |
|
如果因为网络问题克隆不下来,直接跳转文末获取Vue3的源码;笔者克隆下来时,当前版本号为
3.5.12
。
我们看到整个项目的目录还是非常多的,又有编译时,又有运行时的目录,主要的代码都存放在packages
目录,因此我们主要看下它下面的目录结构:
1 |
|
上面目录,我们将其归类一下,主要分为下面几个部分;首先就是编译器相关的目录,主要是以下几个目录:
compiler-core
:编译核心,包括模板解析、AST(抽象语法树)构建等。这是Vue模板编译的基础部分,不依赖于特定的平台。compiler-dom
:编译器针对浏览器环境的代码,主要处理模板编译成渲染函数的过程。这是Vue在浏览器环境下进行模板编译的关键部分。compiler-ssr
:编译器针对服务端渲染环境的代码,使得Vue能够在服务端进行模板编译compiler-sfc
:用于解析.vue单文件组件的编译器模块,它负责将.vue文件编译成JavaScript模块,以便在Vue项目中使用。
其次是响应式模块相关的目录,也就是本文重点讲解的目录:
reactivity
:响应式系统的核心代码,这是Vue3实现数据响应式的基础部分,它使得Vue能够追踪数据的变化并自动更新DOM。
还有是运行时模块,包括如下目录:
runtime-core
:与平台无关的运行时核心代码,包括虚拟DOM、组件实例化等。这是Vue运行时系统的核心部分,它负责处理组件的创建、更新和销毁等生命周期事件。runtime-dom
:运行时针对浏览器环境的代码,包括节点操作、事件处理等。这部分代码使得Vue能够在浏览器环境下进行DOM操作,从而实现对用户界面的更新。runtime-test
:用于测试的运行时代码。这部分代码主要用于对Vue的运行时系统进行测试,以确保其正确性和稳定性。
最后是一些其他功能模块,包括以下目录:
server-renderer
:Vue3单文件组件playground,用于测试和演示Vue单文件组件的功能。shared
:包含了一些公共的工具函数和类型定义,这些函数和类型定义被其他模块广泛使用。
我们发现,对目录的功能进行划分后,其实功能就没有那么复杂了;回到上面的第一个问题,编译和运行有什么区别?编译模块compiler通常用来将我们代码中写的Vue模板编译成浏览器可以执行渲染函数,一般是在开发环境和构建工具(Webpack、Vite等)中运行。
而运行时模块runtime是可以直接加载(渲染函数)并执行的,像我们常见的组件的实例化、渲染、更新和销毁等生命周期事件都是在运行时去执行的。
Ref源码部分
下面我们就来看Ref部分的源码,打开reactivity/src/index.ts
,我们看到从各个响应式的子模块中导出了不少函数和type:
1 |
|
我们看到很多模块名称都是我们熟悉的功能,比如watch和computed;我们打开本节要介绍的reactivity/src/ref.ts
,我们先来看下里面最重要的两个函数:ref和shallowRef。
1 |
|
我们发现ref和shallowRef都接收一个任意值any做参数,然后返回Ref和ShallowRef的对象,两者本质上没什么差别,我们看下两者的ts类型定义:
1 |
|
我们看到Ref上只有getter和setter两个方法,而ShallowRef本质上也是从Ref合成而来,只是将ShallowRefMarker标识为true。然后ref和shallowRef都调用了createRef
函数,我们找到createRef函数的实现:
1 |
|
这里先对传入值进行isRef
的判断,判断对象是否是ref对象,判断逻辑其实也非常简单,就是通过对象上是否有一个__v_isRef
的标识判断,因为后面ref对象处理后都会打上这个标识:
1 |
|
回到createRef
函数中,我们看到实例化了RefImpl
类并且返回,这个类的主要作用就是用来创建Ref对象;它的第一个参数就是传入的初始值,第二个参数表明是否是对象是否是浅层响应shallow;如果我们将ref创建的对象打印出来,就会发现本质上其实都是从RefImpl
实例化出来,这也符合我们源码里面的逻辑。
1 |
|
核心类RefImpl
__v_isRef
的标识我们上面在isRef函数中也说了,只要是ref创建出来的对象都会有;我们接着看ref核心类RefImpl的实现:
1 |
|
带有下划线的属性一般是类的私有属性,不会对外暴露,比如_value和_rawValue,在ref对象上就不能被访问。
在RefImpl构造函数中,两个只读的标识符ReactiveFlags.IS_REF
和ReactiveFlags.IS_SHALLOW
其实是两个字符串变量的标识符,用来判断对象是否是Ref和是否是ShallowRef,这里不再赘述;dep对象是用来实现依赖收集的,我们下面会说到。
这里的_value
存储的其实就是我们ref对象的真实值(响应化之后的拦截对象Proxy Object),我们下面通过getter和setter访问和修改时,实际上就是操作_value
中的值,它值的变化追踪是整个响应式的核心;而_rawValue
中存储的是真实值(没有响应化的),在修改value的时候用来进行新老值判断。
在_value
值初始化的时候,我们看到如果是非浅层Shallow,还调用了toReactive
,这个函数其实是我们以后会说的reactive.ts
文件中暴露出来的,如果传入的初始值是对象,还会对其进行更深层次响应化的处理:
1 |
|
我们看到toReactive中如果传入的value是对象等引用类型,会调用reactive进行Proxy拦截;而如果我们传入一个数字或者一个字符串等基本类型时,返回本身。
因此我们可以简单的总结一下,如果ref接收到一个基本类型
,_value中存储的也是原始值,本质上劫持的是_value的getter和setter函数来实现响应式的;而如果ref函数接收到引用类型
,_value中存储的是转化后的Proxy Object代理对象,通过代理对象的属性来实现的响应式。
getter和setter函数
我们接着通过一个简略版的代码,来看下getter和setter中对_value值时做了哪些操作:
1 |
|
首先我们要理解getter和setter函数是在什么时候调用的,就能理解它里面的操作逻辑了;当我们读取ref对象的.value
属性时,getter函数起作用,比如我们在页面上使用插值模板{{ num }}
时(本质调用了num.value),或者watch(num)
监听时,都会触发getter函数;而我们在修改值num.value = 3
时就会触发setter函数。
当然还会有其他的触发逻辑,这里是列举了最常见的一些情况。
因此上面的简略版的代码就很好理解了,也印证了我们上面的说法,即:_value
存储的是真实值(响应化之后的),getter和setter操作的是_value
中的值。
那么这里的this.dep
也很好理解了,我们可以简单的将其理解为内部存储了一个数组,当getter在不同地方访问或者显示value的时候,就将它添加到数组中;而当我们去修改了value值时,就触发trigger函数,将数组中存储的修改的页面地方,全部通知更新。
我们看到上面浏览器里直接打印出来的ref对象num
,由于没有触发getter,因此它的dep显示undefined,我们把它放到template中和watch里面去:
1 |
|
我们看到上面打印出来的dep属性中的map就有两个收集到的依赖了,而不是之前的undefined了。我们继续来看完整的setter函数:
1 |
|
这里将_rawValue暂存到oldValue变量,这里useDirectValue我们可以把它简单理解为newValue,然后判断传入newValue和oldValue是否有变化,其实本质上也就是判断传入的新值和_rawValue
是否有变化,如果变化了,_rawValue和_value都改为新的值,同时触发dep.trigger。
那这里可能还有小伙伴会提出这样的疑问,为什么不直接将newValue和_value中的值进行对比,而要通过_rawValue?因为结合这里的setter函数,我们回想一下构造函数中就能明白了,_rawValue存储的是数据的原始值,而_value存储的是响应化之后的数据,而_rawValue存储的原始值就是用来进行对比操作的;如果我们直接将newValue和_value对比,那么hasChanged永远都会返回true。
我们将ref函数的基本逻辑介绍完后,再把其他几个ref相关的函数顺便也来看一下。
unref
首先就是unref,用来获取ref对象中的value值:
1 |
|
我们发现,其实unref这个函数本质上就是一个语法糖,只是帮我们拿到ref对象的value
属性;对于基本类型,我们知道value中存储的就是值本身;而对于数组和对象等引用类型,value中存的则是Proxy Object拦截对象。
因此如果我们还想继续获取引用类型的真实值,还需要通过toRaw
进行一下转换:
1 |
|
toRefs
ref中还有一个常用的函数就是toRefs,复制reactive
中的所有属性并转成ref对象,我们先来看下它的用法:
1 |
|
下面就来看下它的源码逻辑:
1 |
|
这里先对传入的object进行是否数组的判断,如果是数组,创建相同长度的数组;否则,创建一个空对象,最后返回这个对象。
这里的object其实是我们传入的reactive对象,但是我们发现这里object还可以传入数组,因此,我们可以写出下面的代码:
1 |
|
一般没有人这么干。
回到toRefs,我们发现对object调用了propertyToRef
函数,这里的key就是object上每个属性的名称;我们继续看propertyToRef做了什么:
1 |
|
Record类型
我们看到propertyToRef中传入的参数source是一个Record
类型,这里简单介绍一下这个类型;Record是ts内置的一个高级类型,可以让我们来定义具有特定键和相应值类型的对象类型;它的语法也很简单,后面是一对尖括号,尖括号中两个分别表示定义对象的键的类型和值的类型:
1 |
|
这边我们定义了一个MyRecord类型,具有字符串和数字的对象类型;相比于object类型,我们直接访问object上面的属性会报错:
1 |
|
我们还可以使用联合类型来限制对象的键,限制为特定的字符串:
1 |
|
但是这种写法要求我们将所有的键都写完全,不然会提示报错;可以结合Partial
,将所有的键变为可选的:
1 |
|
这样我们就不需要把所有的属性都写全了。除了将值定义为基本类型,我们还可以定义为对象;比如定义一个学生的字典对象:
1 |
|
总结一下,Record
类型是ts中一个强大并且灵活的工具,可以很方便的让我们定义带有特定键值对的对象。
ObjectRefImpl类
回到上面propertyToRef
函数中,我们看到又封装了一个新的类ObjectRefImpl
,它的源码如下:
1 |
|
这里constructor的缩略写法写法有些小伙伴可能会看不懂了,我们稍微解释一下;constructor传入了带有修饰符的参数,这样ts就会自动帮我们在类里声明实例上的属性,不用我们在下面去吭哧吭哧地一个一个显示地写出来了;因此其实上面的constructor写法就相当于如下:
1 |
|
因此缩略写法中constructor中的修饰符不能省略掉,具体的用法可以参考实例属性的简写形式
回到ObjectRefImpl类
中,它里面的属性其实很简单,一个_object,存储的是我们传进入的reactive对象;_key是reactive对象上需要映射的一个键名;_defaultValue是默认的值;我们发现ObjectRefImpl
这个类做的事情其实非常简单,就是把reactive对象的取值和赋值过程进行了一下封装。
toRef
最后一个常用的函数就是toRef,我们主要用它来复制reactive
里面的单个属性然后转成ref
对象,既保留了响应式,也保留了对原有reactive
的引用;我们先看下它的常见用法:
1 |
|
看到这里很多小伙伴可能也想到了,上面toRefs源码中不是有propertyToRef
函数么,只要传入封装一下不就完事了。
但是实际上这个只是它的其中一个用法,toRef支持传入多种类型的数据;下面我们看下它里面的主要实现源码:
1 |
|
这里分为多种情况,首先第一种情况,如果传入的参数本来就是ref对象,则返回自己,这里不在赘述了;第二种情况,isFunction
检查参数是否是一个函数,因此我们可以在toRef中传入一个函数,比如像下面这种写法:
1 |
|
这里由于传入的是函数,返回的是一个只读的Ref,我们不能去修改它的value值;回到toRef函数中,我们看到又将source(函数)传入了GetterRefImpl类
,我们看它的实现逻辑:
1 |
|
这里constructor的缩略写法和上面一样,就不再赘述了。这里的_getter参数本质上就是一个函数,然后返回泛型T,因此它的类型其实就是() => T
;然后GetterRefImpl类的getter函数就调用这个函数并返回结果。
toRef函数的第三种情况,如果source是对象,并且还有其他参数,调用propertyToRef
函数,就是我们最常见的用法了。
最后一种情况如果是其他类型,直接返回一个ref对象:
1 |
|
因此我们用toRef传入基本类型,其实和直接调用ref函数的效果是一样的。
一些细节问题
上面我们只注重ref函数整体的逻辑,忽略了一些细节问题,本小结我们就来看下细节问题;比如我们上面提到,带有下划线的属性一般是类的私有属性,不会对外暴露;但是我们看到_value定义的时候没有加private,而_rawValue加了private:
1 |
|
因此其实_value属性默认就是public的,这就和我们的认知不符了,但是我们在ref对象上确实也访问不到_value。当事实和我们的认知有所冲突时,肯定是哪里出了问题,这里就需要耐心以及合理的猜测假设了。
我们继续看ref的类型定义,上面确实没有_value,因此这也就是我们在编辑器里面访问_value属性会报错的根本原因所在。
1 |
|
那么既然外面访问不到_value,那么笔者就大胆的猜测,是不是需要在源码内部去访问呢?经过全局查找,总算在笔者忽略的一个函数中看到了_value的身影:
1 |
|
我们看到在newValue中用到了RefImpl类的_value,这就解释了上面为什么_value不是定义成了private。
总结
这里我们对ref函数源码做个简单的总结,本文从ref和shallowRef函数入手,找到了它们共同的封装函数createRef
,然后找到了ref的核心类RefImpl
,ref函数传入的值实际上都存储在它的_value属性中,基本类型的响应式是通过_value的getter和setter函数,而复杂类型则是继续使用reactive.ts中的toReactive转化成代理对象,实现响应式的。
然后继续对ref相关的几个函数unref、toRefs和toRef进行了说明,同时通过源码我们学到了constructor中参数的缩略写法,还有Record类型定义的对象类型,这些小技巧都可以极大的方便我们后续项目中ts的使用。
本来笔者是打算把reactive的源码一起写的,但是随着ref中的类越看越多,本文的内容也越来越多,因此笔者将reactive源码的解析后面会单独一篇文章,希望大家多多点赞收藏,更新会更快哦。
获取Vue3源码敬请关注公众号【前端壹读】,后台回复关键词【Vue3源码】即可获取。
本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。