Khai thác tối đa Props trong Vue 3


Tuan Duc Tran

Props là một trong những cơ chế cơ bản nhất để truyền dữ liệu từ component cha xuống component con trong Vue.

Vì quá quen thuộc, chúng rất dễ bị xem nhẹ.

Bạn có thể chỉ cần viết:

<script setup>
defineProps(['title'])
</script>

Và component vẫn hoạt động.

Nhưng khi component trở thành một phần của design system, component library hoặc một codebase lớn, việc khai báo props rõ ràng trở nên quan trọng hơn rất nhiều.

Một prop không chỉ là “một giá trị được truyền vào component”.

Nó là một phần của API contract của component:

  • Component chấp nhận dữ liệu gì?
  • Dữ liệu đó có bắt buộc không?
  • Có giá trị mặc định hay không?
  • Có thể nhận nhiều kiểu dữ liệu không?
  • Có thể giới hạn vào một tập giá trị cụ thể không?
  • Cấu trúc của object hoặc array có đúng không?
  • TypeScript có thể kiểm tra được điều gì?
  • Vue có thể kiểm tra được gì ở runtime?

Vue 3 cung cấp nhiều công cụ để trả lời những câu hỏi này.

Bài viết này tập trung vào Vue 3 hiện đại, đặc biệt là <script setup> và TypeScript.

1. Hãy khai báo Props một cách rõ ràng

Điều đầu tiên:

Đừng coi props như những attribute tùy ý.

Props là public interface của component.

Trong <script setup>, Vue cung cấp defineProps() để khai báo props:

<script setup>
defineProps(['title'])
</script>

Bạn cũng có thể sử dụng runtime declaration để mô tả type:

<script setup>
defineProps({
  title: String,
  count: Number,
  disabled: Boolean
})
</script>

Vue cũng hỗ trợ type-based declaration, đặc biệt hữu ích khi project sử dụng TypeScript:

<script setup lang="ts">
interface Props {
  title: string
  count?: number
  disabled?: boolean
}
 
defineProps<Props>()
</script>

Với type-based declaration, TypeScript có thể mô tả component API rõ ràng hơn, đồng thời Vue compiler có thể suy luận runtime prop options từ type trong phạm vi mà compiler có thể phân tích.

TypeScript và Vue runtime validation là hai chuyện khác nhau

TypeScript giúp phát hiện lỗi trong quá trình phát triển:

interface Props {
  count: number
}

Trong khi Vue runtime validation có thể cảnh báo khi component thực sự nhận dữ liệu không đúng type:

<script setup>
defineProps({
  count: Number
})
</script>

Vì vậy:

TypeScript giúp kiểm tra contract ở compile time; Vue runtime validation giúp kiểm tra một số yêu cầu khi component thực sự nhận dữ liệu.

Hai cơ chế này bổ sung cho nhau.

2. Giới hạn một Prop vào một tập giá trị cụ thể

Kiểm tra:

type: String

Mới chỉ đảm bảo rằng giá trị là string.

Nó không nói string đó có hợp lệ hay không.

Ví dụ, component StatusBadge chỉ cho phép ba trạng thái:

  • success
  • warning
  • danger

Nếu chỉ khai báo:

<script setup>
defineProps({
  status: String
})
</script>

Thì giá trị này vẫn hợp lệ về mặt runtime type:

<StatusBadge status="banana" />

Nhưng banana rõ ràng không phải trạng thái mà component hỗ trợ.

Sử dụng validator

Vue cho phép thêm custom validator:

<script setup>
defineProps({
  status: {
    type: String,
    validator(value) {
      return ['success', 'warning', 'danger'].includes(value)
    }
  }
})
</script>

validator phù hợp khi bạn cần kiểm tra một constraint ở runtime.

Vue cũng hỗ trợ validator nhận props làm đối số thứ hai, hữu ích khi validation của một prop phụ thuộc vào các prop khác.

