Vue 3.0源码系列之ref、toRef、toRefs

大家好,我是初心,本篇是源码系列之ref、toRef、toRefs 本篇也是我坚持原创文章的第04期文章,如有错误,欢迎指正

在讨论原始值的响应式方案,先看看原始值有哪些吧,目前阶段原始值分别是 Boolean, Number,BigInt, String, Symbol, undefined, null

前言

Vue 3.0源码系列之ref、toRef、toRefs_第1张图片

一、引入ref的概念

由于proxy代理目标必须是非原始值,所以我们没有任何手段拦截对原始值的操作,例如:

let name = 'luanshu';
// 无法拦截对值的修改
name = '巧君'; 

对于ref是一个函数创建响应式,在Vue2.0中已经规范了架子,采用options data对象形式,所以不需要考虑这个原始值的问题,对于这个问题,Vue3的作者及core核心成员们,想到了一个办法,目前官方说是唯一的办法,使用一个非原始值去 “包裹” 原始值,例如我们可以使用对象来包装

import { reactive } from 'Vue';

const wrapper = {value: 'luanshu'
}

// 可以使用 Proxy 代理wrapper,简洁实现对原始值的拦截
const userName = reactive(wrapper);

// 读取value
name.value // luanshu

// 修改值可以触发响应式
name.value = '巧君'; 

但是这样会导致两个问题:

1.用户为了创建一个响应式的原始值,不得不顺带创建一个包裹对象;
2.包裹对象有用户定义,而这以为着不规范,用户可以随意命名,例如wrapper.value 或者 wrapper.val 都是可以的。

为了解决这个问题,使用函数封装,将对象包裹起来封装到该函数中:

// 封装一个 ref 函数
function ref(val){// 在ref函数内部包裹对象const wrapper = {value: val,}// 将包裹对象变成响应式数据return wrapper;
} 

如上面的代码,我们把wrapper对象封装到ref函数内部,然后使用reactive函数将包裹对象变成响应式数据并返回,这样就解决了上述两个问题

import { effect } from 'Vue';

// 创建原始值的响应式数据
const refValue = ref('栾树');

effect(()=>{// 在函数副作用下 通过 value 值读取原始值console.log(refValue.value);
})

// 修改值能够触发副函数重新执行
refValue.value = 'luanshu'; 

我们都知道在Vue3.0中创建响应式有 ref 和 reactive 函数,现在就面临一个问题了,如何区分是reactive函数创建的响应式还是 ref 函数创建的响应式呢?

import { ref, reactive } from 'Vue';
// ref
const refValue = ref(1);
// reactive
const reactiveValue = reactive({ value: 1 }); 

core核心大佬们 想到使用Object.defineProperty区分

import { reactive } from 'Vue';

function ref(val){// 在ref函数内部包裹对象const wrapper = {value: val,}// Object.definePropertyObject.defineProperty(wrapper, '__v_isRef', {value: true,})// 将包裹对象变成响应式数据return reactive(wrapper);
} 

我们使用Object.defineProperty为包裹对象 wrapper 定义了一个不可枚举且不可写属性 __v_isRef,它的值为true,代表这个是一个ref,而非普通对象,这样就可以通过__v_isRef判断检查一个数据是否是ref。

二、响应丢失问题

ref除了用于原始值响应式方案之外,还能用来解决响应式丢失问题,首先,我们需要看下响应式丢失问题



 

然而,这样做会丢失响应式,其表现是,当我们修改响应式数据的值时,不会触发重新渲染,为什么会丢失响应式呢?这里是由运算符(…)导致的,实际上下面这段代码:

const wrapper = reactive({ flowerName: '栾树', userName: '巧君' })

return {...wrapper
}

// 等价于
return {flowerName: '栾树',userName: '巧君'
} 

可以发现,这其实是返回的一个普通对象,它不具备任何响应式能力,普通对象暴露到模板中使用,不会渲染函数和响应式数据之间建立响应式联系的,

如何解决这个问题呢,换句话说,有没有办法能够帮忙解决实现:在函数副作用内,即使通过普通对象来访问值呢,也可以建立联系?其实是有的,嘿嘿

import { reactive } from 'Vue';

const wrapper = reactive({ flowerName: '栾树',userName: '巧君'
});

// 通过对象访问器属性 value 当读取到 value 值时, 其实读取的是 wrapper 对象下对应的属性值
const newObj = {flowerName: {get value(){return reactive.flowerName}},userName: {get value(){return reactive.userName}}
}

