Hub

Vue 3

Bài này nói về Vue 3.5 ở góc nhìn senior cho phỏng vấn: compiler chạy thế nào, góc cạnh reactivity, và kiến trúc. Dành cho engineer đã thuộc API, giờ cần chọn theo ràng buộc: hoist hay fetch, provide hay store, cái gì giữ client-only.

Đọc 5.1 trước (compiler VDOM, Composition API, reactivity, khi nào fetch). Người phỏng vấn xoáy patch flag, mất tracking, và fetch SSR nhiều hơn cú pháp. Rồi đọc 5.2 cho teleport, suspense, và hydration. Ưu tiên Vue 3.5 script setup; React là bản đối chiếu. Với mỗi topic, nêu rõ ràng buộc, lỗi bạn đã gặp, và cách chứng minh fix.


Tóm tắt: Virtual DOM (VDOM: cây JS mô tả UI nên trông thế nào) để Vue diff rồi patch DOM thật. Compiler Vue 3 hoist node tĩnh, gắn patch flag, và dựng block tree để runtime bỏ qua phần tĩnh.

Vì sao quan trọng: Bảng Vue 3 100k hàng giật lúc filter. Nói “diff cây rồi patch DOM” mới là mức junior. Người phỏng vấn muốn patch flag, static hoisting, block tree, và khi nào VDOM không phải bottleneck.

  1. Cách hoạt động: Compiler biến template thành một hàm render. VNode tĩnh được hoist. Node động thì nhận patch flag (TEXT, CLASS, STYLE, PROPS, NEED_HYDRATION).
  2. openBlock lấy snapshot cấu trúc con ổn định, để runtime chỉ patch binding đã gắn flag. NEED_HYDRATION là tên Vue 3.4 của flag cũ HYDRATE_EVENTS.
  3. v-if / v-for ở gốc block, v-bind="obj", và v-html buộc flag kém chi tiết hơn. Render function và JSX mất hint của compiler.
  4. v-for không key, sau khi swap identity, sẽ patch tại chỗ: input dính sang hàng sai. Virtualize hàng nghìn hàng; đừng “tối ưu VDOM” khi layout hoặc network chiếm phần lớn thời gian.
const _hoisted = { class: 'title' }

export function render(_ctx: { msg: string }) {
  return (_openBlock(), _createElementBlock('div', null, [
    _createElementVNode('p', _hoisted, 'Hello'),
    _createElementVNode(
      'p',
      null,
      _toDisplayString(_ctx.msg),
      1 /* TEXT */,
    ),
  ]))
}

// v-for không :key sẽ patch tại chỗ khi identity bị swap
// NEED_HYDRATION (Vue 3.4) từng là HYDRATE_EVENTS

Điều gì xảy ra: Title tĩnh được hoist và không bị patch. msg gắn flag TEXT, nên chỉ binding đó được so. NEED_HYDRATION đánh dấu node phải gắn listener lúc hydrate. List không key là bug khác: chuyện identity, không phải chi phí VDOM.

Cơ chếLàm gìKhi không
Patch flag / hoistBỏ qua phần tĩnhBottleneck là layout / network
v-memo / v-onceĐóng băng subtree ổn địnhĐiều kiện sai → UI stale
VirtualizeRender hàng đang thấyVài trăm hàng

Góc nhìn senior

  • VDOM Vue 3 là thứ compiler đã gắn hint. Patch flag và block tree bỏ qua phần tĩnh.
  • Thời gian VDOM hiếm khi là bottleneck của sản phẩm. Tách JS (render + reactivity) vs Recalculate style / Layout vs network.
  • v-for không key sau khi swap identity làm input và transition dính sang hàng sai.
  • Object literal mới làm prop mỗi lần render (:style="{}") phá flag. v-bind="obj" buộc FULL_PROPS.
  • Virtualize hàng nghìn hàng. v-memo dành cho subtree bạn chứng minh được là ổn định. Ở sản phẩm admin, giật lúc filter là layout, không phải VDOM.

Lỗi hay gặp

  • Triệu chứng: giật lúc filter 100k hàng. Nguyên nhân: layout hoặc hàng reactive sâu. Cách xử lý: virtualize + shallowRef, không phải “VDOM nhanh hơn”.
  • Triệu chứng: state của input dính sau sort. Nguyên nhân: v-for không key. Cách xử lý: :key ổn định.
  • Triệu chứng: v-memo hiện cell stale. Nguyên nhân: điều kiện memo sót một field. Cách xử lý: liệt kê mọi input reactive.
  • Triệu chứng: bảng JSX chậm hơn template. Nguyên nhân: mất hint compiler. Cách xử lý: template cho UI app.

Người phỏng vấn hỏi gì

Bảng Vue 3 100k hàng giật lúc filter. Bạn nói “diff cây rồi patch DOM”. Họ đẩy tiếp: compiler đổi gì trong câu chuyện đó? Họ muốn patch flag, static hoisting, và block tree, và khi nào VDOM không phải bottleneck.

Trả lời khi phỏng vấn

Mình coi VDOM Vue 3 là thứ compiler đã gắn hint, không phải walk hết cây. Compiler hoist VNode tĩnh, gắn patch flag, và block tree để runtime chỉ patch binding có flag. NEED_HYDRATION là tên Vue 3.4 của flag cũ HYDRATE_EVENTS. Mình không tối ưu VDOM khi layout, network, hoặc hàng nghìn effect reactive đang chiếm thời gian. v-for không key khi identity bị swap sẽ patch tại chỗ và state dính sang hàng sai. Hàng nghìn hàng thì mình virtualize; v-memo dành cho subtree mình chứng minh được là ổn định. Ở sản phẩm admin, giật lúc filter là Recalculate style, không phải patch flag, và team mình chứng minh trên panel Performance.

Nhớ nhanh

  • VDOM có compiler gắn hint: hoist tĩnh + patch flag + block tree.
  • NEED_HYDRATION là tên Vue 3.4 của HYDRATE_EVENTS.
  • v-for không key sau khi swap identity làm input dính sang hàng sai.
  • Virtualize hàng nghìn hàng; đừng tối ưu VDOM khi layout chiếm thời gian.

Câu hỏi nối tiếp thường gặp

  • Patch flag là gì? Một bit trên VNode động để runtime chỉ so binding đó.
  • Khi nào VDOM không phải bottleneck? Layout, network, hoặc mảng reactive sâu 50k hàng.
  • Vì sao v-for không key phá input? Runtime tái sử dụng cùng node DOM sau khi swap identity.
  • JSX khác template thế nào? JSX mất hoist và flag. Giữ UI app trong template.

Tóm tắt: Options API nhóm theo option (data, methods). Composition API nhóm theo feature trong setup. Composition là mặc định cho code mới và logic cắt ngang; Options vẫn ổn trên leaf nhỏ bạn sẽ không extract.

Vì sao quan trọng: App Vue 2 200 SFC có mixin, filter, và đồng nghiệp chỉ Options. Người phỏng vấn không muốn câu “cái nào mới hơn”. Họ muốn extract logic, TypeScript, và rủi ro team, cùng việc bạn có cấm Options API hay không.

  1. Cách hoạt động: Mixin gãy ở namespace (hai key data() đụng nhau), origin (DevTools không cho biết mixin nào inject loading), và thứ tự merge lifecycle. Composable trả binding tường minh và tree-shake.
  2. Composition + <script setup> cho code mới và mọi thứ cắt ngang options. Options giữ cho wrapper icon và chrome tĩnh.
  3. Migrate một monolith Vue 2 theo từng feature: extract composable đặt cạnh mixin, xóa mixin khi hết chỗ gọi. Trộn setup() và Options là đường đi từng bước.
  4. Composable gọi getCurrentInstance() chính là mixin phiên bản mới. Filter là hàm. Đừng dùng this trong code mới.
// Options: logic tách giữa data / methods / watch
export default {
  mixins: [loadingMixin],
  data: () => ({ q: '' }),
  watch: { q: 'search' },
  methods: { search() {} },
}

// Composition: một feature, một hàm
export function useSearch() {
  const q = ref('')
  const { loading, run } = useLoading()
  watch(q, run)
  return { q, loading }
}

Điều gì xảy ra: loading của mixin đụng loading của mixin khác và DevTools không biết file nào inject. useSearch trả binding có tên, tree-shake, và type không cần this. Cùng UI, khác cách extract.

CầnChọnTránh
Mới / cắt ngangComposition + script setupMixin mới
SFC leaf nhỏOptions vẫn ổnComposable một dòng
Monolith Vue 2Extract theo featureViết lại cả quý

Góc nhìn senior

  • Composition là mặc định cho code mới. Options chỉ trên leaf sẽ mãi là leaf.
  • Mixin đụng nhau ở namespace, origin, và thứ tự merge lifecycle.
  • Trộn setup() và Options là cách migrate từng bước, không phải smell.
  • Composable gọi getCurrentInstance() là mixin mới. Truyền thứ bạn cần.
  • Cách đo là thời gian thêm quyền hoặc feature flag mà không đụng option không liên quan. Ở sản phẩm admin, team mình extract search cạnh mixin, rồi xóa mixin.

Lỗi hay gặp

  • Triệu chứng: hai mixin, một loading. Nguyên nhân: đụng namespace. Cách xử lý: composable trả { loading }.
  • Triệu chứng: rewrite cả quý bị kẹt. Nguyên nhân: migrate big-bang. Cách xử lý: extract theo feature.
  • Triệu chứng: getCurrentInstance() trong helper. Nguyên nhân: mixin mới. Cách xử lý: truyền ref.
  • Triệu chứng: cấm Options trong app Vue 2 200 SFC. Nguyên nhân: rủi ro team. Cách xử lý: mặc định Composition, giữ leaf.

Người phỏng vấn hỏi gì

Không phải “cái nào mới hơn”. App Vue 2 200 SFC có mixin, filter, và đồng nghiệp chỉ Options. Bạn migrate thế nào, làm gì với mixin, và có cấm Options API không? Họ muốn extract logic, TypeScript, và rủi ro team.

Trả lời khi phỏng vấn

Composition API là mặc định cho code mới và mọi thứ cắt ngang options. Mình giữ Options trên leaf nhỏ, những cái mình sẽ không extract. Mixin đụng key data và giấu origin; composable trả binding tường minh và tree-shake. Mình migrate monolith Vue 2 theo feature: extract composable cạnh mixin, không viết lại cả quý. Trộn setup với Options là cách migrate từng bước. Composable gọi getCurrentInstance là mixin kiểu mới. Ở sản phẩm admin, bọn mình extract search cạnh mixin, rồi xóa mixin khi hết chỗ gọi.

Nhớ nhanh

  • Mặc định Composition; Options chỉ trên leaf sẽ mãi là leaf.
  • Mixin đụng nhau ở namespace, origin, và thứ tự merge lifecycle.
  • Migrate theo feature: composable cạnh mixin, rồi xóa nó.
  • getCurrentInstance() trong helper là mixin kiểu mới.

Câu hỏi nối tiếp thường gặp

  • Bạn có cấm Options API? Không. Mặc định Composition; giữ leaf nhỏ.
  • Filter migrate thế nào? Chúng là hàm. Import chúng.
  • TypeScript? Composition suy type không cần this. Options cần defineComponent.
  • Trộn trong một SFC được không? Được. Đó là đường migrate từng bước.

Tóm tắt: Auto-import (unplugin-vue-components / Nuxt) giấu import UI local. Dùng cho component trong app. Import tường minh package public, public API, và mọi thứ resolver tham lam không được kéo.

Vì sao quan trọng: “Bạn có auto-import mọi thứ?” Barrel của design system re-export mọi component từ một index, và route không import chart vẫn ship chunk chart. Người phỏng vấn muốn DX đối với public API tường minh, circular dep, và tree-shaking.

  1. Cách hoạt động: Plugin quét components/ và sinh import kèm components.d.ts. Nuxt làm tương tự cho API Vue và composable. Resolver tham lam: khớp tên là kéo file.
  2. Cycle (useCart → useUser → useCart) thành undefined hoặc lỗi TDZ lúc load lần đầu. Auto-import giấu chúng; không phá được chúng.
  3. Barrel index re-export hết thì tree-shaking mất tác dụng. app.component() global luôn nằm trong bundle và không colocation.
  4. Với library bạn publish, đừng dựa auto-import của consumer. Tên đụng (Modal từ hai folder) cần prefix thư mục hoặc import tường minh.
// nuxt.config.ts / vite.config.ts
Components({
  dirs: ['app/components'],
  directoryAsNamespace: true, // Modal vs ChartModal
})

// ❌ barrel design system, kéo chart vào mọi route
export * from './Chart'
export * from './Table'
export * from './Modal'

// ✅ public API tường minh
import { UserTable } from '@acme/ui/UserTable'

Điều gì xảy ra: UserTable trong app có thể auto-import. Barrel re-export khiến route settings không nhắc Chart vẫn ship chunk Chart. directoryAsNamespace tránh hai Modal đụng nhau. Import package tường minh nên resolver không kéo được.

LoạiImportVì sao
UI trong appAuto-importDX; colocated
Package publicTường minhTree-shake; public API
Plugin có side effectTường minhResolver tham lam không được kéo

Góc nhìn senior

  • Auto-import UI trong app và API Vue. Import tường minh cho public API, package, và plugin có side effect.
  • Auto-import giấu cycle; không phá được chúng. useCart → useUser → useCart hiện undefined hoặc TDZ lúc load lần đầu.
  • Barrel design system từ một index làm hỏng tree-shaking. Export từng entry có tên.
  • Coi components.d.ts là artifact build, hoặc commit kèm check CI để type generation không lệch.
  • Kiểm bundle theo route (vite-bundle-visualizer) tìm chunk editor bất ngờ. Ở sản phẩm admin, route settings ship chart qua barrel.

