likes
comments
collection
share

把window传入Vue的reactive竟是这样的效果

作者站长头像
站长
· 阅读数 20

早年刚开始接触 Vue3 的 reactive 时候,一直以为它只能监控到最顶层的属性变化,而对深层次的数据无能为力,比如:

const person = reactive({
    user: {
        name: 'Mike',
    },
});

person.user.name = 'Kate';

直到我看到了 shallowReactive。我当时就很好奇,我经常会有这样的数据:

reactive({
    dialogDOM: document.createElement('dialog'),
});

一个 DOM 对象的属性是很复杂的,比如它的 ownerDocument 指向 document,而 document 的 defaultView 又指向 window。难道 reactive 也会 window 做了一层 Proxy 吗?

当然,我们知道无论是 ownerDocument 还是 defaultView 都并不在对象本身上,而是在原型链上,Proxy 对此没什么影响。但是仍然激起了我的好奇心——当给 reactive 传入 window 等特殊对象会发生什么呢?

为此我特意去看了一眼 Vue 中 reactive 的源码,才发现了好多“秘密”。

reactive 的源码定义

reactive 的源码在 packages/reactivity/src/reactive.ts 中,TypeScript 声明为:

function reactive<T extends object>(target: T): UnwrapNestedRefs<T>;

看样子参数只要是对象就行,继续往下挖,来到 createReactiveObject 中, 它的逻辑特别清晰:

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  // 1️⃣
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  // 2️⃣
  // target is already a Proxy, return it.
  // exception: calling readonly() on a reactive object
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
  // 3️⃣
  // target already has corresponding Proxy
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  // 4️⃣
  // only specific value types can be observed.
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target
  }
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

在创建 Proxy 对象之前,有 4 个条件分支进行了拦截,这就是 reactive 不支持或不需重复处理的四种情况,我标记了序号标签,我们一一解释一下。

1️⃣ isObject 返回 false 则不予处理,直接返回。那么这个函数是怎么定义的呢?看源码:

const isObject = (val: unknown): val is Record<any, any> => val !== null && typeof val === 'object'

很简单,在 typeof 下返回 "object",并且排除掉 null 即可,这是最清晰、最简单的对象类型定义。也就是说,像 string、number、bigint、boolean、symbol、function、undefined 是不被 reactive 支持的。我想大家都非常容易理解了,这些字面量都是直接使用,基本不会再对其任何属性有赋值操作。

但是也能推测出,像下面这些值,是可以被支持的,因为它们在 typeof 下都是 "object"

new String();
new Number();
new Boolean();
Object(BigInt(45))
Object(Symbol(45))

2️⃣ 这里判断传入参数是否已经是一个 Vue 生成的 Proxy 了,那么为什么不直接判断它是不是一个 Proxy 呢?

其实判断一个对象是不是 Proxy 还是比较困难的,如果你用 instanceof,那么会受到 Proxy 的 getPrototypeOf 的影响,况且 Proxy.prototype === undefined,也无法应用 instanceof;如果你用 .constructor,那么也会受到其 get 返回值的影响;即便用 Object.prototype.call 也会返回 "[object Object]",可以说黔驴技穷。

所以,Vue 在生成的 Proxy 上施加了一些标记物,这就是上述代码中的 ReactiveFlags 枚举。

这些 ReactiveFlags 并非是 Proxy 对象的属性,只不过当你在 Proxy 上访问这些属性时,是有可能有返回值的,取决于不同条件下创建的不同的 Proxy。比如 ReactiveFlags.RAW 返回了被 Proxy 封装的原始对象,可以认为 Vue 的 toRaw API 就是访问的这个属性;ReactiveFlags.IS_READONLY 则是作用于被 readonly 封装的 Proxy 上,而 ReactiveFlags.IS_REACTIVE 则与之相反,等等。

再来看 2️⃣ 的条件部分,前半段 target[ReactiveFlags.RAW] 相当于确认就是 Vue 创建的 Proxy,后半段 !(isReadonly && target[ReactiveFlags.IS_REACTIVE]) 对于 reactive 来说就是 true,因为 createReactiveObjectisReadonly 就是 false。

从这一点上来说,你自己创建的 Proxy 还是可以传入 reactive 的:

reactive(new Proxy({ name: 'foo' }, {}));

3️⃣ 这部分很容易理解,对于同一个对象,reactive 封装的对象是复用的:

var target = {};

reactive(target) === reactive(target) // true

来到 4️⃣,这是最关键的部分,它用 getTargetType 探测了传入参数的类型:

function targetTypeMap(rawType: string) {
  switch (rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION
    default:
      return TargetType.INVALID
  }
}

