Bài này nói về hiệu năng frontend mức senior cho phỏng vấn: vòng đo trước, token và pref nằm ở đâu, cách tách và tree-shake bundle, và Core Web Vitals (LCP, INP, CLS) được chấm trên field thế nào. Dành cho engineer đã ship Vue 3 / Nuxt, giờ cần một quyết định dưới ràng buộc thật: network, JavaScript, main thread (luồng chính), layout, hoặc hydration, không phải một list cơ chế.
Đọc 9.1 trước (storage, vòng tối ưu, checklist review, case study). Người phỏng vấn xoáy Core Web Vitals trên field và “ràng buộc là gì” nhiều hơn trivia debounce. Rồi 9.2 cho code splitting, tree shaking, debounce khác throttle, ảnh và font, và định nghĩa vitals 2026. Nêu rõ metric, chỗ gãy bạn đã gặp, và cách chứng minh fix bằng trace. Không phải một lần chạy Lighthouse trên laptop.
Tóm tắt: Web Storage (localStorage, sessionStorage) là kho key/value cùng origin, JS đọc được. Cookie là chuỗi nhỏ trình duyệt gửi kèm request khớp. Memory là biến chết lúc refresh. Dung lượng, lifetime, và rủi ro XSS quyết định chỗ để, không phải thói quen.
Vì sao quan trọng: “Nhét JWT vào localStorage” là fail phỏng vấn và món quà XSS trên production. Cookie trông như cache lại đi theo mọi API call. Catalog 5 MB trong localStorage chặn main thread (luồng chính) mỗi lần đọc. Chi tiết auth nằm ở Best practices về authentication; topic này là byte nằm ở đâu.
- Cách hoạt động:
localStoragesống đến khi user hoặc origin xóa (~5–10 MB, không phải bảo đảm).sessionStoragechết cùng tab. Cả hai là API JS đồng bộ: JSON parse lớn chặn input (INP, Interaction to Next Paint). - Cookie khoảng 4 KB mỗi cái, theo domain/path, và gắn vào HTTP khớp.
httpOnlygiấu chúng khỏi JS (XSS không cướp được; giờ CSRF là việc của bạn). Chúng không phải cache. - Access token: memory, hoặc cookie session httpOnly. Pref chấp nhận mất (theme, locale):
localStorage. Draft wizard theo tab:sessionStorage. Catalog offline lớn: IndexedDB, không phải Web Storage. Xem security 10.3 cho refresh rotation.
// Access token ở lại memory, không bao giờ Web Storage
let accessToken: string | null = null
export const tokenMemory = {
get: () => accessToken,
set: (t: string | null) => { accessToken = t },
}
// Refresh: Set-Cookie từ API (httpOnly, Secure, SameSite=Lax)
// Theme thì được trong localStorage; catalog 5 MB thì không
localStorage.setItem('theme', 'dark')
try {
localStorage.setItem('catalog:v3', json) // QuotaExceededError trên Safari
} catch { /* LRU hoặc IndexedDB, đừng treo checkout */ }Điều gì xảy ra: Access token không đụng kho JS đọc được, nên XSS cùng origin không exfiltrate được. Theme sống sau refresh. Ghi catalog quá nặng có thể throw QuotaExceededError; bắt nó để checkout không bị treo. Version key (catalog:v3), không thì một lần deploy phục vụ JSON cũ mãi.
| Cơ chế | Lifetime | Size / JS | Senior dùng |
|---|---|---|---|
| Memory | Đến lúc refresh | RAM; JS có | Access token |
| Cookie httpOnly | Server định nghĩa | ~4 KB; JS không | Refresh / session |
localStorage | Đến lúc xóa | ~5–10 MB; JS có | Theme, không secret |
sessionStorage | Tab này | ~5 MB; JS có | Draft wizard |
Góc nhìn senior
- Auth mặc định: session httpOnly + Secure + SameSite, hoặc access trong memory + refresh httpOnly. Chi tiết ở security 10.3.
localStoragelà đồng bộ. Parse lớn lúc boot là bug INP và TBT (Total Blocking Time), không phải cache hay hơn.- Cookie đi theo mọi request khớp. Đừng nhét catalog vào cookie; bạn sẽ phình TTFB và rò data ra log.
- Safari ITP và storage phân vùng khiến cookie “remember me” do JS gán thành nói dối sau khoảng một tuần.
- Nuxt/SSR: chỉ gọi
localStoragekhiimport.meta.client/onMounted, không thì throw trên server.
Lỗi hay gặp
- Triệu chứng: session bị cướp sau một XSS. Nguyên nhân: access token trong
localStorage. Cách xử lý: memory hoặc cookie httpOnly (security 10.3).- Triệu chứng: checkout bị treo vì quota. Nguyên nhân: nhét catalog vào Web Storage. Cách xử lý: IndexedDB + LRU.
- Triệu chứng: “localStorage mã hóa” vẫn đổ session. Nguyên nhân: XSS cùng origin giải mã được. Cách xử lý: đừng cất token ở đó.
- Triệu chứng: các tab không share token memory. Nguyên nhân: RAM theo tab. Cách xử lý:
BroadcastChannelhoặc lock + refresh. Đừng đồng bộ access token vàolocalStorage.
Nhà tuyển dụng hay hỏi gì
Bạn cất JWT ở đâu: localStorage, cookie, hay memory? Nếu câu đầu là “token trong localStorage”, thanh senior đã miss. Họ cũng muốn bạn nêu dung lượng, cookie trên wire, và vì sao catalog 5 MB không thuộc Web Storage.
Trả lời khi phỏng vấn
Mình không nhét access token vào localStorage. Mặc định là cookie session httpOnly, Secure, SameSite, hoặc access token trong memory kèm cookie refresh httpOnly. Luồng login nằm ở topic authentication của security. localStorage cho theme và locale mình chấp nhận mất. sessionStorage là state wizard theo tab. Web Storage đồng bộ và XSS đọc được; catalog quá nặng thì mình để IndexedDB, không chặn main thread. Cookie khoảng bốn kilobyte và đi theo mọi request khớp, nên không phải cache. Safari ITP giết cookie remember-me do JS gán. Trên Nuxt mình chỉ đụng localStorage ở client.
Nhớ nhanh
- Access token không mặc định để trong
localStorage. Memory + refresh httpOnly, hoặc cookie session httpOnly. - Web Storage đồng bộ và XSS đọc được. IndexedDB cho data offline lớn.
- Cookie đi theo request khớp (~4 KB). Chúng là credential, không phải cache.
- Auth rotation và CSRF nằm ở security 10.3. Topic này là byte nằm ở đâu.
Câu hỏi nối tiếp thường gặp
- Tab share access token memory thế nào?
BroadcastChannelhoặc lock kèm refresh. Đừng copy access token vàolocalStorage. - IndexedDB khác
localStoragecho catalog lớn? IndexedDB.localStorageđồng bộ và chặn main thread. - Vì sao “encrypted
localStorage” vẫn sai cho token? XSS cùng origin giải mã được. - PII hoặc data thẻ? Không phải Web Storage. PCI / GDPR. Server hoặc vault đã tokenise.
Tóm tắt: Tối ưu theo vòng: đo, nêu loại bottleneck (network, JavaScript, render, layout), sửa đúng loại đó, rồi kiểm chứng bằng cùng metric. Vue đã batch update. v-memo / useMemo dùng sớm là nhiễu đến khi profile hiện component nóng.
Vì sao quan trọng: “Mình debounce và lazy-load ảnh” là miss mặc định. Bạn không memo được API 400 ms hay PNG hero 2 MB. Người phỏng vấn muốn Core Web Vitals trên field (xem 9.2.5) và profile Performance máy tầm trung trước mọi cơ chế Vue.
- Cách hoạt động: 1. Đo LCP / INP / CLS trên field (CrUX hoặc RUM) và một profile trên máy tầm trung. INP (Interaction to Next Paint) đã thay FID.
- 2. Phân loại: network (TTFB, payload, waterfall), JavaScript (nặng theo route, dep chết), render (việc của component, hydration), layout (reflow vì media chưa size hoặc ép
offsetHeight). - 3. Đổi một lever cho đúng loại: cache/CDN, tách/bỏ dep, virtualize list khổng lồ, chừa chỗ ảnh. 4. Đo lại cùng metric field.
// Đo trước, rồi phân loại. Đừng bắt đầu bằng memo.
performance.mark('search:start')
const res = await fetch(`/api/search?q=${q}`)
performance.mark('search:end')
performance.measure('search', 'search:start', 'search:end')
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
const ttfb = nav.responseStart - nav.requestStart
// Network: TTFB 400 ms → cache / BFF, không phải v-memo
// JS: main chunk 800 KB → tách chart, không debounce input
// Render: table 5k hàng → virtualize; layout: hero chưa size → width/height
console.log({ ttfb, search: performance.getEntriesByName('search')[0]?.duration })Điều gì xảy ra: Mark bọc search. Nếu TTFB át, folklore debounce sẽ không giúp. Nếu main chunk 800 KB, tách chart. Nếu table 5k node DOM, virtualize. Mỗi loại một lever khác. Chứng minh bằng RUM trước/sau, không phải một Lighthouse 100.
| Loại | Nhìn gì | Sửa sau evidence |
|---|---|---|
| Network | TTFB, payload, waterfall | Cache, CDN, BFF |
| JavaScript | Byte route/vendor | Tách, bỏ dep |
| Render | Long tasks, 5k hàng, hydration | Virtualize, ClientOnly |
| Layout | Media chưa size, forced reflow | Width/height, đọc gộp |
Góc nhìn senior
- Thứ tự xử lý: network → JS → main thread → DOM → hydration. Memo không cứu được API 400 ms.
- Vue đã batch và hoist cây tĩnh.
shallowRef/v-memochỉ dùng khi profiler chỉ ra component nóng. - Virtualize được list 60 fps nhưng phá find-in-page; ship một dump a11y. Đừng virtualize 20 hàng.
- Hydrate dashboard user không thấy là phí TTI. Nuxt
<ClientOnly>/ islands tồn tại vì chuyện này. - Kiểm chứng bằng p75 field và long task (> 50 ms), không phải FPS folklore. INP gồm: input delay, processing, presentation.
Lỗi hay gặp
- Triệu chứng: bọc hết
computedvẫn chậm. Nguyên nhân: ràng buộc là TTFB. Cách xử lý: đo, rồi cache.- Triệu chứng: Lighthouse 100, user vẫn giận. Nguyên nhân: lab trên laptop. Cách xử lý: RUM field trên Android tầm trung.
- Triệu chứng: table 20 hàng bị “virtualized”. Nguyên nhân: cargo cult. Cách xử lý: profile; virtualize hàng nghìn, không phải vài chục.
- Triệu chứng: bỏ qua warning hydration, CLS nhảy. Nguyên nhân:
Date.now()lúc render. Cách xử lý: markup ổn định; xem case study.
Nhà tuyển dụng hay hỏi gì
Bạn tối ưu app Vue thế nào? Họ muốn Profiler và Core Web Vitals trước, không phải liệt kê computed với ảnh lazy. Nêu rõ ràng buộc, một đòn bẩy, và cách bạn chứng minh.
Trả lời khi phỏng vấn
Mình không mở bằng debounce và ảnh lazy. Mình đo Core Web Vitals trên field (INP đã thay FID) và lấy profile Performance trên máy tầm trung. Rồi mình hỏi đó là network, JavaScript, render, hay layout. Vue đã batch; shallowRef và v-memo chờ component nóng. Mình không virtualize table 20 hàng, cũng không hydrate chart user không thấy. API 400 ms là bài cache hoặc BFF, không phải bài memo. Cách xử lý là RUM trước và sau trên cùng route, không phải một lần Lighthouse trên laptop mình.
Nhớ nhanh
- INP đã thay FID. CWV field kèm profile máy tầm trung trước mọi cơ chế Vue.
- Thứ tự xử lý: network → JS → main thread → DOM → hydration.
- Memo không cứu được API 400 ms hay PNG 2 MB. Phân loại, rồi đổi một đòn bẩy.
- Chứng minh bằng RUM field trước/sau, không phải một Lighthouse 100.
Câu hỏi nối tiếp thường gặp
- Chứng minh cách xử lý thế nào? RUM field trước/sau trên cùng route, không phải một lần Lighthouse.
- Long task là gì và vì sao 50 ms? Một frame ~16 ms; 50 ms là ngưỡng long-task làm trễ input.
- Khi nào đòn bẩy là cache Nitro, không phải
shallowRef? Khi TTFB/payload át.shallowReflà đòn bẩy trên render-graph. - Map React / Next?
memo/ RSC /dynamic(..., { ssr: false })nằm ở JS, HTML server, và hydration. Vẫn đo trước.
Tóm tắt: Review senior là checklist rủi ro ngắn, không phải lượt soi style. Linter đã lo thứ tự import. Dành phút cho authz, tiền, dependency mới, a11y, và public API.
Vì sao quan trọng: Thời gian review có hạn. Một v-html, thư viện date 200 KB trên route home, feature flag mặc định bật, hoặc check quyền chỉ ở UX sẽ lọt ra production. Thứ tự class Tailwind thì không.
- Cách hoạt động: 1. Đọc ticket và đi hết flow user, gồm empty, error, unauthorized. Đừng chỉ đọc diff.
- 2. Bảo mật:
v-html/innerHTML/ hrefjavascript:; token trong storage; secret trongVITE_hoặc log. - 3. a11y: control native vs
div role="button"giả; focus trên dialog mới; name/role/value. - 4. Bundle: nặng dep mới và
sideEffects; import theo route khác global. 5. API contract và flag (mặc định tắt, rollback có tên).
<script setup lang="ts">
import moment from 'moment' // CHẶN: ~200 KB trên route home
const html = inject('cmsHtml') as string
</script>
<template>
<!-- CHẶN: XSS + không có cây a11y. Sanitize hoặc dùng slot. -->
<div v-html="html" />
<div role="button" @click="pay">Pay</div> <!-- CHẶN: không phải <button> -->
</template>
// Review: chạy flow pay. Flag mặc định? Ticket rollback? Delta lockfile?Điều gì xảy ra: Ba issue chặn trong một PR “nhỏ”: thư viện date CJS quá nặng, HTML chưa sanitize, và button giả trên luồng tiền. Nit style có thể chờ. Lỗ auth và tiền không được để ticket follow-up.
| Check | Chặn khi |
|---|---|
| Bảo mật | v-html, token trong storage, secret trong VITE_ |
| A11y | Button giả, không focus trap, thiếu name |
| Bundle / flag | Dep mới 200 KB, flag mặc định bật |
| API contract | Cắt field im lặng, pay không idempotent |
Góc nhìn senior
- Checklist 8 mục team chạy hay hơn checklist 40 mục không ai mở.
- Approve polish kèm ticket follow-up. Đừng bao giờ approve lỗ auth hoặc tiền kiểu đó.
- Liếc
nuxt.config, header CSP, và diff chỉ lockfile (supply chain). - PR design system: a11y + visual regression + bundle. Xem tài liệu a11y và testing.
- Review chỉ Vue mà miss SDK cloud trên route home vẫn ship rủi ro.
Lỗi hay gặp
- Triệu chứng: nit che một
v-html. Nguyên nhân: review style trước. Cách xử lý: list rủi ro trước, rồi mới soi tên.- Triệu chứng: PR “nhỏ” thêm SDK cloud. Nguyên nhân: không xem nặng import. Cách xử lý: visualizer / sizebot trên PR.
- Triệu chứng: flag ship lên ở trạng thái bật. Nguyên nhân: ticket không ghi default/rollback. Cách xử lý: chặn đến khi nêu rõ.
- Triệu chứng: đổi chỉ lockfile bị bỏ qua. Nguyên nhân: “không có code app”. Cách xử lý: review version transitive mới.
Nhà tuyển dụng hay hỏi gì
Bạn nhìn gì trong một PR? Senior review rủi ro, không phải style: linter đã lo phần style. Họ muốn list bạn chạy thật, và thứ bạn chặn khác thứ để follow-up.
Trả lời khi phỏng vấn
Mình đọc ticket và flow user trước, rồi đi security, a11y, bundle, API contract, và feature flag. Linter lo phần style. Mình sẽ chặn một v-html, thư viện date 200 KB, hoặc flag mặc định bật. Mình không soi thứ tự class Tailwind. Mình chạy flow đó, liếc nuxt.config và lockfile, và chỉ approve lỗ auth hoặc tiền sau khi đã sửa. Polish thì để ticket follow-up. Checklist 8 mục team chạy hay hơn checklist 40 mục không ai mở.
Nhớ nhanh
- Review rủi ro: security, a11y, bundle, API contract, flag. Style là hygiene.
- Checklist 8 mục team chạy hay hơn checklist 40 mục không ai mở.
- Ticket follow-up chỉ cho polish, không bao giờ cho lỗ auth hoặc tiền.
- Diff chỉ lockfile là supply chain. PR “nhỏ” vẫn import được SDK cloud.
Câu hỏi nối tiếp thường gặp
- PR design system? a11y, visual regression, và bundle. Xem tài liệu a11y và testing.
- Approve kèm follow-up? Có cho polish. Không bao giờ cho lỗ auth hoặc tiền.
- Đổi chỉ lockfile? Version transitive mới. Review supply-chain.
- Composable import SDK cloud trên home? Tách hoặc chặn. Đừng approve “chỉ lần này”.
Tóm tắt: Kể incident production theo Situation, Task, Action, Result. Nêu rõ ràng buộc, đổi một đòn bẩy, chứng minh bằng trace. Số bên dưới là placeholder: nói là bạn sẽ kiểm chứng, đừng trích blog.
Vì sao quan trọng: Người phỏng vấn bắt “mình đã virtualize” mà không đo, hoặc “LCP giảm vì một bài viết nói 3 MB → 900 KB”. Điểm cho thấy senior nằm ở cột kiểm chứng: long task, QPS Network, transfer size, log hydration, không phải FPS folklore.
- Cách hoạt động (STAR 1, list): Situation: grid admin ~[N] hàng giật lúc scroll. Task: lấy lại INP. Action: virtualize (
@tanstack/vue-virtual), flatten hàng, sort ngoài main thread. Result: long task lúc scroll xuống dưới 50 ms; ship dump a11y không virtualize. - STAR 2, refetch: GET trùng lúc focus tab. Action: TanStack Query /
useAsyncDatastaleTime, debounce query,AbortController. Result: QPS server lúc search giảm [metric]. - STAR 3, chunk quá nặng / hydration: Chunk main ~[size] vì chart và barrel. Action: tách theo route,
defineAsyncComponent, bỏ barrel (xem 9.2.2). Hydration lệch (Date.now()) →<ClientOnly>+ id ổn định. Result: LCP và CLS field dịch; xác nhận bằng trace, không phải số marketing đã gzip.
// Phác STAR 2: abort search đang chạy để “s” không ghi đè “sql”
let generation = 0
async function search(q: string, signal: AbortSignal) {
const mine = ++generation
const res = await fetch(`/api/search?q=${q}`, { signal })
if (mine !== generation) return // response muộn không được thắng
return res.json()
}
// Kiểm chứng: waterfall Network + QPS server, không phải “cảm giác mượt hơn”
// STAR 1 kiểm chứng: long task trên panel Performance lúc scroll + attribution INPĐiều gì xảy ra: Bộ đếm generation kèm AbortSignal bỏ response muộn. Đó là STAR refetch. STAR list virtualize DOM; STAR chunk tách JS. Mỗi cái cần trace khác. Số để [N] / [metric] đến khi bạn có số của mình.
| Case | Nguyên nhân → action | Kiểm chứng |
|---|---|---|
| List chậm | DOM đầy → virtualize | Long task + INP |
| Bão refetch | Không abort / staleTime | Waterfall + QPS |
| JS quá nặng / LCP | Chart + barrel → tách | Transfer size + LCP field |
| Hydration / CLS | Render không ổn định → ClientOnly | Log hydration + CLS |
Góc nhìn senior
- Năm pattern phủ hầu hết phỏng vấn Vue/Nuxt: list DOM, refetch, JS quá nặng, store quá nặng, hydration/CLS.
- Virtualize cần path cho print / a11y.
staleTimecủa query có thể che một bug invalidation. - Bỏ barrel chẳng giúp gì nếu LCP là PNG chưa nén. Xem 9.2.4.
v-memosau store Pinia quá nặng là chữa triệu chứng. Tách store trước.- Trích trace. Coi mili giây trên blog là minh họa, đừng lấy làm kết quả của bạn.
Lỗi hay gặp
- Triệu chứng: list đã virtualize, find-in-page trống. Nguyên nhân: DOM offscreen. Cách xử lý: dump a11y / export không virtualize.
- Triệu chứng: đồng đội đổ “JS” cho LCP 4 s, trong khi đó là PNG 2 MB. Nguyên nhân: trace không có element LCP. Cách xử lý: chỉ Resource Timing.
- Triệu chứng:
v-memoche một closure cũ. Nguyên nhân: memo sau store quá nặng. Cách xử lý: profile, rồi tách state.- Triệu chứng: “console sạch trên máy mình”. Nguyên nhân: không có RUM field. Cách xử lý: log hydration trên staging + trace CLS.
Người phỏng vấn hỏi gì
Kể một incident production theo Problem → Cause → Solution → Result. Số liệu chỉ để minh họa: họ muốn bạn nói sẽ kiểm chứng bằng trace (RUM, profile Performance, waterfall network), không trích blog.
Trả lời khi phỏng vấn
Mình kể Situation, Task, Action, Result, và coi số liệu là placeholder mình sẽ thay bằng trace. Grid admin giật thì thường vì DOM đầy: mình virtualize rồi kiểm bằng long task lúc scroll, không phải folklore FPS, và mình ship dump a11y. GET trùng lúc đổi tab là thiếu staleTime và abort. Chunk main quá nặng là chart hoặc barrel, xác nhận bằng transfer size và LCP field. Gõ mà dashboard re-render là store quá nặng: colocate, rồi profile, rồi mới tính v-memo. Mình không trích mili giây trên blog như thể đó là RUM của team mình.
Nhớ nhanh
- Format là STAR. Trích trace, không phải mili giây trên blog.
- Hầu hết incident Vue là list DOM, bão refetch, chunk quá nặng, store quá nặng, hoặc hydration/CLS.
- Virtualize cần dump a11y. Ảnh nằm ở 9.2.4. Đo nằm ở 9.1.2.
- Giữ số là [N] / [metric] đến khi bạn có evidence trên field.
Câu hỏi nối tiếp thường gặp
- Vì sao virtualize làm gãy find-in-page? Hàng offscreen không có trong DOM. Ship dump a11y.
- Focus-refetch vs search mỗi lần gõ?
staleTimevs debounce kèm abort. - Đồng đội đổ JS cho LCP 4 s dù là PNG? Chỉ element LCP và Resource Timing.
- Khi nào
v-memomới là bước sau khi tách Pinia? Sau khi profiler vẫn hiện list nóng. Không phải trước.
Tóm tắt: Code splitting (tách code) chỉ load JS khi route hoặc widget cần. Route-level là mặc định. Component-level cho widget nặng hoặc hiếm. Tách vendor là runtime dùng chung, cache lâu. import() là primitive; prefetch thì maybe.
Vì sao quan trọng: Mỗi chunk thêm là một request và một UI loading bạn phải thiết kế. Không tách editor 400 KB trên mọi trang là miss thường gặp. Tách button 2 KB là phí. Prefetch cả admin trên landing marketing đốt bandwidth.
- Cách hoạt động: Tách route: Vue Router
() => import(), Nuxtpages/(auto), segment Nextapp/. Lần vào đầu chịu waterfall; lần sau trúng cache nếu hash ổn định. - Tách component:
defineAsyncComponent/React.lazy+ Suspense cho chart, editor, admin. Skeleton chừa chiều cao để CLS không nhảy. Timeout để chunk treo không thành màn trống. - Tách vendor: chunk
vue+piniaổn định, sống qua các lần deploy app. Prefetch route kế có khả năng sau idle (rel=prefetch/router.onReady). Lùi prefetch khisaveData. Xem tree shaking cho barrel phá lần tách.
<script setup lang="ts">
const HeavyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
delay: 150,
timeout: 15_000,
})
</script>
<template>
<Suspense>
<HeavyChart />
<template #fallback>
<div class="chart-slot" aria-busy="true" /> <!-- chừa chiều cao, không phải spinner nhảy -->
</template>
</Suspense>
</template>
// routes: component: () => import('@/pages/Dashboard.vue')Điều gì xảy ra: Chart không nằm trong JS đầu của dashboard. Sau 150 ms một slot chừa chiều cao hiện ra (aria-busy). Timeout 15 s fail rõ. Import trang này vào main sẽ phá auto-split của Nuxt.
| Tách | Dùng cho | Chi phí |
|---|---|---|
| Route | Trang không phải lúc nào cũng vào | Waterfall lần nav đầu |
| Component | Widget tùy chọn / nặng | Thiết kế Suspense / skeleton |
| Vendor | Runtime dùng chung, cache lâu | Hash stale, tách quá vụn |
Góc nhìn senior
- HTTP/2 có giúp; 80 chunk nhỏ trên 3G vẫn đau. Đừng tách button 2 KB.
- SSR phải biết cái nào inline, cái nào defer. Màn trống không timeout tệ hơn bundle hơi to.
- Prefetch lúc
saveData/ 2G đốt gói data của user. Chỉ idle + route kế có khả năng. - Nuxt/Next đã tự tách route. Import tĩnh một trang vào entry sẽ phá auto-split.
- Barrel của design system có thể kéo mọi component vào entry app. Xem 9.2.2.
Lỗi hay gặp
- Triệu chứng: landing marketing tải JS admin. Nguyên nhân: import tĩnh / prefetch sớm. Cách xử lý: tách route + chỉ prefetch lúc idle.
- Triệu chứng: spinner làm nhảy trang (CLS). Nguyên nhân: không chừa chiều cao. Cách xử lý: skeleton có aspect-ratio.
- Triệu chứng: chunk treo, app trống. Nguyên nhân: không timeout. Cách xử lý:
timeout+ UI lỗi.- Triệu chứng: Nuxt vẫn đưa trang vào
main. Nguyên nhân: import module trang từ layout. Cách xử lý: để file router chịu trách nhiệm.
Người phỏng vấn hỏi gì
Tách route khác tách component khác tách vendor: khi nào prefetch, và user thấy gì lúc chunk đang load? Họ cũng muốn biết thứ gì phá auto-split của Nuxt/Next.
Trả lời khi phỏng vấn
Mình tách theo route mặc định: import động Vue Router, pages Nuxt, segment Next. Chart và editor mình tách component, qua defineAsyncComponent và skeleton chừa chiều cao để CLS không nhảy, kèm timeout để chunk treo không thành màn trống. Vendor thì mình chỉ tách một chunk vue kèm pinia ổn định. Mình prefetch route kế có khả năng sau idle, và lùi khi saveData. Import một trang vào main sẽ phá auto-split của Nuxt. Button 2 KB không đáng một chunk; editor 400 KB trên mọi trang mới là miss thường gặp.
Nhớ nhanh
- Tách route mặc định. Tách component widget nặng hoặc hiếm. Tách vendor runtime ổn định.
import()là primitive. UX loading (skeleton, timeout) là một phần của việc tách.- Prefetch route kế có khả năng sau idle. Đừng prefetch cả admin trên trang marketing.
- Import tĩnh vào entry sẽ phá auto-split của Nuxt/Next. Barrel: xem 9.2.2.
Câu hỏi nối tiếp thường gặp
- Tách design system thế nào? Import từng component. Coi chừng barrel. 9.2.2.
- Tách SSR khác client? Server vẫn phải biết island.
ClientOnly/ssr: falselà hydration, không phải miễn phí. - 80 chunk trên 3G? Gộp bớt. HTTP/2 không phải song song vô hạn trên sóng kém.
- Next
next/dynamic? Cùng ý vớidefineAsyncComponent. Chừa chiều cao, không thì CLS nhảy.
Tóm tắt: Tree shaking là bundler bỏ export ESM không dùng sau phân tích tĩnh. Cần import tĩnh kèm cờ sideEffects khai đúng. CJS, import(variable) động, và barrel đầy re-export thường không shake được.
Vì sao quan trọng: import debounce from 'lodash' thường kéo theo một graph CJS lớn. Một icon từ index của design system có thể kéo cả nghìn SVG. sideEffects: false trên thư viện Vue mà CSS load như side effect làm mất style trên production.
- Cách hoạt động: Rollup/Vite đọc ESM. Named export không dùng bị bỏ nếu file được đánh dấu không có side effect.
import debounce from 'lodash-es/debounce'là leaf.import { debounce } from 'lodash'(CJS) thì thường không. package.json"sideEffects": falsenghĩa là “bạn được bỏ file này nếu không import gì từ nó”. CSS và SFC Vue là side effect: hãy liệt kê ("**/*.css","**/*.vue").- Ưu tiên native (
toSorted,structuredClone) rồi helper ESM leaf. Plugin Vueapp.usehết mọi thứ thì không shake được: hãy tách. Xác nhận bằng visualizer; fail CI nếu package cấm lọt vàomain.
// Import named ESM, không phải barrel CJS
import debounce from 'lodash-es/debounce'
// native trước, khi baseline cho phép
const rows = list.toSorted((a, b) => a.name.localeCompare(b.name))
// package.json của thư viện Vue: giữ CSS / SFC là side effect
// {
// "sideEffects": ["**/*.css", "**/*.vue"]
// }
// sideEffects: false ở đây sẽ làm mất style trên productionĐiều gì xảy ra: Một hàm debounce tới từ leaf ESM. toSorted thêm 0 byte. Nếu thư viện Vue này gán sideEffects: false toàn cục, CSS production sẽ biến mất. Barrel index re-export icon vẫn kéo cả thế giới.
| Import | Shake được? | Ghi chú |
|---|---|---|
lodash (CJS) | Thường không | Graph lớn |
lodash-es/debounce | Có (leaf ESM) | Vẫn nặng hơn native |
Native toSorted | 0 byte | Kiểm tra browser baseline |
DS / icon index | Không: re-export | Một icon, 1k SVG |
Góc nhìn senior
- Tree shaking là phân tích tĩnh, không phải GC runtime.
import(path)động giữ cả module graph bạn trỏ tới. - Micro-import hay một
radashshake sạch: phải đo. Ideology không phải ngân sách byte. - Cấm barrel bằng lint rẻ hơn cãi lúc review sau khi đã lọt một megabyte.
- Auto-import Nuxt có thể giấu composable nặng trên trang home. Search tên đó trên visualizer.
- Moment.js và full icon pack là rule fail CI hay gặp.
Lỗi hay gặp
- Triệu chứng:
lodashvẫn trong bundle sau một hàm. Nguyên nhân: barrel CJS. Cách xử lý: leaflodash-eshoặc native.- Triệu chứng: production không có CSS. Nguyên nhân:
sideEffects: falsetrên file import style. Cách xử lý: glob CSS/SFC.- Triệu chứng: một icon, 1 MB. Nguyên nhân: re-export
index. Cách xử lý: import SVG/component leaf.- Triệu chứng: visualizer sạch local, quá nặng trên CI. Nguyên nhân: NODE_ENV / alias khác. Cách xử lý: so production build.
Nhà tuyển dụng hay hỏi gì
Vì sao lodash vẫn trong bundle khi bạn chỉ import một hàm, và sideEffects làm gì? Họ cũng muốn nghe bẫy file barrel.
Trả lời khi phỏng vấn
Nếu lodash còn trong bundle sau một lần import, mình đã import CJS. Mình dùng đường ESM leaf hoặc native, và khai sideEffects cho đúng: false trên thư viện Vue mà CSS load như side effect sẽ làm mất style trên production. Barrel là megabyte hay gặp: import một icon kéo theo cả nghìn SVG. Mình xác nhận bằng visualizer và fail CI nếu package cấm lọt vào chunk main. Plugin Vue mà app.use mọi thứ thì không shake được; mình tách nó. Tree shaking là phân tích tĩnh, không phải phép màu.
Nhớ nhanh
- Tree shaking cần ESM tĩnh kèm cờ
sideEffectskhai đúng. - Import module leaf, đừng bao giờ barrel CJS hay icon.
sideEffects: falsetrên CSS/SFC làm mất style production. Glob chúng.- Native trước (
toSorted,structuredClone), rồi helper leaf. Đo.
Câu hỏi nối tiếp thường gặp
- Plugin Vue mà
app.usemọi thứ? Không shake được. Tách plugin hoặc exportcreateX. - Dynamic
import(name)? Bundler giữ mọi module mà tên động có thể trỏ tới. Dùng map tĩnh. - CJS năm 2026? Vẫn phổ biến ở dep cũ. Visualizer mới là nguồn đúng.
- Nuxt auto-import composable nặng? Giống import tĩnh. Search trên production graph.
Tóm tắt: Debounce chờ khoảng lặng, rồi chạy một lần (search, hết resize). Throttle lấy mẫu đều trong một luồng (scroll, kéo). Không cái nào thay abort việc đang bay. Live-coding helper nằm ở các mục Q&A debounce và throttle.
Vì sao quan trọng: Debounce giá trị input làm ô bị trễ. Response “s” chậm hơn có thể ghi đè “sql” nếu bạn không abort. Throttle không trailing thì bỏ vị trí scroll cuối. Cả hai phải hủy lúc unmount.
- Cách hoạt động: Debounce: mỗi event reset timer. Sau 200–400 ms lặng, một call chạy. Trailing = chạy ở cuối (search). Leading = chạy ngay, rồi bỏ qua (khóa double-submit).
- Throttle: tối đa một call mỗi window. Để bám visual, window là
requestAnimationFrame. Trailing để state cuối đúng. - Search: giữ input live, debounce request, abort request đang bay (
AbortController/ VueUseonCleanup). Đừng search lúc IME composition (isComposing). Vẫn rate-limit server.
import { watchDebounced } from '@vueuse/core'
watchDebounced(
query,
async (q, _prev, onCleanup) => {
const ac = new AbortController()
onCleanup(() => ac.abort())
if (!q) { results.value = []; return }
results.value = await searchCatalog(q, { signal: ac.signal })
},
{ debounce: 300 },
)
// Input vẫn live. Request chờ 300 ms trailing, rồi abort lúc gõ lần sau.Điều gì xảy ra: Gõ “sql” reset timer ba lần. Một request chạy sau 300 ms lặng. Cleanup abort call đang bay để “s” không thắng. Bản thân input không bị debounce. Phần implement timer nằm ở Q&A live-coding debounce.
| t (ms) | Debounce search | Throttle scroll / rAF |
|---|---|---|
| 0 | gõ, start timer 300 ms | lấy mẫu vị trí |
| 80 | gõ, reset timer | bỏ (trong window) |
| 300 | lặng → chạy + abort lần trước | mẫu trailing (state cuối) |
| Dùng | Search, hết resize, autosave | Scroll spy, kéo, pointermove |
Góc nhìn senior
- 300 ms khác “cảm giác đơ”: chỉnh bằng analytics search bỏ dở, không phải cargo cult.
requestAnimationFramelà throttle đúng để bám visual.setTimeout16 ms canh giờ kém hơn.- Flush autosave lúc
visibilitychange/ unmount, không thì mất draft cuối. - Debounce leading-edge là khóa double-submit, không phải search.
- VueUse gắn cancel vào owner scope. Timer thô không cleanup sẽ leak request.
Lỗi hay gặp
- Triệu chứng: ô bị trễ. Nguyên nhân: debounce
v-model. Cách xử lý: giá trị live, debounce request.- Triệu chứng: “s” ghi đè “sql”. Nguyên nhân: không abort. Cách xử lý:
AbortController/onCleanup.- Triệu chứng: scroll spy kẹt sớm một section. Nguyên nhân: throttle không trailing. Cách xử lý: trailing hoặc rAF.
- Triệu chứng: search thừa lúc gõ CJK. Nguyên nhân: bỏ qua
isComposing. Cách xử lý: skip đến compositionend.
Nhà tuyển dụng hay hỏi gì
Khác nhau; leading khác trailing; và ô search mà response “s” chậm hơn ghi đè “sql”. Họ cũng muốn bạn không debounce giá trị đang hiện trên ô.
Trả lời khi phỏng vấn
Debounce là khoảng lặng: search, hết resize. Throttle là lấy mẫu đều: scroll, kéo. Mình không bao giờ debounce giá trị input; ô vẫn live và request chờ 200 đến 400 ms trailing, rồi mình abort request đang bay để s không ghi đè sql. Mình dùng VueUse để cancel gắn với owner scope. requestAnimationFrame là throttle để bám visual. Mình vẫn rate-limit server, và không search lúc IME composition. Phần implement timer là bài live-coding; topic này là tool nào và khi nào dùng.
Nhớ nhanh
- Debounce khoảng lặng. Throttle lấy mẫu đều. rAF để bám visual.
- Giữ input live. Debounce request. Abort request đang bay, không thì “s” ghi đè “sql”.
- Trailing để event cuối thắng. Leading là khóa double-submit.
- Phần implement timer nằm ở Q&A live-coding debounce và throttle.
Câu hỏi nối tiếp thường gặp
- Test thế nào? Fake timer kèm MSW; assert abort. Skip lúc
isComposing. - Resize? Debounce trailing, hoặc
ResizeObserver. Throttle chỉ khi bạn paint trong lúc resize. - Autosave? Debounce trailing và flush lúc ẩn/unmount.
- Live-coding? Các mục Q&A debounce và throttle bảo bạn viết helper. Topic này là lựa chọn sản phẩm.
Tóm tắt: Size mọi ảnh (width/height hoặc aspect-ratio) để CLS bằng không. Ảnh LCP là priority (fetchpriority="high", NuxtImg preload, Next priority), không bao giờ loading="lazy". Font: subset, preload một family, font-display: optional hoặc swap gần.
Vì sao quan trọng: Media chưa size và font swap muộn là CLS hay gặp. Lazy-load hero làm trễ LCP (Largest Contentful Paint). Preload ba file font cướp băng thông của LCP. PNG local trong /public bỏ qua image optimizer.
- Cách hoạt động: Hero:
<img>thật (hoặc<NuxtImg>/next/image) có width/height, format hiện đại (AVIF/WebP + fallback),srcset/sizes,fetchpriority="high". Dưới fold:loading="lazy"+decoding="async". - Font: subset (
unicode-range), preload một family body,font-display: optional(không swap muộn; mạng chậm có thể giữ font hệ) hoặcswapvới fallback khớp metric. - CDN / Nuxt Image / Next Image cần domain trong allow-list. UGC có max dimension. Banner cookie không chừa chiều cao là CLS sản phẩm, không phải bất ngờ. Xem Core Web Vitals.
<NuxtImg
src="/hero.jpg"
width="1200"
height="630"
format="webp"
densities="1x 2x"
sizes="100vw"
preload
alt="Product hero"
/>
<!-- Đừng loading="lazy" hero đó. Dưới fold: lazy + decoding="async". -->
@font-face {
font-family: InterVar;
src: url("/fonts/inter-subset.woff2") format("woff2");
font-display: optional;
unicode-range: U+0000-00FF;
}Điều gì xảy ra: Hero đã size, preload, và là ảnh thật, nên LCP paint được. Font body được subset và optional, nên file muộn không đẩy layout. Lazy dành cho thumbnail dưới fold, không bao giờ hero này.
| Asset | Làm | Đừng |
|---|---|---|
| LCP / hero | Size, priority, AVIF/WebP, <img> thật | loading="lazy", background-image |
| Dưới fold | Lazy + sizes + decode async | Retina 4× không sizes |
| Font body | Subset, optional, preload một | Preload ba file; swap muộn |
Góc nhìn senior
font-display: swap→ chữ hiện nhanh, có thể CLS.optional→ không swap muộn (thường là lựa chọn senior cho body).- AVIF nhỏ hơn nhưng encode chậm hơn; một số WebView cũ xử lý kém. Giữ fallback WebP/JPEG.
- Retina 4× mà không có
sizesthì điện thoại nhận hero 4 MB.sizes="100vw"là contract. - Thiếu allow-list domain ảnh Next/Nuxt thì gãy chỉ trên prod. Bắt ở preview deploy.
- Đo element LCP (
<img>hero,<h1>, hay banner muộn?) và attribution CLS (font hay ad).
Lỗi hay gặp
- Triệu chứng: LCP 4 s, JS trông ổn. Nguyên nhân: hero lazy hoặc PNG 2 MB. Cách xử lý: priority + nén /
NuxtImg.- Triệu chứng: CLS lúc paint đầu. Nguyên nhân: ảnh chưa size hoặc font
swapmuộn. Cách xử lý: width/height +optional.- Triệu chứng: hero là
background-imageCSS. Nguyên nhân: không có element LCP. Cách xử lý:<img>thật.- Triệu chứng: ảnh prod 404. Nguyên nhân: thiếu
remotePatterns/ allow-list domain. Cách xử lý: check preview.
Nhà tuyển dụng hay hỏi gì
CLS từ ảnh và font; ảnh nào là LCP; font-display; và bạn có dùng Nuxt Image / Next Image không. Họ sẽ fail hero lazy.
Trả lời khi phỏng vấn
Mọi ảnh có width và height nên CLS bằng 0. Ảnh LCP được priority: NuxtImg preload hoặc Next priority, không bao giờ lazy. Mình dùng AVIF hoặc WebP kèm fallback, và img thật cho hero, không phải background-image. Font được subset, preload một family, font-display optional cho body để không swap muộn. Mình đo element LCP và attribution CLS; banner cookie không chừa chiều cao là quyết định product, không phải bất ngờ. Ảnh do user tạo đi qua optimizer với max dimension. PNG hai megabyte không phải chuyện JavaScript.
Nhớ nhanh
- Gán size cho mọi ảnh. Priority-load cái LCP. Không bao giờ
loading="lazy"hero. - Subset font, preload một family,
font-display: optional(hoặcswapkhớp metric). <img>thật cho hero, không phảibackground-image. Allow-list domain remote.- Định nghĩa LCP/CLS và field khác lab nằm ở 9.2.5.
Câu hỏi nối tiếp thường gặp
- Art direction?
<picture>/ NuxtImgsizeskèm densities. Vẫn phải size slot. font-display: optionalkhácswap? Optional tránh CLS muộn; mạng chậm có thể giữ font hệ thống.- Ảnh UGC? Optimizer + max dimension + allow-list. Đừng hotlink file thô.
- LCP là banner? Product nên chừa chiều cao hoặc hoãn banner đến sau LCP.
Tóm tắt: Core Web Vitals trên field năm 2026 là LCP (Largest Contentful Paint, tốt ≤ 2,5 s), INP (Interaction to Next Paint, tốt ≤ 200 ms), và CLS (Cumulative Layout Shift, tốt ≤ 0,1). INP đã thay FID. Debug field trước, rồi reproduce trên lab, rồi đổi một đòn bẩy.
Vì sao quan trọng: Lab chỉ là desktop giả làm điện thoại. Field gồm Android tầm trung, 4G, extension, banner consent, và flag A/B. Lighthouse 100 mà p75 INP xấu nghĩa là bạn tối ưu nhầm thứ. FID chỉ đo độ trễ của tương tác đầu.
- Cách hoạt động: LCP = lúc hero lớn nhất (ảnh hoặc chữ) paint. Kém > 4,0 s. Nguyên nhân điển hình: TTFB chậm, hero lazy/chưa tối ưu, font trễ.
- INP = độ trễ tương tác tệ nhất (gần như) xuyên suốt lượt thăm. Kém > 500 ms. Tách: input delay, processing, presentation. Long tasks (> 50 ms) và hydration nằm ở đây.
- CLS = layout shift bất ngờ. Kém > 0,25. Media chưa size, font muộn, banner bị nhét vào, swap hydration. Playbook: p75 field (28 ngày, mobile) → attribution → CPU 4× + 4G chậm → một đòn bẩy sau flag.
import { onCLS, onINP, onLCP } from 'web-vitals'
function send(metric: { name: string; value: number; id: string }) {
navigator.sendBeacon('/rum', JSON.stringify(metric))
}
onLCP(send, { reportAllChanges: false }) // tốt ≤ 2500 ms
onINP(send, { reportAllChanges: false }) // tốt ≤ 200 ms; đã thay FID
onCLS(send) // tốt ≤ 0.1
// attribution: true trong web-vitals v4+ trỏ tới element / event
// Lab (Lighthouse) để reproduce. p75 field trên route mới là điểm.Điều gì xảy ra: Snippet gửi LCP, INP, và CLS tới RUM của bạn. Ngưỡng là chuẩn field năm 2026. Attribution (khi bật) nêu rõ element LCP, target INP, và node CLS, nên bạn không phải đoán. Lab là cách reproduce sau khi field báo bạn fail.
| Vital | Tốt / kém | Nguyên nhân điển hình |
|---|---|---|
| LCP | ≤ 2,5 s / > 4,0 s | TTFB, hero lazy, font trễ |
| INP | ≤ 200 ms / > 500 ms | Long tasks, hydration, third-party |
| CLS | ≤ 0,1 / > 0,25 | Media chưa size, font muộn, ads |
Góc nhìn senior
- Field trước (CrUX, RUM có attribution). Lab (Lighthouse, panel Performance, CPU 4×) để reproduce.
- Preload đúng ảnh LCP kèm CSS critical. Preload mọi thứ thì tranh băng thông với LCP.
- Hỗ trợ CWV cho soft-navigation vẫn đang thay đổi. SPA đổi route vẫn dõi INP ở tương tác kế.
- Tag manager với marketing: hãy để chi phí INP hiện trên RUM, không thì bạn hết cửa cãi.
- Hydration là bài INP. Hero lazy là bài LCP. Trang im với click Pay 400 ms vẫn fail INP.
Lỗi hay gặp
- Triệu chứng: Lighthouse 100, p75 đang đỏ. Nguyên nhân: lab trên laptop. Cách xử lý: RUM field, 28 ngày, mobile.
- Triệu chứng: vẫn nhắc FID. Nguyên nhân: cheat sheet cũ. Cách xử lý: INP đã thay FID; đo xuyên suốt lượt thăm.
- Triệu chứng: preload sáu font, LCP tệ hơn. Nguyên nhân: tranh băng thông. Cách xử lý: preload ảnh LCP + một family.
- Triệu chứng: “trang im nên INP ổn”. Nguyên nhân: một click nặng. Cách xử lý: Event Timing / LoAF trên Pay.
Nhà tuyển dụng hay hỏi gì
Core Web Vitals bây giờ là gì? FID khác INP thế nào? Bạn debug một regression ra sao? Họ muốn ngưỡng, field khác lab, và một đòn bẩy bạn sẽ đổi.
Trả lời khi phỏng vấn
Core Web Vitals bây giờ là LCP, INP, và CLS. INP đã thay FID: FID chỉ thấy độ trễ của tương tác đầu. Tốt khoảng hai giây rưỡi, hai trăm mili giây, và không phẩy một. Mình bắt đầu từ p75 field trên route, không phải Lighthouse một trăm trên laptop. Attribution cho mình biết element LCP, target INP, và node CLS. Mình reproduce với CPU gấp bốn và 4G chậm, đổi một đòn bẩy sau flag, rồi nhìn cùng bảng RUM. Hydration là INP; hero lazy làm LCP tệ hơn; trang im với click Pay chậm vẫn fail INP.
Nhớ nhanh
- Vitals field 2026: LCP ≤ 2,5 s, INP ≤ 200 ms, CLS ≤ 0,1. INP đã thay FID.
- p75 field trước. Lab để reproduce. Đổi một đòn bẩy sau flag.
- LCP là lúc hero paint. INP là độ trễ tương tác. CLS là shift bất ngờ.
- Lighthouse 100 trên laptop không phải một điểm. RUM trên route mới là điểm.
Câu hỏi nối tiếp thường gặp
- Thứ gì thay FID và vì sao? FID chỉ đo độ trễ của tương tác đầu. INP quan sát xuyên suốt lượt thăm.
- Soft navigation? Hỗ trợ vẫn đang thay đổi. Vẫn lấy INP của tương tác kế làm metric product.
scheduler.yield? Cắt long task để presentation của INP kịp xảy ra. Không thay được việc làm ít đi.- CrUX khác RUM của bạn? CrUX là user Chrome đã opt-in. RUM của bạn gồm phần còn lại của fleet.