Lỗi hay gặp

  • Triệu chứng: route settings ship chart. Nguyên nhân: barrel index. Cách xử lý: entry có tên.
  • Triệu chứng: useCart undefined lúc load lần đầu. Nguyên nhân: cycle ẩn. Cách xử lý: phá cycle; auto-import sẽ không làm hộ.
  • Triệu chứng: hai Modal đụng. Nguyên nhân: khớp tên tham lam. Cách xử lý: directoryAsNamespace hoặc import tường minh.
  • Triệu chứng: components.d.ts lệch trong CI. Nguyên nhân: generate chưa commit. Cách xử lý: generate trong CI hoặc commit kèm check.

Người phỏng vấn hỏi gì

Bạn có auto-import mọi thứ? Barrel của design system re-export mọi component từ một index, và route không import chart vẫn ship chunk chart. Họ muốn DX đối với public API tường minh, circular dep, và tree-shaking.

Trả lời khi phỏng vấn

Mình auto-import UI trong app và API Vue. Thứ gì là public API, package public, hoặc có side effect thì mình import theo tên: resolver tham lam không được kéo. Auto-import giấu cycle; useCart tới useUser tới useCart hiện undefined hoặc TDZ lúc load lần đầu. Mình không ship barrel design system re-export mọi component từ một index. Mình kiểm bundle theo route tìm chunk editor bất ngờ và giữ components.d.ts khớp trong CI. Ở sản phẩm admin, route settings ship chart đến khi bọn mình tách barrel.

Nhớ nhanh

  • Auto-import UI trong app; import tường minh cho public API và package.
  • Auto-import giấu cycle; không phá được chúng.
  • Barrel index re-export hết thì tree-shaking mất tác dụng.
  • Giữ components.d.ts khớp trong CI.

Câu hỏi nối tiếp thường gặp

  • Nuxt vs unplugin? Cùng ý. Nuxt còn auto-import composable và API Vue.
  • Library bạn publish? Đừng dựa auto-import của consumer. Export file có tên.
  • Tên đụng? directoryAsNamespace hoặc import tường minh.
  • Chứng minh chunk bất ngờ thế nào? Bundle analyzer theo route.

Tóm tắt: v-bind một chiều. v-model là nguồn sự thật do parent (hoặc thư viện form) chịu trách nhiệm: modelValue kèm update:modelValue. Đừng v-model một prop. defineModel() là sugar Vue 3.4+.

Vì sao quan trọng: Child form làm v-model="props.user"; parent refetch thì bản đang gõ biến mất. Người phỏng vấn đã biết sugar. Họ muốn nhiều v-model, modifier, defineModel, và quy tắc đừng v-model một prop.

  1. Cách hoạt động: Vue 3 bỏ mặc định value/input của Vue 2 trừ element native. v-model:foo đặt tên cũng chỉ là một model khác.
  2. Trong child, emit update hoặc dùng defineModel(). Đừng gán vào prop. Mutate field lồng trên object prop thì “chạy” đến khi parent refetch ghi đè bản nháp.
  3. v-model do parent chịu trách nhiệm sẽ re-render parent mỗi lần gõ, trừ khi bạn debounce emit. Gắn v-model thẳng vào field Pinia thì mỗi lần gõ phát tới mọi subscriber.
  4. Bản local emit lúc blur sẽ lệch sự thật phía server. Chọn một nguồn sự thật rồi ghi rõ.
const model = defineModel<string>({ required: true })
const modifiers = model.modifiers // .trim / .number / .lazy

function onInput(e: Event) {
  let v = (e.target as HTMLInputElement).value
  if (modifiers.trim) v = v.trim()
  model.value = v // parent chịu trách nhiệm nguồn sự thật
}

// ❌ v-model="props.user", parent refetch ghi đè bản nháp
// ❌ v-model="store.user.name" từ nhiều input, mọi lần gõ đều bị phát

Điều gì xảy ra: Child ghi model.value; parent vẫn chịu trách nhiệm field. modelModifiers là cách .trim tới custom input. v-model trên prop hoặc field store từ nhiều input là cách mất lần gõ.

BindingHướngQuy tắc
v-bind / :Một chiều xuốngChild không được ghi
v-modelXuống + emit lênParent (hoặc lib form) chịu trách nhiệm
defineModelSugar cho v-modelRa ngoài vẫn modelValue

Góc nhìn senior

  • Đừng v-model một prop. Emit hoặc defineModel().
  • v-model:foo đặt tên cũng chỉ là một model khác. Ghi rõ tên.
  • Modifier native không tự có trên custom input. Đọc modelModifiers.
  • Coi cảnh báo Vue “mutation of prop” là fail CI. Đếm cầu watch trong SFC form (phải giảm).
  • Store và prop cùng ghi là cách bản nháp biến mất. Ở sản phẩm admin, refetch giữa lúc gõ ghi đè form user đến khi child dùng defineModel.

Lỗi hay gặp

  • Triệu chứng: bản gõ biến mất lúc refetch. Nguyên nhân: v-model trên prop. Cách xử lý: defineModel hoặc bản nháp local.
  • Triệu chứng: mọi lần gõ tới mọi subscriber. Nguyên nhân: v-model trên field Pinia. Cách xử lý: bản nháp local, emit lúc blur.
  • Triệu chứng: .trim không làm gì trên custom input. Nguyên nhân: không đọc modifier. Cách xử lý: modelModifiers.
  • Triệu chứng: value/input Vue 2 trên component. Nguyên nhân: Vue 3 bỏ mặc định. Cách xử lý: modelValue / defineModel.

Người phỏng vấn hỏi gì

Họ đã biết v-model là :modelValue + @update:modelValue. Child form làm v-model="props.user"; parent refetch thì bản đang gõ biến mất. Họ muốn nhiều v-model, modifier, defineModel, và quy tắc đừng v-model một prop.

Trả lời khi phỏng vấn

v-bind một chiều. v-model là nguồn sự thật do parent chịu trách nhiệm. Mình emit hoặc dùng defineModel, không gán vào prop. Vue 3 bỏ mặc định value/input của Vue 2 trên component, và modifier native không apply trừ khi mình đọc modelModifiers. v-model của props.user trông ổn đến khi parent refetch ghi đè bản nháp, hoặc store và prop cùng ghi. Mình không gắn v-model thẳng vào field Pinia từ nhiều input nếu không có bản nháp local. Ở sản phẩm admin, refetch giữa lúc gõ ghi đè form user đến khi child dùng defineModel.

Nhớ nhanh

  • Đừng v-model một prop; emit hoặc defineModel().
  • v-model:foo đặt tên chỉ là model khác; ghi rõ tên.
  • Modifier native không tự áp trên custom input. Đọc modelModifiers.
  • Đừng gắn v-model thẳng vào field Pinia từ nhiều input.

Câu hỏi nối tiếp thường gặp

  • Nhiều v-model? v-model:firstName và v-model:lastName là hai model.
  • Mặc định Vue 2 khác 3? Vue 3 dùng modelValue / update:modelValue trên component.
  • Nháp local khác parent giữ? Nháp lúc blur khi chi phí gõ cao; form nhỏ thì parent giữ.
  • defineModel khác emit? Sugar. Ra ngoài vẫn là modelValue.

Tóm tắt: Props là contract một chiều, có type. Object truyền theo reference, nên coi chúng là snapshot chỉ đọc. Provide/inject phủ theo cây; Pinia phủ cả app.

Vì sao quan trọng: Parent viết :filters="{ ...filters, page }" còn child watch(() => props.filters, { deep: true }) chạy lại mỗi lần gõ. Ba trang còn props.filters.page++ rồi “bí ẩn” đồng bộ với nhau.

  1. Cách hoạt động: Object và array truyền theo reference. Vue không phải lúc nào cũng warn khi mutate lồng. Object mới mỗi render kích watch của child, dù value không đổi.
  2. Attr fallthrough áp vào root trừ khi inheritAttrs: false. Prop không khai báo thành attr và bypass contract có type.
  3. Props cho contract đã cấu hình bạn phụ trách. Provide/inject cho theme, form context, i18n. Pinia cho state client toàn app với subtree không liên quan.
  4. Prop boolean: disabled không có value thì là true. withDefaults đi cùng defineProps<T>(). Validate ở ranh giới nếu còn consumer không dùng TS.
const props = defineProps<{ filters: Filters }>()

// ❌ object mới mỗi lần gõ, deep watch của child chạy lại
// <Child :filters="{ ...filters, page }" />

const filters = computed(() => props.filters)

function nextPage() {
  emit('update:page', props.filters.page + 1)
  // không phải props.filters.page++
}

Điều gì xảy ra: Spread object filters mới mỗi render khiến deep watch của child nghĩ mọi thứ đã đổi. Mutate props.filters.page thì ba trang đồng bộ vì chúng dùng chung reference. Emit cập nhật page.

Công cụPhạm viMutate?
PropsParent → childKhông, emit
Provide/injectMột câyChỉ đọc trừ khi ghi rõ
PiniaToàn appQua action

Góc nhìn senior

  • Props là contract một chiều, có type. Mutate lồng là bug runtime, thường vẫn type-check.
  • Object mới mỗi render (:filters="{ ...filters, page }") kích watch của child. Truyền computed ổn định hoặc primitive.
  • Provide/inject phủ theo cây. Pinia phủ cả app. Đừng chọn provide vì “props cũ”.
  • Attr fallthrough bypass contract có type. inheritAttrs: false kèm bind tường minh.
  • Ở sản phẩm admin, ba trang list đồng bộ vì chúng mutate cùng một object filters.

Lỗi hay gặp

  • Triệu chứng: ba trang đồng bộ. Nguyên nhân: mutate lồng object dùng chung. Cách xử lý: emit primitive.
  • Triệu chứng: watch của child chạy mỗi lần gõ. Nguyên nhân: prop là object literal mới. Cách xử lý: computed ổn định.
  • Triệu chứng: prop không khai báo thành HTML attr. Nguyên nhân: fallthrough. Cách xử lý: inheritAttrs: false.
  • Triệu chứng: disabled là chuỗi "false". Nguyên nhân: attr fallthrough khác prop boolean. Cách xử lý: khai báo prop.

Người phỏng vấn hỏi gì

Data flow một chiều là mức junior. Parent viết :filters="{ ...filters, page }" còn child deep-watch mỗi lần gõ. Ba trang còn props.filters.page++ rồi đồng bộ với nhau. Họ muốn nghe identity, attr fallthrough, và provide khác props khác store.

Trả lời khi phỏng vấn

Props là contract một chiều, có type. Object truyền theo reference, nên mình coi chúng là snapshot chỉ đọc và emit primitive, không tăng props.filters.page. Object mới mỗi render kích watch của child dù value không đổi, nên mình truyền computed ổn định. Provide/inject phủ theo cây cho theme và form context; Pinia là state client toàn app. Attr fallthrough áp vào root trừ khi inheritAttrs là false, và prop không khai báo thì bypass contract có type. Ở sản phẩm admin, ba trang list đồng bộ vì chúng mutate cùng một object filters.

Nhớ nhanh

  • Props là một chiều. Mutate lồng là bug runtime.
  • Prop object mới mỗi render kích watch của child.
  • Provide/inject phủ theo cây; Pinia phủ cả app.
  • Attr fallthrough bypass contract có type.

Câu hỏi nối tiếp thường gặp

  • Props khác provide khác Pinia? Contract khác phủ theo cây khác toàn app.
  • Vì sao không warn khi mutate lồng? Vue không phải lúc nào cũng thấy. Coi object là snapshot.
  • Attr boolean? Khai báo prop, không thì disabled thành chuỗi.
  • Truyền object ổn định thế nào? Một computed, không phải spread inline.

Tóm tắt: Computed cache theo dependency và chỉ chạy lại khi dep đổi. Method chạy lại mỗi render. Dùng computed cho giá trị suy ra, method cho event.

Vì sao quan trọng: Template gọi heavy(item) trong v-for 10k row. Mọi lần gõ không liên quan cũng chạy lại. Người phỏng vấn muốn cache theo dependency, và một counter cho thấy khác biệt.

  1. Cách hoạt động: Vue theo dõi ref nào computed đọc. Lần access sau trả cache đến khi một dep đổi. Method là lời gọi hàm, không cache.
  2. Computed phải pure và synchronous. Side effect để trong watch. Computed ghi một ref khác thì dễ thành vòng lặp vô hạn.
  3. Truyền đối số (heavy(item)) thì không thể là computed. Tính trước Map/Set một lần, hoặc list row đã decorate bằng computed.
  4. Viết computed để cache reverse index; đừng list.filter trong template mỗi lần gõ. Method giữ trên click handler.
const count = ref(0)
const doubled = computed(() => count.value * 2) // được cache
function inc() {
  count.value++ // method: event, không phải state suy ra
}

const q = ref('')
const rows = shallowRef<User[]>([])
const visible = computed(() => {
  const needle = q.value.toLowerCase()
  return rows.value.filter((u) => u.name.toLowerCase().includes(needle))
})
// template: visible, không phải rows.filter(...) mỗi render

Điều gì xảy ra: doubled chỉ chạy lại khi count đổi. inc là method vì nó ghi. visible cache list đã lọc; parent re-render không liên quan thì không lọc lại 10k row.

LoạiChạyDùng
ComputedKhi một dep đổiGiá trị suy ra
MethodMọi lần gọi / renderClick, submit
WatchKhi nguồn đổiSide effect

Góc nhìn senior

  • Computed cho giá trị suy ra. Method cho event. Watch cho side effect.
  • Computed có đối số là method đội lốt. Tính trước một Map.
  • Computed phải pure. Computed ghi thì sớm muộn cũng thành vòng lặp.
  • Filter/sort trong computed, không trong template, với list lớn.
  • Ở sản phẩm admin, lọc bảng user là computed trên row shallowRef, không phải method trong v-for.

Lỗi hay gặp

  • Triệu chứng: gõ chỗ khác lọc lại 10k row. Nguyên nhân: method trong template. Cách xử lý: list computed.
  • Triệu chứng: heavy(item) trong v-for. Nguyên nhân: computed không nhận đối số. Cách xử lý: Map computed một lần.
  • Triệu chứng: computed ghi other.value. Nguyên nhân: side effect. Cách xử lý: watch.
  • Triệu chứng: computed không bao giờ cập nhật. Nguyên nhân: đọc bản copy không reactive. Cách xử lý: đọc ref.

