【Vue3】如何封裝一個超級好用的 Hook !

本文將通過介紹什麼是 Hook、如何在 Vue 使用 Hook,以及在實踐場景中如何封裝自己的 Vue Hook,帶你走進 Hook 的世界,寫出更優雅的代碼。如果你覺得這篇文章寫的不錯,可以點贊支持一下,如果文章中存在不足(代碼量多,難免出現 bug,咳咳),歡迎在評論區指出!

什麼是 Hook

Vue3 官方文檔是這樣定義組合式函數的。A "composable" is a function that leverages Vue's Composition API to encapsulate and reuse stateful logic.,一個利用 Vue 的組合式 API 來封裝和複用具有狀態邏輯的函數。

這個概念借鑑自 React 的 Hook。在 16.8 的版本中,React 引入了 React Hook。這是一項特別強大的技術,通過封裝有狀態的函數,極大提高了組件的編寫效率和維護性。在下文中也是使用 Hook 來替代 “組合式函數” 進行敘述。

在開發中,我們經常會發現一些可以重複利用的代碼段,於是我們將其封裝成函數以供調用。這類函數包括工具函數,但是又不止工具函數,因爲我們可能也會封裝一些重複的業務邏輯。以往,在前端原生開發中,我們封裝的這些函數都是 “無狀態” 的。爲了建立數據與視圖之間的聯繫,基於 MVC 架構的 React 框架和基於 MVVM 的 Vue 框架都引入了 “狀態” 這一概念,狀態是特殊的 JavaScript 變量,它的變化會引起視圖的變化。在這類框架中,如果一個變量的變化不會引起視圖的變化,那麼它就是普通變量,如果一個變量已經被框架註冊爲狀態,那麼這個變量的變化就會引發視圖的變化,我們稱之爲響應式變量。如果一個函數包含了狀態(響應式變量),那麼它就是一個 Hook 函數。

在具備 “狀態” 的框架的基礎上,纔有 Hook 這一說。Hook 函數與普通函數的本質區別在於是否具備“狀態”。

比如,在一個 Vue 項目中,我們可能同時引入了 lodash 庫和 VueUse 庫,這兩個庫都是提供一些方便的工具函數。工具函數庫只引入一個不行嗎,不會重複嗎?或許不行,因爲 lodash 的函數是無狀態的,用來處理普通變量或者響應式變量中的數據部分,而 VueUse 提供的 api 都是 Hook。如果你的項目中既有普通變量又有響應式變量,你或許就會在同一個項目中同時接觸到這兩個庫。

React 官方爲我們提供了一些非常方便的 Hook 函數,比如 useState、useEffect(我們通常使用 use 作爲前綴來標識 Hook 函數),但是這遠遠不夠,或者說,它們足夠通用但是不夠具體。爲了在具體業務下複用某些邏輯,我們往往會封裝自己的 Hook,即自定義 Hook。爲什麼這裏會反覆提到 React 中呢?因爲提到 Hook,就不可能避開 React。Hook 是 React 發揚光大的,使用 Hook 已經是 React 社區的主流。然而,只要框架具備 “狀態” 這一概念,都可以使用 Hook 技術!下面文章將會介紹如何將 Hook 應用到 Vue 當中。

在 Vue 中使用 Hook

下面我們來看一個簡單的自定義 Hook(來自 Vue 官方文檔):

需求:在頁面實時顯示鼠標的座標。實現:沒有使用 Hook。

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const x = ref(0)
const y = ref(0)

