Vue 3 Composables Nâng Cao: Các Pattern Tái Sử Dụng và Câu Hỏi Phỏng Vấn 2026

Hướng dẫn chi tiết về Vue 3 composables nâng cao với các pattern tái sử dụng, xử lý lỗi bất đồng bộ, dependency injection, form validation và câu hỏi phỏng vấn kỹ thuật cập nhật 2026.

Các pattern nâng cao của Vue 3 Composables với ví dụ code TypeScript và sơ đồ tổ hợp phản ứng

Composition API của Vue 3 đã thay đổi căn bản cách các lập trình viên tổ chức và tái sử dụng logic trong ứng dụng frontend. Trung tâm của sự chuyển đổi này chính là composables: các hàm đóng gói trạng thái phản ứng (reactive state), logic nghiệp vụ và side effects thành những đơn vị độc lập, có thể tái sử dụng. Khác với mixins trong Vue 2 thường gây ra xung đột tên và các dependency ẩn, composables mang lại sự minh bạch hoàn toàn, hỗ trợ kiểu tĩnh với TypeScript và cho phép tổ hợp (composition) tường minh.

Với sự ra mắt của Alien Signals và khả năng tương thích với Vapor Mode trong Vue 3.6, việc thành thạo các pattern composable có tác động trực tiếp đến kiến trúc ứng dụng, hiệu năng và khả năng bảo trì trong năm 2026. Bài viết này phân tích các pattern thường gặp nhất trong phỏng vấn kỹ thuật, từ cấu trúc của một composable chuẩn chỉ đến dependency injection với provide/inject, bao gồm cả chiến lược testing để kiểm chứng tính phản ứng của từng thành phần.

Composable trong Vue 3 là gì?

Composable là một hàm sử dụng Composition API của Vue để đóng gói và tái sử dụng logic có trạng thái (stateful logic). Theo quy ước, tên composable bắt đầu với use (ví dụ: useCounter, useFetch). Mỗi composable trả về các ref phản ứng, computed và các hàm mà component có thể sử dụng trực tiếp, không cần kế thừa hay liên kết ngầm định.

Cấu Trúc Của Một Composable Chuẩn Chỉ

Một composable đạt chuẩn production tuân theo cấu trúc có thể dự đoán: nhận cấu hình qua tham số, tạo trạng thái phản ứng bên trong và trả về một đối tượng có kiểu rõ ràng. Pattern này đảm bảo khả năng tổ hợp (composability), khả năng kiểm thử (testability) và ranh giới API minh bạch.

useCounter.tstypescript
import { ref, computed, type Ref } from 'vue'

interface UseCounterOptions {
  min?: number
  max?: number
  initialValue?: number
}

interface UseCounterReturn {
  count: Ref<number>
  doubled: Ref<number>
  increment: () => void
  decrement: () => void
  reset: () => void
}

export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
  const { min = 0, max = Infinity, initialValue = 0 } = options

  const count = ref(initialValue)
  const doubled = computed(() => count.value * 2)

  function increment() {
    if (count.value < max) count.value++
  }

  function decrement() {
    if (count.value > min) count.value--
  }

  function reset() {
    count.value = initialValue
  }

  return { count, doubled, increment, decrement, reset }
}

Có một số điểm đáng chú ý trong pattern này. Interface UseCounterReturn định nghĩa tường minh hợp đồng (contract) của composable, giúp hỗ trợ autocompletion trong editor và ngăn chặn việc vô tình thay đổi API công khai. Đối tượng options với giá trị mặc định cho phép cấu hình linh hoạt mà không làm phức tạp chữ ký hàm. Mỗi lần gọi useCounter() đều tạo ra một instance trạng thái độc lập, loại bỏ hoàn toàn vấn đề chia sẻ trạng thái mà mixins thường gặp phải.

Kiểu trả về tường minh UseCounterReturn phục vụ hai mục đích: nó tài liệu hóa API công khai của composable và ngăn chặn việc vô tình lộ chi tiết triển khai nội bộ. Component sử dụng chỉ cần destructure những gì cần thiết, giữ cho template bindings luôn minh bạch.

Composable Bất Đồng Bộ Với Xử Lý Lỗi và Trạng Thái Loading

Gọi dữ liệu (data fetching) là một trong những trường hợp sử dụng phổ biến nhất của composables. Một composable bất đồng bộ chắc chắn cần quản lý trạng thái loading, xử lý lỗi và tự động dọn dẹp tài nguyên. Những pattern này thường xuyên được hỏi trong phỏng vấn kỹ thuật.