Người phỏng vấn hỏi gì

Template gọi heavy(item) trong v-for 10k row. Mọi lần gõ không liên quan cũng chạy lại. Họ muốn cache theo dependency, và một counter cho thấy khác biệt.

Trả lời khi phỏng vấn

Computed cache theo dependency và chỉ chạy lại khi dep đổi. Method chạy lại mỗi render, nên mình để method trên click handler. Lọc 10k row để trong computed, không phải method trong template. Computed không nhận đối số như heavy(item), nên mình tính trước Map hoặc list đã decorate. Computed phải pure; computed ghi một ref khác là vòng lặp. Ở sản phẩm admin, lọc bảng user là computed trên row shallowRef, và doubled của một counter là ví dụ nhỏ mình sẽ viết trên whiteboard.

Nhớ nhanh

  • Computed cache theo dependency. Method chạy lại mỗi render.
  • Giá trị suy ra trong computed; event trong method; effect trong watch.
  • Computed không nhận đối số. Tính trước một Map.
  • Computed phải thuần. Ghi thì để trong watch.

Câu hỏi nối tiếp thường gặp

  • Computed khác method trong template? State suy ra thì computed. Event thì method.
  • Vì sao không dùng heavy(item) làm computed? Computed không nhận đối số. Tính trước.
  • Computed ghi được? Getter kèm setter. Dễ lạm dụng; nên dùng hàm set có tên.
  • Chi phí của computed? Rẻ nếu dep rẻ. Đừng duyệt sâu 50k hàng trong một computed.

Tóm tắt: Computed là giá trị suy ra. watch / watchEffect là side effect (tác dụng phụ). Dùng computed khi nêu được tên kết quả; dùng watch khi phải nói chuyện với bên ngoài.

Vì sao quan trọng: Một watch chép first + last vào fullName, rồi watch khác ghi ngược lại. Đó là bẫy vòng lặp vô hạn. Người phỏng vấn muốn giá trị suy ra khác side effect, các option flush, và watch khác watchEffect.

  1. Cách hoạt động: watch(source, cb) chạy callback khi source đổi. watchEffect(cb) track những gì callback đọc rồi chạy lại. Computed cache một value; watch thì không.
  2. flush: 'pre' | 'post' | 'sync'. Mặc định là pre, trước render. Post chạy sau khi DOM cập nhật: đó là nextTick lặp lại. Sync hiếm khi là thứ bạn muốn.
  3. Watch ghi value mà watch khác đang đọc thì thành vòng lặp. fullName nên là computed. Hủy việc đang chạy trong watch (search race).
  4. watchEffect dễ subscribe quá tay. Trong app nên dùng watch tường minh. Unmount thì stop watch: watch trong setup đã được scope sẵn.
const first = ref('Ada')
const last = ref('Lovelace')
const fullName = computed(() => first.value + ' ' + last.value)

watch(q, async (value, _prev, onCleanup) => {
  const ac = new AbortController()
  onCleanup(() => ac.abort())
  results.value = await search(value, { signal: ac.signal })
}, { flush: 'post' })

// ❌ watch(fullName) ghi first/last: vòng lặp

Điều gì xảy ra: fullName được suy ra, không phải watch chép. Watch search abort request trước để response chậm không thắng. flush: 'post' chạy sau khi DOM của render này cập nhật.

Công cụTrả về valueSide effect
ComputedCó, được cacheKhông được
watchKhôngCó: fetch, DOM, log
watchEffectKhôngCó, dep ẩn

Góc nhìn senior

  • Nêu được tên kết quả thì đó là computed. Nói chuyện với network hoặc DOM thì là watch.
  • watchEffect giấu dep. Trong app nên dùng watch tường minh.
  • flush: 'post' chính là nextTick lặp lại. sync gắn bạn vào scheduler.
  • Watch async thì luôn abort việc đang chạy. Đó là search race.
  • Trên sản phẩm admin, fullName là computed, search dùng watch kèm AbortController.

Lỗi hay gặp

  • Triệu chứng: fullName và first đá nhau. Nguyên nhân: hai watch chép. Cách xử lý: computed.
  • Triệu chứng: search chậm hiện sai q. Nguyên nhân: không abort. Cách xử lý: onCleanup + AbortController.
  • Triệu chứng: watch chạy trước khi DOM có. Nguyên nhân: mặc định pre. Cách xử lý: flush: 'post'.
  • Triệu chứng: watchEffect chạy lại quá nhiều. Nguyên nhân: đọc ẩn thêm. Cách xử lý: watch tường minh.

Nhà tuyển dụng hỏi gì

Một watch chép first + last vào fullName, rồi watch khác ghi ngược lại. Đó là bẫy vòng lặp vô hạn. Họ muốn giá trị suy ra khác side effect, các option flush, và watch khác watchEffect.

Trả lời khi phỏng vấn

Computed là giá trị suy ra. Watch là side effect. Nếu mình nêu được tên kết quả thì đó là computed: fullName là first với last, không phải watch chép. Watch và watchEffect nói chuyện với bên ngoài: fetch, DOM, log. Trong app mình ưu tiên watch tường minh vì watchEffect giấu dep. Flush post chính là nextTick lặp lại; sync thì mình hiếm khi muốn. Watch async phải abort lần chạy trước để search chậm không thắng. Trên sản phẩm admin, fullName là computed, search dùng watch kèm AbortController.

Nhớ nhanh

  • Computed suy ra một value. Watch chạy side effect.
  • flush: 'post' chính là nextTick lặp lại.
  • Watch async thì abort việc đang chạy.
  • Trong app, ưu tiên watch tường minh hơn watchEffect.

Câu hỏi nối tiếp thường gặp

  • watch khác watchEffect? Source tường minh khác lần đọc ẩn.
  • Khi nào bắt buộc flush post? Khi bạn cần DOM đã cập nhật.
  • Vòng lặp vô hạn? Watch ghi một source mà watch khác đang đọc.
  • Watch trong setup tự stop? Có, theo scope của component.

Tóm tắt: Vue gộp các lần cập nhật DOM. nextTick chạy sau flush này, không phải “đợi một chút”. Dùng để focus, đo, hoặc đọc layout mà tick hiện tại chưa vẽ.

Vì sao quan trọng: Bạn gán show = true rồi gọi input.focus() ngay, nhưng input chưa có trên DOM. Người phỏng vấn muốn batching, ví dụ focus sau render, và nối với event loop.

  1. Cách hoạt động: Ghi state được xếp hàng. Vue flush cây component, rồi mới DOM. nextTick(fn) xếp fn thành microtask sau flush đó, cùng hàng với promise, sau call stack hiện tại.
  2. Bug biến mất trong nextTick là phụ thuộc timing. Thay cách vá tạm bằng onMounted hoặc watch(..., { flush: 'post' }) và một test flush Vue.
  3. flush: 'post' chính là nextTick lặp lại. requestAnimationFrame / ResizeObserver dành cho layout không phải Vue. SSR có nextTick nhưng không có DOM trình duyệt.
  4. Đừng await nextTick() trong vòng lặp để “UI bắt kịp”. Việc đó giấu một long task (tác vụ chạy > 50 ms) trên main thread (luồng chính).
const open = ref(false)
const input = ref<HTMLInputElement | null>(null)

async function showAndFocus() {
  open.value = true
  await nextTick()
  input.value?.focus() // DOM có sau flush
}

watch(open, async (v) => {
  if (!v) return
  await nextTick()
  input.value?.focus()
}, { flush: 'post' })

Điều gì xảy ra: open = true xếp một render. focus() trong cùng tick fail vì input chưa mount. nextTick (microtask sau flush của Vue) là lúc element đã có. Focus lặp lại thì để trong flush: 'post'.

CầnCông cụKhông phải
Một lần sau lần ghi nàynextTicksetTimeout(0)
Mỗi khi source đổiwatch + flush: 'post'Vá tạm nextTick trong watcher
Layout không phải VuerAF / ResizeObserverFlush của Vue

Góc nhìn senior

  • nextTick = sau flush của Vue, không phải “đợi một chút”. Nó là microtask, cùng hàng với promise. Xem topic Event Loop trong tài liệu JavaScript.
  • Bug biến mất trong nextTick là phụ thuộc timing. Thay bằng onMounted hoặc flush: 'post'.
  • SSR có nextTick nhưng không có DOM trình duyệt. Đừng focus trên server.
  • Đừng await nextTick trong vòng lặp. Cách đó giấu long task.
  • Trên sản phẩm admin, dialog tạo user focus field tên trong nextTick sau khi mở, kèm test flush Vue.

Lỗi hay gặp

  • Triệu chứng: focus() không làm gì. Nguyên nhân: DOM chưa flush. Cách xử lý: nextTick hoặc onMounted.
  • Triệu chứng: bug biến mất trong nextTick. Nguyên nhân: phụ thuộc timing. Cách xử lý: flush: 'post' + test.
  • Triệu chứng: await nextTick() trong vòng lặp. Nguyên nhân: mong UI bắt kịp. Cách xử lý: chia chunk; đừng giấu long task.
  • Triệu chứng: focus lúc SSR. Nguyên nhân: không có DOM trình duyệt. Cách xử lý: chỉ client.

Nhà tuyển dụng hỏi gì

Bạn gán show = true rồi gọi input.focus() ngay, nhưng input chưa có trên DOM. Họ muốn batching, ví dụ focus sau render, và nối với event loop.

Trả lời khi phỏng vấn

Vue gộp các lần cập nhật DOM. nextTick chạy sau flush này như một microtask, cùng hàng với promise, không phải đợi một chút. Mình dùng để focus hoặc đo layout mà tick này chưa vẽ. Bug biến mất trong nextTick là phụ thuộc timing: mình thay cách vá tạm bằng onMounted hoặc watch flush post, kèm test flush Vue. Việc sau render lặp lại thì dùng flush post, không nhét nextTick vào mọi watcher. SSR có nextTick nhưng không có DOM trình duyệt. Trên sản phẩm admin, dialog tạo user focus field tên sau khi mở, và bọn mình chứng minh bằng test flush Vue.

Nhớ nhanh

  • nextTick = sau flush của Vue, không phải “đợi một chút”.
  • Bug biến mất trong nextTick là phụ thuộc timing.
  • flush: 'post' cho việc sau render lặp lại; rAF cho layout không phải Vue.
  • Nó là microtask, cùng hàng với promise.

Câu hỏi nối tiếp thường gặp

  • nextTick vs setTimeout(0)? Microtask sau flush của Vue, khác macrotask chạy sau.
  • SSR? nextTick vẫn có; DOM trình duyệt thì không. Đừng focus.
  • Focus lặp lại? watch + flush post, đừng nhét nextTick vào mọi nhánh.
  • Event loop? Sau stack hiện tại, cùng microtask khác. Xem topic Event Loop.

Tóm tắt: Phân loại bug: mất reactivity, cache stale, race, identity, lệch SSR. Sửa loại bug, không phải từng instance. Tái hiện đường đi tối thiểu: props → computed → DOM.

Vì sao quan trọng: Component “ngẫu nhiên” không cập nhật, hoặc keep-alive hiện khách cũ. Người phỏng vấn muốn một phương pháp, không phải “mình console.log”.

  1. Cách hoạt động: Destructure reactive ra number thì không được track: dùng toRefs. watch(toRaw(store.item)) sẽ không thấy store cập nhật. Proxy Vue 3 khiến === với object raw fail.
  2. keep-alive không destroy instance; onMounted sẽ không chạy lại. Fetch trong onActivated hoặc watch id. :key="userId" hợp lệ khi identity của entity đổi, không phải reset đại trà.
  3. Map/Set hoặc instance class nên markRaw, không thì bạn proxy nội bộ và phá chúng. Đi tìm những update chạy mỗi frame: thường là watcher ghi một value rồi tự invalidate.
  4. DevTools trước, đừng vội thêm log: DevTools ít sai hơn về dependency nào trigger; log trong computed/watch làm lệch timing. Binary-search git khi chưa rõ loại bug.
const state = reactive({ count: 0 })
const { count } = state // number, không được track
const { count: countRef } = toRefs(state) // Ref, được track

watch(() => store.item, render, { flush: 'post' })
// watch(toRaw(store.item)) SẼ KHÔNG thấy store cập nhật

onActivated(() => {
  void reload(route.params.id) // keep-alive bỏ qua onMounted
})

Điều gì xảy ra: Destructure reactive làm mất tracking, nên template đọc count không bao giờ cập nhật. toRefs giữ ref. keep-alive bỏ onMounted, nên khách cũ còn đó đến khi onActivated reload.

ClassTriệu chứngCách xử lý
Mất reactivityUI không cập nhậttoRefs / đọc proxy
Cache staleKhách cũonActivated / :key
RaceHiện sai userAbortController
Lệch SSRCảnh báo hydrationonMounted / useId

Góc nhìn senior

  • Sửa loại bug, không phải từng instance. Tái hiện props → computed → DOM.
  • DevTools inspector + timeline, rồi onRenderTracked local. Tin DevTools về dep nào trigger.
  • key="Date.now()" không phải fix. max + LRU trên keep-alive nếu memory tăng.
  • Watcher ghi value rồi tự invalidate chính là giật mỗi frame.
  • Trên admin nội bộ, keep-alive hiện khách cũ đến khi bọn mình fetch trong onActivated.

Lỗi hay gặp

  • Triệu chứng: UI không cập nhật sau destructure. Nguyên nhân: mất tracking. Cách xử lý: toRefs.
  • Triệu chứng: khách cũ trong drawer. Nguyên nhân: keep-alive + onMounted. Cách xử lý: onActivated.
  • Triệu chứng: key={Date.now()}. Nguyên nhân: reset vì hoảng. Cách xử lý: id của entity, hoặc không dùng.
  • Triệu chứng: giật mỗi frame. Nguyên nhân: watch tự ghi. Cách xử lý: computed, hoặc guard lần ghi.

