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.
- 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). openBlocklấy snapshot cấu trúc con ổn định, để runtime chỉ patch binding đã gắn flag.NEED_HYDRATIONlà tên Vue 3.4 của flag cũHYDRATE_EVENTS.v-if/v-forở gốc block,v-bind="obj", vàv-htmlbuộc flag kém chi tiết hơn. Render function và JSX mất hint của compiler.v-forkhô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 / hoist | Bỏ qua phần tĩnh | Bottleneck là layout / network |
v-memo / v-once | Đóng băng subtree ổn định | Điều kiện sai → UI stale |
| Virtualize | Render hàng đang thấy | Và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-forkhô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ộcFULL_PROPS. - Virtualize hàng nghìn hàng.
v-memodà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-forkhông key. Cách xử lý::keyổn định.- Triệu chứng:
v-memohiệ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_HYDRATIONlà tên Vue 3.4 củaHYDRATE_EVENTS.v-forkhô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-forkhô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.
- Cách hoạt động: Mixin gãy ở namespace (hai key
data()đụng nhau), origin (DevTools không cho biết mixin nào injectloading), và thứ tự merge lifecycle. Composable trả binding tường minh và tree-shake. - Composition +
<script setup>cho code mới và mọi thứ cắt ngang options. Options giữ cho wrapper icon và chrome tĩnh. - 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. - Composable gọi
getCurrentInstance()chính là mixin phiên bản mới. Filter là hàm. Đừng dùngthistrong 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ần | Chọn | Tránh |
|---|---|---|
| Mới / cắt ngang | Composition + script setup | Mixin mới |
| SFC leaf nhỏ | Options vẫn ổn | Composable một dòng |
| Monolith Vue 2 | Extract theo feature | Viế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ầndefineComponent. - 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.
- Cách hoạt động: Plugin quét
components/và sinh import kèmcomponents.d.ts. Nuxt làm tương tự cho API Vue và composable. Resolver tham lam: khớp tên là kéo file. - Cycle (
useCart→useUser→useCart) thànhundefinedhoặc lỗi TDZ lúc load lần đầu. Auto-import giấu chúng; không phá được chúng. - Barrel
indexre-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. - Với library bạn publish, đừng dựa auto-import của consumer. Tên đụng (
Modaltừ 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ại | Import | Vì sao |
|---|---|---|
| UI trong app | Auto-import | DX; colocated |
| Package public | Tường minh | Tree-shake; public API |
| Plugin có side effect | Tường minh | Resolver 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→useCarthiệnundefinedhoặc TDZ lúc load lần đầu. - Barrel design system từ một
indexlàm hỏng tree-shaking. Export từng entry có tên. - Coi
components.d.tslà 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:
useCartundefined 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ý:directoryAsNamespacehoặc import tường minh.- Triệu chứng:
components.d.tslệ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
indexre-export hết thì tree-shaking mất tác dụng. - Giữ
components.d.tskhớ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?
directoryAsNamespacehoặ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.
- Cách hoạt động: Vue 3 bỏ mặc định
value/inputcủa Vue 2 trừ element native.v-model:foođặt tên cũng chỉ là một model khác. - 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. v-modeldo parent chịu trách nhiệm sẽ re-render parent mỗi lần gõ, trừ khi bạn debounce emit. Gắnv-modelthẳng vào field Pinia thì mỗi lần gõ phát tới mọi subscriber.- 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õ.
| Binding | Hướng | Quy tắc |
|---|---|---|
v-bind / : | Một chiều xuống | Child không được ghi |
v-model | Xuống + emit lên | Parent (hoặc lib form) chịu trách nhiệm |
defineModel | Sugar cho v-model | Ra 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-modeltrên prop. Cách xử lý:defineModelhoặ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-modeltrên field Pinia. Cách xử lý: bản nháp local, emit lúc blur.- Triệu chứng:
.trimkhô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/inputVue 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-modelmột prop; emit hoặcdefineModel(). 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-modelthẳ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:firstNamevàv-model:lastNamelà hai model. - Mặc định Vue 2 khác 3? Vue 3 dùng
modelValue/update:modelValuetrê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.
- 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.
- Attr fallthrough áp vào root trừ khi
inheritAttrs: false. Prop không khai báo thành attr và bypass contract có type. - 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.
- Prop boolean:
disabledkhông có value thì làtrue.withDefaultsđi cùngdefineProps<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 vi | Mutate? |
|---|---|---|
| Props | Parent → child | Không, emit |
| Provide/inject | Một cây | Chỉ đọc trừ khi ghi rõ |
| Pinia | Toàn app | Qua 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: falsekè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:
disabledlà 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ì
disabledthà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.
- 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.
- 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. - Truyền đối số (
heavy(item)) thì không thể là computed. Tính trướcMap/Setmột lần, hoặc list row đã decorate bằng computed. - Viết computed để cache reverse index; đừng
list.filtertrong 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ại | Chạy | Dùng |
|---|---|---|
| Computed | Khi một dep đổi | Giá trị suy ra |
| Method | Mọi lần gọi / render | Click, submit |
| Watch | Khi nguồn đổi | Side 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)trongv-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.
- Cách hoạt động:
watch(source, cb)chạy callback khisourceđổi.watchEffect(cb)track những gì callback đọc rồi chạy lại. Computed cache một value; watch thì không. 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.- Watch ghi value mà watch khác đang đọc thì thành vòng lặp.
fullNamenên là computed. Hủy việc đang chạy trong watch (search race). watchEffectdễ subscribe quá tay. Trong app nên dùngwatchtường minh. Unmount thì stop watch:watchtrongsetupđã đượ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ề value | Side effect |
|---|---|---|
| Computed | Có, được cache | Không được |
watch | Không | Có: fetch, DOM, log |
watchEffect | Không | Có, 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.
watchEffectgiấu dep. Trong app nên dùngwatchtường minh.flush: 'post'chính là nextTick lặp lại.syncgắ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:
fullNamevà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:
watchEffectchạy lại quá nhiều. Nguyên nhân: đọc ẩn thêm. Cách xử lý:watchtườ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
watchtường minh hơnwatchEffect.
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.
- 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ếpfnthành microtask sau flush đó, cùng hàng với promise, sau call stack hiện tại. - Bug biến mất trong
nextTicklà phụ thuộc timing. Thay cách vá tạm bằngonMountedhoặcwatch(..., { flush: 'post' })và một test flush Vue. flush: 'post'chính là nextTick lặp lại.requestAnimationFrame/ResizeObserverdành cho layout không phải Vue. SSR cónextTicknhưng không có DOM trình duyệt.- Đừ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ần | Công cụ | Không phải |
|---|---|---|
| Một lần sau lần ghi này | nextTick | setTimeout(0) |
| Mỗi khi source đổi | watch + flush: 'post' | Vá tạm nextTick trong watcher |
| Layout không phải Vue | rAF / ResizeObserver | Flush 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
nextTicklà phụ thuộc timing. Thay bằngonMountedhoặcflush: 'post'. - SSR có
nextTicknhưng không có DOM trình duyệt. Đừng focus trên server. - Đừng await
nextTicktrong 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ý:nextTickhoặconMounted.- 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
nextTicklà 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”.
- Cách hoạt động: Destructure
reactivera number thì không được track: dùngtoRefs.watch(toRaw(store.item))sẽ không thấy store cập nhật. Proxy Vue 3 khiến===với object raw fail. - keep-alive không destroy instance;
onMountedsẽ không chạy lại. Fetch trongonActivatedhoặcwatchid.:key="userId"hợp lệ khi identity của entity đổi, không phải reset đại trà. - 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. - 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.
| Class | Triệu chứng | Cách xử lý |
|---|---|---|
| Mất reactivity | UI không cập nhật | toRefs / đọc proxy |
| Cache stale | Khách cũ | onActivated / :key |
| Race | Hiện sai user | AbortController |
| Lệch SSR | Cảnh báo hydration | onMounted / 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
onRenderTrackedlocal. 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
reactivelàm mất tracking; dùngtoRefs. - keep-alive:
onMountedsẽ không chạy lại. DùngonActivated. - 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.
- 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. reactiveở mức module dùng chung giữa các request SSR, trừ khi bạn tạo per app.reactiveunwrap ref lồng nhau;refcủa object reactive thì bọc hai lần.- Ghi tại chỗ trên
shallowRefkhông làm gì đến khitriggerRef. Đặt object reactive làm key trongSet/Mapthì dùng identity Proxy:toRawđể interop. onScopeDisposelà 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.
| API | Khi nào | Bẫy |
|---|---|---|
ref | Thay nguyên value | .value trong script |
reactive | Object nhỏ hay mutate | Singleton module SSR |
shallowRef | Trang lớn bạn swap | Ghi tại chỗ im lặng |
markRaw | Mapbox / Chart / class | Mutate vô hình |
Góc nhìn senior
refthay nguyên;reactivemutate object nhỏ;shallowRef/markRawkhi Proxy sâu gây hại.- Ghi tại chỗ trên
shallowRefkhông làm gì đến khitriggerRef. reactivemứ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:
reactivemứ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
refthay nguyên;reactivemutate object nhỏ;shallowRef/markRawkhi Proxy sâu gây hại.- Ghi tại chỗ trên
shallowRefkhông trigger gì cho đến khi gọitriggerRef. reactivekhai báo ở mức module sẽ rò sang request khác lúc SSR.- Proxy track được key mới;
definePropertycủ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.
- Cách hoạt động: SSR render chạy
setup+onServerPrefetch(và async data của Nuxt). Không chạyonMounted/onUpdated. Hydration phía client chạy lạisetuprồionBeforeMount/onMounted. - Dừng listener trong
onBeforeUnmount, hoặconScopeDisposenếu composable có thể chạy ngoài component. Composable phải gọi sync và không điều kiện trongsetup. onMountedtrong childv-ifchạy khi child được tạo, không phải khi parent mount. keep-alive dùngonActivated, không phảionMountedlần hai.setupasync 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.
| Hook | SSR | Client |
|---|---|---|
setup | Có | Có, rồi hydrate |
onServerPrefetch | Có | Không |
onMounted | Không | Có |
onActivated | Không | chỉ keep-alive |
Góc nhìn senior
onMountedkhông chạy trên server. Câu đó là cả bài phỏng vấn.- DOM / window / widget để trong
onMounted; dispose trongonBeforeUnmount/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 trongsetup. 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ờ
onMountedlầ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
onMountedkhông chạy trên server.- DOM /
window/ widget trongonMounted; dispose lúc unmount. - Composable: sync, không điều kiện, trong
setup. - keep-alive dùng
onActivated, không phảionMountedlầ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.
- Cách hoạt động:
awaittop-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. onMountedkhông bao giờ chạy trên server, nên HTML sẽ không có data.createdcủa Vue 2 thì chạy lúc SSR, nên port máy móc fetch lúcmountedsang Nuxt sẽ làm mất payload.- Lỗi 500 trong setup async kéo sập cả boundary Suspense, không chỉ một field. Fetch trong
setupmà không có cache key thì GET lại khi reuse route. - 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,useAsyncDatacó 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 | Ở đâu | Không |
|---|---|---|
| First paint / SEO / SSR | setup / useAsyncData | chỉ onMounted |
DOM / window | onMounted | Cả hai “cho chắc” |
| Đổi id / unmount | AbortController | Ghi sau thắng |
Góc nhìn senior
- Data first-paint / SSR trong
setuphoặcuseAsyncData; việc DOM trongonMounted. Đừ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.
createdVue 2 chạy lúc SSR;onMountedVue 3 thì không. Port máy móc làm mất payload HTML.useAsyncDatacó 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
onMountedtrê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
setuphoặcuseAsyncData; 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".
- Cách hoạt động: Teleport chuyển DOM của VNode sang
to. Parent Vue vẫn là owner logic, nênprovide, props, và teardown vẫn chạy ở đó. - Trên SSR, target phải có trong HTML server. Teleport tới
#modaltạo trongonMountedsẽ lệch. - 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. disabledgiữ overlay in-flow (test, mobile). Trap focus, trả về opener, và setrole="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ần | Làm | Đừng |
|---|---|---|
| Cắt / stacking | Teleport tới body / #overlays | position: fixed trong card có transform |
| SSR | Target trong HTML server | Tạo #modal trong onMounted |
| Focus | Trap + trả opener | Tab 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
onMountedsẽ 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#overlaysvà 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:
#modaltạo trongonMounted. 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”.
- Cách hoạt động: Child có
setupasync (awaittop-level) sẽ suspend đến khi resolve. Suspense gần nhất hiện#fallback, rồi#default. - Nó experimental; API có thể đổi. Không có API chính thức “reset boundary này”: remount bằng
keyhoặc điều hướng đi. - Suspense không bắt lỗi. Kèm
onErrorCaptured(returnfalsekhi xử lý xong). Suspense lồng có thể giấu fallback của parent. - Server và client phải resolve cùng một cây async, không thì lệch hydrate. Ưu tiên
useAsyncDatacó 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ặn | Lỗi |
|---|---|---|
Vue <Suspense> | Setup async / async component | Không: dùng onErrorCaptured |
Nuxt useAsyncData | Payload route + pending | error ref trên lời gọi |
| Suspense lồng | Fallback bên trong trước | Throw 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,
useAsyncDatacó 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
defineAsyncComponentthì 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
useAsyncDatacó 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.
- 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.
- Hook Vue 3 là
beforeMount/mounted/updated/unmounted. Tên Vue 2bind/insertedkhông còn. Xử lýbinding.valuetrongupdated; đừng đăng ký listener thứ hai. created/mountedkhông chạy trên SSR. DùnggetSSRPropsnếu first paint cần attribute.binding.instancehỏng khi dùng<script setup>.- Luôn gỡ trong
unmounted(vàbeforeUnmountnế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ớiinheritAttrs.
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ạng | Khi nào | Cleanup |
|---|---|---|
| Composable + template ref | State, test, TypeScript | onScopeDispose |
| Directive | Annotation DOM gắn như HTML | unmounted / beforeUnmount |
| Component bọc | Cần ranh giới slot | Unmount 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-outsidechạy trên click vừa mở, hoặc đăng ký hai lần trongupdated, là kịch bản lỗi.created/mountedkhông chạy trên SSR. DùnggetSSRPropskhi first paint cần attribute.- Tên Vue 2
bind/insertedkhông còn. Nhiều codebase cấm directive, trừ a11y hoặc đo DOM. useClickOutside(el, cb)test được quaonScopeDispose. 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ốiv-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:
updatedthêm listener nữa. Cách xử lý: chỉ đổibinding.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.
getSSRPropskhi first paint cần attribute.- Xử lý
binding.valuetrongupdated: đừng đăng ký hai lần. - Hook Vue 3 thay
bind/insertedcủ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.
- Cách hoạt động:
installđăng ký component, provide một client, hoặc thêm directive. Gọiapp.usemột lần trongmain.tshoặc Nuxt plugin, không từ SFC linh tinh. - 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
.clienthoặc sauimport.meta.client. - Không cần
appthì là composable, không phải plugin. Composeapp.config.errorHandlervới handler sẵn có; đừng thay thế. - Ưu tiên
app.providekèmInjectionKeycó type hơnglobalPropertiescho API mới. Plugin Vue 3 không được giả địnhthisở 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ấy | SSR | TS |
|---|---|---|---|
provide / inject | Ngầm | Per-app nếu tạo trong install | InjectionKey |
globalProperties | this.$x (Options) | Dễ dùng sai | Yếu |
| Pinia | useX() tường minh | Module Nuxt chính thức | Mạnh |
| Import module | Tường minh | Không singleton-cache | Mạnh |
Góc nhìn senior
- Plugin cài vào một app instance. Không cần
appthì là composable. providekèmInjectionKeyhơnglobalPropertiescho API mới.$httplà 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.clienthoặcimport.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: đọcwindowlúc import. Cách xử lý:.clienthoặcimport.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.usetừ SFC linh tinh. Cách xử lý: một lần trongmain.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 =
installper-app; không share cache ở module scope. provide+InjectionKeycho API mới, không dùngglobalProperties.- Không cần
appthì 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
appthì là composable. - Test thế nào?
createApp,use, rồiprovidetrong 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.
- 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. - 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). - Vue JSX là Vue-specific:
v-model,v-show, vàonUpdate:modelValuelà transform của compiler, không phải HTMLonclick. Functional component Vue 3 chỉ là hàm trả VNode, không còn mythfunctional: truecủa Vue 2. - Gọi
slots.default()sai chỗ thì phá tối ưu slot đã compile vàv-iftrong slot.v-memokhông có tương đương thật trongh()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 | Được | Mất |
|---|---|---|
| Template | Tối ưu compiler, dễ cho designer, tooling SFC | Khó với children hoàn toàn dynamic |
h() | Kiểm soát tối đa, wrapper mỏng | Khó đọc cho UI thật, dễ mất patch flag |
| JSX | Dễ đọc hơn h() lồng nhau | Thê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ãyinheritAttrs.- 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-iftrong slot không ẩn. Nguyên nhân: gọislots.default()sai chỗ. Cách xử lý: truyền slot quah().- Triệu chứng: table
innerHTMLvì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: truecủ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-iftrong 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.
- Cách hoạt động: Ancestor
providemột giá trị trongsetup. Descendantsinjecttrongsetup. Inject không reactive nếu bạn provide giá trị không reactive. - 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. - Default object trong
inject(key, default)phải là factory (inject(key, () => ..., true)). Một default mutable share sẽ alias mọi instance. - Dùng provide như store toàn cục từ
App.vuethì rò trên SSR, test khó, và dễ dính update vòng. Pinia (hoặc NuxtuseState) 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/inject | Pinia | Props | |
|---|---|---|---|
| Phạm vi | Cây Vue | App (hoặc request SSR) | Parent–child |
| Dep ẩn | Có | Import tường minh | Không |
| SSR | Per app nếu tạo trong setup | Hướng dẫn chính thức | Dễ |
| Test | Phải bọc | setActivePinia | Truyề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
injectphả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
useStatethườ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
injectphả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ở.
- Cách hoạt động: Một
beforeEachglobal đợi hydrate auth một lần, đọcto.meta.requiresAuth/roles, rồi return một location redirect hoặcfalse. Vue Router 4: return location, đừng mixnext(). - Walk
to.matchedchometalồng nhau: child có thể yêu cầu auth dù layout phía trên không yêu cầu.beforeEnterper-route cho case đặc biệt.onBeforeRouteLeavecho việc chưa lưu, không phải auth. - 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
localStoragekhông có trên server; cookie thì có. - Hydrate trong guard và trong
App.vuemà không lock thì nhân đôi/me. Return{ path }trùngto.fullPaththì loop.afterEachkhô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ầng | Việc | Không phải việc |
|---|---|---|
beforeEach global | Hydrate một lần, đọc meta, return | God function gồm năm global |
meta trên record | Data-driven, dùng được với to.matched | Authorization thật |
| Guard cấp component | Việ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.roleskhông phải security. - Hydrate auth một lần. Hit
/memọi navigation thì nháy UI và bị rate-limit. - Vue Router 4 return một location. Mix
next()với return thì loop. Walkto.matchedcho meta lồng nhau. - Auth cấp component quá muộn và làm nháy UI được bảo vệ. Token
localStoragekhông có trên server; cookie thì có. - Ở admin nội bộ, một
beforeEachđợiauth.ready, rồi đọcmeta. 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ùngto.fullPath. Cách xử lý: path login riêng và queryredirect.- Triệu chứng:
/memỗi lần click. Nguyên nhân: hydrate trong guard và trongApp.vuekhô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.matchedchometalồng nhau. - Khóa hydrate để
/mekhô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).
keyremount 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/middlewaremớ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".
- 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.
v-bind="$attrs"không forward slot. Dùng<slot name="x" v-bind="..."/>hoặc duyệt$slots. Vue 3 dùngv-slot/#; attributeslot=đã deprecated.$slots.fookhá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-iftrên slot và nội dung fallback.- Đừ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.
| API | Khi nào | Gãy khi |
|---|---|---|
| Boolean / variant prop | Đóng, ít variant | showAvatar thứ 40 lên |
| Named slot | Vùng layout | Bọc outlet thì mất fallback |
| Scoped slot | Parent 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.fookhác$slots.foo()có thể lệch trên SSR. Template giữ tối ưu compiler; gọi slot trongh()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
#cellvà 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:$attrskhô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.fookhá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;
$attrskhông forward slot. $slots.fookhá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.
- 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. - Key instance cache theo entity id khi cùng kiểu component tái dùng cho record khác.
includekhớp tên component:<script setup>cầndefineOptions({ name }), không thì không bao giờ cache. onMountedkhông chạy lại. Fetch hoặc refresh trongonActivatednếu data có thể stale. Tạm dừng timer, video, và poll trongonDeactivated; resume trongonActivated.- 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 đặtmaxrồ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ược | State | Rủi ro |
|---|---|---|
Remount :key="id" | Luôn mới | Mất local UI; tốn setup |
keep-alive | Giữ được khi back | Data stale + listener leak |
| Store draft | Giữ được không cần keep-alive | Thê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.
onMountedkhông chạy lại: refetch trongonActivated, tạm dừng poll trongonDeactivated.maxlà LRU; không đặtmaxrồi giữ 50 record là heap leak.includekhớp tên component:defineOptionstrong<script setup>.:key="route.fullPath"có thể quá mạnh (query string);params.idcó 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 trongonActivated.
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
keyentity. 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ý:maxLRU.- Triệu chứng:
includekhô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 trongonDeactivated.
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;
onMountedkhông chạy lại: dùngonActivated. maxlà LRU; không đặtmaxthì leak instance.includekhớp tên component:defineOptionstrong<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?
fullPathremount khi query đổi;params.idthườ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.
- Cách hoạt động: Router / trang Nuxt
import()tách tốt nhất cho app.defineAsyncComponentmịn hơn: editor trong modal. Import chỉ type làimport typeđể Vite không kéo lib. - 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.
- Không
errorComponent, loader throw sẽ lỗi parent. CDN giật một cái làm trắng widget mãi.delaytránh nháy spinner khi cache hit nhanh. - 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ách | Mức tách | Kịch bản lỗi |
|---|---|---|
Router / trang Nuxt import() | Tốt nhất cho app | Layout quá nặng vẫn trên critical path |
defineAsyncComponent | Island trong trang | Không errorComponent làm trắng parent |
Nuxt Lazy / delayed hydration | Island đã có HTML | Vẫn phải khớp cho hydration |
Góc nhìn senior
- Tách theo route là mặc định.
defineAsyncComponentlà island trong trang: editor trong modal. - Luôn ship
errorComponentkèmdelay. Dùngimport 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ó.
manualChunkskhi 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èmtimeout.- 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:
delaylà 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;
defineAsyncComponentcho đảo trong trang. - Luôn ship
errorComponent+delay; dùngimport 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?
Lazyprefix 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.
- 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).
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.- 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 đó.
- Vue 3.4+
data-allow-mismatchlà 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ữ HTML | Chi phí |
|---|---|---|
| Làm deterministic | Có | Phải share locale / flag / payload |
ClientOnly | Không (đảo đó) | SEO/content của subtree mất |
| Lazy / delayed hydration | Có | Vẫn phải khớp; interactivity tới sau |
data-allow-mismatch | Có | 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.
ClientOnlycho 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-mismatchlà 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ặcClientOnly.- 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.
ClientOnlydà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.