effect(()=>{// 在副函数作用域访问 newObj.userName console.log(newObj.userName);
})

// 这个时候就可以触发响应式了
wrapper.userName = '巧军'; 

在这段代码里面其实我们修改 newObj 对象的实现方式。可以看到,在现在的 newObj 对象下,具有与 wrapper 对象同名的属性, 而且每个属性的值都是一个对象,例如 flowerName 属性的值是:

{get value(){return wrapper.flowerName;}
} 

该对象有一个访问器属性 value, 当读取 value 的值时, 最终读取的响应式数据 wrapper 下的同名属性值。也就是说,当在副作用函数内读取 newObj.flowerName时, 等价于间接读取了 wrapper.flowerName 的值。这样这样响应式数据自然是能够与副作用函数建立起响应联系。于是,当我们尝试修改 wrapper.flowerName 的值时,能够触发副作用函数重新执行。

观察 newObj 对象, 可以发现他的结构存在相似之处:

import { reactive } from 'Vue';

const wrapper = reactive({ age: 25, userName: '巧君' });

const newObj = {age: {get value(){return wrapper.age;}},userName: {get value(){return wrapper.userName}}

} 

age 和 userName 这两个属性的结构非常像, 这启发core核心作者将这种结构抽象出来并封装成函数,如下代码所示:

function toRef(obj, key){const wrapper = {get value(){return obj[key];}}return wrapper;
} 

toRef函数接受两个参数,第一个参数 obj 是一个响应式数据,第二个参数 obj 对象的一个键。该函数会返回一个类似 ref 结构的 wrapper 对象。 有了 toRef 函数后, 我们就可以重新 wrapper 对象:

import { toRef,reactive } from 'Vue';

const wrapper = reactive({ age: 25, userName: '栾树' })

const newObj = {age: toRef(wrapper, 'age'),userName: toRef(wrapper, 'userName'),
} 

可以看到,代码变得非常简洁。但如果响应式数据 wrapper 的键非常多,需要花费很大力气来做这一层转换。为此,我们可以封装 toRefs 函数,来批量地完成转换:

import { toRef } from 'Vue';

function toRefs(obj){const ret = {};// 使用 for...in 循环遍历对象for(const key in obj){// 逐个调用 toRef 完成转换ret[key] = toRef(obj, key);}
} 

这样我们只需要哦异步操作即可完成对一个对象的转换:

import { toRefs,reactive } from 'Vue';

const wrapper = reactive({ age: 25, userName: '栾树' })

const newObj = { ...toRefs(wrapper);
}

console.log(newObj.age.value) // 25 

现在,响应式丢失问题贝彻底解决了。解决问题的思路是,将响应式数据转换成类似于 ref 结构的数据。但为了概念上的统一,我们会将通过 toRef 或 toRefs 转换后得到的结果视为真正的 ref 数据,为此需要为 toRef 函数增加一段代码。

function toRef(obj,key){const wrapper = {get value () {return obj[key];}}// 定义一个 __v_isRef 属性Object.defineProperty(wrapper, '__v_isRef', {value: true})return wrapper;
} 

可以看到,使用 Object.defineProperty 函数为 wrapper 对象定义了 __v_isRef 属性。这样 toRef 函数的返回值就是真正意义上的 ref 了。通过上述的讲解我们能够注意到, ref 的作用不仅仅是是想原始值的响应式方案, 还是解决响应式丢失的问题。

但上文是想的 toRef 函数存在缺陷,即通过 toRef 函数创建的 ref 是可读的,入下面的代码所示:

import { reactive, toRef } from 'Vue';

const wrapper = reactive({ age: 25, userName: '巧君' });

const refWrapper = toRef(wrapper, 'age');

refWrapper.value = 18; // 无效 

这是因为 toRef 返回的 wrapper 对象的 value 属性只有 getter, 没有 setter。为了功能的完整性,我们应该为它加上setter函数,所以最终的实现如下:

