Skip to content
第 14 章 前端 ⏱ 13 分钟阅读

第 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() 时会跑这一行:

ts
const target = typeof url === 'string' ? url : url.value
                                       // ↑ 这里访问了 url.value

如果 url 是 Ref<string>,watchEffect 把 url.value 收集成依赖。

③ 依赖变化,自动重跑:

ts
const 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 是字符串:

ts
const { 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
窗口 resizedebounce(等停下)
提交按钮防双击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持久化状态

动手练习 ​

  1. useToggle:返回 { value, toggle, on, off }
  2. useCopy:把文本写入剪贴板,返回 { copy, copied }
  3. useDarkMode:监听 prefers-color-scheme,切换 class

下一章:第 15 章:指令 →

本站基于 VitePress 构建 · 由 StackHub 团队维护