关于 Vue 中 customRef 的一些奇思妙想

响应式数据更新带来的副作用是立即执行的,可以考虑更换调度策略或使用队列来延缓副作用的执行(比如组件的异步渲染)。但想要让响应式数据的自身的更新延迟执行或条件执行,则需要自己定义,Vue 中提供了 customRef

具有防抖的 ref

比如实现值更新后,延迟 2 秒再发出通知更新页面,并且合并重复通知。这样可以避免页面被频繁更新。

function debouncedRef<T>(value: T, timeout = 0): Ref<T> {
  let timeoutId: number | null = null;
  return customRef((track, trigger) => ({
    get() {
      // 添加到依赖追踪
      track();
      return value;
    },
    set(newValue) {
      value = newValue;
      if (timeoutId) {
        clearTimeout(timeoutId);
      }
      // 通过设置超时为 0 可以触发异步的调度
      // 默认 `watchEffect` 的回调是同步触发的
      // setTimeout(trigger);
      // 通过 setTimeout 设定时间则是延迟更新
      timeoutId = setTimeout(trigger, timeout);
    },
  }));
}

const msg = debouncedRef('debouncedRef', 2_000);
<button @click="msg += '$'">{{ msg }}</button>

单个订阅者

只有首个数据使用者才会收到更新事件(推模型),虽然后续使用者可以访问到最新数据,但并不知道何时数据更新了(拉模型)。依赖追踪是可选的,因此可以选择在特定的上下文中关闭依赖追踪,非常灵活。

function firstRef<T>(value: T): Ref<T> {
  let tracked = false;
  return customRef((track, trigger) => ({
    get() {
      if (!tracked) {
        tracked = true;
        track();
      }
      return value;
    },
    set(newValue) {
      value = newValue;
      trigger();
    },
  }));
}

const msg = firstRef('first');

watchEffect(() => {
  console.log(msg.value);
});
<button @click="msg = 'second'">{{ msg }}</button>

这样页面的内容就不会更新了。但是如果不写 watchEffect,在模板中多次使用 msg,那么他们还是会一起更新:

<button @click="msg = 'second'">{{ msg }}</button>
<pre>{{ msg }}</pre>
<pre>{{ msg }}</pre>

因为组件渲染是一个整体,只要有一个订阅更新,页面的其他部分都会被动地拿到最新值。

实现类似 switchMap 的效果

再比如实现 switchMap 或者说在 onCleanup 中使回调失效的效果:

<script setup lang="ts">
import { customRef, type Ref } from 'vue';

const entries = ['React', 'Vue', 'Angular', 'Svelte', 'Solid', 'Lit'];

async function fetchSuggestions(keyword = ''): Promise<string[]> {
  keyword = keyword.toLowerCase();
  await new Promise(resolve => setTimeout(resolve, 1_000));
  return entries.filter(e => e.toLowerCase().includes(keyword));
}

function switchAsyncRef<T>(task: Promise<T>): Ref<T | null> {
  let id = 0;
  let value: T | null = null;
  const myRef = customRef((track, trigger) => ({
    get() {
      track();
      return value;
    },
    async set(newValue) {
      const currentId = ++id;
      const resolvedValue = await newValue;
      if (currentId !== id) return;
      value = resolvedValue;
      trigger();
    },
  }));
  myRef.value = task as unknown as typeof myRef.value;
  return myRef;
}

const suggestions = switchAsyncRef(fetchSuggestions());
</script>

<template>
  <input
    type="text"
    @input="
      (suggestions as unknown as Promise<typeof suggestions>) =
        fetchSuggestions(($event.target as HTMLInputElement).value)
    "
  />
  <ol>
    <li v-for="suggestion in suggestions">{{ suggestion }}</li>
  </ol>
</template>

上面的代码用了两次类型断言,这是因为 Vue 的类型系统没有考虑到 Ref#valuegetset 函数类型可以不一致,源码。即便补上相关定义,终端中不会报错,但 VS Code 中的类型提示依旧有问题,可能还需要更新 Volar 的部分代码。