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

Phân tích chuyên sâu các composable nâng cao trong Vue 3 với pattern tái sử dụng, xử lý bất đồng bộ, dependency injection qua provide/inject, form validation và testing. Bao gồm câu hỏi phỏng vấn kỹ thuật cập nhật 2026.

Vue 3 Composables Advanced Patterns

Composition API đã thay đổi căn bản cách lập trình viên Vue tổ chức và tái sử dụng logic trong ứng dụng frontend. Tại trung tâm của sự chuyển đổi này chính là composable — những hàm đóng gói trạng thái reactive, logic nghiệp vụ và side effects thành các đơn vị độc lập, có thể tái sử dụng ở bất kỳ đâu trong dự án. Khác với mixin của Vue 2 từng gây ra xung đột tên biến và phụ thuộc ngầm, composable mang đến sự minh bạch hoàn toàn, hỗ trợ static typing với TypeScript và cho phép kết hợp logic một cách tường minh.

Năm 2026, hệ sinh thái Vue tiếp tục phát triển mạnh mẽ với những cải tiến đáng chú ý. Alien Signals — hệ thống reactivity mới được tích hợp vào Vue core — giúp giảm đáng kể memory footprint và tăng tốc độ tracking dependency. Song song đó, Vapor Mode đang dần hoàn thiện, cho phép compile component thành DOM operations trực tiếp mà không cần virtual DOM, mở ra tiềm năng tối ưu hiệu năng chưa từng có. Trong bối cảnh đó, việc nắm vững các pattern composable nâng cao trở thành yêu cầu bắt buộc đối với các vị trí phát triển Vue từ trung cấp trở lên.

Bài viết này phân tích chi tiết các pattern composable được hỏi nhiều nhất trong phỏng vấn kỹ thuật, từ cấu trúc cơ bản của một composable chuẩn chỉnh cho đến dependency injection với provide/inject, kèm theo chiến lược testing kiểm chứng tính reactive 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. Theo quy ước, tên composable bắt đầu bằng use (ví dụ: useCounter, useFetch). Mỗi composable trả về các ref reactive, computed và function mà component có thể sử dụng trực tiếp, không cần kế thừa hay ràng buộc ngầm định nào.

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

Một composable hiệu quả tuân theo các nguyên tắc rõ ràng: nhận cấu hình qua object có kiểu dữ liệu xác định, trả về interface tường minh và giữ trạng thái nội bộ cách ly hoàn toàn giữa các consumer. Ví dụ dưới đây triển khai một counter với giới hạn có thể cấu hình và giá trị computed phái sinh.

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ó nhiều điểm đáng lưu ý trong pattern này. Interface UseCounterReturn định nghĩa tường minh contract của composable, hỗ trợ autocompletion trong editor và ngăn ngừa thay đổi vô tình trên API công khai. Object 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() tạo ra một instance trạng thái hoàn toàn độc lập, loại bỏ triệt để vấn đề shared state từng gây khó khăn với mixin.

Pattern "options đầu vào, interface có kiểu đầu ra" này chính là nền tảng để xây dựng các composable phức tạp hơn. Khi Alien Signals được tích hợp hoàn toàn, cơ chế tracking bên trong refcomputed sẽ hoạt động hiệu quả hơn, nhưng cách viết composable vẫn giữ nguyên — đây là một trong những ưu điểm lớn của thiết kế API ổn định trong Vue.

Composable Bất Đồng Bộ Với Xử Lý Lỗi

Các thao tác mạng là một trong những use case phổ biến nhất cho composable. Pattern dưới đây triển khai hàm fetch reactive, tự động huỷ request đang thực hiện, xử lý lỗi chi tiết và phản ứng khi URL nguồn thay đổi.

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 MaybeRefOrGetter<string> chấp nhận cả giá trị tĩnh lẫn ref reactive hoặc getter function, mang lại sự linh hoạt tối đa cho phía consumer. 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 condition bằng cách huỷ request cũ khi URL thay đổi trước khi response trước đó kịp trả về.

watchEffect thiết lập tracking reactive tự động: mọi ref hay getter được truy cập bên trong callback đều 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 giải phóng tài nguyên khi component bị huỷ — một thực hành quan trọng để tránh memory leak trong single-page application.

Lifecycle hook trong composable

