实现mini-vue -- reactivity模块(四)ref

807 阅读11分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

本篇文章中主要是实现refref除了能够将对象变成响应式之外,还能将基础数据类型也变成响应式的,核心原理就是通过维护一个对象,该对象只有一个value属性,然后给该对象设置proxy代理

并且还实现了proxyRefs,它能够帮助我们“脱掉”refvalue限制,让我们可以直接访问响应式数据,而不需要通过访问value

1. 实现 ref

ref可以用于基础数据类型的响应式,也可以用于对象的响应式,由于基础数据类型无法设置proxy,因此ref是通过将要变为响应式的数据通过包装在一个对象里,作为这个对象的value属性去返回的,然后为这个对象设置proxy来实现响应式的效果

1.1 单元测试

首先编写一下单元测试

// src/reactivity/tests/ref.spec.ts
describe('ref', () => {
  it('should hold a value', () => {
    const a = ref(1);
    expect(a.value).toBe(1);
    a.value = 2;
    expect(a.value).toBe(2);
  });

  it('should be reactive', () => {
    const a = ref(1);
    let dummy;
    let calls = 0;
    effect(() => {
      calls++;
      dummy = a.value;
    });
    expect(calls).toBe(1);
    expect(dummy).toBe(1);
    a.value = 2;
    expect(calls).toBe(2);
    expect(dummy).toBe(2);
    // same value should not trigger
    a.value = 2;
    expect(calls).toBe(2);
    expect(dummy).toBe(2);
  });

  it('should make nested properties reactive', () => {
    const a = ref({
      count: 1,
    });
    let dummy;
    effect(() => {
      dummy = a.value.count;
    });
    expect(dummy).toBe(1);
    a.value.count = 2;
    expect(dummy).toBe(2);
  });
});

这三个单元测试是直接从vue3源码中拿来的:

  1. 第一个单元测试描述了被ref处理过的数据应当放到value
  2. 第二个单元测试描述了ref处理过的数据是响应式的,哪怕是基本数据类型也是响应式的
  3. 第三个单元测试描述了ref能够将对象变为响应式的

1.2 ref 返回包裹了 value 的对象

首先来实现第一个最基本的功能,为了只运行第一个单元测试,排除另外两个暂未实现的单元测试的干扰,我们给第一个单元测试加上only修饰,it.only() 然后给ref创建单独的文件去实现,因为它也算是一个单独的模块了

// src/reactivity/ref.ts
export function ref(value) {
  return {
    value,
  };
}

这样实现确实能够通过第一个单元测试,但是考虑到还要为value属性做代理拦截,并且还要给每个ref对象维护它的依赖,因此我们可以考虑用类来实现,并在类中为value属性设置getset

class RefImpl {
  private _value: any;

  constructor(value) {
    this._value = value;
  }

  get value() {
    return this._value;
  }

  set value(newVal) {
    this._value = newVal;
  }
}

export function ref(value) {
  return new RefImpl(value);
}

现在第一个单元测试就顺利通过了,接下来开始实现第二个单元测试的功能


1.3 依赖收集

相较于reactiveref的依赖收集要显得简单得多,因为它只用维护一个value属性,也就是说对应的依赖只会是value的,因此不需要走reactive那样的targetMap -> depsMap -> deps过程,直接维护一个dep属性即可

由于依赖收集的逻辑和之前的effect.ts中的track函数中找到了dep后是一样的,因此可以复用那一部分的逻辑,首先重构一下track函数

export function track(target, key) {
  // 不是被 track 的状态则不需要进行依赖收集
  if (!isTracking()) return;
  // target -> key -> deps
  let depMaps = targetMap.get(target); // key -> deps 的映射
  if (!depMaps) {
    // 不存在时需要初始化
    depMaps = new Map();
    targetMap.set(target, depMaps);
  }

  let dep = depMaps.get(key);
  if (!dep) {
    dep = new Set(); // dep 存放 target.key 的所有依赖函数
    depMaps.set(key, dep);
  }

-  // 依赖收集 -- 将当前激活的 fn 加入到 dep 中
-  if (dep.has(activeEffect)) return; // 已经在 dep 中则无需再 add
-  dep.add(activeEffect);
-  // 反向收集 effect 给 dep
-  activeEffect.deps.push(dep);

+  trackEffects(dep);
}

