Vivia Preview

Theme Toggle

Vue源码中的Reflect以及Proxy

关于为什么Vue在响应式中要使用Reflect操作对象以及Proxy

lastModified: 2024-08-14

缘由

众所周知的, 在vue3中, 响应式的方式由Proxy实现的. 相比于vue2defineProperty, Proxy为响应式提供了更好的性能和灵活性.

proxy-example

typescript

const $app = document.querySelector('#app') as HTMLDivElement;
const $value = document.querySelector('#value') as HTMLDivElement;
const $input = document.querySelector('#input') as HTMLInputElement;

const obj = {
  value: '',
  oldValue: '',
};

const write = () => {
  $value.innerHTML = `oldValue: ${obj.oldValue} <br/> value: ${obj.value}`;
};

// Proxy的基本示例
const proxyObj = new Proxy(obj, {
  get(target, key) {
    return target[key];
  },
  set(target, key, value) {
    const oldValue = target[key];
    target[key] = value;

    obj.oldValue = oldValue;
    obj.value = value;

    write();
    return true;
  },
});

$input.addEventListener('input', function () {
  proxyObj.value = this.value;
});
defineProperty-example

typescript

const $app = document.querySelector('#app') as HTMLDivElement;
const $value = document.querySelector('#value') as HTMLDivElement;
const $input = document.querySelector('#input') as HTMLInputElement;

const obj = {
  value: '',
  oldValue: '',
};

const write = () => {
  $value.innerHTML = `oldValue: ${obj.oldValue} <br/> value: ${obj.value}`;
};

const defineProperty = () => {
  let value = obj.value;
  Object.defineProperty(obj, 'value', {
    get() {
      return value;
    },
    set(newValue) {
      obj.oldValue = obj.value;
      value = newValue;
      write();
    },
  });
};

defineProperty();
$input.addEventListener('input', function () {
  obj.value = this.value;
});

可以看到相比Proxy, defineProperty的实现要更加麻烦, 需要遍历一个对象的所有属性, 如果对象比较大就会消耗性能, 同时也无法对对象新增和删除的属性做出响应. 而Proxy则作为浏览器提供的一个新特性, 能够对对象的所有操作做出响应, 并且基于此特性, vue3不再需要遍历对象的所有属性, 能够大大提高性能.

但是在源码中有这么一段代码:

src/reactive/baseHandlers.ts

typescript

class BaseReactiveHandler implements ProxyHandler<Target> {
  constructor(
    protected readonly _isReadonly = false,
    protected readonly _isShallow = false,
  ) {}

  get(target: Target, key: string | symbol, receiver: object) {
    ....
    const res = Reflect.get(target, key, receiver);
    ...

    return res;
  }
}

这个Reflect为什么会在这里使用呢?

Reflect和Proxy

Reflect是ES6中新增的一个对象, 它提供了一些操作对象的方法, 比如Reflect.get, Reflect.set, Reflect.deleteProperty等.

typescript


const obj = { a: 1 };

Reflect.get(obj, 'a') === obj.a;
Reflect.set(obj, 'a', 2); // obj.a = 2;
Reflect.deleteProperty(obj, 'a') === true; // delete obj.a;

以上的操作方式时相等的, 那么为什么不直接使用obj.a呢? 正常情况下, obj.aReflect.get(obj, 'a')确实是相等的, 但是当obj是一个Proxy对象时, 情况就有所不同了.

Proxy的一些问题

javascript

const $app = document.querySelector('#app');

const base = {
  __name: 'base',
  get name() {
    return this.__name;
  },
};

const baseProxy = new Proxy(base, {
  get(target, key) {
    return target[key];
  },
});

const child = {
  __proto__: baseProxy,
  __name: 'child',
};

$app.innerHTML = child.name;

可以看到, 理论上应该调用的this应该是child, 但是实际上输出的是base的值. 这是因为Proxy代理了baseget操作, 所以调用时的targetbase而不是child.

ECMA 规范的定义

根据ecma-get的规定, 在访问对象的时候, 会调用对象的[[GET]]方法获取值. 这个方法不会暴露给用户, 也不会显示在对象下. 同时, 对于Proxy创建的特殊对象, ecma-proxy-get也规定了单独的[[GET]]方式.

一般对象的GET调用步骤

先看一般对象调用时, [GET]的规定:

