isRef()、unRef()、toRef()、toRefs()深度解析,为啥解构会失去响应式?

发布时间 2023-12-11 16:06:29作者: 柯基与佩奇

前言

isRef()unRef()toRef()toRefs()这几个函数他们各自都有什么功能,在什么场景下应用以及有哪些细节是我们没有注意到的,我们一起来看一下,为了方便大家理解和对照,这里以官方文档说明 + 解析的方式讲解。

isRef()

检查某个值是否为 ref。

  • 类型

    ts

    function isRef<T>(r: Ref<T> | unknown): r is Ref<T>;
    

    请注意,返回值是一个类型判定 (type predicate),这意味着  isRef  可以被用作类型守卫:

    ts

    let foo: unknown;
    if (isRef(foo)) {
      // foo 的类型被收窄为了 Ref<unknown>
      foo.value;
    }
    

解析

  • 作用

    判断某个值是否为 Ref 对象,如果是 Ref 对象的话,它就是响应式的,且需要通过 .value 取值或赋值。

    另外可以做类型保护

    上面的 is 是一个 ts 中的类型谓词,用来帮助 ts 编译器 收窄 变量的类型。例如:

    function isString(test: any): test is string {
      return typeof test === "string";
    }
    

    isString 函数返回一个类型判定,意思是当 test 的类型是 string 时,那么该函数的返回值类型就是 string,你可以放心地把它当做 string 类型使用。

    image.png

    isRef(foo)用作类型保护时,它能确保你的 .value 操作不会出现任何问题。

    你还可以使用 typeof、instanceof、in 操作符来进行类型收窄,但是都有其弊端,而 is 是更全面的方法,大家感兴趣可以深入去学习,这里不跑题了。

unref()

如果参数是 ref,则返回内部值,否则返回参数本身。这是  val = isRef(val) ? val.value : val  计算的一个语法糖。

  • 类型

    ts

    function unref<T>(ref: T | Ref<T>): T;
    
  • 示例

    ts

    function useFoo(x: number | Ref<number>) {
      const unwrapped = unref(x);
      // unwrapped 现在保证为 number 类型
    }
    

解析

  • 作用

    让你更方便快捷地获取 Ref 对象的 value 值,不用自己重复写判断代码。因为 Ref 对象需要 .value 取值,所以才有了这个函数。

    Ref 在模板中的自动解包源码实现就是用的这个方法。

    关于自动解包原理会再开一篇细嗦。

toRef()

基于响应式对象上的一个属性,创建一个对应的 ref。这样创建的 ref 与其源属性保持同步:改变源属性的值将更新 ref 的值,反之亦然。

  • 类型

    ts

    function toRef<T extends object, K extends keyof T>(
      object: T,
      key: K,
      defaultValue?: T[K]
    ): ToRef<T[K]>;
    
    type ToRef<T> = T extends Ref ? T : Ref<T>;
    
  • 示例

    js

    const state = reactive({
      foo: 1,
      bar: 2,
    });
    
    const fooRef = toRef(state, "foo");
    
    // 更改该 ref 会更新源属性
    fooRef.value++;
    console.log(state.foo); // 2
    
    // 更改源属性也会更新该 ref
    state.foo++;
    console.log(fooRef.value); // 3
    

解析

基于响应式对象上的一个属性,创建一个对应的 ref,实际上就是内部做的就是创建一个对象,这个对象的 value 属性被监听了 getset,将对这个对象的 value 值的访问代理到了响应式对象的一个属性上,我们可以来手写一下:

const obj = {
  foo: 1,
  bar: 2,
};

// 创建一个响应式对象,相当于 Vue 的 reactive()
const state = new Proxy(obj, {
  get(target, key, receiver) {
    console.log("被访问", target, key);
    const res = Reflect.get(target, key, receiver);
    return res;
  },

  set(target, key, newValue, receiver) {
    console.log("被set", target, key, newValue);
    const res = Reflect.set(target, key, newValue, receiver);
    return res;
  },
});