function update(event) {
  x.value = event.pageX
  y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<template>Mouse position is at: {{ x }}{{ y }}</template>

在沒有封裝的情況下,如果我們在另一個頁面也需要這個功能,我們需要將代碼複製過去。另外,可以看出,它聲明瞭兩個變量,並且在生命週期鉤子 onMountedonUnmounted 中書寫了一些代碼,如果這個頁面需要更多的功能,那麼會出現代碼中存在很多變量、生命週期中存在很多邏輯寫在一起的現象,使得這些邏輯混雜在一起,而使用 Hook 可以將其分隔開來(這也是爲什麼會有很多人使用 Hook 的原因,分離代碼,提高可維護性!)

使用 Hook:

<script setup>
import { useMouse } from './mouse.js'

const { x, y } = useMouse()
</script>

<template>Mouse position is at: {{ x }}{{ y }}</template>

可以發現,比原來的代碼更加簡潔,這時如果加入其它功能的變量,也不會覺得眼花繚亂了。

當然,我們需要在外部定義這個 Hook:

// mouse.js
import { ref, onMounted, onUnmounted } from 'vue'

// 按照慣例,組合式函數名以“use”開頭
export function useMouse() {
  // 被組合式函數封裝和管理的狀態
  const x = ref(0)
  const y = ref(0)

  // 組合式函數可以隨時更改其狀態。
  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  // 一個組合式函數也可以掛靠在所屬組件的生命週期上
  // 來啓動和卸載副作用
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  // 通過返回值暴露所管理的狀態
  return { x, y }
}

或許,你可以試着去 VueUse 庫找到別人封裝好的 useMouse!

import { useMouse } from 'VueUse'

恭喜你,掌握了 VueUse 庫的使用方法。如果需要其它 Hook,你可以先試着去官方文檔 VueUse | VueUse(https://vueuse.org/) 查找,使用現成的函數,而不是自己去封裝。

封裝一(入門級的表格 Hook)

在前面,我們介紹完了 Hook 的概念,完成了一個簡單的自定義 Hook,還學會了使用社區提供的大量現成的 Hook 函數(VueUse 庫),接下來,我們將結合實際業務,完成我們自己的 Hook 函數!

場景分析

首先定義一個表格:

<template>
  <el-table :data="tableData">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>
  <button @click="refresh">refresh</button>
</template>

表格的數據通過 api 獲取(一般寫法):

<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi } from "./api.ts";

const tableData = ref([]);
const refresh=async () => {
  const data = await getTableDataApi();
  tableData.value = data;
}

onMounted(refresh);
</script>

模擬 api:

// api.ts
export const getTableDataApi = () ={
  const data = [
    {
      date: '2016-05-03',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-02',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-04',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-01',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
  ]
  return new Promise(resolve ={
    setTimeout(() ={
      resolve(data)
    }, 100);
  })
}

如果存在多個表格,我們的 js 代碼會變得比較複雜:

<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi1, getTableDataApi2, getTableDataApi3 } from "./api.ts";

const tableData1 = ref([]);
const refresh1=async () => {
  const data = await getTableDataApi1();
  tableData1.value = data;
}

const tableData2 = ref([]);
const refresh2=async () => {
  const data = await getTableDataApi2();
  tableData2.value = data;
}

const tableData3 = ref([]);
const refresh3=async () => {
  const data = await getTableDataApi3();
  tableData3.value = data;
}

onMounted(refresh1);
</script>

封裝實例

封裝我們的 useTable:

// useTable.ts
import { ref } from 'vue'
export function useTable(api) {
  const data = ref([])
  const refresh = () ={ api().then(res => data.value = res) };
  refresh()
  return [data, refresh]
}

改造代碼:

<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi1, getTableDataApi2, getTableDataApi3 } from "./api.ts";
import { useTable } from './useTable.ts'

const [tableData1, refresh1] = useTable(getTableDataApi1);
const [tableData2, refresh2] = useTable(getTableDataApi2);
const [tableData3, refresh3] = useTable(getTableDataApi3);

onMounted(refresh1);
</script>

封裝技巧 - Hook 返回值

  1. 一般自定義 Hook 有返回數組的,也有返回對象的,上面 useTable 使用了返回數組的寫法,useMouse 使用了返回對象的寫法。數組是對應位置命名的,可以方便重命名,對象對於類型和語法提示更加友好。兩種寫法都是可以替換的。

  2. 因爲 Hook 返回對象或者數組,那麼它一定是一個非 async 函數(async 函數一定返回 Promise),所以在 Hook 中,一般使用 then 而不是 await 來處理異步請求。

  3. 返回值如果是對象,一般在函數中通過 reactive 創建一個對象,最後通過 toRefs 導出,這樣做的原因是可以產生批量的可以解構的 Ref 對象,以免在解構返回值時丟失響應性。