GET的规范

  • 先在自身查找是否有对应的属性, 如果没有则递归调用父级的[[GET]]方法, 直到找到对应的属性描述符.
  • 判断属性描述符结果是不是数据描述符, 即描述符不为空, 描述符包含[[VALUE]]也就是值字段或者[[Writable]]也就是可以写入的话, 则返回true否则false.
  • 如果是数据描述符, 则返回对应的[[VALUE]]字段
  • 如果是操作描述符, 则查找描述符的[[GET]]方法, 如果没有则返回undefiend有则调用并将Receiver对象作为this传入.
  • 重复以上步骤, 直到找到对应的值或者parent父级为空.

方法中, Receiver对象是调用该方法的对象, O是对象本身, P是查找的属性.

Proxy对象的GET调用步骤

Proxy对象中的[[ProxyTarget]][[ProxyHandler]]是在创建Proxy对象时设置的内部属性. 对应传入的原始对象和处理函数对象.

createProxy规范

然后再看Proxy[[GET]]就要简单一点:

ProxyGet规范

可以看到在Proxy代理对象的[[GET]]上, 会先获取[[ProxyHandler]]中的get方法, 如果没有获取到则直接调用且返回原始对象的[[GET]]. 如果获取到了则会调用该方法, 并且将target P Receiver 作为参数传进去, 也就是Proxyget方法接收的几个参数:

  1. target: 被代理的原始对象
  2. P: 属性名
  3. Receiver: 调用该方法的对象

之后则是判断这个属性是否可以被访问可以被写入之类, 如果不可以则抛出异常, 否则返回属性的值, 也就是被代理对象的对应的值.

到这里也就知道了为什么target[key]会返回base而不是child了, 因为Proxyget方法中, target被代理的对象是base, 而Receiverchild, 所以返回的就是base的值. 同时, 如果直接调用receiver[key]则会造成对调用对象的递归调用(不断循环调用同一是代理对象的属性), 导致调用栈溢出.

Reflect.get的调用

相比于上面的Proxy对象的调用, ecma-reflectionReflect.get的调用就更加简单的多.

reflect.get规范

就只是简单的调用一下, 因为无法直接操作对象的[[GET]]方法传递receiver, 所以需要通过Reflect来调用.

上面已经看到了, 对于普通对象, 如果是自身属性则返回, 否则查找父级属性返回. 如果是操作符取值则将receiver作为this[[GET]]进行调用. 对于代理对象, 如果有则经过get方法后返回, 否则调用源对象的[[GET]]方法返回.

总结

通过以上的分析, 可以看到ProxyReflect.get在实现上其实非常相似, 都是通过调用对象的[[GET]]方法获取值, 只是Proxy在获取值时, 会优先调用自定义的get方法. 同时也知道为什么vue3要在Proxy中使用Reflect进行操作, 是为了避免一些情况下的代理对象取值错误, 比如操作符取值时(目前也不知道是否有其他的可能)的this能够准确指向执行对象.

由于Proxytarget是被代理的对象, 所以会出现使用操作符this指向错误的问题, 所以使用Reflect来进行操作.

不过在实际业务中, 使用get set 之类的操作符还是非常少见的. 在vue3的源码中使用倒是用了不少, 比如ref.

reactivity/src/ref.ts

typescript

/**
 * @internal
 */
class RefImpl<T = any> {
  _value: T;
  private _rawValue: T;

  dep: Dep = new Dep();

  public readonly [ReactiveFlags.IS_REF] = true;
  public readonly [ReactiveFlags.IS_SHALLOW]: boolean = false;

  constructor(value: T, isShallow: boolean) {
    this._rawValue = isShallow ? value : toRaw(value);
    this._value = isShallow ? value : toReactive(value);
    this[ReactiveFlags.IS_SHALLOW] = isShallow;
  }

  get value() {
    if (__DEV__) {
      this.dep.track({
        target: this,
        type: TrackOpTypes.GET,
        key: 'value',
      });
    } else {
      this.dep.track();
    }
    return this._value;
  }

  set value(newValue) {
    const oldValue = this._rawValue;
    const useDirectValue = this[ReactiveFlags.IS_SHALLOW] || isShallow(newValue) || isReadonly(newValue);
    newValue = useDirectValue ? newValue : toRaw(newValue);
    if (hasChanged(newValue, oldValue)) {
      this._rawValue = newValue;
      this._value = useDirectValue ? newValue : toReactive(newValue);
      if (__DEV__) {
        this.dep.trigger({
          target: this,
          type: TriggerOpTypes.SET,
          key: 'value',
          newValue,
          oldValue,
        });
      } else {
        this.dep.trigger();
      }
    }
  }
}

而且类的继承也不会出现这种问题, 可以算是在业务中基本遇不到的问题了.

© 9999 Vivia Name

Powered by Nextjs & Theme Vivia

主题完全模仿 Vivia 主题, 有些许差异, 及使用nextjs乱写

Theme Toggle