Vue.js 3 · Phần 10 — TypeScript với Vue
Dùng TypeScript đúng cách với Vue 3: type props/emits/slots, generic component, template ref đã ép kiểu, type cho composable và Pinia store, ép kiểu provide/inject, và những bẫy type thường gặp.
Cả series đã viết lang="ts", nhưng TypeScript với Vue có vài chỗ riêng đáng gom lại một chỗ. Composition API + <script setup> cho trải nghiệm type tốt nhất trong các framework — phần này chỉ cách khai thác trọn: từ props/emits đến generic component và template ref.
1. Cấu hình & vue-tsc
tsc thường không hiểu file .vue. Dùng vue-tsc để type-check (Volar/Vue - Official lo trong editor):
// package.json
{
"scripts": {
"type-check": "vue-tsc --noEmit" // chạy trong CI
}
}
create-vue đã cấu hình tsconfig chuẩn. Bật "strict": true — toàn bộ lợi ích type đến từ đây.
2. Type props & emits
Dạng type-based (đã dùng ở Phần 4) là cách viết khuyến nghị:
<script setup lang="ts">
interface Props {
title: string;
count?: number;
items: readonly string[];
}
const props = withDefaults(defineProps<Props>(), { count: 0 });
const emit = defineEmits<{
change: [value: number]; // payload typed
submit: [data: Props];
}>();
</script>
defineProps<T>() cho kiểu chính xác hơn dạng object runtime; withDefaults gắn default mà vẫn giữ type. Emit dạng tuple change: [value: number] ràng kiểu payload, nên emit('change', 'x') sẽ báo lỗi.
3. Template ref đã ép kiểu
Tham chiếu phần tử/component qua ref. Với Vue 3.5+, dùng useTemplateRef:
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue';
const inputRef = useTemplateRef<HTMLInputElement>('myInput');
onMounted(() => inputRef.value?.focus()); // typed: biết .focus() tồn tại
</script>
<template>
<input ref="myInput" />
</template>
Ref tới một component con cần kiểu instance của nó:
import type ChildComp from './ChildComp.vue';
const child = useTemplateRef<InstanceType<typeof ChildComp>>('child');
// gọi method con phơi qua defineExpose()
Con phải defineExpose({ method }) thì cha mới gọi được method của nó qua ref.
4. Generic component
Vue 3.3+ cho component generic — cực mạnh cho component danh sách/bảng tái dùng:
<!-- TypedList.vue -->
<script setup lang="ts" generic="T">
defineProps<{
items: T[];
getKey: (item: T) => string | number;
}>();
defineEmits<{ select: [item: T] }>();
</script>
<template>
<li v-for="item in items" :key="getKey(item)">
<slot :item="item" />
</li>
</template>
<!-- T tự suy ra là User; slot và emit đều typed theo User -->
<TypedList :items="users" :get-key="(u) => u.id" @select="onSelect">
<template #default="{ item }">{{ item.name }}</template>
</TypedList>
generic="T" cho phép giữ quan hệ kiểu giữa items, getKey, slot và emit — không phải any.
5. Type composable & store
Composable suy ra kiểu trả về tự động, nhưng nêu rõ kiểu generic đầu vào/ra giúp API rõ ràng:
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null); // ref<T | null> để gọi useFetch<User[]>(...)
// ...
return { data };
}
Pinia setup store suy ra kiểu từ những gì bạn return — không cần khai báo thêm:
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null); // chỉ cần annotate state khởi tạo null
const isAdmin = computed(() => user.value?.role === 'admin'); // suy ra boolean
return { user, isAdmin };
});
6. Ép kiểu provide/inject
Dùng InjectionKey (Phần 4) để inject suy ra đúng kiểu, không phải unknown:
import type { InjectionKey, Ref } from 'vue';
export const themeKey = Symbol() as InjectionKey<Ref<'dark' | 'light'>>;
// provide(themeKey, theme)
const theme = inject(themeKey); // Ref<'dark' | 'light'> | undefined
Xử lý undefined (khi không có provider) bằng default hoặc khẳng định bắt buộc:
const theme = inject(themeKey);
if (!theme) throw new Error('themeKey chưa được provide');
7. Bẫy type thường gặp
reflồng nhau:ref<{ user: Ref<User> }>hiếm khi đúng ý —reftự unwrap ref ở tầng trên; ưu tiên cấu trúc phẳng.reactivemất type khi destructure →toRefs(Phần 3).$eventtrong template làanyngầm — ép kiểu:($event.target as HTMLInputElement).value.- Tránh
astrừ khi đã narrow runtime (tuân prohibition của project) — ưu tiên type guard.
8. Bài tập
1. Vì sao defineProps<Props>() tốt hơn defineProps({ title: String })?
Lời giải
Dạng type-based cho kiểu TS chính xác (union, optional, readonly, object phức tạp) và không cần lặp lại khai báo runtime. Trình biên dịch Vue sinh runtime check từ type.
2. Làm sao gọi được một method của component con từ cha qua template ref?
Lời giải
Con defineExpose({ method }); cha lấy ref kiểu InstanceType<typeof Child> rồi child.value?.method().
3. generic="T" trên component giải quyết điều gì?
Lời giải
Giữ quan hệ kiểu giữa props (items: T[]), callback, slot và emit theo cùng một T được suy ra từ chỗ dùng — thay vì rơi về any/unknown.
Điểm chính
- Type-check bằng
vue-tsc --noEmit; bậtstrict. defineProps<T>()+withDefaults,defineEmits<{...}>()cho props/emits typed.useTemplateRef<T>()cho template ref; condefineExposeđể cha gọi method.generic="T"cho component danh sách/bảng type-safe.- Pinia/composable suy ra kiểu tự động;
InjectionKeycho provide/inject; tránhaskhông có narrow.
Phần tiếp theo
App đã reactive và type-safe — giờ làm nó nhanh. Phần 11 — Tối ưu hiệu năng đi qua: tránh re-render thừa, v-once/v-memo, shallowRef cho dữ liệu lớn, lazy-load component & route, ảo hóa danh sách dài, và đọc bundle để cắt thừa.