// 使用 reactive 和 toRefs 可以快速創建多個ref對象,並在解構後使用時不丟失其響應性和與原先數據的關聯性
function usePaginaion(){
 const pagination = reactive({
  current: 1,
  total: 0,
  sizeOption,
  size: sizeOption[0]
 })
 ...
 return {...toRefs(pagination)}
}

const { current,total } = usePagination()

封裝二(支持分頁查詢)

需求分析

上面我們封裝了一個簡單的 hook,但是實際應用中並不會如此簡單,下面我列出一個比較完整的 useTable 在實踐中應該具備的功能,並在後續的文章部分完成它。

封裝表格組件邏輯:

  1. 維護 api 的調用和刷新(已完成)

  2. 支持分頁查詢(頁數、總條數、每頁大小等)

  3. 支持 api 參數。

  4. 增加輔助功能(loading、立即執行等)

下面我們將對 useTable 進行改造,使其支持分頁器。

先改造一些我們的 api,使其支持分頁查詢:

export const getTableDataApi = (page, limit) ={
  const data = [
    {
      date: '2016-05-03',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-02',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-04',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-01',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2017-05-03',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2017-05-02',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2017-05-04',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2017-05-01',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
  ]
  return new Promise(resolve ={
    setTimeout(() ={
      resolve({
        total: data.length,
        data: data.slice((page - 1) * limit, (page - 1) * limit + limit)
      })
    }, 100);
  })
}

如果沒有使用 Hook,我們的 vue 文件應該是這樣的:

<template>
  <el-table :data="tableData">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>
  <button @click="refresh">refresh</button>
  <!-- 分頁器 -->
  <el-pagination
    v-model:current-page="current"
    :page-size="size"
    layout="total, prev, pager, next"
    :page-sizes="sizeOption"
    :total="total"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />
</template>

<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi } from "./api.ts";

const tableData = ref([]); // 表格數據
const current = ref(1); // 當前頁數
const sizeOption = [10, 20, 50, 100, 200]; // 每頁大小選項
const size = ref(sizeOption[0]); //每頁大小
const total = ref(0); // 總條數

// 每頁大小變化
const handleSizeChange = (size: number) => {
  size.value = size;
  current.value = 1;
  // total.value = 0;
  refresh();
};

// 頁數變化
const handleCurrentChange = (page: number) => {
  current.value = page;
  // total.value = 0;
  refresh();
};

const refresh = async () => {
  const result = await getTableDataApi({
    page: current.value,
    limit: size.value,
  });
  tableData.value = result.data || [];
  total.value = result.total || 0;
};

onMounted(refresh);
</script>

可以看出,如果存在多個表格,會創建很多套變量和重複的代碼。

封裝實例

先寫個 usePagination:該鉤子接受一個回調函數,當頁數改變時就會調用該函數。

import { reactive } from "vue";
export function usePagination(
  cb: any,
  sizeOption: Array<number> = [10, 20, 50, 100, 200]
): any {
  const pagination = reactive({
    current: 1,
    total: 0,
    sizeOption,
    size: sizeOption[0],
    // 維護page和size(一般是主動觸發)
    onPageChange: (page: number) ={
      pagination.current = page;
      return cb();
    },
    onSizeChange: (size: number) ={
      pagination.current = 1;
      pagination.size = size;
      return cb();
    },
    // 一般調用cb後會還會修改total(一般是被動觸發)
    setTotal: (total: number) ={
      pagination.total = total;
    },
    reset() {
      pagination.current = 1;
      pagination.total = 0;
      pagination.size = pagination.sizeOption[0];
    },
  });

  return [
    pagination,
    pagination.onPageChange,
    pagination.onSizeChange,
    pagination.setTotal,
  ];
}

與 useTable 結合:代碼非常簡單,在調用 api 時傳入參數,並在接受返回值時更新 data 和 total。這裏我們的 refresh 函數是一個返回 Promise 的函數,能夠支持在調用 refresh 處再鏈接 then 進行下一層處理。

