用 TypeScript 讓 Vue 組件更上一層樓 [12 個實例講解]
关于 JavaScript 和 TypeScript 的争论已经持续了很多年,我原以为到 2024 年我们应该会有明确的结论却未能达成。
可惜的是,情况并不是这样。
讨论依然很热烈,两边都有不错的观点。
个人来说,我没什么特别的看法,但有句话我觉得很有道理:
因为你觉得你精通 JavaScript 而不去使用 TypeScript,就像拒绝系上安全带因为你 “开车技术很好”。
这个比喻说明了 TypeScript 在开发过程中力求确保代码的韧性、可靠性和可读性。最终,理解代码常常是编程中最难的部分,而这就是 TypeScript 发挥最大作用的地方。清晰的类型和一些额外的功能增强使代码更容易阅读和理解,尤其是在大型项目中。
我们来看几个例子,也许会让你觉得用 TypeScript 编写的 Vue 组件更棒。
1. 编写组件的属性这个我们就先搞定吧。
虽然 Vue 已经支持 prop 验证,但 TypeScript 做得更进一步,在编译时进行类型验证,并支持使用类型和接口来定义复杂的数据类型。
在 JavaScript 里呀
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
foo: { type: String, required: true },
bar: Number,
});
// props.foo 是字符串
// props.bar 是数字或未定义
</script>
使用 TypeScript 时:
<script setup lang="ts">
const props = defineProps<{
foo: string;
bar?: number;
}>()
</script>
<script setup lang="ts">
interface Props {
foo: string;
bar?: number;
}
const props = defineProps<Props>()
</script>
2. 输入组件事件
使用打字事件是一个很大的改进。不再需要使用字符串来表示事件名,而是可以利用 TypeScript 来防止打字错误,并确保回调函数的签名正确。
在 JavaScript 里,比如
<script setup>
const emit = defineEmits(['change', 'update'])
</script>
使用 TypeScript:
<script setup lang="ts">
const 发布 = defineEmits<{
变更: [id: number]
更新: [value: string]
}>()
</script>
3. 输入 Ref 和响应式数据绑定
这是我们在不花一分钱的情况下免费获得的一个好处,得益于type inference(类型推断)。
以下示例仅会在 TypeScript 中引发错误,因为 TypeScript 认为 count
变量应该始终是数字。
<script setup>
import { ref } from 'vue';
// `script setup`部分,从`vue`导入`ref`,然后创建一个名为`count`的引用并赋值为0,之后将其值改为字符串`string`
const count = ref(0);
count.value = 'string';
</script>
在使用反应式变量时也遵循同样的规则
import { reactive } from 'vue';
const user = reactive({
name: 'Alice',
age: 30,
});
user.name = 123; // TypeScript 会捕捉到这个错误
// 这行代码会触发TypeScript的类型检查错误。
4: 输入服务器的回应
在API调用时,反应式数据尤为突出,此时数据应遵循特定的约定。TypeScript将确保项目中对响应的所有使用都符合定义的接口。
<script setup lang="ts">
import { ref, onMounted } from 'vue';
interface User {
id: number;
name: string;
email: string;
}
const userData = ref<User | null>(null);
onMounted(async () => {
const response = await fetch('https://api.example.com/user');
const data: User = await response.json();
userData.value = data; // TypeScript 确保这里的数据使用符合 User 接口定义
});
</script>
第5部分 输入计算结果
又一次,类型推断在我们几乎不需要做什么的情况下,就能很好地处理类型问题。
import { ref, computed } from 'vue'
const count = ref(0) // 初始化计数为0
// 推断类型: ComputedRef<number>
const double = computed(() => count.value * 2) // 计算 count 的两倍
// => TS 错误:属性 'split' 不存在于类型 'number' 上
const result = double.value.toString().split('')
我们也可以在需要时明确地定义返回类型。
const 双倍 = computed(() => {
// 如果这里不返回一个数字,就会报类型错误信息。
})
6. 输入作用域插槽内容
掌握作用域插槽几乎就像是Vue开发者拥有了超能力一样。通过正确使用作用域插槽,可以简化大量代码和复杂性。
然而,使用原始 Vue,在没有类型安全的情况下管理插槽属性,可能导致错误,并对如何使用插槽产生误解。
在使用 TS 的时候,我们可以使用 defineSlots 来为插槽定义类型,确保它们被正确使用。
<template>
<插槽 :msg="message"></插槽>
</template>
<script setup lang="ts">
const message = 'Hello, Vue!';
const slots = defineSlots<{
默认插槽: (props: { msg: string }) => any;
}>()
</script>
在这个例子中,defineSlots
指定该组件具有一个默认插槽,期望接收一个名为 msg
的字符串类型的 prop。类型不匹配会导致错误。
模板引用 是 Vue 中访问 DOM 元素的方法。虽然有时非常有用,但与 TypeScript 一起使用时几乎总是会出现问题。这就是为什么在 Vue 3.5 中引入了 useTemplateRef 这个功能,它与 TypeScript 结合使用时可以自动推断 ref
为 HTMLInputElement
或 null
类型。
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue';
const myInput = useTemplateRef('my-input');
onMounted(() => {
myInput.value?.focus();
});
</script>
<template>
<input ref="my-input" />
</template>
这段代码实现了一个简单的Vue组件,使用useTemplateRef
获取一个输入元素的引用并在组件挂载后让该输入元素获得焦点。
提供/注入 是一种干净的方式来共享组件之间的数据,避免了 prop 钻取 问题。然而,如果没有类型安全,很容易通过注入错误类型的数据显示引入 bug 或者遗漏必要的注入。使用 TypeScript,我们能够明确地定义提供的和注入的值的类型,使一切更加可预测。
<!-- ParentComponent.vue -->
<script setup>
import { provide } from 'vue';
定义一个名为theme的常量,其值为'dark';
提供一个名为'theme'的提供值,其值为theme;
</script>
<!-- ChildComponent.vue -->
<script setup>
import { inject } from 'vue';
const theme = inject('theme'); // 没有类型安全,可以注入任何类型的内容
// 假设 theme 是一个字符串,但 TypeScript 无法验证这一点
</script>
在上面的例子中,注入的主题内容必须是字符串形式,但我们也可以注入任何内容,或者什么都不注入。
<!-- 父组件.vue -->
<script setup lang="ts">
// 引入Vue的provide函数
import { provide } from 'vue';
// 定义主题为暗色
const 主题 = '暗';
// 提供主题给子组件
provide('主题', 主题);
</script>
<!-- ChildComponent.vue -->
<script setup lang="ts">
import { inject } from 'vue';
const theme = inject<string>('theme'); // 注入期望的类型
// TypeScript 确保 'theme' 是一个字符串
</script>
输入变量theme
确保它是字符串类型。
当然,也可以用界面。
<!-- 子组件.vue -->
<script setup lang="ts">
import { inject } from 'vue';
接口 Theme {
color: string;
fontSize: number;
}
const theme = inject<Theme | undefined>('theme'); // 注入主题样式
</script>
最后,我不得不提到在 Vue 中推荐的依赖注入的方法,即使用一个被当作 InjectionKey
的符号,如官方 Vue 文档中的此链接所示。我觉得这种方式太繁琐,没有任何明显的优点,这也是我通常不会选择这种方式的原因。如果您认为这种方式有优点,欢迎留言或与我讨论,我很想听听您的想法。
import { provide, inject } from 'vue'
import type { InjectionKey } from 'vue'
const key = Symbol() as InjectionKey<string>
provide(key, 'foo') // 提供非字符串值将引发错误
const foo = inject(key) // foo 的类型为 string | undefined
9. 泛型(Generics)
TypeScript 的泛型可以与 Vue 组件结合使用,从而提供很大的灵活性,让它们能够支持多种类型。我们可以在 script
标签上通过泛型属性声明泛型类型参数。
<script setup lang="ts" generic="T">
defineProps<{
items: T[]; // 类型 T 的项列表,
selected: T; // 类型 T 的选中项,
}>()
</script>
在这个例子中,状态通过泛型参数 T
来指定类型。这意味着组件可以适用于任何类型的状态,只要在使用组件时指定了类型。
想象一下一个列表视窗,采用相同的界面来展示出不同类型的信息。
<script setup lang="ts" generic="T">
defineProps<{
items: T[]; // 类型 T 的项目数组
selected: T; // 类型 T 的单个选中的项
}>()
</script>
<template>
<div>
<h3>选中的项: {{ selected }}</h3>
<ul>
<li v-for="(item, index) in items" :key="index">{{ item }}</li>
</ul>
</div>
</template>
<styles>
// ...省略...
</styles>
我们可以用这个组件,比如字符串、数字,或者自定义对象,来使用不同类型的数据。
<template>
<div>
<!-- 字符串项列表 -->
<ListComponent :items="['Apple', 'Banana', 'Cherry']" selected="Banana" />
<!-- 数字项列表 -->
<ListComponent :items="[1, 2, 3, 4]" :selected="2" />
<!-- 自定义项列表 -->
<ListComponent :items="users" :selected="selectedUser" />
</div>
</template>
<script setup lang="ts">
import ListComponent from './ListComponent.vue';
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
{ id: 3, name: 'Doe' }
];
const selectedUser: User = users[0];
</script>
10. 带类型的组合函数
在大规模代码库中,复杂的业务逻辑通常会被抽象出来并放到可组合函数中。由于可组合函数作为可重用的状态函数,因此强类型系统可以确保它们输入和输出的一致性,这对它们非常有帮助。
在使用 JavaScript 时,很容易误用或传递错误参数,这可能导致运行时错误。
// useUser.js
import { ref } from 'vue';
export function useUser() {
const user = ref(null);
function fetchUser(id) {
// 获取用户信息的逻辑
user.value = { id, name: 'John Doe', age: 30 };
}
return { user, fetchUser };
}
使用 TypeScript,我们可以为可组合函数的参数和返回值定义清晰的类型,这样确保它们被正确使用。
// useUser.ts
import { ref } from 'vue';
interface User {
id: number;
name: string;
age: number;
}
export function useUser() {
const user = ref<User | null>(null);
function fetchUser(id: number) {
user.value = { id, name: 'John Doe', age: 30 }; // 获取用户信息
}
return { user, fetchUser };
}
我再给大家分享一个 TypeScript 组件中表单验证的例子。这是一个真实的例子。
// useFormValidation.ts
import { ref } from 'vue';
interface ValidationResult {
isValid: boolean;
errors: string[];
}
export function useFormValidation() {
const validationResult = ref<ValidationResult>({ isValid: true, errors: [] });
function validateField(fieldName: string, value: string) {
// 验证逻辑如下
if (value.trim() === '') {
validationResult.value = { isValid: false, errors: [`${fieldName} 不能为空,`] };
} else {
validationResult.value = { isValid: true, errors: [] };
}
}
return { validationResult, validateField };
}
顾客:后面的内容缺失
<script setup lang="ts">
import { useFormValidation } from './useFormValidation';
const { validationResult, validateField } = useFormValidation();
// 验证字段有效性
validateField('username', '');
console.log(validationResult.value.isValid);
// 检查验证结果是否有效
</script>
TypeScript 确保了对验证结果的类型安全和可组合访问的正确性。
11. 状态管理在 Vue 2 中,其中一个最大的痛点是官方的状态管理库(Vuex)与 TypeScript 不太兼容,这常常导致一些繁琐的类型操作,有限的类型安全,使得状态管理变得很棘手。
这就是为什么 Pinia 现在被广泛使用。基于 composables,它利用了我们在上文提到的所有内容,提供了更好的 TypeScript 集成。
在简单的情况下,类型推断对于简单的 状态 变量 (字符串、数字、布尔类型)
非常有用。对于复杂的变量,则可以定义为类型或实现接口。
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { Customer } from '@/types';
export const useCustomerStore = defineStore('customerStore', () => {
const isRequestLoading = ref(false); // 被推断为 boolean
const totalCustomers = ref(0); // 被推断为 number
// 需要明确类型声明的复杂状态
const customers = ref<Array<Customer>>([]); // 定义为 Customer 类型的数组
return { customers, totalCustomers, isRequestLoading };
});
export default useCustomerStore;
此外,行为也能因严格的类型定义而受益,因为它们是输入和输出明确的简单函数。
另一方面,getter 通常会直接从状态推断类型,在大多数情况下,显式类型声明变得多余。
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { Customer } from '@/types';
export const useCustomerStore = defineStore('customerStore', () => {
const isRequestLoading = ref(false); // 会被推断为布尔值
const totalCustomers = ref(0); // 会被推断为数字
// 需要显式指定类型的复杂状态
const customers = ref<Array<Customer>>([]); // 类型为 Customer 的数组
// 操作
function setCustomers(newCustomers: Customer[]): void {
customers.value = newCustomers;
totalCustomers.value = newCustomers.length;
}
function addCustomer(newCustomer: Customer): void {
customers.value.push(newCustomer);
totalCustomers.value++;
}
// 计算值
const activeCustomers = computed(() => {
return customers.value.filter(customer => customer.isActive);
});
const activeCustomersCount = computed(() => {
return activeCustomers.value.length;
});
return {
// 状态:
customers,
totalCustomers,
isRequestLoading,
// 操作:
setCustomers,
addCustomer,
// 计算值:
activeCustomers,
activeCustomersCount,
};
});
export default useCustomerStore;
现在使用这个 store 的组件不得不正确地使用。
最后,将所有这些功能结合起来,它们显著提升了IDE的功能。强类型和类型推断使得它能够提供实时反馈和有用的建议,远远超越了单纯代码检查器的功能。在编写代码的过程中,自动完成、错误高亮和类型检查功能共同提高了代码质量。唯一的不足是,与之相比,使用JavaScript感觉相当原始。
正如所展示的,使用 Vue 和 TypeScript 带来了许多好处。通过类型化的 props、emits、slots、状态管理和泛型等功能,它确实提高了代码库的可读性,并且确保一切都按预期运作。
当然,学习曲线可能比使用JavaScript时更陡峭一些,但长期来看,这些优势包括早期错误检测、更清晰的代码结构、更安全的重构和更好的IDE支持,这些优点让这一切完全物超所值,特别是在规模较大的项目中。
你对在 Vue 里使用 TypeScript 有什么看法?你觉得这是一段愉快的经历吗?有没有什么实用的功能是我没注意到的,或者有哪些棘手的问题我还不清楚?咱们在评论区聊一聊吧。
更多资源:
共同學習,寫下你的評論
評論加載中...
作者其他優(yōu)質(zhì)文章