iT邦幫忙

2024 iThome 鐵人賽

DAY 13
1
Modern Web

Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器系列 第 13

Day 13: 使用 @vueuse/core 和自定義 Composables 提升 Vue 3 開發效率

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20240921/20117461PTgswzaI4K.jpg

介紹

在 Vue 3 的世界裡,Composition API 為我們帶來了更靈活、更強大的組件編寫方式。而 @vueuse/core 和自定義 Composables 則是在這基礎上,進一步提升我們的開發效率和代碼質量的利器。今天,我們將深入探討如何運用這些工具,讓我們的 Vue 3 + TypeScript 開發更加高效和優雅。

@vueuse/core:您的 Vue 3 瑞士軍刀

什麼是 @vueuse/core?

@vueuse/core 是一個基於 Composition API 的實用函數集合,它提供了大量常用的邏輯封裝,幫助我們快速實現各種功能,從而減少重複編碼的工作。

安裝和基本使用

首先,讓我們安裝 @vueuse/core

bun add @vueuse/core

現在,讓我們通過幾個例子來看看 @vueuse/core 如何提升我們的開發效率。

例子 1:使用 useLocalStorage

import { 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));
}

這個例子,可以讓我們即便重整頁面,狀態仍然都可以存在

例子 2:使用 useWindowSize

import { 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:打造你的工具箱

除了使用現成的工具,創建自定義的 Composables 也是提升開發效率的重要方式。

創建一個自定義 Composable

讓我們創建一個 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

現在,讓我們在組件中使用這個自定義的 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

最後,讓我們看一個結合 @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 的開發之路上走得更遠、更快。


上一篇
Day 12: 在 UnoCSS 中設計響應式布局:從手機到桌面應用
下一篇
Day 14: Pinia 與 Vue Router 的結合:實現高級應用狀態的導航守衛
系列文
Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言