export function useTable(api: (params: any) => Promise<T>) {
  const [pagination, , , setTotal] = usePagination(() => refresh());
  const data = ref([]);

  const refresh = () ={
    return api({ page: pagination.current, limit: pagination.size }).then(
      (res) ={
        data.value = res.data;
        setTotal(res.total);
      }
    );
  };
  return [data, refresh, pagination];
}

注:我們新建一個文件 customHooks.js 並將 usePagination 和 useTable 放在裏面。

使用 useTable:

<template>
  <el-table :data="tableData">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>
  <button @click="refresh">refresh</button>
  <!-- 分頁器 -->
  <el-pagination
    v-model:current-page="pagination.current"
    :page-size="pagination.size"
    layout="total, prev, pager, next"
    :page-sizes="pagination.sizeOption"
    :total="pagination.total"
    @size-change="pagination.onSizeChange"
    @current-change="pagination.onCurrentChange"
  />
</template>

<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi } from "./api.ts";
import { useTable } from './customHooks.ts'

const [tableData, refresh, pagination] = useTable(getTableDataApi);

onMounted(refresh);
</script>

封裝三(支持不同接口字段)

封裝分析

上面我們封裝了一個 “看起來” 比較使用的 useTable 函數,但實際上,你會發現很多問題:

  1. 每次都要寫 onMounted 來初始化數據。

  2. 接口接受的格式可能不一樣,比如,頁數的字段爲 "currentPage",而不是 “page”。

  3. 接口返回的格式可能不一樣,比如,返回的 data 並不在 refresh 方法定義的 “data” 上。

封裝實例

接下來,我們通過增加 useTable 函數的參數,來解決上面所有問題!

import { get, has, defaults } from "lodash-es";
type keyPath = Array<string> | string;
export function useTable<T>(
  api: (params: any) => Promise<T>,
  options?: {
    path?: { data?: keyPath; total?: keyPath; page?: string; size?: string };
    immediate?: boolean;
  }
) {
  // 參數處理
  defaults(options, {
    path: { data: "data", total: "total", page: "page", size: "size" },
    immediate: false,
  });

  const [pagination, , , setTotal] = () => refresh();
  const data = ref([]);
  const loading = ref(false)

  const refresh = () ={
 loading.value = true
    return api({ [options?.path?.page]: pagination.current, [options?.path?.size]: pagination.size }).then(
      (res) ={
        data.value = get(res, options!.path?.data, []);
        setTotal(get(res, options!.path?.total, 0));
        // 友好提示
        if (!has(res, options!.path?.data) || !has(res, options!.path?.total)) {
          console.warn("useTable:響應數據缺少所需字段");
        }
      }.finally(() ={
        loading.value = false
      })
    );
  };
 // 立即執行
  options!.immediate && refresh();
  return [data, refresh, loading, pagination];
}

這裏引入了 lodash 庫中的三個工具函數來輔助處理對象:

具體用法可以查看官方文檔 Lodash 中文網 (https://www.lodashjs.com/) 此外,還新增了 loading,可以掛載到 el-table 的 v-loading 上,展示數據加載中的效果。

<el-table v-loding="loading" ...>...</el-table>

改造後:不管接口接受的格式還是響應的格式字段是什麼樣的,都可以正常接收。設置 immediate 爲 true,調用 useTable 時立即執行一遍 api,onMounted 都不用寫了。

<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi } from "./api.ts";
import { useTable } from './customHooks.ts'

const [tableData, refresh, loading, pagination] = useTable(getTableDataApi, {
  path: {
    data: 'data',
    total: 'total',
    page: 'page',
    size: 'limit'
  },
  immediate: true
});

// onMounted(refresh);
</script>

JavaScript 函數傳參技巧

  1. 一般函數定義參數越少越好,最好不要超過兩個,所以這裏我只定義了兩個參數 api 和 options。

  2. 在函數頭上可以給參數定義默認值,但是如果參數是一個對象,只要傳入一個屬性,就不會使用默認值,比如:

export function useTable<T>(
  api: (params: any) => Promise<T>,
  options: {
    path?: { data?: keyPath; total?: keyPath; page?: string; size?: string };
    immediate?: boolean;
  } = {
    path: { data: "data", total: "total", page: "page", size: "size" },
    immediate: false,
  }
){...函數體} 

useTable(xxxApi,{immediate:false})

只要該位置的值非 undefined,那麼 options 將不會使用默認值,這意味着,此時 options 的值爲 {immediate:false},其它地方的默認值不會生效,{path:undefined,}。所以對於函數參數爲對象的,我們往往通過在函數體內賦默認值,比如:

保證options只傳入一個值,其它位置也會有默認值
{
  options.path = options.path || {}
  options.path.data = options.path.data || 'data'
  options.path.total = options.path.total || 'total'
  options.path.page = options.path.page || 'page'
  options.path.size = options.path.size || 'size'
  options.immediate = options.immediate ?? false
}

需要注意元素的層次,在不存在 path 時,給 path. data 賦值會出現錯誤,需要先保證 path 有值,才能給 path 的下一層賦值。

使用 defaults 可以快速給整個對象賦默認值:

  defaults(options, {
    path: { data: "data", total: "total", page: "page", size: "size" },
    immediate: false,
  });

封裝四(接口傳參 - 定義時)

封裝分析

現在,我們的 useTable 趨近完整了:

  1. 維護 api 的調用和刷新(已完成)

  2. 支持分頁查詢(已完成)

  3. 支持 api 參數。

  4. 增加輔助功能 loading、立即執行等。(已完成)

我們還可以讓我們的 api 接受參數。但是如何實現?還需要考慮一下。

首先我們想一想那裏可以接受 api 的參數?

const params = {
 id:2
}

// api本身
getTableDataApi({limit:3,page:2,...params})

// useTable也可以接受參數
const [data,refresh]=useTable(getTableDataApi,params,api)

// refresh也可以接受參數
refresh(params)

從使用上看,我們在 refresh 上接受參數,和我們在 getTableDataApi 的使用上感覺是最相似的,因爲 refresh 本來就是在 api 的基礎上增加 then 維護了頁數而已。但是我們還是先從 useTable 傳參開始講起,最後我們兩種方式都可以接受!

方案一:在調用 useTable 的時候就接受參數,在 useTable 內部將這個參數傳給 refresh。存在問題:如果我們傳入的是值類型,那麼這個值會被拷貝過去,並傳給 refresh,後續調用 refresh,都是不變的參數。只適合需要傳參但參數之後都不會變的接口,比如接受當前用戶的 id。如果參數會變,這種方法是不行的。

function useTable(api,id,options){
 ...
 const refresh=()=>api(id).then(res=>data=res)
 return [data,refresh]
}

const [data,refresh]=useTable(api,id)
refresh()
refresh() // 都是id=2

如果我們傳入的是引用類型,那麼在後續調用中,我們可以通過改變對象的屬性值來改變 refresh 的參數(但是需要一些技巧,因爲我們需要和分頁參數進行結合)。

const params = { id:12 }
function useTable(api,params,options){
 ...
 // 錯誤,使用解構會丟失與原來對象的聯繫,導致原來的對象params更改,但這裏仍使用舊值。
 const refresh=()=>api({[options.path.size]:pagination.size,[options.path.page]:pagination.page,...params}).then(res=>data=res)
 // 正確,可以保持與外部params的聯繫。
 const refresh=()=>api(Object.assign(params,{[options.path.size]:pagination.size,[options.path.page]:pagination.page})).then(res=>data=res)
 return [data,refresh]
}

const [data,refresh]=useTable(api,params)
refresh() // id=12
params.id = 10
refresh() // id=10

這樣,我們就實現了 api 參數的傳遞,而且如果 params 的屬性 id 是響應式的,還可以與頁面結合,實現搜索功能!然而,使用同一個引用 params,可以解決傳參問題,但是還是存在一些問題:在 refresh 中,Object. assign 會給原來的對象 params 增加兩個屬性,要注意避免在 params 中與這兩個屬性發生衝突。另外,我們可以看到這裏的參數間存在了一種優先級,就是如果我們在 param 中也傳入了分頁參數,會在 refresh 中被 pagination 的分頁參數覆蓋調,pagination 的分頁參數比 params 中的分頁參數優先級更高,這樣好嗎?

