创建日期:2026-09-20 | 最近更新:2026-09-20 本文所有行为数字均为本机真实运行结果:Node v24.14.1 +
@vue/reactivity3.5.42 与@vueuse/core15.0.0(对比用),脚本留档在source/vue-lab/。不是背文档。 系列定位:这是 Vue3 系列的第 6 篇,从「原理」转向「用响应式原语手写可复用逻辑」。
用 customRef 实现防抖:把「延迟」做成一个 ref
防抖(debounce)大家都会写:
setTimeout+clearTimeout。但在 Vue 里,防抖该放在哪一层是个设计问题——放在每个消费者里,有几个消费者就要写几遍;放在数据源头,下游全都自动受益。
customRef正好能把它放到源头:让一个 ref 的「写入」不立即生效,延迟后再通知下游。这篇把它实现出来,然后用真实运行结果说明它在什么情况下会静默出问题——其中两个坑我自己先踩了。
一、customRef 是什么
一句话:一个能让你自己决定「什么时候收集依赖(track)、什么时候通知更新(trigger)」的 ref。
普通 ref 的读写时机是框架定死的;customRef 把它们交给你:
import { customRef } from 'vue';
const r = customRef((track, trigger) => ({
get() { track(); return value; }, // 读:声明「我依赖这个 ref」
set(v) { value = v; trigger(); }, // 写:声明「这个 ref 变了,通知下游」
}));
关键点:track() 和 trigger() 是两个独立动作。你可以「写的时候先不 trigger」——这就是防抖的实现空间。第 3 篇 响应式原理 讲过 track/trigger 的机制,这里是它的第一个实用出口。
二、为什么防抖适合放在 ref 这一层
看你现在的写法大概是这样的——每个消费者各自防抖:
watch(keyword, debounce(() => search(keyword.value), 300));
watch(keyword, debounce(() => saveDraft(keyword.value), 300)); // 又来一遍
问题不在代码量,而在语义:「用户停止输入 300ms 后再算」是数据源的性质,不是消费者的性质。放进 ref 之后,下游只看到一个「已经稳定」的值。
实测对比(3 次连续写入,防抖 200ms,两个消费者):
| 方案 | 消费者 1 触发 | 消费者 2 触发 |
|---|---|---|
| 普通 ref + 各自防抖 | 3 次 | 3 次 |
| customRef 源头防抖 | 1 次 | 1 次 |
三、实现
import { customRef, onScopeDispose } from 'vue';
export function useDebouncedRef(value, delay = 200) {
let timer = null;
let raw = value; // 真实值:写入即更新
let debounced = value; // 对外值:延迟后更新
const r = customRef((track, trigger) => ({
get() {
track();
return debounced; // 下游读到的永远是「已稳定」的值
},
set(newValue) {
raw = newValue;
if (timer) clearTimeout(timer); // 重新计时(trailing edge)
timer = setTimeout(() => {
timer = null;
if (!Object.is(debounced, raw)) { // 值真的变了才 trigger
debounced = raw;
trigger();
}
}, delay);
},
}));
// ★ 作用域销毁时取消挂起的定时器(为什么必须写,见 §六)
onScopeDispose(() => {
if (timer) { clearTimeout(timer); timer = null; }
});
return r;
}
用法就是普通 ref:
const keyword = useDebouncedRef('', 300);
watch(keyword, () => search(keyword.value)); // 只在用户停手后触发一次
四、实测:它到底怎么表现
实验 1|连续写 5 次(间隔 50ms,防抖 200ms)
[ 4ms] 写入 "h"
[ 55ms] 写入 "he"
[ 106ms] 写入 "hel"
[ 157ms] 写入 "hell"
[ 208ms] 写入 "hello"
[ 409ms] watch 触发 → "hello"
→ 写入 5 次,实际 flush 1 次,下游 watch 触发 1 次
实验 2|落地时机(确认是 trailing 而非 leading)
写入 "a" → 计时器启动
+120ms 写入 "ab" → 计时器重置
+240ms(超过 200ms,但因为重置过,不该触发)→ 没有触发 ✓
+283ms 落地 → "ab"
结论:是标准的 trailing edge——从最后一次写入起算 200ms。
实验 3|写入相同值
写入 "x"(与当前相同)→ 写入 "y" → 写入 "y"(再来一次相同值)
→ sets=3,flushes=1,下游触发 1 次
Object.is 把相同值挡掉了:写入 3 次,只有真正变化那一次唤醒了下游。(如果把那行判断去掉,下游会被无意义地唤醒 2 次。)
实验 6|写入瞬间读它,拿到新值还是旧值?
刚写入 "new":d.value = "old"(对外仍是旧值)
200ms 后: d.value = "new"
这个行为很重要:它意味着 v-model 绑上去之后,输入框里打字会「粘手」(显示值滞后 200ms)——所以 v-model 场景不要直接绑防抖 ref,它适合绑「派生出来的查询条件」而不是输入框本身。见 §七 的修正版。
五、★ 坑一:不写清理,定时器会在组件销毁后照样跑
这是最容易忽略的一处,而且看下游触发次数根本发现不了。
我第一版实验就是错的:我数的是「stop 之后下游触发几次」——结果是 0 次,看起来完好无损。因为这个测法本身有缺陷:scope.stop() 已经把所有订阅者停掉了,无论定时器跑不跑,下游都不会被通知。
改成数「定时器回调本身执行了几次」才看出真相:
| stop 之后定时器实际执行 | 下游触发 | |
|---|---|---|
不写 onScopeDispose | 1 次 | 0 次 |
| 写了 | 0 次 | 0 次 |
为什么危险:它是静默的保留,不是静默的报错。定时器闭包仍持有 raw / debounced,如果 raw 指向一个大对象(比如列表数据、DOM 引用),这块内存在触发前一直不会释放。组件已经卸载了,逻辑还在跑——这类问题在压测里才会浮出来。
六、★ 坑二:在 await 之后创建,onScopeDispose 会静默失效
这条更隐蔽,是我在跑实验 5 时看到 Vue 警告才顺藤摸瓜查出来的:
[Vue warn] onScopeDispose() is called when there is no active effect scope to be associated with.
原因:effectScope 的「活跃作用域」不跨越 await。实测对照(同为 effectScope().run(async () => …)):
| 创建时机 | Vue 警告数 |
|---|---|
await 之前 调用 useDebouncedRef() | 0 |
await 之后 调用 useDebouncedRef() | 1 |
含义:onScopeDispose 关联不上作用域 → 清理逻辑静默失效,回到了 §五 的泄漏状态,而且只给一条 warning,很容易被日志淹没。
高发场景:<script setup> 里带 await(async setup / Suspense)、或者在 onMounted 里先 await 一个接口再创建 ref。
两条修法(按推荐顺序):
// ① 把创建提到 await 之前(最省事,也最推荐)
const keyword = useDebouncedRef('', 300);
const data = await fetchList(); // 之后再用 keyword
// ② 如果必须在 await 之后创建,就显式传入/持有取消函数,别依赖隐式作用域
const { ref: keyword, cancel } = useDebouncedRefWithCancel('', 300);
onScopeDispose(cancel); // 由调用方显式收尾
七、诚实对比:VueUse 已经有 refDebounced 了
必须把这一节写出来,否则像在教人手写轮子。实测三方对比(3 次写入、防抖 200ms):
| 方案 | 下游触发 | 落地耗时 | 写入瞬间读到 |
|---|---|---|---|
本文 customRef 实现 | 1 次 | ≈303ms | 旧值 "" |
VueUse refDebounced(200ms) | 1 次 | ≈303ms | 旧值 "" |
| 普通 ref + 每个消费者自己防抖 | 3 次 | ≈200ms | 旧值 "" |
结论要说清楚:refDebounced 的可观察行为和我这个实现一致(触发次数、落地耗时、读值语义都相同)。
所以:
- 生产代码优先用
@vueuse/core的refDebounced/watchDebounced—— 它测得更全、边界处理更多; - 自己写 customRef 的价值有两块:一是理解机制(
track/trigger分离到底意味着什么);二是需要自定义语义时——refDebounced只给你「延迟后的值」,而这些它不管:- leading + trailing 都要触发(首次立即执行,之后防抖);
- 手动
flush()/cancel()(比如「立刻提交」按钮要跳过等待、「取消」要丢弃挂起值); - 按 key 分组防抖(多个输入框共用一个组合式函数,各自独立计时);
- 写即时、读延迟(内部立刻拿到最新值做校验,对外仍给稳定值)。
八、需要自定义语义时的完整版本
把上面那些能力补齐(flush / cancel / peek),这也是「自己写」真正划算的地方:
import { customRef, onScopeDispose } from 'vue';
/**
* 带手动控制的防抖 ref
* flush 立即把挂起值落地并触发(例如「立即搜索」按钮)
* cancel 丢弃挂起值,不触发(例如「取消」)
* peek 读真实值(不延迟、不建立依赖)
*/
export function useDebouncedRefControlled(value, delay = 300) {
let timer = null;
let raw = value; // 真实值
let debounced = value; // 对外值
let triggerFn = null; // ★ 把 trigger 存出来,供 flush 手动调用
const r = customRef((track, trigger) => {
triggerFn = trigger; // 工厂在构造 ref 时就会执行,这里一定能拿到
return {
get() { track(); return debounced; },
set(v) {
raw = v;
if (timer) clearTimeout(timer);
timer = setTimeout(settle, delay);
},
};
});
const cancelTimer = () => { if (timer) { clearTimeout(timer); timer = null; } };
function settle() {
timer = null;
if (Object.is(debounced, raw)) return; // 没真变就不通知
debounced = raw;
triggerFn?.();
}
onScopeDispose(cancelTimer);
return {
ref: r,
flush: () => { cancelTimer(); settle(); }, // 跳过等待,立刻落地
cancel: () => { cancelTimer(); raw = debounced; }, // 丢弃挂起值并回退真实值
peek: () => raw, // 读真实值,不建立依赖
};
}
注意:flush 不能靠「再赋值一次 r.value = raw」来实现——那会重新走 setter、又起一个定时器,等于没 flush。必须把 trigger 存出来直接调。(这个实现我跑过:flush() 落地且下游只触发一次;cancel() 丢弃挂起值且不唤醒下游;对相同值 flush() 也不会重复触发。)
v-model的「粘手」问题在这里也能解:输入框绑一个普通 ref(显示即时),防抖 ref 只喂给查询逻辑——即「显示即时、查询防抖」,而不是让输入框本身延迟。
九、局限
- 实测基于
@vue/reactivity在 Node 里直接跑(与第 3 篇同一套方法):能真实反映track/trigger、effectScope、watch的行为,但不覆盖组件渲染层(如模板里{{ ref }}的更新时机、SSR 行为、<Suspense>下的作用域归属); - 防抖延迟用
setTimeout,不做时间 mocking,所以日志里的毫秒数有 ±10ms 抖动,看的是数量级与相对关系; - 只对比了
refDebounced,未展开 VueUse 的watchDebounced/useDebounceFn; - VueUse 版本为 15.0.0,其内部实现可能变化,对比结论以本文版本为准;
- §五、§六 两个坑都是真实踩到并修正后才写进本文的(第一版实验 4 的设计是错的,已改)。
关联
- 原理前置:响应式原理(
track/trigger/effectScope的机制) - 组合式写法基础:Composition API 与 script setup
- 组合式进阶与工程化:第 5 篇
- 另一门「手写原语」的实例:用最小实现理解 Agent 循环
自测
customRef的track()和trigger()分别在哪里调用?为什么防抖正好需要把它们分开?- 为什么「防抖放在源头」比「每个消费者各自防抖」更好?用实测数字回答。
- 写入 5 次、防抖 200ms,下游为什么只触发 1 次?它是 leading 还是 trailing?
- 「stop 之后下游触发 0 次」为什么不能证明没有泄漏?该数什么才对?
- 在
await之后创建customRef会出什么问题?为什么?怎么修? - 既然
refDebounced行为一致,什么情况下才值得自己写?
参考
- 实测脚本(随仓库留档):
source/vue-lab/——debounce.mjs(六组行为实验 + 清理与 async 边界)、compare.mjs(customRef vs VueUse vs 手工防抖)、controlled.mjs(§八 的 flush/cancel 实现),跑法见其中README.md - 环境:Node v24.14.1 /
@vue/reactivity3.5.42 /@vueuse/core15.0.0 - Vue 官方文档 · 响应式进阶(
customRef定义):cn.vuejs.org/guide/extras/reactivity-in-depth - Vue 官方文档 ·
effectScope与onScopeDispose:cn.vuejs.org/api/reactivity-advanced - VueUse ·
refDebounced:vueuse.org/shared/refdebounced