Vue3 源码解读之原始值的响应式原理

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第5天,点击查看活动详情 >>

版本:3.2.31

原始值指的是 Boolean、Number、BigInt、String、Symbol、undefined 和 null 等类型的值。在 JavaScript 中,原始值是按值传递的。ES6 的 Proxy 无法提供对原始值的代理,因此想要将原始值变成响应式数据,需要对其进行一层包裹。

ref 的实现

ref 本质上是一个 “包裹对象”,因为 ES6 的 Proxy 无法提供对原始值的代理,所以需要使用一层对象作为包裹,间接实现原始值的响应式方案。

ref 函数

封装一个ref函数,将包裹对象的创建工作封装到该函数中,如下面的代码所示:

// packages/reactivity/src/ref.ts

export function ref<T extends object>(
  value: T
): [T] extends [Ref] ? T : Ref<UnwrapRef<T>>
export function ref<T>(value: T): Ref<UnwrapRef<T>>
export function ref<T = any>(): Ref<T | undefined>
export function ref(value?: unknown) {
  return createRef(value, false)
}
复制代码

由上面的代码可以看到,ref 函数接受一个可选的参数,这个参数就是要变成响应式数据的原始值。在 ref 函数中只是调用了 createRef 函数来创建 ref 对象,接下来看看 createRef 函数的实现。

createRef 函数

// packages/reactivity/src/ref.ts

function createRef(rawValue: unknown, shallow: boolean) {
  // 如果已经是 ref 对象,直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  // 创建一个 ref 对象实例
  return new RefImpl(rawValue, shallow)
}
复制代码

createRef 函数的第一个参数rawValue是要变成响应式数据的值,第二个参数shallow是一个布尔值,表示创建的ref对象是深响应的还是浅响应的。如果 rawValue 已经是一个 ref 对象,则直接将其返回。否则调用 RefImpl 类,创建一个 ref 对象实例。

RefImpl 类

// packages/reactivity/src/ref.ts

// ref 的实现类
class RefImpl<T> {
  private _value: T
  private _rawValue: T

  public dep?: Dep = undefined
  // ref实例下都有一个 __v_isRef 的只读属性,标识它是一个ref
  public readonly __v_isRef = true

  constructor(value: T, public readonly __v_isShallow: boolean) {
    // 如果是 浅层响应,则直接将 _rawValue 置为 value,否则通过代理对象的 raw 属性获取原始值
    this._rawValue = __v_isShallow ? value : toRaw(value)
    // 如果是 浅层响应,则直接将 _value 置为 value,否则将 value 转换成深响应
    this._value = __v_isShallow ? value : toReactive(value)
  }

  // 拦截 读取 操作
  get value() {
    // 通过 trackEffects 收集 value 依赖
    trackRefValue(this)
    // 返回 该ref 对应的 value 属性值,实现自动脱 ref 能力 
    return this._value
  }

  // 拦截 设置 操作
  set value(newVal) {
    newVal = this.__v_isShallow ? newVal : toRaw(newVal)
    // 比较新值和旧值
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      // 转换成响应式数据
      this._value = this.__v_isShallow ? newVal : toReactive(newVal)
      // 调用 triggerRefValue, 通过 triggerEffects 派发 value 更新
      triggerRefValue(this, newVal)
    }
  }
}
复制代码
  1. 在 RefImpl 类中,定义了两个私有变量,其中 _value 用来存储原始值转变成响应式的数据,_rawValue 则用过来存储原始值。
  2. 接着定义两个两个公有变量,dep 变量是一个 Set 集合,用来收集副作用函数。__v_isRef 变量是一个只读属性,用来标识一个对象是否是 ref 对象。
  3. 然后在constructor构造方法中分别初始化私有变量 _value 和 _rawValue。
  4. 接下来定义取值函数getter。在取值函数 getter 中,调用 trackRefValue 函数完成当前ref对象实例的依赖收集,并读取ref对象实例的私有属性 _value,将其作为 ref 对象对应的 value 属性值返回。
  5. 最后定义存值函数 setter。在 存值函数 setter 中,比较新值和旧值是否发生变化。如果发生了变化,则将新值转换成响应式数据存储到私有变量 _value 上,然后调用 triggerRefValue 函数触发副作用函数重新执行。

区分数据是否是 ref

在 RefImpl 类中,定义了一个 __v_isRef 的只读属性,它用来标识一个对象是否是 ref 对象。因此每个 ref 对象实例下都会有一个 __v_isRef 的属性。可以通过这个属性来区分数据是否是ref。源码中提供了一个 isRef 函数来判断数据是否是ref,如下面的代码所示:

// packages/reactivity/src/ref.ts

// 通过 __v_isRef 属性判断一个数据是否是 ref 对象
export function isRef<T>(r: Ref<T> | unknown): r is Ref<T>
export function isRef(r: any): r is Ref {
  return !!(r && r.__v_isRef === true)
}
复制代码

可以看到,在 isRef 函数中,就是通过 ref 对象实例的 __v_isRef 属性来判断数据是否是 ref。如果 __v_isRef 的值为 true,那么这个数据是一个 ref。

响应式丢失的问题

ref 除了能够用于将原始值转换成响应式数据之外,还能用来解决响应丢失的问题。

首先我们来看下面的一个例子:

// obj 是响应式数据
const obj = rective({ foo: 1, bar: 2 })

// 将响应式数据展开到一个新的对象 newObj 中
const newObj = {
  ...obj
}

effect(() => {
  // 在副作用函数内通过新的对象 newObj 读取 foo 属性值
  console.log(newObj.foo)
})

// 很显然,此时修改 obj.foo 并不会触发响应
obj.foo = 100
复制代码

如上面的代码所示,首先创建一个响应式的数据对象 obj ,然后使用展开运算符得到一个新的对象 newObj,它是一个普通对象,不具有响应能力。这里的关键点在于,副作用函数内访问的是普通对象 newObj,它没有任何响应能力,所以当我们尝试修改 obj.foo 的值时,不会触发副作用函数重新执行。

为了解决这个问题,源码中封装了一个 toRef 函数,将响应式数据的某个 property 创建一个新的 ref。

toRef

// packages/reactivity/src/ref.ts

export type ToRef<T> = IfAny<T, Ref<T>, [T] extends [Ref] ? T : Ref<T>>

export function toRef<T extends object, K extends keyof T>(
  object: T,
  key: K
): ToRef<T[K]>

export function toRef<T extends object, K extends keyof T>(
  object: T,
  key: K,
  defaultValue: T[K]
): ToRef<Exclude<T[K], undefined>>

//用来为源响应式对象上的某个 property 新创建一个 ref
// 第一个参数 obj 是一个响应式数据,第二个参数是 obj 对象的一个健
export function toRef<T extends object, K extends keyof T>(
  object: T,
  key: K,
  defaultValue?: T[K]
): ToRef<T[K]> {
  const val = object[key]
  // 返回 ref 对象
  return isRef(val)
    ? val
    : (new ObjectRefImpl(object, key, defaultValue) as any)
}
复制代码

toRef 函数的第一个参数是一个响应式数据对象,第二个参数是 object 对象的一个键。然后根据这个键来从源响应式对象上获取该键对应的键值,如果该键值已经是一个 ref 对象,则直接返回,否则调用 ObjectRefImpl 类新建一个 ref 对象并返回。

ObjectRefImpl

// packages/reactivity/src/ref.ts

// ObjectRefImpl 类用于为源响应式对象某个property 创建一个 ref
class ObjectRefImpl<T extends object, K extends keyof T> {
  public readonly __v_isRef = true

  constructor(
    private readonly _object: T,
    private readonly _key: K,
    private readonly _defaultValue?: T[K]
  ) {}

  // 取值函数
  get value() {
    const val = this._object[this._key]
    return val === undefined ? (this._defaultValue as T[K]) : val
  }

  // 存值函数
  set value(newVal) {
    this._object[this._key] = newVal
  }
}
复制代码

ObjectRefImpl 类用于为源响应式对象某个property 创建一个 ref。在该类中也是定义了一个 __v_isRef 的只读属性,用来标识一个对象的某个property 是否是 ref 对象。并分别定义了取值函数 getter 和存值函数 setter。

toRefs

响应式数据 object 的键可能会非常多,因此,源码中封装了 toRefs 函数,来批量地完成转换。如下面的代码所示:

// packages/reactivity/src/ref.ts

// 将响应式对象转换为普通对象,其中结果对象的每个 property 都是指向原始对象相应 property 的 ref
export function toRefs<T extends object>(object: T): ToRefs<T> {
  if (__DEV__ && !isProxy(object)) {
    console.warn(`toRefs() expects a reactive object but received a plain one.`)
  }
  const ret: any = isArray(object) ? new Array(object.length) : {}
  // 使用 for...in 循环遍历对象
  for (const key in object) {
    // 逐个调用完成转换
    ret[key] = toRef(object, key)
  }
  return ret
}
复制代码

可以看到,在 toRefs 中定义了一个普通对象,然后使用一个 for...in 循环来遍历响应式对象 object 的 key,调用 toRef 逐个将该 key 对应的 value 转换成 ref,并存储到普通对象 ret 中。