useFetchData.tstypescript
import { ref, watchEffect, onUnmounted, toValue, type Ref, type MaybeRefOrGetter } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<string | null>
  isLoading: Ref<boolean>
  refresh: () => Promise<void>
}

export function useFetchData<T>(
  url: MaybeRefOrGetter<string>
): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<string | null>(null)
  const isLoading = ref(false)
  let abortController: AbortController | null = null

  async function fetchData() {
    // Cancel any in-flight request
    abortController?.abort()
    abortController = new AbortController()

    isLoading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url), {
        signal: abortController.signal
      })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json()
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') return
      error.value = err instanceof Error ? err.message : 'Unknown error'
    } finally {
      isLoading.value = false
    }
  }

  // Re-fetch when URL changes reactively
  watchEffect(() => {
    fetchData()
  })

  onUnmounted(() => abortController?.abort())

  return { data, error, isLoading, refresh: fetchData }
}

Composable này giới thiệu một số khái niệm nâng cao. Kiểu tham số MaybeRefOrGetter<string> chấp nhận cả giá trị tĩnh (plain string), ref phản ứng hoặc hàm getter, mang lại sự linh hoạt tối đa cho người dùng. Hàm toValue() trích xuất giá trị bên trong bất kể kiểu đầu vào là gì. Việc sử dụng AbortController ngăn chặn race conditions bằng cách hủy các request đã cũ khi URL thay đổi trước khi response trước đó kịp trả về.

watchEffect thiết lập cơ chế theo dõi phản ứng tự động: bất kỳ ref hoặc getter nào được truy cập bên trong callback đều tự động trở thành dependency. Nếu URL là một ref và giá trị của nó thay đổi, effect sẽ tự động chạy lại. Hook onUnmounted đảm bảo việc giải phóng tài nguyên khi component bị hủy.

Lifecycle hooks trong composables

Các composable sử dụng onMounted, onUnmounted hoặc các lifecycle hook khác bắt buộc phải được gọi đồng bộ (synchronously) bên trong setup(). Việc gọi composable bên trong một async callback hoặc setTimeout sẽ khiến lifecycle hook không được đăng ký một cách thầm lặng vì không có component instance nào đang hoạt động.

Tổ Hợp Composables: Xây Dựng Lớp Trừu Tượng Cao Hơn

Sức mạnh thực sự của composables bộc lộ khi chúng tổ hợp với nhau. Điều này phản ánh nguyên tắc tổ hợp hàm (functional composition): các đơn vị nhỏ, tập trung được kết hợp để tạo ra hành vi phức tạp mà không cần dùng đến kế thừa.

usePaginatedSearch.tstypescript
import { ref, computed, watch, type Ref } from 'vue'
import { useFetchData } from './useFetchData'
import { useDebouncedRef } from './useDebouncedRef'

interface UsePaginatedSearchReturn<T> {
  query: Ref<string>
  page: Ref<number>
  results: Ref<T[] | null>
  totalPages: Ref<number>
  isLoading: Ref<boolean>
  error: Ref<string | null>
  nextPage: () => void
  prevPage: () => void
}

export function usePaginatedSearch<T>(
  baseUrl: string,
  perPage = 20
): UsePaginatedSearchReturn<T> {
  const query = useDebouncedRef('', 300)
  const page = ref(1)
  const totalPages = ref(1)

  const apiUrl = computed(
    () => `${baseUrl}?q=${encodeURIComponent(query.value)}&page=${page.value}&limit=${perPage}`
  )

  const { data, error, isLoading } = useFetchData<{ items: T[]; total: number }>(apiUrl)

  const results = computed(() => data.value?.items ?? null)

  watch(data, (response) => {
    if (response) {
      totalPages.value = Math.ceil(response.total / perPage)
    }
  })

  // Reset to page 1 when query changes
  watch(query, () => { page.value = 1 })

  function nextPage() {
    if (page.value < totalPages.value) page.value++
  }

  function prevPage() {
    if (page.value > 1) page.value--
  }

  return { query, page, results, totalPages, isLoading, error, nextPage, prevPage }
}

Việc tổ hợp diễn ra trên nhiều tầng. useDebouncedRef trả về một ref có tích hợp debounce, ngăn chặn các request thừa trong quá trình nhập liệu. useFetchData nhận apiUrl dưới dạng computed, nghĩa là bất kỳ thay đổi nào trên query hoặc page đều tính lại URL và tự động kích hoạt một request mới. Watcher trên query đặt lại phân trang về trang 1 mỗi khi từ khóa tìm kiếm thay đổi.