// toRef 函数可以将响应式对象的一个属性创建一个新的 Ref 对象(被监听 value 属性的对象)
const toRef = function (object, key) {
  const newObj = {}; // 创建一个新对象,监听 value 属性的 get 和 set
  Object.defineProperty(newObj, "value", {
    enumerable: true,
    configurable: true,
    get: function () {
      return object[key]; // 访问的其实还是原响应式对象
    },
    set: function (newVal) {
      object[key] = newVal;
    },
  });

  return newObj;
};

const fooRef = toRef(state, "foo");

fooRef.value = 66; // .value属性已经被代理到原响应式对象上,输出被“set”

console.log(obj.foo); // .value属性已经被代理到原响应式对象上,输出“被访问”

源码使用的是类来监听 get 和 set 的,像 下面这样,上面用的Object.defineProperty来模拟,原理是一样的(类底层也是 ES5 实现的):

export function toRef<T extends object, K extends keyof T>(
  object: T,
  key: K,
  defaultValue?: T[K]
): ToRef<T[K]> {
  const val = object[key]
  return isRef(val) // 如果已经是 Ref 直接返回,不是就创建 Ref 对象
    ? val
    : (new ObjectRefImpl(object, key, defaultValue) as any)
}

class ObjectRefImpl<T extends object, K extends keyof T> {
  public readonly __v_isRef = true

  constructor(
    private readonly _object: T,
    private readonly _key: K,
    private readonly _defaultValue?: T[K]
  ) { }

  get value() {
    const val = this._object[this._key]; // 支持可选传入默认值,在属性不存在时返回
    return val === undefined ? (this._defaultValue as T[K]) : val
  }

  set value(newVal) {
    this._object[this._key] = newVal
  }
}

除此之外,toRef()  这个函数在你想把一个 prop 的 ref 传递给一个组合式函数时会很有用:

vue

<script setup>
import { toRef } from "vue";

const props = defineProps(/* ... */);

// 将 `props.foo` 转换为 ref,然后传入
// 一个组合式函数
useSomeFeature(toRef(props, "foo"));

// 相当于
const fooRef = toRef(props, "foo"); // 你可以简单粗暴理解为 fooRef.value = props.foo
useSomeFeature(fooRef); // 操作 fooRef.value 时就会访问原 props.foo 所以关于禁止对 props 做出更改的限制依然有效
</script>

当  toRef  与组件 props 结合使用时,关于禁止对 props 做出更改的限制依然有效。尝试将新的值传递给 ref 等效于尝试直接更改 props,这是不允许的。在这种场景下,你可能可以考虑使用带有  get  和  set  的  computed  替代。详情请见在组件上使用  v-model  指南。

如果要更改 fooRef.value,最好使用计算属性,不要直接更改 fooRef.value。

即使源属性当前不存在,toRef()  也会返回一个可用的 ref。这让它在处理可选 props 的时候格外实用,相比之下  toRefs  就不会为可选 props 创建对应的 refs。

即使源属性当前不存在,toRef()  也会返回一个可用的 ref,你可以传入一个默认值在触发 get 时返回,因为 fooRef.value 操作的是原响应式对象,相当于 fooRef.value = {},所以如果没有这个属性的话你取值就是 undefined,赋值就直接添加属性了,此处不理解的同学可以再看一下上面源码。

toRefs()

