Vue3防抖搜索:从Lodash到customRef全解析

博主介绍

👨‍💻 了解博主:波仔椿

📖 人生箴言:组件拆得够小,页面才装得下变化。

🧰 我的专栏:Vue3-实战派


文章内容

上周我在一个后台管理系统里做用户搜索框,产品说"输入即搜索,体验要丝滑"。我上来就 watch 了输入框的值,结果用户每敲一个字母,前端就往后端发一次请求。一秒钟十几个请求打过去,后端同事在群里 @ 我:"你悠着点。"

后来我加了防抖(debounce),问题秒解。今天我把 Vue3 组合式 API 下实现防抖搜索的几种写法,从最省事的到最底层的,一口气拆给你看。每个代码块都能直接复制运行,而且我会讲清楚每段代码为什么这么写。

说白了:防抖不是"拦住输入",而是"等你停手了再动手"------你连续敲字,它一个请求都不发,等你停顿 300ms 才发那唯一一次。

下面代码基于 Vue 3.5 + Vite (当前最新稳定版 Vue 3.5.40)。你先用 npm create vite@latest demo -- --template vue 起个项目,再把每个代码块首行标注的文件名原样建文件放进去,就能直接跑。

一、防抖到底是什么:别和节流搞混

很多人把防抖和节流(throttle)当成一回事,其实它俩解决的是不同问题。我用一张表说清楚:

维度 防抖 debounce 节流 throttle
触发时机 连续触发,只在最后一次后等待 delay 执行 连续触发,每隔 delay 固定执行一次
适合场景 搜索输入、表单校验、窗口 resize 结束 滚动加载、鼠标移动坐标上报、按钮连点
一句话 等你"歇口气"再动 不管你多频繁,按固定节奏动

搜索框用防抖是对的:用户还在打字,你发请求没意义;等他停了,发那一次就够了。

我画个流程图,你就明白一次搜索请求是怎么被"摁住"的:
#mermaid-svg-UolyyZIa3hwPhOv3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UolyyZIa3hwPhOv3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UolyyZIa3hwPhOv3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UolyyZIa3hwPhOv3 .error-icon{fill:#552222;}#mermaid-svg-UolyyZIa3hwPhOv3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UolyyZIa3hwPhOv3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UolyyZIa3hwPhOv3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UolyyZIa3hwPhOv3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UolyyZIa3hwPhOv3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UolyyZIa3hwPhOv3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UolyyZIa3hwPhOv3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UolyyZIa3hwPhOv3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UolyyZIa3hwPhOv3 .marker.cross{stroke:#333333;}#mermaid-svg-UolyyZIa3hwPhOv3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UolyyZIa3hwPhOv3 p{margin:0;}#mermaid-svg-UolyyZIa3hwPhOv3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UolyyZIa3hwPhOv3 .cluster-label text{fill:#333;}#mermaid-svg-UolyyZIa3hwPhOv3 .cluster-label span{color:#333;}#mermaid-svg-UolyyZIa3hwPhOv3 .cluster-label span p{background-color:transparent;}#mermaid-svg-UolyyZIa3hwPhOv3 .label text,#mermaid-svg-UolyyZIa3hwPhOv3 span{fill:#333;color:#333;}#mermaid-svg-UolyyZIa3hwPhOv3 .node rect,#mermaid-svg-UolyyZIa3hwPhOv3 .node circle,#mermaid-svg-UolyyZIa3hwPhOv3 .node ellipse,#mermaid-svg-UolyyZIa3hwPhOv3 .node polygon,#mermaid-svg-UolyyZIa3hwPhOv3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UolyyZIa3hwPhOv3 .rough-node .label text,#mermaid-svg-UolyyZIa3hwPhOv3 .node .label text,#mermaid-svg-UolyyZIa3hwPhOv3 .image-shape .label,#mermaid-svg-UolyyZIa3hwPhOv3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-UolyyZIa3hwPhOv3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UolyyZIa3hwPhOv3 .rough-node .label,#mermaid-svg-UolyyZIa3hwPhOv3 .node .label,#mermaid-svg-UolyyZIa3hwPhOv3 .image-shape .label,#mermaid-svg-UolyyZIa3hwPhOv3 .icon-shape .label{text-align:center;}#mermaid-svg-UolyyZIa3hwPhOv3 .node.clickable{cursor:pointer;}#mermaid-svg-UolyyZIa3hwPhOv3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UolyyZIa3hwPhOv3 .arrowheadPath{fill:#333333;}#mermaid-svg-UolyyZIa3hwPhOv3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UolyyZIa3hwPhOv3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UolyyZIa3hwPhOv3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UolyyZIa3hwPhOv3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UolyyZIa3hwPhOv3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UolyyZIa3hwPhOv3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UolyyZIa3hwPhOv3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UolyyZIa3hwPhOv3 .cluster text{fill:#333;}#mermaid-svg-UolyyZIa3hwPhOv3 .cluster span{color:#333;}#mermaid-svg-UolyyZIa3hwPhOv3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UolyyZIa3hwPhOv3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UolyyZIa3hwPhOv3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-UolyyZIa3hwPhOv3 .icon-shape,#mermaid-svg-UolyyZIa3hwPhOv3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UolyyZIa3hwPhOv3 .icon-shape p,#mermaid-svg-UolyyZIa3hwPhOv3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UolyyZIa3hwPhOv3 .icon-shape .label rect,#mermaid-svg-UolyyZIa3hwPhOv3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UolyyZIa3hwPhOv3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UolyyZIa3hwPhOv3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UolyyZIa3hwPhOv3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否,又输入了