Pattern này minh họa một nguyên tắc cơ bản: mỗi composable giải quyết một vấn đề cụ thể, và việc tổ hợp kết nối chúng mà không tạo liên kết chặt. Trong phỏng vấn kỹ thuật, việc giải thích chuỗi phản ứng này (query thay đổi -> apiUrl được tính lại -> useFetchData chạy lại -> results được cập nhật) thể hiện sự hiểu biết sâu sắc về hệ thống reactive của Vue.

Sẵn sàng chinh phục phỏng vấn Vue.js / Nuxt.js?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Dependency Injection Với provide/inject

Một số trạng thái cần được chia sẻ xuyên suốt cây component mà không cần truyền props qua từng cấp (prop drilling). Hệ thống provide/inject của Vue, kết hợp với composables, tạo ra một pattern context có kiểu an toàn.

useTheme.tstypescript
import { provide, inject, ref, readonly, type InjectionKey, type Ref } from 'vue'

type Theme = 'light' | 'dark' | 'system'

interface ThemeContext {
  theme: Readonly<Ref<Theme>>
  setTheme: (t: Theme) => void
  resolvedTheme: Readonly<Ref<'light' | 'dark'>>
}

const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')

export function provideTheme(initial: Theme = 'system') {
  const theme = ref<Theme>(initial)

  const resolvedTheme = computed<'light' | 'dark'>(() => {
    if (theme.value !== 'system') return theme.value
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
  })

  function setTheme(t: Theme) {
    theme.value = t
  }

  const context: ThemeContext = {
    theme: readonly(theme),
    setTheme,
    resolvedTheme: readonly(resolvedTheme)
  }

  provide(ThemeKey, context)
  return context
}

export function useTheme(): ThemeContext {
  const context = inject(ThemeKey)
  if (!context) {
    throw new Error('useTheme() requires a parent component to call provideTheme()')
  }
  return context
}

Pattern này tách biệt việc cung cấp (provide) và tiêu thụ (consume). Component gốc (hoặc layout) gọi provideTheme() một lần duy nhất, và bất kỳ component con nào đều có thể truy cập context thông qua useTheme() bất kể độ sâu trong cây component. Việc sử dụng InjectionKey<ThemeContext> với Symbol đảm bảo an toàn kiểu tại thời điểm biên dịch.

Hàm readonly() bọc các ref được expose để ngăn chặn việc thay đổi trực tiếp từ các component tiêu thụ. Chỉ setTheme() mới có thể thay đổi theme, bắt buộc luồng dữ liệu một chiều (unidirectional data flow). Thuộc tính resolvedTheme phân giải giá trị 'system' bằng cách truy vấn media query của trình duyệt, luôn cung cấp một giá trị cụ thể ('light' hoặc 'dark') mà component có thể dùng trực tiếp để áp dụng style.

Trong phỏng vấn, pattern này thường được so sánh với React Context hoặc Pinia stores. Điểm khác biệt then chốt là provide/inject hoạt động ở cấp độ cây component (không phải global) và không cần bất kỳ dependency bên ngoài nào.

Composable Xác Thực Biểu Mẫu (Form Validation)

Xác thực biểu mẫu là lĩnh vực mà composables thể hiện đặc biệt hiệu quả, thay thế logic lặp đi lặp lại bằng một pattern khai báo dựa trên quy tắc.

useFormValidation.tstypescript
import { reactive, computed, type UnwrapNestedRefs } from 'vue'

type ValidationRule<T> = (value: T) => string | true
type FieldRules<T> = { [K in keyof T]?: ValidationRule<T[K]>[] }

interface UseFormReturn<T extends Record<string, any>> {
  fields: UnwrapNestedRefs<T>
  errors: Record<keyof T, string>
  isValid: Ref<boolean>
  validate: () => boolean
  resetErrors: () => void
}

export function useFormValidation<T extends Record<string, any>>(
  initialValues: T,
  rules: FieldRules<T>
): UseFormReturn<T> {
  const fields = reactive({ ...initialValues }) as UnwrapNestedRefs<T>

  const errors = reactive(
    Object.keys(initialValues).reduce(
      (acc, key) => ({ ...acc, [key]: '' }),
      {} as Record<keyof T, string>
    )
  )

  function validate(): boolean {
    let valid = true
    for (const key of Object.keys(rules) as (keyof T)[]) {
      const fieldRules = rules[key] || []
      errors[key] = '' as any
      for (const rule of fieldRules) {
        const result = rule(fields[key])
        if (result !== true) {
          errors[key] = result as any
          valid = false
          break // Stop at first error per field
        }
      }
    }
    return valid
  }

  function resetErrors() {
    for (const key of Object.keys(errors)) {
      (errors as any)[key] = ''
    }
  }

  const isValid = computed(() =>
    Object.values(errors).every((e) => e === '')
  )

  return { fields, errors, isValid, validate, resetErrors }
}

