VueUse 工具库:前端的 Hutool

1. VueUse 是什么

一句话:VueUse 是 Vue 3 生态最大的 composable 合集,200+ 个现成的 useXxx,从鼠标位置、剪贴板、本地存储到防抖节流、网络状态,基本覆盖前端日常 90% 的"我需要一个带状态的工具"需求。

Java 对照

Java Vue 生态
Apache Commons Lang / IO VueUse
Hutool VueUse(更偏"框架绑定"版本)
自己写 StringUtils.isBlank 自己写 useMouse

道理是一样的:轮子别自己造,社区精心打磨过的版本更健壮(处理了 SSR、边界情况、组件卸载时的清理)。

in4vue 已经装好package.json@vueuse/core: ^14.3.0vite.config.tsAutoImport 也配了 '@vueuse/core'所有 useXxx 在模板和 script 里都不用 import


2. 和自己写 composable 的对照:useMouse 为例

第二阶段的 composables 笔记里讲过怎么自己写 useMouse。现在看 VueUse 官方版:

<script setup lang="ts">
// 不用 import(AutoImport 已配置)
const { x, y } = useMouse()
</script>

<template>
  <p>鼠标位置:{{ x }}, {{ y }}</p>
</template>

三行代码,和自己写的那版功能一样,但 VueUse 版本额外处理了:

原则能用 VueUse 就别自己写。写 composable 是学习阶段理解原理,生产环境直接用社区版。


3. 最常用的 10 个 composable(按频率排)

3.1 useLocalStorage / useSessionStorage:自动双向同步

手写版要 watch + JSON.stringify,还要考虑初始值。VueUse 一行搞定:

// 读:有就用,没有就用默认值
// 写:改 value 自动同步到 localStorage
const theme = useLocalStorage('app-theme', 'light')

theme.value = 'dark'   // localStorage 自动更新

Java 对照:像 Spring Data 的 @Autowired 仓储——对象字段改了自动持久化。

vs Pinia + persist 插件:如果状态是全局共享的(多组件都要读),走 Pinia;如果是单组件/单功能的持久化小数据useLocalStorage 更直接。

3.2 useDark / useToggle:暗色模式一行代码

上一篇 Pinia 笔记里写的 themeStore 有十几行,VueUse 版:

const isDark = useDark()       // 自动:读 localStorage、写 html class="dark"
const toggleDark = useToggle(isDark)

配合 TailwindCSS 的 dark: 前缀,整套暗色模式搞定:

<template>
  <el-button @click="toggleDark()">
    {{ isDark ? '切亮' : '切暗' }}
  </el-button>
  <div class="bg-white dark:bg-gray-900">内容</div>
</template>

in4vue 建议:如果只是主题切换,直接用 useDark;如果要带"跟随系统"这种第三选项,才自己写 store。

3.3 useDebounceFn / useThrottleFn:防抖节流

搜索框防抖是前端最常见需求:

const keyword = ref('')

// 停止输入 300ms 后才触发
const search = useDebounceFn((q: string) => {
  noteApi.search(q)
}, 300)

watch(keyword, (v) => search(v))

节流类似:滚动监听、resize 监听,每 200ms 最多触发一次

const onScroll = useThrottleFn(() => {
  console.log(window.scrollY)
}, 200)

Java 对照:Spring 限流组件(@RateLimiter)。防抖=连续请求合并成最后一次,节流=单位时间内最多 N 次。

3.4 useClipboard:复制到剪贴板

"复制代码"按钮的标配:

<script setup lang="ts">
const { copy, copied } = useClipboard()

const code = `pnpm add @vueuse/core`
</script>

<template>
  <pre>{{ code }}</pre>
  <el-button @click="copy(code)">
    {{ copied ? '已复制' : '复制' }}
  </el-button>
</template>

copied 会在复制后短暂变 true,自动回 false——做成小反馈效果无脑。

in4vue 用场景:笔记里的代码块复制按钮。

3.5 useEventListener:永远别再写 onUnmounted(() => removeEventListener(...))

// 自动在组件卸载时 removeEventListener
useEventListener(window, 'resize', () => {
  console.log('窗口变化')
})

useEventListener(document, 'keydown', (e) => {
  if (e.key === 'Escape') closeModal()
})

对比手写

// ❌ 易漏清理导致内存泄漏
onMounted(() => window.addEventListener('resize', handler))
onBeforeUnmount(() => window.removeEventListener('resize', handler))   // 忘写就漏

// ✅ VueUse 版,自动管理生命周期
useEventListener(window, 'resize', handler)

