Vue3 组合式 API 进阶:深入解析 customRef 的设计哲学与实战技巧

系列文章目录

Vue3 组合式 API 进阶:深入解析 customRef 的设计哲学与实战技巧
Vue3 watchEffect 进阶使用指南:这些特性你可能不知道
Vue3高级特性:深入理解effectScope及其应用场景



一、前言

在 Vue 3 的组合式 API 中,customRef 是一个强大但不太为人熟知的特性。它允许开发者自定义 ref 的行为,包括控制依赖追踪和更新触发。本文将带您深入了解 customRef 的核心概念、使用场景和实际应用,帮助你充分利用这一特性提升应用性能。

在这里插入图片描述


二、什么是 customRef?

customRef 是 Vue 3 提供的一个高级 API,它允许开发者创建自定义的响应式引用对象。与标准 ref 不同,customRef 让您可以完全控制依赖追踪和触发更新的时机。这对于需要控制依赖追踪的时机或需要自定义值变化时的行为(如防抖、节流)的开发场景中特别有用。

何时使用 customRef?

在以下场景中,customRef 特别有用:

  • 需要控制依赖追踪的时机

  • 需要自定义值变化时的行为(如防抖、节流)

  • 需要与外部系统(如 localStorage、IndexedDB)集成

  • 需要实现复杂的状态管理逻辑

  • 需要创建带有历史记录或撤销/重做功能的状态


三、customRef API 详解

API 基本结构

import { customRef } from 'vue';

const myCustomRef = customRef((track, trigger) => {
  return {
    get() {
      // 追踪依赖
      track();
      //return 返回值
    },
    set(newValue) {
      // 这里写更新值逻辑
      // 触发更新
      trigger();
    }
  };
});

语法说明:

它接受一个工厂函数作为参数,该工厂函数返回一个包含 get 和 set 方法的对象。通过myCustomRef.value访问属性值将调用内部get方法,通过myCustomRef.value=xxx赋值将调用内部set方法。

工厂函数参数解析:

  • track():调用此方法会通知 Vue 追踪当前值的依赖关系
  • trigger():调用此方法会通知 Vue 触发依赖更新。

返回值

customRef 返回一个自定义的 ref 对象,该对象具有普通 ref 的所有特性,但行为由你定义。

快速上手示例代码——自带防抖ref:

import { customRef } from 'vue'

function useDebouncedRef(value, delay = 200) {
  let timeout
  return customRef((track, trigger) => {
    return {
      get() {
        track() // 追踪依赖
        return value
      },
      set(newValue) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
          value = newValue
          trigger() // 触发更新
        }, delay)
      }
    }
  })
}

三、实际开发场景及示例

1.防抖

防抖(Debounce)是一种常见的优化技术,用于限制函数的执行频率,确保在特定时间内只有最后一次调用生效。这在处理高频触发事件(如输入框搜索、窗口大小变化等)时特别有用,可以有效减少性能开销

封装一个通用带防抖功能customRef

debouncedRef.js

import { customRef } from "vue";

/**
 * 
 * @param {*} value :值
 * @param {*} delay :防抖延迟时间
 * @param {*} immediate :是否立即执行
 * @returns customRef
 */
export function useDebounceRef(value, delay = 200, immediate = false) {
  let timeout = null;
  let initial = true;
  return customRef((track, trigger) => {
    return {
      get() {
        track(); // 追踪依赖
        return value;
      },
      set(newValue) {
        if (initial && immediate) {//立即执行
          initial = false;
          value = newValue;
          trigger();// 触发更新
          return;
        }
        clearTimeout(timeout);
        timeout = setTimeout(() => {
          value = newValue;
          trigger(); // 触发更新
        }, delay);
      },
    };
  });
}

场景1: 防抖搜索输入框

在搜索场景中,频繁触发搜索请求会带来性能问题。使用 customRef 可以轻松实现防抖功能:

<template>
  <div class="container">
    <input v-model="keyword" placeholder="关键词搜索" />
    <ul>
      <li v-for="(item, index) in list" :key="item.id">{{ item.title }}</li>
    </ul>
  </div>
</template>
<script setup>
import { useDebounceRef } from "./hooks/debounceRef.js";
import {ref, watch } from "vue";

//输入关键词
const keyword = useDebounceRef("", 300); //默认值为空,防抖时间为300ms
//文章列表
const list = ref([]);

watch(keyword, (newValue, oldValue) => {
  //模拟接口请求,搜索文章数据
  setTimeout(() => {
    list.value = new Array(10).fill().map((item, index) => {
      return {
        id:index,
        title: `文章${index + 1}${newValue}`,
      };
    });
  }, 200);
});
</script>

运行效果:

请添加图片描述

场景2:窗口大小调整处理

