在現代 Web 應用開發中,確保數據的完整性和類型安全是至關重要的。今天,我們將探討如何使用 TypeScript 和 Zod 來進行後端 API 數據驗證,同時整合 Pinia store、Vee-Validate 和 @vueuse/core 等工具,創建一個強大而可維護的表單驗證系統。我們將通過一個實際的例子來展示如何實現這一切,同時考慮性能優化技巧如 debounce 和 throttle。
首先,我們需要設置我們的專案結構和安裝必要的依賴。
bun add pinia zod vee-validate @vee-validate/zod @vueuse/core
創建一個 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,
}
建立一個 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));
}
創建 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;
現在,讓我們創建一個 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>
最後,我們可以增加更好的錯誤處理和用戶回饋。修改 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>
結果
通過這個綜合實例,我們展示了如何使用 TypeScript 和 Zod 進行後端 API 數據驗證,同時整合了 Pinia store、Vee-Validate 和 @vueuse/core 等工具。我們實現了一個可維護、類型安全且性能優化的表單驗證系統。
關鍵點總結:
useDebounceFn
和 useThrottleFn
實現性能優化。這種方法不僅提高了代碼的可維護性和可讀性,還確保了前後端數據驗證的一致性。通過使用 TypeScript 和 Zod,我們可以在開發過程中及早發現潛在的類型錯誤,提高應用的穩定性和可靠性。
記住,在實際應用中,您可能需要根據具體需求進行調整,例如添加更複雜的驗證規則,處理更多的邊緣情況,或者根據 API 響應動態更新 UI。不斷練習和改進這些技術將幫助您成為一個更優秀的 Vue 和 TypeScript 開發者。