iT邦幫忙

2024 iThome 鐵人賽

DAY 15
1
Modern Web

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

Day 15: 使用 TypeScript 和 Zod 進行後端 API 數據驗證

  • 分享至 

  • xImage
  •  

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

本文簡介

在現代 Web 應用開發中,確保數據的完整性和類型安全是至關重要的。今天,我們將探討如何使用 TypeScript 和 Zod 來進行後端 API 數據驗證,同時整合 Pinia store、Vee-Validate 和 @vueuse/core 等工具,創建一個強大而可維護的表單驗證系統。我們將通過一個實際的例子來展示如何實現這一切,同時考慮性能優化技巧如 debounce 和 throttle。

實作步驟

步驟 1: 設置基本結構

首先,我們需要設置我們的專案結構和安裝必要的依賴。

bun add pinia zod vee-validate @vee-validate/zod @vueuse/core

步驟 2: 定義 Zod Schema

創建一個 schemas.ts 文件來定義我們的 Zod schema:

(檔案: src/schemas/user.ts)

import * as zod from 'zod';

export const userSchema = zod.object({
  userName: zod.string().min(3, '要大於三個字喔').max(20, '字給太多了..'),
  email: zod.string().email('要信箱格式喔!!😎'),
  age: zod.number({ message: '給數字唷!'})
    .int('年齡要整數吧🙃')
    .positive('你逆著長呀😗')
    .max(120, '太大了吧!我的系統收不了你😅')
});

export type UserSchema = zod.infer<typeof userSchema>;

export enum LoadingStatus {
  CreateUser = 'createUser',
  // 這裡可以根據 api 加入狀態
}

export enum CommonHttpStatusCode {
  Success = 200,
  Created = 201,
}

步驟 3: 創建 Pinia Store

建立一個 Pinia store 來管理用戶數據和 API 調用:

備註:如果看不懂 definePrivateState 是什麼,請參考 Day11

import { acceptHMRUpdate } from 'pinia';
import { definePrivateState } from './privateState';
import { LoadingStatus, UserSchema } from '../schemas/user';
import { useUserApi } from '../composables/useUserApi';
import { useLoadingStore } from './useLoadingStore';

export interface UserStoreState {
  user: UserSchema | null;
  error: string | null;
}

export const useUserStore = definePrivateState('useUserStore', (): UserStoreState => {
  return {
    user: null,
    error: null
  }
}, privateState => {
  const { createUserApi } = useUserApi()

  const loadingStore = useLoadingStore();
  const { addLoadingStatus, isLoadingStatusExist, removeLoadingStatus, isTypeError } = loadingStore;

  // getters::

  // methods::

  const createUser = async (user: UserSchema): Promise<boolean> => {
    if (isLoadingStatusExist(LoadingStatus.CreateUser)) return false;
    addLoadingStatus(LoadingStatus.CreateUser);
    try {
      const createdUser = await createUserApi(user);
      privateState.user = createdUser;
      return true;
    } catch (error) {
      if (isTypeError(error)) {
        // 如果是型別錯誤在這裡做一些處理
      }
      return false;
    } finally {
      removeLoadingStatus(LoadingStatus.CreateUser);
    }
  };
  
  return {
    // getters::

    // methods::
    createUser
  }
});

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot));
}

這裡我們針對 api 加入 loading 處理的 store

(檔案: src/stores/useLoadingStore.ts)

import { acceptHMRUpdate } from 'pinia';
import { definePrivateState } from './privateState';
import { LoadingStatus } from '../schemas/user';

export const useLoadingStore = definePrivateState('useLoadingStore', () => {
  return {
    loadingStatus: new Set<LoadingStatus>(),
  }
}, privateState => {
  const addLoadingStatus = (loading: LoadingStatus): void => {
    privateState.loadingStatus.add(loading);
  };

  const isLoadingStatusExist = (loading: LoadingStatus): boolean => {
    return privateState.loadingStatus.has(loading);
  };

  const removeLoadingStatus = (loading: LoadingStatus): boolean => {
    return privateState.loadingStatus.delete(loading);
  };


  const isTypeError = (error: unknown): error is TypeError => {
    return error instanceof TypeError;
  };

  return {
    // methods::
    addLoadingStatus,
    isLoadingStatusExist,
    removeLoadingStatus,
    isTypeError,
  }
});

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useLoadingStore, import.meta.hot));
}

步驟 4: 創建 Composable 函數

創建 composable 處理 api 的部分 (由於篇幅問題,我們這裡用簡單的 fetch 示範)

(檔案: src/composables/useUserApi.ts)

import { CommonHttpStatusCode, userSchema, UserSchema } from "../schemas/user";

export const useUserApi = () => {
  const createUserApi = async (user: UserSchema): Promise<UserSchema> => {
    const requestValidator = userSchema.safeParse(user);

    if (!requestValidator.success) {
      console.error(requestValidator.error);
      throw new TypeError('request zod type error');
    }

    const url = 'http://api.example.com/createUser';
    const response = await fetch(url, {
      method: 'POST',
      body: JSON.stringify(user),
    })

    if (response.status !== CommonHttpStatusCode.Created) {
      throw new Error('created fail')
    }
    const rawJsonData = await response.json();
    const responseValidator = userSchema.safeParse(rawJsonData);
    if (!responseValidator.success) {
      console.error(responseValidator.error);
      throw new TypeError('response zod type error');
    }
    return responseValidator.data;
  };

  return {
    createUserApi,
  };
};

export type UseUserApi = typeof useUserApi;

創建一個 composable 函數來處理表單邏輯和驗證:

(檔案: src/composables/useCreateUserForm.ts)