Người phỏng vấn hỏi gì

Component “ngẫu nhiên” không cập nhật, hoặc keep-alive hiện khách cũ. Họ muốn một phương pháp: mất reactivity, cache stale, race, identity, lệch SSR. Không phải “mình console.log”.

Trả lời khi phỏng vấn

Mình tái hiện đường đi tối thiểu, props tới computed tới DOM, rồi phân loại: mất reactivity, cache stale, race, identity, hay lệch SSR. Destructure reactive làm mất tracking; mình dùng toRefs. watch toRaw store item sẽ không thấy store cập nhật. keep-alive bỏ onMounted; mình fetch trong onActivated. key Date.now không phải fix. DevTools và log lệch thì mình tin DevTools về dependency nào trigger. Trên admin nội bộ, keep-alive hiện khách cũ đến khi bọn mình fetch trong onActivated.

Nhớ nhanh

  • Sửa loại bug, không phải từng instance.
  • Destructure reactive làm mất tracking; dùng toRefs.
  • keep-alive: onMounted sẽ không chạy lại. Dùng onActivated.
  • Tin DevTools về dependency nào trigger.

Câu hỏi nối tiếp thường gặp

  • toRaw trong watch? Bạn sẽ không thấy store cập nhật. Watch proxy hoặc getter.
  • Khi nào :key hợp lệ? Khi identity của entity đổi, không phải reset đại trà.
  • Giật mỗi frame? Watch ghi value rồi tự invalidate.
  • Class lệch SSR? Date.now / window trong setup. Chuyển sang onMounted hoặc useId.

Tóm tắt: Vue 3 track bằng Proxy (track / trigger). ref khi bạn thay nguyên value; reactive cho object nhỏ bạn mutate; shallowRef / markRaw khi Proxy sâu là chi phí, hoặc sẽ phá instance third-party.

Vì sao quan trọng: Vue 2 Object.defineProperty khác Proxy Vue 3, rồi các cơ chế production: markRaw, shallowRef, triggerRef. Bảng 50k hàng bọc reactive() sâu bị giật: đó là đề bài.

  1. Cách hoạt động: Proxy chặn get (track) và set (trigger). Nó track key mới và ghi index, Vue 2 không làm được. Không bọc được primitive, và không thấy mutate trong markRaw / target shallow.
  2. reactive ở mức module dùng chung giữa các request SSR, trừ khi bạn tạo per app. reactive unwrap ref lồng nhau; ref của object reactive thì bọc hai lần.
  3. Ghi tại chỗ trên shallowRef không làm gì đến khi triggerRef. Đặt object reactive làm key trong Set/Map thì dùng identity Proxy: toRaw để interop.
  4. onScopeDispose là lý do composable dừng track lúc unmount. reactive() sâu trên bảng 50k hàng là gánh nặng heap.
const rows = shallowRef<Row[]>([])
function setPage(next: Row[]) {
  rows.value = next // một lần trigger
}
function patchHidden(i: number, row: Row) {
  rows.value[i] = row // KHÔNG được track (shallow)
  triggerRef(rows) // tín hiệu tường minh khi phải mutate tại chỗ
}
const map = markRaw(new MapboxMap(el))

function useRows() {
  onScopeDispose(() => { /* dừng */ })
  return rows
}

Điều gì xảy ra: Đổi trang là một lần trigger. Ghi row tại chỗ thì vô hình đến khi triggerRef. Mapbox giữ raw để Vue không proxy nội bộ. onScopeDispose dừng composable khi owner unmount.

APIKhi nàoBẫy
refThay nguyên value.value trong script
reactiveObject nhỏ hay mutateSingleton module SSR
shallowRefTrang lớn bạn swapGhi tại chỗ im lặng
markRawMapbox / Chart / classMutate vô hình

Góc nhìn senior

  • ref thay nguyên; reactive mutate object nhỏ; shallowRef / markRaw khi Proxy sâu gây hại.
  • Ghi tại chỗ trên shallowRef không làm gì đến khi triggerRef.
  • reactive mức module là leak SSR xuyên request. Tạo per app.
  • Identity Proxy trong key Map/Set hay làm bất ngờ. toRaw để interop.
  • Trên admin nội bộ, bảng 50k hàng là shallowRef; Mapbox là markRaw.

Lỗi hay gặp

  • Triệu chứng: bảng 50k hàng giật. Nguyên nhân: reactive() sâu. Cách xử lý: shallowRef.
  • Triệu chứng: Mapbox hỏng sau khi bọc. Nguyên nhân: nội bộ bị proxy. Cách xử lý: markRaw.
  • Triệu chứng: ghi row tại chỗ không làm gì. Nguyên nhân: shallow. Cách xử lý: thay mảng hoặc triggerRef.
  • Triệu chứng: user SSR dùng chung state. Nguyên nhân: reactive mức module. Cách xử lý: tạo per app.

Người phỏng vấn hỏi gì

Vue 2 Object.defineProperty khác Vue 3 Proxy, ref khác reactive, rồi các cơ chế production: markRaw, shallowRef, triggerRef. Bảng 50k hàng bọc reactive() sâu bị giật: đó là đề bài.

Trả lời khi phỏng vấn

ref khi mình thay nguyên value; reactive cho object nhỏ mình mutate; shallowRef cho trang API lớn mình swap bằng một lần gán; markRaw cho Mapbox, Chart, hoặc class có identity riêng. Proxy track key mới và ghi index, Vue 2 không làm được, nhưng không thấy nội bộ markRaw hay shallow. reactive ở scope module trong Nuxt là leak xuyên request. Ghi tại chỗ trên shallowRef không làm gì đến khi triggerRef. onScopeDispose là lý do composable dừng track lúc unmount. Trên admin nội bộ, bảng 50k hàng là shallowRef, Mapbox là markRaw.

Nhớ nhanh

  • ref thay nguyên; reactive mutate object nhỏ; shallowRef / markRaw khi Proxy sâu gây hại.
  • Ghi tại chỗ trên shallowRef không trigger gì cho đến khi gọi triggerRef.
  • reactive khai báo ở mức module sẽ rò sang request khác lúc SSR.
  • Proxy track được key mới; defineProperty của Vue 2 thì không.

Câu hỏi nối tiếp thường gặp

  • ref vs reactive? Thay cả cục, hay mutate một object nhỏ. Đừng mix nếu không có lý do.
  • Vì sao triggerRef? Ghi tại chỗ trên shallow thì im lặng nếu không gọi.
  • Singleton SSR? reactive ở module là dùng chung. Tạo mới cho từng app.
  • Key Set/Map? Identity Proxy ≠ identity raw. Dùng toRaw khi interop.

Tóm tắt: setup tạo state. onMounted mới đụng DOM và window. onMounted không chạy trên server. Câu đó là cả bài phỏng vấn.

Vì sao quan trọng: Họ so tên Vue 2 với Vue 3, rồi nhảy sang setup vs onMounted vs SSR và onScopeDispose. Listener đăng ký trong setup thì crash server, hoặc chạy hai lần vì không cleanup.

  1. Cách hoạt động: SSR render chạy setup + onServerPrefetch (và async data của Nuxt). Không chạy onMounted / onUpdated. Hydration phía client chạy lại setup rồi onBeforeMount / onMounted.
  2. Dừng listener trong onBeforeUnmount, hoặc onScopeDispose nếu composable có thể chạy ngoài component. Composable phải gọi sync và không điều kiện trong setup.
  3. onMounted trong child v-if chạy khi child được tạo, không phải khi parent mount. keep-alive dùng onActivated, không phải onMounted lần hai.
  4. setup async mà không có <Suspense> thì component không hiện. Prefetch rồi fetch lúc mounted, không có key, là fetch hai lần.
const el = ref<HTMLElement | null>(null)

onMounted(() => {
  const ro = new ResizeObserver(() => measure(el.value))
  ro.observe(el.value!)
  onBeforeUnmount(() => ro.disconnect())
})

export function useWindowWidth() {
  const w = ref(0)
  function onResize() { w.value = window.innerWidth }
  window.addEventListener('resize', onResize) // ❌ crash SSR
  onScopeDispose(() => window.removeEventListener('resize', onResize))
  return w
}

Điều gì xảy ra: ResizeObserver phải nằm trong onMounted vì server không có window. Composable gắn listener ngay trong thân hàm sẽ crash SSR; chuyển listener vào onMounted và dispose bằng onScopeDispose.

HookSSRClient
setupCóCó, rồi hydrate
onServerPrefetchCóKhông
onMountedKhôngCó
onActivatedKhôngchỉ keep-alive

Góc nhìn senior

  • onMounted không chạy trên server. Câu đó là cả bài phỏng vấn.
  • DOM / window / widget để trong onMounted; dispose trong onBeforeUnmount / onScopeDispose.
  • Composable: sync, không điều kiện, trong setup. if (flag) useFoo() ra instance sai.
  • Parent setup → child setup → child mount → parent mount. SSR không có mount.
  • Ở admin nội bộ, listener resize trong setup làm crash SSR đến khi bọn mình chuyển sang onMounted.

Lỗi hay gặp

  • Triệu chứng: SSR crash vì window. Nguyên nhân: listener trong setup. Cách xử lý: onMounted.
  • Triệu chứng: listener chạy hai lần, không dừng. Nguyên nhân: không dispose. Cách xử lý: onBeforeUnmount / onScopeDispose.
  • Triệu chứng: if (flag) useFoo(). Nguyên nhân: composable có điều kiện. Cách xử lý: luôn gọi, rẽ nhánh bên trong.
  • Triệu chứng: keep-alive bỏ setup data. Nguyên nhân: chờ onMounted lần hai. Cách xử lý: onActivated.

Người phỏng vấn hỏi gì

Họ so tên Vue 2 với Vue 3, rồi nhảy sang setup vs onMounted vs SSR và onScopeDispose. Listener đăng ký trong setup thì crash server, hoặc chạy hai lần vì không cleanup. onMounted không chạy trên server.

Trả lời khi phỏng vấn

setup tạo state. onMounted mới đụng DOM và window. onMounted không chạy trên server: câu đó là cả bài phỏng vấn. Mình gỡ listener trong onBeforeUnmount, hoặc onScopeDispose khi composable có thể sống trong store. Composable gọi sync và không điều kiện trong setup; if flag useFoo ra instance sai. setup async không có Suspense thì không hiện. keep-alive dùng onActivated, không phải onMounted lần hai. Ở admin nội bộ, listener resize trong setup làm crash SSR đến khi bọn mình chuyển sang onMounted.

Nhớ nhanh

  • onMounted không chạy trên server.
  • DOM / window / widget trong onMounted; dispose lúc unmount.
  • Composable: sync, không điều kiện, trong setup.
  • keep-alive dùng onActivated, không phải onMounted lần hai.

Câu hỏi nối tiếp thường gặp

  • Vue 2 created vs setup? Cả hai đều chạy lúc SSR. created là Options; setup là Composition.
  • onServerPrefetch vs fetch lúc onMounted? Prefetch để HTML đủ nội dung; mounted cho widget chỉ client.
  • Thứ tự SPA? Parent setup → child setup → child mount → parent mount.
  • Composable có điều kiện? Luôn gọi; rẽ nhánh bên trong.

Tóm tắt: Data first-paint / SEO / SSR thì fetch trong setup hoặc useAsyncData của Nuxt. Việc chỉ DOM để trong onMounted. Đừng làm cả hai “cho chắc”. Hủy lúc unmount và lúc id đổi.

Vì sao quan trọng: Vue 2: created vs mounted. Vue 3: await top-level trong setup + Suspense vs onMounted. userId đổi nhanh hơn network; response chậm thì hiện nhầm user. SSR: mounted không chạy nên HTML không có data.

  1. Cách hoạt động: await top-level trong <script setup> biến component thành async; parent phải bọc <Suspense> (hoặc Nuxt phải xử lý). Không có thì ra chỗ trống hoặc warning.
  2. onMounted không bao giờ chạy trên server, nên HTML sẽ không có data. created của Vue 2 thì chạy lúc SSR, nên port máy móc fetch lúc mounted sang Nuxt sẽ làm mất payload.
  3. Lỗi 500 trong setup async kéo sập cả boundary Suspense, không chỉ một field. Fetch trong setup mà không có cache key thì GET lại khi reuse route.
  4. App chỉ SPA thường bỏ Suspense và fetch trong onMounted, chấp nhận spinner và SEO kém hơn. Với Nuxt, useAsyncData có key để payload dedupe lúc client replay.
watch(
  () => props.id,
  (id, _prev, onCleanup) => {
    const ac = new AbortController()
    onCleanup(() => ac.abort())
    void (async () => {
      try {
        user.value = await api.getUser(id, { signal: ac.signal })
      } catch (e) {
        if ((e as { name?: string }).name === 'AbortError') return
        error.value = e
      }
    })()
  },
  { immediate: true },
)

Điều gì xảy ra: watch immediate fetch lúc setup (SSR + client) và fetch lại khi id đổi. Cleanup abort lần trước để user chậm không đè lên user mới. Widget chỉ DOM vẫn để trong onMounted.

CầnỞ đâuKhông
First paint / SEO / SSRsetup / useAsyncDatachỉ onMounted
DOM / windowonMountedCả hai “cho chắc”
Đổi id / unmountAbortControllerGhi sau thắng

Góc nhìn senior

  • Data first-paint / SSR trong setup hoặc useAsyncData; việc DOM trong onMounted. Đừng làm cả hai “cho chắc”.
  • Hủy lúc unmount và lúc id đổi. Lỗi 500 trong setup async kéo sập cả boundary Suspense.
  • created Vue 2 chạy lúc SSR; onMounted Vue 3 thì không. Port máy móc làm mất payload HTML.
  • useAsyncData có key thì payload dedupe lúc client replay trong Nuxt.
  • Ở admin nội bộ, trang user fetch trong useAsyncData, đổi id thì abort getUser trước.

