【vue3】寫hook三天,治好了我的元件封裝強迫症。
前言
我以前很喜歡封裝元件,什麼東西不喜歡別人的,總喜歡自己搞搞,這讓人很有成就感,雖然是重複造輪子,但是能從無聊的crud業務中暫時解脫出來,對我來說也算是一種休息,相信有很多人跟我一樣有這個習慣。 這種習慣在獨立開發時無所謂,畢竟沒人會關心你咋實現的,但是在跟人合作時就給別人造成了很大的困擾了,畢竟每個人封裝的東西都是根據自己習慣來的,別人看著多少會有點不順眼,而且自己封裝的元件大概率也是沒有寫文件和註釋的,所以專案其他成員的使用率也不會太高,所以今天,我試著解決這個問題。 另外,我還在一些群裡看到有人抱怨vue3不如vue2好用,主要是適應不了setup寫法,希望這篇部落格能改變你的看法。
怎麼用hook改造我的元件
關於hook是什麼之類的介紹,我這就不贅述了,請看這篇文章淺談:為啥vue和react都選擇了Hooks🏂?。 前言中說到重複造輪子的元件,除開一些毫無必要的重複以外,有一些功能元件確實需要封裝一下,比如說,一些需要請求後端字典到前端展示的下來選擇框,點選之後要展示loading狀態的按鈕,帶有查詢條件的表單,這些非常常用的業務場景,我們就可以封裝成元件,但是封裝成元件就會遇到前面說的問題,每個人的使用習慣和封裝習慣不一樣,很難讓每個人都滿意,這種場景,就可以讓hook來解決。
普通實現
就拿字典選擇下拉框來說,如果不做封裝,我們是這樣寫的 (這裡拿ant-design-vue元件庫來做示例)
```ts
```
看起來很簡單是吧,忽略我們模擬呼叫介面的程式碼,我們用在ts/js部分的程式碼才只有6行而已,看起來根本不需要什麼封裝。
但是這只是一個最簡單
的邏輯,不考慮介面請求超時和錯誤的情況,甚至都沒考慮下拉框的loading
表現。
如果我們把所有的意外情況
都考慮到的話,程式碼就會變得很臃腫了。
```ts
```
這一次,程式碼直接來到了22
行,雖說使用者體驗確實好了不少,但是這也忒費事了,而且這還只是一個下拉框,頁面裡有好幾個下拉框也是很常見的,如此這般,可能什麼邏輯都沒寫,頁面程式碼就要上百行了。
這個時候,就需要我們來封裝一下了,我們有兩種選擇:
- 把字典下拉框封裝成一個
元件
; - 把請求、載入中、錯誤這些處理邏輯封裝到
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
interface AutoRequestOptions { // 定義一下初始狀態 loading?: boolean; // 介面呼叫成功時的回撥 onSuccess?: (data: any) => void; }
type AutoRequestResult
/ 控制loading狀態的自動切換hook /
export function useAutoRequest
const requestLoading = ref(loading);
const run: TApiFun
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
/ 在給run方法傳入一個promise,會在promise執行前或執行後將loading狀態設為true,在執行完成後設為false / export function useAutoLoading(defaultLoading = false): AutoLoadingResult { const ld = ref(defaultLoading);
function run
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,
}
}
我們假定當code
為0
時代表成功,不為0
表示失敗,為-100
時表示正在載入,當然介面並不會也不需要返回-100
,-100
是我們本地捏造出來的,只是為了讓骨架屏元件顯示對應的載入狀態。
在頁面中使用時,我們需要先宣告一個code
為-100
的res
物件繫結給骨架屏元件,然後在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
/ 定義可自定義的預設狀態 / export type SkeletonStatus = "loading" | "success";
export interface IUseAutoSkeletonViewProps
export type IAutoSkeletonViewResult
export function useAutoSkeletonView
const status = ref
const result = ref
const placeholder = ref
const errorMsg = ref("");
const lastFetchParams = ref
const executeApiFun: TApiFun
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}>;
}
比起vue
的v-bind
和v-on
算是簡便了不少。那麼,有沒有一種辦法也能做到差不多的效果呢?就比如能做到v-xxx="select"
。
博主首先想到的就是vue的自定義指令了,文件在這裡,但是折騰了半天發現行不通,因為自定義指令主要還是針對dom來的。vue官網原話:
總的來說,不推薦在元件上使用自定義指令。
那麼就只能考慮打包外掛了,只要我們在vue
解析template
之前把v-xxx="select"
翻譯成v-bind="select.bindProps" v-on="select.bindEvents"
就好了,聽起來並不難,只要我們開發的時候規定繫結元件的hook返回格式必須有bindProps
和bindEvents
就好了。
思路有了,直接開幹,現在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";