第 14 章:组合式函数
学习目标
- 编写可复用的 composables
- 接受参数 + 返回响应式数据
- 处理异步副作用
- 避坑:命名、清理、SSR
一、最小骨架
ts
// src/composables/useCounter.ts
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const inc = () => count.value++
const dec = () => count.value--
const reset = () => (count.value = initial)
return { count, inc, dec, reset }
}vue
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
const { count, inc, reset } = useCounter(10)
</script>二、异步封装:useFetch
ts
import { ref, watchEffect, type Ref } from 'vue'
export function useFetch<T>(url: Ref<string> | string) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
const exec = async () => {
loading.value = true
error.value = null
try {
const target = typeof url === 'string' ? url : url.value
data.value = await (await fetch(target)).json()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
watchEffect(() => { exec() })
return { data, error, loading, refresh: exec }
}💡
watchEffect(() => exec())= URL 一变就自动重新请求——这一行让 useFetch 从"一次性函数"变成"响应式 hook"。三步拆解:
① 立刻执行:
watchEffect创建后第一件事就是立即执行一次回调——所以进入 useFetch 就开始请求,不用手动调。② 收集依赖:执行
exec()时会跑这一行:tsconst target = typeof url === 'string' ? url : url.value // ↑ 这里访问了 url.value如果
url是Ref<string>,watchEffect 把url.value收集成依赖。③ 依赖变化,自动重跑:
tsconst url = ref('/api/user/1') const { data, loading } = useFetch(url) // 首次:watchEffect 立即跑 → 请求 /api/user/1 url.value = '/api/user/2' // ↑ url.value 变了 → watchEffect 自动再跑 → 自动重新请求 /api/user/2不用手动
watch/watchEffect包一层,函数本身就帮你做了。如果 url 是字符串:
tsconst { data, loading } = useFetch('/api/user/1') // url 是字符串
exec()里走typeof url === 'string'分支,没访问 ref,watchEffect 没收集到任何依赖——只跑一次,以后 url 不再变化(它本来就不可变)。三种写法对比:
写法 效果 本节 watchEffecturl 是 ref 时自动响应,字符串时只跑一次(最佳兼容) watch(url, () => exec(), { immediate: true })同效果,但要传 getter / immediate 选项,代码多 只调一次 exec()url 变了不会重新请求(手动挡)
⚠️ 坑 1:
watchEffect默认会立即执行,如果你不想,传{ immediate: false }。
三、监听 + 清理:useEventListener
ts
import { onMounted, onUnmounted } from 'vue'
export function useEventListener(target: Window | HTMLElement, event: string, handler: (e: Event) => void) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler))
}ts
useEventListener(window, 'scroll', () => console.log(window.scrollY))四、节流:useThrottle
ts
import { ref, watch, onUnmounted } from 'vue'
export function useThrottle<T>(source: Ref<T>, delay = 300) {
const throttled = ref(source.value) as Ref<T>
let lastTime = 0 // ① 记录上次真正赋值的时间戳
let timer: number | undefined // ② 用于补"尾巴"的定时器
watch(source, (val) => {
const now = Date.now()
const remaining = delay - (now - lastTime) // ③ 距离下次能赋值的剩余时间
if (remaining <= 0) {
// ④ 已经过了 delay,直接赋值(高频下也保证每 delay ms 至少更新一次)
throttled.value = val
lastTime = now
} else {
// ⑤ 还没到,延时补一次"尾巴"(避免最后一次变化被吞掉)
if (timer) clearTimeout(timer)
timer = window.setTimeout(() => {
throttled.value = val
lastTime = Date.now()
}, remaining)
}
})
onUnmounted(() => { if (timer) clearTimeout(timer) })
return throttled
}💡 真节流 vs 真防抖:
节流 (throttle) 防抖 (debounce) 触发时机 每隔 delay ms 触发一次 停下 delay ms 才触发一次 持续高频时 每个 delay ms 准时触发 一直不触发,停才触发 代码关键 lastTime时间戳 + 剩余时间判断clearTimeout取消上一次实际开发 90% 用 debounce 就够了(尾部触发语义清晰);真需要 throttle 是"持续高频且必须给反馈"的场景(滚动条实时同步 / 动画帧 / 实时日志)。
典型场景对照:
场景 用啥 搜索框输入(请求搜索建议) debounce 滚动监听(scroll 位置) throttle 鼠标移动(mousemove) throttle 窗口 resize debounce(等停下) 提交按钮防双击 debounce 实时进度条 / 心跳 throttle
五、防抖:useDebounce
ts
import { ref, watch, onUnmounted } from 'vue'
export function useDebounce<T>(source: Ref<T>, delay = 300) {
const debounced = ref(source.value) as Ref<T>
let timer: number | undefined
watch(source, (val) => {
if (timer) clearTimeout(timer)
timer = window.setTimeout(() => (debounced.value = val), delay)
})
onUnmounted(() => { if (timer) clearTimeout(timer) })
return debounced
}⚠️ 坑 2:debounce 必须
clearTimeout防止组件卸载后仍在排队。
六、IntersectionObserver:useInfiniteScroll
ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useInfiniteScroll(el: Ref<HTMLElement | null>, onIntersect: () => void) {
const observer = ref<IntersectionObserver | null>(null)
onMounted(() => {
if (!el.value) return
observer.value = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) onIntersect()
})
observer.value.observe(el.value)
})
onUnmounted(() => observer.value?.disconnect())
}七、useLocalStorage
ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T>
watch(data, (v) => localStorage.setItem(key, JSON.stringify(v)), { deep: true })
return data
}⚠️ 坑 3:JSON.stringify 不能存
Date/Map/Set,需要自定义序列化。
八、SSR 注意
ts
if (typeof window !== 'undefined') {
localStorage.getItem(...)
}⚠️ 坑 4:
useXxx里访问window/document时,要包 SSR 守卫。
九、本章小结
| 函数 | 作用 |
|---|---|
useFetch | 异步数据 |
useEventListener | 统一监听 |
useDebounce / useThrottle | 频率控制 |
useInfiniteScroll | 滚动加载 |
useLocalStorage | 持久化状态 |
动手练习
- useToggle:返回
{ value, toggle, on, off } - useCopy:把文本写入剪贴板,返回
{ copy, copied } - useDarkMode:监听
prefers-color-scheme,切换 class
下一章:第 15 章:指令 →