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

各位听众朋友们,大家好!我是今天的主讲人,很高兴和大家一起探讨如何使用 TypeScript 的类型系统打造坚如磐石的 Vue 3 Composition API 自定义 Hook。

今天的内容,咱们不搞那些虚头巴脑的概念,直接上干货,用代码说话,让大家伙儿都能听得懂,学得会,用得上。

咱们的目标是:让你的 Hook 不仅能跑,还能跑得稳,跑得安全,让你在未来的维护工作中少掉头发。

一、 为什么 TypeScript 和 Composition API 是天生一对?

Vue 3 的 Composition API 给了我们更大的灵活性,但同时也意味着更容易写出一些类型错误的代码。想想看,如果一个 Hook 返回的值类型不明确,或者你给 Hook 传递的参数类型不对,那 debug 的时候可就热闹了。

TypeScript 的出现,就是来拯救我们的。它就像一个严格的门卫,在你写代码的时候就盯着你,一旦发现类型不匹配,立刻发出警告。

简单来说,TypeScript 赋予了 Composition API 以下能力:

  • 类型推断: 自动推断变量、函数和组件的类型,减少手动声明类型的繁琐。
  • 静态类型检查: 在编译时检查类型错误,避免运行时出现意料之外的 bug。
  • 代码提示和自动补全: 提高开发效率,减少拼写错误。
  • 代码重构: 更安全地重构代码,因为类型系统可以帮助你找到所有需要修改的地方。

二、 从一个简单的计数器 Hook 开始

咱们先从一个最简单的例子入手,创建一个计数器 Hook:

import { ref, computed } from 'vue';

interface UseCounterOptions {
  initialValue?: number;
  step?: number;
}

interface UseCounterReturn {
  count: Ref<number>;
  increment: () => void;
  decrement: () => void;
  double: ComputedRef<number>;
}

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 double = computed(() => count.value * 2);

  return {
    count,
    increment,
    decrement,
    double,
  };
}

export default useCounter;

代码解读:

  1. 接口定义: UseCounterOptions 定义了 Hook 的可选参数,包括初始值 initialValue 和步长 stepUseCounterReturn 定义了 Hook 返回值的类型,包括 countincrementdecrementdouble

  2. 默认参数: 使用默认参数 options: UseCounterOptions = {},允许用户不传递任何参数,也能正常使用 Hook。

  3. 类型注解: ref(initialValue) 创建了一个响应式的数字引用,computed(() => count.value * 2) 创建了一个基于 count 的计算属性。

  4. 返回值类型: return { count, increment, decrement, double }; 明确指定了返回值的类型,让 TypeScript 能够进行类型检查。

使用示例:

<template>
  <p>Count: {{ counter.count }}</p>
  <p>Double: {{ counter.double }}</p>
  <button @click="counter.increment">+</button>
  <button @click="counter.decrement">-</button>
</template>

<script setup lang="ts">
import useCounter from './useCounter';

const counter = useCounter({ initialValue: 10, step: 5 });
</script>

这样做的好处:

  • 类型安全: 如果你尝试将一个字符串赋值给 counter.count,TypeScript 会立即报错。
  • 代码提示: 在 Vue 文件中使用 counter. 时,编辑器会自动提示 countincrementdecrementdouble
  • 可读性: 通过接口定义,可以清晰地了解 Hook 的参数和返回值类型。

三、 处理异步操作:以一个数据获取 Hook 为例

现在,咱们来一个更复杂的例子,创建一个用于获取数据的 Hook。这个 Hook 需要处理异步操作,并且需要考虑 loading 状态和错误处理。

import { ref, onMounted, Ref } from 'vue';

interface UseFetchOptions<T> {
  url: string;
  initialData?: T;
  onError?: (error: Error) => void;
}

interface UseFetchReturn<T> {
  data: Ref<T | undefined>;
  loading: Ref<boolean>;
  error: Ref<Error | undefined>;
  fetchData: () => Promise<void>; // 添加 fetchData 函数
}