自动脱 ref

toRefs 带来的问题

toRefs 会把响应式数据的第一层属性值转换为 ref,因此如果想要访问属性值只能通过 value 属性来访问。如下面的代码所示:

const obj = reactive({ foo: 1, bar: 2 })
obj.foo // 1
obj.bar // 2

const newObj = { ...toRefs(obj) }
// 必须使用 value 访问值
newObj.foo.value // 1
newObj.bar.value // 2
复制代码

通常情况下,我们希望直接通过 newObj.foo 来访问属性值,而不是通过 newObj.foo.value 来访问属性值。因此,我们需要自动脱 ref 的能力。自动脱 ref ,指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref 对应的 value 属性值返回。

自动脱 ref 的实现

在 Vue.js 3 源码中,定义了一个 proxyRefs 函数来实现自动脱 ref 的能力。如下代码所示:

// core/packages/reactivity/src/ref.ts

// 实现自动脱 ref 的get/set 拦截函数
const shallowUnwrapHandlers: ProxyHandler<any> = {
  // 读取的值是 ref ,则调用 unref 函数返回它的value属性值
  get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)),
  set: (target, key, value, receiver) => {
    const oldValue = target[key]
    // 自动为 ref 设置值
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value
      return true
    } else {
      return Reflect.set(target, key, value, receiver)
    }
  }
}

// 实现自动脱 ref 
export function proxyRefs<T extends object>(
  objectWithRefs: T
): ShallowUnwrapRef<T> {
  return isReactive(objectWithRefs)
    ? objectWithRefs
    // 如果不是响应式对象,则创建一个代理对象,拦截get操作,
    // 通过 __v_isRef 属性判断读取的值是否是 ref ,从而返回它的value属性值。
    : new Proxy(objectWithRefs, shallowUnwrapHandlers)
}
复制代码

在 proxyRefs 函数中,通过使用 Proxy 创建一个代理对象,拦截get操作,如果读取的值是 ref ,则调用 unref 函数返回它的value属性值从而实现自动脱 ref 的能力。

既然读取属性的值有自动脱 ref 的能力,对应地,设置属性的值也应该有自动为 ref 设置值的能力。在 set 拦截函数中,如果旧值为 ref,并且新值不是 ref ,那么将新值设置为旧值的 value 属性,从而实现自动为 ref 设置值的能力。

Vue.js 组件的自动脱 ref

我们在编写 Vue.js 组件时,组件中的 setup 函数所返回的数据会传递给 proxyRefs 函数进行处理:

const MyComponent = {
  setup() {
    const count = ref(0)
    
    // 返回的这个对象会传递给 proxyRefs
    return { count }
  }
}
复制代码

源码中对于setup 函数所返回的数据的处理在 handleSetupResult 函数中,如下代码:

// core/packages/runtime-core/src/component.ts

export function handleSetupResult(
  instance: ComponentInternalInstance,
  setupResult: unknown,
  isSSR: boolean
) {
  if (isFunction(setupResult)) {
    
    // 省略部分代码
    
  } else if (isObject(setupResult)) {
    
    // 省略部分代码
    
    // 将 setup 函数所返回的数据传递给 proxyRefs 函数进行处理
    // 使组件数据自动脱 ref
    instance.setupState = proxyRefs(setupResult)
    
    // 省略部分代码
    
  }
  
  // 省略部分代码
  
  finishComponentSetup(instance, isSSR)
}
复制代码

这也就是为什么我们可以在模板中直接访问一个 ref 值,而无需通过 value 属性来访问。

总结

ref 本质上是一个 “包裹对象”,由于 Proxy 无法提供对原始值的代理,因此需要使用一层对象作为包裹,间接实现原始值的响应式方案。

为了区分 ref 对象与普通响应式对象,在 ref 对象中定义了值为 true 的 __v_isRef 属性,用它作为 ref 的标识。

ref 除了能够用于将原始值转换成响应式数据之外,还能用来解决响应丢失的问题。为此,Vue.js 3 源码中定义了 toRef 和 toRefs 两个函数来解决这个问题。它们本质上是对响应式数据做了一层包装,即将响应式数据的第一层属性值转换为 ref。

为了减轻用户的心智负担,Vue.js 3 源码中定义了proxyRefs 函数来实现自动脱ref的能力,对暴露到模板中的响应式数据进行脱 ref 处理。这样,用户在模板中使用响应式数据时,就无须关心一个值是不是 ref 了。

猜你喜欢

转载自juejin.im/post/7128165809490722823
今日推荐