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.0,vite.config.ts 里 AutoImport 也配了 '@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 版本额外处理了:
- SSR 环境下
window不存在 - 支持
touch事件(手机上也能用) - 组件卸载时自动解绑监听
- TS 类型完善
原则:能用 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 会不会变慢"。答案是:
- Tree-shaking 友好:没用到的 composable 不会打进包
- 用到的 composable 代码量通常很小(几十到几百字节)
- 底层大多基于浏览器原生 API(ResizeObserver、IntersectionObserver),比手写
setInterval轮询高效
真实开销:用 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 别混
- Pinia 的
storeToRefs(store):给 store 的 state / getters 保持响应式解构 - Vue 的
toRefs(reactive对象):给 reactive 对象解构保持响应式
都叫 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 的最佳教材。
推荐从这几个入手(由浅入深):
useToggle:10 行,理解 composable 返回值约定useMouse:30 行,理解事件监听 + 生命周期管理useFetch:复杂 composable 的拆分模式useStorage:处理各种边界情况(JSON 错误、旧值迁移)
阅读路径:GitHub vueuse/vueuse → packages/core/useXxx/index.ts。每个都配有 demo.vue 和 index.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)
小练习
- 把上一篇 Pinia 笔记里的
useThemeStore改用useDark+useToggle重写,对比代码量 - 给
MarkdownRenderer.vue的代码块加 "复制" 按钮(useClipboard) - 在
HomePage.vue的搜索输入框上加useDebounceFn(暂时不接真实搜索,console.log(q)就行) - 用
useTitle+useRoute做路由切换时的标题同步 - 浏览一遍 VueUse 函数列表,挑 3 个你觉得有意思的读源码
做完后,你就习惯了"先查 VueUse 再考虑自己写"的前端日常工作流。
延伸阅读
- VueUse 官网
- 函数列表(按分类)
- VueUse GitHub(源码即文档)
- Anthony Fu 的博客(VueUse 作者,前端生态主要贡献者)
- composable 设计准则(官方给出的"怎么写好 composable")