在一些需要监听窗口大小变化并执行计算或重新渲染UI的场景中,不希望频繁触发重计算/重绘。例如响应式布局(echart图表、海康视频插件播放窗等)根据窗口尺寸变化自适应大小

<template>
  <div class="container">
    <ChildComponent :width="windowWidth" />
  </div>
</template>
<script setup>
import { useDebounceRef } from "./hooks/debounceRef.js";
import ChildComponent from "./ChildComponent.vue"; //子组件

//窗口大小
const windowWidth = useDebounceRef(window.innerWidth);
const handleResize = () => {
  windowWidth.value = window.innerWidth;
};

onMounted(() => {
  window.addEventListener("resize", handleResize);
});

onUnmounted(() => {
  window.removeEventListener("resize", handleResize);
});
</script>

场景3: 表单输入验证

用户输入表单字段时,实时验证但不希望过于频繁触发验证逻辑。

<template>
  <div class="container">
    <form @submit.prevent="submitForm">
      <div class="form-group">
        <label>Email:</label>
        <input
          v-model="email"
          type="email"
        />
        <div v-if="emailError" class="error-message">{{ emailError }}</div>
      </div>

      <button type="submit">Submit</button>
    </form>
  </div>
</template>
<script setup>
import { useDebounceRef } from "./hooks/debounceRef.js";
import {ref,watch} from 'vue';
//输入邮箱
const email = ref('')
// 对原始 email 进行防抖处理
const debouncedEmail = useDebounceRef(email.value, 500)
const emailError = ref('')

// 监听防抖后的邮箱变化,进行验证
watch(debouncedEmail, (newValue) => {
  if (!newValue) {
    emailError.value = '邮箱必填'
  } else if (!/\S+@\S+\.\S+/.test(newValue)) {
    emailError.value = '格式错误'
  } else {
    emailError.value = ''
  }
})

// 同步原始值到防抖ref
watch(email, (newValue) => {
  debouncedEmail.value = newValue
})

const submitForm = () => {
  if (!emailError.value) {
    // 提交表单逻辑
    console.log('提交邮箱为:', email.value)
  }
}
</script>

运行效果:
请添加图片描述

场景4:优化实时图表(echart等)数据更新

当实时数据高频更新时,避免图表频繁重绘。

<template>
  <div class="container">
    <ChartComponent :data="debouncedChartData" />
  </div>
</template>

<script setup>
import { ref, onMounted ,onUnmounted} from 'vue'
import { useDebouncedRef } from './useDebouncedRef'

const rawChartData = ref([])
// 对图表数据进行防抖处理,延迟 500ms
const debouncedChartData = useDebouncedRef(rawChartData.value, 500)

// 模拟实时数据更新(每秒更新 10 次)
onMounted(() => {
  const interval = setInterval(() => {
    rawChartData.value = [...rawChartData.value, Math.random()]
    // 更新防抖 ref
    debouncedChartData.value = rawChartData.value
  }, 100)
  
  onUnmounted(() => clearInterval(interval))
})
</script>

总结

customRef 实现的防抖在实际开发中的核心价值在于:

  • 减少不必要的计算或 API 请求,提升性能。
  • 保持数据响应式的同时控制更新频率。
  • 统一处理防抖逻辑,避免在组件中重复实现。

使用时需注意:

  • 根据需求可以将原始数据与防抖数据分离管理(如示例 3 中的 email 和 debouncedEmail)。
  • 在组件卸载时清理定时器,防止内存泄漏。
  • 根据场景调整防抖延迟时间(通常 200-500ms 适合)。

2.节流

在实际开发中,customRef 实现的节流(Throttle)功能常用于需要限制操作频率的场景,例如滚动加载、高频点击事件、实时数据更新等

封装一个通用带节流功能customRef

throttleRef.js

import { customRef } from "vue";
/**
 *
 * @param {*} value :值
 * @param {*} delay :节流延迟时间
 * @returns customRef
 */
export function useThrottleRef(value, delay = 200) {
  let timeout = null;
  return customRef((track, trigger) => {
    return {
      get() {
        track();
        return value;
      },
      set(newValue) {
        if (!timeout) {
          value = newValue;
          trigger(); // 触发更新
          timeout = setTimeout(() => {
            timeout = null;
          },delay);
        }
      },
    };
  });
}


场景1:列表滚动触底加载更多数据

监听滚动事件加载更多内容,但避免频繁触发网络请求。

<template>
  <div class="list-container" ref="listRef">
    <div v-for="item in items" :key="item" class="item">{{ item }}</div>
    <div v-if="loading" class="loading">加载更多...</div>
  </div>
</template>
<script setup>
import { useThrottleRef } from "./hooks/throttleRef.js";
import { ref, watch, onMounted, onBeforeUnmount} from "vue";

const listRef = ref(null);
const items = ref([1, 2, 3, 4, 5, 6, 7, 8,]);
const loading = ref(false);
const page = ref(1);

