第5章 5.5 自定义 Hooks(composables):把「重复代码」变成「乐高积木」

🎯 开场:为什么你的代码越写越乱?

上一章我们学会了用 Vue 的过渡动画让界面动起来,那种「轻轻一加就变酷」的感觉是不是很爽?

但问题来了——

你有没有遇到过这种情况:写了一个带动画的下拉菜单,过了两天又要一个带动画的弹窗,再过两天产品说「再加一个带动画的抽屉」……

结果你发现:
- 代码copy了三份,改一个bug要改三处
- 别人看你的代码满脑子问号
- 三个月后自己看自己的代码也想问「这谁写的」

这就是没有抽象复用的代价。

这一章我们要学的「自定义 Hooks」(也叫 composables),就是来解决这个问题的。学完你能:

  1. 把「某段逻辑」抽成一个可复用的积木块
  2. 在不同地方反复用,但只维护一份代码
  3. 跟同事吹牛时说「我这是 Hook 模式,封装度高」

说白了:Hooks 就是把「散落的工具」变成「工具箱」,随用随取。


🧱 基础:什么是 Hooks?\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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 分钟):加个判断

  • 输入:在 useCounteradd 函数里,加一个判断: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 件事:

  1. Hooks 就是「可复用的逻辑块」——把散落的代码封装成一个名字,随用随取
  2. return 暴露东西——Hook 里的变量不 return 外面用不了
  3. 配套使用要成双——addEventListener 要配 removeEventListeneronMounted 要配 onUnmounted

延伸资源:

  • Vue3 官方文档 - Composables(英文,但例子很清晰)
  • VueUse 库——社区最强 Hook 库,100+ 个现成的 Hook
  • 《Vue.js 设计与实现》——侯VELOPER 著,适合想深入理解原理的同学

互动钩子:

你有没有写过一段「总觉得在哪见过」的代码?是用 Hook 抽出来的,还是 copy 了 N 份?评论区聊聊,复用做得好的人老粉优先回复!


下一章我们要解决一个问题:JavaScript 是个「弱类型」语言,写着写着变量就变成鬼——字符串、数字、对象乱成一锅粥。下一章「TypeScript + Vue3」教你怎么给代码加「类型锁」,让 Bug 在写的时候就暴露,而不是上线之后才发现。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。