凡是 addEventListener,都换成 useEventListener,这是硬规矩。

3.6 useElementSize / useWindowSize:响应式尺寸

<script setup lang="ts">
const { width, height } = useWindowSize()
const isMobile = computed(() => width.value < 768)
</script>

<template>
  <div v-if="isMobile">手机视图</div>
  <div v-else>桌面视图</div>
</template>

useElementSize 观察某个元素的尺寸:

const container = ref<HTMLDivElement>()
const { width } = useElementSize(container)

底层ResizeObserver——浏览器原生 API,但手动用要自己管生命周期。

3.7 useIntersectionObserver:懒加载 / 无限滚动

"图片进入视口再加载"、"滚到底加载下一页":

<script setup lang="ts">
const trigger = ref<HTMLDivElement>()

useIntersectionObserver(trigger, ([entry]) => {
  if (entry.isIntersecting) loadMore()
})
</script>

<template>
  <div v-for="item in list" :key="item.id">...</div>
  <div ref="trigger">加载更多触发点</div>
</template>

比监听滚动位置再算算算高效多了——浏览器原生支持,性能好。

3.8 useFetch:带状态的数据请求

VueUse 自己也有 useFetch,但——项目里有 Axios 封装,用自己的就行。VueUse 的 useFetch 用于没有后端请求层封装的快速原型,真实项目还是 useAsyncState 更合适:

const { state, isReady, isLoading, execute } = useAsyncState(
  () => noteApi.list(),
  [],                     // 初始值
  { immediate: true },    // 挂载时自动执行
)

和 Pinia 对比:单页面用 useAsyncState,跨页面共享缓存用 Pinia store。

3.9 onClickOutside:点击弹层外部关闭

下拉菜单、自定义 Popover 的刚需:

<script setup lang="ts">
const dropdown = ref<HTMLDivElement>()
const open = ref(false)

onClickOutside(dropdown, () => {
  open.value = false
})
</script>

<template>
  <div ref="dropdown">
    <button @click="open = !open">菜单</button>
    <ul v-if="open">
      <li>选项 1</li>
      <li>选项 2</li>
    </ul>
  </div>
</template>

Element Plus 组件自带这能力,但自己写一个弹层时别漏了。

3.10 useTitle:改浏览器标签页标题

const title = useTitle('in4vue')
title.value = '笔记详情 - in4vue'

路由切换时改标题,SEO 基础操作。结合 useRoute

const route = useRoute()
watch(() => route.meta.title, (t) => {
  if (t) useTitle(`${t} - in4vue`)
}, { immediate: true })

4. 进阶:组合多个 VueUse

VueUse 设计上 composable 可以互相组合。举个例子:滚动到底部自动加载更多

<script setup lang="ts">
import { ref, watch } from 'vue'

const list = ref<Note[]>([])
const page = ref(1)
const loading = ref(false)

async function loadMore() {
  if (loading.value) return
  loading.value = true
  const data = await noteApi.list({ page: page.value })
  list.value.push(...data.list)
  page.value++
  loading.value = false
}

// 监听窗口滚动
const { arrivedState } = useScroll(window)

// 节流一下,别疯狂触发
const throttledLoad = useThrottleFn(loadMore, 500)

watch(() => arrivedState.bottom, (hit) => {
  if (hit) throttledLoad()
})
</script>

三个 composable 组合:useScroll(监听滚动状态) + useThrottleFn(节流) + 自己的业务逻辑。这就是 Composition API 的威力——乐高式拼装


5. 分包:@vueuse/core vs 其他

VueUse 不止 @vueuse/core

内容 何时装
@vueuse/core 核心 200+ 通用 composable 一开始就装(已装)
@vueuse/router 和 vue-router 集成(如 useRouteQuery) 需要路由查询参数响应式
@vueuse/integrations 第三方库封装(axios / focus-trap / jwt-decode / qrcode 等) 用到对应库再装
@vueuse/components 一些 composable 的组件版本 喜欢用组件语法再装

按需装,别上来就装全套。

举个 useRouteQuery 的例子(路由查询参数双向绑定)

原本同步路由 query 很烦:

// 手动写
const route = useRoute()
const router = useRouter()
const keyword = ref(route.query.q as string ?? '')
watch(keyword, (v) => {
  router.replace({ query: { ...route.query, q: v } })
})
watch(() => route.query.q, (v) => {
  keyword.value = v as string ?? ''
})

@vueuse/router