Lỗi hay gặp

  • Triệu chứng: HTML không có user. Nguyên nhân: fetch trong onMounted trên Nuxt. Cách xử lý: useAsyncData / setup.
  • Triệu chứng: user chậm đè lên user mới. Nguyên nhân: không abort. Cách xử lý: AbortController.
  • Triệu chứng: chỗ trống ở parent. Nguyên nhân: setup async không Suspense. Cách xử lý: bọc hoặc dùng Nuxt.
  • Triệu chứng: GET hai lần lúc load đầu. Nguyên nhân: fetch cả setup lẫn mounted. Cách xử lý: một chỗ, có key.

Người phỏng vấn hỏi gì

Vue 2: created vs mounted. Vue 3: await top-level trong setup + Suspense vs onMounted. userId đổi nhanh hơn network; response chậm thì hiện nhầm user. SSR: mounted không chạy nên HTML không có data.

Trả lời khi phỏng vấn

Data first paint và SSR thì fetch trong setup hoặc useAsyncData. Việc chỉ DOM để trong onMounted. Mình không gọi cả hai cho chắc. await top-level biến component thành async, parent cần Suspense. Mình abort lúc unmount và lúc id đổi để response chậm không thắng. onMounted không chạy trên server, nên fetch mounted của Vue 2 port sang Nuxt sẽ làm mất payload HTML. Lỗi 500 trong setup async kéo sập cả boundary Suspense. Ở admin nội bộ, trang user fetch trong useAsyncData, đổi id thì abort getUser trước.

Nhớ nhanh

  • Data first-paint / SSR thì fetch trong setup hoặc useAsyncData; việc DOM thì để onMounted.
  • Đừng fetch cả hai chỗ “cho chắc”.
  • Abort khi unmount và khi id đổi.
  • Một 500 trong setup async làm sập cả boundary Suspense.

Câu hỏi nối tiếp thường gặp

  • Vue 2 created chạy trên SSR? Có. onMounted Vue 3 không chạy trên server.
  • Suspense vs Nuxt? Nuxt xử lý setup async. Parent trong SPA phải bọc Suspense.
  • Double fetch? useAsyncData có key, hoặc chỉ một chỗ.
  • Race khi đổi id? AbortController trong cleanup của watch.