Composable này nhận các giá trị khởi tạo và một bản đồ quy tắc xác thực cho từng trường. Mỗi quy tắc là một hàm trả về true nếu giá trị hợp lệ hoặc một chuỗi chứa thông báo lỗi. Phương thức validate() duyệt qua các quy tắc và dừng lại ở lỗi đầu tiên của mỗi trường, tránh hiển thị nhiều thông báo cùng lúc khiến người dùng bị rối.

Việc sử dụng reactive() thay vì ref() cho fields và errors giúp đơn giản hóa truy cập trong template: fields.email thay vì fields.value.email. Thuộc tính computed isValid tự động cập nhật khi bất kỳ lỗi nào thay đổi, cho phép bật/tắt nút gửi biểu mẫu một cách phản ứng.

Pattern này có tính mở rộng cao. Có thể bổ sung quy tắc bất đồng bộ (kiểm tra email đã tồn tại), xác thực chéo giữa các trường (xác nhận mật khẩu) và thông báo lỗi đa ngôn ngữ mà không cần thay đổi cấu trúc cơ bản của composable.

Kiểm Thử Composables

Các composable sử dụng reactive API của Vue cần một component context để hoạt động đúng cách. Hàm trợ giúp withSetup giải quyết yêu cầu này bằng cách tạo một component tối giản thực thi composable bên trong setup().

useCounter.spec.tstypescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { useCounter } from './useCounter'

function withSetup<T>(composable: () => T): { result: T; unmount: () => void } {
  let result!: T
  const wrapper = mount(
    defineComponent({
      setup() {
        result = composable()
        return () => h('div')
      }
    })
  )
  return { result, unmount: () => wrapper.unmount() }
}

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = withSetup(() => useCounter())
    expect(result.count.value).toBe(0)
  })

  it('respects min and max boundaries', () => {
    const { result } = withSetup(() =>
      useCounter({ min: 0, max: 3, initialValue: 3 })
    )
    result.increment()
    expect(result.count.value).toBe(3) // Capped at max

    result.count.value = 0
    result.decrement()
    expect(result.count.value).toBe(0) // Capped at min
  })

  it('computes doubled value reactively', () => {
    const { result } = withSetup(() => useCounter({ initialValue: 5 }))
    expect(result.doubled.value).toBe(10)
    result.increment()
    expect(result.doubled.value).toBe(12)
  })
})

Hàm withSetup là một pattern chuẩn trong hệ sinh thái Vue để kiểm thử composables một cách độc lập. Nó mount một component wrapper gọi composable bên trong setup(), cung cấp quyền truy cập vào kết quả và một hàm unmount() để mô phỏng việc hủy component.

Mỗi test kiểm tra một khía cạnh cụ thể: khởi tạo với giá trị mặc định, tuân thủ giới hạn đã cấu hình và tính phản ứng của giá trị computed. Test thứ ba đặc biệt quan trọng vì nó xác nhận rằng doubled tự động cập nhật khi count thay đổi thông qua increment(), chứng minh đồ thị phản ứng (reactive graph) hoạt động đúng.

Đối với các composable bất đồng bộ như useFetchData, việc kiểm thử yêu cầu mock fetch và xử lý promise với flushPromises() từ Vue Test Utils. Trong phỏng vấn, việc thể hiện sự quen thuộc với pattern testing này truyền đạt sự trưởng thành chuyên nghiệp và cam kết đối với chất lượng code.

VueUse: thư viện composables tham khảo

VueUse (vueuse.org) là thư viện composables tiện ích được sử dụng rộng rãi nhất trong hệ sinh thái Vue, với hơn 200 hàm bao gồm cảm biến trình duyệt, animation, trạng thái, mạng và lưu trữ. Trước khi xây dựng một composable tự định nghĩa, việc kiểm tra xem VueUse đã cung cấp giải pháp đã được thử nghiệm và tối ưu hóa chưa là một thực hành tốt mà các nhà tuyển dụng đánh giá cao.

Câu Hỏi Phỏng Vấn Thường Gặp

Các câu hỏi sau đây xuất hiện thường xuyên trong quá trình tuyển dụng cho vị trí Vue cấp trung và cấp cao trong năm 2026:

1. Sự khác biệt giữa composable và mixin là gì?

