各位听众朋友们,大家好!我是今天的主讲人,很高兴和大家一起探讨如何使用 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;
代码解读:
-
接口定义:
UseCounterOptions
定义了 Hook 的可选参数,包括初始值initialValue
和步长step
。UseCounterReturn
定义了 Hook 返回值的类型,包括count
、increment
、decrement
和double
。 -
默认参数: 使用默认参数
options: UseCounterOptions = {}
,允许用户不传递任何参数,也能正常使用 Hook。 -
类型注解:
ref(initialValue)
创建了一个响应式的数字引用,computed(() => count.value * 2)
创建了一个基于count
的计算属性。 -
返回值类型:
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.
时,编辑器会自动提示count
、increment
、decrement
和double
。 - 可读性: 通过接口定义,可以清晰地了解 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;
代码解读:
- 泛型:
useFetch<T>
使用了泛型,允许我们指定获取的数据类型。例如,useFetch<User>({ url: '/api/users' })
表示获取的是User
类型的数据。 - 错误处理: 使用
try...catch
块来捕获错误,并将错误信息存储在error
引用中。 - 可选回调:
onError
是一个可选的回调函数,用于处理错误。使用onError?.(error.value)
可以安全地调用回调函数,避免因为onError
未定义而导致的错误。 - loading 状态:
loading
引用用于表示数据是否正在加载。 - 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 技术,用于缩小变量的类型范围。例如,我们可以使用 typeof
、instanceof
或自定义的类型谓词来判断变量的类型。
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;
代码解读:
- 条件类型: 使用条件类型
T extends 'number' ? number : T extends 'boolean' ? boolean : string
,根据type
的值来确定initialValue
、value
和setValue
的类型。 - 类型断言: 使用类型断言
as Ref<...>
,告诉 TypeScriptvalue
的类型。 - 可选验证:
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 会检查
name
、age
和agree
的类型是否与type
匹配。 - 代码提示: 在 Vue 文件中使用
name.value
时,编辑器会自动提示字符串类型的方法。 - 可复用性:
useInput
可以用于处理任何类型的表单输入,只需要指定type
即可。
五、 总结:打造坚如磐石的 Hook
通过以上的例子,我们学习了如何使用 TypeScript 的类型系统来编写可维护、类型安全的 Vue 3 Composition API 自定义 Hook。
总结一下,关键点包括:
- 接口定义: 使用接口定义 Hook 的参数和返回值类型。
- 泛型: 使用泛型来增加 Hook 的灵活性和可复用性。
- 类型保护: 使用类型保护来缩小变量的类型范围。
- 条件类型: 使用条件类型来根据条件表达式选择不同的类型。
- 错误处理: 使用
try...catch
块和可选回调函数来处理错误。 - 默认参数: 使用默认参数来简化 Hook 的使用。
记住,类型系统是你的朋友,它可以帮助你避免很多不必要的错误,提高代码的可读性和可维护性。
希望今天的分享对大家有所帮助,祝大家写出更棒的 Vue 3 应用!
谢谢大家!