Nhưng TypeScript có thể làm tốt hơn ở nhiều trường hợp

Nếu project sử dụng TypeScript, một union type thường là cách tốt hơn để mô tả tập giá trị hợp lệ:

<script setup lang="ts">
interface Props {
  status: 'success' | 'warning' | 'danger'
}
 
defineProps<Props>()
</script>

Bây giờ TypeScript và IDE có thể phát hiện:

<StatusBadge status="banana" />

Là không hợp lệ.

Đây là một khác biệt quan trọng:

Không phải validation nào cũng cần được thực hiện bằng validator.

Nếu yêu cầu có thể được biểu đạt chính xác bằng TypeScript, hãy tận dụng type system.

validator phù hợp hơn khi bạn cần runtime validation.

3. Kiểm tra cấu trúc của Object và Array

Một hạn chế của runtime declaration là:

<script setup>
defineProps({
  user: Object
})
</script>

Chỉ cho Vue biết rằng user là một object.

TypeScript có thể mô tả cấu trúc chính xác hơn:

<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}
 
interface Props {
  user: User
}
 
defineProps<Props>()
</script>

Tương tự với array:

<script setup lang="ts">
interface Props {
  tags: string[]
}
 
defineProps<Props>()
</script>

TypeScript lúc này hiểu:

props.tags

là:

string[]

IDE có thể cung cấp autocomplete, type checking và hỗ trợ refactoring.

Nhưng TypeScript không kiểm tra dữ liệu runtime

Giả sử dữ liệu đến từ API:

const user = await fetchUser()

Việc khai báo một TypeScript type không tự động xác thực response runtime.

Nếu dữ liệu thực tế không đúng shape, TypeScript không thể tự kiểm tra response chỉ vì bạn đã viết một interface.

Đây là lúc runtime validation hoặc schema validation có giá trị.

Ví dụ đơn giản:

<script setup>
defineProps({
  tags: {
    type: Array,
    validator(tags) {
      return tags.every(
        tag => typeof tag === 'string' && tag.length >= 3
      )
    }
  }
})
</script>

Tuy nhiên, nếu validation ngày càng phức tạp, đừng biến validator thành một schema-validation framework thu nhỏ.

Một object lớn với nhiều nested object, conditional rules và business constraints thường nên được validate ở data boundary phù hợp.

4. Hiểu đúng về required

Một prop là required khi component thực sự cần consumer cung cấp giá trị đó.

Ví dụ với runtime declaration:

<script setup>
defineProps({
  title: {
    type: String,
    required: true
  }
})
</script>

Với TypeScript:

<script setup lang="ts">
interface Props {
  title: string
}
 
defineProps<Props>()
</script>

Trong khi:

interface Props {
  title?: string
}

Mô tả một prop optional.

Vue compiler có thể suy luận runtime options tương ứng từ type-based declaration trong phạm vi mà compiler có thể phân tích.

Đừng dùng required chỉ vì “có vẻ an toàn hơn”

Một prop nên là required khi component thực sự cần dữ liệu đó từ bên ngoài.

Nếu component có thể hoạt động bình thường mà không cần consumer truyền prop, hãy để nó optional hoặc cung cấp behavior mặc định phù hợp.

5. Không phải Prop optional nào cũng cần default

Một nguyên tắc quá tuyệt đối là:

“Nếu prop không required thì luôn phải có default.”

Điều này không đúng trong mọi trường hợp.

Có những prop hoàn toàn hợp lệ khi giá trị của chúng là undefined.

Ví dụ:

interface Props {
  subtitle?: string
}

Component có thể đơn giản không render subtitle nếu prop không được truyền.

Không cần tạo một default giả chỉ để tránh undefined.

Khi nào nên dùng default?

Hãy dùng default khi component có một behavior mặc định rõ ràng.

Ví dụ:

<script setup lang="ts">
interface Props {
  size?: 'sm' | 'md' | 'lg'
}
 
