大家好!欢迎来到今天的“TypeScript 与 Vue 3 Composition API:打造类型安全的自定义 Hook”讲座。我是今天的讲师,准备好了吗?让我们一起深入探索如何利用 TypeScript 的强大类型系统,为 Vue 3 的 Composition API 构建坚如磐石、易于维护的自定义 Hook。
第一部分:热身运动:Composition API 的 TypeScript 基础
在开始之前,我们需要对 Vue 3 Composition API 的 TypeScript 用法有一个清晰的认识。Composition API 的核心思想是将组件的逻辑拆分成独立的函数,这些函数就是 Hook。TypeScript 的类型系统可以帮助我们确保这些 Hook 的输入和输出都是类型安全的。
1.1 ref
、reactive
和 computed
的类型推断
Vue 3 提供了 ref
、reactive
和 computed
三个核心函数,用于创建响应式数据。TypeScript 可以自动推断这些函数的类型,大大减少了我们的工作量。
import { ref, reactive, computed } from 'vue';
// ref 的类型推断
const count = ref(0); // count 的类型是 Ref<number>
// reactive 的类型推断
const state = reactive({
name: 'Vue',
age: 3,
}); // state 的类型是 { name: string; age: number; }
// computed 的类型推断
const doubleCount = computed(() => count.value * 2); // doubleCount 的类型是 ComputedRef<number>
1.2 手动指定类型
当然,有时候 TypeScript 无法自动推断出我们想要的类型,或者我们需要更精确地控制类型,这时我们可以手动指定类型。
import { ref, reactive, computed, Ref } from 'vue';
// 手动指定 ref 的类型
const message: Ref<string | null> = ref(null);
// 手动指定 reactive 的类型
interface User {
id: number;
name: string;
email: string;
}
const user = reactive<User>({
id: 1,
name: 'John Doe',
email: '[email protected]',
});
1.3 defineComponent
的类型支持
defineComponent
函数用于定义 Vue 组件,它也提供了强大的类型支持。我们可以使用 defineComponent
来定义组件的 props 和 emits 的类型。
import { defineComponent } from 'vue';
export default defineComponent({
props: {
title: {
type: String,
required: true,
},
age: {
type: Number,
default: 18,
},
},
emits: ['update'],
setup(props, { emit }) {
// props.title 的类型是 string
// props.age 的类型是 number
const handleClick = () => {
emit('update', 'something new');
};
return {
handleClick,
};
},
});
第二部分:进入正题:自定义 Hook 的类型安全
现在,我们已经掌握了 Composition API 的 TypeScript 基础,可以开始编写类型安全的自定义 Hook 了。
2.1 简单示例:useCounter
Hook
让我们从一个简单的例子开始:一个 useCounter
Hook,用于管理计数器的状态。
import { ref, Ref } from 'vue';
interface UseCounterOptions {
initialValue?: number;
step?: number;
}
interface UseCounterReturn {
count: Ref<number>;
increment: () => void;
decrement: () => void;
reset: () => void;
}
function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
const { initialValue = 0, step = 1 } = options;
const count = ref(initialValue);
const increment = () => {
count.value += step;
};
const decrement = () => {
count.value -= step;
};
const reset = () => {
count.value = initialValue;
};
return {
count,
increment,
decrement,
reset,
};
}
export default useCounter;
在这个例子中,我们定义了 UseCounterOptions
接口来描述 Hook 的选项,UseCounterReturn
接口来描述 Hook 的返回值。通过这些接口,我们可以确保 Hook 的输入和输出都是类型安全的。
2.2 更复杂的示例:useFetch
Hook
现在,让我们编写一个更复杂的 Hook:一个 useFetch
Hook,用于发起 HTTP 请求。
import { ref, Ref, onMounted, onUnmounted } from 'vue';
interface UseFetchOptions<T> {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: any;
headers?: Record<string, string>;
initialData?: T;
}
interface UseFetchReturn<T> {
data: Ref<T | null>;
loading: Ref<boolean>;
error: Ref<Error | null>;
fetchData: () => Promise<void>;
}
function useFetch<T>(options: UseFetchOptions<T>): UseFetchReturn<T> {
const { url, method = 'GET', body, headers, initialData = null as T } = options;
const data = ref<T | null>(initialData);
const loading = ref(false);
const error = ref<Error | null>(null);
const fetchData = async () => {
loading.value = true;
error.value = null;
try {
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : null,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
data.value = await response.json();
} catch (e: any) {
error.value = e;
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchData();
});
return {
data,
loading,
error,
fetchData,
};
}
export default useFetch;
在这个例子中,我们使用了泛型 T
来描述返回数据的类型。这使得 useFetch
Hook 可以用于获取不同类型的数据,同时保持类型安全。
2.3 使用 readonly
保护数据
有时候,我们希望 Hook 返回的数据是只读的,防止在组件中意外修改。我们可以使用 readonly
函数来实现这一点。
import { ref, readonly, Ref } from 'vue';
interface UseCounterReturn {
count: Readonly<Ref<number>>; //count 变成只读的 Ref<number>
increment: () => void;
decrement: () => void;
reset: () => void;
}
function useCounter(initialValue: number = 0): UseCounterReturn {
const count = ref(initialValue);
const increment = () => {
count.value++;
};
const decrement = () => {
count.value--;
};
const reset = () => {
count.value = initialValue;
};
return {
count: readonly(count), // 使用 readonly 包装 count
increment,
decrement,
reset,
};
}
export default useCounter;
现在,count
在组件中是只读的,任何尝试修改 count.value
的操作都会导致 TypeScript 报错。
第三部分:进阶技巧:更强大的类型体操
掌握了基本用法之后,我们可以使用 TypeScript 的高级特性来编写更强大的自定义 Hook。
3.1 使用 Omit
和 Pick
创建类型
Omit
和 Pick
是 TypeScript 提供的两个实用工具类型,可以帮助我们从现有类型中创建新的类型。
Omit<T, K>
:从类型T
中排除键K
。Pick<T, K>
:从类型T
中选择键K
。
例如,我们可以使用 Omit
来创建一个不包含 id
字段的 User
类型:
interface User {
id: number;
name: string;
email: string;
}
type UserWithoutId = Omit<User, 'id'>;
或者使用 Pick
来创建一个只包含 name
和 email
字段的 User
类型:
type UserNameAndEmail = Pick<User, 'name' | 'email'>;
3.2 使用 Partial
和 Required
创建类型
Partial
和 Required
是 TypeScript 提供的另外两个实用工具类型,可以帮助我们控制类型的可选性和必填性。
Partial<T>
:将类型T
的所有属性设置为可选。Required<T>
:将类型T
的所有属性设置为必填。
例如,我们可以使用 Partial
来创建一个所有属性都是可选的 User
类型:
type PartialUser = Partial<User>;
或者使用 Required
来创建一个所有属性都是必填的 PartialUser
类型:
type RequiredUser = Required<PartialUser>; // 等同于 User
3.3 使用条件类型
条件类型允许我们根据条件选择不同的类型。它的语法类似于 JavaScript 中的三元运算符。
type IsString<T> = T extends string ? true : false;
type Result1 = IsString<string>; // true
type Result2 = IsString<number>; // false
条件类型可以用于编写非常灵活的类型定义。例如,我们可以根据选项的类型来返回不同的类型。
3.4 使用 Extract
和 Exclude
Extract
和 Exclude
可以帮助我们从联合类型中提取或排除特定的类型。
Extract<T, U>
:从T
中提取可以赋值给U
的类型。Exclude<T, U>
:从T
中排除可以赋值给U
的类型。
type AllowedTypes = string | number | boolean;
type StringOrNumber = Extract<AllowedTypes, string | number>; // string | number
type BooleanOnly = Exclude<AllowedTypes, string | number>; // boolean
第四部分:实战演练:一个复杂的表单 Hook
现在,让我们通过一个更复杂的实战案例来巩固我们的知识:一个 useForm
Hook,用于管理表单的状态和验证。
import { ref, reactive, computed, ComputedRef, Ref, watch } from 'vue';
interface FormField<T> {
value: Ref<T>;
error: Ref<string | null>;
rules: ((value: T) => string | null)[]; // 验证规则
}
type FormSchema<T extends Record<string, any>> = {
[K in keyof T]: FormField<T[K]>;
};
interface UseFormOptions<T extends Record<string, any>> {
initialValues: T;
validationSchema?: (data: T) => Partial<Record<keyof T, string | null>>; // 自定义验证
resetAfterSubmit?: boolean;
}
interface UseFormReturn<T extends Record<string, any>> {
form: FormSchema<T>;
isValid: ComputedRef<boolean>;
isDirty: ComputedRef<boolean>;
validate: () => Promise<boolean>;
submit: (onSubmit: (data: T) => Promise<void> | void) => Promise<void>;
reset: () => void;
setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
}
function useForm<T extends Record<string, any>>(options: UseFormOptions<T>): UseFormReturn<T> {
const { initialValues, validationSchema, resetAfterSubmit = false } = options;
const form = reactive<FormSchema<T>>({} as FormSchema<T>);
const initialFormValues = { ...initialValues }; // 存储初始值
// 初始化 form
for (const key in initialValues) {
if (Object.prototype.hasOwnProperty.call(initialValues, key)) {
form[key] = {
value: ref(initialValues[key]),
error: ref(null),
rules: [], // 可以根据需要添加默认规则
};
}
}
const isValid = computed(() => {
return Object.values(form).every((field) => field.error.value === null);
});
const isDirty = computed(() => {
for (const key in initialFormValues) {
if (Object.prototype.hasOwnProperty.call(initialFormValues, key)) {
if (form[key].value.value !== initialFormValues[key]) {
return true;
}
}
}
return false;
});
const validate = async (): Promise<boolean> => {
let isValidForm = true;
for (const key in form) {
if (Object.prototype.hasOwnProperty.call(form, key)) {
const field = form[key];
for (const rule of field.rules) {
const errorMessage = rule(field.value.value);
field.error.value = errorMessage;
if (errorMessage) {
isValidForm = false;
break;
} else {
field.error.value = null;
}
}
}
}
// 自定义验证
if (validationSchema) {
const customErrors = validationSchema(getFormData());
for (const key in customErrors) {
if (Object.prototype.hasOwnProperty.call(customErrors, key)) {
const errorMessage = customErrors[key];
if (form[key]) {
form[key].error.value = errorMessage || null; // 允许返回 null 清除错误
if (errorMessage) {
isValidForm = false;
}
}
}
}
}
return isValidForm;
};
const getFormData = (): T => {
const data: any = {};
for (const key in form) {
if (Object.prototype.hasOwnProperty.call(form, key)) {
data[key] = form[key].value.value;
}
}
return data as T;
};
const submit = async (onSubmit: (data: T) => Promise<void> | void): Promise<void> => {
const isValidForm = await validate();
if (isValidForm) {
await onSubmit(getFormData());
if (resetAfterSubmit) {
reset();
}
}
};
const reset = () => {
for (const key in form) {
if (Object.prototype.hasOwnProperty.call(form, key)) {
form[key].value.value = initialFormValues[key];
form[key].error.value = null;
}
}
};
const setFieldValue = <K extends keyof T>(field: K, value: T[K]) => {
form[field].value.value = value;
};
return {
form,
isValid,
isDirty,
validate,
submit,
reset,
setFieldValue,
};
}
export default useForm;
这个 useForm
Hook 提供了以下功能:
- 管理表单字段的状态和错误信息。
- 验证表单数据。
- 提交表单数据。
- 重置表单数据。
它使用了泛型、条件类型和实用工具类型来确保类型安全。
第五部分:最佳实践:让你的 Hook 更上一层楼
最后,让我们讨论一些编写高质量自定义 Hook 的最佳实践。
- 单一职责原则: 每个 Hook 只负责一个特定的功能。
- 可组合性: Hook 应该可以组合在一起,形成更复杂的功能。
- 可测试性: Hook 应该易于测试。
- 文档: 为 Hook 编写清晰的文档,说明其用途、参数和返回值。
- 类型安全: 始终使用 TypeScript 的类型系统来确保 Hook 的类型安全。
总结
今天,我们深入探讨了如何利用 TypeScript 的类型系统,为 Vue 3 的 Composition API 编写可维护、类型安全的自定义 Hook。我们学习了如何使用 ref
、reactive
和 computed
的类型推断,如何手动指定类型,如何使用泛型、条件类型和实用工具类型来编写更强大的类型定义,以及如何遵循最佳实践来编写高质量的 Hook。
希望今天的讲座对你有所帮助。记住,类型安全是构建大型、可维护 Vue 应用的关键。通过掌握 TypeScript 的类型系统,你可以编写出更加健壮、可靠的自定义 Hook。感谢大家的参与,下次再见!