Tóm tắt: Teleport đưa DOM overlay sang một host ổn định (body hoặc #overlays) để ancestor overflow và transform không cắt được. State vẫn nằm ở component logic: Teleport chỉ chuyển DOM, không đổi parent Vue.

Vì sao quan trọng: Hai dialog đang mở; Tab vẫn đi vòng trang phía sau; popover position: fixed bị ancestor transform cắt. Người phỏng vấn muốn nghe về focus, target SSR phải có sẵn, và stacking. Không phải cú pháp to="#id".

  1. Cách hoạt động: Teleport chuyển DOM của VNode sang to. Parent Vue vẫn là owner logic, nên provide, props, và teardown vẫn chạy ở đó.
  2. Trên SSR, target phải có trong HTML server. Teleport tới #modal tạo trong onMounted sẽ lệch.
  3. Nhiều teleport cùng host thì append theo thứ tự mount, không theo z-index. Bọc <Transition> quanh nội dung, không quanh Teleport.
  4. disabled giữ overlay in-flow (test, mobile). Trap focus, trả về opener, và set role="dialog" / aria-modal.
<Teleport to="#overlays" :disabled="isMobileSheet">
  <Transition name="fade">
    <div
      v-if="open"
      role="dialog"
      aria-modal="true"
      aria-labelledby="title"
      @keydown.esc="close"
    >
      <h2 id="title">Edit user</h2>
      <input ref="first" />
    </div>
  </Transition>
</Teleport>
<!-- #overlays phải có trong HTML SSR; focus phần tử đầu lúc mở, trả focus lúc đóng -->

Điều gì xảy ra: DOM của dialog nằm dưới #overlays, nên transform trên card không cắt được. State và close vẫn ở trang chịu trách nhiệm nút mở. Transition bọc dialog, không bọc Teleport, không thì animation leave không chạy.

CầnLàmĐừng
Cắt / stackingTeleport tới body / #overlaysposition: fixed trong card có transform
SSRTarget trong HTML serverTạo #modal trong onMounted
FocusTrap + trả openerTab xuyên trang phía sau

Góc nhìn senior

  • Teleport overlay; giữ state ở owner logic (component của nút mở).
  • Target SSR phải có trong HTML server. Host tạo trong onMounted sẽ lệch.
  • Trap focus, Escape, trả focus. role="dialog" và aria-modal.
  • <Transition> bọc nội dung, không bọc Teleport. Thứ tự append là thứ tự mount.
  • to="body" thì đơn giản; root overlay riêng cho lớp toast > modal > popover. Ở sản phẩm admin, dialog user teleport tới #overlays và trả focus về Edit.

Lỗi hay gặp

  • Triệu chứng: popover bị cắt. Nguyên nhân: ancestor transform / overflow. Cách xử lý: Teleport tới host ổn định.
  • Triệu chứng: lệch hydration. Nguyên nhân: #modal tạo trong onMounted. Cách xử lý: host phải có trong HTML server.
  • Triệu chứng: Tab đi vòng trang. Nguyên nhân: không trap focus. Cách xử lý: trap + trả opener.
  • Triệu chứng: thiếu animation leave. Nguyên nhân: Transition bọc Teleport. Cách xử lý: bọc nội dung.

Nhà tuyển dụng hay hỏi gì

Modal và toast là đề bài. Hai dialog đang mở; Tab vẫn đi vòng trang phía sau; popover position: fixed bị ancestor transform cắt. Họ muốn nghe về focus, target SSR phải có sẵn, và stacking. Không phải cú pháp to="#id".

Trả lời khi phỏng vấn

Mình teleport overlay ra body hoặc một root overlays riêng, để ancestor overflow và transform không cắt được. State vẫn ở component logic: Teleport chuyển DOM, không đổi parent Vue. Target SSR phải có trong HTML server; host modal tạo trong onMounted sẽ lệch. Mình trap focus, trả về opener, và bọc Transition quanh nội dung, không quanh Teleport. Thứ tự append là thứ tự mount, không phải z-index. Ở sản phẩm admin, dialog sửa user teleport tới overlays rồi trả focus về Edit.

Nhớ nhanh

  • Teleport overlay; giữ state ở owner logic.
  • Target SSR phải có trong HTML server.
  • Trap focus + trả; Transition bọc nội dung, không bọc Teleport.
  • Thứ tự append là thứ tự mount, không phải z-index.

Câu hỏi nối tiếp thường gặp

  • Teleport khác store modal global? Thường cần cả hai: store cho stacking, Teleport cho host DOM.
  • Vì sao không tạo host trong onMounted? Nó không có trong HTML SSR, nên hydration lệch.
  • disabled? Giữ overlay in-flow cho test hoặc sheet mobile.
  • Stacking hai dialog? Thứ tự mount kèm thang z-index tường minh trên root overlay.

Tóm tắt: Vue <Suspense> chặn setup async và async component sau fallback. Nó vẫn experimental và không phải error boundary. Trên Nuxt, useAsyncData có key là bản dùng cho production.

Vì sao quan trọng: Họ biết slot fallback. Một widget lỗi làm trắng cả app vì một <Suspense> bọc cả cây. Ở team Nuxt, câu đúng có thể là “bọn mình ít khi tự dùng Vue Suspense”.

  1. Cách hoạt động: Child có setup async (await top-level) sẽ suspend đến khi resolve. Suspense gần nhất hiện #fallback, rồi #default.
  2. Nó experimental; API có thể đổi. Không có API chính thức “reset boundary này”: remount bằng key hoặc điều hướng đi.
  3. Suspense không bắt lỗi. Kèm onErrorCaptured (return false khi xử lý xong). Suspense lồng có thể giấu fallback của parent.
  4. Server và client phải resolve cùng một cây async, không thì lệch hydrate. Ưu tiên useAsyncData có key ở mức route trên Nuxt.
<Suspense>
  <template #default>
    <UserPanel :id="id" />
  </template>
  <template #fallback>
    <UserSkeleton />
  </template>
</Suspense>

<!-- UserPanel.vue: await top-level khiến component này thành async -->
<script setup lang="ts">
const props = defineProps<{ id: string }>()
const user = await fetchUser(props.id) // parent phải có Suspense
</script>
<!-- onErrorCaptured gần đó: 500 không được làm trắng route -->

Điều gì xảy ra: UserPanel await, nên Suspense parent hiện skeleton đến khi user resolve. Không có boundary thì component không bao giờ hiện. 500 vẫn cần onErrorCaptured: Suspense sẽ không bắt.

Công cụChặnLỗi
Vue <Suspense>Setup async / async componentKhông: dùng onErrorCaptured
Nuxt useAsyncDataPayload route + pendingerror ref trên lời gọi
Suspense lồngFallback bên trong trướcThrow vẫn bubble lên

Góc nhìn senior

  • Suspense chặn setup async. Nó experimental và không phải error boundary.
  • Đừng bọc cả app trong một boundary để 500 của widget làm trắng trang.
  • Trên Nuxt, useAsyncData có key là bản dùng cho production. Mình ít khi tự dùng Vue Suspense.
  • Suspense lồng có thể giấu fallback của parent. Kết hợp defineAsyncComponent thì có khi ra hai UI loading.
  • Thêm delay pending để response nhanh không nháy skeleton. Ở sản phẩm admin, route user dùng useAsyncData, không phải Suspense cả cây.

Lỗi hay gặp

  • Triệu chứng: cả app trắng vì 500 của widget. Nguyên nhân: một Suspense cả cây. Cách xử lý: lồng, kèm onErrorCaptured.
  • Triệu chứng: setup async không hiện. Nguyên nhân: không có boundary khớp. Cách xử lý: bọc parent.
  • Triệu chứng: hai skeleton. Nguyên nhân: Suspense + async loadingComponent. Cách xử lý: chọn một.
  • Triệu chứng: lệch hydration. Nguyên nhân: server/client resolve khác cây. Cách xử lý: cùng một đường async.

Nhà tuyển dụng hay hỏi gì

Họ biết slot fallback. Một widget lỗi làm trắng cả app vì một <Suspense> bọc cả cây. Họ muốn nghe lưu ý experimental, lỗi, và Suspense lồng. Ở team Nuxt, câu đúng có thể là “bọn mình ít khi tự dùng Vue Suspense”.

Trả lời khi phỏng vấn

Suspense chặn setup async. Nó vẫn experimental và không phải error boundary, nên mình kèm onErrorCaptured. Mình không bọc cả app trong một boundary để widget 500 làm trắng trang. Ở team Nuxt mình ít khi tự dùng Vue Suspense; useAsyncData có key là bản dùng cho production. Suspense lồng có thể giấu fallback của parent. onErrorCaptured phải return false khi mình xử lý xong. Ở sản phẩm admin, route user dùng pending và error của useAsyncData, không phải Suspense cả cây.

Nhớ nhanh

  • Suspense chặn setup async; không phải error boundary.
  • Đừng bọc cả app trong một boundary.
  • Ưu tiên useAsyncData có key ở mức route trên Nuxt.
  • Nó vẫn experimental. Remount bằng key để reset.

Câu hỏi nối tiếp thường gặp

  • Error boundary? onErrorCaptured, không phải Suspense. Return false khi xử lý xong.
  • Reset boundary đang kẹt? Remount bằng key, hoặc điều hướng đi. Không có API reset chính thức.
  • Nuxt khác Vue Suspense? useAsyncData pending/error ở mức route. Vue Suspense cho island setup async.
  • Fallback lồng bị giấu? Inner đã resolve, outer vẫn pending vì một child khác.

Tóm tắt: Ưu tiên composable. Custom directive chỉ là annotation trên DOM, bắt buộc phải cleanup. Thêm getSSRProps khi first paint cần attribute HTML.

Vì sao quan trọng: v-focus là junior. Đề bài senior là v-click-outside chạy ngay trên click vừa mở menu, rò listener, và không có getSSRProps.

  1. Cách hoạt động: Cần Vue state, lifecycle, hoặc test thì dùng composable kèm template ref. Directive chỉ dành cho annotation DOM thuần, gắn như HTML.
  2. Hook Vue 3 là beforeMount / mounted / updated / unmounted. Tên Vue 2 bind / inserted không còn. Xử lý binding.value trong updated; đừng đăng ký listener thứ hai.
  3. created / mounted không chạy trên SSR. Dùng getSSRProps nếu first paint cần attribute. binding.instance hỏng khi dùng <script setup>.
  4. Luôn gỡ trong unmounted (và beforeUnmount nếu element đã không còn). Directive trên component lắng trên root element; fragment thì phải cẩn thận với inheritAttrs.
export const vClickOutside = {
  beforeMount(el, binding) {
    el._cb = binding.value
    el._outside = (e) => {
      if (!el.contains(e.target)) el._cb(e)
    }
    // bỏ qua click vừa mở menu
    requestAnimationFrame(() => document.addEventListener('click', el._outside))
  },
  updated(el, binding) {
    el._cb = binding.value // đừng thêm listener thứ hai
  },
  unmounted(el) {
    document.removeEventListener('click', el._outside)
  },
  getSSRProps() {
    return { 'data-click-outside': '' } // chỉ attr first paint
  },
}

Điều gì xảy ra: Listener được đăng ký sau click mở, nên menu không tự đóng. updated chỉ đổi callback. unmounted gỡ listener. getSSRProps emit một attribute vô hại để HTML SSR có attribute đó.

DạngKhi nàoCleanup
Composable + template refState, test, TypeScriptonScopeDispose
DirectiveAnnotation DOM gắn như HTMLunmounted / beforeUnmount
Component bọcCần ranh giới slotUnmount bình thường

Góc nhìn senior

  • Ưu tiên composable. Directive dễ rò listener và không có instance có type như component.
  • v-click-outside chạy trên click vừa mở, hoặc đăng ký hai lần trong updated, là kịch bản lỗi.
  • created / mounted không chạy trên SSR. Dùng getSSRProps khi first paint cần attribute.
  • Tên Vue 2 bind / inserted không còn. Nhiều codebase cấm directive, trừ a11y hoặc đo DOM.
  • useClickOutside(el, cb) test được qua onScopeDispose. Directive của design system tiện hơn cho consumer không viết setup. Trên sản phẩm admin chỉ giữ một directive click-outside và từ chối v-focus.

Lỗi hay gặp

  • Triệu chứng: menu đóng ngay lúc mở. Nguyên nhân: cùng click đụng listener trên document. Cách xử lý: đăng ký ở frame sau.
  • Triệu chứng: listener tăng sau mỗi update. Nguyên nhân: updated thêm listener nữa. Cách xử lý: chỉ đổi binding.value.
  • Triệu chứng: cảnh báo hydration trên attribute. Nguyên nhân: không có getSSRProps. Cách xử lý: emit attr từ server.
  • Triệu chứng: không bind trên component fragment. Nguyên nhân: directive lắng trên root element. Cách xử lý: một root hoặc inheritAttrs.

Người phỏng vấn hỏi gì

Họ muốn bạn ưu tiên composable. v-focus là junior; đề bài senior là v-click-outside chạy ngay trên click vừa mở menu, rò listener, và không có getSSRProps.

Trả lời khi phỏng vấn

Mình ưu tiên composable. Directive chỉ là annotation trên DOM, bắt buộc phải cleanup. Click-outside rò listener hoặc chạy trên click vừa mở là kịch bản lỗi. created và mounted không chạy trên SSR, nên mình dùng getSSRProps khi first paint cần attribute. Tên bind và inserted của Vue 2 không còn. Mình xử lý binding.value trong updated để không đăng ký hai lần. Nhiều codebase cấm directive, trừ a11y hoặc việc DOM. Trên sản phẩm admin team mình giữ một directive click-outside, phần còn lại viết thành composable.

Nhớ nhanh

  • Ưu tiên composable; directive chỉ DOM và bắt buộc cleanup.
  • getSSRProps khi first paint cần attribute.
  • Xử lý binding.value trong updated: đừng đăng ký hai lần.
  • Hook Vue 3 thay bind / inserted của Vue 2.

Câu hỏi nối tiếp thường gặp

  • Vì sao v-memo không phải custom directive? Nó là compiler directive. Compiler emit flag; directive runtime không làm được.
  • IntersectionObserver: directive hay composable? Composable cho infinite scroll: bạn cần state, test, và onScopeDispose.
  • Cấm directive trừ a11y/DOM? Có, ở hầu hết codebase. Consumer của design system là ngoại lệ.
  • Directive trên fragment? Nó bind vào root element. Fragment cần một root hoặc cẩn thận với inheritAttrs.

Tóm tắt: install(app, options) của plugin thêm capability vào một app instance. Ưu tiên provide kèm InjectionKey. Đừng giữ cache mutable ở module scope.

Vì sao quan trọng: let cache = {} ở module scope rò user A sang user B trên hai request SSR chạy song song. $http trên globalProperties là câu trả lời kiểu Vue 2. Senior nói về context per-app.

  1. Cách hoạt động: install đăng ký component, provide một client, hoặc thêm directive. Gọi app.use một lần trong main.ts hoặc Nuxt plugin, không từ SFC linh tinh.
  2. SSR tạo một app mỗi request. Cache ở module scope được share giữa các request. SDK chỉ chạy trên browser thì để trong Nuxt plugin .client hoặc sau import.meta.client.
  3. Không cần app thì là composable, không phải plugin. Compose app.config.errorHandler với handler sẵn có; đừng thay thế.
  4. Ưu tiên app.provide kèm InjectionKey có type hơn globalProperties cho API mới. Plugin Vue 3 không được giả định this ở consumer chỉ dùng Options.
import type { App, InjectionKey } from 'vue'

export const HttpKey: InjectionKey<HttpClient> = Symbol('http')

export function createHttpPlugin(options: { baseURL: string }) {
  return {
    install(app: App) {
      // client per-app: SSR là một app mỗi request
      const client = createHttp(options.baseURL)
      app.provide(HttpKey, client)
      const prev = app.config.errorHandler
      app.config.errorHandler = (err, instance, info) => {
        client.report(err)
        if (prev) prev(err, instance, info) // compose, đừng thay
      }
    },
  }
}

Điều gì xảy ra: Mỗi createApp có HTTP client riêng qua provide. Hai request SSR không share một map ở module scope. errorHandler cũ vẫn chạy.

Cơ chếDễ thấySSRTS
provide / injectNgầmPer-app nếu tạo trong installInjectionKey
globalPropertiesthis.$x (Options)Dễ dùng saiYếu
PiniauseX() tường minhModule Nuxt chính thứcMạnh
Import moduleTường minhKhông singleton-cacheMạnh

Góc nhìn senior

  • Plugin cài vào một app instance. Không cần app thì là composable.
  • provide kèm InjectionKey hơn globalProperties cho API mới. $http là thói quen Vue 2.
  • SSR là một app mỗi request. let cache = {} ở module scope rò user A sang user B.
  • Compose errorHandler; đừng thay. SDK chỉ chạy trên client thì để sau .client hoặc import.meta.client.
  • Thứ tự Nuxt plugin so với i18n và auth rất quan trọng. Trên sản phẩm admin, plugin HTTP provide client theo request và không cache user ở module scope.

Lỗi hay gặp

  • Triệu chứng: user B thấy data của user A. Nguyên nhân: cache module scope trên SSR. Cách xử lý: tạo state trong install.
  • Triệu chứng: window is not defined. Nguyên nhân: đọc window lúc import. Cách xử lý: .client hoặc import.meta.client.
  • Triệu chứng: lỗi biến mất. Nguyên nhân: plugin thay errorHandler. Cách xử lý: compose với handler cũ.
  • Triệu chứng: plugin chạy hai lần. Nguyên nhân: app.use từ SFC linh tinh. Cách xử lý: một lần trong main.ts.

Người phỏng vấn hỏi gì

app.use khác provide, và plugin có an toàn SSR không. let cache = {} ở module scope rò user A sang user B trên hai request SSR chạy song song. Senior nói context per-app, không phải $http.

Trả lời khi phỏng vấn

Plugin cài một capability vào một app instance. Mình provide client có type bằng InjectionKey; không gắn $http lên globalProperties cho việc mới. SSR là một app mỗi request. Cache ở module scope rò user A sang user B. Không cần app thì là composable. Mình compose errorHandler, không thay. SDK chỉ chạy trên client thì để sau plugin .client hoặc import.meta.client. Trên sản phẩm admin, plugin HTTP tạo client trong install để hai request không share được.

Nhớ nhanh

  • Plugin = install per-app; không share cache ở module scope.
  • provide + InjectionKey cho API mới, không dùng globalProperties.
  • Không cần app thì không phải plugin.
  • Compose errorHandler; đừng thay.

Câu hỏi nối tiếp thường gặp

  • Bọc analytics SDK chỉ chạy client? Queue event tới khi có consent, load trong plugin .client, không load lúc import.
  • Plugin khác composable? Không cần app thì là composable.
  • Test thế nào? createApp, use, rồi provide trong harness: hai user giả không được rò sang nhau.
  • Vì sao không thay errorHandler? Bạn nuốt handler của app. Compose: gọi cả hàm trước đó.

Tóm tắt: Template là mặc định vì bạn muốn compiler: hoist, patch flag, v-model. Xuống h() hoặc JSX cho library headless và tag chọn lúc runtime. JSX không nhanh hơn trong Vue.

Vì sao quan trọng: Đồng nghiệp viết lại design system sang JSX “vì React nhanh hơn”. Đó là câu sai trong Vue. Người phỏng vấn muốn nghe khi nào template thắng, và khi nào h() mới là lựa chọn đúng.

  1. Cách hoạt động: Compiler biến template thành hàm render với hoist tĩnh và patch flag. h() viết tay thường mất những flag đó, trừ khi bạn làm lại đúng output của compiler.
  2. Dùng h() / JSX cho library headless (props vào, slot hoặc VNode ra), map tag chọn lúc runtime, hoặc khi children đã là VNode. Wrapper mỏng là h(resolveDynamicComponent(tag), attrs, slots).
  3. Vue JSX là Vue-specific: v-model, v-show, và onUpdate:modelValue là transform của compiler, không phải HTML onclick. Functional component Vue 3 chỉ là hàm trả VNode, không còn myth functional: true của Vue 2.
  4. Gọi slots.default() sai chỗ thì phá tối ưu slot đã compile và v-if trong slot. v-memo không có tương đương thật trong h() viết tay.
import { h, resolveDynamicComponent, type FunctionalComponent } from 'vue'

export const Dynamic: FunctionalComponent<{ tag: string }> = (
  props,
  { attrs, slots },
) => {
  // tag chọn lúc runtime; template không viết được gọn
  const Comp = resolveDynamicComponent(props.tag)
  return h(Comp, attrs, slots)
}
Dynamic.props = ['tag']
// Ưu tiên <component :is> trong UI app để compiler giữ patch flag.
// Wrapper này cho library headless đã có VNode sẵn.

Điều gì xảy ra: resolveDynamicComponent biến string hoặc component thành thứ h() render được. UI app vẫn nên dùng <component :is> để giữ patch flag. Wrapper này dành cho library đã có VNode sẵn.

KiểuĐượcMất
TemplateTối ưu compiler, dễ cho designer, tooling SFCKhó với children hoàn toàn dynamic
h()Kiểm soát tối đa, wrapper mỏngKhó đọc cho UI thật, dễ mất patch flag
JSXDễ đọc hơn h() lồng nhauThêm toolchain, semantics Vue-specific

Góc nhìn senior

  • Template thắng vì compiler. “JSX nhanh hơn” là sai trong Vue.
  • Xuống h() cho library headless hoặc tag chọn lúc runtime. Component renderless với scoped slot thường rõ hơn JSX.
  • h() viết tay thường mất patch flag. Viết lại design system sang JSX vì quen tay sẽ làm chậm update và có thể gãy inheritAttrs.
  • Functional component Vue 3 chỉ là hàm, không còn myth hiệu năng functional: true.
  • Số liệu profiler bằng nhau thì giữ template cho team. Ở admin nội bộ, table vẫn là template; chỉ wrapper heading dynamic dùng h().

Lỗi hay gặp

  • Triệu chứng: list update chậm hơn sau khi viết lại JSX. Nguyên nhân: mất patch flag. Cách xử lý: giữ template.
  • Triệu chứng: v-if trong slot không ẩn. Nguyên nhân: gọi slots.default() sai chỗ. Cách xử lý: truyền slot qua h().
  • Triệu chứng: table innerHTML vì h() thấy dài dòng. Nguyên nhân: sai công cụ. Cách xử lý: template, hoặc scoped slot.
  • Triệu chứng: mong tốc độ functional component Vue 2. Nguyên nhân: myth. Cách xử lý: hàm trả VNode, không có fast path đặc biệt.

Người phỏng vấn hỏi gì

Khi nào template tốt hơn, và khi nào xuống h() hoặc JSX cho component headless hoặc children dynamic. Đồng nghiệp viết lại design system sang JSX “vì React nhanh hơn”. Đó là câu sai trong Vue.

Trả lời khi phỏng vấn

Template là mặc định vì mình muốn compiler: hoist, patch flag, v-model. Mình xuống h() hoặc JSX cho library headless hoặc tag chọn lúc runtime. JSX nhanh hơn là sai trong Vue; h() viết tay thường mất patch flag. Functional component Vue 3 chỉ là hàm, không còn myth functional true. Renderless với scoped slot thường rõ hơn JSX. Số liệu profiler bằng nhau thì mình giữ template cho team. Ở admin nội bộ chỉ wrapper heading dynamic dùng h().

Nhớ nhanh

  • Template thắng vì compiler; JSX không nhanh hơn trong Vue.
  • h() / JSX cho lib headless và tag chọn lúc runtime.
  • Không còn myth hiệu năng functional: true của Vue 2 trong Vue 3.
  • Renderless với scoped slot thường rõ hơn JSX.

Câu hỏi nối tiếp thường gặp

  • Component is khác h(tag)? <component :is> giữ flag compiler. h() dành cho library đã có VNode sẵn.
  • Tương đương v-memo trong h()? Gần như không. Giữ template nếu cần v-memo.
  • Publish library không dùng Vue compiler? Ship hàm render. UI app vẫn có thể là SFC.
  • slots.default() sai chỗ? Phá tối ưu slot đã compile và v-if trong slot.

Tóm tắt: Provide/inject là contract của một subtree (form, tabs, theme). Pinia là state toàn app. Provide readonly ref kèm method tường minh, không phải object mutable thô.

Vì sao quan trọng: Prop drilling là đề bài. Một leaf mutate object reactive đã inject, owner của form bất ngờ. Cốt lõi là ai chịu trách nhiệm phần ghi.

  1. Cách hoạt động: Ancestor provide một giá trị trong setup. Descendants inject trong setup. Inject không reactive nếu bạn provide giá trị không reactive.
  2. Dùng symbol InjectionKey. String key đụng nhau xuyên library. Provide readonly ref kèm method (setTheme), trừ khi child là writer được chỉ định.
  3. Default object trong inject(key, default) phải là factory (inject(key, () => ..., true)). Một default mutable share sẽ alias mọi instance.
  4. Dùng provide như store toàn cục từ App.vue thì rò trên SSR, test khó, và dễ dính update vòng. Pinia (hoặc Nuxt useState) chịu trách nhiệm state toàn app / theo request.
import { provide, inject, readonly, ref, type InjectionKey, type Ref } from 'vue'

export const FormKey: InjectionKey<{
  disabled: Readonly<Ref<boolean>>
  setDisabled: (v: boolean) => void
}> = Symbol('form')

export function provideForm() {
  const disabled = ref(false)
  provide(FormKey, {
    disabled: readonly(disabled),
    setDisabled: (v) => { disabled.value = v },
  })
}

export function useForm() {
  // default là factory: đừng share một object xuyên instance
  return inject(FormKey, () => ({
    disabled: readonly(ref(false)),
    setDisabled: () => {},
  }), true)
}

Điều gì xảy ra: Form chịu trách nhiệm disabled. Leaf đọc được và gọi setDisabled, nhưng không thay được ref. FormKey là symbol, nên string "form" của library khác không đụng. Test truyền fake provide.

Provide/injectPiniaProps
Phạm viCây VueApp (hoặc request SSR)Parent–child
Dep ẩnCóImport tường minhKhông
SSRPer app nếu tạo trong setupHướng dẫn chính thứcDễ
TestPhải bọcsetActivePiniaTruyền props

Góc nhìn senior

  • Contract subtree khác Pinia toàn app: ai chịu trách nhiệm phần ghi. Leaf mutate object đã inject là kịch bản lỗi.
  • Provide readonly kèm method. String key đụng nhau; dùng symbol InjectionKey.
  • Default object của inject phải là factory, không thì mọi instance share một default mutable.
  • Optional inject cho component chạy standalone hoặc trong group. Storybook không mount được child với fake provide thì contract đang giấu quá.
  • Nuxt useState thường thắng provide theo request tự viết. Ở admin nội bộ, form context là provide/inject; user session là Pinia.

Lỗi hay gặp

  • Triệu chứng: child ghi nhưng không cập nhật. Nguyên nhân: provide giá trị thường. Cách xử lý: provide một ref / reactive.
  • Triệu chứng: mọi form dùng chung disabled. Nguyên nhân: default là object, không phải factory. Cách xử lý: inject(key, () => ..., true).
  • Triệu chứng: SSR rò giữa các user. Nguyên nhân: provide từ singleton module. Cách xử lý: tạo trong setup / Pinia.
  • Triệu chứng: test không mount được leaf. Nguyên nhân: inject ẩn, không có default. Cách xử lý: optional inject hoặc wrapper test.

Người phỏng vấn hỏi gì

InjectionKey có type, mutability, khác Pinia, và testing. Prop drilling là đề bài; một leaf mutate object reactive đã inject, owner của form bất ngờ. Cốt lõi là ai chịu trách nhiệm phần ghi.

Trả lời khi phỏng vấn

Provide/inject là contract của một subtree (form context, tabs, theme), không phải store toàn cục. Pinia là state toàn app, sống lâu hơn cây. Mình provide readonly ref kèm method tường minh; leaf mutate object đã inject chính là kịch bản lỗi. String key đụng nhau; mình dùng symbol InjectionKey. Default object trong inject phải là factory, không thì mọi instance share một default mutable. Storybook không mount được child với fake provide thì contract đang giấu quá. Ở admin nội bộ, form context là provide/inject; user session là Pinia.

Nhớ nhanh

  • Contract subtree khác Pinia toàn app: ai chịu trách nhiệm phần ghi.
  • Provide readonly kèm method; dùng symbol InjectionKey, không dùng string.
  • Default object của inject phải là factory.
  • Test không fake provide được thì contract đang giấu quá.

Câu hỏi nối tiếp thường gặp

  • Headless Listbox? Nó provide register / highlight xuống cây để ô vẫn dumb.
  • Vì sao Pinia không phải provide/inject thêm vài bước? Devtools, SSR, setActivePinia, và import tường minh.
  • Optional inject? Factory default để component chạy standalone hoặc trong group.
  • Nuxt useState khác provide? Scoped theo request, serialize trong payload: hơn provide tự viết từ App.vue.

Tóm tắt: Guard Vue Router là UX, không phải auth thật. Một beforeEach hydrate auth một lần, đọc to.meta, và return một location. API vẫn phải authorize.

Vì sao quan trọng: Mọi navigation đều hit /me, next() kiểu Vue Router 3 bị gọi hai lần, và meta.roles bị coi là security trong khi API admin để mở.

  1. Cách hoạt động: Một beforeEach global đợi hydrate auth một lần, đọc to.meta.requiresAuth / roles, rồi return một location redirect hoặc false. Vue Router 4: return location, đừng mix next().
  2. Walk to.matched cho meta lồng nhau: child có thể yêu cầu auth dù layout phía trên không yêu cầu. beforeEnter per-route cho case đặc biệt. onBeforeRouteLeave cho việc chưa lưu, không phải auth.
  3. Guard chạy trên client (và trên SSR nếu dùng Vue Router với SSR / Nuxt). Chúng không phải ranh giới security. Token localStorage không có trên server; cookie thì có.
  4. Hydrate trong guard và trong App.vue mà không lock thì nhân đôi /me. Return { path } trùng to.fullPath thì loop. afterEach không cancel được: dùng cho title và analytics.
router.beforeEach(async (to) => {
  const auth = useAuthStore()
  if (!auth.ready) await auth.hydrate() // một lần mỗi session, không phải mỗi navigation
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }
  const roles = to.matched
    .flatMap((r) => (r.meta.roles as string[] | undefined) ?? [])
  if (roles.length && !roles.some((r) => auth.roles.includes(r))) {
    return { path: '/403' }
  }
  // Vue Router 4: return location hoặc không return gì, đừng gọi next()
})