const {
  size = 'md'
} = defineProps<Props>()
</script>

Ở đây md thực sự là behavior mặc định của component.

Vue hỗ trợ reactive props destructure trong <script setup>, cho phép đặt default value trực tiếp khi destructure props.

Default không phải để “làm cho code an toàn hơn” một cách máy móc.

Nó phải có ý nghĩa trong API của component.

withDefaults

Nếu cần dùng cách khai báo type-based truyền thống hoặc project chưa sử dụng reactive props destructure, Vue cung cấp withDefaults():

<script setup lang="ts">
interface Props {
  msg?: string
  labels?: string[]
}
 
const props = withDefaults(defineProps<Props>(), {
  msg: 'hello',
  labels: () => ['one', 'two']
})
</script>

Với withDefaults, các default value cho array hoặc object nên được cung cấp qua function để mỗi component instance có giá trị riêng.

6. Đừng lạm dụng nhiều type cho một Prop

Vue cho phép một prop nhận nhiều runtime types:

<script setup>
defineProps({
  value: {
    type: [String, Number]
  }
})
</script>

Component lúc này có thể nhận:

<Component value="10" />

Hoặc:

<Component :value="10" />

Đây là tính năng hợp lệ.

Nhưng nó không phải lúc nào cũng là thiết kế tốt.

Nếu component thực sự cần một number:

interface Props {
  value: number
}

Thường tốt hơn:

interface Props {
  value: string | number
}

Bởi vì union type làm tăng số trạng thái mà component phải xử lý.

Ví dụ:

if (typeof value === 'string') {
  // xử lý string
} else {
  // xử lý number
}

Nếu mọi nơi đều phải chuyển:

'10'

Thành:

10

Thì tốt hơn là thống nhất contract ngay từ đầu.

Tuy nhiên, union type không phải điều xấu

Có những union type hoàn toàn hợp lý:

interface Props {
  size: 'sm' | 'md' | 'lg'
}

Hoặc:

interface Props {
  id: string | number
}

Vấn đề không phải:

“Không bao giờ dùng nhiều type.”

Mà là:

Chỉ cho phép nhiều type khi component thực sự có ý nghĩa với tất cả các type đó.

7. Tận dụng TypeScript thay vì biến validator thành một type system thứ hai

Đây là một trong những khác biệt lớn giữa Vue hiện đại và các bài viết về props từ thời Vue 2.

Ngày nay, bạn có thể viết:

<script setup lang="ts">
interface Props {
  variant: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
}
 
defineProps<Props>()
</script>

Thay vì:

<script setup>
defineProps({
  variant: {
    type: String,
    validator: value =>
      ['primary', 'secondary', 'danger'].includes(value)
  },
  size: {
    type: String,
    validator: value =>
      ['sm', 'md', 'lg'].includes(value)
  },
  disabled: Boolean
})
</script>

Cả hai cách đều có giá trị, nhưng chúng phục vụ những mục đích khác nhau.

TypeScript phù hợp với

  • autocomplete
  • static analysis
  • refactoring
  • type checking
  • union types
  • object shapes
  • generic types
  • function signatures

Vue runtime validation phù hợp với

  • kiểm tra dữ liệu thực tế truyền vào component
  • cảnh báo sai runtime trong development
  • những constraint cần được kiểm tra lúc runtime

Vue sẽ cố gắng chuyển type-based defineProps() thành runtime declaration, nhưng quá trình này dựa trên compiler và khả năng phân tích type. Một số TypeScript type phức tạp không thể được chuyển hoàn toàn thành runtime validation tương ứng.

Do đó:

Đừng xem TypeScript và Vue runtime validation là cùng một hệ thống.

Chúng hoạt động ở hai tầng khác nhau.

8. Hiểu đúng về Reactive Props Destructure

Một pattern phổ biến trong Vue 3 là:

const { foo } = defineProps(['foo'])

