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

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

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

前言

一、引入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.defineProperty
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true,
  })
  // 将包裹对象变成响应式数据
  return reactive(wrapper);
}

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

二、响应丢失问题

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

<template>
  <div>姓名:{{ userName }} / 花名: {{ flowerName }}</div>
</template>

<script>
import { reactive } from 'Vue';
  
export default{
  setup(){
    
      const wrapper = reactive({ flowerName: '栾树', userName: '巧君' })
      
      
      // 1s 后修改响应式数据的值 不会触发重新渲染
      setTimeout(()=>{
        wrapper.flowerName = 'luanshu';
      },1000)
      
      // 这里丢失响应式
      return {
        ...wrapper
      }
  },
}

</script>

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

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 属性值访问, 如以下代码:

// 创建一个普通对象
const  weapper = {
    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);

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

<template>
  <div>
    <p>{{ flowerName }} / {{ userName }}</p>
    
    {{ '用户不希望编写以下的代码' }}
    
    <p>{{ flowerName.value }} / {{ userName.value }}</p>
  </div>
</template>

因此我们需要自动脱 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
const  weapper = {flowerName: '栾树',userName: '巧君'};
const newWrapper = proxyRefs({
  ...toRefs(weapper)
});
console.log(newWrapper.flowerName); // 栾树

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

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

<template>
  <p>{{ count }}</p>
</template>

<script>
import { ref } from 'Vue';

const ClComponent = {
  setup(){
    const count = ref(0);
    
    // 返回的这个对象会传递给 proxyRefs
    return {
      count
    }
  }
}
</script>

这也是为什么我们可以在模板中直接访问一个 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 了。

  • 40
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值