WSの小屋

VueUse 实战:用好组合式工具函数提升开发效率

VueUse 是 Vue 3 生态中最大的工具函数集,200+ 个组合式函数覆盖了状态管理、网络请求、动画、设备检测等几乎所有场景。但很多人只用了 useDarkuseStorage。本文从实际项目出发,挑选真正高频、高价值的工具函数深入讲解。

状态与持久化

useStorage:响应式的 localStorage

import { useStorage } from '@vueuse/core'

// 基本用法:自动同步到 localStorage
const theme = useStorage('theme', 'light')

// 支持 sessionStorage、自定义 serializer
const token = useStorage('token', '', sessionStorage)

// 对象类型自动序列化
const user = useStorage('user', { name: '', avatar: '' })
user.value.name = 'John'  // 自动写入 localStorage

实战技巧:跨标签页同步

// useStorage 默认监听 storage 事件,多标签页自动同步
const cart = useStorage('cart', [])
// 在标签页 A 修改 cart,标签页 B 的 cart 自动更新

useDark / useColorMode:主题切换

import { useDark, useToggle } from '@vueuse/core'

const isDark = useDark({
  selector: 'html',
  attribute: 'class',
  valueDark: 'dark',
  valueLight: ''
})
const toggleDark = useToggle(isDark)

useColorMode 更灵活,支持多主题:

import { useColorMode } from '@vueuse/core'

const mode = useColorMode({
  modes: {
    light: '',
    dark: 'dark',
    sepia: 'sepia',
    nord: 'nord'
  }
})
// mode.value: 'light' | 'dark' | 'sepia' | 'nord'
// 自动持久化,自动切换 class

useLocalStorage 的进阶:useAsyncState

import { useAsyncState } from '@vueuse/core'

const { state, isLoading, error, execute } = useAsyncState(
  () => fetch('/api/users').then(r => r.json()),
  [],  // 初始值
  { delay: 500 }  // 延迟显示 loading(防闪烁)
)

// 手动刷新
execute()

网络请求层

useFetch:类型安全的请求封装

import { useFetch } from '@vueuse/core'

const { data, error, isFetching, abort } = useFetch('/api/users', {
  immediate: false  // 手动触发
}).json<User[]>()

// 手动触发
execute()

// 取消请求
abort()

实战:带防抖的搜索

import { useFetch, useDebounce } from '@vueuse/core'

const keyword = ref('')
const debounced = useDebounce(keyword, 300)

const { data } = useFetch(() => `/api/search?q=${debounced.value}`, {
  refetch: true  // debounced 变化自动重新请求
}).json<SearchResult[]>()

useEventSource:SSE 流式数据

import { useEventSource } from '@vueuse/core'

const { data, status, close } = useEventSource('/api/stream')
// data 自动更新为最新收到的事件
// status: 'connecting' | 'open' | 'closed'

DOM 与交互

useIntersectionObserver:懒加载和无限滚动

import { useIntersectionObserver } from '@vueuse/core'

// 图片懒加载
const imgRef = ref<HTMLImageElement>()
const isVisible = ref(false)

useIntersectionObserver(
  imgRef,
  ([{ isIntersecting }]) => {
    if (isIntersecting) {
      isVisible.value = true
    }
  },
  { threshold: 0.1 }
)
<template>
  <img ref="imgRef" :src="isVisible ? src : placeholder" />
</template>

实战:无限滚动

import { useIntersectionObserver } from '@vueuse/core'

const sentinelRef = ref<HTMLElement>()
const list = ref<Item[]>([])
const page = ref(1)
const hasMore = ref(true)

useIntersectionObserver(sentinelRef, async ([{ isIntersecting }]) => {
  if (!isIntersecting || !hasMore.value) return

  const res = await fetch(`/api/items?page=${page.value}`)
  const items = await res.json()

  if (items.length === 0) {
    hasMore.value = false
    return
  }

  list.value.push(...items)
  page.value++
})
<template>
  <div v-for="item in list" :key="item.id">{{ item.name }}</div>
  <div ref="sentinelRef">
    <span v-if="hasMore">加载中...</span>
    <span v-else>没有更多了</span>
  </div>
</template>

useEventListener:自动清理的事件监听

import { useEventListener } from '@vueuse/core'

// 在组件卸载时自动移除监听
useEventListener(window, 'scroll', handleScroll)
useEventListener(document, 'keydown', (e) => {
  if (e.key === 'Escape') closeModal()
})

// 也可以监听 ref
const buttonRef = ref<HTMLButtonElement>()
useEventListener(buttonRef, 'click', handleClick)

useMouse / useMouseInElement:鼠标追踪

import { useMouse, useMouseInElement } from '@vueuse/core'

// 全局鼠标位置
const { x, y, sourceType } = useMouse()
// sourceType: 'mouse' | 'touch' | 'pen'

// 相对于某个元素的鼠标位置
const targetRef = ref<HTMLElement>()
const { elementX, elementY, isOutside } = useMouseInElement(targetRef)

实战:卡片悬浮光效

<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'

const cardRef = ref<HTMLElement>()
const { elementX, elementY, isOutside } = useMouseInElement(cardRef)

const spotlightStyle = computed(() => ({
  opacity: isOutside.value ? 0 : 1,
  background: `radial-gradient(240px at ${elementX.value}px ${elementY.value}px, rgba(255,255,255,0.15), transparent 40%)`
}))
</script>