Với Vue hiện đại, destructuring trực tiếp từ defineProps() trong <script setup> có thể giữ được tính reactive nhờ Reactive Props Destructure. Điều này cũng cho phép đặt default value trực tiếp trong destructuring.

Ví dụ:

<script setup lang="ts">
interface Props {
  msg: string
  count?: number
  foo?: string
}
 
const {
  msg,
  count = 1,
  foo: bar
} = defineProps<Props>()
</script>

Khi prop thay đổi, các biến destructured tương ứng vẫn được compiler xử lý theo cơ chế reactive props destructure.

Điều này khác với việc destructure một object props thông thường trong những ngữ cảnh không có compiler transform.

Ví dụ trong setup():

export default {
  props: {
    title: String
  },
 
  setup(props) {
    const { title } = props
  }
}

Cách destructure này có thể làm mất tính reactive của biến destructured. Nếu cần giữ reactivity trong trường hợp đó, Vue cung cấp toRefs()toRef().

Vì vậy, hãy phân biệt:

  • Reactive Props Destructure trong <script setup>
  • destructuring một reactive props object thông thường
  • toRefs() / toRef()

Chúng không hoàn toàn giống nhau.

9. Khi cần type phức tạp trong Options API, sử dụng PropType

Không phải project nào cũng sử dụng <script setup>.

Nếu đang sử dụng Options API với TypeScript, Vue cung cấp PropType để mô tả những kiểu phức tạp hơn runtime constructor thông thường.

Ví dụ:

import { defineComponent } from 'vue'
import type { PropType } from 'vue'
 
interface User {
  id: number
  name: string
  email: string
}
 
export default defineComponent({
  props: {
    user: {
      type: Object as PropType<User>,
      required: true
    }
  }
})

Hoặc với function:

callback: Function as PropType<(id: number) => void>

Object một mình chỉ mô tả runtime constructor.

PropType<User> bổ sung thông tin type cho TypeScript.

Trong code Vue 3 mới sử dụng:

<script setup lang="ts">

Bạn thường có thể dùng type-based defineProps() thay vì PropType cho phần lớn trường hợp.

10. Đừng biến Props thành nơi chứa business logic

Có một giới hạn quan trọng:

Prop validation không phải business validation.

Ví dụ:

validator: value => value >= 18

Có thể hợp lý nếu component thực sự chỉ chấp nhận một giá trị từ 18 trở lên.

Nhưng nếu bắt đầu viết:

validator(value) {
  // kiểm tra database
  // gọi API
  // kiểm tra permission
  // kiểm tra subscription
  // kiểm tra trạng thái user
}

Thì component đang nhận quá nhiều trách nhiệm.

Prop validation nên tập trung vào contract của component.

Ví dụ:

interface Props {
  size: 'sm' | 'md' | 'lg'
}

Là một contract tốt.

Trong khi một validator chứa nhiều business rules thường là dấu hiệu validation đang nằm sai tầng.

Vue thực hiện prop validation trước khi component instance được tạo, vì vậy instance properties như data, computed và các property khác của instance không có sẵn bên trong default hoặc validator.

11. Props nên mô tả API của component, không phải implementation detail

Hãy xem một component button:

<Button
  variant="primary"
  size="md"
  :disabled="isLoading"
/>

Đây là một API khá rõ ràng.

Props mô tả những gì người dùng component cần biết:

interface Props {
  variant: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
}

Ngược lại, một API như:

<Button
  :internal-padding="12"
  :button-render-mode="3"
  :use-primary-color="true"
/>

Đang để lộ implementation detail.

Prop càng phản ánh ý nghĩa của component, component càng dễ sử dụng và bảo trì.

Props nên mô tả what, thay vì buộc consumer phải biết how component được triển khai.

12. Props có one-way data flow

Props được truyền từ parent xuống child.

Mô hình cơ bản là:

Parent

  │ props

Child

Child không nên trực tiếp mutate prop:

<script setup lang="ts">
const props = defineProps<{
  value: string
}>()
 
// Không nên:
// props.value = 'new value'
</script>

Nếu child cần yêu cầu parent cập nhật giá trị, hãy dùng event hoặc v-model.

Trong Vue hiện đại, defineModel() cung cấp một cách thuận tiện để khai báo model prop cho component sử dụng v-model:

<script setup lang="ts">
const model = defineModel<string>()
</script>
 
<template>
  <input v-model="model" />
</template>

Điều này giúp phân biệt rõ giữa:

  • prop chỉ đọc
  • dữ liệu có thể được cập nhật thông qua v-model

13. Props cũng là một phần của Design System

Trong một component library, props không chỉ phục vụ một component đơn lẻ.

Chúng tạo thành vocabulary chung cho toàn bộ hệ thống.

Ví dụ:

type Size = 'sm' | 'md' | 'lg'
type Variant = 'primary' | 'secondary' | 'danger'

Sau đó nhiều component có thể sử dụng cùng vocabulary:

interface ButtonProps {
  size?: Size
  variant?: Variant
}
interface BadgeProps {
  size?: Size
  variant?: Variant
}
interface AlertProps {
  variant?: Variant
}

Điều này giúp API của design system nhất quán.

Consumer không phải học:

Button: small | medium | large
Badge: sm | md | lg
Alert: compact | normal | large

Nếu chúng thực sự biểu đạt cùng một concept.

Type system vì vậy không chỉ giúp bắt lỗi.

Nó còn giúp chuẩn hóa ngôn ngữ của component library.

14. Một ví dụ hoàn chỉnh với Vue 3 + TypeScript

Hãy kết hợp những nguyên tắc trên vào một component thực tế:

<script setup lang="ts">
interface Props {
  label: string
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  loading?: boolean
}
 
const {
  label,
  variant = 'primary',
  size = 'md',
  disabled = false,
  loading = false
} = defineProps<Props>()
</script>
 
<template>
  <button
    :class="[
      'button',
      `button--${variant}`,
      `button--${size}`
    ]"
    :disabled="disabled || loading"
  >
    <span v-if="loading">Loading...</span>
    <span v-else>{{ label }}</span>
  </button>
</template>

Component này có một API rõ ràng:

label: string
variant: 'primary' | 'secondary' | 'danger'
size: 'sm' | 'md' | 'lg'
disabled: boolean
loading: boolean

Các giá trị mặc định cũng được đặt ngay tại nơi destructure:

const {
  variant = 'primary',
  size = 'md',
  disabled = false,
  loading = false
} = defineProps<Props>()

Điều này làm cho default behavior dễ nhìn thấy ngay trong phần khai báo API.

15. Khi nào nên dùng Runtime Declaration và khi nào nên dùng Type-based Declaration?

Vue 3 hỗ trợ cả hai hình thức.

Runtime declaration

<script setup>
defineProps({
  title: {
    type: String,
    required: true
  },
 
  status: {
    type: String,
    validator: value =>
      ['success', 'error'].includes(value)
  }
})
</script>

Phù hợp khi bạn cần trực tiếp sử dụng:

  • type
  • required
  • default
  • validator
  • runtime prop validation

Type-based declaration

<script setup lang="ts">
interface Props {
  title: string
  status: 'success' | 'error'
}
 
defineProps<Props>()
</script>

Phù hợp khi:

  • project sử dụng TypeScript
  • props có object shape phức tạp
  • cần union types
  • cần generic types
  • muốn tận dụng IDE
  • muốn có type inference mạnh

Vue hỗ trợ cả runtime declaration và type-based declaration, nhưng một lần gọi defineProps() không thể đồng thời dùng cả hai kiểu declaration.

Một nguyên tắc thực tế là:

Chọn một cách khai báo phù hợp với nhu cầu của component thay vì cố gắng trộn hai hệ thống với nhau.

16. Một Props tốt nên có API nhỏ và rõ ràng

