Vue Hooks——让Vue开发更简单与高效 | 青训营
前言
Hooks是React等函数式编程框架中非常受欢迎的工具,随着VUE3 Composition API 函数式编程风格的推出,现在也受到越来越多VUE3开发者的青睐,它让开发者的代码具有更高的复用度且更加清晰、易于维护。
本文将快速略过并了解Hooks的使用基础以及自定义HOOK开发相关的要点,快速入门。
本文含有参考自官方文档、他人文章的内容,侵删。
Hooks简介
1. 什么是Hooks
Hooks并不是VUE特有的概念,实际上它原本被用于指代一些特定时间点会触发的勾子。而在React16之后,它被赋予了新的意义:
一系列以
use
作为开头的方法,它们提供了让你可以完全避开class式写法
,在函数式组件中完成生命周期、状态管理、逻辑复用等几乎全部组件开发工作的能力Hooks最核心的价值来自于内部的状态管理
在VUE3中,Hooks
的概念结合了VUE的响应式系统,被称为组合函数
。组合函数是VUE3组合式API中提供的新的逻辑复用的方案,是一类利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数
简单来说,它就是一个创建工具的工具
2. Hooks与composition Api
Hooks是一种基于闭包
的函数式编程思维产物,所以通常我们会在函数式风格
的框架或组件中使用Hook,比如VUE的组合式API(Composition Api)。Hooks在VUE2所使用的选项式风格API
中也不是不可以使用,毕竟Hook本质只是一个函数,只要hook内部所使用的api能够得到支持,我们可以在任何地方使用它们,只是可能需要额外的支持以及效果没有函数式组件中那么好,因为仍会被选项分割。
VUE3推出时为开发者带来了全新的Composition API即组合式API。它是一种通过函数来描述组件逻辑的开发模式。组合式API为开发者带来了更好的逻辑复用能力,通过组合函数
来实现更加简洁高效的逻辑复用。
js
复制代码
<script setup>
import { ref, onMounted } from 'vue'
// 响应式状态
const count = ref(0)
// 用来修改状态、触发更新的函数
function increment() {
count.value++
}
// 生命周期钩子
onMounted(() => {
console.log(`The initial count is ${count.value}.`)
})
</script>
<template>
<button @click="increment">Count is: {{ count }}</button>
</template>
为什么要使用Hook
1. Mixin/Class的局限性
在以往VUE2的选项式API中,主要通过Mixin或是Class继承来实现逻辑复用,但这种方式有三个明显的短板
:
- 不清晰的数据来源:当使用了多个mixin/class时,哪个数据是哪个模块提供的将变得难以追寻,这将提高维护难度
- 命名空间冲突:来自多个class/mixin的开发者可能会注册同样的属性名,造成冲突
- 隐性的跨模块交流:不同的mixin/class之间可能存在某种相互作用,产生未知的后果
以上三种主要的缺点导致在大型项目的开发中,多mixin/class的组合将导致逻辑的混乱以及维护难度的提升,因而在VUE3的官方文档中不再继续推荐使用,保留mixin也只是为了迁移的需求或方便VUE2用户熟悉。
2. Hooks的优势
其实Mixin/Class的缺点反过来就是Hooks的优点:
- 清晰一目了然的源头:Hooks不是一个类,没有将状态、方法存放在对象中,然后通过导出对象的形式实现复用,也就不会有对象间过度
耦合
、干扰
等问题。Hooks中的各类状态是封装在内部的,与外界隔离,仅暴露部分函数、变量,这使得其来源、功能清晰可辨
且不易被干扰
- 没有命名冲突的问题:Hooks本质是闭包函数,内部所导出的变量、方法支持重命名,因而同一个Hook在同一个组件中可以N次使用而不冲突
- 精简逻辑:一个Hook开发完成后,在使用Hook时不需要关心其内部逻辑,只需知道有什么效果、如何使用即可,专注于其他核心业务逻辑,可以节省大量重复代码
TypeScript
复制代码
<script lang="ts" setup>
import { useAutoRequest } from '/@/utils/hooks'
import m from '/@/utils/message'
// 使用Hook
const [loadingWithHook, newApiWithHook] = useAutoRequest(testApi, {
onSuccess: xxxData => {
xxxData // 做些什么
},
onCatch: err => {
err // 做些什么
},
message: '调用成功',
})
newApiWithHook()
// 不使用Hook
const loading = ref(false)
const fetchData = async () => {
try {
loading.value = true
const xxxData = await testApi()
xxxData //做些什么
m.success('调用成功')
} catch (err) {
err // 做些什么
} finally {
loading.value = false
}
}
fetchData()
// 模拟接口
interface Response {
name: string
age: number
}
function testApi(): Promise<Response> {
return new Promise(resolve => {
setTimeout(() => {
const result = { name: 'test', age: 18 }
resolve(result)
}, 1000)
})
}
</script>
可以看到在不使用Hook时,实现一个loading功能需要创建一个变量并手动控制它,而且需要使用try catch或者promise链式调用处理原来的接口的各种情况,这将产生大量的重复代码且可能因粗心而产生不必要的BUG
而在使用了Hook的情况下,loading状态以及接口的状态区分已经在内部处理好,仅需添加对应的参数即可,规范了写法、节省代码量、便捷且不易出错
3. 组合式API的优点
组合式API有一个很重要的优点,在组合式API中可以实现更灵活的代码组织
: 在选项式API中,人为地将代码分为了多个模块,非常有益于开发者上手,但是在模块复杂、代码量多的情况下将带来一些问题:
模块复杂
的情况下,查阅相同逻辑的内容时,需要反复翻阅
组件的内容,对于开发者特别是非原本组件开发者而言,这会极大地加重负担
,而如果使用组合式API,因为整个组件都是基于响应式变量以及函数,我们可以把处理相同业务逻辑的内容放在同一个区域,这样可以方便阅读理解,并且在抽离、复用时提供了很大的便利,在大型项目维护中非常重要。
以下是使用组合式API组织代码的示例:
TypeScript
复制代码
<script lang="ts" setup>
import { getAllForSelect, type UserInfo } from '/@/api'
import { useAutoRequest } from '/@/utils/hooks'
interface Option {
label: string
value: string | number
}
const emit = defineEmits(['focus', 'update:modelValue', 'clear', 'blur', 'change'])
const props = defineProps({...})
const { modelValue } = toRefs(props)
const selectRef = ref<any>(null)
const selectedValue = ref<string | number>('')
const options = ref<Array<Option>>([])
watch(modelValue, val => (selectedValue.value = val), { immediate: true })
// 使用Hook创建自动loading的请求接口
const [loading, getOptions] = useAutoRequest(getAllForSelect, {
onSuccess: res => options.value = res.map(...)
})
onBeforeMount(getOptions)
const onChange = (v: number | string) => (emit('update:modelValue', v), emit('change', v))
const onFocus = () => emit('focus')
const onBlur = () => emit('blur')
const onClear = () => emit('clear')
const blur = () => selectRef.value.blur()
const focus = () => selectRef.value.focus()
defineExpose({ blur, focus })
</script>
使用选项式API写同样功能的代码则需要将各个变量、函数置于不同的模块中,在模块复杂时,将增加维护难度
怎么开始玩Hooks
1. Hooks的各类规范
在开始使用/创建Hook之前,我们需要明白它的一些规范
,以下是创建/使用hook时的一些要求:
- 通常来讲,一个Hook的命名需要以use开头,比如useTimeOut,这是约定俗成的,开发者看到useXXX即可明白这是一个Hook。Hook的名称需要清楚地表明其功能。
- 只在组件生命周期中调用Hook,而不在普通函数中调用Hook (React中规定,但在Hook概念扩大化后,其实并非绝对)
- 只在当前关注的最顶级作用域使用Hook,而不要在嵌套函数、循环中调用Hook
补充:
- 函数必须是纯函数,没有副作用
- 返回值是一个函数或数据,供外部使用
- Hook内部可以使用其他的Hook,组合功能
- 数据必须依赖于输入,不依赖于外部状态,保持数据流的明确性
- 在Hook内部处理错误,不要把错误抛出到外部,否则会增加hook的使用成本
- Hook是单一功能的,不要给一个Hook设计过多功能。单个Hook只负责做一件事,复杂的功能可以使用多个Hook互相组合实现,如果给单个Hook增加过多功能,又会陷入过于臃肿、使用成本高、难维护的问题中
规范化使用Hook可以使得除了开发者本人之外的其他协作者也可以快速上手他人代码。
Hooks虽然有很多规定,但它们并不是铁律,在充分理解了Hook的工作原理,特殊情况下可以打破部分规范,前提是清楚这么做不会有意料之外的后果,但大部分情况下还是遵守规范比较好
2. 如何使用Hooks
在VUE中,使用Hooks时,需要使用组合式API,因而最好在VUE3中使用,VUE2想要使用组合式API则需要配合@vue/composition-api
,并且版本需要高于VUE2.6
。
Hooks的使用十分简单,这也是它们被设计的意义所在,只需引入并调用函数即可。
TypeScript
复制代码
<script lang="ts" setup>
import { useScroll } from '@vueuse/core'
const el = ref<HTMLElement | null>(null)
const { x, y, isScrolling, arrivedState, directions } = useScroll(el)
</script>
<template>
<div ref="el"></div>
</template>
VUE社区有很多优秀的Hooks库,比如VueUse,它是由部分VUE核心成员开发的VUE Hook库,提供了很多非常好用的hook,查看、学习它的源码也非常有助于开发自己的hook
3. 如何创建自己的自定义Hook
在设计一个定制的Hook之前,应当至少明白以下几点:
- 明确自己想要的功能以及实现的效果
- 遵守Hook的命名规范以及其他注意事项
- 尽可能好的性能表现以及精简的代码
- 使用TypeScript
我们在开发自己的Hook时应该明确它的设计目的,遵守各项规范,最好使用TypeScript,特别是复杂的Hook
当一个Hook内部较为复杂,配置项较多时,为了避免被错误使用,也为了尽早地发现可能的BUG,使用TypeScript的类型检查是非常有必要的,甚至为了更好的使用体验,应该结合TS类型计算,约束输入以及做到对输出内容的更好的类型提示
以下是一个简单的分页模块Hook示例:
TypeScript
复制代码
export function usePagination(val?: number): [Ref<number>, Ref<number>, computedRef<number>, Ref<number>, () => void] {
const pageSize = ref(val ?? 20);
const currentPage = ref(1);
const total = ref(0);
const skipCount = computed(() => (currentPage.value - 1) * pageSize.value);
return [currentPage, pageSize, total, skipCount, reset];
function reset() {
currentPage.value = 1;
total.value = 0;
}
}
// 使用
const [currentPage, pageSize, totalCount, skipCount, reset] = usePagination();
这样就可以创建一个简单的分页功能hook,只需在需要分页的VUE组件中引入调用usePagination,就可以轻松创建分页模块,高效且清晰。
创建复杂Hook时,需要尽可能地对各种情况做好预先的处理,以保证它代码的健壮性
Hooks在一定程度上可以取代传统的VUE组件
4. 使用TypeScript类型计算的Hook
以下是一个自动创建携带Loading状态的接口的Hook:
TypeScript
复制代码
import m from '/@/utils/message'
type TApiFun<TData, TParams extends Array<any>> = (...params: TParams) => Promise<TData>
type AutoRequestResult<TData, TParams extends Array<any>> = [Ref<boolean>, TApiFun<TData, TParams>]
interface AutoRequestOptions<T> {
/**
* @description 默认的loading状态
*
* @default false
*/
loading?: boolean
/**
* @description 成功时是否自动提示
*/
message?: string
/**
* @description 接口调用成功时的回调
*/
onSuccess?: (data: T) => unknown | Promise<unknown>
/**
* @description 接口调用失败时的回调
*/
onCatch?: (err: Error) => unknown | Promise<unknown>
}
/**
* @description loading状态hooks
* @param fun 接口方法
* @param options 配置选项:设置默认loading状态、接口回调与自动提示开关
* @returns [loading,接口]
*/
export default function useAutoRequest<TData, TParams extends any[] = any[]>(
fun: TApiFun<TData, TParams>,
options?: AutoRequestOptions<TData>,
): AutoRequestResult<TData, TParams> {
const { loading = false, onSuccess, onCatch, message } = options ?? { loading: false }
const requestLoading = ref(loading)
const run: TApiFun<TData, TParams> = (...params) => {
requestLoading.value = true
return fun(...params)
.then(async res => {
onSuccess && (await onSuccess(res))
message && m.success(message)
return res
})
.catch(async err => {
onCatch && (await onCatch(err))
throw new Error(err)
})
.finally(() => {
requestLoading.value = false
})
}
return [requestLoading, run]
}
// 使用
const [loading,apiWithLoading] = useAutoRequest(originApi, { message:'success' })
这是一个自动创建loading状态的Hook,在使用时只需传入原始接口以及一些配置项即可快捷创建自动loading状态,节省了大量重复的loading控制代码,使用该Hook后只需调用apiWithLoading即可实现loading变量的自动控制,且继承原接口的类型
如果不使用TS进行处理,那么新创建的apiWithLoading在使用时将会丢失类型,无法提供像原本接口originApi一样的类型支持,使得出错的风险增加,而经过处理后的useAutoRequest可以在外部使用时自动推断apiWithLoading的类型,也可以在填入onSuccess时提供更好的类型支持:
5. Hooks与普通工具函数的区别
简单来讲,Hooks是创建工具的工具
而普通工具函数则是纯粹的工具
。实际上根据开发者的喜好,一个普通的工具函数也可以被创建成Hooks的形式,但并不是很有必要,因为作为工具本身它已经很好用了,一定要包装成Hook反而多饶了一层,而且可能没有利用到Hook的优势
什么情况下适合创建为Hooks呢?
- 具有一定泛用性的功能
- 具有一定复杂度,需要外部提供初始条件,由模块内部进行状态管理的功能
- 若干相关的、共享状态的业务功能
- 等等
当需要内部状态管理时,才需要创建Hook
总结
Hooks是VUE3中利用组合式API响应式的特性的,实现简单高效的逻辑复用、提高开发效率、提高VUE模块可维护性的工具。Hooks的组合可以让组件低代价、高效率地实现高复杂度业务,Hooks之间通常相互独立,没有过度耦合,降低后期陷入维护地狱
的风险,而且可以使得功能模块更加易于测试
使用开源的Hook将为开发带来很多方便,而开发自定义Hook则需要花费一些时间,但在实现后,高度的定制化将为项目开发带来巨大的便利
Hooks的出现不意味着抛弃Class,Hooks也有自己的缺点比如内存泄漏和可能的性能问题。Class更加易于上手,在经验丰富、技术深厚的开发者手中也可以一定程度上避开Class的缺点
建议
1. 插件
- 项目中可以加入类似unplugin-auto-import的插件,配置后可以实现自动引入VUE函数、类型或一些指定的内容,可以节省反复引入VUE API的时间
- Type Challenges插件,提供很多不同难度的类型体操题目,可以提升对TS类型运算的理解
2. TypeScript
- TS严格模式:使用TS时,要开启tsconfig中的严格模式,如果关闭严格模式,类型检查的效果将大打折扣
- 避免AnyScript:使用TS时要进行严格的类型声明,避免过多的any,因为使用any将失去类型检查,如果实在难以描述类型,则可以考虑使用unknow。TS项目中如果存在很多的any,不如抛弃TypeScript
- 不要保留报错:各类报错通常是用来处理
边界情况
的,这正是此类报错存在的意义,需要重视并解决。有时候开发者会比TS更清楚数据的类型,此时一些不必要的类型报错可以通过类型断言
解决。重视并解决所有报错可以为代码提供更好的健壮性
3. 代码建议
- 规范、明确的命名: 在命名变量或函数时,名称应该尽可能的明确它的作用/功能,不要使用缩写特别是拼音缩写,这将导致代码可读性严重下降,复杂变量/方法使用注释进行注解
- 积极使用新的ES语法:包括可选链操作符(?.)、解构、剩余参数语法、空值合并运算符(??)等,合理地使用它们将有效地提高代码可读性
- 合理的代码组织:单个函数中,一些相关的函数内容写在一起可以有效的规范代码结构,在某个代码块比较复杂时,还可以提取为一个函数置于函数后部,前半部分仅
保留核心逻辑
,可以有效提升代码可读性。在VUE组件中也是类似的逻辑 - 语义化代码:在编写代码时,调用各类JSAPI时,应该注重
语义化
,比如要对数组进行某种批处理,就使用Array.map而不是使用Array.forEach或其他循环方式然后配合外部创建的另一个空数组进行处理。要实现什么效果就使用什么API,这样既可以让代码精简,也可以增强可读性,让代码自己描述自己
,这是增强代码可读性的关键
- 基础功能使用工具类:在进行一些基础判断等操作时,尽量使用一些封装好的工具类,这样可以
避免
判断时的疏漏
而产生错误;使用某功能时也先查询是否已有相关工具,同一类功能使用同一个封装好的工具将更方便管理
,但要注意的是此类工具不能过于复杂
,否则大范围应用后将会导致难以维护
、牵一发而动全身
简单示例:
TypeScript
复制代码
class Info {
name: string = ''
age: number = 0
}
interface TestInfo extends Info {
other: string
}
const entity = ref<Info>({ name: '', age: 0 })
const updateFormState = (data?: TestInfo): void => {
clear()
const { other = 'empty', ...info } = data ?? {}
entity.value = isObjectOfType<Info>(info, 'name') ? info : new Info()
other // 使用other
}
/**
* @description 通用联合类型的类型守卫,使用时需传入类型
* @param obj 需要确认类型的对象
* @param symbolKey 指示键值
* @returns
*/
export function isObjectOfType<TExpected>(obj: any, symbolKey: keyof TExpected): obj is TExpected {
return typeof obj === 'object' && obj !== null && symbolKey in obj
}
const clear = () => {}