+ /**
+  * @description   依赖收集 -- 将当前激活的 fn 加入到 dep 中
+  * @param dep 依赖集合 Set 对象
+  */
+  export function trackEffects(dep) {
+   if (dep.has(activeEffect)) return; // 已经在 dep 中则无需再 add
+   dep.add(activeEffect);
+   // 反向收集 effect 给 dep
+   activeEffect.deps.push(dep);
+ }

重构完成之后要立马检查一下之前已通过的单元测试是否受到影响,确保没影响后才能继续 现在抽离了依赖收集的逻辑之后,就可以在ref.ts中复用了

+ import { trackEffects } from './effect';

class RefImpl {
  private _value: any;
+ public dep;

  constructor(value) {
    this._value = value;
+   this.dep = new Set();
  }

  get value() {
+   trackEffects(this.dep);

    return this._value;
  }

  set value(newVal) {
    this._value = newVal;
  }
}

1.4 触发依赖

触发依赖的逻辑同样是可以复用的,我们也将它抽离出来

// src/reactivity/effect.ts

export function trigger(target, key) {
  // 根据 target 拿到 targetMap 对应的 depMaps 再根据 key 拿到 dep Set 后遍历执行依赖函数
  const depMaps = targetMap.get(target);
  const dep = depMaps.get(key);

-  for (const effect of dep) {
-    if (effect.scheduler) {
-      effect.scheduler();
-    } else {
-      effect.run();
-    }
-  }
+  triggerEffects(dep);
}

+ export function triggerEffects(dep) {
+   for (const effect of dep) {
+     if (effect.scheduler) {
+       effect.scheduler();
+     } else {
+       effect.run();
+     }
+   }
+ }

然后在ref.ts中复用

class RefImpl {
  private _value: any;
  public dep;

  constructor(value) {
    this._value = value;
    this.dep = new Set();
  }

  get value() {
    trackEffects(this.dep);

    return this._value;
  }

  set value(newVal) {
    this._value = newVal;

+   triggerEffects(this.dep);
  }
}

1.5 运行单元测试

现在就算是完成了,让我们跑一下单元测试看看效果 image.png 可恶!居然出错了,这个报错的问题是不是和之前扩展stop功能的时候遇到的一模一样,因为在没有用effect函数包裹的副作用函数的情况下如果触发了get拦截,就会去触发依赖收集的逻辑,而activeEffectundefined,但是依赖收集中又会用到activeEffect.deps,从而报错了

既然知道了问题所在就好办了,我们只要在调用trackEffects之前检测一下activeEffect是不是undefined就好了,诶,但是activeEffect是在effect.ts中维护的一个全局变量,ref.ts中访问不到呀,但是别忘了,前面已经抽离了一个isTracking函数,正是用于检测是否应当进行依赖收集的,我们只要调用它就可以了!

class RefImpl {
  private _value: any;
  public dep;

  constructor(value) {
    this._value = value;
    this.dep = new Set();
  }

  get value() {
+   if (isTracking()) {
+     trackEffects(this.dep);
+   }

    return this._value;
  }

  set value(newVal) {
    this._value = newVal;

    triggerEffects(this.dep);
  }
}

现在单元测试还有一个报错: image.png 我们希望赋值同一个值的时候不要触发副作用函数,这个逻辑肯定是在set中去处理的,由于我们还没有修改set的逻辑,所以无法通过也是很自然的


1.6 相同值不触发依赖

只需要在set修改值之前判断一下新的值和旧的值是否相同即可,如果是相同的值则直接return,不修改值也不触发依赖

set value(newVal) {
+  // same value should not trigger
+  if (Object.is(newVal, this._value)) return;

  this._value = newVal;
  triggerEffects(this.dep);
}

现在测试就通过了 image.png 然后就是立马重构一下,这里的Object.is是偏底层一点的方法,为了提高可读性,我们需要将它封装到一个函数中,既然这行代码的意思是判断新值相较于旧值来说有没有改变,那么我们可以封装一个名为hasChanged的函数,由于调用的是偏底层的方法,且逻辑是通用的,所以应当放在shared模块中

