『手写Vue3』Ref & Computed

184 阅读5分钟

这一次来搞定Ref,然后是Computed,先思考几个问题:

  • 为什么Ref要用.value。
  • 在setup中使用ref需要.value,但是在template中不需要,是如何实现的。
  • computed的缓存机制是什么。

Ref

reactive通过proxy实现,依靠proxy的handlers来实现依赖收集和依赖触发,依赖收集到全局变量targetMap中。

对于ref,我们创建类RefImpl,impl是implement的缩写。类中保存该ref的value值,以及dep。通过get valueset value的方式实现依赖收集和依赖触发。

之前实现的track函数中,同时包含了“从targetMap中取出dep”和“将activeEffect加入dep”两部分逻辑。为了在ref这里复用track,需要进一步封装。

export function track(target, key) {
  if (!isTracking()) return;
  // target -> key -> dep
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }
  // 封装
  trackEffects(dep);
}

export function trackEffects(dep) {
  if (dep.has(activeEffect)) return;

  dep.add(activeEffect);
  activeEffect.deps.push(dep);
}

对于trigger,之前同时包含了“获取dep”和“遍历dep”两部分逻辑,也需要拆开。

export function trigger(target, key) {
  let depsMap = targetMap.get(target);
  let dep = depsMap.get(key);
  // 封装
  triggerEffects(dep);
}

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

这样就可以在RefImpl类中调用trackEffects和triggerEffects了。

还有其他的要点:

  • 一开始传入ref的参数是对象时,需要转换成reactive。
  • setter中如果新值等于旧值,即把value设置成和原先一样的值,无需修改值,也不会触发trigger。
  • 在hasChanged(对Object.is()的封装,返回值取反)中比较两个对象时,即使两个对象是同一个,proxy和原值也不会相等,所以需要保存_rawValue,通过原始值进行比较。
function trackRefValue(ref) {
  if (isTracking()) {
    trackEffects(ref.dep);
  }
}

// 如果value是对象,需要转换成reactive
function convert(value) {
  return isObject(value) ? reactive(value) : value;
}

class RefImpl {
  private _value: any;
  public dep;
  private _rawValue: any;
  public __v_isRef = true; // 用于isRef()
  constructor(value) {
    this._rawValue = value;
    this._value = convert(value);
    this.dep = new Set();
  }

  get value() {
    // 做了进一步封装
    trackRefValue(this);
    return this._value;
  }

  set value(newValue) {
    if (hasChanged(newValue, this._rawValue)) {
      this._rawValue = newValue;
      this._value = convert(newValue);
      triggerEffects(this.dep);
    }
  }
}

isRef、unRef

可以看到,RefImpl类中保存了属性__v_isRef,通过该属性就可以判断是不是ref。如果是reactive或原始值,该属性为undefined,通过!!转为false。

export function isRef(ref) {
  return !!ref.__v_isRef;
}

export function unRef(ref) {
  return isRef(ref) ? ref.value : ref;
}

proxyRefs

先看单元测试:

  it("proxyRefs", () => {
    const user = {
      age: ref(10),
      name: "xiaohong",
    };

    const proxyUser = proxyRefs(user);
    expect(user.age.value).toBe(10);
    // 无需使用.value
    expect(proxyUser.age).toBe(10);
    expect(proxyUser.name).toBe("xiaohong");
    
    proxyUser.age = 20;

    expect(proxyUser.age).toBe(20);
    // 修改proxy也改变原值
    expect(user.age.value).toBe(20);

    proxyUser.age = ref(10);
    expect(proxyUser.age).toBe(10);
    expect(user.age.value).toBe(10);
  });

可见,模板中省略掉的.value,靠的就是proxyRefs。setup()会返回一个对象obj,该对象里可能包含了一些ref,那么就和上面的user是同样的objectWithRefs结构,然后objProxy = proxyRefs(obj),以后objProxy里的ref不用.value就能访问到,再通过某些方式省略掉外层的对象,直接在模板中使用属性。

