自定义 Hooks 让逻辑复用变得优雅,但也容易因为对响应式系统理解不够而写出“看起来对、实际跑偏”的代码。以下是在实际开发中最常踩的坑,以及对应的解决思路。
1. 解构丢失响应式
问题:直接从 reactive 对象解构出属性,或从 ref 解构出 value,拿到的是普通值,不再是响应式。
// 错误示例
function useUser() {
const state = reactive({ name: 'Alice', age: 25 })
return { name: state.name, age: state.age } // 解构后丢失响应式
}
解决:使用 toRefs 包装后再解构,或者直接用 ref 来定义独立状态。
// 正确示例
function useUser() {
const state = reactive({ name: 'Alice', age: 25 })
return { ...toRefs(state) } // 保留了响应式连接
}
2. 直接替换 reactive 对象
问题:用一个新对象整体替换 reactive 的值,会导致原响应式代理断开。
const state = reactive({ list: [] })
state.list = [1, 2, 3] // 这是可以的,修改属性
state = reactive({ list: [4,5,6] }) // 错误:重新赋值给变量本身,丢失响应式
解决:永远通过属性修改,或用 ref 包装对象,因为 ref 允许整体替换 .value。
const state = ref({ list: [] })
state.value = { list: [4,5,6] } // 正确
3. Hooks 内部创建非响应式变量
问题:在 Hook 里定义了普通变量,认为它能像 ref 一样自动触发更新。
function useCounter() {
let count = 0
const increment = () => count++
return { count, increment } // count 永远是 0
}
解决:所有需要驱动视图变化的数据,都必须用 ref 或 reactive 包裹。
4. 闭包陷阱:拿到过期的响应式值
问题:在异步回调或 setTimeout 中直接使用解构出来的值,而不是响应式源本身,导致读取的是旧值。
function useTimer() {
const count = ref(0)
setTimeout(() => {
console.log(count.value) // 正确:拿到最新值
// 但如果写成 const val = count.value; setTimeout(() => console.log(val)) 就是旧值
}, 1000)
}
利用 watch 或 watchEffect 来处理副作用,或者在回调中始终通过 .value 访问,不要解构后存为局部变量。
5. 忘记清理副作用(内存泄漏)
问题:Hooks 中注册了定时器、事件监听、订阅,但在组件卸载时没有清除。
function useMouse() {
const x = ref(0)
const update = (e) => { x.value = e.pageX }
window.addEventListener('mousemove', update)
// 组件卸载时 listener 依然存在
}
解决:利用 onUnmounted 或 watchEffect/watch 返回的清理函数。
function useMouse() {
const x = ref(0)
const update = (e) => { x.value = e.pageX }
window.addEventListener('mousemove', update)
onUnmounted(() => window.removeEventListener('mousemove', update))
}
如果使用 watchEffect,可以返回清理函数,Vue 会自动在停止监听时调用。
6. Hook 返回值命名混淆
问题:自定义 Hook 返回一个对象或数组,但没有清晰的命名规范,导致使用时不知哪个是数据、哪个是方法。
// 反例
const [a, b] = useSomething() // 难以理解
解决:尽量返回对象(具名)或使用有意义的数组解构命名(如 const { x, y, reset } = useCoord())。如果必须返回数组,遵循约定(如 [value, setter] 或 [data, loading, error])。
7. 在 Hook 内部修改外部传入的响应式对象
问题:将父组件传过来的 reactive 对象通过参数传入 Hook,然后直接修改其属性,破坏了单向数据流,导致状态变更难以追踪。
function useEdit(user) {
// 错误:直接修改了传入的响应式对象
user.name = 'new name'
}
解决:如果需要修改外部状态,应该通过回调函数或事件通知,或者内部创建副本再操作。只在明确设计为“双向绑定”的场景(如表单 Hook)才直接修改,并且要文档化。
8. 过度封装与抽象
问题:把很多不相关逻辑挤进一个 Hook,或者为了复用而强行抽象,导致 Hook 参数爆炸,内部判断分支复杂。
解决:保持 Hook 职责单一,遵循“一个 Hook 只做一件事”。如果逻辑确实复杂,可以拆成多个独立 Hook,在组件中自由组合。
9. 忽略 TypeScript 类型导出
问题:自定义 Hook 没有明确的类型标注,使用时没有智能提示和类型校验。
解决:为 Hook 的参数和返回值明确定义 interface/type,并导出,让调用方享受完整类型支持。
10. 响应式依赖缺失导致 watch/watched 不触发
问题:在 watchEffect 内部使用了不可追踪的变量(如普通变量解构),导致依赖没有收集。
const state = reactive({ count: 0 })
const { count } = state
watchEffect(() => {
console.log(count) // 这里的 count 已经是普通数字,非响应式源
})
解决:直接在回调里使用 state.count 或 toRef 后访问 .value。
最佳实践要点
- 传入参数尽量使用
ref或reactive源的属性引用(toRef),保持响应式链接。 - 返回对象使用
toRefs或直接暴露ref对象。 - 副作用必须清理,利用
onUnmounted、watch的onCleanup。 - 名称以
use开头,便于编辑器识别和 lint 检查。 - 编写单元测试,尤其是涉及异步操作和复杂状态变动的 Hook。
遵循这些注意事项,自定义 Hooks 才能真正成为你工具箱里“拿来即用、久用不坏”的可靠零件。