function useFetch<T>(options: UseFetchOptions<T>): UseFetchReturn<T> {
  const { url, initialData, onError } = options;

  const data = ref<T | undefined>(initialData);
  const loading = ref(false);
  const error = ref<Error | undefined>(undefined);

  const fetchData = async () => { // 重命名 fetch 为 fetchData
    loading.value = true;
    error.value = undefined;

    try {
      const response = await fetch(url);

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const jsonData: T = await response.json();
      data.value = jsonData;
    } catch (e: any) { // 使用 any 避免类型错误
      error.value = e instanceof Error ? e : new Error(String(e)); // 类型判断
      onError?.(error.value); // 使用 ?. 安全调用 onError
    } finally {
      loading.value = false;
    }
  };

  onMounted(() => {
    fetchData();
  });

  return {
    data,
    loading,
    error,
    fetchData, // 返回 fetchData
  };
}

export default useFetch;

代码解读:

  1. 泛型: useFetch<T> 使用了泛型,允许我们指定获取的数据类型。例如,useFetch<User>({ url: '/api/users' }) 表示获取的是 User 类型的数据。
  2. 错误处理: 使用 try...catch 块来捕获错误,并将错误信息存储在 error 引用中。
  3. 可选回调: onError 是一个可选的回调函数,用于处理错误。使用 onError?.(error.value) 可以安全地调用回调函数,避免因为 onError 未定义而导致的错误。
  4. loading 状态: loading 引用用于表示数据是否正在加载。
  5. fetchData 函数: 添加了fetchData函数,允许组件在需要的时候手动触发数据获取。

使用示例:

<template>
  <div v-if="fetchResult.loading">Loading...</div>
  <div v-else-if="fetchResult.error">Error: {{ fetchResult.error.message }}</div>
  <div v-else-if="fetchResult.data">
    <h1>User</h1>
    <p>Name: {{ fetchResult.data.name }}</p>
    <p>Email: {{ fetchResult.data.email }}</p>
  </div>
  <button @click="fetchResult.fetchData">重新加载</button>
</template>

<script setup lang="ts">
import useFetch from './useFetch';

interface User {
  id: number;
  name: string;
  email: string;
}

const fetchResult = useFetch<User>({
  url: '/api/user',
  onError: (error) => {
    console.error('Failed to fetch user:', error);
  },
});
</script>

这样做的好处:

  • 类型安全: TypeScript 会检查 data 的类型是否与泛型 T 匹配。
  • 错误处理: 通过 error 引用和 onError 回调函数,可以更好地处理错误。
  • 可复用性: useFetch 可以用于获取任何类型的数据,只需要指定泛型即可。
  • 手动触发: 通过fetchData函数可以手动重新加载数据,更加灵活。

四、 高级技巧:类型保护和条件类型

有时候,我们需要处理一些更复杂的类型场景,例如,根据不同的条件返回不同的类型。这时候,就需要用到类型保护和条件类型。

1. 类型保护

类型保护是一种 TypeScript 技术,用于缩小变量的类型范围。例如,我们可以使用 typeofinstanceof 或自定义的类型谓词来判断变量的类型。

function isString(value: any): value is string {
  return typeof value === 'string';
}

function processValue(value: string | number) {
  if (isString(value)) {
    // 在这个代码块中,TypeScript 知道 value 的类型是 string
    console.log(value.toUpperCase());
  } else {
    // 在这个代码块中,TypeScript 知道 value 的类型是 number
    console.log(value.toFixed(2));
  }
}

2. 条件类型

条件类型允许我们根据条件表达式来选择不同的类型。它的语法类似于 JavaScript 中的三元运算符:

type Result<T> = T extends string ? string : number;

type StringResult = Result<string>; // string
type NumberResult = Result<number>; // number

一个实际的例子:

假设我们要创建一个 Hook,用于处理表单输入。这个 Hook 需要根据不同的输入类型,返回不同的值。