function toRef(obj, key){const wrapper = {get(){return obj[key];},// 允许设置值set(val){obj[key] = val}}Object.defineProperty(wrapper, '__v_isRef', {value: true,})return wrapper;
} 

可以看到,当设置 value 属性的值时,最终设置的是响应式数据的同名属性值,这样就能正确的触发响应式了。

三、自动脱ref

toRefs 函数的确解决了响应式丢失问题,但同时也带来了新的问题,由于 toRefs 会吧响应式数据的第一层属性值转换为 ref, 因此必须通过 value 属性值访问, 如以下代码:

// 创建一个普通对象
constweapper = {flowerName: '栾树',userName: '巧君'
}

console.log(weapper.flowerName); // 栾树
console.log(weapper.userName); // 巧君

// 通过 toRefs 包装
const newWrapper = {...toRefs(weapper)
}
// 必须通过 value 访问值
console.log(newWrapper.flowerName.value);
console.log(newWrapper.userName.value); 

其实这增加了用户的心智负担,因为通常情况下用户在模板中访问数据的,例如:

 

因此我们需要自动脱 ref 的能力,所谓的自动脱 ref,指的是属性的访问行为, 即如果读取的属性是一个ref, 则直接将该 ref 对应的 value 属性值返回,例如:

console.log(newWrapper.flowerName); // 栾树 

可以看到,即使 newWrapper.flowerName 是一个ref, 也无法通过 newWrapper.flowerName.value 来访问它的值,需要使用 Proxy 为 newWrapper 创建一个代理对象, 通过代理来实现最终目标,这时就用到了上文中介绍的 ref 标识,即 __v_isRef 属性, 如下面的代码表示:

// proxyRefs
function proxyRefs(target){return new Proxy(target, {get(target, key, receiver){const value = Reflect.get(target, key, receiver);// 自动脱 ref 实现 如果读取的值是 ref, 则返回它的 value 属性值return value.__v_isRef ? value.value : value;}})
}

// 调用 proxyRefs
constweapper = {flowerName: '栾树',userName: '巧君'};
const newWrapper = proxyRefs({...toRefs(weapper)
});
console.log(newWrapper.flowerName); // 栾树 

在上面的代码中,我们定义了 proxyRefs 函数,该函数接受一个对象作为参数,并返回改对象的代理对象。代理对象的作用是拦截get操作,当读取的函数是一个 ref 时,则直接返回改 ref 的 value 值,这样就实现了自动脱ref

实际上,我们在编写Vue.js时,组件中的setup函数所返回的数据会传递给 proxyRefs 函数来进行处理



 

这也是为什么我们可以在模板中直接访问一个 ref 值,而无须通过 value 属性来访问:既然读取属性的值有自动脱落 ref 的能力,对应地,设置属性值耶应该自动为ref设置的能力,例如:

wrapper.flowerName = 'luanshu'; 

实现此功能很简单,只需要天啊及对应的 set 拦截函数即可:

function proxyRefs(target){return new Proxy(target, {get(target, key, receiver){const value = Reflect.get(target, key, receiver);// 自动脱 ref 实现 如果读取的值是 ref, 则返回它的 value 属性值return value.__v_isRef ? value.value : value;},set(target, key, newValue, receiver){// 通过 target 读取真实值const value = target[key];// 如果值是 ref 则设置其对应的value属性值if(value.__v_isRef){value.value = newValue;return true;}return Reflect.set(target, key, newValue, receiver);}})
} 

如上面的代码表示,我们为 proxyRefs 函数返回的代理对象添加了 set 函数。如果设置的属性是一个ref, 则简洁设置该 ref 的 value 属性值即可。实际上,自动脱 ref 不仅存在上述场景。在Vue.js中, reactive 函数也有自动脱 ref的能力,哈哈 reactive 就留在下一次的技术分享吧!

总结

我们首先介绍 ref 的概念, ref 本质上是一个 “包裹对象”。因为 JavaScript 的 Proxy 无法提供对原始值的代理,所以我们需要使用一层对象作为包裹,间接实现原始值的响应式方案。由于 “包裹对象” 本质上与普通对象没有任何区别, 因此为了区分 ref 与普通响应式对象,我们还未 “包裹对象” 定义了一个值为 true 的属性,即__v_isRef, 用它作为 ref 的标识

ref出了能够用于原始值的响应式之外,还能用解决响应式丢失的问题。为了解决该问题,我们实现了 toRef 以及 toRefs 这两个函数。它们本质上是对响应式数据做了一层包装,或者叫做 “访问代理”

最后,讲述了自动脱 ref 的能力。为了减轻用户的心智负担,我们自动对暴露到模板中的响应式数据进行脱 ref 处理。这样,用户在模块中使用响应式数据时,就无须关心一个值是不是 ref 了。

最后

最近还整理一份JavaScript与ES的笔记,一共25个重要的知识点,对每个知识点都进行了讲解和分析。能帮你快速掌握JavaScript与ES的相关知识,提升工作效率。



有需要的小伙伴,可以点击下方卡片领取,无偿分享

你可能感兴趣的:(vue.js,javascript,前端)