
在 Vue 3 的世界裡,Composition API 為我們帶來了更靈活、更強大的組件編寫方式。而 @vueuse/core 和自定義 Composables 則是在這基礎上,進一步提升我們的開發效率和代碼質量的利器。今天,我們將深入探討如何運用這些工具,讓我們的 Vue 3 + TypeScript 開發更加高效和優雅。
@vueuse/core 是一個基於 Composition API 的實用函數集合,它提供了大量常用的邏輯封裝,幫助我們快速實現各種功能,從而減少重複編碼的工作。
首先,讓我們安裝 @vueuse/core:
bun add @vueuse/core
現在,讓我們通過幾個例子來看看 @vueuse/core 如何提升我們的開發效率。
useLocalStorageimport { ref, computed } from 'vue'
import { useLocalStorage } from '@vueuse/core'
const count = useLocalStorage('count', 0)
const doubleCount = computed<number>(() => count.value * 2)
const increment = () => {
  count.value++;
}
這個例子展示了如何使用 useLocalStorage 來持久化數據,無需手動處理 localStorage 的讀寫。
也因為這樣我們可以經由 pinia 可以實現永久儲存的狀況,
import { computed, readonly } from "vue"
import { defineStore, acceptHMRUpdate } from "pinia";
import { useLocalStorage } from "@vueuse/core";
export const useBaseStore = defineStore("useBaseStore", () => {
  // state::
  const count = useLocalStorage('count', 0);
  // getter::
  const doubleCount = computed<number>(() => {
    return count.value * 2;
  });
  // methods::
  const increment = (): void => {
    count.value++;
  };
  return {
    // state::
    count: readonly(count),
    // getters::
    doubleCount,
    //methods::
    increment
  }
})
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useBaseStore, import.meta.hot));
}
這個例子,可以讓我們即便重整頁面,狀態仍然都可以存在
useWindowSizeimport { useWindowSize } from '@vueuse/core'
const { width, height } = useWindowSize();
useWindowSize 幫助我們輕鬆獲取並響應窗口大小的變化,非常適合用於響應式設計。
注意:因為 useWindowSize 或是 useMouse 會偵測滑鼠指標或是螢幕變化,所以假設有兩三個不同的 component 使用 useWindowSize,那就會浪費多餘的資源和監控 window size 的變化。
vueuse 也提供了 createSharedComposable 方法針對資源調用可以進行一些優化。
<script setup lang="ts">
  import { createSharedComposable, useWindowSize } from '@vueuse/core';
  const useSharableWindowSize = createSharedComposable(useWindowSize);
  const { width, height } = useSharableWindowSize();
</script>
<template>
  <div data-testid="area" rounded-lg px-3 py-2 border="solid 1px red-500" bg-red-200>
    寬:{{ width }} 高:{{ height }}
  </div>
