Vue3 - ref 原始值的响应式方案

313 阅读4分钟

本文为《Vue.js设计与实现》的笔记。

1. 原始值

原始值是指 Boolean Number BigInt String Symbol undefined null 等类型的值。

2. ref

ES6中的Proxy仅可代理非原始值,而对原始值的操作无法拦截。

问题解决思路:使用一个非原始值去“包裹”原始值,再将包裹对象变成响应式数据。

const wrapper = {
    value: 'vue'
};
const name = reactive(wrapper);

其中reactive为将非原始值变成响应式对象的方法,内部使用Proxy实现。

上述代码由两个问题:

  • 不友好:用户为了创建一个响应式的原始值,不得不创建一个包裹对象
  • 不规范:随意命名,可能不使用value,使用val等

为解决以上问题,提供一个封装函数,统一行为:

function ref(val){
    // 创建包裹对象
    const wrapper = {
        value: val
    }
    // 将包裹对象变成响应式数据
    return reactive(wrapper);
}

具体使用:

const refVal = ref(1);
effect(() => console.log(refVal.value));

refVal.value = 2;

3. 区分ref与reactive

在模版中ref的数据,我们需要自动进行脱ref的操作,这就涉及到如何区分一个数据是不是ref,而上面ref的实现无法做到这样的区分。

修改ref:

function ref(val) {
  // 创建包裹对象
  const wrapper = {
    value: val,
  };
  // 在wrapper上定义一个不可枚举的属性
  Object.defineProperty(wrapper, "__v_isRef", {
    value: true,
  });
  // 将包裹对象变成响应式数据
  return reactive(wrapper);
}

上面的代码使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__v_isRef,其值为true。可通过这一属性来判断一个数据是否为ref。

4. 用于解决响应丢失问题

先看看什么是响应丢失问题:

export default {
    setup(){
        const state = reactive({foo: 1, bar: 2})
        return { ...state }
    }
}

此处使用了展开运算符将响应式对象暴露到外部,使数据丢失了响应式

return { ...state }
// 等价于
return {foo: 1, bar: 2}

其实就是返回了一个普通对象,不具有响应式能力。

前面讲了我们会在模版中访问ref时会进行自动脱ref功能,换句话说,此处return的数据的键对应的值为一个ref是可以接受的(提供ref作为可使用数据是可接受的)

即state中为如此形式:

export default {
    setup(){
        const state = reactive({foo: ref(1), bar: ref(2)})
        return { ...state }
    }
}

这样相当于:

return { ...state }
// 等价于
return {foo: ref(1), bar: ref(2)}

这样展开运算符便不会使数据丢失响应式。但这样的形式十分繁琐,为非原始值每一个值套一个ref,是的state在setup中的使用也不方便。不修改原有reactive的数据,而是间接地获取数据:

function toRef(obj, key) {
  const wrapper = {
    get value() {
      return obj[key];
    },
    set value(val) {
      obj[key] = val;
    },
  };
  // 在wrapper上定义一个不可枚举的属性
  Object.defineProperty(wrapper, "__v_isRef", {
    value: true,
  });
  return wrapper;
}

定义了一个toRef函数,入参obj为一个响应式数据,key为该对象的一个键。该函数内部使用一个wrapper对象,使用一个访问器属性value,当读取value的值时,最终读取的是objkey的值,设置value的值时,最终设置的是响应式数据的同名属性的值。

toRef只能对单个key进行处理,我们定义一个toRefs来对一个响应式对象批量地进行转换:

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

对本小节响应丢失的情况,使用toRefs即可解决:

export default {
    setup(){
        const state = reactive({foo: 1, bar: 2})
        return { ...toRefs(state) }
    }
}

4. 自动脱ref

4.1 toRefs带来的问题

toRefs函数解决了响应式丢失的问题,但也带来了新的问题,那就是它会把响应式数据的第一层属性值转换为ref,意味着必须要通过value属性去访问值,这增加了用户的心智负担,因此,需要提供自动脱ref的能力。

4.2 自动脱ref实现

// 自动脱ref
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[key]访问真实值
      const value = target[key];
      if (value.__v_isRef) {
        value.value = newValue;
        return true;
      }
      return Reflect.set(target, key, newValue, receiver);
    },
  });
}

proxyRefstarget创建一个代理对象,主要根据__v_isRef来判断当前数据是否为ref,在进行读取操作时,若取得的值为ref,则自动返回其value的值,在进行设置操作时,若键对应的原值为ref,则设置其value的值。

实际上,Vue组件中的setup函数所返回的数据会传递给proxyRefs进行处理

const setupResult = setup(shallowReadonly(instance.props), {
      emit: instance.emit,
    });
instance.setupState = proxyRefs(setupResult);