// 使用节流 ref 监听滚动位置
const scrollY = useThrottleRef(0, 200);

//滚动监听
const handleScroll = () => {
  if (!listRef.value) return;
  const { scrollTop, scrollHeight, clientHeight } = listRef.value;
  // 距离底部 100px 时触发加载
  if (scrollTop + clientHeight >= scrollHeight - 100) {
    scrollY.value = scrollTop; // 触节流更新,触发 loadMore 监听
  }
};

onMounted(() => {
  listRef.value && listRef.value.addEventListener("scroll", handleScroll);
});

onBeforeUnmount(() => {
  listRef.value && listRef.value.removeEventListener("scroll", handleScroll);
});

//加载更多数据
const loadMore = async () => {
  if (loading.value) return;

  loading.value = true;
  page.value++;
  // 模拟加载更多数据
  await new Promise((resolve) => setTimeout(resolve, 800));


  // 追加新数据
 const newItems = Array(5)
    .fill(0)
    .map((v, i) => items.value.length + i + 1);
  items.value = [...items.value, ...newItems];
  loading.value = false;
};

// 监听节流后的滚动位置变化
watch(scrollY, loadMore);
</script>

运行效果:

请添加图片描述

场景2:高频点击事件防重触发

防止按钮在短时间内被多次点击,避免重复提交表单或触发操作。


对throttleRef.js进行改进:

import { customRef } from "vue";
/**
 *
 * @param {*} value :值或函数
 * @param {*} delay :节流延迟时间
 * @returns customRef
 */
export function useThrottleRef(value, delay = 200) {
  let timeout = null;
  //是否函数类型
  let isFunction = typeof value === "function";

  return customRef((track, trigger) => {
    let throttledFunc = null;//节流函数
    if (isFunction) {
      throttledFunc = (...args) => {
        if (!timeout) {
          value(...args);
          trigger();
          timeout = setTimeout(() => {
            timeout = null;
          }, delay);
        }
      };
    }

    //函数类型
    if (isFunction) {
      return {
        get() {
          track();
          return throttledFunc;
        },
        set() {}, // 函数无需设置值
      };
    } else {
      //普通值类型
      return {
        get() {
          track();
          return value;
        },
        set(newValue) {
          if (!timeout) {
            value = newValue;
            trigger(); // 触发更新
            timeout = setTimeout(() => {
              timeout = null;
            }, delay);
          }
        },
      };
    }
  });
}

使得入参既可以是函数,也可以是普通值

<template>
  <div class="button-demo">
    <button @click="throttledClick" :disabled="isLoading">
      {{ isLoading ? "提交中..." : "点击提交" }}
    </button>
    <p>已提交次数: {{ clickCount }}</p>
  </div>
</template>

<script setup>
import { ref} from "vue";
import { useThrottleRef } from "./hooks/throttleRef.js";
const clickCount = ref(0);
const isLoading = ref(false);

// 使用节流 ref 控制点击频率
const throttledClick = useThrottleRef(() => {
  //提交数据
  isLoading.value = true;
  clickCount.value++;

  // 模拟接口请求
  setTimeout(() => {
    isLoading.value = false;
    console.log("提交次数:", clickCount.value);
  }, 2000);
}, 2000); // 2000ms 内只能点击一次


</script>

运行结果:

请添加图片描述


总结

customRef 实现的节流在实际开发中的核心价值在于:

  • 限制高频操作的执行频率,避免浏览器卡顿或资源浪费。
  • 保持数据响应式的同时控制更新节奏,平衡性能与用户体验。
  • 统一处理节流逻辑,减少组件内的重复代码。

使用时需注意:

  • 根据场景调整节流间隔(如滚动加载适合 200-300ms,实时数据适合 500-1000ms)。
  • 处理边界情况(如首次加载、数据为空)。
  • 确保在组件卸载时清理定时器或事件监听,避免内存泄漏

3.数据自动持久化

将自定义 ref 与 localStorage集成,实现数据(变量)自动持久化存储本地,也可以用在vuex或pina等第三方库上实现数据持久化。

localStorageRef.js

import { customRef } from "vue";

/**
 *
 * @param {*} key :localStorage的key
 * @param {*} initialValue :默认值
 * @returns
 */
export function useLocalStorageRef(key, initialValue) {
  // 从 localStorage 获取值
  let value = localStorage.getItem(key) ?? initialValue;
  return customRef((track, trigger) => {
    return {
      get() {
        track();
        return value;
      },
      set(newValue) {
        value = newValue;
        localStorage.setItem(key, newValue);
        trigger();
      },
    };
  });
}

页面使用:

<template>
  <div class="container">
  <input v-model="account" placeholder="请输入账号" />
  <p>本地存储数据为: {{ account }}</p>
  <button @click="handleReload">刷新页面</button>
</div>
</template>

