本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
大家好,我是欧阳,又跟大家见面啦!
欧阳建了一个高质量 vue 源码交流群,扫描文末的二维码加欧阳微信,拉你进群。
前言
最近在我的vue源码交流群有位面试官分享了一道他的面试题:**vue3 的 ref 是如何实现响应式的?**下面有不少小伙伴回答的是Proxy,其实这些小伙伴只回答对了一半。
当 ref 接收的是一个对象时确实是依靠
Proxy去实现响应式的。但是 ref 还可以接收
string、number或boolean这样的原始类型,当是原始类型时,响应式就不是依靠Proxy去实现的,而是在value属性的getter和setter方法中去实现的响应式。
本文将通过 debug 的方式带你搞清楚当 ref 接收的是对象和原始类型时,分别是如何实现响应式的。注:本文中使用的 vue 版本为3.4.19。
看个 demo
还是老套路,我们来搞个 demo,index.vue文件代码如下:
<template> <div> <p>count的值为:{{ count }}</p> <p>user.count的值为:{{ user.count }}</p> <button @click="count++">count++</button> <button @click="user.count++">user.count++</button> </div></template><script setup lang="ts">import { ref } from "vue";const count = ref(0);const user = ref({ count: 0,});</script>在上面的 demo 中我们有两个 ref 变量,count变量接收的是原始类型,他的值是数字 0。
count变量渲染在 template 的 p 标签中,并且在 button 的 click 事件中会count++。
user变量接收的是对象,对象有个count属性。
同样user.count也渲染在另外一个 p 标签上,并且在另外一个 button 的 click 事件中会user.count++。
接下来我将通过 debug 的方式带你搞清楚,分别点击count++和user.count++按钮时是如何实现响应式的。
开始打断点
第一步从哪里开始下手打断点呢?
既然是要搞清楚 ref 是如何实现响应式的,那么当然是给 ref 打断点吖,所以我们的第一个断点是打在const count = ref(0);代码处。这行代码是运行时代码,是跑在浏览器中的。
要在浏览器中打断点,需要在浏览器的 source 面板中打开index.vue文件,然后才能给代码打上断点。
那么第二个问题来了,如何在 source 面板中找到我们这里的index.vue文件呢?
很简单,像是在 vscode 中一样使用command+p(windows 中应该是 control+p)就可以唤起一个输入框。在输入框里面输入index.vue,然后点击回车就可以在 source 面板中打开index.vue文件。如下图:
然后我们就可以在浏览器中给const count = ref(0);处打上断点了。
RefImpl类
刷新页面此时断点将会停留在const count = ref(0);代码处,让断点走进ref函数中。在我们这个场景中简化后的ref函数代码如下:
function ref(value) { return createRef(value, false);}可以看到在ref函数中实际是直接调用了createRef函数。
接着将断点走进createRef函数,在我们这个场景中简化后的createRef函数代码如下:
function createRef(rawValue, shallow) { return new RefImpl(rawValue, shallow);}从上面的代码可以看到实际是调用RefImpl类 new 了一个对象,传入的第一个参数是rawValue,也就是 ref 绑定的变量值,这个值可以是原始类型,也可以是对象、数组等。
接着将断点走进RefImpl类中,在我们这个场景中简化后的RefImpl类代码如下:
class RefImpl { private _value: T private _rawValue: T constructor(value) { this._rawValue = toRaw(value); this._value = toReactive(value); } get value() { trackRefValue(this); return this._value; } set value(newVal) { newVal = toRaw(newVal); if (hasChanged(newVal, this._rawValue)) { this._rawValue = newVal; this._value = toReactive(newVal); triggerRefValue(this, 4, newVal); } }}从上面的代码可以看到RefImpl类由三部分组成:constructor构造函数、value属性的getter方法、value属性的setter方法。
RefImpl类的constructor构造函数
constructor构造函数中的代码很简单,如下:
constructor(value) { this._rawValue = toRaw(value); this._value = toReactive(value);}在构造函数中首先会将toRaw(value)的值赋值给_rawValue属性中,这个toRaw函数是 vue 暴露出来的一个 API,他的作用是根据一个 Vue 创建的代理返回其原始对象。因为ref函数不光能够接受普通的对象和原始类型,而且还能接受一个 ref 对象,所以这里需要使用toRaw(value)拿到原始值存到_rawValue属性中。
接着在构造函数中会执行toReactive(value)函数,将其执行结果赋值给_value属性。toReactive函数看名字你应该也猜出来了,如果接收的 value 是原始类型,那么就直接返回 value。如果接收的 value 不是原始类型(比如对象),那么就返回一个 value 转换后的响应式对象。这个toReactive函数我们在下面会讲。
_rawValue属性和_value属性都是RefImpl类的私有属性,用于在RefImpl类中使用的,而暴露出去的也只有value属性。
经过constructor构造函数的处理后,分别给两个私有属性赋值了:
_rawValue中存的是 ref 绑定的值的原始值。如果 ref 绑定的是原始类型,比如数字 0,那么
_value属性中存的就是数字 0。如果 ref 绑定的是一个对象,那么
_value属性中存的就是绑定的对象转换后的响应式对象。
RefImpl类的value属性的getter方法
我们接着来看value属性的getter方法,代码如下:
get value() { trackRefValue(this); return this._value;}当我们对 ref 的 value 属性进行读操作时就会走到getter方法中。
我们知道 template 经过编译后会变成 render 函数,执行 render 函数会生成虚拟 DOM,然后由虚拟 DOM 生成真实 DOM。
在执行 render 函数期间会对count变量进行读操作,所以此时会触发count变量的value属性对应的getter方法。
在getter方法中会调用trackRefValue函数进行依赖收集,由于此时是在执行 render 函数期间,所以收集的依赖就是 render 函数。
最后在getter方法中会 return 返回_value私有属性。
RefImpl类的value属性的setter方法
我们接着来看value属性的setter方法,代码如下:
set value(newVal) { newVal = toRaw(newVal); if (hasChanged(newVal, this._rawValue)) { this._rawValue = newVal; this._value = toReactive(newVal); triggerRefValue(this, 4, newVal); }}当我们对 ref 的 value 的属性进行写操作时就会走到setter方法中,比如点击count++按钮,就会对count的值进行+1,触发写操作走到setter方法中。
给setter方法打个断点,点击count++按钮,此时断点将会走到setter方法中。初始化count的值为 0,此时点击按钮后新的count值为 1,所以在setter方法中接收的newVal的值为 1。如下图:
从上图中可以看到新的值newVal的值为 1,旧的值this._rawValue的值为 0。然后使用if (hasChanged(newVal, this._rawValue))判断新的值和旧的值是否相等,hasChanged的代码也很简单,如下:
const hasChanged = (value, oldValue) => !Object.is(value, oldValue);Object.is方法大家平时可能用的比较少,作用也是判断两个值是否相等。和==的区别为Object.is不会进行强制转换,其他的区别大家可以参看 mdn 上的文档。
使用hasChanged函数判断到新的值和旧的值不相等时就会走到 if 语句里面,首先会执行this._rawValue = newVal将私有属性_rawValue的值更新为最新值。接着就是执行this._value = toReactive(newVal)将私有属性_value的值更新为最新值。
最后就是执行triggerRefValue函数触发收集的依赖,前面我们讲过了在执行 render 函数期间由于对count变量进行读操作。触发了getter方法,在getter方法中将 render 函数作为依赖进行收集了。
所以此时执行triggerRefValue函数时会将收集的依赖全部取出来执行一遍,由于 render 函数也是被收集的依赖,所以 render 函数会重新执行。重新执行 render 函数时从count变量中取出的值就是新值 1,接着就是生成虚拟 DOM,然后将虚拟 DOM 挂载到真实 DOM 上,最终在页面上count变量绑定的值已经更新为 1 了。
看到这里你是不是以为关于 ref 实现响应式已经完啦?
我们来看 demo 中的第二个例子,user对象,回顾一下在 template 和 script 中关于user对象的代码如下:
<template> <div> <p>user.count的值为:{{ user.count }}</p> <button @click="user.count++">user.count++</button> </div></template><script setup lang="ts">import { ref } from "vue";const user = ref({ count: 0,});</script>在 button 按钮的 click 事件中执行的是:user.count++,前面我们讲过了对 ref 的 value 属性进行写操作会走到setter方法中。但是我们这里 ref 绑定的是一个对象,点击按钮时也不是对user.value属性进行写操作,而是对user.value.count属性进行写操作。所以在这里点击按钮不会走到setter方法中,当然也不会重新执行收集的依赖。
那么当 ref 绑定的是对象时,我们改变对象的某个属性时又是怎么做到响应式更新的呢?
这种情况就要用到Proxy了,还记得我们前面讲过的RefImpl类的constructor构造函数吗?代码如下:
class RefImpl { private _value: T private _rawValue: T constructor(value) { this._rawValue = toRaw(value); this._value = toReactive(value); }}其实就是这个toReactive函数在起作用。
Proxy实现响应式
还是同样的套路,这次我们给绑定对象的名为user的 ref 打个断点,刷新页面代码停留在断点中。还是和前面的流程一样最终断点走到RefImpl类的构造函数中,当代码执行到this._value = toReactive(value)时将断点走进toReactive函数。代码如下:
const toReactive = (value) => (isObject(value) ? reactive(value) : value);在toReactive函数中判断了如果当前的value是对象,就返回reactive(value),否则就直接返回 value。这个reactive函数你应该很熟悉,他会返回一个对象的响应式代理。因为reactive不接收 number 这种原始类型,所以这里才会判断value是否是对象。
我们接着将断点走进reactive函数,看看他是如何返回一个响应式对象的,在我们这个场景中简化后的reactive函数代码如下:
function reactive(target) { return createReactiveObject( target, false, mutableHandlers, mutableCollectionHandlers, reactiveMap );}从上面的代码可以看到在reactive函数中是直接返回了createReactiveObject函数的调用,第三个参数是mutableHandlers。从名字你可能猜到了,他是一个 Proxy 对象的处理器对象,后面会讲。
接着将断点走进createReactiveObject函数,在我们这个场景中简化后的代码如下:
function createReactiveObject( target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) { const proxy = new Proxy(target, baseHandlers); return proxy;}在上面的代码中我们终于看到了大名鼎鼎的Proxy了,这里 new 了一个Proxy对象。new 的时候传入的第一个参数是target,这个target就是我们一路传进来的 ref 绑定的对象。第二个参数为baseHandlers,是一个 Proxy 对象的处理器对象。这个baseHandlers是调用createReactiveObject时传入的第三个参数,也就是我们前面讲过的mutableHandlers对象。
在这里最终将 Proxy 代理的对象进行返回,我们这个 demo 中 ref 绑定的是一个名为user的对象,经过前面讲过函数的层层 return 后,user.value的值就是这里 return 返回的proxy对象。
当我们对user.value响应式对象的属性进行读操作时,就会触发这里 Proxy 的 get 拦截。
当我们对user.value响应式对象的属性进行写操作时,就会触发这里 Proxy 的 set 拦截。
get和set拦截的代码就在mutableHandlers对象中。
Proxy的set和get拦截
在源码中使用搜一下mutableHandlers对象,看到他的代码是这样的,如下:
const mutableHandlers = new MutableReactiveHandler();从上面的代码可以看到mutableHandlers对象是使用MutableReactiveHandler类 new 出来的一个对象。
我们接着来看MutableReactiveHandler类,在我们这个场景中简化后的代码如下:
class MutableReactiveHandler extends BaseReactiveHandler { set(target, key, value, receiver) { let oldValue = target[key]; const result = Reflect.set(target, key, value, receiver); if (target === toRaw(receiver)) { if (hasChanged(value, oldValue)) { trigger(target, "set", key, value, oldValue); } } return result; }}在上面的代码中我们看到了set拦截了,但是没有看到get拦截。
MutableReactiveHandler类是继承了BaseReactiveHandler类,我们来看看BaseReactiveHandler类,在我们这个场景中简化后的BaseReactiveHandler类代码如下:
class BaseReactiveHandler { get(target, key, receiver) { const res = Reflect.get(target, key, receiver); track(target, "get", key); return res; }}在BaseReactiveHandler类中我们找到了get拦截,当我们对 Proxy 代理返回的对象的属性进行读操作时就会走到get拦截中。
前面讲过了经过层层 return 后user.value的值就是这里的proxy响应式对象,而我们在 template 中使用user.count将其渲染到 p 标签上,在 template 中读取user.count,实际就是在读取user.value.count的值。
同样的 template 经过编译后会变成 render 函数,执行 render 函数会生成虚拟 DOM,然后将虚拟 DOM 转换为真实 DOM 渲染到浏览器上。在执行 render 函数期间会对user.value.count进行读操作,所以会触发BaseReactiveHandler这里的get拦截。
在get拦截中会执行track(target, "get", key)函数,执行后会将当前 render 函数作为依赖进行收集。到这里依赖收集的部分讲完啦,剩下的就是依赖触发的部分。
我们接着来看MutableReactiveHandler,他是继承了BaseReactiveHandler。在BaseReactiveHandler中有个get拦截,而在MutableReactiveHandler中有个set拦截。
当我们点击user.count++按钮时,会对user.value.count进行写操作。由于对count属性进行了写操作,所以就会走到set拦截中,set拦截代码如下:
class MutableReactiveHandler extends BaseReactiveHandler { set(target, key, value, receiver) { let oldValue = target[key]; const result = Reflect.set(target, key, value, receiver); if (target === toRaw(receiver)) { if (hasChanged(value, oldValue)) { trigger(target, "set", key, value, oldValue); } } return result; }}我们先来看看set拦截接收的 4 个参数,第一个参数为target,也就是我们 proxy 代理前的原始对象。第二个参数为key,进行写操作的属性,在我们这里key的值就是字符串count。第三个参数是新的属性值。
第四个参数receiver一般情况下是 Proxy 返回的代理响应式对象。这里为什么会说是一般是呢?看一下 MDN 上面的解释你应该就能明白了:
假设有一段代码执行
obj.name = "jen",obj不是一个 proxy,且自身不含name属性,但是它的原型链上有一个 proxy,那么,那个 proxy 的set()处理器会被调用,而此时,obj会作为 receiver 参数传进来。
接着来看set拦截函数中的内容,首先let oldValue = target[key]拿到旧的属性值,然后使用Reflect.set(target, key, value, receiver)
在Proxy中一般都是搭配Reflect进行使用,在Proxy的get拦截中使用Reflect.get,在Proxy的set拦截中使用Reflect.set。
这样做有几个好处,在 set 拦截中我们要 return 一个布尔值表示属性赋值是否成功。如果使用传统的obj[key] = value的形式我们是不知道赋值是否成功的,而使用Reflect.set会返回一个结果表示给对象的属性赋值是否成功。在 set 拦截中直接将Reflect.set的结果进行 return 即可。
还有一个好处是如果不搭配使用可能会出现this指向不对的问题。
前面我们讲过了receiver可能不是 Proxy 返回的代理响应式对象,所以这里需要使用if (target === toRaw(receiver))进行判断。
接着就是使用if (hasChanged(value, oldValue))进行判断新的值和旧的值是否相等,如果不相等就执行trigger(target, "set", key, value, oldValue)。
这个trigger函数就是用于依赖触发,会将收集的依赖全部取出来执行一遍,由于 render 函数也是被收集的依赖,所以 render 函数会重新执行。重新执行 render 函数时从user.value.count属性中取出的值就是新值 1,接着就是生成虚拟 DOM,然后将虚拟 DOM 挂载到真实 DOM 上,最终在页面上user.value.count属性绑定的值已经更新为 1 了。
这就是当 ref 绑定的是一个对象时,是如何使用 Proxy 去实现响应式的过程。
看到这里有的小伙伴可能会有一个疑问,为什么 ref 使用RefImpl类去实现,而不是统一使用Proxy去代理一个拥有value属性的普通对象呢?比如下面这种:
const proxy = new Proxy( { value: target, }, baseHandlers);如果是上面这样做那么就不需要使用RefImpl类了,全部统一成 Proxy 去使用响应式了。
但是上面的做法有个问题,就是使用者可以使用delete proxy.value将proxy对象的value属性给删除了。而使用RefImpl类的方式去实现就不能使用delete的方法去将value属性给删除了。
总结
这篇文章我们讲了ref是如何实现响应式的,主要分为两种情况:ref 接收的是 number 这种原始类型、ref 接收的是对象这种非原始类型。
当 ref 接收的是 number 这种原始类型时是依靠
RefImpl类的value属性的getter和setter方法中去实现的响应式。当我们对 ref 的 value 属性进行读操作时会触发 value 的
getter方法进行依赖收集。当我们对 ref 的 value 属性进行写操作时会进行依赖触发,重新执行 render 函数,达到响应式的目的。
当 ref 接收的是对象这种非原始类型时,会调用
reactive方法将 ref 的 value 属性转换成一个由Proxy实现的响应式对象。当我们对 ref 的 value 属性对象的某个属性进行读操作时会触发
Proxy的 get 拦截进行依赖收集。当我们对 ref 的 value 属性对象的某个属性进行写操作时会触发
Proxy的 set 拦截进行依赖触发,然后重新执行 render 函数,达到响应式的目的。
最后我们讲了为什么 ref 不统一使用Proxy去代理一个有value属性的普通对象去实现响应式,而是要多搞个RefImpl类。
因为如果使用Proxy去代理的有 value 属性的普通的对象,可以使用delete proxy.value将proxy对象的value属性给删除了。而使用RefImpl类的方式去实现就不能使用delete的方法去将value属性给删除了。
长按图片加欧阳微信,拉你进欧阳的高质量 vue 源码交流群,群里可谓是藏龙卧虎。
关注公众号,发送消息:
666,领取欧阳研究 vue 源码过程中收集的源码资料。欧阳写文章有时也会参考这些资料,同时让你的朋友圈多一位对 vue 有深入理解的人。
因为微信公众号修改规则,如果不标星或点在看,你可能会收不到我公众号文章的推送,请大家将本公众号星标,看完文章后记得点下赞或者在看,谢谢各位!