Vue3 源码解读之原始值的响应式原理
原始值指的是 Boolean、Number、BigInt、String、Symbol、undefined
和 null
等类型的值。在 JavaScript
中,原始值是按值传递 的。ES6
的 Proxy
无法提供对原始值的代理,因此想要将原始值变成响应式数据,需要对其进行一层包裹。
下面,我们来讲解下 Vue3
中原始值的响应式原理:
ref 的实现
ref
本质上是一个 "包裹对象",因为 ES6
的 Proxy
无法提供对原始值的代理,所以需要使用一层对象作为包裹,间接实现原始值的响应式方案。
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 函数
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 类
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)
}
}
}
- 在
RefImpl
类中,定义了两个私有变量,其中_value
用来存储原始值转变成响应式的数据 ,_rawValue
则用过来存储原始值。 - 接着定义两个两个公有变量,
dep
变量是一个Set
集合,用来收集副作用函数。__v_isRef
变量是一个只读属性,用来标识一个对象是否是ref
对象。 - 然后在
constructor
构造方法中分别初始化私有变量_value
(响应式数据) 和_rawValue
(原始值)。 - 接下来定义取值函数
getter
。在取值函数getter
中,调用trackRefValue
函数完成当前ref
对象实例的依赖收集,并读取ref
对象实例的私有属性_value
,将其作为ref
对象对应的value
属性值返回。 - 最后定义存值函数
setter
。在存值函数setter
中,比较新值和旧值是否发生变化。如果发生了变化,则将新值转换成响应式数据存储到私有变量_value
上,然后调用triggerRefValue
函数触发副作用函数重新执行。
区分数据是否是 ref
在 RefImpl
类中,定义了一个 __v_isRef
的只读属性,它用来标识一个对象是否是 ref
对象。因此每个 ref
对象实例下都会有一个 __v_isRef
的属性。可以通过这个属性来区分数据是否是ref
。源码中提供了一个 isRef
函数来判断数据是否是ref
,如下面的代码所示:
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
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
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
函数,来批量地完成转换。如下面的代码所示:
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
循环来遍历响应式对象 object
的 key
,调用 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
的能力。如下代码所示:
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
函数中,如下代码:
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 源码中定义了 toRef
和 toRefs
两个函数来解决这个问题。它们本质上是对响应式数据做了一层包装,即将响应式数据的第一层属性值转换为 ref
。
为了减轻用户的心智负担,Vue3 源码中定义了 proxyRefs
函数来实现**自动脱ref
**的能力,对暴露到模板中的响应式数据进行脱 ref
处理。这样,用户在模板中使用响应式数据时,就无须关心一个值是不是 ref
了。
解读下源码是不是觉得清晰多了?