Các hook như onMounted, onUnmountedwatchEffect chỉ hoạt động chính xác khi composable được gọi bên trong setup() hoặc <script setup>. Gọi composable ngoài ngữ cảnh này (ví dụ trong một callback bất đồng bộ trì hoãn) sẽ dẫn đến cảnh báo runtime và effect không bao giờ được đăng ký. Đây là một trong những câu hỏi phỏng vấn kỹ thuật về Vue xuất hiện thường xuyên nhất.

Kết Hợp Composable Với Nhau

Sức mạnh thực sự của composable bộc lộ khi chúng được kết hợp với nhau. Ví dụ dưới đây xây dựng tính năng tìm kiếm phân trang bằng cách tái sử dụng useFetchData và một useDebouncedRef giả định, minh hoạ cách kết hợp tạo ra chức năng phức tạp từ những mảnh ghép đơn giản.

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 kết hợp diễn ra trên nhiều tầng. useDebouncedRef trả về một ref có tích hợp debounce, tránh gửi request liên tục 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 toán lại URL và tự động kích hoạt request mới. Watcher trên query reset phân trang về trang 1 mỗi khi từ khoá tìm kiếm thay đổi.

Pattern này thể hiện một nguyên tắc cốt lõi: mỗi composable giải quyết một vấn đề cụ thể và việc kết hợp liên kết chúng mà không tạo ràng buộc chặt. Trong phỏng vấn kỹ thuật, khả năng giải thích chuỗi reactive này (query thay đổi -> apiUrl được tính lại -> useFetchData chạy lại -> results cập nhật) chứng minh 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 phải truyền props qua nhiều tầng (prop drilling). Hệ thống provide/inject của Vue, khi kết hợp với composable, tạo ra pattern context có kiểu dữ liệu an toàn và rõ ràng.

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 rõ ràng giữa phần cung cấp (provide) và phần tiêu thụ (inject). Component gốc (hoặc layout) gọi provideTheme() một lần duy nhất, và bất kỳ component con nào ở bất kỳ độ sâu nào trong cây đều có thể truy cập context thông qua useTheme(). Việc sử dụng InjectionKey<ThemeContext> với Symbol đảm bảo an toàn kiểu dữ liệu tại thời điểm biên dịch.

Hàm readonly() bọc các ref được expose ra ngoài để ngăn chặn mutation trực tiếp từ phía consumer. Chỉ setTheme() mới có thể thay đổi theme, buộc luồng dữ liệu đi theo một chiều. 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 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 store. Đ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 yêu cầu thư viện bên ngoài. Đây là lựa chọn lý tưởng khi trạng thái chỉ cần chia sẻ trong một nhánh cụ thể của ứng dụng.

Composable Validation Cho Form

Validation form là lĩnh vực mà composable phát huy hiệu quả đặc biệt, thay thế logic lặp đi lặp lại bằng pattern khai báo dựa trên rule.

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 giá trị khởi tạo và một bản đồ rule validation theo từng field. Mỗi rule 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 rule và dừng lại ở lỗi đầu tiên của mỗi field, tránh hiển thị nhiều thông báo cùng lúc gây rối cho người dùng.

Việc sử dụng reactive() thay vì ref() cho fields và errors giúp đơn giản hoá 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 enable hoặc disable nút submit một cách reactive.