// src/shared/index.ts
export const hasChanged = (value, oldValue) => !Object.is(value, oldValue);

取反是因为当新旧两个值不一样时才是改变了的,为了符合函数名的语义,所以要取反 然后重构一下set拦截

set value(newVal) {
  // same value should not trigger
-  if (Object.is(newVal, this._value)) return;
-  this._value = newVal;
-  triggerEffects(this.dep);
+  if (hasChanged(newVal, this._value)) {
+    this._value = newVal;
+    triggerEffects(this.dep);
+  }
}

还有一个地方可以重构,我们可以将收集ref.value依赖的逻辑抽离出来,封装到trackRefValue的函数中

+ function trackRefValue(ref) {
+   if (isTracking()) {
+     trackEffects(ref.dep);
+   }
+ }

class RefImpl {
  private _value: any;
  public dep;

  constructor(value) {
    this._value = value;
    this.dep = new Set();
  }

  get value() {
-   if (isTracking()) {
-     trackEffects(ref.dep);
-   }
+   trackRefValue(this);
    return this._value;
  }

  set value(newVal) {
    // same value should not trigger
    if (hasChanged(newVal, this._value)) {
      this._value = newVal;
      triggerEffects(this.dep);
    }
  }
}

1.7 ref 包裹对象

现在我们看看第三个单元测试,它描述的意思是ref能够将对象也变成响应式的,那么我们先来跑一下这个单元测试看看我们目前的实现能不能将对象变成响应式的呢 image.png 看来并不能,那么我们开始思考一下如何实现,对于传入的value是对象类型的时候,我们应该将其变为reactive的对象,这样它就是响应式的了

class RefImpl {
  private _value: any;
  public dep;

  constructor(value) {
-   this._value = value;
+   // 对象类型需要转成 reactive 对象
+   this._value = isObject(value) ? reactive(value) : value;
    this.dep = new Set();
  }

  get value() {
    trackRefValue(this);
    return this._value;
  }

  set value(newVal) {
    // same value should not trigger
    if (hasChanged(newVal, this._value)) {
      this._value = newVal;
      triggerEffects(this.dep);
    }
  }
}

还有一点需要注意,在set的时候,对比的是newValthis._value,但是this._value已经被变成reactive对象了,是一个Proxy对象,因此即便是同一个对象,判断的结果也是false,所以我们还需要维护一个原始对象,对比的时候比较的是新对象和未被代理的对象

class RefImpl {
  private _value: any;
+ private _rawValue: any;
  public dep;

  constructor(value) {
    // 对象类型需要转成 reactive 对象
+   this._rawValue = value;
    this._value = isObject(value) ? reactive(value) : value;
    this.dep = new Set();
  }

  get value() {
    trackRefValue(this);
    return this._value;
  }

  set value(newVal) {
    // same value should not trigger
-   if (hasChanged(newVal, this._value)) {
+   if (hasChanged(newVal, this._rawValue)) {
-     this._value = newVal;
+     this._rawValue = newVal;
+     this._value = isObject(newVal) ? reactive(newVal) : newVal;
      triggerEffects(this.dep);
    }
  }
}

现在单元测试能够通过了,那么我们立马来重构一下,很明显有一个可以优化的地方,this._value赋值这一逻辑在构造函数和set value拦截中有大量重复,因此可以抽离出来,封装到一个名为convert的函数当中,意思是将_value转换成reactive对象(如果新值是对象的话)

+ /**
+  * @description 新值是对象类型的时候转成 reactive 对象
+  * @param value 新值
+  */
+ function convert(value) {
+   return isObject(value) ? reactive(value) : value;
+ }

class RefImpl {
  private _value: any;
  private _rawValue: any;
  public dep;

  constructor(value) {
    // 对象类型需要转成 reactive 对象
    this._rawValue = value;
-   this._value = isObject(value) ? reactive(value) : value;
+   this._value = convert(value);
    this.dep = new Set();
  }

  get value() {
    trackRefValue(this);
    return this._value;
  }

