ShawDubie

Vue 系列(七):利用 Ref 代理原始值

在前面的文章中,我们介绍了如何代理普通对象、数组还有集合。但是这些值都是属于引用类型的值,所以我们能够通过 Proxy 对她们进行代理。但是对于基本类型的值,或者说原始值,我们是没办法通过 Proxy 进行代理的。比如 Number、Booelan、String 等,那么本篇我们就将介绍如何去代理原始值。

代理原始值的思路


先来看一个例子:

let foo = 1;
let bar = true;

foo = 2;
bar = false;

上面定义了两个变量,她们都是原始值,可以看到,我们是没法对其进行代理的。既然引用值是可以被代理的,那么我们很容易想到,通过引用值将其包裹即可实现对原始值的代理:

// 通过一个对象将原始值包裹,就可以实现对其的代理了
const foo = reactive({ value: 1 });
const bar = reactive({ value: true });

effect(() => {
  console.log(foo.value);
  console.log(bar.value);
});

// 能够触发响应
setTimeout(() => {
  foo.value = 2;
  bar.value = false;
}, 1000)

可以看到,通过上面的方式,使用一个对象将原始值进行包裹,就可以实现对原始值的代理。但是这样会产生一些问题。比如用户在使用 Vue 代理原始值的时候,每次都需要创建一个新的对象来进行包裹,这与直接创建一个对象没有什么区别。而我们希望能够从框架层面给出一套解决方案,所以 Ref 就呼之欲出了。

简单实现 ref


在前面我们介绍了代理原始值的思路——只能通过对象进行包裹。在 Vue 中,我们是通过 ref 来实现的,比如在日常的开发中,我们通常这样去代理原始值:

const foo = ref(false);

foo.value = true;

在使用 Vue 进行开发的时候,我们经常会写出如上述例子所示的代码,通过 ref 我们就可以将原始值转换为响应式数据了。ref 的实现原理其实就是我们上面提到的通过对象进行包裹,只不过 ref 是框架层面提供的,这样我们就避免了用户随意定义对象包裹原始值而出现一些不规范的现象。下面是对 ref 的简单实现:

const ref = (value: any) => {
  const wrapper = { value };
  return reactive(wrapper);
}

上面就是对 ref 的实现,可以看到非常简单。我们先通过一个对象 wrapper 将原始值包裹,然后返回一个响应式对象,接下来我们通过一个例子来验证一下:

// 通过 ref 代理原始值
const foo = ref(false);

effect(() => {
  // 在副作用函数中打印其值
  console.log('foo--->', foo.value); // foo--->false
});

setTimeout(() => {
  foo.value = true; // foo---> true
}, 1000);

在上面的代码中,我们通过 ref 代理了原始值,然后在副作用函数中打印了这个值。当修改该值的时候能够触发副作用函数的执行。但是上面实现的 ref 还是有些问题,因为我们没办法区分某个值到底是通过 ref 包裹的,还是用户自己定义的对象,所以我们需要给 ref 返回的对象增加一个标识:

const ref = (value: any) => {
  const wrapper = { value };
  // 添加一个不可枚举且不可修改的属性,用来标识该对象是通过 ref 包裹的
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  });
  return reactive(wrapper);
}

在上面的代码中,我们给包裹对象 wrapper 增加了一个不可枚举且不可修改的值 __v_isRef 以此来标识某个对象是否是通过 ref 生成的。

toRef 和 toRefs


在日常开发中,为了方便使用对象中的数据,我们经常会通过解构的方式来获取对象中的数据:

// 定义响应式数据
const data = reactive({ foo: 1, bar: 2 });

// 解构响应式对象
const { foo } = data;

effect(() => {
  console.log('foo----->', foo);
});

setTimeout(() => {
  data.foo = 2;
}, 1000);

在上面的代码中,我们首先定义了一个响应式数据 data,然后通过解构拿到其中的 foo。接着在副作用函数中打印 foo。当我们修改 data.foo = 2 发现并不会触发副作用函数的执行。这个其实也比较好理解,因为解构出来的值是原始值,这样她与被解构的对象就没有什么联系了,所以并不会触发响应。为了让解构出来的值依然能与被解构对象产生联系,在 Vue 中,我们通常通过 toRef 或者 toRefs 来实现:

// 定义响应式数据
const data = reactive({ foo: 1, bar: 2 });

// 解构响应式对象
const { foo } = toRefs(data);

// 另一种方式
const bar = toRef(data, 'bar');

effect(() => {
  console.log('foo----->', foo);
});

effect(() => {
  console.log('bar----->', bar);
});

setTimeout(() => {
  data.foo = 2;
  data.bar = 3;
}, 1000);

在上面的例子中,我们通过 toRefs 或者 toRef 对解构的值进行了封装,这样当响应式数据再次被改变的时候,就能触发副作用函数的执行了。toRef 的作用就是将响应式对象中的某个属性转换为 ref,并且能够保持该 ref 与响应式对象的联系。那我们来看看 toRef 的实现:

const toRef = (target: any, key: string) => {
  const wrapper = {
    // getter
    get value() {
      return target[key];
    },
    // setter
    set value(val: any) {
      target[key] = val;
    }
  }
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  });
  return wrapper;
}

上面就是 toRef 的实现,可以看到我们首先定义了一个包裹对象 wrapper,该对象内有一个 getter 和一个 setter,通过这种方式,我们就在响应式对象与她的属性之间建立了联系。有了 toRef,toRefs 的实现就很简单了:

const toRefs = (target: any) => {
  const refs = {};
  for(const key in target) {
    refs[key] = toRef(target, key);
  }
  return refs;
}

自动脱落 ref


先来看一个例子:

<template>
  <div>{{ foo }}</div>
</template>

<script lang="ts" setup>
// 定义一个 ref
const foo = ref(1);
</script>

上面是一个在 Vue 中使用 ref 例子,可以看到,在模板中,我们并不需要通过 foo.value 来访问 ref 的值,而是可以直接使用 foo。这样的好处就是用户在模板中可以非常方便地使用原始值,降低了用户的心智负担。那么我们要怎样才能做到自动脱落 ref 呢?

能够想到的思路就是,当用户在模板中访问的值为 ref 的时候,直接返回 ref 的 value 就可以了。要实现这样的功能就可以用我们前面文章中介绍过的 Proxy:

const proxyRefs = (target: any) => {
  return new Proxy(target, {
    get(target: any, key: string | symbol, receiver: any) {
      const value = Reflect.get(target, key, receiver);
      // 判断是否是ref,如果是则脱落ref
      return value.__v_isRef ? value.value : value;
    },
    set(target: any, key: string | symbol, newValue: any, receiver: any) {
      const value = target[key];
      // 如果是 ref,则修改 ref 的 value
      if (value.__v_isRef) {
        value.value = newValue;
        return true
      }
      return Reflect.set(target, key, newValue, receiver);
    }
  })
}

上面就是脱落 ref 的方法,我们通过 proxyRefs 返回一个代理对象,在该代理对象中,我们拦截了其 set 和 get 操作。当某个值是 ref 的时候,我们会直接操作其 value 值,这样就不需要用户手动去操作 value 了。在 Vue 中,setup 方法返回的数据就是通过 proxyRefs 进行处理的。

最后


本篇我们介绍了如何去代理原始值,在 Vue 中,我们是通过 ref 来实现的。ref 的原理其实就是使用一个包裹对象来将原始值进行包裹。那么至此,我们的响应式系统已经能够处理引用值和原始值了。本篇的代码请戳。