用户连续输入 vue
每次输入都触发?
重置 300ms 定时器
停顿超过 300ms?
发起搜索请求
渲染结果列表

你发现没有:关键就在那个"重置定时器"------只要 300ms 内又来了新输入,之前的计时就作废了,所以连续打字期间一个请求都发不出去。

二、方案一最常用:watch + lodash 防抖

工程里我最推荐这个写法。原因很简单:lodash 的 debounce 经过海量项目验证,边界情况(取消防抖、立即执行等)都处理好了,你不用自己造轮子。

bash 复制代码
# 装一下 lodash(只要防抖函数,可以只装子包减小体积)
npm install lodash
html 复制代码
<!-- src/components/SearchBox.vue -->
<script setup>
import { ref, watch } from 'vue'
// 只引 debounce 子包,tree-shaking 友好
import debounce from 'lodash/debounce'

const keyword = ref('')
const result = ref([])
const loading = ref(false)

// 真正去请求后端的函数
async function fetchSearch(q) {
  if (!q) {
    result.value = []
    return
  }
  loading.value = true
  try {
    // 用 GitHub 公开搜索接口做演示,无需登录
    const res = await fetch(
      `https://api.github.com/search/repositories?q=${q}`
    ).then(r => r.json())
    result.value = res.items ?? []
  } finally {
    loading.value = false
  }
}

// 用 lodash 的 debounce 把函数包一层,延迟 300ms
const debouncedSearch = debounce(fetchSearch, 300)

// 监听输入变化,把新值交给防抖函数
watch(keyword, (val) => {
  debouncedSearch(val)
})
</script>

<template>
  <div class="search">
    <input v-model="keyword" placeholder="输入关键词搜索 GitHub 仓库" />
    <p v-if="loading">搜索中...</p>
    <ul>
      <li v-for="item in result" :key="item.id">{{ item.full_name }}</li>
    </ul>
  </div>
</template>

<style scoped>
.search input {
  width: 320px;
  padding: 8px 12px;
  font-size: 14px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
}
.search ul {
  margin-top: 12px;
  padding-left: 0;
  list-style: none;
}
.search li {
  padding: 6px 0;
  border-bottom: 1px solid #f0f0f0;
}
</style>

这段代码我特意用 GitHub 的公开搜索接口,你原样复制就能跑 (匿名用户每分钟 10 次请求额度,演示足够)。注意一个细节:防抖包的是 fetchSearch 这个函数,而不是 watch 的回调本身------这样语义最清楚,watch 只负责"值变了,通知一声",防抖逻辑完全交给 debounce 处理。

三、方案二学原理:用 Vue3 自带 customRef 手写

如果你想真正搞懂"防抖 ref"是怎么做到的,Vue3 官方文档就给了一个 customRef 的示例(源码见官方 API 文档)。我直接用它,再讲透原理。

customRef 允许你亲手控制什么时候收集依赖(track)、什么时候触发更新(trigger) 。普通 ref 是赋值就 trigger,而 customRef 让你决定 trigger 的时机:

script 复制代码
// src/composables/useDebouncedRef.js
import { customRef } from 'vue'