</template>
以上例子即便在其他地方使用 useWindowSize 只會進行一次性的監控,但狀態可以在不同地方享有。
除了使用現成的工具,創建自定義的 Composables 也是提升開發效率的重要方式。
讓我們創建一個 useApiFetch Composable,用於處理 API 請求並且封裝 zod 去驗證回傳資料和錯誤:
import type { AfterFetchContext, BeforeFetchContext, OnFetchErrorContext } from '@vueuse/core';
import { createFetch, useLocalStorage } from '@vueuse/core';
import type { MaybeRef } from 'vue';
import { toValue } from 'vue';
import * as zod from 'zod';
export type RequestInput = string | number | boolean | File;
export type RequestInputs = RequestInput | RequestInput[];
export type RequestDataStructureInputs = RequestInputs | Record<string, RequestInputs> | Record<string, RequestInputs>[];
export type RequestJsonInputs = Record<string, RequestDataStructureInputs> | Record<string, RequestDataStructureInputs>[];
export interface CustomFetchErrorCtx {
  data: unknown
  response: Response | null
  error: string;
}
export interface UseCustomFetchOptions {
  isBearerTokenRequired?: boolean
  query?: MaybeRef<Record<string, RequestInputs>>
  json?: MaybeRef<RequestJsonInputs>
  formData?: MaybeRef<Record<string, RequestInputs>>
  responseSchema?: zod.ZodTypeAny
  errorResponseSchema?: zod.ZodTypeAny
}
export type UseCustomFetchOptionsKey = UseCustomFetchOptions[keyof UseCustomFetchOptions];
export const useApiFetch = () => {
  const useApi = (options: UseCustomFetchOptions) =>
    createFetch({
      baseUrl: `${import.meta.env.VITE_APP_API ?? ''}`,
      options: {
        timeout: 30000,
        immediate: false,
        beforeFetch: getBeforeFetch(options),
        afterFetch: getAfterFetch(options),
        onFetchError: getOnFetchError(options)
      },
      fetchOptions: {
        mode: 'cors'
      }
    });
  const getBeforeFetch = (options: UseCustomFetchOptions): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
    const { isBearerTokenRequired, query, json, formData } = options;
    return (ctx: BeforeFetchContext) =>
      fetchCurryFn<BeforeFetchContext>(ctx, [
        getAuthorizationBeforeFetch(isBearerTokenRequired),
        getQueryBeforeFetch(query),
        getJsonFormatBeforeFetch(json),
        getFormDataFormatBeforeFetch(formData),
      ]);
  };
  const fetchCurryFn = <T extends BeforeFetchContext | AfterFetchContext | CustomFetchErrorCtx>(
    ctx: T,
    fnList: ((ctx: T) => T)[]
  ): T => fnList.reduce((acc, fn) => fn(acc), ctx);
  const getAuthorizationBeforeFetch = (isTokenRequired: boolean = false): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
    const token = useLocalStorage('token', '');
    if (!isTokenRequired)
      return noActionContext<BeforeFetchContext>;
    return (ctx: BeforeFetchContext) => {
      if (!token) {
        ctx.cancel();
        return ctx;
      }
      ctx.options.headers = {
        ...ctx.options.headers,
        Authorization: `Bearer ${token}`
      };
      return ctx;
    };
  };
  const getQueryBeforeFetch = <T extends string | number | boolean | File>(
    query?: MaybeRef<Record<string, T | T[]>>
  ): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
    if (!query)
      return noActionContext<BeforeFetchContext>;
    const currentQuery = toValue(query);
    if (!currentQuery)
      return noActionContext<BeforeFetchContext>;
    if (Object.keys(currentQuery).length === 0)
      return noActionContext<BeforeFetchContext>;
    return (ctx: BeforeFetchContext): BeforeFetchContext => {
      ctx.url += `?${generateQueryString(currentQuery)}`;
      return ctx;
    };
  };
  const generateQueryString = <T extends string | number | boolean | File>(queryData: Record<string, T | T[]>): string => {
    const query = new URLSearchParams();
    for (const [key, value] of Object.entries(queryData)) {
      if (Array.isArray(value)) {
        value.forEach(el => query.append(key, el.toString()));
        continue;
      }
      if (checkIsNotEmpty(value)) {
        query.append(key, value.toString());
      }
    }
    const queryString = query.toString();
    return queryString.length > 0 ? `${queryString}` : '';
  };
  const checkIsNotEmpty = (val: unknown) => {
    if (typeof val === 'number' || typeof val === 'boolean')
      return true;
    return val !== '' && typeof val !== 'undefined';
  };
  const getJsonFormatBeforeFetch = (
    jsonInput?: MaybeRef<RequestJsonInputs>
  ): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
    if (!jsonInput)
      return noActionContext<BeforeFetchContext>;
    const currentRawData = toValue(jsonInput);
    if (!currentRawData)
      return noActionContext<BeforeFetchContext>;
    return (ctx: BeforeFetchContext): BeforeFetchContext => {
      ctx.options.headers = {
        ...ctx.options.headers,
        'Content-Type': 'application/json'
      };
      ctx.options.body = JSON.stringify(removeNullishInRecursiveObject(currentRawData));
      return ctx;
    };
  };
  const removeNullishInRecursiveObject = (obj: RequestJsonInputs): RequestJsonInputs => {
    if (Array.isArray(obj)) {
      return obj.map(el => removeNullishInRecursiveObject(el)) as RequestJsonInputs;
    }
    // if obj is boolean or number
    if (isAllowBooleanNumberString(obj))
      return obj;
    // if obj is object but not array
    const entries = Object.entries(obj)
      .filter(([, v]) => {
        if (!isAllowBooleanNumberAndObject)
          return isNotEmpty(v);
        return true;
      })
      .map(([k, v]) => {
        if (Array.isArray(v)) {
          return [
            k,
            v.filter(el => isNotEmptyExcludeEmptyString(el)).map(el => removeNullishInRecursiveObject(el as RequestJsonInputs))
          ];
        }
        if (isFile(v))
          return [k, v];
        if (typeof v === 'object') {
          return [k, removeNullishInRecursiveObject(v)];
        }
        return [k, v];
      });
    return Object.fromEntries(entries);
  };
  const isNotEmpty = (v: unknown): boolean => {
    return v !== '' && isNotEmptyExcludeEmptyString(v);
  };
  const isNotEmptyExcludeEmptyString = (v: unknown): boolean => {
    return v !== undefined && v !== null;
  };
  const isAllowBooleanNumber = (v: unknown): boolean => {
    if (typeof v === 'boolean')
      return true;
    return typeof v === 'number';
  };
  const isAllowBooleanNumberString = (v: unknown): boolean => {
    return typeof v === 'string' || isAllowBooleanNumber(v);
  };
  const isAllowBooleanNumberAndObject = (v: unknown): boolean => {
    if (isAllowBooleanNumber(v))
      return true;
    if (typeof v === 'object')
      return true;
    return false;
  };
  const getFormDataFormatBeforeFetch = <T extends string | number | boolean | File>(
    formDataInput?: MaybeRef<Record<string, T | T[]>>
  ): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
    if (!formDataInput)
      return noActionContext<BeforeFetchContext>;
    const currentRawData = toValue(formDataInput);
    if (!currentRawData)
      return noActionContext<BeforeFetchContext>;
    return (ctx: BeforeFetchContext): BeforeFetchContext => {
      ctx.options.body = convertObjectToFormData(currentRawData);
      return ctx;
    };
  };
  const isFile = (input: unknown): input is File => {
    return input instanceof File;
  };
  const convertFormDataResult = <T extends string | number | boolean | File>(input: T): File | string => {
    if (isFile(input))
      return input;
    return input.toString();
  };
  const convertObjectToFormData = <T extends string | number | boolean | File>(obj: Record<string, T | T[]>): FormData => {
    const formData = new FormData();
    for (const [key, value] of Object.entries(obj)) {
      if (Array.isArray(value)) {
        value.forEach(el => formData.append(key, convertFormDataResult(el)));
        continue;
      }
      if (value === null || typeof value === 'undefined') {
        continue;
      }
      formData.append(key, convertFormDataResult(value));
    }
    return formData;
  };
  const getAfterFetch = (options: UseCustomFetchOptions): ((ctx: AfterFetchContext) => AfterFetchContext) => {
    const { responseSchema, errorResponseSchema } = options;
    return (ctx: AfterFetchContext) =>
      fetchCurryFn<AfterFetchContext>(ctx, [
        responseSchemaAfterFetch(responseSchema),
        errorSchemaAfterFetch(errorResponseSchema)
      ]);
  };
  const responseSchemaAfterFetch = (responseSchema?: zod.ZodTypeAny): ((ctx: AfterFetchContext) => AfterFetchContext) => {
    if (!responseSchema)
      return noActionContext<AfterFetchContext>;
    return (ctx: AfterFetchContext) => {
      if (!ctx.response.ok)
        return ctx;
      const validatedResponse = responseSchema.safeParse(ctx.data);
      if (!validatedResponse.success) {
        if (import.meta.env.MODE !== 'production') {
          console.group(`%c ${ctx.response.url} [api response] type error`, 'color: yellow;');
          console.log(validatedResponse);
          console.groupEnd();
        }
      }
      return ctx;
    };
  };
  const errorSchemaAfterFetch = (errorResponseSchema?: zod.ZodTypeAny): ((ctx: AfterFetchContext) => AfterFetchContext) => {
    if (!errorResponseSchema)
      return noActionContext<AfterFetchContext>;
    return (ctx: AfterFetchContext) => {
      if (ctx.response.ok)
        return ctx;
      const validatedError = errorResponseSchema.safeParse(ctx.data);
      if (!validatedError.success) {
        if (import.meta.env.MODE !== 'production') {
          console.group('%c [api error] type error', 'color: yellow;');
          console.log(validatedError.error);
          console.groupEnd();
        }
        throw new TypeError('型別錯誤');
      }
      return ctx;
    };
  };
  const getOnFetchError = (
    options: UseCustomFetchOptions
  ): ((ctx: CustomFetchErrorCtx) => Promise<Partial<OnFetchErrorContext>> | Partial<OnFetchErrorContext>) => {
    const { } = options;
    return (ctx: CustomFetchErrorCtx) =>
      fetchCurryFn<CustomFetchErrorCtx>(ctx, [
        typeErrorOnFetchError(), // 這裡可以持續擴展 
      ]);
  };
  const typeErrorOnFetchError = (): ((ctx: CustomFetchErrorCtx) => CustomFetchErrorCtx) => {
    return (ctx: CustomFetchErrorCtx) => {
      if (ctx.error === 'someError') {
        // type error do something
        return ctx;
      }
      return ctx;
    };
  };
 const noActionContext = <T extends BeforeFetchContext | AfterFetchContext | CustomFetchErrorCtx>(ctx: T): T => ctx;
  return {
    useApi
  };
};
export type UseApiFetch = typeof useApiFetch;
現在,讓我們在組件中使用這個自定義的 Composable:
  const mySampleApi = () => {
    const responseSchema = zod.object({
      message: zod.string()
    });
    return useApi({
      responseSchema
    })<zod.infer<typeof responseSchema>>('/api/hello')
      .post()
      .json<zod.infer<typeof responseSchema>>();
  };