第一個問題,在 refresh 中每次都會被 pagination 的屬性覆蓋,所以並不會出現什麼問題,除非你在 params 上保存相同屬性名的數據,這將被覆蓋掉。第二個問題和第一個問題本質是一樣的,就是覆蓋問題。根本原因就是都是引用同一個對象。如果我們能夠額外創建一個對象,就不會改變原來的對象,但是如何保持新創建對象能夠動態變化呢?

方案二:試試 useTable 接受傳入函數 params 如何?

const params={id:12}
const paramsFn =()=>{ id: params.id }
function useTable(api,paramsFn(),options){
 ...
 const refresh=()=>api(Object.assign(paramsFn(),{[options.path.size]:pagination.size,[options.path.page]:pagination.page})).then(res=>data=res)
 return [data,refresh]
}

const [data,refresh]=useTable(api,paramsFn)
refresh() // id=12
params.id = 10
refresh() // id=10

完美解決。

最後,兼容一下兩種參數,讓傳入 useTable 的 api 參數既可以是函數,又可以是對象:

export function useTable<T>(
  api: (params: any) => Promise<T>,
  params?: object | (() => object),
  options?: {
    path?: { data?: keyPath; total?: keyPath; page?: string; size?: string }
    immediate?: boolean
  },
) {
  // 參數處理
  defaults(options, {
    path: { data: 'data', total: 'total', page: 'page', size: 'size' },
    immediate: false,
  })

  const [pagination, , , setTotal] = usePagination(() =>refresh())
  const loading = ref(false)
  const data = ref([])

  const refresh = (extraData?: object | (() => object)) ={
    const requestData = {
      [options?.path?.page as string]: pagination.current,
      [options?.path?.size as string]: pagination.size,
    }
    if (params) {
      if (typeof params === 'function') {
        Object.assign(requestData, params())
      } else {
        Object.assign(requestData, params)
      }
    }
    loading.value = true
    return api(requestData)
      .then((res) ={
        data.value = get(res, options!.path?.data, [])
        setTotal(get(res, options!.path?.total, 0))
        if (!has(res, options!.path?.data) || !has(res, options!.path?.total)) {
          console.warn('useTable:響應數據缺少所需字段')
        }
      })
      .finally(() ={
        loading.value = false
      })
  }

  options!.immediate && refresh()

  return [data as T, refresh, loading, pagination]
}

這裏代碼主要新增了三處改變:

  1. 如果 params 是對象,直接使用,如果是函數,則讀取其返回值。

  2. 優先級調整:paginaiton 的參數可以被 params 的同名屬性覆蓋,適用於開發者自己維護分頁參數。

  3. 定義了返回值的類型。

使用示例

試想一個常見,點擊列表的某一項,就展示列表對應 id 的表格,如何實現?

<template>
 <ul>
  // 自定義組件,點擊時emit發送onClick事件並傳入item的id
  <Item v-for="item in list" :key="item.key" :label="item.label" @on-click="handleClick">
  ...
 </ul>
</template>

<script>
...
// 這裏接受item的id
const handleClick=(id:number)=>{
 params.id=number;
 refresh()
}
...
</script>

封裝五(接口傳參 - 調用時)

最後,來讓 refresh 函數也能接受我們的傳參。先看效果:

<script>
...
// 這裏接受item的id
const handleClick=(id:number)=>{
 refresh({id})
}
...
</script>

可以省去 params 和 paramsFn 的定義了!

實現代碼:在定義 refresh 時允許加入參數。