Pattern này dễ dàng mở rộng. Có thể bổ sung rule bất đồng bộ (kiểm tra email đã tồn tại), validation chéo giữa các field (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 nền tảng của composable.

Testing Composable Trong Môi Trường Cách Ly

Các composable sử dụng API reactive của Vue cần ngữ cảnh component để hoạt động đúng. Hàm helper withSetup giải quyết nhu 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à pattern chuẩn trong hệ sinh thái Vue để test composable một cách cách ly. Hàm này mount một component wrapper gọi composable bên trong setup(), cung cấp quyền truy cập kết quả và hàm unmount() để mô phỏng huỷ component.

Mỗi test kiểm chứng 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 reactive của giá trị computed. Test thứ ba đặc biệt quan trọng vì nó xác minh rằng doubled tự động cập nhật khi count thay đổi thông qua increment(), xác nhận đồ thị reactive hoạt động đúng.

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

VueUse — thư viện composable tham khảo

VueUse (vueuse.org) là thư viện composable utility được sử dụng rộng rãi nhất trong hệ sinh thái Vue, với hơn 200 hàm bao phủ cảm biến trình duyệt, animation, state, network và storage. Trước khi tự xây dựng composable, kiểm tra xem VueUse đã cung cấp giải pháp sẵn có và được tối ưu hay 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 Kỹ Thuật Thường Gặp

Những câu hỏi sau xuất hiện thường xuyên trong quy trình tuyển dụng cho các vị trí Vue từ trung cấp đến senior vào năm 2026.

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

Mixin hợp nhất 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 nguyên nguồn gốc của từng thuộc tính trở nên khó khăn. Composable trả về giá trị một cách tường minh, hỗ trợ typing đầy đủ với TypeScript và cho phép đổi tên biến khi destructure kết quả trả về. Ngoài ra, mỗi lần gọi composable tạo instance trạng thái riêng biệt, trong khi mixin chia sẻ chung namespace với component.

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

Vue liên kết các lifecycle hook (onMounted, onUnmounted, watch, watchEffect) với instance component đang hoạt động trong quá trình setup(). Gọi composable ngoài ngữ cảnh này sẽ phá vỡ liên kết đó, dẫn đến effect không được đăng ký và không bao giờ được dọn dẹp. Đây là ràng buộc kiến trúc cốt lõi của Composition API, không phải hạn chế tuỳ ý.

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

Có hai chiến lược chính. Đối với trạng thái toàn cục, khai báo ref bên ngoài hàm composable (pattern singleton) — mọi component gọi composable sẽ dùng chung instance đó. Đối với trạng thái phạm vi một nhánh cây component, sử dụng provide/inject với InjectionKey có kiểu dữ liệu, như đã minh hoạ trong ví dụ useTheme. Lựa chọn giữa hai cách phụ thuộc vào phạm vi và vòng đời mong muốn của trạng thái.

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

MaybeRefOrGetter là kiểu utility của Vue chấp nhận một giá trị thuần, một Ref hoặc một getter function. Kiểu này cho phép composable nhận đầu vào cả tĩnh lẫn reactive, tối đa hoá tính linh hoạt cho phía consumer. Hàm toValue() trích xuất giá trị bên trong bất kể kiểu đầu vào, đảm bảo composable xử lý thống nhất mọi trường hợp.

5. Làm thế nào để test composable sử dụng onMounted hoặc onUnmounted?

Sử dụng hàm helper 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 chứng việc dọn dẹp tài nguyên, gọi unmount() rồi xác minh rằng các side effect (listener, timer, abort controller) đã được loại bỏ. Đối với composable bất đồng bộ, kết hợp flushPromises() để chờ các promise resolve trước khi assertion.

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

Pinia phù hợp khi trạng thái cần truy cập toàn cục từ bất kỳ component nào không có quan hệ phân cấp, khi cần tích hợp persistence (localStorage/sessionStorage), khi cần debug với Vue DevTools, hoặc khi nhiều cây component cần dùng chung trạng thái. Composable với provide/inject thích hợp hơn cho trạng thái ngữ cảnh giới hạn trong một nhánh cụ thể. Trong thực tế, nhiều dự án sử dụng cả hai: Pinia cho trạng thái ứng dụng toàn cục và provide/inject cho trạng thái cục bộ theo tính năng.

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

Composable trong Vue 3 không đơn thuần là phương án thay thế mixin — đây là mô hình tổ chức code có khả năng mở rộng từ hàm utility đơn giản đến hệ thống phức tạp với dependency injection và kết hợp đa tầng. Việc làm chủ các pattern này tạo ra sự khác biệt rõ rệt giữa người chỉ sử dụng Vue và người thực sự hiểu sâu hệ thống reactive của framework.

Những điểm cần nắm vững:

  • Cấu trúc rõ ràng: Interface có kiểu dữ liệu cho đầu vào và đầu ra với UseXxxOptionsUseXxxReturn
  • Cách ly trạng thái: Mỗi lần gọi tạo instance độc lập, loại bỏ xung đột
  • Kết hợp tường minh: Composable sử dụng composable khác tạo chuỗi reactive có thể dự đoán
  • Quản lý tài nguyên: AbortControlleronUnmounted đảm bảo dọn dẹp side effect
  • Injection có kiểu: provide/inject với InjectionKey thay thế prop drilling mà không mất an toàn kiểu dữ liệu
  • Khả năng test: Pattern withSetup cho phép kiểm chứng tính reactive trong môi trường cách ly hoàn toàn
  • Thực dụng: Kiểm tra VueUse trước khi tự triển khai chức năng phổ biến

Việc chuẩn bị các pattern này với triển khai cụ thể và khả năng giải thích các quyết định thiết kế đằng sau mỗi pattern mang lại lợi thế đáng kể trong các quy trình 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
#typescript
#interview

Chia sẻ

Bài viết liên quan