Vue.js 3 · Phần 4 — Component: Props, Emits, Slots & Provide/Inject
Giao tiếp giữa component trong Vue 3: truyền dữ liệu xuống bằng defineProps (typed), bắn sự kiện lên bằng defineEmits, tùy biến nội dung qua slot (named & scoped), và chia sẻ xuyên tầng với provide/inject.
App thật là một cây component. Phần này dạy bốn kênh giao tiếp giữa chúng: props (dữ liệu xuống), emits (sự kiện lên), slots (nội dung do cha rót vào), và provide/inject (chia sẻ xuyên nhiều tầng). Nguyên tắc nền tảng của Vue: dữ liệu chảy một chiều xuống, sự kiện đi một chiều lên — giữ luồng dễ suy luận.
1. Props — truyền dữ liệu xuống
Component con khai báo props nó nhận bằng defineProps. Với TypeScript, dùng dạng type-based:
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
name: string;
age?: number; // optional
role?: 'user' | 'admin';
}
const props = withDefaults(defineProps<Props>(), {
age: 0,
role: 'user', // giá trị mặc định
});
</script>
<template>
<div class="card">
<h3>{{ props.name }} ({{ role }})</h3>
<p v-if="age">Tuổi: {{ age }}</p>
</div>
</template>
Cha truyền xuống:
<UserCard name="An" :age="30" role="admin" />
Lưu ý: name="An" truyền chuỗi tĩnh, còn :age="30" (có :) truyền số qua biểu thức. Props là read-only — con không được sửa props. Cần đổi thì hoặc emit sự kiện lên cha, hoặc tạo state cục bộ từ props.
2. Emits — bắn sự kiện lên
Con không sửa được props, nên muốn “báo” cho cha thì emit sự kiện. Khai báo bằng defineEmits:
<!-- SearchBox.vue -->
<script setup lang="ts">
const emit = defineEmits<{
search: [query: string]; // tên sự kiện : [kiểu payload]
clear: [];
}>();
function onSubmit(q: string) {
emit('search', q); // gửi lên cha
}
</script>
Cha lắng nghe bằng @:
<SearchBox @search="handleSearch" @clear="results = []" />
Đây chính là mô hình hai chiều của v-model ở mức thấp: prop xuống + sự kiện lên. Ta dựng v-model tùy biến từ cặp này ở Phần 6.
3. Slots — để cha rót nội dung
Props truyền dữ liệu; slot truyền markup. Đây là cách dựng component “container” linh hoạt (Card, Modal, Layout).
<!-- Card.vue -->
<template>
<div class="card">
<header><slot name="title">Tiêu đề mặc định</slot></header>
<div class="body"><slot /></div> <!-- slot mặc định -->
<footer><slot name="actions" /></footer>
</div>
</template>
Cha rót vào theo tên:
<Card>
<template #title>Hồ sơ</template>
<p>Nội dung chính ở slot mặc định.</p>
<template #actions><button>Lưu</button></template>
</Card>
<slot>Tiêu đề mặc định</slot> cho fallback khi cha không cung cấp. #title là viết tắt của v-slot:title.
Scoped slot — con truyền dữ liệu ngược ra slot
Mạnh nhất: con phơi dữ liệu cho cha quyết định cách render — nền tảng của component “renderless”/headless:
<!-- DataList.vue -->
<template>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="item.id" /> <!-- truyền item ra cho cha -->
</li>
</template>
<DataList :items="users">
<template #default="{ item }">
<strong>{{ item.name }}</strong> — {{ item.email }}
</template>
</DataList>
Con sở hữu vòng lặp & dữ liệu; cha sở hữu cách hiển thị. Đây là pattern tách logic khỏi UI rất mạnh.
4. Provide / Inject — chia sẻ xuyên tầng
Truyền prop qua nhiều tầng trung gian không dùng tới (prop drilling) rất mệt. provide/inject cho component tổ tiên cung cấp giá trị mà mọi con cháu inject được, không qua tầng trung gian.
// component cha (hoặc App) — provide
import { provide, ref, readonly } from 'vue';
const theme = ref<'dark' | 'light'>('dark');
provide('theme', readonly(theme)); // readonly: con chỉ đọc, không sửa trực tiếp
provide('toggleTheme', () => { theme.value = theme.value === 'dark' ? 'light' : 'dark'; });
// component con cháu bất kỳ — inject
import { inject } from 'vue';
const theme = inject<Ref<string>>('theme');
const toggleTheme = inject<() => void>('toggleTheme');
Để type-safe và tránh trùng key chuỗi, dùng InjectionKey:
import type { InjectionKey, Ref } from 'vue';
export const themeKey = Symbol() as InjectionKey<Ref<string>>;
// provide(themeKey, theme) → const theme = inject(themeKey) // tự suy ra kiểu
provide/inject hợp cho cặp tổ tiên ↔ con cháu (theme, i18n, form context). Để state dùng chung toàn app (auth, giỏ hàng), dùng Pinia (Phần 8) — nó có devtools, SSR-safe và dễ test hơn.
5. Bài tập
1. Vì sao con không nên (và không thể) gán trực tiếp vào props?
Lời giải
Props read-only để giữ luồng một chiều: dữ liệu xuống, sự kiện lên. Sửa props phá nguồn-sự-thật ở cha và gây bug khó lần. Muốn đổi → emit lên cho cha cập nhật, hoặc copy ra state cục bộ.
2. Khi nào dùng scoped slot thay vì props thường?
Lời giải
Khi con sở hữu dữ liệu/logic (vòng lặp, fetch) nhưng muốn cha quyết định cách render từng item. Con <slot :item="item" />, cha nhận qua #default="{ item }".
3. provide/inject vs Pinia — chọn cái nào cho theme, cái nào cho auth toàn app?
Lời giải
Theme (gắn với cây component, ngữ cảnh cục bộ) → provide/inject. Auth state dùng khắp app, cần devtools/test/SSR → Pinia.
Điểm chính
- Luồng Vue: props xuống, emits lên — một chiều, dễ suy luận.
defineProps<T>()+withDefaultscho props typed; props read-only.defineEmits<T>()khai báo sự kiện con bắn lên cha.- Slot rót markup (named, fallback); scoped slot để con truyền dữ liệu ra cho cha render.
- provide/inject chống prop drilling cho ngữ cảnh tổ tiên↔con cháu; state toàn app dùng Pinia.
Phần tiếp theo
Bạn đã ghép được component thành cây. Phần 5 — Composition API & Composable chỉ cách trích logic có trạng thái (fetch, mouse, localStorage) ra hàm useXxx() tái dùng được — bí quyết để code Vue không phình to và lặp lại.