  set value(newVal) {
    // same value should not trigger
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
-     this._value = isObject(newVal) ? reactive(newVal) : newVal;
+     this._value = convert(newVal);
      triggerEffects(this.dep);
    }
  }
}

2. 实现 isRef 和 unRef

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

2.1 isRef

可以给RefImpl维护一个标志属性,用于标识是否是ref对象,只要该属性存在就说明的ref对象,若为undefined则说明不是ref对象 首先编写单元测试

// src/reactivity/tests/ref.spec.ts
it('isRef', () => {
  const a = ref(1);
  const foo = reactive({ bar: 1 });
  expect(isRef(a)).toBe(true);
  expect(isRef(1)).toBe(false);
  expect(isRef(foo)).toBe(false);
});

RefImpl类添加一个标志属性

class RefImpl {
  private _value: any;
  private _rawValue: any;
  public dep;
+ public __v_isRef = true;

  constructor(value) {
    // 对象类型需要转成 reactive 对象
    this._rawValue = value;
    this._value = convert(value);
    this.dep = new Set();
  }

  get value() {
    trackRefValue(this);
    return this._value;
  }

  set value(newVal) {
    // same value should not trigger
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
      this._value = convert(newVal);
      triggerEffects(this.dep);
    }
  }
}

然后添加isRef函数

export function isRef(r) {
  return !!(r && r.__v_isRef === true);
}

2.2 unRef

从官方文档的介绍中就已经看出unRef的实现了,核心部分就是一个三目运算符,判断一下传入的值是否是ref对象,是的话就返回它的value,否则就返回传入的值本身 单元测试

it('unref', () => {
  expect(unref(1)).toBe(1);
  expect(unref(ref(1))).toBe(1);
});

实现

export function unref(r) {
  return isRef(r) ? r.value : r;
}

3. 实现 proxyRefs

proxyRefs这一apivue3官方文档中并没有看到,但实际上它十分有用,用过vue3ref的读者应该都有一个痛点,就是每次取值都要先调用value,觉得十分麻烦,如果可以直接调用就好了 其次,不知道大家有没有好奇过,在setup中返回了一个ref对象,但是在template中居然可以不通过value,直接访问到里面的属性,这就是proxyRefs这个api在发挥作用了

首先编写单元测试,通过单元测试可以更好地了解到它的功能

it('proxyRefs', () => {
  const foo = {
    name: 'foo',
    age: ref(20),
  };
  const proxyFoo = proxyRefs(foo);
  expect(foo.age.value).toBe(20);
  expect(proxyFoo.name).toBe('foo');
  expect(proxyFoo.age).toBe(20);

  proxyFoo.age = 21;
  expect(proxyFoo.age).toBe(21);
  expect(foo.age.value).toBe(21);

  proxyFoo.age = ref(22);
  expect(proxyFoo.age).toBe(22);
  expect(foo.age.value).toBe(22);
});
  1. 对于ref属性,可以不通过value,直接获取到值,比如proxyFoo.age === 20
  2. 修改ref属性时,不需要修改value,直接修改属性即可,比如proxyFoo.age = 21
  3. 修改ref属性时,无论修改传入的新值是普通数据还是ref数据,都可以正常修改

对于第一点,我们可以通过前面实现的unref实现,给传入的对象设置代理,拦截get操作,用unref处理后返回即可

// src/reactivity/ref.ts
export function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, {
    get(target, key) {
      // 无论属性是否是 ref 对象 只要调用 unref 就可以保证返回的是用户想要的值
      return unref(Reflect.get(target, key));
    },
  });
}

对于第二第三点,就需要拦截set操作来实现了,set的时候,如果原始值是ref类型,而新值是普通值,就需要通过oldVal.value = newVal来修改,而其他情况则直接使用Reflect.set即可

export function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, {
    get(target, key) {
      // 无论属性是否是 ref 对象 只要调用 unref 就可以保证返回的是用户想要的值
      return unref(Reflect.get(target, key));
    },
    set(target, key, newVal) {
      const oldVal = target[key];
      if (isRef(oldVal) && !isRef(newVal)) {
        oldVal.value = newVal;
        return true;
      } else {
        return Reflect.set(target, key, newVal);
      }
    },
  });
}