第5章 5.5 自定义 Hooks(composables):把「重复代码」变成「乐高积木」
🎯 开场:为什么你的代码越写越乱?
上一章我们学会了用 Vue 的过渡动画让界面动起来,那种「轻轻一加就变酷」的感觉是不是很爽?
但问题来了——
你有没有遇到过这种情况:写了一个带动画的下拉菜单,过了两天又要一个带动画的弹窗,再过两天产品说「再加一个带动画的抽屉」……
结果你发现:
- 代码copy了三份,改一个bug要改三处
- 别人看你的代码满脑子问号
- 三个月后自己看自己的代码也想问「这谁写的」
这就是没有抽象复用的代价。
这一章我们要学的「自定义 Hooks」(也叫 composables),就是来解决这个问题的。学完你能:
- 把「某段逻辑」抽成一个可复用的积木块
- 在不同地方反复用,但只维护一份代码
- 跟同事吹牛时说「我这是 Hook 模式,封装度高」
说白了:Hooks 就是把「散落的工具」变成「工具箱」,随用随取。
🧱 基础:什么是 Hooks?\n\n
\n\n
\n\n用点菜来理解
3 分钟弄懂核心概念
Hooks 是什么?
一句话:一段有名字的、可以复用的逻辑。
用点菜举个例子:
❌ 没有 Hooks:
你去餐厅,每次都说「要一碗米饭,加两勺菜,拌一下,放点酱油...」
(每次都重复描述整个过程)
✅ 有 Hooks:
你说「来一份宫保鸡丁盖饭」
(一个名字,封装好了整个流程)
Vue 里的 useMouse() 就是「宫保鸡丁盖饭」——它封装了「监听鼠标位置」的所有逻辑,你只要调用就能用。
为什么叫 "use" 前缀?
这是一个社区约定,就像给函数加 get_ 或 _calculate 前缀一样。use 开头的函数表示「这是一个 Hook」。
10 分钟写第一个 Hook
我们用 Vue3 Composition API 来写。先写一个最简单的:useCounter——一个能「计数」的小工具。
<script setup>
import { ref } from 'vue'
// 🪝 这就是我们的第一个 Hook!
function useCounter() {
const count = ref(0)
function increment() {
count.value++
}
function decrement() {
count.value--
}
// 把需要暴露的东西返回出去
return {
count,
increment,
decrement
}
}
// 用它!
const { count, increment, decrement } = useCounter()
</script>
<template>
<button @click="decrement">-</button>
<span>{{ count }}</span>
<button @click="increment">+</button>
</template>
运行效果:点 - 就减,点 + 就加。
这四行在干嘛:
1. ref(0) —— 创建一个「盒子」,里面装数字 0
2. increment() —— 每次调用,盒子里的数字 +1
3. decrement() —— 每次调用,盒子里的数字 -1
4. return { ... } —— 把这些东西暴露给外面用
注意!Hooks 必须 return 东西,否则外面用不了里面的「盒子」。
7 分钟:两个真实场景 Hook
场景 1:监听鼠标位置 useMouse
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
function useMouse() {
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.clientX
y.value = e.clientY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
const { x, y } = useMouse()
</script>
<template>
<p>鼠标位置:{{ x }}, {{ y }}</p>
</template>
代码解释:
- onMounted —— 等页面加载完再监听(不然会报错)
- onUnmounted —— 页面切换时要把监听去掉,不然内存泄漏
- addEventListener / removeEventListener —— 配套使用,成双成对
场景 2:读写浏览器本地存储 useLocalStorage
<script setup>
import { ref, watch } from 'vue'
function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored ? JSON.parse(stored) : defaultValue)
// 每次 data 变化,自动同步到浏览器
watch(data, (newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
}, { deep: true }) // deep: true 是为了监听对象内部变化
return data
}
// 用法:存一个「主题设置」
const theme = useLocalStorage('theme', 'light')
</script>
5 分钟:为什么要用 Hooks?
好处说三个最实在的:
| 不用 Hooks | 用 Hooks |
|---|---|
| 代码 copy 三份,改一处要改三处 | 一份代码,到处引用 |
| 逻辑散落各处,维护靠人品 | 逻辑集中,bug 好找 |
| 别人看代码像读天书 | Hook 名就是注释,「宫保鸡丁」 |
🔥 实战:三个项目递进练手
项目 1(5 分钟):计数器全家桶
需求:页面同时显示「商品数量」和「访问量」,各管各的。
完整可运行代码:
<script setup>
import { ref } from 'vue'
// 🎯 项目 1: 计数器 Hook
function useCounter() {
const count = ref(0)
function add() { count.value++ }
function sub() { count.value-- }
function reset() { count.value = 0 }
return { count, add, sub, reset }
}
// 实例 1:购物车商品数量
const { count: goodsCount, add: addGoods, sub: subGoods } = useCounter()
// 实例 2:页面访问量
const { count: visitCount, add: addVisit } = useCounter()
</script>
<template>
<h3>购物车</h3>
<button @click="subGoods">-</button>
<span>{{ goodsCount }}</span>
<button @click="addGoods">+</button>
<h3>访问量</h3>
<button @click="addVisit">+1 访问</button>
<span>{{ visitCount }}</span>
</template>
预期输出: 两个计数器独立运作,互不影响。
一句话解释: 每次调用 useCounter() 就像开了一个新店铺,各赚各的钱。
项目 2(15 分钟):从 JSON 文件读数据并筛选
需求:读取一份「学生成绩 JSON」,显示总分前三名。
数据文件 students.json:
[
{"name": "张三", "math": 85, "english": 92},
{"name": "李四", "math": 90, "english": 78},
{"name": "王五", "math": 72, "english": 88},
{"name": "赵六", "math": 95, "english": 85}
]
完整可运行代码:
<script setup>
import { ref, computed } from 'vue'
import studentsData from './students.json'
// 🎯 项目 2: 数据筛选 Hook
function useStudentFilter(students) {
const sortKey = ref('total')
const topN = ref(3)
// 计算总分
const withTotal = computed(() =>
students.map(s => ({
...s,
total: s.math + s.english
}))
)
// 排序后取前 N 名
const topStudents = computed(() =>
[...withTotal.value]
.sort((a, b) => b[sortKey.value] - a[sortKey.value])
.slice(0, topN.value)
)
return {
sortKey,
topN,
topStudents
}
}
const { sortKey, topN, topStudents } = useStudentFilter(studentsData)
</script>
<template>
<h3>总分前 {{ topN }} 名</h3>
<ul>
<li v-for="s in topStudents" :key="s.name">
{{ s.name }}:数学{{ s.math }},英语{{ s.english }},总分{{ s.total }}
</li>
</ul>
</template>
预期输出:
1. 赵六:数学95,英语85,总分180
2. 张三:数学85,英语92,总分177
3. 李四:数学90,英语78,总分168
一句话解释: computed 像个「实时计算器」,原始数据一变,它就算出新的结果。
项目 3(15 分钟):做个待办清单小工具
需求:增删查待办事项,数据自动存到 localStorage,刷新不丢。
完整可运行代码:
<script setup>
import { ref, watch } from 'vue'
// 🎯 项目 3: 待办事项 Hook
function useTodos() {
const STORAGE_KEY = 'my_todos'
// 读取本地存储
const raw = localStorage.getItem(STORAGE_KEY)
const todos = ref(raw ? JSON.parse(raw) : [])
// 变化时自动保存
watch(todos, (newVal) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newVal))
}, { deep: true })
// 添加
function addTodo(text) {
todos.value.push({
id: Date.now(),
text,
done: false
})
}
// 删除
function delTodo(id) {
const idx = todos.value.findIndex(t => t.id === id)
if (idx !== -1) todos.value.splice(idx, 1)
}
// 切换完成状态
function toggleTodo(id) {
const t = todos.value.find(t => t.id === id)
if (t) t.done = !t.done
}
return { todos, addTodo, delTodo, toggleTodo }
}
const { todos, addTodo, delTodo, toggleTodo } = useTodos()
let newText = ref('')
</script>
<template>
<h3>我的待办</h3>
<input v-model="newText" @keyup.enter="addTodo(newText); newText = ''" placeholder="写点啥..." />
<button @click="addTodo(newText); newText = ''">添加</button>
<ul>
<li v-for="t in todos" :key="t.id">
<input type="checkbox" :checked="t.done" @change="toggleTodo(t.id)" />
<span :style="{ textDecoration: t.done ? 'line-through' : 'none' }">{{ t.text }}</span>
<button @click="delTodo(t.id)">删除</button>
</li>
</ul>
<p>共 {{ todos.length }} 条,未完成 {{ todos.filter(t => !t.done).length }} 条</p>
</template>
预期输出: 添加/删除/勾选都生效,刷新后数据还在。
一句话解释: watch 像个小监工,todos 一有动静,它就立刻把新数据备份到 localStorage。
💪 进阶:5 个新人必踩的坑
坑 1:忘了 return,外面访问不到
// ❌ 错误示例
function useBad() {
const count = ref(0)
// 忘了 return
}
// ✅ 正确示例
function useGood() {
const count = ref(0)
return { count } // 必须 return!
}
解释: Hook 里的变量就像「私房钱」,不 return 就只能自己花,别人用不了。
坑 2:在 return 外面调用函数
// ❌ 错误示例
function useBad() {
const count = ref(0)
function add() { count.value++ }
add() // 这里调用了,但 count 还是 0!
return { count } // count 永远是 0
}
// ✅ 正确示例
function useGood() {
const count = ref(0)
function add() { count.value++ }
return { count, add } // 把 add 也暴露出去
}
// 使用时
const { count, add } = useGood()
add() // 这样 count 才会变成 1
解释: return 里面的东西才能跟外部「联动」,外面的调用是无效的。
坑 3:忘记清理副作用
// ❌ 错误示例
function useMouse() {
const pos = ref({ x: 0, y: 0 })
onMounted(() => {
window.addEventListener('mousemove', (e) => {
pos.value = { x: e.clientX, y: e.clientY }
})
})
// ❌ 没写 onUnmounted,监听永远在!
return { pos }
}
// ✅ 正确示例
function useMouse() {
const pos = ref({ x: 0, y: 0 })
onMounted(() => {
window.addEventListener('mousemove', handler)
})
onUnmounted(() => {
window.removeEventListener('mousemove', handler) // 清理掉!
})
return { pos }
}
解释: 监听不清理,就像租房子不退押金——迟早出问题(内存泄漏)。
坑 4:用 ref 还是 reactive 傻傻分不清
// ❌ 错误示例:用 reactive 返回基本类型
function useBad() {
const count = reactive(0) // ❌ reactive 只能用于对象/数组
return { count }
}
// ✅ 正确示例
function useGood() {
const count = ref(0) // ✅ 基本类型用 ref
const user = reactive({ name: '小明', age: 18 }) // ✅ 对象用 reactive
return { count, user }
}
记忆口诀:「基本类型用 ref,对象数组用 reactive」。
坑 5:对象解构丢失响应式
// ❌ 错误示例
function useUser() {
const user = reactive({ name: '小明', age: 18 })
const name = user.name // ❌ 这是一个普通值,不是响应式的!
return { user } // ❌ 只返回 user 就行,别单独抽
}
// ✅ 正确示例
function useUser() {
const user = reactive({ name: '小明', age: 18 })
return { user } // ✅ 返回整个对象
}
// 使用时如果要单独用 name
const { user } = useUser()
const name = toRef(user, 'name') // ✅ 用 toRef 转成 ref
解释: reactive 对象里的属性,单独抽出来就「断联」了,要用 toRef 才能保持连接。
调试技巧:console.log 的正确姿势
function useCounter() {
const count = ref(0)
function add() {
count.value++
console.log('【调试】count 变了,现在是:', count.value) // 加个标签好找
}
return { count, add }
}
进阶调试:用 Vue DevTools
- 浏览器装 Vue DevTools 扩展
- 打开开发者工具 → Vue 标签
- 选中组件,直接看 data 里的值,所见即所得
✏️ 练习题
练习 1(2 分钟):抄改计数器
- 输入:把
useCounter的初始值从 0 改成 10 - 预期输出:页面加载时显示 10,点
-变成 9 - 提示:
ref()里面写初始值
练习 2(2 分钟):加个判断
- 输入:在
useCounter的add函数里,加一个判断:count < 99 才能加 - 预期输出:count 到 99 后,再点
+没反应 - 提示:用
if (count.value < 99)包住count.value++
练习 3(3 分钟):换个数据源
- 输入:把项目 2 的数据换成你自己的 5 个同学成绩
- 预期输出:显示你这 5 个人的前三名
- 提示:改
students.json文件即可
练习 4(5 分钟):串起来
- 输入:把项目 2 的「筛选前三名」功能,加到项目 3 的待办清单里——只显示「未完成」的前 3 条
- 预期输出:待办清单只显示前 3 条未完成的
- 提示:在
topStudents的逻辑基础上,加个filter(t => !t.done)
练习 5(5 分钟):看图找错
- 输入:以下代码运行时报错「Cannot read property 'value' of undefined」
- 预期输出:修复代码,让它正常运行
- 提示:
onMounted里用到的东西,要确保在函数作用域内定义
function useMouse() {
onMounted(() => {
window.addEventListener('mousemove', (e) => {
pos.value = { x: e.clientX, y: e.clientY } // pos 在哪?
})
})
return { pos } // pos 从哪来?
}
作业:做一个「习惯追踪器」
需求描述:
做一个「每日习惯打卡」小工具,可以添加习惯、每天打卡、查看连续打卡天数。
功能点:
1. 添加新习惯(如「每天喝八杯水」「睡前看书」)
2. 点击习惯名称进行打卡(今天打过了就不能再打)
3. 显示每个习惯的「连续打卡天数」和「最近打卡日期」
加分项:
1. 数据存到 localStorage,刷新不丢
2. 打卡记录带时间戳
验收标准:
- 能跑起来
- 添加习惯后刷新页面还在
- 打卡后连续天数正确累计
提交方式: 评论区贴代码或 GitHub 链接
📚 总结
这一章学了 3 件事:
- Hooks 就是「可复用的逻辑块」——把散落的代码封装成一个名字,随用随取
- 用
return暴露东西——Hook 里的变量不 return 外面用不了 - 配套使用要成双——
addEventListener要配removeEventListener,onMounted要配onUnmounted
延伸资源:
- Vue3 官方文档 - Composables(英文,但例子很清晰)
- VueUse 库——社区最强 Hook 库,100+ 个现成的 Hook
- 《Vue.js 设计与实现》——侯VELOPER 著,适合想深入理解原理的同学
互动钩子:
你有没有写过一段「总觉得在哪见过」的代码?是用 Hook 抽出来的,还是 copy 了 N 份?评论区聊聊,复用做得好的人老粉优先回复!
下一章我们要解决一个问题:JavaScript 是个「弱类型」语言,写着写着变量就变成鬼——字符串、数字、对象乱成一锅粥。下一章「TypeScript + Vue3」教你怎么给代码加「类型锁」,让 Bug 在写的时候就暴露,而不是上线之后才发现。

评论(0)