<script setup>
import { useLocalStorageRef } from './localStorageRef'

const account = useLocalStorageRef('account', '')

const handleReload = () => {
  window.location.reload()
}
</script>

运行效果:
请添加图片描述

4.实现只读 ref

创建一个只读的 ref,外部无法修改其值,保护敏感数据不被组件内部意外修改。

readonlyRef.js

import { customRef } from 'vue'

export function useReadonlyRef(value) {
  return customRef((track, trigger) => {
    return {
      get() {
        track()
        return value
      },
      set() {
        // 抛出警告
        console.warn('readonly ref')
      }
    }
  })
}

页面使用

<template>
  <div class="container">
    <button @click="increment">+1</button>
  <p>Readonly: {{ readonlyCount }}</p>
</div>
</template>

<script setup>
import { useReadonlyRef } from './readonlyRef'

const readonlyCount = useReadonlyRef(0)

const increment = () => {
  readonlyCount.value++
}
</script>

运行效果:
请添加图片描述

5. 带历史记录的 ref(撤销 / 重做功能)

记录值的变更历史,支持撤销和重做操作

historyRef.js


import { customRef, ref, computed, reactive } from "vue";

/**
 * 
 * @param {*} initialValue :初始值
 * @param {*} maxHistory :最大历史记录数
 * @returns 
 */
export function useHistoryRef(initialValue, maxHistory = 10) {
  const history = ref([initialValue]); // 历史记录数组
  const historyIndex = ref(0); // 当前历史索引

  // 创建自定义customRef
  const mainRef = customRef((track, trigger) => {
    return {
      get() {
        track();
        return history.value[historyIndex.value];
      },
      set(newValue) {
        // 移除当前索引之后的历史记录
        if (historyIndex.value < history.value.length - 1) {
          history.value = history.value.slice(0, historyIndex.value + 1);
        }
        // 添加新值到历史记录
        const newHistory = [...history.value, newValue];
        // 限制历史记录长度
        if (newHistory.length > maxHistory) {
          newHistory.shift();
          historyIndex.value = Math.max(0, historyIndex.value - 1);
        }

        history.value = newHistory;
        historyIndex.value = newHistory.length - 1;
        trigger(); // 触发响应式更新
      },
    };
  });

  // 撤销操作
  const undo = () => {
    if (historyIndex.value > 0) {
      historyIndex.value--;
    }
  };
  // 重做操作
  const redo = () => {
    if (historyIndex.value < history.value.length - 1) {
      historyIndex.value++;
    }
  };
  //是否可以撤销
  const canUndo = computed(() => historyIndex.value > 0);
  //是否可以重做
  const canRedo = computed(() => historyIndex.value < history.value.length - 1);

  // 使用 reactive 确保所有属性都是响应式的
  return reactive({
    value: mainRef,
    undo,
    redo,
    canUndo,
    canRedo,
    history: computed(() => history.value),
    historyIndex: computed(() => historyIndex.value),
    historyLength: computed(() => history.value.length),
    
    get current() {
      return mainRef.value;
    },

    set current(newValue) {
      mainRef.value = newValue;
    },
  });
}

页面使用:

<template>
  <div class="container">
    <!-- 直接绑定到 自定义ref 注意需要.value访问 -->
    <input v-model="text.value" />

    <!-- 使用方法 -->
    <button @click="text.undo" :disabled="!text.canUndo">撤销</button>
    <button @click="text.redo" :disabled="!text.canRedo">重做</button>

    <!-- 访问附加属性 -->
    <div>可撤销: {{ text.canUndo }}</div>
    <div>可重做: {{ text.canRedo }}</div>
    <div>历史记录长度: {{ text.historyLength }}</div>
    <div>当前索引: {{ text.historyIndex }}</div>
  </div>
</template>

<script setup>
import { useHistoryRef } from "./historyRef.js";
const text = useHistoryRef("");
</script>

运行效果:
请添加图片描述


四、使用技巧与注意事项

1. 避免过度使用

customRef 是一个底层 API,大多数情况下普通的 ref 和 computed 已经足够。只有在需要精确控制依赖追踪和更新触发时才使用。

2. 性能优化

通过合理使用 customRef,可以减少不必要的重新渲染,提高应用性能。但要注意,过度复杂的自定义逻辑可能会适得其反。

3. 与其他 API 结合

customRef 可以与 computed、watch 等 API 结合使用,构建更复杂的响应式逻辑。


总结

customRef 为 Vue 开发者提供了一种灵活且强大的方式来自定义响应式行为。通过掌握其 API 和应用场景,你可以优雅地处理高频事件、数据持久化、历史操作记录等创建更高效的 Vue 应用。在实际开发中,合理使用 customRef 能够帮助你解决许多性能和集成难题,是 Vue 3 工具箱中不可或缺的一部分。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

pixle0

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值