Điều gì xảy ra: Store hydrate một lần. meta.roles lồng nhau được gom từ to.matched. Thiếu role thì return /403. Không có next(): mix với return sẽ loop hoặc cảnh báo.

TầngViệcKhông phải việc
beforeEach globalHydrate một lần, đọc meta, returnGod function gồm năm global
meta trên recordData-driven, dùng được với to.matchedAuthorization thật
Guard cấp componentViệc chưa lưu (onBeforeRouteLeave)Auth: quá muộn, nháy UI

Góc nhìn senior

  • Guard là UX. API vẫn phải authorize. meta.roles không phải security.
  • Hydrate auth một lần. Hit /me mọi navigation thì nháy UI và bị rate-limit.
  • Vue Router 4 return một location. Mix next() với return thì loop. Walk to.matched cho meta lồng nhau.
  • Auth cấp component quá muộn và làm nháy UI được bảo vệ. Token localStorage không có trên server; cookie thì có.
  • Ở admin nội bộ, một beforeEach đợi auth.ready, rồi đọc meta. API vẫn check role mọi mutation.

Lỗi hay gặp

  • Triệu chứng: loop redirect /login → /login. Nguyên nhân: return cùng to.fullPath. Cách xử lý: path login riêng và query redirect.
  • Triệu chứng: /me mỗi lần click. Nguyên nhân: hydrate trong guard và trong App.vue không lock. Cách xử lý: một cờ auth.ready.
  • Triệu chứng: Vue Router cảnh báo next(). Nguyên nhân: next() kiểu Vue 3 mix với return. Cách xử lý: chỉ return.
  • Triệu chứng: UI được bảo vệ bị nháy. Nguyên nhân: auth trong onMounted. Cách xử lý: guard global.

Người phỏng vấn hỏi gì

Cách bạn gate route trong Vue SPA (không Nuxt). Mọi navigation hit /me, next() bị gọi hai lần, và meta.roles bị coi là security trong khi API admin để mở. Họ muốn hydrate một lần, return khác next(), và “guard là UX”.

Trả lời khi phỏng vấn

Guard là UX, không phải auth thật. API vẫn phải authorize. Một beforeEach hydrate auth một lần, đọc to.meta, rồi return location. Mình không mix next() kiểu Vue Router 3 với return; gọi next() hai lần thì loop. Mình walk to.matched cho meta lồng nhau. Token localStorage không có trên server; cookie thì có. Auth cấp component làm nháy UI được bảo vệ. Hit /me mọi navigation chính là kịch bản lỗi. Ở admin nội bộ, guard đợi auth.ready và API vẫn check role.

Nhớ nhanh

  • Guard là UX; API phải authorize.
  • Hydrate một lần; Vue Router 4 thì return, đừng mix next().
  • Walk to.matched cho meta lồng nhau.
  • Khóa hydrate để /me không bị nhân đôi.

Câu hỏi nối tiếp thường gặp

  • onBeforeRouteUpdate khác :key? Update giữ instance (tốt cho tab). key remount khi entity id đổi.
  • Feature-flag một route mà không ship chunk? Lazy import sau flag vẫn không phải security: chỉ ẩn UX.
  • Map sang Nuxt? Route middleware cho UX; Nitro server/middleware mới là ranh giới thật.
  • Cùng fullPath thì loop? Guard redirect tới path mà guard sẽ redirect tiếp. Đổi path, hoặc bỏ qua khi đã ở login.

Tóm tắt: Default slot cho case phổ thông, named slot cho vùng layout, scoped slot đưa data child đang giữ (row, open, error) lên parent. Cách đó hơn một rừng boolean prop.

Vì sao quan trọng: Một mega-Table với 40 boolean prop (showAvatar, avatarRounded, …) là đề bài. Họ muốn scoped slot khác render prop, forward slot, và hệ quả lúc compile, không phải slot="header".

  1. Cách hoạt động: Prop của scoped slot là contract của child. Đổi shape là breaking API. Headless component là scoped slot kèm provide.
  2. v-bind="$attrs" không forward slot. Dùng <slot name="x" v-bind="..."/> hoặc duyệt $slots. Vue 3 dùng v-slot / #; attribute slot= đã deprecated.
  3. $slots.foo khác $slots.foo() có thể lệch SSR vs client về việc slot có tồn tại hay không. Wrapper thừa có thể phá v-if trên slot và nội dung fallback.
  4. Đừng mutate object row đi qua slot. Design system vẫn có thể có variant pre-styled không slot cho case đóng.
<template>
  <table>
    <thead><slot name="head" /></thead>
    <tbody>
      <tr v-for="row in rows" :key="row.id">
        <slot name="cell" :row="row" :columns="columns">
          <td v-for="col in columns" :key="col.key">{{ row[col.key] }}</td>
        </slot>
      </tr>
    </tbody>
  </table>
</template>
<!-- { row, columns } là breaking API; $attrs không forward slot -->

Điều gì xảy ra: Table chịu trách nhiệm rows. Consumer chịu trách nhiệm UI của ô qua #cell="{ row, columns }". Fallback render <td> thường. Đổi những slot prop đó là breaking change với mọi consumer.

APIKhi nàoGãy khi
Boolean / variant propĐóng, ít variantshowAvatar thứ 40 lên
Named slotVùng layoutBọc outlet thì mất fallback
Scoped slotParent cần data child đang giữBạn đổi shape prop của slot

Góc nhìn senior

  • Scoped slot hơn một rừng boolean prop. Consumer không thay được ô mà không fork thì table đang đóng quá.
  • Shape prop của slot là breaking API. v-bind="$attrs" không forward slot.
  • $slots.foo khác $slots.foo() có thể lệch trên SSR. Template giữ tối ưu compiler; gọi slot trong h() có thể mất.
  • Props cho variant: đơn giản, design đóng. Slot: mở, khó theme, dễ gãy a11y (consumer quên label).
  • Ở admin nội bộ, table user expose #cell và variant dense không slot cho các màn đóng.

Lỗi hay gặp

  • Triệu chứng: 40 boolean prop. Nguyên nhân: mọi tweak visual thành prop. Cách xử lý: scoped slot cho ô.
  • Triệu chứng: wrapper không hiện #header. Nguyên nhân: $attrs không forward slot. Cách xử lý: <slot name="header" />.
  • Triệu chứng: mutate row ở parent thì alias nguồn. Nguyên nhân: slot truyền object gốc. Cách xử lý: xem slot prop là readonly.
  • Triệu chứng: fallback SSR lệch client. Nguyên nhân: $slots.foo khác $slots.foo(). Cách xử lý: một cách check, test dưới SSR.

Người phỏng vấn hỏi gì

Cách bạn thiết kế table / listbox / form field để consumer chịu trách nhiệm UI của ô. Mega-Table với 40 boolean prop là đề bài. Họ muốn scoped slot, forward, và hệ quả compile, không phải slot="header".

Trả lời khi phỏng vấn

Mình dùng default slot cho case phổ thông, named slot cho từng vùng, scoped slot khi parent cần data mà child đang giữ: row, open, error. Cách đó hơn một rừng boolean prop. Shape của slot prop là breaking API. v-bind $attrs không forward slot. $slots.foo khác $slots.foo() có thể lệch trên SSR. Mình không mutate object row đi qua slot. Consumer không thay được ô nếu không fork thì table đang đóng quá. Ở sản phẩm admin, table user expose slot ô và variant dense không slot.

Nhớ nhanh

  • Scoped slot hơn một rừng boolean prop.
  • Shape của slot prop là breaking API; $attrs không forward slot.
  • $slots.foo khác $slots.foo() có thể lệch trên SSR.
  • Đừng mutate object row đi qua slot.

Câu hỏi nối tiếp thường gặp

  • Scoped slot khác provide cho ô nằm sâu? Slot cho một cấp; provide khi ô nằm dưới vài wrapper.
  • defineSlots? Khai báo type cho tên slot và prop của API library.
  • Vì sao variant không slot? Màn hình đóng cần default theme được, không bắt mọi consumer điền #cell.
  • v-if trên slot outlet? Fallback rỗng khác không render: chọn một rồi test dưới SSR.

Tóm tắt: <keep-alive> cache Vue instance trong memory. Key theo entity id, refresh trong onActivated, vì onMounted không chạy lại.

Vì sao quan trọng: Đang ở profile user A, chuyển sang user B, vẫn thấy user A vì instance cache theo tên component. WebSocket mở trong onMounted không bao giờ đóng.

  1. Cách hoạt động: Dùng keep-alive khi teardown đắt (map, form nặng, tab panel) và user muốn giữ state. include / exclude / max (LRU) kiểm soát cache.
  2. Key instance cache theo entity id khi cùng kiểu component tái dùng cho record khác. include khớp tên component: <script setup> cần defineOptions({ name }), không thì không bao giờ cache.
  3. onMounted không chạy lại. Fetch hoặc refresh trong onActivated nếu data có thể stale. Tạm dừng timer, video, và poll trong onDeactivated; resume trong onActivated.
  4. Remount (:key) luôn mới nhưng mất local UI state. Store draft tường minh vẫn còn dù không keep-alive. Không đặt max rồi giữ 50 record là heap leak.