Không phải cứ thêm nhiều props là component càng linh hoạt.

Ví dụ:

<Card
  :show-header="true"
  :show-footer="true"
  :header-padding="16"
  :footer-padding="12"
  :body-padding="20"
  :border-width="1"
  :border-style="'solid'"
  :border-radius="8"
/>

Component này có thể rất configurable.

Nhưng nó cũng có một API rất lớn.

Mỗi prop là một phần của contract mà consumer phải hiểu và bạn phải duy trì.

Thay vì expose quá nhiều implementation details, hãy ưu tiên những props mang ý nghĩa ở cấp component:

<Card
  variant="outlined"
  size="md"
>
  ...
</Card>

Nếu component cần customization sâu hơn, hãy cân nhắc những cơ chế phù hợp khác của Vue như slots, CSS variables, provide/inject hoặc component composition.

Props không phải công cụ duy nhất để tạo ra một API component.

17. Một checklist thiết kế Props cho Vue 3 hiện đại

Trước khi thêm một prop vào component, hãy tự hỏi:

1. Prop này có thực sự cần thiết không?

Nếu component có thể tự quyết định giá trị, có thể không cần expose prop.

2. Type có đủ cụ thể chưa?

Tránh:

value: any

nếu bạn có thể mô tả chính xác hơn.

3. Có nên dùng union type không?

Thay vì:

variant: string

hãy cân nhắc:

variant: 'primary' | 'secondary' | 'danger'

4. Prop có thực sự required không?

Không phải prop optional nào cũng cần default.

5. Có default hợp lý không?

Nếu component có behavior mặc định rõ ràng:

size = 'md'

thường tốt hơn việc để component xử lý undefined ở nhiều nơi.

6. TypeScript đã đủ chưa?

Nếu type system có thể biểu đạt yêu cầu, đừng viết thêm validator chỉ để lặp lại cùng một logic.

7. Có cần runtime validation không?

Nếu dữ liệu có thể đến từ runtime không đáng tin cậy, custom validator hoặc validation ở data boundary có thể cần thiết.

8. Prop có đang chứa business logic không?

Nếu có, validation có thể đang nằm sai tầng.

9. API của component có dễ hiểu không?

Một component tốt nên cho người dùng biết:

“Tôi có thể truyền gì vào đây?”

mà không cần đọc implementation bên trong.

10. Prop có làm component quá configurable không?

Nếu component có quá nhiều props, có thể vấn đề không nằm ở validation mà nằm ở component architecture.

Kết luận

Props không chỉ là cách truyền dữ liệu từ component cha xuống component con.

Chúng là contract của component.

Trong Vue 3 hiện đại, cách tiếp cận tốt không đơn giản là:

“Hãy thêm càng nhiều prop validation càng tốt.”

Thay vào đó, hãy sử dụng đúng công cụ cho đúng tầng.

TypeScript để mô tả type và API ở compile time.

Vue runtime props để khai báo runtime contract.

validator cho những constraint thực sự cần kiểm tra lúc runtime.

Default values khi component có một behavior mặc định rõ ràng.

Union types để giới hạn những giá trị có ý nghĩa.

Object và array types để mô tả cấu trúc dữ liệu.

PropType khi cần mô tả type phức tạp trong Options API.

Và quan trọng nhất:

Hãy thiết kế props như một public API, không phải như một nơi để truyền mọi thứ mà component tình cờ cần.

Một prop tốt làm cho component dễ hiểu hơn.

Một prop type tốt làm cho API khó sử dụng sai hơn.

Một default hợp lý làm cho component có behavior nhất quán hơn.

Một validator tốt bảo vệ những contract cần được kiểm tra ở runtime.

Và một API component tốt sẽ khiến phần implementation bên trong trở nên đơn giản hơn.

Props vì vậy không chỉ là một tính năng của Vue.

Chúng là một phần quan trọng trong thiết kế component architecture.