在vue3中如何編寫一個標準的hooks?
前言
在 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 來創建狀態。
- 副作用操作是指在應用執行過程中會產生外部可觀察效果的操作,比如數據獲取、訂閱事件、定時器等。這些操作可能會影響應用的狀態或與外部系統進行交互。
記?。篽ooks就是特殊的函數,可以在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