jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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

  • ref lồng nhau: ref<{ user: Ref<User> }> hiếm khi đúng ý — ref tự unwrap ref ở tầng trên; ưu tiên cấu trúc phẳng.
  • reactive mất type khi destructuretoRefs (Phần 3).
  • $event trong templateany ngầm — ép kiểu: ($event.target as HTMLInputElement).value.
  • Tránh as trừ 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ật strict.
  • defineProps<T>() + withDefaults, defineEmits<{...}>() cho props/emits typed.
  • useTemplateRef<T>() cho template ref; con defineExpose để cha gọi method.
  • generic="T" cho component danh sách/bảng type-safe.
  • Pinia/composable suy ra kiểu tự động; InjectionKey cho provide/inject; tránh as khô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.