前言
在 Vue 3 中,组合式 API 为开发者提供了更加灵活和高效的方式来组织和复用逻辑,其中 Hooks 是一个重要的概念。Hooks 允许我们将组件中的逻辑提取出来,使其更具可复用性和可读性,让我们的代码编写更加灵活。
hooks的定义
其实,事实上官方并未管这种方式叫做hooks,而似乎更应该叫做compositions更加确切些,更加符合vue3的设计初衷。由于react的hooks设计理念在前,而vue3的组合式使用也像一个hook钩子挂载vue框架的生命周期中,对此习惯性地称作hooks。
对于onMounted、onUnMounted等响应式API都必须在setup阶段进行同步调用。
图片
要理解 Vue 3 中的 Hooks,需要明白它的本质是一个函数,这个函数可以包含与组件相关的状态和副作用操作。
- 状态是应用中存储的数据,这些数据可以影响组件的外观和行为。在 Vue 3 中,可以使用 ref 和 reactive 来创建状态。
- 副作用操作是指在应用执行过程中会产生外部可观察效果的操作,比如数据获取、订阅事件、定时器等。这些操作可能会影响应用的状态或与外部系统进行交互。
记住:hooks就是特殊的函数,可以在vue组件外部使用,可以访问vue的响应式系统。
vue3中hooks和react的区别
vue3的compositions和react的hooks还是有所区别的,对此官方还特别写了两者的比较,原文如下:
图片
大抵意思如下,Vue Composition API 与 React Hooks 都具有逻辑组合能力,但存在一些重要差异。
React Hooks 的问题:
- 每次组件更新都会重复调用,存在诸多注意事项,可能使经验丰富的开发者也感到困惑,并导致性能优化问题。
- 对调用顺序敏感且不能有条件调用。
- 变量可能因依赖数组不正确而“过时”,开发者需依赖 ESLint 规则确保正确依赖,但规则不够智能,可能过度补偿正确性,遇到边界情况会很麻烦。
- 昂贵的计算需使用 useMemo,且要手动传入正确依赖数组。
- 传递给子组件的事件处理程序默认会导致不必要的子组件更新,需要显式使用 useCallback 和正确的依赖数组,否则可能导致性能问题。陈旧闭包问题结合并发特性,使理解钩子代码何时运行变得困难,处理跨渲染的可变状态也很麻烦。
Vue Composition API 的优势:
- setup() 或 <script setup> 中的代码仅执行一次,不存在陈旧闭包问题,调用顺序不敏感且可以有条件调用。
- Vue 的运行时响应式系统自动收集计算属性和监听器中使用的响应式依赖,无需手动声明依赖。
- 无需手动缓存回调函数以避免不必要的子组件更新,精细的响应式系统确保子组件仅在需要时更新,手动优化子组件更新对 Vue 开发者来说很少是问题。
自定义hooks需要遵守的原则
那么,在编写自定义Hooks时,有哪些常见的错误或者陷阱需要避免?
以下是一些需要注意的点:
- 状态共享问题:不要在自定义Hooks内部创建状态(使用ref或reactive),除非这些状态是暴露给使用者的API的一部分。Hooks应该是无状态的,避免在Hooks内部保存状态。
- 副作用处理不当:副作用(例如API调用、定时器等)应该在生命周期钩子(如onMounted、onUnmounted)中处理。不要在自定义Hooks的参数处理或逻辑中直接执行副作用。
- 过度依赖外部状态:自定义Hooks应尽量减少对外部状态的依赖。如果必须依赖,确保通过参数传递,而不是直接访问组件的状态或其他全局状态。
- 参数验证不足:自定义Hooks应该能够处理无效或意外的参数。添加参数验证逻辑,确保Hooks的鲁棒性。
- 使用不稳定的API:避免使用可能在未来版本中更改或删除的API。始终查阅官方文档,确保你使用的API是稳定的。
- 性能问题:避免在自定义Hooks中进行昂贵的操作,如深度比较或复杂的计算,这可能会影响组件的渲染性能。
- 重渲染问题:确保自定义Hooks不会由于响应式依赖不当而导致组件不必要的重渲染。
- 命名不一致:自定义Hooks应该遵循一致的命名约定,通常是use前缀,以便于识别和使用。
- 过度封装:避免创建过于通用或复杂的Hooks,这可能会导致难以理解和维护的代码。Hooks应该保持简单和直观。
- 错误处理不足:自定义Hooks应该能够妥善处理错误情况,例如API请求失败或无效输入。
- 生命周期钩子滥用:不要在自定义Hooks中滥用生命周期钩子,确保只在必要时使用。
- 不遵循单向数据流:Hooks应该遵循Vue的单向数据流原则,避免创建可能导致数据流混乱的逻辑。
- 忽视类型检查:使用TypeScript编写Hooks时,确保进行了适当的类型检查和类型推断。
- 使用不恰当的响应式API:例如,使用ref而不是reactive,或者在应该使用readonly的场景中使用了可变对象。
- 全局状态管理不当:如果你的Hooks依赖于全局状态,确保正确处理,避免造成状态管理上的混乱。
我们自定义一个hooks方法
记住这些军规后,我们尝试自己写一个自定义hooks函数。下面代码实现了一个自定义的钩子函数,用于处理组件的事件监听和卸载逻辑,以达到组件逻辑的封装和复用目的。
import { ref, onMounted, onUnmounted } from 'vue';
function useEventListener(eventType, listener, options = false) {
const targetRef = ref(null);
onMounted(() => {
const target = targetRef.value;
if (target) {
target.addEventListener(eventType, listener, options);
}
});
onUnmounted(() => {
const target = targetRef.value;
if (target) {
target.removeEventListener(eventType, listener, options);
}
});
return targetRef;
}
对于简单的数字累加自定义hooks方法,我们可以这样写:
import { ref } from 'vue';
function useCounter(initialValue = 0) {
const count = ref(initialValue);
const increment = () => {
count.value++;
};
return { count, increment };
}
编写单元测试来验证你的自定义Hooks是否按预期工作:
import { mount } from '@vue/test-utils';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('should increment count', () => {
const { count, increment } = useCounter();
increment();
expect(count.value).toBe(1);
});
});
使用hooks:
<template>
<div>{{ count }}</div>
</template>
<script setup>
import { useCounter } from './useCounter';
const { count } = useCounter(10);
</script>
hooks工具库vueuse和vue-hooks-plus
对于常用的hooks方法可以单独抽取进行发包成hooks工具。在业务开发中常用的vue hooks方法库有:vueuse和vue-hooks-plus。那么,咱们看看这两个库对于useCounter的封装是什么样的。
vueuse:
// eslint-disable-next-line no-restricted-imports
import { ref, unref } from 'vue-demi'
import type { MaybeRef } from 'vue-demi'
export interface UseCounterOptions {
min?: number
max?: number
}
/**
* Basic counter with utility functions.
*
* @see https://vueuse.org/useCounter
* @param [initialValue]
* @param options
*/
export function useCounter(initialValue: MaybeRef<number> = 0, options: UseCounterOptions = {}) {
let _initialValue = unref(initialValue)
const count = ref(initialValue)
const {
max = Number.POSITIVE_INFINITY,
min = Number.NEGATIVE_INFINITY,
} = options
const inc = (delta = 1) => count.value = Math.min(max, count.value + delta)
const dec = (delta = 1) => count.value = Math.max(min, count.value - delta)
const get = () => count.value
const set = (val: number) => (count.value = Math.max(min, Math.min(max, val)))
const reset = (val = _initialValue) => {
_initialValue = val
return set(val)
}
return { count, inc, dec, get, set, reset }
}
vue-hooks-plus:
import { Ref, readonly, ref } from 'vue'
import { isNumber } from '../utils' // export const isNumber = (value: unknown): value is number => typeof value === 'number'
export interface UseCounterOptions {
/**
* Min count
*/
min?: number
/**
* Max count
*/
max?: number
}
export interface UseCounterActions {
/**
* Increment, default delta is 1
* @param delta number
* @returns void
*/
inc: (delta?: number) => void
/**
* Decrement, default delta is 1
* @param delta number
* @returns void
*/
dec: (delta?: number) => void
/**
* Set current value
* @param value number | ((c: number) => number)
* @returns void
*/
set: (value: number | ((c: number) => number)) => void
/**
* Reset current value to initial value
* @returns void
*/
reset: () => void
}
export type ValueParam = number | ((c: number) => number)
function getTargetValue(val: number, options: UseCounterOptions = {}) {
const { min, max } = options
let target = val
if (isNumber(max)) {
target = Math.min(max, target)
}
if (isNumber(min)) {
target = Math.max(min, target)
}
return target
}
function useCounter(
initialValue = 0,
options: UseCounterOptions = {},
): [Ref<number>, UseCounterActions] {
const { min, max } = options
const current = ref(
getTargetValue(initialValue, {
min,
max,
}),
)
const setValue = (value: ValueParam) => {
const target = isNumber(value) ? value : value(current.value)
current.value = getTargetValue(target, {
max,
min,
})
return current.value
}
const inc = (delta = 1) => {
setValue(c => c + delta)
}
const dec = (delta = 1) => {
setValue(c => c - delta)
}
const set = (value: ValueParam) => {
setValue(value)
}
const reset = () => {
setValue(initialValue)
}
return [
readonly(current),
{
inc,
dec,
set,
reset,
},
]
}
export default useCounter
两段代码都在代码实现上都遵守了上面的hook军规,实现了相似的功能,即创建一个可复用的计数器模块,具有增加、减少、设置特定值和重置等操作,并且都可以配置最小和最大计数范围。
差异点
- 代码细节:
- 第一段代码使用了unref函数来获取初始值的实际数值,第二段代码没有使用这个函数,而是直接在初始化响应式变量时进行处理。
- 第二段代码引入了一个辅助函数isNumber和getTargetValue来确保设置的值在合法范围内,第一段代码在设置值的时候直接进行范围判断,没有单独的辅助函数。
- 返回值处理:
- 第二段代码返回的响应式变量是只读的,这可以提高代码的安全性,防止在组件中意外修改计数器的值;第一段代码没有对返回的响应式变量进行只读处理。
那么什么场景下需要抽取hooks呢?
在以下几种情况下,通常需要抽取 Hooks 方法:
1.逻辑复用当多个组件中存在相同或相似的逻辑时,抽取为 Hooks 可以提高代码的复用性。例如,在多个不同的页面组件中都需要进行数据获取和状态管理,如从服务器获取用户信息并显示加载状态、错误状态等。可以将这些逻辑抽取为一个useFetchUser的 Hooks 方法,这样不同的组件都可以调用这个方法来获取用户信息,避免了重复编写相同的代码。
2.复杂逻辑的封装如果某个组件中有比较复杂的业务逻辑,将其抽取为 Hooks 可以使组件的代码更加清晰和易于维护。比如,一个表单组件中包含了表单验证、数据提交、错误处理等复杂逻辑。可以将这些逻辑分别抽取为useFormValidation、useSubmitForm、useFormErrorHandling等 Hooks 方法,然后在表单组件中组合使用这些 Hooks,使得表单组件的主要逻辑更加专注于用户界面的呈现,而复杂的业务逻辑被封装在 Hooks 中。
3.与特定功能相关的逻辑当有一些特定的功能需要在多个组件中使用时,可以抽取为 Hooks。例如,实现一个主题切换功能,需要管理当前主题状态、切换主题的方法以及保存主题设置到本地存储等逻辑。可以将这些逻辑抽取为useTheme Hooks 方法,方便在不同的组件中切换主题和获取当前主题状态。
4.提高测试性如果某些逻辑在组件中难以进行单元测试,可以将其抽取为 Hooks 以提高测试性。比如,一个组件中的定时器逻辑可能与组件的生命周期紧密耦合,难以单独测试。将定时器相关的逻辑抽取为useTimer Hooks 方法后,可以更容易地对定时器的行为进行单元测试,而不依赖于组件的其他部分。
总之,抽取 Hooks 方法可以提高代码的复用性、可维护性和测试性,当遇到上述情况时,考虑抽取 Hooks 是一个很好的实践。
案例:vue-vben-admin中的usePermission
我们看看关于在业务开发中如何进行hooks抽取封装的案例,vue-vben-admin(https://github.com/vbenjs/vue-vben-admin)是个优秀的中后台管理项目,在项目中设计很复杂也很全面,很多地方都充分体现了vue3的设计思想,也能窥见作者对于vue3源码的深入。
import type { RouteRecordRaw } from 'vue-router';
import { useAppStore } from '/@/store/modules/app';
import { usePermissionStore } from '/@/store/modules/permission';
import { useUserStore } from '/@/store/modules/user';
import { useTabs } from './useTabs';
import { router, resetRouter } from '/@/router';
// import { RootRoute } from '/@/router/routes';
import projectSetting from '/@/settings/projectSetting';
import { PermissionModeEnum } from '/@/enums/appEnum';
import { RoleEnum } from '/@/enums/roleEnum';
import { intersection } from 'lodash-es';
import { isArray } from '/@/utils/is';
import { useMultipleTabStore } from '/@/store/modules/multipleTab';
// User permissions related operations
export function usePermission() {
const userStore = useUserStore();
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const { closeAll } = useTabs(router);
/**
* Change permission mode
*/
async function togglePermissionMode() {
appStore.setProjectConfig({
permissionMode:
appStore.projectConfig?.permissionMode === PermissionModeEnum.BACK
? PermissionModeEnum.ROUTE_MAPPING
: PermissionModeEnum.BACK,
});
location.reload();
}
/**
* Reset and regain authority resource information
* 重置和重新获得权限资源信息
* @param id
*/
async function resume() {
const tabStore = useMultipleTabStore();
tabStore.clearCacheTabs();
resetRouter();
const routes = await permissionStore.buildRoutesAction();
routes.forEach((route) => {
router.addRoute(route as unknown as RouteRecordRaw);
});
permissionStore.setLastBuildMenuTime();
closeAll();
}
/**
* Determine whether there is permission
*/
function hasPermission(value?: RoleEnum | RoleEnum[] | string | string[], def = true): boolean {
// Visible by default
if (!value) {
return def;
}
const permMode = projectSetting.permissionMode;
if ([PermissionModeEnum.ROUTE_MAPPING, PermissionModeEnum.ROLE].includes(permMode)) {
if (!isArray(value)) {
return userStore.getRoleList?.includes(value as RoleEnum);
}
return (intersection(value, userStore.getRoleList) as RoleEnum[]).length > 0;
}
if (PermissionModeEnum.BACK === permMode) {
const allCodeList = permissionStore.getPermCodeList as string[];
if (!isArray(value)) {
return allCodeList.includes(value);
}
return (intersection(value, allCodeList) as string[]).length > 0;
}
return true;
}
/**
* Change roles
* @param roles
*/
async function changeRole(roles: RoleEnum | RoleEnum[]): Promise<void> {
if (projectSetting.permissionMode !== PermissionModeEnum.ROUTE_MAPPING) {
throw new Error(
'Please switch PermissionModeEnum to ROUTE_MAPPING mode in the configuration to operate!',
);
}
if (!isArray(roles)) {
roles = [roles];
}
userStore.setRoleList(roles);
await resume();
}
/**
* refresh menu data
*/
async function refreshMenu() {
resume();
}
return { changeRole, hasPermission, togglePermissionMode, refreshMenu };
}
这段代码实现了一个与权限管理相关的模块,主要用于在 Vue 应用中处理用户权限、切换权限模式、重新获取权限资源信息以及刷新菜单等操作。
主要结构和组成部分
- 引入依赖:
- 引入了RouteRecordRaw类型,用于表示路由记录。
- 从特定路径引入了应用的store模块,包括useAppStore、usePermissionStore和useUserStore,用于管理应用状态。
- 引入了自定义的useTabs函数,用于处理标签页相关操作。
- 引入了router和resetRouter,用于操作路由。
- 引入了一些项目设置和工具函数,如projectSetting、PermissionModeEnum、RoleEnum、intersection和isArray。
- 定义**usePermission**函数:
- 该函数内部获取了用户存储、应用存储和权限存储的实例,并调用了useTabs函数获取标签页操作方法。
- togglePermissionMode方法:用于切换权限模式,通过更新应用存储中的项目配置,然后重新加载页面。
- resume方法:用于重置和重新获取权限资源信息。它先清除多标签页存储中的缓存标签,重置路由,重新构建路由并添加到路由实例中,设置最后构建菜单的时间,并关闭所有标签页。
- hasPermission方法:用于判断用户是否具有特定的权限。根据不同的权限模式,检查用户的角色列表或权限代码列表是否包含给定的值。
- changeRole方法:用于切换用户角色。如果当前权限模式不是ROUTE_MAPPING,则抛出错误。如果角色不是数组,则转换为数组,然后更新用户存储中的角色列表,并调用resume方法重新获取权限资源信息。
- refreshMenu方法:用于刷新菜单数据,实际上是调用了resume方法。
- 返回值:
- usePermission函数最后返回一个包含changeRole、hasPermission、togglePermissionMode和refreshMenu方法的对象。
总结
本文主要介绍了 Vue 3 中的组合式 API 及 Hooks 相关内容。首先说明了 Vue 3 组合式 API 中 Hooks 的概念、作用及与 React Hooks 的区别,指出 Vue Composition API 的优势。接着详细阐述了编写自定义 Hooks 时应避免的错误和陷阱,如状态共享、副作用处理、过度依赖外部状态等问题,并给出了自定义 Hooks 函数的示例及单元测试方法。然后对比了两个库(vueuse 和 vue-hooks-plus)对 useCounter 的封装差异。还探讨了抽取 Hooks 的场景,如逻辑复用、复杂逻辑封装等,并以 vue-vben-admin 项目中的权限管理模块为例进行分析。
参考素材:
- https://router.vuejs.org/
- https://inhiblabcore.github.io/docs/hooks/
- https://vueuse.org/
- https://juejin.cn/post/7083401842733875208