在vue3中如何編寫一個(gè)標(biāo)準(zhǔn)的hooks?
前言
在 Vue 3 中,組合式 API 為開(kāi)發(fā)者提供了更加靈活和高效的方式來(lái)組織和復(fù)用邏輯,其中 Hooks 是一個(gè)重要的概念。Hooks 允許我們將組件中的邏輯提取出來(lái),使其更具可復(fù)用性和可讀性,讓我們的代碼編寫更加靈活。
hooks的定義
其實(shí),事實(shí)上官方并未管這種方式叫做hooks,而似乎更應(yīng)該叫做compositions更加確切些,更加符合vue3的設(shè)計(jì)初衷。由于react的hooks設(shè)計(jì)理念在前,而vue3的組合式使用也像一個(gè)hook鉤子掛載vue框架的生命周期中,對(duì)此習(xí)慣性地稱作hooks。
對(duì)于onMounted、onUnMounted等響應(yīng)式API都必須在setup階段進(jìn)行同步調(diào)用。
圖片
要理解 Vue 3 中的 Hooks,需要明白它的本質(zhì)是一個(gè)函數(shù),這個(gè)函數(shù)可以包含與組件相關(guān)的狀態(tài)和副作用操作。
- 狀態(tài)是應(yīng)用中存儲(chǔ)的數(shù)據(jù),這些數(shù)據(jù)可以影響組件的外觀和行為。在 Vue 3 中,可以使用 ref 和 reactive 來(lái)創(chuàng)建狀態(tài)。
- 副作用操作是指在應(yīng)用執(zhí)行過(guò)程中會(huì)產(chǎn)生外部可觀察效果的操作,比如數(shù)據(jù)獲取、訂閱事件、定時(shí)器等。這些操作可能會(huì)影響應(yīng)用的狀態(tài)或與外部系統(tǒng)進(jìn)行交互。
記?。篽ooks就是特殊的函數(shù),可以在vue組件外部使用,可以訪問(wèn)vue的響應(yīng)式系統(tǒng)。
vue3中hooks和react的區(qū)別
vue3的compositions和react的hooks還是有所區(qū)別的,對(duì)此官方還特別寫了兩者的比較,原文如下:
圖片
大抵意思如下,Vue Composition API 與 React Hooks 都具有邏輯組合能力,但存在一些重要差異。
React Hooks 的問(wèn)題:
- 每次組件更新都會(huì)重復(fù)調(diào)用,存在諸多注意事項(xiàng),可能使經(jīng)驗(yàn)豐富的開(kāi)發(fā)者也感到困惑,并導(dǎo)致性能優(yōu)化問(wèn)題。
- 對(duì)調(diào)用順序敏感且不能有條件調(diào)用。
- 變量可能因依賴數(shù)組不正確而“過(guò)時(shí)”,開(kāi)發(fā)者需依賴 ESLint 規(guī)則確保正確依賴,但規(guī)則不夠智能,可能過(guò)度補(bǔ)償正確性,遇到邊界情況會(huì)很麻煩。
- 昂貴的計(jì)算需使用 useMemo,且要手動(dòng)傳入正確依賴數(shù)組。
- 傳遞給子組件的事件處理程序默認(rèn)會(huì)導(dǎo)致不必要的子組件更新,需要顯式使用 useCallback 和正確的依賴數(shù)組,否則可能導(dǎo)致性能問(wèn)題。陳舊閉包問(wèn)題結(jié)合并發(fā)特性,使理解鉤子代碼何時(shí)運(yùn)行變得困難,處理跨渲染的可變狀態(tài)也很麻煩。
Vue Composition API 的優(yōu)勢(shì):
- setup() 或 <script setup> 中的代碼僅執(zhí)行一次,不存在陳舊閉包問(wèn)題,調(diào)用順序不敏感且可以有條件調(diào)用。
- Vue 的運(yùn)行時(shí)響應(yīng)式系統(tǒng)自動(dòng)收集計(jì)算屬性和監(jiān)聽(tīng)器中使用的響應(yīng)式依賴,無(wú)需手動(dòng)聲明依賴。
- 無(wú)需手動(dòng)緩存回調(diào)函數(shù)以避免不必要的子組件更新,精細(xì)的響應(yīng)式系統(tǒng)確保子組件僅在需要時(shí)更新,手動(dòng)優(yōu)化子組件更新對(duì) Vue 開(kāi)發(fā)者來(lái)說(shuō)很少是問(wèn)題。
自定義hooks需要遵守的原則
那么,在編寫自定義Hooks時(shí),有哪些常見(jiàn)的錯(cuò)誤或者陷阱需要避免?
以下是一些需要注意的點(diǎn):
- 狀態(tài)共享問(wèn)題:不要在自定義Hooks內(nèi)部創(chuàng)建狀態(tài)(使用ref或reactive),除非這些狀態(tài)是暴露給使用者的API的一部分。Hooks應(yīng)該是無(wú)狀態(tài)的,避免在Hooks內(nèi)部保存狀態(tài)。
- 副作用處理不當(dāng):副作用(例如API調(diào)用、定時(shí)器等)應(yīng)該在生命周期鉤子(如onMounted、onUnmounted)中處理。不要在自定義Hooks的參數(shù)處理或邏輯中直接執(zhí)行副作用。
- 過(guò)度依賴外部狀態(tài):自定義Hooks應(yīng)盡量減少對(duì)外部狀態(tài)的依賴。如果必須依賴,確保通過(guò)參數(shù)傳遞,而不是直接訪問(wèn)組件的狀態(tài)或其他全局狀態(tài)。
- 參數(shù)驗(yàn)證不足:自定義Hooks應(yīng)該能夠處理無(wú)效或意外的參數(shù)。添加參數(shù)驗(yàn)證邏輯,確保Hooks的魯棒性。
- 使用不穩(wěn)定的API:避免使用可能在未來(lái)版本中更改或刪除的API。始終查閱官方文檔,確保你使用的API是穩(wěn)定的。
- 性能問(wèn)題:避免在自定義Hooks中進(jìn)行昂貴的操作,如深度比較或復(fù)雜的計(jì)算,這可能會(huì)影響組件的渲染性能。
- 重渲染問(wèn)題:確保自定義Hooks不會(huì)由于響應(yīng)式依賴不當(dāng)而導(dǎo)致組件不必要的重渲染。
- 命名不一致:自定義Hooks應(yīng)該遵循一致的命名約定,通常是use前綴,以便于識(shí)別和使用。
- 過(guò)度封裝:避免創(chuàng)建過(guò)于通用或復(fù)雜的Hooks,這可能會(huì)導(dǎo)致難以理解和維護(hù)的代碼。Hooks應(yīng)該保持簡(jiǎn)單和直觀。
- 錯(cuò)誤處理不足:自定義Hooks應(yīng)該能夠妥善處理錯(cuò)誤情況,例如API請(qǐng)求失敗或無(wú)效輸入。
- 生命周期鉤子濫用:不要在自定義Hooks中濫用生命周期鉤子,確保只在必要時(shí)使用。
- 不遵循單向數(shù)據(jù)流:Hooks應(yīng)該遵循Vue的單向數(shù)據(jù)流原則,避免創(chuàng)建可能導(dǎo)致數(shù)據(jù)流混亂的邏輯。
- 忽視類型檢查:使用TypeScript編寫Hooks時(shí),確保進(jìn)行了適當(dāng)?shù)念愋蜋z查和類型推斷。
- 使用不恰當(dāng)?shù)捻憫?yīng)式API:例如,使用ref而不是reactive,或者在應(yīng)該使用readonly的場(chǎng)景中使用了可變對(duì)象。
- 全局狀態(tài)管理不當(dāng):如果你的Hooks依賴于全局狀態(tài),確保正確處理,避免造成狀態(tài)管理上的混亂。
我們自定義一個(gè)hooks方法
記住這些軍規(guī)后,我們嘗試自己寫一個(gè)自定義hooks函數(shù)。下面代碼實(shí)現(xiàn)了一個(gè)自定義的鉤子函數(shù),用于處理組件的事件監(jiān)聽(tīng)和卸載邏輯,以達(dá)到組件邏輯的封裝和復(fù)用目的。
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;
}
對(duì)于簡(jiǎn)單的數(shù)字累加自定義hooks方法,我們可以這樣寫:
import { ref } from 'vue';
function useCounter(initialValue = 0) {
const count = ref(initialValue);
const increment = () => {
count.value++;
};
return { count, increment };
}
編寫單元測(cè)試來(lái)驗(yàn)證你的自定義Hooks是否按預(yù)期工作:
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工具庫(kù)vueuse和vue-hooks-plus
對(duì)于常用的hooks方法可以單獨(dú)抽取進(jìn)行發(fā)包成hooks工具。在業(yè)務(wù)開(kāi)發(fā)中常用的vue hooks方法庫(kù)有:vueuse和vue-hooks-plus。那么,咱們看看這兩個(gè)庫(kù)對(duì)于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
兩段代碼都在代碼實(shí)現(xiàn)上都遵守了上面的hook軍規(guī),實(shí)現(xiàn)了相似的功能,即創(chuàng)建一個(gè)可復(fù)用的計(jì)數(shù)器模塊,具有增加、減少、設(shè)置特定值和重置等操作,并且都可以配置最小和最大計(jì)數(shù)范圍。
差異點(diǎn)
- 代碼細(xì)節(jié):
- 第一段代碼使用了unref函數(shù)來(lái)獲取初始值的實(shí)際數(shù)值,第二段代碼沒(méi)有使用這個(gè)函數(shù),而是直接在初始化響應(yīng)式變量時(shí)進(jìn)行處理。
- 第二段代碼引入了一個(gè)輔助函數(shù)isNumber和getTargetValue來(lái)確保設(shè)置的值在合法范圍內(nèi),第一段代碼在設(shè)置值的時(shí)候直接進(jìn)行范圍判斷,沒(méi)有單獨(dú)的輔助函數(shù)。
- 返回值處理:
- 第二段代碼返回的響應(yīng)式變量是只讀的,這可以提高代碼的安全性,防止在組件中意外修改計(jì)數(shù)器的值;第一段代碼沒(méi)有對(duì)返回的響應(yīng)式變量進(jìn)行只讀處理。
那么什么場(chǎng)景下需要抽取hooks呢?
在以下幾種情況下,通常需要抽取 Hooks 方法:
1.邏輯復(fù)用當(dāng)多個(gè)組件中存在相同或相似的邏輯時(shí),抽取為 Hooks 可以提高代碼的復(fù)用性。例如,在多個(gè)不同的頁(yè)面組件中都需要進(jìn)行數(shù)據(jù)獲取和狀態(tài)管理,如從服務(wù)器獲取用戶信息并顯示加載狀態(tài)、錯(cuò)誤狀態(tài)等。可以將這些邏輯抽取為一個(gè)useFetchUser的 Hooks 方法,這樣不同的組件都可以調(diào)用這個(gè)方法來(lái)獲取用戶信息,避免了重復(fù)編寫相同的代碼。
2.復(fù)雜邏輯的封裝如果某個(gè)組件中有比較復(fù)雜的業(yè)務(wù)邏輯,將其抽取為 Hooks 可以使組件的代碼更加清晰和易于維護(hù)。比如,一個(gè)表單組件中包含了表單驗(yàn)證、數(shù)據(jù)提交、錯(cuò)誤處理等復(fù)雜邏輯??梢詫⑦@些邏輯分別抽取為useFormValidation、useSubmitForm、useFormErrorHandling等 Hooks 方法,然后在表單組件中組合使用這些 Hooks,使得表單組件的主要邏輯更加專注于用戶界面的呈現(xiàn),而復(fù)雜的業(yè)務(wù)邏輯被封裝在 Hooks 中。
3.與特定功能相關(guān)的邏輯當(dāng)有一些特定的功能需要在多個(gè)組件中使用時(shí),可以抽取為 Hooks。例如,實(shí)現(xiàn)一個(gè)主題切換功能,需要管理當(dāng)前主題狀態(tài)、切換主題的方法以及保存主題設(shè)置到本地存儲(chǔ)等邏輯??梢詫⑦@些邏輯抽取為useTheme Hooks 方法,方便在不同的組件中切換主題和獲取當(dāng)前主題狀態(tài)。
4.提高測(cè)試性如果某些邏輯在組件中難以進(jìn)行單元測(cè)試,可以將其抽取為 Hooks 以提高測(cè)試性。比如,一個(gè)組件中的定時(shí)器邏輯可能與組件的生命周期緊密耦合,難以單獨(dú)測(cè)試。將定時(shí)器相關(guān)的邏輯抽取為useTimer Hooks 方法后,可以更容易地對(duì)定時(shí)器的行為進(jìn)行單元測(cè)試,而不依賴于組件的其他部分。
總之,抽取 Hooks 方法可以提高代碼的復(fù)用性、可維護(hù)性和測(cè)試性,當(dāng)遇到上述情況時(shí),考慮抽取 Hooks 是一個(gè)很好的實(shí)踐。
案例:vue-vben-admin中的usePermission
我們看看關(guān)于在業(yè)務(wù)開(kāi)發(fā)中如何進(jìn)行hooks抽取封裝的案例,vue-vben-admin(https://github.com/vbenjs/vue-vben-admin)是個(gè)優(yōu)秀的中后臺(tái)管理項(xiàng)目,在項(xiàng)目中設(shè)計(jì)很復(fù)雜也很全面,很多地方都充分體現(xiàn)了vue3的設(shè)計(jì)思想,也能窺見(jiàn)作者對(duì)于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
* 重置和重新獲得權(quán)限資源信息
* @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 };
}
這段代碼實(shí)現(xiàn)了一個(gè)與權(quán)限管理相關(guān)的模塊,主要用于在 Vue 應(yīng)用中處理用戶權(quán)限、切換權(quán)限模式、重新獲取權(quán)限資源信息以及刷新菜單等操作。
主要結(jié)構(gòu)和組成部分
- 引入依賴:
- 引入了RouteRecordRaw類型,用于表示路由記錄。
- 從特定路徑引入了應(yīng)用的store模塊,包括useAppStore、usePermissionStore和useUserStore,用于管理應(yīng)用狀態(tài)。
- 引入了自定義的useTabs函數(shù),用于處理標(biāo)簽頁(yè)相關(guān)操作。
- 引入了router和resetRouter,用于操作路由。
- 引入了一些項(xiàng)目設(shè)置和工具函數(shù),如projectSetting、PermissionModeEnum、RoleEnum、intersection和isArray。
- 定義**usePermission**函數(shù):
- 該函數(shù)內(nèi)部獲取了用戶存儲(chǔ)、應(yīng)用存儲(chǔ)和權(quán)限存儲(chǔ)的實(shí)例,并調(diào)用了useTabs函數(shù)獲取標(biāo)簽頁(yè)操作方法。
- togglePermissionMode方法:用于切換權(quán)限模式,通過(guò)更新應(yīng)用存儲(chǔ)中的項(xiàng)目配置,然后重新加載頁(yè)面。
- resume方法:用于重置和重新獲取權(quán)限資源信息。它先清除多標(biāo)簽頁(yè)存儲(chǔ)中的緩存標(biāo)簽,重置路由,重新構(gòu)建路由并添加到路由實(shí)例中,設(shè)置最后構(gòu)建菜單的時(shí)間,并關(guān)閉所有標(biāo)簽頁(yè)。
- hasPermission方法:用于判斷用戶是否具有特定的權(quán)限。根據(jù)不同的權(quán)限模式,檢查用戶的角色列表或權(quán)限代碼列表是否包含給定的值。
- changeRole方法:用于切換用戶角色。如果當(dāng)前權(quán)限模式不是ROUTE_MAPPING,則拋出錯(cuò)誤。如果角色不是數(shù)組,則轉(zhuǎn)換為數(shù)組,然后更新用戶存儲(chǔ)中的角色列表,并調(diào)用resume方法重新獲取權(quán)限資源信息。
- refreshMenu方法:用于刷新菜單數(shù)據(jù),實(shí)際上是調(diào)用了resume方法。
- 返回值:
- usePermission函數(shù)最后返回一個(gè)包含changeRole、hasPermission、togglePermissionMode和refreshMenu方法的對(duì)象。
總結(jié)
本文主要介紹了 Vue 3 中的組合式 API 及 Hooks 相關(guān)內(nèi)容。首先說(shuō)明了 Vue 3 組合式 API 中 Hooks 的概念、作用及與 React Hooks 的區(qū)別,指出 Vue Composition API 的優(yōu)勢(shì)。接著詳細(xì)闡述了編寫自定義 Hooks 時(shí)應(yīng)避免的錯(cuò)誤和陷阱,如狀態(tài)共享、副作用處理、過(guò)度依賴外部狀態(tài)等問(wèn)題,并給出了自定義 Hooks 函數(shù)的示例及單元測(cè)試方法。然后對(duì)比了兩個(gè)庫(kù)(vueuse 和 vue-hooks-plus)對(duì) useCounter 的封裝差異。還探討了抽取 Hooks 的場(chǎng)景,如邏輯復(fù)用、復(fù)雜邏輯封裝等,并以 vue-vben-admin 項(xiàng)目中的權(quán)限管理模塊為例進(jìn)行分析。
參考素材:
- https://router.vuejs.org/
- https://inhiblabcore.github.io/docs/hooks/
- https://vueuse.org/
- https://juejin.cn/post/7083401842733875208