博主介绍
👨💻 了解博主:波仔椿
📖 人生箴言:组件拆得够小,页面才装得下变化。
🧰 我的专栏: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解决了"回来太晚"的问题。两个一起用,搜索框才算真正能上线。
精彩推荐
本篇博客文章唯一版权归属©波仔椿