import { shallowRef } from "vue";
import { useThrottleFn } from "@vueuse/core";
import { useForm, useField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import { userSchema, UserSchema } from "../schemas/user";

export const useCreateUserForm = (submitFn: (values: UserSchema) => Promise<boolean>, submitErrorFn?: () => void) => {
  const isSubmittingDisabled = shallowRef<boolean>(false);
  const validationSchema = toTypedSchema(userSchema);

  const initialValues: UserSchema = {
    userName: '',
    email: '',
    age: 18
  };

  const { handleSubmit, isSubmitting, resetForm, errors } = useForm({
    validationSchema,
    initialValues
  });

  const formSubmit = handleSubmit(
    useThrottleFn(async values => {
      isSubmittingDisabled.value = true;
      const isSuccess = await submitFn(values);
      if (!isSuccess && submitErrorFn) {
        submitErrorFn();
      }
      isSubmittingDisabled.value = false;
    }, 800)
  );

  const { value: userName } = useField<string>("userName");
  const { value: email } = useField<string>("email");
  const { value: age } = useField<number>("age");

  return {
    userName,
    email,
    age,
    formSubmit,
    isSubmitting,
    isSubmittingDisabled,
    resetForm,
    errors
  };
};

export type UseCreateUserForm = typeof useCreateUserForm;

步驟 5: 創建 Vue 組件

現在,讓我們創建一個 Vue 組件來用在我們的表單:

(檔案: src/components/CustomInput.vue)

<script setup lang="ts" generic="T extends string | number">
  import { useId } from 'vue';

  const { id = useId(), isShowLabel = true, errorMessage = '', disabled = false } = defineProps<{
    label: string;
    id?: string;
    isShowLabel?: boolean;
    errorMessage?: string;
    disabled?: boolean;
  }>();

  const errorID = useId();
  const modelValue = defineModel<T>();
</script>

<template>
  <div>
    <label text-xl font-bold v-show="isShowLabel" :for="id">{{ label }}</label>
    <input 
      mt-2
      w-full
      :type="typeof modelValue === 'string' ? 'text' : 'number'"
      px-4 py-3 
      rounded-md 
      outline-none 
      border="solid 1px black"
      bg="disabled:[#5756ff]"
      :aria-describedby="errorMessage ? errorID : undefined" :id :disabled="disabled"
      v-model="modelValue" 
    />
    <span text="sm [#ff2b3c]" v-show="errorMessage" :id="errorID">{{ errorMessage }}</span>
  </div>
</template>

步驟 6:畫面展示和使用

最後,我們可以增加更好的錯誤處理和用戶回饋。修改 UserForm.vue

<script setup lang="ts">
  import CustomInput from '../components/CustomInput.vue';
  import { useCreateUserForm } from '../composables/useCreateUserForm';
  import { useUserStore } from '../stores/useUserStore';
  const userStore = useUserStore();
  const { createUser } = userStore;

  const {
    userName,
    email,
    age,
    formSubmit,
    isSubmittingDisabled,
    errors
  } = useCreateUserForm(async (values) => {
    const isApiResponseSuccess = await createUser(values);
    if (isApiResponseSuccess) {
      alert('送出成功'); // 本來想做個漂亮的 alert 但有點懶
    }
    return isApiResponseSuccess;
  });

</script>

<template>  
  <form w="1/3 2xl:1/5" bg-gradient-to-tl from="#7bd1ff" opacity="0.5" shadow-xl px-8 py-4 rounded-md flex="~ col" gap-y-4 @submit.prevent="formSubmit">
    <CustomInput label="Username" v-model="userName" :errorMessage="errors.userName" :disabled="isSubmittingDisabled" />
    <CustomInput label="Email" v-model="email" :errorMessage="errors.email" :disabled="isSubmittingDisabled" />
    <CustomInput label="Age" v-model.number="age" :errorMessage="errors.age" :disabled="isSubmittingDisabled" />
    <button 
      type="submit" 
      aria-label="create user submit" 
      border-none 
      px-3 py-2 
      rounded-md 
      cursor-pointer 
      box-border 
      text="hover:white" 
      font-bold
      bg="#ff5bff hover:#ffbd8e"
      duration-400
      >{{ isSubmittingDisabled ? 'submitting...' : 'Submit' }}</button>
  </form>
</template>

結果

image_content

結論

通過這個綜合實例,我們展示了如何使用 TypeScript 和 Zod 進行後端 API 數據驗證,同時整合了 Pinia store、Vee-Validate 和 @vueuse/core 等工具。我們實現了一個可維護、類型安全且性能優化的表單驗證系統。

關鍵點總結:

  1. 使用 Zod 定義強類型的 schema,確保數據驗證的一致性。
  2. 利用 Pinia store 管理應用狀態和 API 調用。
  3. 通過 Vee-Validate 和 @vee-validate/zod 實現前端表單驗證。
  4. 使用 composables 封裝邏輯,提高代碼的可重用性。
  5. 利用 @vueuse/core 的 useDebounceFnuseThrottleFn 實現性能優化。

這種方法不僅提高了代碼的可維護性和可讀性,還確保了前後端數據驗證的一致性。通過使用 TypeScript 和 Zod,我們可以在開發過程中及早發現潛在的類型錯誤,提高應用的穩定性和可靠性。

記住,在實際應用中,您可能需要根據具體需求進行調整,例如添加更複雜的驗證規則,處理更多的邊緣情況,或者根據 API 響應動態更新 UI。不斷練習和改進這些技術將幫助您成為一個更優秀的 Vue 和 TypeScript 開發者。


上一篇
Day 14: Pinia 與 Vue Router 的結合:實現高級應用狀態的導航守衛
下一篇
Day 16: 如何使用 Pinia 儲存並管理 API 請求的異步數據
系列文
Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言