function getTargetType(value: Target) {
  return value[ReactiveFlags.SKIP] || !Object.isExtensible(value)
    ? TargetType.INVALID
    : targetTypeMap(toRawType(value))
}

首先带 ReactiveFlags.SKIP 的不支持,什么样的数据有这个属性呢?Vue 组件有这个属性,另外就是用 markRaw 包装的数据,举个例子,如果数据的某个部分你不想让它变成可响应的:

const person = reactive({
    name: 'Mike',
    edu: markRaw({
        year: 1967
    }),
});

这样的话,修改 person.edu.year 就不会触发视图更新。

另外就是不可扩展的对象不被 reactive 支持,这里算比较严格了,因为不可扩展的对象,其属性照样可以变更。

接下来看到 targetTypeMap 我们能明白 reactive 支持什么样的数据了,虽然名义上是任何对象,但是其实主要面向对象、数组、Map(WeakMap) 和 Set(WeakSet)。

这个类型认定是通过 toRawType,也就是 Object.prototype.toString 得到的。

现在即便是在校大学生都知道用 Object.prototype.toString 判断类型最一劳永逸,一句话搞定。当然这要看需求场景,毕竟它分得太细,将几乎所有内置类型都归为了 Object 之外,比如:

Object.prototype.toString.call(new Date()) // [object Date]
Object.prototype.toString.call(new Error()) // [object Error]
Object.prototype.toString.call(new Map()) // [object Map]
Object.prototype.toString.call(window) // [object Window]
Object.prototype.toString.call(navigator) // [object Navigator]
Object.prototype.toString.call(history) // [object History]

如果对 JavaScript 更了解的人应该知道,这种行为其实是各个类型的自定义行为,只不过 Object.prototype.toString 引用了它。具体来说就是:

var X = {
    [Symbol.toStringTag]: 'X'
};

Object.prototype.toString.call(X) // [object X]

这是 Symbol.toStringTag 的作用,也就是说 Date、Array、Error、Map、Set、Window、Navigator、History 都有自己的 [Symbol.toStringTag] 定义,因此也就都不被 reactive 支持,这就解释了为什么 window 在 reactive 下是安全的

个人觉得这样做多少有一些风险,毕竟需要仰赖各个对象自己去实现 [Symbol.toStringTag]万一将来某个浏览器内置对象没有声明此属性,那么在被 reactive 处理后将成为响应式的,有可能有问题。

Hack reactive

看透了 reactive 的原理之后,我们就能做更多的事,我举几个例子。

另一种避免被响应式的办法

如果你的数据是一个类实例,比如:

class Student {
    public name: string;
    public age: number;
    constructor (name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

如果禁止响应,可以用 markRaw

const stu = Student('Mike', 16);

reactive({
    student: markRaw(stu),
})

但这有副作用,会在对象上定义一个额外的 ReactiveFlags.SKIP 属性,污染了原始对象。

我们还有另一种办法,就是改变对象在 Object.prototype.call 下的输出,即定义 [Symbol.toStringTag]

class Student {
    public name: string;
    public age: number;
    constructor (name: string, age: number) {
        this.name = name;
        this.age = age;
    }
    
    get [Symbol.toStringTag]() {
        return 'Student';
    }
}

只要不返回 Object、Array、Map 等那几种就行了。

其实 [Symbol.toStringTag] 也是一种标记,本质和 ReactiveFlags.SKIP 无异。

响应式的错误

正常情况下,错误对象(Error)是不可响应的,但是我们可以魔改:

const rawError = new Error('y');

Reflect.defineProperty(rawError, Symbol.toStringTag, {
    value: 'Object',
    configurable: true,
});

const error = reactive(rawError);

现在,对 error.message 赋值就可以驱动视图更新了。

响应式的 localStorage

正常情况下,localStorage 是不可响应的,但是我们仍然可以魔改:

Reflect.defineProperty(localStorage, Symbol.toStringTag, {
    value: 'Object',
    configurable: true,
});

const ls = reactive(localStorage);

现在,对 localStorage 任何 key 赋值就可以驱动视图更新了。


以上只是列举了几个 Hack Code,并没有在所有浏览器下测试,而且更多内置对象即便这样修改仍然不能在 reactive 下工作,这取决于目标对象的具体属性配置,以及相关函数的上下文要求。

总结

经过对 reactive 源码的分析,我们知道事实上它只对 Object.prototype.toString 返回值为 [object Object/Array/Map/WeakMap/Set/WeakSet] 的数据有效,特殊对象如 window、document、history 等等因为不满足此条件,因而避免了被改造为响应式。

由于 createReactiveObject 也被 shallowReactivereadonlyshallowReadonly 使用,因此它们也满足上述规则条件。

不可扩展(non-extensible)对象以及经过特殊标记的对象,也不会被改造。