// value 是初始值,delay 是防抖延迟,默认 200ms(与官方示例一致)
export function useDebouncedRef(value, delay = 200) {
  let timeout
  return customRef((track, trigger) => {
    return {
      get() {
        track()          // 告诉 Vue:有人读我了,把这次读取记进依赖
        return value
      },
      set(newValue) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
          value = newValue
          trigger()       // 延迟到点后,才通知依赖它的视图去更新
        }, delay)
      }
    }
  })
}

用起来也简单,这个 ref 本身就是"防抖过的",v-model 绑上去就行:

html 复制代码
<!-- src/App.vue -->
<script setup>
import { watch } from 'vue'
import { useDebouncedRef } from './composables/useDebouncedRef'

// keyword 就是防抖后的响应式值,300ms 停顿后才更新
const keyword = useDebouncedRef('', 300)

watch(keyword, async (val) => {
  if (!val) return
  const res = await fetch(
    `https://api.github.com/search/repositories?q=${val}`
  ).then(r => r.json())
  console.log('搜索结果', res.items)
})
</script>

<template>
  <input v-model="keyword" placeholder="试试连着打字" />
</template>

有意思的地方就在这儿 :普通 ref 是"一赋值就 trigger";customRef 把 trigger 塞进了 setTimeout 里,于是赋值不会立刻更新视图,要等延迟过了才更新------防抖就这么来的。

我把一次输入到请求的过程画成时序图,依赖关系一眼可见:
GitHub API watch customRef keyword 输入框 用户 GitHub API watch customRef keyword 输入框 用户 #mermaid-svg-kJ1vHi5uaLh99wh7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kJ1vHi5uaLh99wh7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kJ1vHi5uaLh99wh7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kJ1vHi5uaLh99wh7 .error-icon{fill:#552222;}#mermaid-svg-kJ1vHi5uaLh99wh7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kJ1vHi5uaLh99wh7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kJ1vHi5uaLh99wh7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kJ1vHi5uaLh99wh7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kJ1vHi5uaLh99wh7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kJ1vHi5uaLh99wh7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kJ1vHi5uaLh99wh7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kJ1vHi5uaLh99wh7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kJ1vHi5uaLh99wh7 .marker.cross{stroke:#333333;}#mermaid-svg-kJ1vHi5uaLh99wh7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kJ1vHi5uaLh99wh7 p{margin:0;}#mermaid-svg-kJ1vHi5uaLh99wh7 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kJ1vHi5uaLh99wh7 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-kJ1vHi5uaLh99wh7 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-kJ1vHi5uaLh99wh7 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-kJ1vHi5uaLh99wh7 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-kJ1vHi5uaLh99wh7 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-kJ1vHi5uaLh99wh7 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-kJ1vHi5uaLh99wh7 .sequenceNumber{fill:white;}#mermaid-svg-kJ1vHi5uaLh99wh7 #sequencenumber{fill:#333;}#mermaid-svg-kJ1vHi5uaLh99wh7 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-kJ1vHi5uaLh99wh7 .messageText{fill:#333;stroke:none;}#mermaid-svg-kJ1vHi5uaLh99wh7 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kJ1vHi5uaLh99wh7 .labelText,#mermaid-svg-kJ1vHi5uaLh99wh7 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-kJ1vHi5uaLh99wh7 .loopText,#mermaid-svg-kJ1vHi5uaLh99wh7 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-kJ1vHi5uaLh99wh7 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-kJ1vHi5uaLh99wh7 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-kJ1vHi5uaLh99wh7 .noteText,#mermaid-svg-kJ1vHi5uaLh99wh7 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-kJ1vHi5uaLh99wh7 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kJ1vHi5uaLh99wh7 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kJ1vHi5uaLh99wh7 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kJ1vHi5uaLh99wh7 .actorPopupMenu{position:absolute;}#mermaid-svg-kJ1vHi5uaLh99wh7 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-kJ1vHi5uaLh99wh7 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kJ1vHi5uaLh99wh7 .actor-man circle,#mermaid-svg-kJ1vHi5uaLh99wh7 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-kJ1vHi5uaLh99wh7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 连续输入 "vu" v-model 赋值 触发 set,重置定时器 继续输入 "e" 再次赋值,定时器又重置 停顿 300ms 定时器到点,trigger 通知 只发一次搜索请求 返回数据 控制台打印结果

四、方案三图省事:VueUse 的 useDebounceFn

