关于 Vue 中响应式变量的类型

在前端框架 Vue 中,我们经常定义一个响应式变量,来实现当数据更新时自动更新 view 视图的效果。对于我们定义的“响应式”变量,实际上是通过 Object.defineProperty() 或者是 Proxy() 等数据劫持的方式监听了数据的变动,从而实现了响应式更新视图的效果。

简述 TR;DR

​ 在使用 Vue 中的响应式系统时,通过 reactive() 函数转换的响应式变量得到的 TypeScript 类型推断,可能与原始的 TypeScript 类型不相同。

背景

​ 对于不熟悉 Vue 框架的读者:在 Vue 项目的开发中,如果使用了 TypeScript,那么当我们定义响应式变量的时候,可以这样同时声明变量的初始值与类型:

const count = ref<number>(0)
count.value = '1' // error: Type 'string' is not assignable to type 'number'.

响应式变量的属性问题

​ 最近在开发一个使用 Vue + TypeScript 技术栈的项目,使用面向对象的开发方式的时候,遇到了一个奇怪的类型问题。如下是简化的代码:

class Car {
  public name: string
  private owner: string

  constructor(name: string, owner: string) {
    this.name = name
    this.owner = owner
  }
}

function getCarName(car: Car) {
  return car.name
}

const su7 = new Car('xiaomi_su7', 'Mr.Lei')
getCarName(su7)

​ 如上声明了一个 Car 的类,并实例化了 su7 这个对象,到目前为止,代码运行正常。

​ 但是当我们将 su7 这个变量变为响应式的时候,会发现响应式的变量不再能传入 getCarName 方法:

import { reactive } from 'vue'

// ...

const reactiveSU7 = reactive(su7)
getCarName(reactiveSU7)
//               ^^^^^^^^^^^
// Argument of type '{ name: string; }' is not assignable to parameter of type 'Car'.
//   Property 'owner' is missing in type '{ name: string; }' but required in type 'Car'.

​ 当我们将鼠标移动到分别移动到 su7reactiveSU7 上面的时候,会发现 TypeScript 对于这两个变量的类型提示并不相同。

the_type_of_reactive_variables_in_vue_1
the_type_of_reactive_variables_in_vue_2

​ 我们发现,su7 在变为响应式变量 reactiveSU7 的时候,私有属性 owner 消失了。当我们把 owner 的声明由 private 改为 public 时,变量的属性在 reactiveSU7 上便可以被访问到了。

​ 上述现象有些反直觉,我们不禁发问:owner 属性真的在转换为响应式变量的过程中丢失了吗?我们来打印一下:

the_type_of_reactive_variables_in_vue_10

​ 可以看到,两个变量都成功打印了。那么看来是 TypeScript 的变量类型推断错误。

​ 既然知道了是变量推断错误,那么解决上述问题的方式就非常简单了,我们直接对 reactiveSU7 进行断言:

const reactiveSU7 = reactive<Car>(su7) as Car
getCarName(reactiveSU7)

​ 类型检查通过!

​ 注意,为了类型安全,此时需要加上泛型保证传入 reactive 函数的参数类型。

reactive 的实现

​ 点击进入 reactive,我们会发现它会返回一个 Reactive 的泛型

the_type_of_reactive_variables_in_vue_3

​ 我们继续点击 Reactive ,查看定义:

the_type_of_reactive_variables_in_vue_4

​ 由于我们传入的不是数组,所以是对 UnwrapNestedRefs<T> 和任意非空元素 {} 取了并集,继续点击 UnwrapNestedRefs<T> 查看:

the_type_of_reactive_variables_in_vue_7

UnwrapNestedRefs<T> 判断了传入参数是否是一个 Ref (Vue 中针对原始类型实现响应式的方法),由于我们传入的是一个原始对象而不是 Ref,所以会走到 UnwrapRefSimple<T> 这里:

the_type_of_reactive_variables_in_vue_8

​ 可以看到 UnwrapRefSimple<T> 的定义很长,让人感到有些泄气,但是别担心,它们都是针对类型进行了一系列复杂的类型三目运算,它陆续判断了 Map<K, V>WeakMap<K, V>Set<V>WeakSet<V> 等,最后到了判断对象环节 T extends object 也就是我们上面 demo 所描述的 case:

the_type_of_reactive_variables_in_vue_5

​ 这里使用 [P in keyof T] 重新遍历提取了 T 中的属性,由于 TypeScript 的类型属性遍历是不遍历私有属性的,在这里我们上文举例的私有属性 owner 被过滤掉了,缺失的私有属性 owner 导致 reactiveSU7 的类型不等同于 Car 构造出的类型。

​ 为了证明这一点,我们将这个属性遍历删除,而是直接返回 T

the_type_of_reactive_variables_in_vue_6

岁月静好,无事发生

the_type_of_reactive_variables_in_vue_9

探索 reactive 遍历属性的原因

​ 众所周知,当我们发现源码有问题的时候,一定是我们自己的问题。Vue 的作者尤大在写代码的时候难道考虑不到这个么?

​ 让我们再仔细看下它在这个遍历里面到底做了些什么:

the_type_of_reactive_variables_in_vue_5

​ 它先判断了这个对象的 Key 是不是一个 symbol 类型,如果是 symbol 类型,就直接根据 Key 取值的类型,否则则会进入到 UnwrapRef<T[P]> 的逻辑中,我们再看 UnwrapRef<T[P]>

the_type_of_reactive_variables_in_vue_7

​ 由于转换成的响应式变量并不是原始值,因此里面也是根据三目运算符判断是否存在 Ref 嵌套,来分别解构,取出被 Ref 劫持属性的原始类型。

不相关的闲谈:

​ 有细心的读者可能会发现,在 Reactive 里面取并集的是 {},而 UnwrapNestedRefs<T> 中三目运算符判断的则是 T extends object

​ 在 TypeScript 中,{} 的定义比较宽松,表示任何非空(undefinednull),并允许基本类型如 numberstring 等:

let a1: {} = 42         // 合法
let b1: {} = 'hello'    // 合法
let c1: {} = []         // 合法
let d1: {} = {}         // 合法

object 表示严格对象类型,并且不允许基本类型。

let a2: object = 42                 // 非法
let b2: object = 'hello'            // 非法
let c2: object = []                 // 合法
let d2: object = {}                 // 合法
let e2: object = function () {}     // 合法

尾声

​ 综上,由于 Vue 在转换响应式变量时,需要做一些额外的类型处理,导致 TypeScript 的类型推断与使用类构造出的原始类型不相同。

For Further Reading:

对于响应式的转换,Vue 的文档关于 reactive() 的部分中也有相应的说明。

https://vuejs.org/api/reactivity-core.html#reactive

如果你对此足够感兴趣,也可以再看看:

https://github.com/vuejs/core/blob/main/packages/reactivity/src/reactive.ts

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部