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

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

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

下面,我们来讲解下 Vue3 中原始值的响应式原理:

ref 的实现

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

ref 函数

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

ref源码

js 复制代码
// 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 函数

createRef源码

js 复制代码
// 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 类

RefImpl源码

js 复制代码
// 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,如下面的代码所示:

isRef

js 复制代码
// 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 除了能够用于将原始值 转换成响应式数据 之外,还能用来解决响应丢失的问题。

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

js 复制代码
// obj 是响应式数据
const obj = reactive({ 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

toRef源码

js 复制代码
// 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

ObjectRefImpl源码

js 复制代码
// 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 函数,来批量地完成转换。如下面的代码所示:

toRefs源码

js 复制代码
// 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 中先判断对象是否为响应式对象 ,如果是普通对象在开发环境会有警告,然后定义了一个普通对象 ret,然后使用一个 for...in 循环来遍历响应式对象 objectkey,调用 toRef 逐个将该 key 对应的 value 转换成 ref,并存储到普通对象 ret 中。

自动脱 ref

toRefs 带来的问题

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

js 复制代码
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 的能力。如下代码所示:

shallowUnwrapHandlers源码

js 复制代码
// 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 函数进行处理:

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

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

handleSetupResult源码

js 复制代码
// 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 除了能够用于将原始值转换成响应式数据之外,还能用来解决响应丢失 的问题。为此,Vue3 源码中定义了 toReftoRefs 两个函数来解决这个问题。它们本质上是对响应式数据做了一层包装,即将响应式数据的第一层属性值转换为 ref

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

解读下源码是不是觉得清晰多了?

相关推荐
m0_748256144 分钟前
前端 MYTED单篇TED词汇学习功能优化
前端·学习
小白学前端6661 小时前
React Router 深入指南:从入门到进阶
前端·react.js·react
web130933203982 小时前
前端下载后端文件流,文件可以下载,但是打不开,显示“文件已损坏”的问题分析与解决方案
前端
outstanding木槿2 小时前
react+antd的Table组件编辑单元格
前端·javascript·react.js·前端框架
好名字08212 小时前
前端取Content-Disposition中的filename字段与解码(vue)
前端·javascript·vue.js·前端框架
隐形喷火龙3 小时前
element ui--下拉根据拼音首字母过滤
前端·vue.js·ui
m0_748241123 小时前
Selenium之Web元素定位
前端·selenium·测试工具
风无雨3 小时前
react杂乱笔记(一)
前端·笔记·react.js
前端小魔女3 小时前
2024-我赚到自媒体第一桶金
前端·rust
鑫~阳3 小时前
快速建站(网站如何在自己的电脑里跑起来) 详细步骤 一
前端·内容管理系统cms