如果你项目里本来就用了 VueUse(@vueuse/core,Vue 生态事实上的工具集),那几行代码就搞定,完全不用自己写防抖逻辑。

bash 复制代码
npm install @vueuse/core
html 复制代码
<!-- src/components/SearchBox.vue -->
<script setup>
import { ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'

const keyword = ref('')
const result = ref([])

// 直接得到一个"防抖 300ms 的函数"
const search = useDebounceFn(async (q) => {
  if (!q) {
    result.value = []
    return
  }
  const res = await fetch(
    `https://api.github.com/search/repositories?q=${q}`
  ).then(r => r.json())
  result.value = res.items ?? []
}, 300)

// watch 输入,把值交给防抖函数
watch(keyword, (val) => search(val))
</script>

<template>
  <div class="search">
    <input v-model="keyword" placeholder="搜索 GitHub 仓库" />
    <ul>
      <li v-for="item in result" :key="item.id">{{ item.full_name }}</li>
    </ul>
  </div>
</template>

useDebounceFn 内部已经在组件卸载时自动取消了未执行的定时器,不用你手动 onUnmounted 清理,这点比自己写 setTimeout 省心。据 VueUse 官方文档,它还支持第三个参数 { maxWait } 设置最大等待时间,防止用户一直打字导致请求永远发不出去。

五、三种方案怎么选

别纠结,照这张表选就行:

方案 代码量 适合场景 需要额外依赖
watch + lodash 绝大多数业务搜索框,求稳 lodash
customRef 手写 想完全掌控、理解原理、不引第三方 无(Vue 自带)
VueUse useDebounceFn 项目已用 VueUse,追求最短代码 @vueuse/core

我理解下来:新手先用方案一跑通,再用方案二搞懂原理,等项目里 VueUse 成了标配,方案三就是日常写法。三步走完,这块知识就焊死在你脑子里了。

六、进阶:完整状态管理 + 取消过期请求

真实搜索框不能只管发请求,至少得告诉用户"在搜""搜到了""翻车了"。还有一个容易忽略的问题:用户快速输入时,前一个请求还没返回,后一个请求就发出去了------如果前一个请求比后一个慢,结果会"串台",旧数据覆盖新数据。

我用状态图把组件的几种状态画出来:
#mermaid-svg-IVkJla6wmTXBwDyL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IVkJla6wmTXBwDyL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IVkJla6wmTXBwDyL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IVkJla6wmTXBwDyL .error-icon{fill:#552222;}#mermaid-svg-IVkJla6wmTXBwDyL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IVkJla6wmTXBwDyL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IVkJla6wmTXBwDyL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IVkJla6wmTXBwDyL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IVkJla6wmTXBwDyL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IVkJla6wmTXBwDyL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IVkJla6wmTXBwDyL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IVkJla6wmTXBwDyL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IVkJla6wmTXBwDyL .marker.cross{stroke:#333333;}#mermaid-svg-IVkJla6wmTXBwDyL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IVkJla6wmTXBwDyL p{margin:0;}#mermaid-svg-IVkJla6wmTXBwDyL defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-IVkJla6wmTXBwDyL g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-IVkJla6wmTXBwDyL g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-IVkJla6wmTXBwDyL g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-IVkJla6wmTXBwDyL g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-IVkJla6wmTXBwDyL g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-IVkJla6wmTXBwDyL .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-IVkJla6wmTXBwDyL .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-IVkJla6wmTXBwDyL .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-IVkJla6wmTXBwDyL .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-IVkJla6wmTXBwDyL .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-IVkJla6wmTXBwDyL .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-IVkJla6wmTXBwDyL .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-IVkJla6wmTXBwDyL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IVkJla6wmTXBwDyL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IVkJla6wmTXBwDyL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IVkJla6wmTXBwDyL .edgeLabel .label text{fill:#333;}#mermaid-svg-IVkJla6wmTXBwDyL .label div .edgeLabel{color:#333;}#mermaid-svg-IVkJla6wmTXBwDyL .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-IVkJla6wmTXBwDyL .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-IVkJla6wmTXBwDyL .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-IVkJla6wmTXBwDyL .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-IVkJla6wmTXBwDyL .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-IVkJla6wmTXBwDyL .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IVkJla6wmTXBwDyL .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IVkJla6wmTXBwDyL #statediagram-barbEnd{fill:#333333;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IVkJla6wmTXBwDyL .cluster-label,#mermaid-svg-IVkJla6wmTXBwDyL .nodeLabel{color:#131300;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-IVkJla6wmTXBwDyL .note-edge{stroke-dasharray:5;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-note text{fill:black;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram-note .nodeLabel{color:black;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagram .edgeLabel{color:red;}#mermaid-svg-IVkJla6wmTXBwDyL #dependencyStart,#mermaid-svg-IVkJla6wmTXBwDyL #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-IVkJla6wmTXBwDyL .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IVkJla6wmTXBwDyL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 初始/清空输入
输入触发防抖后
请求成功
请求失败
继续输入
继续输入
清空输入框
清空输入框
Idle
Loading
Success
Error

解决"串台"问题,最直接的办法是用 AbortController 取消上一个还没返回的请求。Vue 3.5 的 watch 回调支持注册清理函数(onCleanup),刚好用来做这件事:

html 复制代码
<!-- src/components/SearchBox.vue -->
<script setup>
import { ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'

const keyword = ref('')
const result = ref([])
// 状态:idle 空闲 / loading 搜索中 / success 成功 / error 失败
const status = ref('idle')

// 用 AbortController 取消上一个未完成的请求,防止旧结果覆盖新结果
let abortController = null

const search = useDebounceFn(async (q) => {
  if (!q) {
    result.value = []
    status.value = 'idle'
    return
  }
  // 取消上一个请求(如果还在飞行中)
  if (abortController) {
    abortController.abort()
  }
  abortController = new AbortController()

  status.value = 'loading'
  try {
    const res = await fetch(
      `https://api.github.com/search/repositories?q=${q}`,
      { signal: abortController.signal }
    ).then(r => r.json())
    result.value = res.items ?? []
    status.value = 'success'
  } catch (e) {
    // abort 触发的 AbortError 不算真正的错误,不展示给用户
    if (e.name !== 'AbortError') {
      status.value = 'error'
    }
  }
}, 300)

watch(keyword, (val) => search(val))
</script>

<template>
  <div class="search">
    <input v-model="keyword" placeholder="搜索 GitHub 仓库" />
    <p v-if="status === 'loading'">搜索中...</p>
    <p v-else-if="status === 'error'" style="color: #e53935;">出错了,请重试</p>
    <p v-else-if="status === 'success'">共 {{ result.length }} 条结果</p>
    <ul>
      <li v-for="item in result" :key="item.id">{{ item.full_name }}</li>
    </ul>
  </div>
</template>

<style scoped>
.search input {
  width: 320px;
  padding: 8px 12px;
  font-size: 14px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
}
.search input:focus {
  border-color: #1890ff;
  outline: none;
}
.search ul {
  margin-top: 12px;
  padding-left: 0;
  list-style: none;
}
.search li {
  padding: 6px 0;
  border-bottom: 1px solid #f0f0f0;
}
</style>

组件之间的依赖关系也很直白,一张图收尾:
#mermaid-svg-ZHcZZ4wIw68yU8CY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZHcZZ4wIw68yU8CY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZHcZZ4wIw68yU8CY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZHcZZ4wIw68yU8CY .error-icon{fill:#552222;}#mermaid-svg-ZHcZZ4wIw68yU8CY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZHcZZ4wIw68yU8CY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZHcZZ4wIw68yU8CY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZHcZZ4wIw68yU8CY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZHcZZ4wIw68yU8CY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZHcZZ4wIw68yU8CY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZHcZZ4wIw68yU8CY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZHcZZ4wIw68yU8CY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZHcZZ4wIw68yU8CY .marker.cross{stroke:#333333;}#mermaid-svg-ZHcZZ4wIw68yU8CY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZHcZZ4wIw68yU8CY p{margin:0;}#mermaid-svg-ZHcZZ4wIw68yU8CY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZHcZZ4wIw68yU8CY .cluster-label text{fill:#333;}#mermaid-svg-ZHcZZ4wIw68yU8CY .cluster-label span{color:#333;}#mermaid-svg-ZHcZZ4wIw68yU8CY .cluster-label span p{background-color:transparent;}#mermaid-svg-ZHcZZ4wIw68yU8CY .label text,#mermaid-svg-ZHcZZ4wIw68yU8CY span{fill:#333;color:#333;}#mermaid-svg-ZHcZZ4wIw68yU8CY .node rect,#mermaid-svg-ZHcZZ4wIw68yU8CY .node circle,#mermaid-svg-ZHcZZ4wIw68yU8CY .node ellipse,#mermaid-svg-ZHcZZ4wIw68yU8CY .node polygon,#mermaid-svg-ZHcZZ4wIw68yU8CY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZHcZZ4wIw68yU8CY .rough-node .label text,#mermaid-svg-ZHcZZ4wIw68yU8CY .node .label text,#mermaid-svg-ZHcZZ4wIw68yU8CY .image-shape .label,#mermaid-svg-ZHcZZ4wIw68yU8CY .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZHcZZ4wIw68yU8CY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZHcZZ4wIw68yU8CY .rough-node .label,#mermaid-svg-ZHcZZ4wIw68yU8CY .node .label,#mermaid-svg-ZHcZZ4wIw68yU8CY .image-shape .label,#mermaid-svg-ZHcZZ4wIw68yU8CY .icon-shape .label{text-align:center;}#mermaid-svg-ZHcZZ4wIw68yU8CY .node.clickable{cursor:pointer;}#mermaid-svg-ZHcZZ4wIw68yU8CY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZHcZZ4wIw68yU8CY .arrowheadPath{fill:#333333;}#mermaid-svg-ZHcZZ4wIw68yU8CY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZHcZZ4wIw68yU8CY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZHcZZ4wIw68yU8CY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZHcZZ4wIw68yU8CY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZHcZZ4wIw68yU8CY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZHcZZ4wIw68yU8CY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZHcZZ4wIw68yU8CY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZHcZZ4wIw68yU8CY .cluster text{fill:#333;}#mermaid-svg-ZHcZZ4wIw68yU8CY .cluster span{color:#333;}#mermaid-svg-ZHcZZ4wIw68yU8CY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZHcZZ4wIw68yU8CY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZHcZZ4wIw68yU8CY rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZHcZZ4wIw68yU8CY .icon-shape,#mermaid-svg-ZHcZZ4wIw68yU8CY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZHcZZ4wIw68yU8CY .icon-shape p,#mermaid-svg-ZHcZZ4wIw68yU8CY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZHcZZ4wIw68yU8CY .icon-shape .label rect,#mermaid-svg-ZHcZZ4wIw68yU8CY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZHcZZ4wIw68yU8CY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZHcZZ4wIw68yU8CY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZHcZZ4wIw68yU8CY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} App.vue
SearchBox.vue
useDebounceFn 防抖
AbortController 取消旧请求
fetch 请求
GitHub API
status 状态驱动视图

说白了 :防抖解决了"发太多次"的问题,AbortController 解决了"回来太晚"的问题。两个一起用,搜索框才算真正能上线。


精彩推荐

🔎AI大模型:从百年积淀到终极跨越

🔎大模型落地全景:普通人生活中无处不在的AI应用

🔎大模型底层逻辑:优势局限与天生短板

🔎大模型风险与规范:AI发展背后的安全与伦理问题

🔎未来已来:大模型下一代进化方向与AI终极趋势


本篇博客文章唯一版权归属©波仔椿

相关推荐
达子6662 小时前
第22章_HarmonyOs开发图解之 蓝牙
vue.js
无人生还3 小时前
从 Vue3 到 React · 快速上手系列第 9 篇:自定义 Hook(对标 Composables)
前端·vue.js·react.js
别怪我很水3 小时前
Vue element admin 浏览器本地存储 localStorage、useStorage
前端·javascript·vue.js
猫猫不是喵喵.14 小时前
Vue3 Props 属性
前端·javascript·vue.js
小罗水1 天前
第17章 后台管理端与演示支持
javascript·vue.js·ecmascript
濮水大叔1 天前
不必把 Vue3 写成“麻花”:从状态碎片到对象协作,重新理解 Zova 的前端心智模型
前端·typescript·vue3·ioc·tsx·zova
猫猫不是喵喵.1 天前
Vue3 中 computed 计算属性与 watch、watchEffect 监听
前端·javascript·vue.js
En^_^Joy1 天前
Vue项目创建与入口配置全攻略
前端·vue.js·arcgis
猫猫不是喵喵.1 天前
Vue2 的 Vuex 状态管理与 Vue3 的 Pinia 状态管理
前端·javascript·vue.js