Mixins nhập các thuộc tính vào component một cách ngầm định, gây ra xung đột tên và khiến việc truy vết nguồn gốc của từng thuộc tính trở nên khó khăn. Composables trả về giá trị một cách tường minh, cung cấp kiểu đầy đủ với TypeScript và cho phép đổi tên biến khi destructure giá trị trả về. Mỗi lần gọi đều tạo ra một instance trạng thái độc lập, trong khi mixins chia sẻ trạng thái giữa tất cả component sử dụng chung.

2. Tại sao composables phải được gọi bên trong setup()?

Vue liên kết các lifecycle hook (onMounted, onUnmounted, watch, watchEffect) với component instance đang hoạt động trong setup(). Việc gọi composable ngoài ngữ cảnh này sẽ phá vỡ liên kết đó, khiến các effect không được đăng ký và không được dọn dẹp. Đây là một trong những câu hỏi thường gặp nhất trong phỏng vấn kỹ thuật Vue.

3. Làm thế nào để chia sẻ trạng thái giữa nhiều component bằng composables?

Có hai chiến lược chính. Đối với trạng thái toàn cục (global state), khai báo ref bên ngoài hàm composable (singleton pattern). Đối với trạng thái theo ngữ cảnh trong một nhánh cây component, sử dụng provide/inject với InjectionKey có kiểu, như đã minh họa trong ví dụ useTheme. Đối với quản lý trạng thái toàn cục phức tạp hơn, nên sử dụng Pinia.

4. MaybeRefOrGetter là gì và tại sao nó quan trọng?

Đây là một utility type của Vue chấp nhận giá trị thuần (plain value), Ref hoặc hàm getter. Nó cho phép composables nhận đầu vào cả tĩnh lẫn phản ứng, tối đa hóa sự linh hoạt cho người sử dụng. Hàm toValue() trích xuất giá trị bên trong bất kể kiểu đầu vào là gì.

5. Làm sao kiểm thử composables sử dụng onMounted hoặc onUnmounted?

Sử dụng hàm trợ giúp như withSetup để mount một component wrapper. Component này thực thi composable bên trong setup(), cung cấp ngữ cảnh instance cần thiết. Để kiểm tra việc dọn dẹp, gọi unmount() và xác nhận rằng các side effect (listener, timer, abort controller) đã được loại bỏ.

6. Khi nào nên dùng Pinia thay vì composables với provide/inject?

Pinia phù hợp hơn khi trạng thái cần truy cập toàn cục từ bất kỳ component nào mà không cần quan hệ phân cấp, khi cần lưu trữ lâu dài (localStorage/sessionStorage), khi cần tích hợp với Vue DevTools để debug, hoặc khi nhiều cây component cần chia sẻ cùng trạng thái. Composables với provide/inject thích hợp hơn cho trạng thái theo ngữ cảnh giới hạn trong một nhánh cây cụ thể.

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Kết Luận

Composables trong Vue 3 không chỉ là một phương án thay thế mixins: đây là một mô hình tổ chức code có khả năng mở rộng từ những hàm tiện ích đơn giản đến những hệ thống phức tạp với dependency injection và tổ hợp nhiều tầng. Việc thành thạo những pattern này phân biệt giữa những lập trình viên chỉ đơn thuần sử dụng Vue và những người hiểu sâu hệ thống reactive của nó.

Các điểm chính cần nắm vững:

  • Cấu trúc rõ ràng: Interface có kiểu cho đầu vào và đầu ra với UseXxxOptionsUseXxxReturn tài liệu hóa API và ngăn chặn việc lộ chi tiết nội bộ
  • Cô lập trạng thái: Mỗi lần gọi tạo ra một instance độc lập, loại bỏ hoàn toàn xung đột
  • Tổ hợp tường minh: Composables tiêu thụ các composable khác tạo ra chuỗi phản ứng có thể dự đoán
  • Quản lý tài nguyên: AbortControlleronUnmounted đảm bảo dọn dẹp side effects, ngăn chặn memory leaks
  • Injection có kiểu: provide/inject với InjectionKey thay thế prop drilling mà không hy sinh an toàn kiểu
  • Khả năng kiểm thử: Pattern withSetup cho phép xác thực tính phản ứng trong môi trường cô lập hoàn toàn
  • Tính thực dụng: Kiểm tra VueUse trước khi tự xây dựng lại chức năng phổ biến giúp tiết kiệm thời gian và tận dụng các giải pháp đã được chứng minh

Việc chuẩn bị những pattern này với các triển khai cụ thể và khả năng giải thích quyết định thiết kế đằng sau mỗi pattern mang lại lợi thế đáng kể trong các kỳ tuyển dụng kỹ thuật cho vị trí Vue trong năm 2026.

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Thẻ

#vue
#composables
#composition-api
#interview
#deep-dive

Chia sẻ

Bài viết liên quan