const keyword = useRouteQuery('q', '')
// 改 keyword.value,URL 自动变;URL 变(如点浏览器后退),keyword 自动同步

一行顶十行。


6. 性能:VueUse 有开销吗

很多人担心"引一堆 composable 会不会变慢"。答案是:

真实开销:用 10 个 VueUse composable ≈ 打包体积 +2~5KB gzipped,可以忽略。

反而更省:你不会写 setInterval 轮询尺寸、忘记 removeEventListener,长期运行性能反而更好


7. 在 in4vue 里会用到的 VueUse 清单

按实际需求排:

场景 composable
暗色模式 useDark + useToggle
代码块复制按钮 useClipboard
搜索笔记防抖 useDebounceFn
响应式布局判断(手机/桌面) useWindowSize
浏览器标签页标题 useTitle
AI 对话框点外部关闭 onClickOutside
笔记列表懒加载图片 useIntersectionObserver
滚动到底加载更多 useScroll
编辑器快捷键(Cmd+S 保存) useMagicKeys
网络断线提示 useOnline

建议:遇到需求时先去 VueUse 文档 搜关键词,90% 能找到现成的。


8. 常见坑

坑 1:storeToRefs 和 VueUse 的 toRefs 别混

都叫 toRefs,但场景不同。VueUse 返回的 composable 结果本来就是 ref直接解构不用 toRefs

const { x, y } = useMouse()   // ✅ x/y 都是 ref,直接用

坑 2:useLocalStorage 存对象,别忘默认值是新建的

// ❌ 多个组件调用,共享同一个默认对象,其中一个改了全污染
const user = useLocalStorage('user', { name: '', email: '' })

// ✅ 用函数返回,每次拿到新对象
const user = useLocalStorage('user', () => ({ name: '', email: '' }))

这个坑和 reactive 初始值的陷阱一模一样。

坑 3:AutoImport 没生效时手动 import

偶尔 IDE 标红但代码能跑——AutoImport 是 Vite 插件,IDE 类型声明文件需要构建一次才生成

pnpm dev   # 或 pnpm build,跑一次生成 src/types/auto-imports.d.ts

实在不行就手动 import { useMouse } from '@vueuse/core',运行时没差。

坑 4:SSR 环境慎用会访问 window 的 composable

VueUse 大部分都做了 SSR guard,但如果你的项目跑 Nuxt / SSR,个别 composable(如 useWindowSize)初始值在服务端会是 0。in4vue 是纯 SPA,没这问题。


9. 源码阅读建议

VueUse 是学写高质量 composable 的最佳教材。

推荐从这几个入手(由浅入深):

  1. useToggle:10 行,理解 composable 返回值约定
  2. useMouse:30 行,理解事件监听 + 生命周期管理
  3. useFetch:复杂 composable 的拆分模式
  4. useStorage:处理各种边界情况(JSON 错误、旧值迁移)

阅读路径:GitHub vueuse/vueusepackages/core/useXxx/index.ts。每个都配有 demo.vueindex.md,对着跑。


速查表

// 鼠标 / 窗口 / 元素
const { x, y } = useMouse()
const { width, height } = useWindowSize()
const { width } = useElementSize(elRef)

// 持久化 / 主题
const theme = useLocalStorage('key', defaultValue)
const isDark = useDark()
const toggle = useToggle(isDark)

// 函数包装
const debouncedFn = useDebounceFn(fn, 300)
const throttledFn = useThrottleFn(fn, 200)

// 浏览器能力
const { copy, copied } = useClipboard()
const title = useTitle('初始标题')
const online = useOnline()

// 事件 / 交互
useEventListener(window, 'resize', handler)
onClickOutside(elRef, () => closeIt())

// 观察器
useIntersectionObserver(elRef, ([e]) => { if (e.isIntersecting) ... })
useResizeObserver(elRef, (entries) => {...})

// 异步
const { state, isLoading, execute } = useAsyncState(fetcher, initial)

小练习

  1. 把上一篇 Pinia 笔记里的 useThemeStore 改用 useDark + useToggle 重写,对比代码量
  2. MarkdownRenderer.vue 的代码块加 "复制" 按钮(useClipboard
  3. HomePage.vue 的搜索输入框上加 useDebounceFn(暂时不接真实搜索,console.log(q) 就行)
  4. useTitle + useRoute 做路由切换时的标题同步
  5. 浏览一遍 VueUse 函数列表,挑 3 个你觉得有意思的读源码

做完后,你就习惯了"先查 VueUse 再考虑自己写"的前端日常工作流。


延伸阅读