export function useTable<T>(
  api: (params: any) => Promise<T>,
  params?: object | (() => object),
  options?: {
    path?: { data?: keyPath; total?: keyPath; page?: string; size?: string }
    immediate?: boolean
  },
) {
  defaults(options, {
    path: { data: 'data', total: 'total', page: 'page', size: 'size' },
    immediate: false,
  })

  // 使用()=>fn()而不是fn()區別在於後者只是一個值且立即執行
  const [pagination, , , setTotal] = usePagination((extraData?: object) =>
    extraData ? refresh(extraData) : refresh(),
  )
  const loading = ref(false)
  const data = ref([])

  const refresh = (extraData?: object | (() => object)) ={
    const requestData = {
      [options?.path?.page as string]: pagination.current,
      [options?.path?.size as string]: pagination.size,
    }
    if (extraData) {
      if (typeof extraData === 'function') {
        Object.assign(requestData, extraData())
      } else {
        Object.assign(requestData, extraData)
      }
    }
    if (params) {
      if (typeof params === 'function') {
        Object.assign(requestData, params())
      } else {
        Object.assign(requestData, params)
      }
    }
    loading.value = true
    return api(requestData)
      .then((res) ={
        // TODO 檢查響應狀態碼
        data.value = get(res, options!.path?.data, [])
        setTotal(get(res, options!.path?.total, 0))
        // 友好提示
        if (!has(res, options!.path?.data) || !has(res, options!.path?.total)) {
          console.warn('useTable:響應數據缺少所需字段')
        }
      })
      .finally(() ={
        loading.value = false
      })
  }

 return[data,refresh,paginaiton,loading]
}

需要注意的是,usePagination 處接受的回調函數也要適當修改。當然,pagination 也是要修改的了(增加回調函數有參數的情況,之前回調是沒有參數的)。這裏還額外新增了一個 reset 方法,用於重置分頁器狀態,這或許會有用!

export function usePagination(
  cb: any,
  sizeOption: Array<number> = [10, 20, 50, 100, 200],
): any {
  const pagination = reactive({
    current: 1,
    total: 0,
    size: sizeOption[0],
    sizeOption,
    onPageChange: (page: number, extraData?: object) ={
      pagination.current = page
      return extraData ? cb(extraData) : cb()
    },
    onSizeChange: (size: number, extraData?: object) ={
      pagination.current = 1
      pagination.size = size
      return extraData ? cb(extraData) : cb()
    },
    setTotal: (total: number) ={
      pagination.total = total
    },
    reset() {
      pagination.current = 1
      pagination.total = 0
      pagination.size = pagination.sizeOption[0]
    },
  })

  return [
    pagination,
    pagination.onPageChange,
    pagination.onSizeChange,
    pagination.setTotal,
  ]
}

使用:

  <!-- 分頁器 -->
  <el-pagination
    v-model:current-page="current"
    :page-size="size"
    layout="total, prev, pager, next"
    :page-sizes="sizeOption"
    :total="total"
    @size-change="(size)=>handleSizeChange(size,params.id)"
    @current-change="(page)=>handleCurrentChange(page,params.id)"
  />

在此之前,需要保存 item. id 作爲全局變量以供讀取。

const handleClick=(id:number)=>{
 params.id=id;
}

這樣,我們就完成了一個功能相對完善的 Hook 函數。

總結

本文通過介紹 Hook 的概念和使用方法,並在實踐的過程中封裝了一個功能相對完善的 Hook 函數,但是它還有很多可以拓展的地方,比如 useTable 中可以再導出一個 clear 函數,用來將 data 賦值爲空數組,以及對 data 數據的每一項進行查找、刪除,或者新增一個 showData,用來過濾 data 並展示在視圖上,總之,我們打開了 Hook 世界的大門,看到了 Hook 這項技術的強大之處:狀態複用!

因爲本文主要講解 Hook 封裝,所以比較少提及組件封裝。如果代碼需要複用,首先考慮組件封裝,因爲它可以對 html、css 和 javacript 代碼進行復用,而 Hook 只是複用 JavaScript 代碼。如果將二者結合,能夠高效地提高你的開發效率,以及項目的可維護性,幫助你寫出優雅的代碼。

作者:天氣好 

https://juejin.cn/post/7299849645206781963

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DqydkjBKo8qUPbquGH-KxQ