jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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>() + withDefaults cho 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.