<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'
defineOptions({ name: 'CustomerPanel' }) // include khớp tên này
const props = defineProps<{ entityId: string }>()
onActivated(() => refetchIfStale(props.entityId))
onDeactivated(() => pausePolls())
</script>
<template>
  <!-- parent: <keep-alive :max="8"><CustomerPanel :key="id" /></keep-alive> -->
  <form><!-- local UI state vẫn còn; onMounted không chạy lại --></form>
</template>

Điều gì xảy ra: :key="id" biến user A và user B thành hai mục cache khác nhau. onActivated refetch nếu stale. onDeactivated tạm dừng poll để tab ẩn không giữ socket. max="8" là LRU.

Chiến lượcStateRủi ro
Remount :key="id"Luôn mớiMất local UI; tốn setup
keep-aliveGiữ được khi backData stale + listener leak
Store draftGiữ được không cần keep-aliveThêm code

Góc nhìn senior

  • Key theo entity id để user B không thấy user A. Cache identity mới là thứ họ hỏi.
  • onMounted không chạy lại: refetch trong onActivated, tạm dừng poll trong onDeactivated.
  • max là LRU; không đặt max rồi giữ 50 record là heap leak. include khớp tên component: defineOptions trong <script setup>.
  • :key="route.fullPath" có thể quá mạnh (query string); params.id có thể đúng hơn.
  • Store draft vẫn còn dù không keep-alive. Ở sản phẩm admin, panel customer dùng max={8} và refetch trong onActivated.

Lỗi hay gặp

  • Triệu chứng: user B hiện user A. Nguyên nhân: cache theo tên component, không key entity. Cách xử lý: :key="entityId".
  • Triệu chứng: heap tăng sau 50 record. Nguyên nhân: không đặt max. Cách xử lý: max LRU.
  • Triệu chứng: include không bao giờ khớp. Nguyên nhân: <script setup> không có name. Cách xử lý: defineOptions({ name }).
  • Triệu chứng: WebSocket không bao giờ đóng. Nguyên nhân: mở trong onMounted. Cách xử lý: tạm dừng trong onDeactivated.

Người phỏng vấn hỏi gì

Wizard theo tab và route được cache. Profile user A, chuyển sang user B, vẫn thấy user A vì instance cache theo tên component. WebSocket mở trong onMounted không bao giờ đóng. Họ muốn cache identity, onActivated, memory, và data stale.

Trả lời khi phỏng vấn

keep-alive khi teardown đắt và user muốn giữ state. Mình key theo entity id để user B không thấy user A. onMounted không chạy lại: mình refetch trong onActivated và tạm dừng poll trong onDeactivated. max là LRU; không đặt max rồi giữ 50 record là heap leak. include khớp tên component, nên script setup cần defineOptions. Store draft vẫn còn dù không keep-alive. Ở sản phẩm admin, panel customer dùng max 8 và refetch trong onActivated.

Nhớ nhanh

  • Key theo entity id; onMounted không chạy lại: dùng onActivated.
  • max là LRU; không đặt max thì leak instance.
  • include khớp tên component: defineOptions trong <script setup>.
  • Tạm dừng poll và socket trong onDeactivated.

Câu hỏi nối tiếp thường gặp

  • Suspense kèm setup async? Dễ ra hai UI loading hoặc cây pending bị cache. Ưu tiên data cấp route.
  • Vì sao max làm mất form draft? LRU đuổi instance cũ nhất. Persist draft trong store.
  • Nuxt keepalive? definePageMeta({ keepalive: true }) cùng ý tưởng ở cấp trang.
  • fullPath khác params.id làm key router-view? fullPath remount khi query đổi; params.id thường là entity.

Tóm tắt: Mặc định tách theo route. defineAsyncComponent cho island nặng trong trang. Ship timeout, errorComponent, và delay để load nhanh không nháy spinner.

Vì sao quan trọng: Parent SFC import editor “chỉ để lấy type” và chunk không tách. CDN giật một cái làm trắng dashboard mãi. Loading, error, timeout, và chỗ nó đứng so với tách cấp route.

  1. Cách hoạt động: Router / trang Nuxt import() tách tốt nhất cho app. defineAsyncComponent mịn hơn: editor trong modal. Import chỉ type là import type để Vite không kéo lib.
  2. Async component là một wrapper type; keep-alive và DevTools dùng wrapper đó. Kết hợp Suspense bạn có thể ra hai UI loading. Chọn một.
  3. Không errorComponent, loader throw sẽ lỗi parent. CDN giật một cái làm trắng widget mãi. delay tránh nháy spinner khi cache hit nhanh.
  4. SSR: async component vẫn phải resolve trên server nếu HTML nên gồm chúng; không thì ClientOnly. Prefetch lúc hover hay đợi open là lựa chọn product.
import { defineAsyncComponent, type Component } from 'vue'
import EditorError from './EditorError.vue'
import EditorSkeleton from './EditorSkeleton.vue'

export const Editor: Component = defineAsyncComponent({
  loader: () => import('./HeavyEditor.vue'),
  delay: 200, // load nhanh: không nháy spinner
  timeout: 10_000,
  errorComponent: EditorError,
  loadingComponent: EditorSkeleton,
})
// parent: import type { EditorProps } from './HeavyEditor.vue'
// value import HeavyEditor sẽ kéo chunk vào trang này

Điều gì xảy ra: Chunk editor load khi modal cần. Delay 200 ms bỏ skeleton khi cache đã hit. Timeout CDN render EditorError thay vì làm trắng trang. Type vẫn là type-only.

TáchMức táchKịch bản lỗi
Router / trang Nuxt import()Tốt nhất cho appLayout quá nặng vẫn trên critical path
defineAsyncComponentIsland trong trangKhông errorComponent làm trắng parent
Nuxt Lazy / delayed hydrationIsland đã có HTMLVẫn phải khớp cho hydration

Góc nhìn senior

  • Tách theo route là mặc định. defineAsyncComponent là island trong trang: editor trong modal.
  • Luôn ship errorComponent kèm delay. Dùng import type để parent không kéo lib.
  • Kết hợp Suspense có thể ra hai UI loading. SSR vẫn phải resolve component nếu HTML nên gồm nó.
  • manualChunks khi hai async component nên share vendor. Prefetch lúc hover hay đợi open là lựa chọn product.
  • Ở sản phẩm admin, editor báo cáo là async trong modal; bản thân route là import trang Nuxt.

Lỗi hay gặp

  • Triệu chứng: chunk không bao giờ tách. Nguyên nhân: value import “để lấy type”. Cách xử lý: import type.
  • Triệu chứng: dashboard trắng vì CDN giật. Nguyên nhân: không errorComponent. Cách xử lý: ship một cái kèm timeout.
  • Triệu chứng: hai skeleton. Nguyên nhân: Suspense kèm loadingComponent. Cách xử lý: chọn một.
  • Triệu chứng: nháy spinner khi load cache. Nguyên nhân: delay là 0. Cách xử lý: ~200 ms.

Người phỏng vấn hỏi gì

Cách bạn tách widget nặng (chart, editor, admin panel) mà không trắng trang khi lỗi. Parent import editor “chỉ để lấy type” và chunk không tách. CDN giật một cái làm trắng dashboard mãi.

Trả lời khi phỏng vấn

Mình mặc định tách theo route. defineAsyncComponent dành cho đảo nặng trong trang (editor trong modal), kèm timeout, errorComponent, và delay để load nhanh không nháy. Import lib ở top parent chỉ để lấy type thì chunk không bao giờ tách; mình dùng import type. Ghép với Suspense có thể ra hai UI loading. CDN giật mà không có errorComponent thì widget trắng mãi. SSR vẫn phải resolve component nếu HTML cần có nó. Ở admin nội bộ, editor báo cáo là async trong modal.

Nhớ nhanh

  • Mặc định tách theo route; defineAsyncComponent cho đảo trong trang.
  • Luôn ship errorComponent + delay; dùng import type.
  • Suspense + async component có thể nhân đôi UI loading.
  • SSR vẫn phải resolve đảo nếu HTML cần có nó.

Câu hỏi nối tiếp thường gặp

  • Prefetch lúc hover khác đợi open? Hover nếu khả năng mở cao; đợi nếu đảo hiếm và nặng.
  • Share chunk? manualChunks (hoặc vendor chung) khi hai đảo import cùng editor.
  • Khác island Nuxt? Lazy prefix và delayed hydration giữ HTML; async component thì thay HTML đó.
  • Khi nào SSR vẫn phải resolve? Khi HTML cần có widget. Không thì ClientOnly.

Tóm tắt: HTML first-paint phải deterministic, khớp first render phía client. ClientOnly dành cho đảo chỉ chạy trên client (map, chart), không phải để giấu bug.

Vì sao quan trọng: “DOM không khớp”. v-if="isMobile" false trên server và true sau khi sniff user-agent. Date format theo TZ của server. Một <div> nằm trong <p> bị browser “sửa”. Đây là topic senior trên production.

  1. Cách hoạt động: Vue hydrate tại chỗ. Mismatch thì vứt DOM server của subtree đó (dev thì warn; production thì bạn trả thêm một client render, flicker, hoặc listener gãy).
  2. Date.now(), Math.random(), window, locale, auth chỉ trên client, và HTML không hợp lệ (<div> trong <p>, <table> lồng sai) đều lệch. Sửa từ nguồn.
  3. Browser “sửa” HTML SSR không hợp lệ; cây VNode của Vue thì không. Script third-party mutate DOM trước hydration (A/B, chat) cũng cùng loại đó.
  4. Vue 3.4+ data-allow-mismatch là lối thoát phẫu thuật cho khác biệt text đã biết, không phải phép bỏ qua bug cấu trúc. useId() hơn hẳn counter tự viết, thứ hay đụng xuyên request.
<script setup lang="ts">
import { useId } from 'vue'
const labelId = useId() // ổn định theo vnode; đừng Date.now()
const now = Date.now() // lệch: ms server !== ms client
</script>
<template>
  <label :for="labelId">Name</label>
  <p>{{ now }}</p>
  <!-- HTML không hợp lệ: browser đóng <p> trước <div>; cây VNode thì không -->
  <ClientOnly>
    <Chart />
  </ClientOnly>
</template>

Điều gì xảy ra: useId() khớp xuyên server và client. Date.now() thì không: Vue sẽ vứt text node đó và render lại. ClientOnly bỏ hydration cho chart, nên SEO của đảo đó mất có chủ đích.

Cách xử lýGiữ HTMLChi phí
Làm deterministicCóPhải share locale / flag / payload
ClientOnlyKhông (đảo đó)SEO/content của subtree mất
Lazy / delayed hydrationCóVẫn phải khớp; interactivity tới sau
data-allow-mismatchCóChỉ text kiểu phẫu thuật, không phải bug cấu trúc

Góc nhìn senior

  • Sửa từ nguồn. ClientOnly cho map hoặc chart, không phải để giấu bug.
  • HTML không hợp lệ là ticket “Vue hỏng” phổ biến nhất: browser viết lại <div> trong <p>; cây VNode thì không.
  • Delayed / lazy hydration (Nuxt) vẫn phải khớp. data-allow-mismatch là phẫu thuật, Vue 3.4+.
  • useId() khác counter không reset mỗi request (đụng xuyên user). Payload Nuxt ngăn “fetch lại, JSON khác”.
  • Debug bằng warning trên staging và visual diff HTML SSR với client. Ở admin nội bộ, mình SSR heading user với flag từ payload, còn chart thì ClientOnly.

Lỗi hay gặp

  • Triệu chứng: lệch timestamp. Nguyên nhân: Date.now() / new Date() lúc render. Cách xử lý: format từ payload, hoặc ClientOnly.
  • Triệu chứng: “Vue hỏng” sau <div> trong <p>. Nguyên nhân: browser sửa HTML không hợp lệ. Cách xử lý: markup hợp lệ.
  • Triệu chứng: id đụng xuyên user. Nguyên nhân: counter module-scope trên SSR. Cách xử lý: useId().
  • Triệu chứng: script A/B viết lại DOM trước hydrate. Nguyên nhân: third-party mutate. Cách xử lý: hoãn script đến sau hydrate.

Nhà tuyển dụng hay hỏi gì

“DOM không khớp”. v-if="isMobile" khác sau khi sniff user-agent; date format theo TZ của server; <div> trong <p> bị browser “sửa”. Họ muốn nghe nguyên nhân, cách debug, và ClientOnly khác với việc sửa HTML.

Trả lời khi phỏng vấn

HTML first-paint phải deterministic, khớp first render phía client. Date.now, Math.random, window, auth chỉ trên client, và HTML không hợp lệ đều lệch. Mình sửa từ nguồn. ClientOnly cho map hoặc chart, không phải để giấu bug. Browser sửa HTML SSR không hợp lệ; cây VNode thì không. Đó là ticket Vue hỏng phổ biến nhất. data-allow-mismatch là phẫu thuật. Lazy hydration vẫn phải khớp. Mình debug bằng warning trên staging và visual diff HTML SSR với client. Ở admin nội bộ, mình SSR heading từ payload, còn chart thì ClientOnly.

Nhớ nhanh

  • HTML và first render phía client phải khớp; sửa từ nguồn.
  • ClientOnly dành cho đảo chỉ chạy trên client, không phải để giấu bug.
  • HTML không hợp lệ là ticket “Vue hỏng” phổ biến nhất.
  • useId() hơn hẳn counter module-scope trên SSR.

Câu hỏi nối tiếp thường gặp

  • Payload Nuxt? Hai phía render cùng JSON. Fetch client lần hai mà trả data khác sẽ lệch.
  • Vì sao data-allow-mismatch là last resort? Nó giấu khác biệt text đã biết. Bug cấu trúc vẫn vứt subtree.
  • Chart? Render SVG/image tĩnh trên server, hoặc đừng SSR chúng (ClientOnly).
  • div trong p? Browser đóng <p> trước. Cây VNode của Vue vẫn lồng, nên hydrate thất bại.