likes
comments
collection
share

写Vue大篇幅的ref、computed,而reactive为何少见?使用Vue3开发项目时,一个vue文件不下20个r

作者站长头像
站长
· 阅读数 19

使用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.nameuser.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属于偏底层函数,因此在各个组件中使用的确实偏少。

我是前端下饭菜,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!

转载自:https://juejin.cn/post/7400264313125584896
评论
请登录