将一个响应式对象转换为一个普通对象,这个普通对象的每个属性都是指向源对象相应属性的 ref。每个单独的 ref 都是使用  toRef()  创建的。

  • 类型

    ts

    function toRefs<T extends object>(
      object: T
    ): {
      [K in keyof T]: ToRef<T[K]>
    }
    
    type ToRef = T extends Ref ? T : Ref<T>
    
  • 示例

    js

    const state = reactive({
      foo: 1,
      bar: 2,
    });
    
    const stateAsRefs = toRefs(state);
    /*
    stateAsRefs 的类型:{
      foo: Ref<number>,
      bar: Ref<number>
    }
    */
    
    // 这个 ref 和源属性已经“链接上了”
    state.foo++;
    console.log(stateAsRefs.foo.value); // 2
    
    stateAsRefs.foo.value++;
    console.log(state.foo); // 3
    

    当从组合式函数中返回响应式对象时,toRefs  相当有用。使用它,消费者组件可以解构/展开返回的对象而不会失去响应性:

    js

    function useFeatureX() {
      const state = reactive({
        foo: 1,
        bar: 2,
      });
    
      // ...基于状态的操作逻辑
    
      // 在返回时都转为 ref
      return toRefs(state);
    }
    
    // 可以解构而不会失去响应性,因为解构后的 foo 是个 Ref 对象,如果直接解构state的话解构完是一个值 ‘1’
    const { foo, bar } = useFeatureX();
    

    toRefs  在调用时只会为源对象上可以枚举的属性创建 ref。如果要为可能还不存在的属性创建 ref,请改用  toRef

解析

实际上就是生成一个新对象,新对象的每个属性对应一个 Ref 对象,原响应式属性的操作被代理到了新对象的 Ref 上,可以解构响应式对象而不会失去响应式。

为啥解构会失去响应式?

这个其实与 JS 基本类型和引用类型在内存中的存储方式有关:

基本数据类型是指存放在中的简单数据段,数据大小确定,内存空间大小可以分配, 它们是直接按值存放的,所以可以直接按值访问。 基本数据类型的值是没有办法添加属性和方法的

引用类型是存放在堆内存中的对象,引用类型数据在栈内存中保存的实际上是对象在堆内存中的引用地址。通过这个引用地址可以快速查找到保存中堆内存中的对象,即按引用访问

const state = reactive({
  foo: 1,
  bar: 2,
});

// state 是响应式对象,但是foo只是一个值

const { foo, bar } = state; // 此时 foo 就等于 1 了,1 是一个值,不是一个响应式对象
foo = 6; // 更改在视图中并不会生效,因为解构时 foo 被重新赋值了,即 const foo = state.foo; const foo = 1; foo 已经和 响应式的state 没有任何关系了

所以,解构基本类型会失去响应式,那如果是引用类型呢?

// 对于子属性值是引用类型的,reactive()方法会递归添加响应式
const state = reactive({
    foo: [1, 2, 3],
    bar: {a: 1, b: 2},
})

// state 是响应式对象,foo 也是响应式对象

const { foo, bar } = state;

相当于

const foo = state.foo; // state.foo指向 [1, 2, 3]的代理对象的地址,即 new Proxy([1,2,3], ...)
foo[1] = 6; // 此时更新是生效的,因为 foo 指向的还是响应式对象

// 控制台可看到 [1, 2, 3] 的代理对象
     Proxy {0: 1, 1: 2, 2: 3}
     [[Handler]]: Object
     [[Target]]: Array(3)
     [[IsRevoked]]: false

所以,实际上对于引用类型的解构是不会失去响应式的,尤大可能为了不增加大家的学习负担,所以也没区分基本类型和引用类型。

将属性传给一个函数时失去响应式也是一样的道理,如果你传的是值就会失去响应式,如果传的是引用地址则不会失去响应式。

这块大家一定要好好理解一下。

补充一个小知识点

解构也会改变 this 指向

举例方便理解:

class MyClass {
  constructor() {
    this.name = "MyClass";
  }

  printName() {
    console.log(this.name);
  }
}

const myInstance = new MyClass();

// 直接调用,this指向实例对象
myInstance.printName(); // 输出: 'MyClass'

// 解构后调用,this丢失
const { printName } = myInstance;
printName(); // 输出: undefined 或 报错

解构后,由于printName已经脱离了其原始对象的上下文,所以在方法内部,this不再指向类的实例,因此无法访问到name属性。这就是为什么它会输出undefined

如果你想在解构后仍然能访问原始对象,你可以在解构后明确地绑定this

const { printName } = myInstance;
const boundPrintName = printName.bind(myInstance);
boundPrintName(); // 输出: 'MyClass'

感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,还请三连支持一下,点赞、关注、收藏,作者会持续与大家分享更多干货