這個例子展示了如何使用我們的自定義 Composable 來處理 API 請求,包括加載狀態和錯誤處理。
並且針對回傳格式用 zod 進行驗證。
最後,讓我們看一個結合 @vueuse/core 和自定義 Composables 的高級例子:
import { useBreakpoints, useColorMode} from '@vueuse/core';
  import { useApiFetch } from '../composables/useApiFetch';
  import * as zod from 'zod';
  const { useApi } = useApiFetch()
  // 這裡展示 vuesue 的 顏色 mode
  const colorMode = useColorMode()
  // 還可以針對斷點做處理
  const breakpoints = useBreakpoints({
    mobile: 640,
    tablet: 768,
    desktop: 1024,
  })
  const isMobile = breakpoints.smaller('tablet') // 判斷是否是手機
  // 這裡展示怎使用 createFetch 自定義的 api 使用
  const mySampleApi = () => {
    const responseSchema = zod.object({
      message: zod.string()
    });
    return useApi({
      responseSchema
    })<zod.infer<typeof responseSchema>>('/api/hello')
      .post()
      .json<zod.infer<typeof responseSchema>>();
  };
  // 這裡的 data 已經經過 zod 驗證, 只要呼叫 execute 即可直接 call api
  const { data, execute, error } = mySampleApi();
這個例子展示了如何結合使用 @vueuse/core 的功能(斷點處理和顏色模式)與自定義的 API 處理 Composable,創建一個響應式的、支持暗黑模式的數據展示組件。
通過運用 @vueuse/core 和創建自定義 Composables,我們可以顯著提高 Vue 3 和 TypeScript 的開發效率。@vueuse/core 為我們提供了大量即用的工具,幫助我們快速實現常見功能。而自定義 Composables 則允許我們根據項目需求,將業務邏輯封裝成可復用的單元。
結合這兩種方法,我們可以創建出更加模塊化、可維護、和高效的 Vue 3 應用。記住,好的工具和模式可以大大提升我們的開發體驗和效率,但關鍵是要根據實際需求靈活運用。持續學習和實踐這些技巧,將使您在 Vue 3 和 TypeScript 的開發之路上走得更遠、更快。