Bài này nói về Browser API mức senior cho phỏng vấn Vue: IndexedDB, worker, service worker, Intersection Observer, một nhóm API hiện đại ngắn, và sync session đa tab. Dành cho engineer đã ship UI, giờ cần đặt việc và data sao cho main thread (luồng chính) vẫn tương tác được.
Đọc 4.1–4.3 trước (data nằm ở đâu, CPU chạy ở đâu, proxy mạng cache thế nào). Người phỏng vấn hỏi version upgrade, chi phí postMessage, và cache poisoning nhiều hơn snippet indexedDB.open. Sau đó đọc 4.4–4.6 về observer, API nền tảng mới hơn, và logout giữa các tab. Với mỗi topic, nêu rõ chi phí, chỗ hỏng, và cách bạn chứng minh phần sửa.
Tóm tắt: IndexedDB là database có cấu trúc, bất đồng bộ, theo quota origin của browser. Nó lưu bản ghi lớn hoặc có index (bản nháp, catalog, outbox). Không phải localStorage phóng to.
Vì sao quan trọng: PWA cho sales đi field phải chạy được dưới tầng hầm, rồi mới sync. Editor tự lưu tài liệu rất lớn. localStorage đồng bộ, chỉ chuỗi, khoảng 5MB. Parse JSON 4MB trên main thread (luồng chính) làm treo tab. Quota, version 12 gặp tab v11, và hai tab cùng server bất đồng mới là câu hỏi thật.
- Cách hoạt động: Data nằm trong DB theo origin, gồm object store và index. Đọc và ghi chạy trong transaction. Đổi schema xảy ra ở
onupgradeneededkhi bạn tăng version. - Chọn store theo việc: IndexedDB cho dữ liệu megabyte và cần index;
localStoragecho cờ sync nhỏ; Cache API cho HTTP response; cookie khi server phải thấy giá trị. - Năm 2026 mình không tự viết IDB. Dùng Dexie (hoặc tương tự) cho schema, live query, và transaction. IDB trần chỉ là wrapper nhỏ bạn chịu trách nhiệm hết, không phải huy hiệu senior.
- IDB bất đồng bộ nhưng không miễn phí. Scan lớn trên main thread vẫn giật. Đọc nặng thì đưa vào worker. Đừng chặn first paint: hiện shell, hydrate từ IDB, rồi mới network.
- Khi tab A mở v13, các tab khác nhận
versionchangevà phải đóng, nếu không upgrade bị chặn (onblocked). Instance Dexie giữ lâu phải lắng nghe và reload.
import Dexie, { type Table } from 'dexie'
class AppDB extends Dexie {
drafts!: Table<{ id: string; body: string; updatedAt: number }>
constructor() {
super('app')
this.version(1).stores({ drafts: 'id, updatedAt' })
}
}
const db = new AppDB()
db.on('versionchange', () => {
db.close()
location.reload() // tab khác đã upgrade schema
})
await db.drafts.put({ id: 'doc-1', body, updatedAt: Date.now() })Điều gì xảy ra: Dexie đặt tên store và index. put là một transaction. Nếu tab khác tăng version, versionchange đóng kết nối này rồi reload để upgrade không bị chặn. Không có listener đó, tab mới kẹt ở onblocked và user tưởng app bị treo.
| Cần | Store | Vì sao |
|---|---|---|
| Theme / “đã xem splash” | localStorage | Nhỏ, đồng bộ, soi được |
| Catalog 30MB / bản nháp | IndexedDB (Dexie) | Bất đồng bộ, có index, biết quota |
| Cookie session | Cookie httpOnly | Server phải thấy |
| JS / ảnh đã hash | Cache API / HTTP cache | HTTP response, không phải bản ghi |
Góc nhìn senior
- Đừng nhét object preference 20 mục vào IDB. Đừng dump JSON 4MB vào
localStorage. navigator.storage.persist()là “xin đừng xóa data”, không phải bảo đảm. Coi eviction là chuyện bình thường trên Safari mobile.- Sync: last-write-wins với
updatedAtlà cách nói thẳng. CRDT dành cho editor nhiều người cùng sửa, không phải todo list. Nếu chưa tả được UI xung đột, bạn chưa sẵn sàng sync. - Bắt
QuotaExceededError, xóa LRU, nói với user. Đừng hard-code 50MB: Chrome và Safari khác nhau. - SSR/Nuxt: IDB chỉ có trên browser. Guard
window; đừng import Dexie ở module scope phía server. Chứng minh quota bằngnavigator.storage.estimate()và test migrate từ vN−1.
Lỗi hay gặp
- Triệu chứng: tab treo lúc mở. Nguyên nhân: parse JSON
localStorage4MB trên main thread. Cách xử lý: IndexedDB + hydrate sau first paint.- Triệu chứng: deploy v12 treo. Nguyên nhân: tab v11 không đóng. Cách xử lý:
versionchange→db.close()+ reload.- Triệu chứng:
onupgradeneededxóa data production. Nguyên nhân:deleteObjectStoretrong migration. Cách xử lý: migration chỉ thêm và test từ vN−1.- Triệu chứng: Dexie
liveQueryleak sau khi rời trang. Nguyên nhân: không unsubscribe. Cách xử lý: stop trongonUnmounted/onScopeDispose.
Nhà tuyển dụng hay hỏi gì
PWA cho sales đi field phải chạy được dưới tầng hầm, rồi mới sync. Hoặc editor tự lưu tài liệu rất lớn. Vì sao không dùng localStorage? Điều gì xảy ra khi vượt quota, khi version 12 gặp tab v11 còn mở, và khi hai tab cùng server bất đồng?
Trả lời khi phỏng vấn
IndexedDB là store có cấu trúc, bất đồng bộ cho data lớn hoặc có index: bản nháp, catalog, outbox. Mình không dùng localStorage cho dữ liệu megabyte: nó đồng bộ, chỉ chuỗi, parse là treo tab. Năm 2026 mình ship Dexie, không phải wrapper IDB tự viết. Đọc nặng đưa vào worker, và mình không để database chặn first paint. Bẫy trên production là tăng version: các tab khác phải đóng khi versionchange, nếu không upgrade bị chặn. Mình lắng nghe, đóng, rồi reload. QuotaExceededError nghĩa là xóa LRU và nói với user, không phải hard-code 50MB. Với sync mình ship last-write-wins với updatedAt đến khi product tả được UI xung đột.
Nhớ nhanh
- IndexedDB bất đồng bộ, có index, quota theo origin.
localStoragenhỏ, đồng bộ, chuỗi. - Hãy ship Dexie. Lắng nghe
versionchange, đóng, rồi reload, nếu không upgrade bị chặn. - Đừng chặn first paint. Hydrate từ IDB, rồi network. Scan nặng đưa vào worker.
- Bắt lỗi quota. Eviction là bình thường trên Safari mobile.
Câu hỏi nối tiếp thường gặp
- Theme vs catalog 30MB vs session vs asset đã hash?
localStorage, IndexedDB, cookie httpOnly, Cache API / HTTP cache. - Tab v11 còn mở khi bạn deploy v12?
versionchangechạy; tab cũ phải đóng, nếu khôngonblockedtreo tab mới. - Outbox offline không double-POST? Idempotency key, server 409, và UI xung đột, không phải “bắn rồi hy vọng”.
- IDB có nên giữ PII? Chỉ khi có chuyện eviction và XSS. JS cùng origin đọc được.
Tóm tắt: Web worker là thread chạy nền và không có DOM. Bạn gửi data bằng postMessage (structured clone, hoặc transfer một ArrayBuffer). Dùng cho CPU mà nếu để trên main thread sẽ thành long task (tác vụ chạy > 50 ms).
Vì sao quan trọng: Parse JSON 8MB, CSV, hoặc resize ảnh làm đơ input. Đồng nghiệp bật worker cho sort 3ms, mà chi phí clone còn lớn hơn phần việc. Người phỏng vấn muốn khi nào worker mới đáng dùng, transferable vs structured clone, SharedWorker, và Comlink.
- Cách hoạt động:
new Worker(url, { type: 'module' })mở một thread.postMessagecopy payload (structured clone), trừ khi bạn transfer mộtArrayBuffer, lúc đó bản phía gửi bị zero. - Worker không có DOM và không có instance Vue. Hãy gửi DTO. Function, prototype class, và Vue proxy không clone được:
toRawtrước. - Hãy profile trước. Nếu việc chỉ ~5ms, cứ giữ trên main thread. Đo long task và tổng thời lượng (clone + worker + clone về).
- Vite:
new URL('./parse.ts', import.meta.url)hoặc?worker. Làm đúng chuyện bundler, nếu không bạn ship 404 trên production. Terminate lúc unmount. - Comlink là RPC trên
postMessage. DX tốt cho module quá nặng; cái giá là API gọi qua lại nhiều và transfer bị giấu.postMessagetrần cho một việc / một kết quả.
// parse.worker.ts
self.onmessage = (e: MessageEvent<{ text: string }>) => {
const rows = JSON.parse(e.data.text) as unknown[]
self.postMessage({ count: rows.length }) // logs: { count: 20000 }
}
const worker = new Worker(new URL('./parse.worker.ts', import.meta.url), {
type: 'module',
})
worker.onmessage = (e: MessageEvent<{ count: number }>) => {
total.value = e.data.count
}
worker.postMessage({ text: rawJson })
onScopeDispose(() => worker.terminate())Điều gì xảy ra: Parse 20k hàng chạy ngoài main thread, nên input vẫn paint được. Kết quả là DTO nhỏ (count), không phải Vue proxy. terminate lúc dispose scope tránh leak thread sau khi đổi route. Gửi rawJson vẫn clone chuỗi: với ArrayBuffer đã decode, hãy transfer.
| Cách gửi | Chi phí | Mất |
|---|---|---|
| Structured clone | Copy; Date giữ được | Function, DOM, Vue proxy |
Transfer ArrayBuffer | Rẻ; length bên gửi → 0 | Bên gửi không đọc lại được |
| JSON.stringify | Tạm rẻ; parse trên main thread | Date như Date, undefined, cycle |
| Không worker (việc ~5ms) | Không clone | Không cần, đừng trả giá một luồng |
Góc nhìn senior
- Đừng worker việc nhỏ. Đừng parse 200ms trên luồng chính vì “worker phức tạp”.
SharedWorker(một worker, nhiều tab) hợp cho connection dùng chung hoặc helper IDB. Lịch sử Safari là lý do nhiều team vẫn dùng dedicated worker + BroadcastChannel.SharedArrayBuffercần header COOP/COEP. Đây là chuyện product/bảo mật, không phải flag một dòng.- Một worker mỗi instance component là cách dashboard mọc 40 luồng. Hãy share pool. Bắt
worker.onerror, không thì UI treo ở “loading”. - Hủy việc đang chạy khi đổi route:
terminate, hoặc job id worker kiểm. SSR: không có worker trên server.
Lỗi hay gặp
- Triệu chứng:
postMessage(vueState)throw. Nguyên nhân: Proxy không structured-clone được. Cách xử lý:toRaw+ DTO.- Triệu chứng: lần render sau đọc buffer rỗng. Nguyên nhân: bạn đã transfer
ArrayBuffervàbyteLengthlà 0. Cách xử lý: đừng đọc bản bên gửi.- Triệu chứng: production 404 file worker. Nguyên nhân: sai Vite
?worker/new URL. Cách xử lý: công thức bundler trong bản prod.- Triệu chứng: 40 worker trên dashboard. Nguyên nhân: một worker mỗi widget. Cách xử lý: pool dùng chung; terminate lúc unmount.
Nhà tuyển dụng hay hỏi gì
Parse JSON 8MB / CSV / resize ảnh làm đơ input. Đồng nghiệp bật worker cho sort 3ms. Họ muốn khi nào worker tự trả giá, postMessage tốn gì, transferable vs structured clone, SharedWorker, và Comlink: không phải worker hello-world.
Trả lời khi phỏng vấn
Worker là một luồng không có DOM. Mình dùng cho CPU sẽ phá long task 50ms: parse, codec, resize, không phải cho “async”. Mình đo trước; nếu việc khoảng 5ms mình giữ trên luồng chính vì structured clone có thể tốn hơn phần việc. Mình gửi DTO, không bao giờ gửi Vue proxy, và transfer ArrayBuffer khi chuyển frame đã decode. Comlink ổn cho module nặng; với một việc mình dùng postMessage trần. Mình terminate lúc unmount và share pool để dashboard không mọc 40 luồng. Vite phải emit URL worker ở production, không thì mình ship 404. Ở import admin mình sẽ parse catalog 8MB trong module worker rồi post về DTO count, terminate khi rời.
Nhớ nhanh
- Worker dành cho CPU sẽ thành long task, không phải để “biến thành async”.
- Structured clone copy. Transfer làm zero bên gửi. Vue proxy không clone được.
- Terminate lúc unmount. Share pool. Bắt
onerror. - Làm đúng URL worker Vite ở production. Comlink là DX, không miễn phí.
Câu hỏi nối tiếp thường gặp
- Worker vs
scheduler.yield()vsrequestIdleCallback? Worker cho CPU nặng; yield/idle để luồng chính còn đáp ứng với chunk nhỏ hơn. - Khi nào Comlink là sai? API gọi qua lại nhiều, hoặc bạn cần transfer list mà RPC đã giấu.
- Dedicated vs SharedWorker vs service worker cho WebSocket? Dedicated mỗi tab thì đơn giản; SharedWorker có thể giữ một socket; SW là proxy mạng, mặc định không phải owner của socket.
- Hủy việc worker đang chạy khi đổi route thế nào?
terminate, hoặc generation id worker kiểm trước khipostMessagevề.
Tóm tắt: Service worker là proxy mạng có cache. Nó chặn fetch, precache asset đã hash, và giữ shell offline. Offline là quyết định sản phẩm, không phải bảng strategy.
Vì sao quan trọng: SW sống lâu hơn tab và có thể phục vụ JS cũ nói chuyện với API mới, hoặc cache HTML 200 từ captive portal. skipWaiting giữa form khiến hai tab chạy hai bundle. Người phỏng vấn muốn cache poisoning, UX update, Workbox vs fetch handler tự viết: không phải checklist PWA.
- Cách hoạt động: Vòng đời là install → waiting → activate. Worker đang chờ không control page cho đến
skipWaiting+clients.claim(), hoặc user reload. - Cache asset tĩnh đã hash theo Cache First (Workbox precache). GET API chỉ khi product hiểu policy. Không bao giờ cache API mutation.
- Stale-while-revalidate hợp news feed. Network First hợp số dư.
GET /api/metheo URL là cách rò user sai. - Revision
index.html(hoặc CDNno-cachetrên document) để user nhận bundle hash mới. Cache document mãi là cách kẹt app cũ mà không hay. - Workbox là thứ mình ship. Tự viết là cách quên
ignoreVary, range request, và opaque response. Tắt SW trong Vite dev, không thì nó đánh nhau với HMR.
// Đăng ký, rồi hỏi user trước skipWaiting.
navigator.serviceWorker.addEventListener('controllerchange', () => {
// SW mới đang control page này: reload một lần
})
async function promptUpdate(reg: ServiceWorkerRegistration) {
const waiting = reg.waiting
if (!waiting) return
showBanner('A new version is ready', () => {
waiting.postMessage({ type: 'SKIP_WAITING' })
})
}
reg.addEventListener('updatefound', () => promptUpdate(reg))Điều gì xảy ra: Worker mới install rồi chờ. Banner là UX sản phẩm: user xong form, rồi skip waiting. controllerchange reload để mọi tab chạy một bundle. skipWaiting() ngay trong install bỏ qua bước hỏi đó, và là cách làm hỏng POST đang gửi.
| Resource | Strategy | Vì sao |
|---|---|---|
| JS / CSS đã hash | Cache First / precache | Tên file bất biến |
| Ảnh sản phẩm | Cache First, có trần | Quota; eviction LRU |
GET /api/account | Network First | Số dư / auth phải mới |
| POST checkout | Network only, không cache | Không idempotent |
Góc nhìn senior
- Offline-first là sản phẩm: write nào vào hàng đợi, user thấy gì khi sync fail, bạn giải thích “đã lưu trên máy” thế nào. Nếu PM không hỏi, hãy cache shell, đừng giả app local-first.
skipWaitingngay thì nhanh và nguy hiểm. Banner Vue “Refresh to update” là mặc định senior khi form đang làm dở.- Cài được mà không offline là listing store, không phải kiến trúc. Đừng SW site marketing đang A/B test HTML.
- Kill switch: header hoặc file nhỏ unregister SW. Hãy ship trước khi cần. Canary
/api/međã login không nằm trong Cache Storage. - Background Sync / periodic sync bị nền tảng giới hạn. Đừng hứa giao outbox mà OS không cho.
Lỗi hay gặp
- Triệu chứng: user không bao giờ nhận bundle mới. Nguyên nhân:
index.htmlcache mãi. Cách xử lý: revision document hoặc CDNno-cache.- Triệu chứng: hai tab, hai bundle, form lưu dở. Nguyên nhân:
skipWaiting+clients.claim()tronginstall. Cách xử lý: banner + reload.- Triệu chứng:
/api/mehiện user trước. Nguyên nhân: GET cache theo URL. Cách xử lý: Network First hoặcno-storecho auth.- Triệu chứng: Vite HMR gãy local. Nguyên nhân: SW đăng ký lúc development. Cách xử lý: chỉ đăng ký ở bản production.
Nhà tuyển dụng hay hỏi gì
Không phải checklist PWA. Họ muốn cache poisoning, skipWaiting, user nhận update thế nào, Workbox vs fetch handler tự viết, và câu: offline là quyết định sản phẩm, không phải bảng strategy.
Trả lời khi phỏng vấn
Service worker là proxy mạng có cache. Mình precache static đã hash theo Cache First, chọn Network First cho số dư và auth, và không bao giờ cache POST. Offline là quyết định sản phẩm: nếu chưa có banner update và kill switch, mình không thêm SW chỉ để trông như PWA. skipWaiting trong install là sự cố support vì hai tab chạy hai bundle giữa form. Mình chờ, hiện Refresh to update, rồi reload lúc controllerchange. Mình revision index.html để user không kẹt JS cũ nói chuyện với API mới, và canary /api/me không nằm Cache Storage. Workbox là thứ mình ship; tự viết là cách quên opaque response.
Nhớ nhanh
- Service worker là proxy mạng. Offline là quyết định sản phẩm.
- Cache static đã hash. Đừng cache API mutation hoặc HTML cá nhân.
- Chờ + “Refresh to update”, không
skipWaitingtronginstall. - Hãy ship kill switch. Tắt SW trong Vite development.
Câu hỏi nối tiếp thường gặp
- Cache poisoning ở đây là gì? Cache
GET /api/usertheo URL, HTML 200 từ captive portal, hoặcindex.htmlmãi. - Vì sao
skipWaitingtronginstalllà sự cố support? Trang đang mở đổi bundle giữa session; POST đang gửi chạy với code không còn khớp. - Unregister SW xấu thế nào? Header hoặc file kill-switch;
registration.unregister()rồi xóa Cache Storage. index.htmlđã hash vs CDNno-cache? Cách nào thì document cũng không được cache mãi, nếu không JS precache không bao giờ cập nhật.
Tóm tắt: Intersection Observer cho biết khi target cắt root + margin + threshold. Đó là API cho ảnh lazy, sentinel của list vô hạn, analytics impression, và prefetch route, không phải listener scroll.
Vì sao quan trọng: Handler scroll trên window để toggle class sẽ giật layout. IO bất đồng bộ và được gộp. Nó không phải virtualization: bạn vẫn mount 10k node Vue và chỉ lazy ảnh. Ai unobserve mới là câu hỏi leak.
- Cách hoạt động: Bạn observe target so với root (viewport nếu
null).rootMarginnới hoặc thu root.thresholdlà tỉ lệ nhìn thấy để callback chạy. - Dùng cho ảnh lazy, sentinel cuối list, viewability, và prefetch (
rootMargin: '200px'). Hãy đặt trần prefetch: bạn sẽ fetch link user không bao giờ mở. - Luôn
unobservesau khi ảnh load, hoặcdisconnect()lúc unmount. Node còn được observe là retainer. divcuộn được phải truyền làmroot, không thì IO trông như “hỏng”. SSR: observe trongonMounted; giữwidth/heighttrên ảnh cho CLS.- Khi list lớn, dùng thư viện virtualization (TanStack Virtual,
vue-virtual-scroller). Một mình IO không unmount hàng.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const el = ref<HTMLElement | null>(null)
const show = ref(false)
onMounted(() => {
const io = new IntersectionObserver(([e]) => {
if (!e?.isIntersecting) return
show.value = true
io.unobserve(e.target) // dừng khi đã hiện
}, { rootMargin: '200px' })
if (el.value) io.observe(el.value)
onUnmounted(() => io.disconnect())
})
</script>
<template>
<img v-if="show" :src="src" width="400" height="300" alt="" />
<div v-else ref="el" class="ph" />
</template>Điều gì xảy ra: Placeholder được observe với margin 200px, nên ảnh bắt đầu load ngay trước khi vào màn hình. Khi đã intersect, unobserve bỏ retainer. width/height giữ chỗ layout để CLS (Cumulative Layout Shift) thấp. Đây là lazy-load, không phải virtualizer 10k hàng.
| Việc | IO? | Không đủ khi… |
|---|---|---|
| Ảnh lazy dưới fold | Có | Ảnh LCP: load eager |
| Sentinel list vô hạn | Có | Bạn vẫn mount mọi hàng |
| Ad viewability 50% trong 1s | Một phần | Vẫn cần timer |
| Bảng 10k hàng | Không, hãy virtualize | IO không unmount cell |
Góc nhìn senior
- Prefetch
rootMarginlà đánh cược network. Đặt trần (N link đầu, chỉ nav đang hiện). - Threshold
[0, 0.25, 0.5, 1]cho ads là contract nghiệp vụ. Ghi rõ ra. scrollvớipassive: truevẫn chạy JS mỗi frame. Nó không trả lời “cái này có trong view không”.- Hydration: đừng
v-ifnội dung chỉ sau khi intersect nếu SEO cần nó trong HTML. Lazy dưới fold, không phải ảnh LCP. IntersectionObservermới mỗi lần render (watchẩu / dep của React effect) là leak. Tạo một lần, observe/unobserve các node.- iframe / overflow / CSS
containcó thể khiến target không bao giờ intersect. Kiểm scrollport trước khi đổ lỗi IO.
Lỗi hay gặp
- Triệu chứng: heap phình khi bạn scroll. Nguyên nhân: không bao giờ
unobserve. Cách xử lý: unobserve lúc load;disconnectlúc unmount.- Triệu chứng: IO không bao giờ chạy trong modal. Nguyên nhân:
root: nulltrong khi scroller làdivbên trong. Cách xử lý: truyền element đó làmroot.- Triệu chứng: 50k hàng vẫn giật. Nguyên nhân:
v-forhết, IO chỉ trên ảnh. Cách xử lý: virtualize list.- Triệu chứng: ảnh LCP đến muộn. Nguyên nhân: lazy-load hero. Cách xử lý: eager +
width/heightcho ảnh LCP.
Nhà tuyển dụng hay hỏi gì
Infinite scroll, ad viewability, hay “prefetch route kế khi link còn 200px”. Sao không dùng listener scroll? Khi nào Intersection Observer không phải virtualization? Ai unobserve?
Trả lời khi phỏng vấn
Intersection Observer cho mình biết khi target cắt root, margin, và threshold. Mình dùng cho ảnh lazy, sentinel của list, impression, và prefetch có trần, không phải listener scroll trên window để gạt class. Nó async và được batch, nên sai cho game loop 16ms, và không phải virtualization: nếu mình v-for 10k hàng thì vẫn trả giá 10k node Vue. Mình unobserve sau khi load và disconnect lúc unmount. Modal có scroller bên trong phải truyền element đó làm root. Với bảng lớn mình dùng TanStack Virtual hoặc vue-virtual-scroller, và không bao giờ lazy-load ảnh LCP. Viewability cần một giây thì vẫn cần timer khi đã đạt threshold.
Nhớ nhanh
- IO là root + margin + threshold. Nó hơn
scrollcho câu “cái này có trong view không”. - Luôn
unobserve/disconnect. Node còn được observe là retainer. - IO không phải virtualization. List lớn vẫn cần virtualizer.
- Truyền đúng scrollport làm
root. Đừng lazy-load ảnh LCP.
Câu hỏi nối tiếp thường gặp
- Vì sao listener
scrolltrên window là bug hiệu năng ở đây? Nó chạy trên luồng chính mỗi frame và có thể force layout.passivekhông trả lời “có trong view không”. rootMargin: '400px'ảnh hưởng data mobile thế nào? Bạn prefetch thứ user có thể không bao giờ mở. Đặt trần.- Một mình IO làm được “50% hiện trong 1s” không? Không. Bạn vẫn cần timer khi đã đạt threshold.
- Test IO thế nào? Set
boundingClientRecttrong fixture, hoặc đặt sentinel trong scrollport nhỏ.
Tóm tắt: Coi API nền tảng hiện đại là capability + quyền + vòng đời. Những cái xuất hiện khi phỏng vấn Vue senior là ResizeObserver, structuredClone, BroadcastChannel, View Transitions, và scheduler.yield: mỗi cái có một vết sẹo, không phải hello-world.
Vì sao quan trọng: Clipboard write thường chạy trong click và fail trong watch khi không có user activation. ResizeObserver lặp khi bạn ghi style làm resize node đang observe. scheduler.yield là cách chia long task (tác vụ chạy > 50 ms) mà không cần worker. Bật đại View Transitions trong Vue Router là lời hứa virtual DOM chưa chắc giữ được.
- Cách hoạt động:
ResizeObserverbáo khi box của element đổi. Ưu tiên hơnwindowresizecho chart theo container. Nếu CSS container query làm được, hãy dùng CSS. structuredClonecopyDate/Map(xem JavaScript 1.2.4).toRawtrước trên Vue proxy. Topic này không lặp bảng clone đó.BroadcastChannellà bus cùng origin giữa các tab. Logout đa tab nằm ở- 4.6. Ở đây: nó tồn tại, không phải server push, mọi script trên origin đều nghe được.
scheduler.yield()(nơi được hỗ trợ) trả promise nhường browser để input kịp paint. Fallback:setTimeout(0)/scheduler.postTask. Kiểm tra support trước khi hứa thắng INP.- View Transitions: progressive enhancement, không phải nền của design system. Support do Chromium dẫn.
const box = ref<HTMLElement | null>(null)
onMounted(() => {
const ro = new ResizeObserver((entries) => {
const { width } = entries[0]!.contentRect
chart.value?.resize(width) // đừng set height lên box ở đây
})
if (box.value) ro.observe(box.value)
onUnmounted(() => ro.disconnect())
})
async function parseAll(chunks: string[]) {
for (const chunk of chunks) {
consume(chunk)
if ('scheduler' in window && 'yield' in scheduler) await scheduler.yield()
else await new Promise((r) => setTimeout(r, 0))
}
}Điều gì xảy ra: Observer đo một box riêng rồi resize chart. Ghi height ngược lên box sẽ lặp (ResizeObserver loop completed with undelivered notifications). scheduler.yield (hoặc setTimeout(0)) nhường renderer giữa các chunk để parse dài không thành một long task 200ms.
| API | Dùng khi | Hỗ trợ / sẹo |
|---|---|---|
ResizeObserver | Layout JS (canvas, virtual list) | Lặp nếu bạn resize node đang observe |
scheduler.yield | Chia long task trên luồng chính | Chromium; fallback setTimeout(0) |
| View Transitions | Tăng cường nav cùng document | Chromium dẫn; VDOM Vue vẫn chịu trách nhiệm UI |
| Clipboard | Copy trong click handler | Cần user activation tức thời |
Góc nhìn senior
- Clipboard:
writeTexttrong click là mặc định;readTextlà prompt quyền riêng tư. Fail cho user thấy. Đừng copy bí mật mà không nói với user.execCommand('copy')cũ là thứ sót lại trên intranet. - Permissions-Policy / iframe: geolocation, clipboard, camera fail nếu thiếu
allow=. Xin quyền Notification trong ngữ cảnh, không phải lần visit đầu. AbortSignaltrênfetch,addEventListener({ signal }), stream,AbortSignal.timeout/.any: xem JavaScript 1.2.7. Đừng dạy lại ở đây.- MutationObserver dành cho DOM không phải Vue (widget legacy). Đừng dùng để giả reactivity của Vue.
- View Transitions: biết là đủ trừ khi team có spike. Đừng bán nó như hệ animation.
Lỗi hay gặp
- Triệu chứng: clipboard reject trong
watch. Nguyên nhân: không có user activation tức thời. Cách xử lý: copy trong click handler; hiện state fail.- Triệu chứng:
ResizeObserver loop completed with undelivered notifications. Nguyên nhân: bạn ghi style làm resize node đang observe. Cách xử lý: observe el bên trong; ghi trênrAF.- Triệu chứng: View Transitions nháy trong Vue Router. Nguyên nhân: VDOM và platform cùng morph một cây. Cách xử lý: giữ Vue
<Transition>; coi VT là tùy chọn.- Triệu chứng: geolocation chạy ở app chính, fail trong embed. Nguyên nhân: Permissions-Policy. Cách xử lý:
allowtrên iframe, hoặc đừng hứa.
Nhà tuyển dụng hay hỏi gì
Hiểu kèm sẹo production: quyền Clipboard và user activation tức thời, vòng lặp ResizeObserver, AbortSignal ngoài fetch, View Transitions là thứ bạn đã nghe và sẽ không bật đại trong Vue Router. Họ không muốn hello-world Geolocation.
Trả lời khi phỏng vấn
Mình coi API nền tảng hiện đại là capability, quyền, và vòng đời. ResizeObserver là cách chart Vue fit theo container, hơn window resize, và chỗ fail là vòng lặp khi mình ghi style làm resize node đang observe. structuredClone và BroadcastChannel nằm ở topic JavaScript và đa tab; ở đây mình chỉ nêu tên. scheduler.yield, chỗ được hỗ trợ, là cách mình chia parse dài để INP còn sống, với setTimeout zero làm fallback. View Transitions là progressive enhancement mình sẽ không hứa làm strategy animation của Vue Router. Clipboard write thuộc click handler, không phải watch.
Nhớ nhanh
ResizeObservercho layout JS. Đừng ghi size làm resize node đang observe.scheduler.yieldchia long task nơi được hỗ trợ; fallbacksetTimeout(0).- Clipboard cần gesture của user. View Transitions là tùy chọn, không phải Vue Router.
BroadcastChannelvàstructuredClonecó topic riêng: nêu tên, đừng kể lại.
Câu hỏi nối tiếp thường gặp
- Vì sao clipboard fail trong
watchnhưng chạy trong click? Cần user activation tức thời. Watch không phải gesture. - Vì sao ResizeObserver lặp? Observe một node, rồi gán class làm đổi size. Hãy observe element bên trong.
- API nào nhận
AbortSignalngoàifetch?addEventListener, stream,AbortSignal.timeout/.any. Xem JavaScript 1.2.7. - View Transitions vs Vue
<Transition>? Vue chịu trách nhiệm UI trong app. API nền tảng là enhancement cùng document, Chromium dẫn.
Tóm tắt: BroadcastChannel là bus tin nhắn cùng origin giữa tab, window, và dedicated worker. Dùng để báo mọi tab rằng session đã kết thúc. Event storage cũ trên localStorage là họ hàng yếu hơn, XSS đọc được.
Vì sao quan trọng: User logout ở tab A; tab B vẫn hiện dashboard và tiếp tục PATCH với user đó. Hoặc login ở A phải refresh B. Đây là thiết kế session đa tab. Cookie không emit cho JavaScript khi chúng đổi, nên bạn vẫn cần bus cho state Pinia / Vue Query in-memory.
- Cách hoạt động:
new BroadcastChannel('session')rồipostMessage({ type: 'session:end' }). Mọi tab trên origin đã subscribe đều nhận. Nó không fire sang browser, thiết bị, hay profile khác. - Lúc logout: broadcast
session:end, xóa state in-memory, abort request đang bay, về login. Auth tốt nhất vẫn là cookie httpOnly kèm invalidation phía server. - Event
storagechỉ chạy ở tab khác, và chỉ khi giá trị đổi. Nó không chạy ở tab vừa ghi. ĐừngJSON.parse(null)khiremoveItem. - Tên channel không phải bí mật. Mọi script trên origin đều nghe được. Đừng broadcast token hoặc object user: hãy gửi “refetch
/me” hoặc “session ended”. - Một composable mức app
useSessionBus(). Channel theo component mà không đóng sẽ leak. Đóng channel mức app lúc teardown là đủ.
const bus = new BroadcastChannel('session')
export function useSessionBus() {
function endSession() {
userStore.clear()
queryClient.clear()
bus.postMessage({ type: 'session:end' })
router.push('/login')
}
function onMessage(e: MessageEvent<{ type: string }>) {
if (e.data.type === 'session:end') {
inflight.abort()
userStore.clear()
router.push('/login')
}
}
bus.addEventListener('message', onMessage)
onScopeDispose(() => bus.removeEventListener('message', onMessage))
return { endSession }
}Điều gì xảy ra: Logout ở tab này xóa memory, báo mọi tab khác, và đưa về login. Tab kia abort PATCH đang bay để không ghi với user cũ. Channel mang một type, không phải token. Listener được gỡ theo scope nên test hoặc mount lần hai không xử lý kép.
| Bus | Chạy ở tab ghi? | Dùng cho auth? |
|---|---|---|
BroadcastChannel | Có, nếu bạn cũng xử lý local | Có: message type, không phải token |
Event storage | Không: chỉ tab khác | Legacy, nếu token đã nằm trong localStorage |
SharedWorker | N/A: một worker, nhiều tab | Có thể cho một socket; nhiều phần phải canh |
| Cookie thay đổi | Không có event JS | Vẫn cần bus cho bộ nhớ Pinia |
Góc nhìn senior
- Auth tốt nhất: cookie httpOnly + invalidation phía server + BroadcastChannel để xóa bộ nhớ. Bạn vẫn cần broadcast vì cookie không emit sang JS.
- Token trong
localStoragethì dính XSS là xong. Nếu bạn nhận sẵn kiểu này,storagesync được logout, nhưng bọc bằngBroadcastChannelđể payload không thành “parse JSON này từ một storage key”. - Đừng dùng event
storagelàm pub/sub trong cùng tab. Chỗ đó là Pinia,mitt, hoặc một emitter nhỏ. - Race: tab B còn POST đang bay sau logout. Abort (
AbortControllertheo scope Vue) và bỏ qua 401 sausession:end. - Hai thiết bị, hoặc Chrome + Firefox trên một máy, không giải được. Đó là revoke session phía server.
Lỗi hay gặp
- Triệu chứng: tab B vẫn PATCH sau khi A logout. Nguyên nhân: không có bus, hoặc bạn chỉ xóa cookie. Cách xử lý: broadcast
session:endvà abort request đang bay.- Triệu chứng: handler
storagekhông chạy ở tab vừa logout. Nguyên nhân: event không fire ở tab ghi. Cách xử lý: tự xử lý nhánh local.- Triệu chứng:
JSON.parsethrow lúc logout. Nguyên nhân:event.newValue === nullkhiremoveItem. Cách xử lý: check null trước khi parse.- Triệu chứng: bạn broadcast object user. Nguyên nhân: coi channel như ống riêng. Cách xử lý: gửi type; refetch
/me.
Nhà tuyển dụng hay hỏi gì
User logout ở tab A; tab B vẫn hiện dashboard và tiếp tục PATCH với user đó. Hoặc login ở A thì B phải refresh. Đây là thiết kế session đa tab, không phải API đố vui. Khi auth là cookie httpOnly, BroadcastChannel còn làm đúng việc gì?
Trả lời khi phỏng vấn
BroadcastChannel là bus cùng origin giữa các tab. Lúc logout mình broadcast session end, xóa Pinia và query cache, abort request đang bay, rồi route về login. Kể cả cookie httpOnly mình vẫn cần bus đó, vì cookie không emit sang JavaScript khi server invalidate chúng. Mình không nhét token lên channel: mình gửi một type rồi refetch /me. Event storage chỉ fire ở tab khác và chỉ khi giá trị đổi, nên nó là bus auth kém và không báo được tab vừa ghi. Hai thiết bị là revoke phía server, không phải bus browser. Chỗ nào có BroadcastChannel mình ưu tiên nó hơn event storage, kể cả quirk Safari private mode.
Nhớ nhanh
BroadcastChannelđồng bộ bộ nhớ giữa các tab cùng origin. Nó không phải server push.- Cookie httpOnly vẫn cần bus: chúng không emit sang JS khi giá trị đổi.
- Broadcast một type, không phải token. Abort các write đang bay sau
session:end. storagechỉ fire ở tab khác. Hai thiết bị cần revoke phía server.
Câu hỏi nối tiếp thường gặp
- Vì sao
storagekhông fire ở tab gọiremoveItem? Spec chỉ báo document khác. Tự xử lý nhánh ghi. - Auth bằng cookie: BroadcastChannel vẫn làm gì? Xóa bộ nhớ Pinia / Vue Query và abort request đang bay. Cookie đổi thì JS không hay biết.
- Có broadcast object user lúc login không? Không. Gửi “session started” rồi refetch
/me. - BroadcastChannel vs SharedWorker vs ping
localStorage? Channel cho admin 3 tab; SharedWorker nếu cần một socket; storage chỉ khi bạn đã lưu ở đó.