【vue3】寫hook三天,治好了我的元件封裝強迫症。

語言: CN / TW / HK

前言

我以前很喜歡封裝元件,什麼東西不喜歡別人的,總喜歡自己搞搞,這讓人很有成就感,雖然是重複造輪子,但是能從無聊的crud業務中暫時解脫出來,對我來說也算是一種休息,相信有很多人跟我一樣有這個習慣。 這種習慣在獨立開發時無所謂,畢竟沒人會關心你咋實現的,但是在跟人合作時就給別人造成了很大的困擾了,畢竟每個人封裝的東西都是根據自己習慣來的,別人看著多少會有點不順眼,而且自己封裝的元件大概率也是沒有寫文件和註釋的,所以專案其他成員的使用率也不會太高,所以今天,我試著解決這個問題。 另外,我還在一些群裡看到有人抱怨vue3不如vue2好用,主要是適應不了setup寫法,希望這篇部落格能改變你的看法。

怎麼用hook改造我的元件

關於hook是什麼之類的介紹,我這就不贅述了,請看這篇文章淺談:為啥vue和react都選擇了Hooks🏂?。 前言中說到重複造輪子的元件,除開一些毫無必要的重複以外,有一些功能元件確實需要封裝一下,比如說,一些需要請求後端字典到前端展示的下來選擇框,點選之後要展示loading狀態的按鈕,帶有查詢條件的表單,這些非常常用的業務場景,我們就可以封裝成元件,但是封裝成元件就會遇到前面說的問題,每個人的使用習慣和封裝習慣不一樣,很難讓每個人都滿意,這種場景,就可以讓hook來解決。

普通實現

就拿字典選擇下拉框來說,如果不做封裝,我們是這樣寫的 (這裡拿ant-design-vue元件庫來做示例)

```ts

```

看起來很簡單是吧,忽略我們模擬呼叫介面的程式碼,我們用在ts/js部分的程式碼才只有6行而已,看起來根本不需要什麼封裝。

但是這只是一個最簡單的邏輯,不考慮介面請求超時和錯誤的情況,甚至都沒考慮下拉框的loading表現。 如果我們把所有的意外情況都考慮到的話,程式碼就會變得很臃腫了。

```ts

```

這一次,程式碼直接來到了22行,雖說使用者體驗確實好了不少,但是這也忒費事了,而且這還只是一個下拉框,頁面裡有好幾個下拉框也是很常見的,如此這般,可能什麼邏輯都沒寫,頁面程式碼就要上百行了。

這個時候,就需要我們來封裝一下了,我們有兩種選擇:

  1. 把字典下拉框封裝成一個元件
  2. 把請求、載入中、錯誤這些處理邏輯封裝到hook裡;

第一種大家都知道,就不多說了,直接說第二種

封裝下拉框hook

```ts import { onMounted, reactive, ref } from 'vue'; // 定義下拉框接收的資料格式 export interface SelectOption { value: string; label: string; disabled?: boolean; key?: string; } // 定義入參格式 interface FetchSelectProps { apiFun: () => Promise; }

export function useFetchSelect(props: FetchSelectProps) { const { apiFun } = props;

const options = ref([]);

const loading = ref(false);

/ 呼叫介面請求資料 / const loadData = () => { loading.value = true; options.value = []; return apiFun().then( (data) => { loading.value = false; options.value = data; return data; }, (err) => { // 未知錯誤,可能是程式碼丟擲的錯誤,或是網路錯誤 loading.value = false; options.value = [ { value: '-1', label: err.message, disabled: true, }, ]; // 接著丟擲錯誤 return Promise.reject(err); } ); };

// onMounted 中呼叫介面 onMounted(() => { loadData(); });

return reactive({ options, loading, }); } ```

然後在元件中呼叫 ```ts

```

這樣一來,程式碼行數直接又從20行降到3行,甚至比剛開始最簡單的那個還要少兩行,但是功能卻一點不少,使用者體驗也是比較完善的。

如果你覺著上面這個例子不能打動你的話,可以看看下面這個

Loading狀態hook

點選按鈕,呼叫介面是另一個我們經常遇到的場景,為了更好的使用者體驗,提示使用者操作已經響應,同時防止使用者多次點選,我們要在呼叫介面的同時將按鈕置為loading狀態,雖說只有一個loading狀態,但是寫多了也覺著麻煩。

為此我們可以封裝一個非常簡單的hook:

hook.ts ```ts import { Ref, ref } from 'vue';

type TApiFun> = (...params: TParams) => Promise;

interface AutoRequestOptions { // 定義一下初始狀態 loading?: boolean; // 介面呼叫成功時的回撥 onSuccess?: (data: any) => void; }

type AutoRequestResult> = [Ref, TApiFun];

/ 控制loading狀態的自動切換hook / export function useAutoRequest(fun: TApiFun, options?: AutoRequestOptions): AutoRequestResult { const { loading = false, onSuccess } = options || { loading: false };

const requestLoading = ref(loading);

const run: TApiFun = (...params) => { requestLoading.value = true; return fun(...params) .then((res) => { onSuccess && onSuccess(res); return res; }) .finally(() => { requestLoading.value = false; }); };

return [requestLoading, run]; } ``` 這次把模擬介面的方法單獨抽出一個檔案