<template>
  <div ref="cardRef" class="card">
    <div class="spotlight" :style="spotlightStyle" />
    <slot />
  </div>
</template>

感知与设备检测

useMediaQuery:响应式断点

import { useMediaQuery } from '@vueuse/core'

const isMobile = useMediaQuery('(max-width: 768px)')
// isMobile 是 ref<boolean>,窗口变化时自动更新

const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)')

实战:响应式组件

<script setup>
import { useMediaQuery } from '@vueuse/core'
import { useBreakpoints } from '@vueuse/core'

const breakpoints = useBreakpoints({
  mobile: 0,
  tablet: 768,
  desktop: 1024,
  wide: 1440
})

const device = breakpoints.active()  // 'mobile' | 'tablet' | 'desktop' | 'wide'

// 根据 device 渲染不同布局
const cols = computed(() => {
  switch (device.value) {
    case 'mobile': return 1
    case 'tablet': return 2
    case 'desktop': return 3
    case 'wide': return 4
  }
})
</script>

usePreferredDark / useNetwork

import { usePreferredDark, useNetwork } from '@vueuse/core'

const prefersDark = usePreferredDark()  // ref<boolean>

const { isOnline, type, saveData } = useNetwork()
// isOnline: ref<boolean>
// type: 'wifi' | 'cellular' | 'ethernet' | ...
// saveData: 用户开启了省流模式

// 省流模式下不加图片动画
watch(saveData, (save) => {
  if (save) disableAnimations()
})

动画与过渡

useTransition:平滑数值动画

import { useTransition } from '@vueuse/core'

const rawValue = ref(0)
const animatedValue = useTransition(rawValue, {
  duration: 800,
  transition: TransitionPresets.easeOutCubic
})

// 改变 rawValue,animatedValue 会平滑过渡
rawValue.value = 100

实战:数字滚动效果

<script setup>
import { useTransition, TransitionPresets } from '@vueuse/core'

const props = defineProps<{ value: number }>()
const source = ref(0)
watch(() => props.value, (v) => source.value = v)

const animated = useTransition(source, {
  duration: 600,
  transition: TransitionPresets.easeOutCubic
})

const display = computed(() => Math.round(animated.value).toLocaleString())
</script>

<template>
  <span>{{ display }}</span>
</template>

useIntervalFn / useTimeoutFn:定时器管理

import { useIntervalFn, useTimeoutFn } from '@vueuse/core'

// 倒计时验证码
const countdown = ref(0)
const { pause, resume } = useIntervalFn(() => {
  countdown.value--
  if (countdown.value <= 0) pause()
}, 1000, { immediateCallback: false })

function sendCode() {
  countdown.value = 60
  resume()
}

// 自动取消的 timeout
const { start, stop } = useTimeoutFn(() => {
  showTooltip.value = false
}, 3000)

逻辑复用模式

组合多个 composable

// 封装一个完整的搜索逻辑
function useSearch() {
  const keyword = ref('')
  const debounced = useDebounce(keyword, 300)

  const { state: results, isLoading } = useAsyncState(
    () => fetch(`/api/search?q=${debounced.value}`).then(r => r.json()),
    [],
    { watch: [debounced] }  // debounced 变化重新执行
  )

  return { keyword, results, isLoading }
}

在 Nuxt 中自动导入

Nuxt 已经内置了 VueUse,无需手动 import:

<script setup>
// useStorage, useDark 等直接可用
const theme = useStorage('theme', 'light')
const { x, y } = useMouse()
</script>

非 Nuxt 项目需要在 main.ts 中安装插件:

import { createApp } from 'vue'
import VueUse from '@vueuse/core'
import App from './App.vue'

// VueUse 不需要 app.use(),直接 import 使用即可
// 如果需要自动导入,使用 unplugin-auto-import
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import { VueUseAutoImport } from '@vueuse/auto-import'

export default {
  plugins: [
    AutoImport({
      imports: [VueUseAutoImport]
    })
  ]
}

性能优化类工具

useDebounceFn / useThrottleFn

import { useDebounceFn, useThrottleFn } from '@vueuse/core'

// 防抖:停止输入 300ms 后才执行
const search = useDebounceFn((q: string) => {
  fetch(`/api/search?q=${q}`)
}, 300)

// 节流:每 300ms 最多执行一次(滚动场景)
const onScroll = useThrottleFn(() => {
  console.log(window.scrollY)
}, 300)

useMemoize:函数结果缓存

import { useMemoize } from '@vueuse/core'

const expensiveCalc = useMemoize((n: number) => {
  console.log('calculating...', n)
  return heavyComputation(n)
})

expensiveCalc(100)  // calculating... 100
expensiveCalc(100)  // 缓存命中,不执行计算

总结

VueUse 的价值不只是「少写几行代码」,更在于经过社区验证、处理了边界情况(SSR 兼容、生命周期自动清理、内存泄漏防护)。

当你在项目中反复写 addEventListener + removeEventListenersetInterval + clearIntervallocalStorage.getItem + JSON.parse 时,先查一下 VueUse 有没有对应的 composable——大概率有,而且比你自己写的更健壮。

建议把 VueUse 文档加到书签,开发中遇到「需要监听什么」「需要缓存什么」「需要防抖节流」的场景,先去 VueUse 搜一下。

Comments | 4条评论