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















 
 
 
















 
 
 
 