import { ref, Ref, watch } from 'vue';

type InputType = 'text' | 'number' | 'boolean';

interface UseInputOptions<T extends InputType> {
  type: T;
  initialValue?: T extends 'number' ? number : T extends 'boolean' ? boolean : string;
  validate?: (value: T extends 'number' ? number : T extends 'boolean' ? boolean : string) => boolean;
}

interface UseInputReturn<T extends InputType> {
  value: Ref<T extends 'number' ? number : T extends 'boolean' ? boolean : string>;
  isValid: Ref<boolean>;
  setValue: (newValue: T extends 'number' ? number : T extends 'boolean' ? boolean : string) => void;
}

function useInput<T extends InputType>(options: UseInputOptions<T>): UseInputReturn<T> {
  const { type, initialValue, validate } = options;

  const value = ref(initialValue !== undefined ? initialValue : (type === 'number' ? 0 : type === 'boolean' ? false : '')) as Ref<T extends 'number' ? number : T extends 'boolean' ? boolean : string>;
  const isValid = ref(true);

  const setValue = (newValue: T extends 'number' ? number : T extends 'boolean' ? boolean : string) => {
    value.value = newValue;
  };

  watch(value, (newValue) => {
    if (validate) {
      isValid.value = validate(newValue);
    }
  });

  return {
    value,
    isValid,
    setValue,
  };
}

export default useInput;

代码解读:

  1. 条件类型: 使用条件类型 T extends 'number' ? number : T extends 'boolean' ? boolean : string,根据 type 的值来确定 initialValuevaluesetValue 的类型。
  2. 类型断言: 使用类型断言 as Ref<...>,告诉 TypeScript value 的类型。
  3. 可选验证: validate 是一个可选的验证函数,用于验证输入值是否有效。

使用示例:

<template>
  <div>
    <label for="name">Name:</label>
    <input type="text" id="name" v-model="name.value" />
    <p v-if="!name.isValid">Name is required.</p>
  </div>

  <div>
    <label for="age">Age:</label>
    <input type="number" id="age" v-model.number="age.value" />
    <p v-if="!age.isValid">Age must be a number.</p>
  </div>

  <div>
    <label for="agree">Agree:</label>
    <input type="checkbox" id="agree" v-model="agree.value" />
    <p v-if="!agree.isValid">You must agree to the terms.</p>
  </div>
</template>

<script setup lang="ts">
import useInput from './useInput';

const name = useInput({
  type: 'text',
  validate: (value) => value.length > 0,
});

const age = useInput({
  type: 'number',
  initialValue: 18,
  validate: (value) => value >= 18,
});

const agree = useInput({
  type: 'boolean',
  validate: (value) => value === true,
});
</script>

这样做的好处:

  • 类型安全: TypeScript 会检查 nameageagree 的类型是否与 type 匹配。
  • 代码提示: 在 Vue 文件中使用 name.value 时,编辑器会自动提示字符串类型的方法。
  • 可复用性: useInput 可以用于处理任何类型的表单输入,只需要指定 type 即可。

五、 总结:打造坚如磐石的 Hook

通过以上的例子,我们学习了如何使用 TypeScript 的类型系统来编写可维护、类型安全的 Vue 3 Composition API 自定义 Hook。

总结一下,关键点包括:

  • 接口定义: 使用接口定义 Hook 的参数和返回值类型。
  • 泛型: 使用泛型来增加 Hook 的灵活性和可复用性。
  • 类型保护: 使用类型保护来缩小变量的类型范围。
  • 条件类型: 使用条件类型来根据条件表达式选择不同的类型。
  • 错误处理: 使用 try...catch 块和可选回调函数来处理错误。
  • 默认参数: 使用默认参数来简化 Hook 的使用。

记住,类型系统是你的朋友,它可以帮助你避免很多不必要的错误,提高代码的可读性和可维护性。

希望今天的分享对大家有所帮助,祝大家写出更棒的 Vue 3 应用!

谢谢大家!

发表回复

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