我是前端下饭菜,两娃的爸创业中。公众号“绘个球”(各种号全网同名)实时分享创业动态,提供军事、地理、地产短视频工具。
使用Vue3开发项目时,一个vue文件不下20个ref、computed定义,看着都头痛。有没有比较好的解决方案,以及vue设计ref、computed、reactive的初衷是什么?源码+实践能告诉答案!
理解定义的初衷
Vue官方文档介绍响应式对象定义时,其顺序为ref、computed、reactive、readonly,和项目中开发使用频次差不多一致。
ref
先抛一个问题:如果给ref b传入的参数a本身也是一个ref类型的值,是通过b.value还是b.value.value来获取值?
const a = ref(1);
const b= ref(a);
// console.log(b.value);
// console.log(a.value);
ref定义:
function ref<T>(value: T): Ref<UnwrapRef<T>> interface Ref<T> { value: T }
传入的value可以是简单值或者复杂对象, 返回类型为Ref<UnwrapRef<T>>
,它确定返回的结果为{ value: x }
格式对象。
export type UnwrapRef<T> =
T extends ShallowRef<infer V>
? V
: T extends Ref<infer V>
? UnwrapRefSimple<V>
: UnwrapRefSimple<T>
通过上述代码分析,ref函数需要对原始值为ShalowRef、Ref、普通对象分别处理。
function createRef(rawValue: unknown, shallow: boolean) {
if (isRef(rawValue)) {
return rawValue
}
return new RefImpl(rawValue, shallow)
}
如果rawValue为Ref类型,则直接返回rawValue即可,不做任何处理。因此,下述代码中a === b
。
const a = ref({ x: 1, y: 2 });
const b= ref(a);
rawValue为非Ref类型,则返回RefImpl的实例化对象。其构造函数如下:
constructor(
value: T,
public readonly __v_isShallow: boolean,
) {
this._rawValue = __v_isShallow ? value : toRaw(value)
this._value = __v_isShallow ? value : toReactive(value)
}
当设置shadow为true时,构造函数中__v_isShallow为true,表示浅度,因此data.a.b = 2
不会触发监听。__v_isShallow为false,则将value转换为reactive类型,可监听所有属性。
const data = createRef({ a: { b: 1 } }, true) // __v_isShallow设置为true
watch(data.a.b, (val) => { }); // 不会被触发
data.value.a.b += 1;
computed
接受一个 getter
函数,返回一个只读的响应式 ref
对象。该 ref 通过 .value
暴露 getter 函数的返回值。
computed的优点,可动态返回一个响应式对象,并且不用显式声明依赖的可监听对象。其定义为:
// 只读
function computed<T>(
getter: (oldValue: T | undefined) => T,
// 查看下方的 "计算属性调试" 链接
debuggerOptions?: DebuggerOptions
): Readonly<Ref<Readonly<T>>>
// 可写的
function computed<T>(
options: {
get: (oldValue: T | undefined) => T
set: (value: T) => void
},
debuggerOptions?: DebuggerOptions
): Ref<T>
挺好奇computed的定义,除了只读的computed,还定义有可读可写的computed,什么场景下需要使用可读可写方法? 看源代码示例:
// Creating a writable computed ref:
const count = ref(1)
const plusOne = computed({
get: () => count.value + 1,
set: (val) => {
count.value = val - 1
}
})
以上示例,plusOne和我直接使用count变量的成本有什么区别?我认为vue2中的computed方法定义更符合字面意思。
另外一点,读方法getter:(oldValue: T | undefined => T)
传参为什么有oldValue?在项目中几乎直接是computed(() => { ... })
,查看Vue3源代码也没看到内部有使用传参的形式。
reactive
reactive方法返回一个对象的深层次响应式代理。定义:
function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
传入的参数限定必须为object类型,除了Array、Map等原生集合类型,reactive会解包传入的参数,例如如果传入的为ref类型,则会将其解包后在绑定。下面的示例中,直接使用obj.count获取值即可。
const count = ref(1)
const obj = reactive({ count }) // ref 会被解包
console.log(obj.count === count.value) // true
如果传参为ref类型,并且其中包含Array等集合对象,那这些集合本身不会被解包,因此返回集合项需要添加.value
。
const books = reactive([ref('Vue 3 Guide')]) // 这里需要 .value
console.log(books[0].value)
疑难问题
ref在script、template使用方式不一致
<script setup lang="ts">
const user = ref({ name: '樊振东', nick: '前端下饭菜' });
const title = computed(() => `大家好,我是${user.value.name}, 笔名:${user.value.nick}`)
</script>
<template>
<div class="head">
<span>{{ `大家好,我是${user.name}, 笔名:${user.nick}` }}</span>
</div>
</template>
<style lang="less" scoped></style>
在script需要通过.value
读取值,假如user是个对象,那么在script会看到大量的user.value.name
代码。
在template中可直接使用user.name
或user.nick
方式读取值,更加简短。
查看编译后的代码片段,template中的span节点转换如下:
_createElementVNode(
"span",
null, _toDisplayString(`\u5927\u5BB6\u597D\uFF0C\u6211\u662F${$setup.user.name}, \u7B14\u540D:${$setup.user.nick}`),
1
/* TEXT */
)
vue声明了$setup上下文,并将$setup中声明的所有变量都挂载到$setup上,因此有$etup.user属性。
为什么$setup上可以直接通过$setup.user.name
访问属性,而不是$setup.user.value.name
?
$setup是一个reactive对象,其源码如下,当读取user
属性,unref(...)
会判断其值是否为Ref类型,是则自动返回解包后的值。
const shallowUnwrapHandlers: ProxyHandler<any> = {
get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)),
set: (target, key, value, receiver) => {
const oldValue = target[key]
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
} else {
return Reflect.set(target, key, value, receiver)
}
},
}
$setup也支持了写操作,如下代码所示,可以直接对user重新赋值,当$setup执行setter方法,如果user为ref类型,则通过oldValue.value = value
重新赋值,等价于user.value = {...}
。
<el-button @click="(e) => {
user = { name: '马龙', nick: '世界第二' };
}">设置User</el-button>
watch监听Ref类型值的策略
const refData = ref({ name: '李磊', detail: { phone: 100000, address: '北京' } });
watch(refData, (newValue) => {
message.value = newValue.detail.phone + '';
});
watch对ref监听存在行为上的不一致,第一个参数不管refData或者refData.value,回调函数的newValue都为refData.value
值。
但两者的监听策略却存在差异:
watch(refData, (value) => {...})
,通过refData.value赋值能触发回调,但深层属性(如redData.value.detail.phone = 100002
)被修改时,不会触发回调函数;watch(refData.value, (value) => {...})
,深层属性修改((如redData.value.detail.phone = 100002
))能触发回调,但refData.value赋值却不触发;
为什么会存在监听策略?我们从watch函数源码入手:
const reactiveGetter = (source: object) =>
deep === true
? source // traverse will happen in wrapped getter below
: // for deep: false, only traverse root-level properties
traverse(source, deep === false ? 1 : undefined)
let getter: () => any
if (isRef(source)) {
getter = () => source.value
} else if (isReactive(source)) {
getter = () => reactiveGetter(source)
}
当source为Ref类型时,getter取值直接为source.value
,这也回答了为什么监听函数的newValue值不需要通过.value
访问。
如果source为refData.value,由于.value值自动封装为reactive类型,满足isReactive函数条件,getter赋值为reactiveGetter
函数。需要特别注意的是deep其实有true、false、undefined三个值。
reactiveGetter
会执行traverse(source, undefined)
,其traverse
函数签名:traverse(value, depth = Inifinity, ...)
,作用是遍历depth深度内的所有属性。
也就是说当source为reactive类型,watch会自动监听所有属性的变化,也就回答了问题:watch(refData.value, (value) => {...})能监听所有属性的变化。
何时触发getter函数?watch函数会声明一个副作用RectiveEffect类型实体,其目的是将effect作为getter值的依赖项,当值更新时,内部触发scheduler调度器,最后触发(newvalue) => {...}
回调函数。
const effect = new ReactiveEffect(getter, NOOP, scheduler)
effect.run()
watch深度监听ref值
想要深度监听ref值,大家都懂,在第三个参数参数deep:true
即可。
watch(
refData.value,
(newValue) => {
message.value = newValue.detail.phone + '';
},
{ deep: true }
);
其原理也比较简单,当deep为true时,将refData.value
值传递给traverse
深度遍历一次即可。
if (cb && deep) {
const baseGetter = getter // () => redData.value
getter = () => traverse(baseGetter())
}
ref、reactive该如何选择
reactive更偏底层,当传入ref函数的参数为对象类型时,ref.value值本身就为reactive形式,所以我认为使用reactive的地方都可以使用ref代替。并且ref能更加限制性能问题爆发,默认不支持深度遍历,需要时再添加{ deep: true }
。
另外,ref可以支持简单值类型,如const visible = ref(false)
;当通过refData.value = {...}
重新赋值时,也能被watch监听,这种场景reactive是不能被监听到的。
什么时候使用computed
computed(getter)最大的特点是,如果getter函数中有使用其他ref、reactive、computed变量,当这些变量值发生变化,computed会自动监听,并得到最新的结果。
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
debugOptions?: DebuggerOptions,
isSSR = false,
) {
const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR)
return cRef as any
}
computed函数返回ComputedRefImpl类型实体,通过__v_isRef
将自身标记为ref类型。因此,可以理解它就是一个Ref类型,都是通过.value
形式访问。
export class ComputedRefImpl<T> {
public readonly __v_isRef = true
}
为什么computed函数体中的ref、reactive等变量更新时,能自动更新?
和watch函数类型,ComputedRefImpl内部也会声明一个副作用对象,当调用getter时,函数中的ref、reactive等变量会注入当前副作用effect,所以只要这些值有更新,effect将被触发。
public readonly __v_isRef = true
constructor(private getter: ComputedGetter<T>, ...) {
this.effect = new ReactiveEffect(
() => getter(this._value),
() => ...
)
}
总之,可以理解为computed(getter)返回的结果就是Ref类型,并且通过watch(cptValue, cb)监听时,会自动为getter中依赖的ref、reactive、computed变量注入副作用。
const name = ref('李磊')
const detail = reactive({ phone: 10000 })
const cptValue = computed(() => {
return `${name}: ${detail.phone}`
})
watch(cptValue, (newValue) => { console.log(newValue) })
如上述代码,当修改name或者detail时,watch函数回调会被触发。
释放watch监听
function doWatch(...): WatchStopHandle {...}
watch函数返回WatchStopHandle类型,类似于() => void
,用于注销监听。
什么场景下使用注销监听?
如果一段逻辑在多个页面重复使用,考虑复用性,需要将其移到非页面的通用模块,watch在这种场景下使用,就得考虑监听的及时释放。
export function useSelectState(cb: (data) => void) {
...
const stopWatch = watch(
() => [store.select, store.overlap],
([select, overlap]) => {
...
cb?.({ select, overlap })
}
)
return {
stopWatch,
}
}
上述useSelectState
函数在多个页面使用,当页面销毁时,得调用stopWatch及时释放监听。
总结
在开发Vue组件时,随着功能的复杂度增加,定义的ref、computed数量也会井喷式暴增,到最后可能有不下20个ref或者computed,如下所示:
const title = ref('')
const id = ref(10)
const visible = ref(false)
const closed = ref(false)
const rendered = ref(false)
const loading = ref(false)
const message = ref('')
...
为了简化组件的复杂度,单独定义视图逻辑模块,例如实现dialog组件时,同时定义dialog.vue、dialog.ts模块,并将ref、computed声明、逻辑添加到dialog.ts中:
export const useDialog = (...) => {
const title = ref('')
const id = ref(10)
const visible = ref(false)
const closed = ref(false)
const rendered = ref(false)
const loading = ref(false)
const message = ref('')
...
return {
title,
id,
visible,
message
}
}
而在vue文件中直接引用,并仅负责页面显示。
const {
title,
id,
visible,
message
} = useDialog(...)
像element-plus等UI库也是采用这种模式开发组件,其思路和React的useState、useEffect等hook机制大同小异。
最后:ref、computed、reactive该如何取舍?通用的vue UI库,一般使用ref、computed的居多,ref适合简单值的定义,而computed更适合包含计算逻辑的定义。reactive属于偏底层函数,因此在各个组件中使用的确实偏少。
我是
前端下饭菜
,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!