如何利用 `TypeScript` 的类型系统,为 Vue 3 `Composition API` 编写可维护、类型安全的自定义 Hook?

大家好!欢迎来到今天的“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 refreactivecomputed 的类型推断

Vue 3 提供了 refreactivecomputed 三个核心函数,用于创建响应式数据。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 使用 OmitPick 创建类型

OmitPick 是 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 来创建一个只包含 nameemail 字段的 User 类型:

type UserNameAndEmail = Pick<User, 'name' | 'email'>;

3.2 使用 PartialRequired 创建类型

PartialRequired 是 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 使用 ExtractExclude

ExtractExclude 可以帮助我们从联合类型中提取或排除特定的类型。

  • 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。我们学习了如何使用 refreactivecomputed 的类型推断,如何手动指定类型,如何使用泛型、条件类型和实用工具类型来编写更强大的类型定义,以及如何遵循最佳实践来编写高质量的 Hook。

希望今天的讲座对你有所帮助。记住,类型安全是构建大型、可维护 Vue 应用的关键。通过掌握 TypeScript 的类型系统,你可以编写出更加健壮、可靠的自定义 Hook。感谢大家的参与,下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注