api/index.ts ts export function submitApi(text: string) { return new Promise((resolve, reject) => { setTimeout(() => { // 模擬介面呼叫有概率出錯 if (Math.random() > 0.5) { resolve({ status: "ok", text: text, }); } else { reject(new Error("不小心出錯了!")); } }, 3000); }); } 使用:

index.vue ```ts

`` 這樣封裝一下,我們使用時就不再需要手動切換loading`的狀態了。

這個hook還有另一種玩法:

hook2.ts ```ts import type { Ref } from "vue"; import { ref } from "vue";

type AutoLoadingResult = [ Ref, (requestPromise: Promise) => Promise ];

/ 在給run方法傳入一個promise,會在promise執行前或執行後將loading狀態設為true,在執行完成後設為false / export function useAutoLoading(defaultLoading = false): AutoLoadingResult { const ld = ref(defaultLoading);

function run(requestPromise: Promise): Promise { ld.value = true; return requestPromise.finally(() => { ld.value = false; }); }

return [ld, run]; } ``` 使用:

index.vue ```ts

```

這裡也是用到了promise鏈式呼叫的特性,在介面呼叫之後馬上將loading置為true,在介面呼叫完成後置為false。而useAutoRequest則是在介面呼叫之前就將loading置為true。

useAutoRequest呼叫時程式碼更簡潔,useAutoLoading的使用則更靈活,可以同時服務給多個介面使用,比較適合提交取消這種互斥的場景。

解放元件

如果你翻看過我的這篇部落格一個省心省力的骨架屏實現方案,那麼肯定知道在骨架屏元件中,我是用了傳入的res物件的code屬性來判斷當前顯示的檢視狀態。長話短說就是, res是介面返回給前端的資料,如 json { "code":0, "msg":'查詢成功', "data":{ "username":"小王", "age":20, } } 我們假定當code0時代表成功,不為0表示失敗,為-100時表示正在載入,當然介面並不會也不需要返回-100-100是我們本地捏造出來的,只是為了讓骨架屏元件顯示對應的載入狀態。 在頁面中使用時,我們需要先宣告一個code-100res物件繫結給骨架屏元件,然後在onMounted中呼叫查詢介面,呼叫成功後更新res物件。

如果像上面這樣使用res物件來給骨架屏元件設定狀態的話,就感覺非常的麻煩,有時候我們只是要設定一個初始時的載入狀態,但是要搞好幾行沒用的程式碼,但是如果我們把res拆解成一個個引數單獨傳遞的話,父元件需要維護的變數就會非常多了,這時我們就可以封裝hook來解決這個問題,把拆解出來的引數都扔到hook裡面儲存。

上程式碼(這部分程式碼比較長,想要詳細瞭解的話可以去看原文章)

骨架屏元件

SkeletonView/index.vue ```ts

`` 這裡樣式中用到的no_url.png`只是一張空白透明圖片,防止載入時圖片顯示裂圖。

hook程式碼 useAutoSkeletonView.ts ```ts import { computed, onMounted, reactive, ref } from "vue"; import type { UnwrapRef } from "vue";

type TApiFun> = ( ...params: TParams ) => Promise;

/ 定義可自定義的預設狀態 / export type SkeletonStatus = "loading" | "success";

export interface IUseAutoSkeletonViewProps { apiFun: TApiFun;// 呼叫介面api placeholderResult?: TData; // 骨架屏用到的佔位資料 queryInMount?: boolean; // 在父元件掛載時自動呼叫介面,預設true initQueryParams?: TParams; // 呼叫介面用到的引數 transformDataFun?: (data: TData) => TData; // 介面請求完成後,轉換資料 updateParamsOnFetch?: boolean; // 手動呼叫介面後,更新請求引數 defaultStatus?: SkeletonStatus; // 預設骨架屏元件狀態 onSuccess?: (data: any) => void; // 介面呼叫成功的回撥 isEmpty?: (data: TData) => boolean; // 重寫骨架屏判空邏輯 }

export type IAutoSkeletonViewResult = UnwrapRef<{ execute: TApiFun; result: TData | null; retry: () => Promise; loading: boolean; status: SkeletonStatus | "error"; getField: (key: string) => any; bindProps: { result: TData | null; status: SkeletonStatus | "error"; errorMsg: string; placeholderResult?: TData; isEmpty?: (data: TData) => boolean; }; bindEvents: { retry: () => Promise; }; }>;

export function useAutoSkeletonView( prop: IUseAutoSkeletonViewProps ): IAutoSkeletonViewResult { const { apiFun, defaultStatus = "loading", placeholderResult, isEmpty, initQueryParams = [], transformDataFun, onSuccess, updateParamsOnFetch = true, queryInMount = true, } = prop;

const status = ref(defaultStatus);

const result = ref(null);

const placeholder = ref(placeholderResult);

const errorMsg = ref("");

const lastFetchParams = ref(initQueryParams as TParams);

const executeApiFun: TApiFun = (...params: TParams) => { if (updateParamsOnFetch) { lastFetchParams.value = params; }

status.value = "loading";

return apiFun(...params)
  .then((res) => {
    let data: any = res;
    if (transformDataFun) {
      data = transformDataFun(res);
    }
    placeholder.value = data;
    result.value = data;
    status.value = "success";
    onSuccess && onSuccess(data);
    return res;
  })
  .catch((e) => {
    console.error("--useAutoSkeletonView--", e);
    status.value = "error";
    errorMsg.value = e.message;
    throw e;
  });

};

function retry() { return executeApiFun(...(lastFetchParams.value as TParams)); }

onMounted(() => { if (queryInMount && defaultStatus === "loading") { executeApiFun(...(initQueryParams as TParams)); } });

const loading = computed(() => { return status.value === "loading"; });

function getField(key: string) { if (status.value !== "success") { return ""; } if (result.value) { // @ts-ignore return result.value[key]; } return ""; }

return reactive({ execute: executeApiFun, result: result, retry, loading, status, getField, bindProps: { result: result, status, errorMsg, placeholderResult: placeholder, isEmpty, }, bindEvents: { retry: retry, }, }); } ```

使用 index.vue ```ts

```

這裡的SkeletonView不光用v-bind綁定了hook丟擲的屬性,還用v-on繫結的事件,目的就是監聽請求報錯時出現的“重試”按鈕的點選事件。

使用優化

經常寫react的朋友可能早就看出來了,這不是跟react中的一部分hook用法如出一轍嗎?沒錯,很多人寫react就這麼寫,而且react中繫結hook跟元件更簡單,只需要...就可以了,比如: tsx function Demo(){ const select = useSelect({ apiFun:getDict }) // 這裡可以直接用...將useSelect返回的屬性與方法全部繫結給Select元件 return <Select {...select}>; } 比起vuev-bindv-on算是簡便了不少。那麼,有沒有一種辦法也能做到差不多的效果呢?就比如能做到v-xxx="select"

博主首先想到的就是vue的自定義指令了,文件在這裡,但是折騰了半天發現行不通,因為自定義指令主要還是針對dom來的。vue官網原話:

總的來說,推薦在元件上使用自定義指令。

那麼就只能考慮打包外掛了,只要我們在vue解析template之前把v-xxx="select"翻譯成v-bind="select.bindProps" v-on="select.bindEvents" 就好了,聽起來並不難,只要我們開發的時候規定繫結元件的hook返回格式必須有bindPropsbindEvents就好了。

思路有了,直接開幹,現在vue官網的預設建立方式也改成vite,我們就直接寫vite的外掛(不想看可以跳到最後用現成的):

```ts // component-enhance-hook import type { PluginOption } from "vite";

// 可以自定義hook繫結的字首、繫結的屬性值合集對應的鍵和事件合集對應的鍵 type HookBindPluginOptions = { prefix?: string; bindKey?: string; eventKey?: string; }; export const viteHookBind = (options?: HookBindPluginOptions): PluginOption => { const { prefix, bindKey, eventKey } = Object.assign( { prefix: "v-ehb", bindKey: "bindProps", eventKey: "bindEvents", }, options );

return { name: "vite-plugin-vue-component-enhance-hook-bind", enforce: "pre", transform: (code, id) => { const last = id.substring(id.length - 4);

  if (last === ".vue") {
    // 處理之前先判斷一下
    if (code.indexOf(prefix) === -1) {
      return code;
    }
    // 獲取 template 開頭
    const templateStrStart = code.indexOf("<template>");
    // 獲取 template 結尾
    const templateStrEnd = code.lastIndexOf("</template>");

    let templateStr = code.substring(templateStrStart, templateStrEnd + 11);

    let startIndex;
    // 迴圈轉換 template 中的hook繫結指令
    while ((startIndex = templateStr.indexOf(prefix)) > -1) {
      const endIndex = templateStr.indexOf(`"`, startIndex + 7);
      const str = templateStr.substring(startIndex, endIndex + 1);
      const obj = str.split(`"`)[1];

      const newStr = templateStr.replace(
        str,
        `v-bind="${obj}.${bindKey}" v-on="${obj}.${eventKey}"`
      );

      templateStr = newStr;
    }

    // 拼接並返回
    return (
      code.substring(0, templateStrStart) +
      templateStr +
      code.substring(templateStrEnd + 11)
    );
  }

  return code;
},

}; }; 應用外掛ts import { fileURLToPath, URL } from "node:url";

import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import vueJsx from "@vitejs/plugin-vue-jsx";

import { viteHookBind } from "./vBindPlugin";

// https://vitejs.dev/config/ export default defineConfig({ plugins: [vue(), vueJsx(), viteHookBind()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), }, }, }); ```

修改一下vue中的用法 ```ts

```

OK! 完成了!

使用npm安裝

不過我也提前打包編譯好了釋出在了npm上,需要的話可以直接使用這個

npm i vite-plugin-vue-hook-enhance -D

改一下引入方式就可以了 ts import { viteHookBind } from "vite-plugin-vue-hook-enhance";