要实现proxyRefs,返回proxy是肯定的,然后就是提供handlers。因为无需.value也能访问属性,和访问原始对象是一样的,所以需要使用unRef。

在setter中有一种情况,就是某个属性以前是ref,新赋值为一个非ref,那么需要修改原来ref的value,而不是直接赋值。

export function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, {
    get(target, key) {
      return unRef(Reflect.get(target, key));
    },

    set(target, key, value) {
      if (isRef(target[key]) && !isRef(value)) {
        return (target[key].value = value);
      } else {
        return Reflect.set(target, key, value);
      }
    },
  });
}

Computed

观察以下测试代码,可以得到Computed的特点:

  • computed传入回调函数,回调函数不是在修改响应对象(reactive、ref)属性值的时候执行,而是在获取计算属性的.value时。
  • computed有缓存功能,由上一条知,计算属性执行传入回调的时机在getter中,但是也不是每一次都会重新计算,如果依赖的值没变,就用之前的缓存。
describe('computed', () => {
  it('happy path', () => {
    const user = reactive({
      age: 1
    });

    const age = computed(() => {
      return user.age;
    });

    // computed用.value,类似于ref
    expect(age.value).toBe(1);
  });

  it('should compute lazily', () => {
    const value = reactive({
      foo: 1
    });
    const getter = jest.fn(() => {
      return value.foo;
    });
    const cValue = computed(getter);

    // 只是向computed传入回调函数,并不会立刻执行它
    expect(getter).not.toHaveBeenCalled();

    expect(cValue.value).toBe(1);
    // 在获取计算属性的value时,执行回调
    expect(getter).toHaveBeenCalledTimes(1);

    // 再次获取计算属性的value,但是由于值未发生改变,并不会再次执行回调
    cValue.value;
    expect(getter).toHaveBeenCalledTimes(1);

    // 修改了原对象的属性,此时还是不立刻执行回调 
    value.foo = 2;
    expect(getter).toHaveBeenCalledTimes(1);

    expect(cValue.value).toBe(2);
    // 下一次获取计算属性的value时,才去执行回调
    expect(getter).toHaveBeenCalledTimes(2);

    cValue.value;
    expect(getter).toHaveBeenCalledTimes(2);
  });
});

computed有点类似于effect,但是不会立刻执行传入的回调函数。ComputedImpl类中要保存_effect,即ReactiveEffect的实例对象,把回调函数传入,之后通过this._effect.run()来执行回调。

此外,为了实现缓存,_value用于保存缓存值,_dirty用于判断computed依赖的值是否发生变化,如果变了,下次就要重新调用run方法获取新的值,并且保存到_value。

注意到回调函数中访问到了响应式对象的属性,通过this._effect.run()执行回调时,activeEffect也被设置成computed内部的_effect,然后它会被track。以后修改响应式对象的属性时,_effect会被trigger,computed值会立刻改变。

但是上面分析过,此时不应该立刻计算,而是等到下一次访问computed的.value时,在getter中计算。因此用到Scheduler。在Scheduler中修改_dirty,然后在getter中判断_dirty。Scheduler应用在这种不想在trigger时执行原先回调函数的场景。

class ComputedRefImpl {
  private _value: any;
  private _effect: any;
  private _dirty = true;

  constructor(getter) {
    // 当依赖的属性改变时,不执行getter,而是执行scheduler
    this._effect = new ReactiveEffect(getter, () => {
      this._dirty = true;
    });
  }

  get value() {
    // 下次get时再去计算目前的值,并保存到_value
    if (this._dirty) {
      this._dirty = false;
      this._value = this._effect.run();
    }
    return this._value;
  }
}

export function computed(getter) {
  return new ComputedRefImpl(getter);
}

思考:目前的缓存仅仅是“如果被依赖值没有被修改过,就不需要重新计算”。如果被依赖值修改为和原来相同的值,脏标记依然设置为true,下次还要重新计算。如何优化这个点?