Bài này nói về JavaScript mức senior cho phỏng vấn Vue và React: array method và mutation, promise, event loop, scope, closure, this, memory, và cancellation. Dành cho engineer đã ship UI, giờ cần giải thích cơ chế, không phải thuộc lòng API.
Đọc 1.1 trước (map/filter/reduce, promise, event loop, var/let/const). Người phỏng vấn hỏi event loop và async/await gần như mọi lần. Rồi đọc 1.2 về closure, this, prototype, và AbortController. Với mỗi topic, nêu rõ ràng buộc, chỗ gãy, và cách bạn chứng minh fix trong DevTools.
Tóm tắt: Hàm bậc cao nhận một hàm làm đối số, trả về một hàm, hoặc cả hai. map, filter, reduce, some, và find là các helper trên mảng mà bạn sẽ phải chọn.
Vì sao quan trọng: Trong bảng Vue 3 gắn vào rows, đồng nghiệp viết rows.sort(byName).filter(isActive).map(toView). Thứ tự gốc mất, child nhận rows qua prop lại mutate parent, và list 100k hàng bị giật. Bug nằm ở mutation và copy thừa, không phải “quên forEach”.
- Cách hoạt động:
mapluôn trả mảng mới cùng độ dài.filtertrả tập con.reducegộp các phần tử thành một giá trị.some/finddừng sớm với boolean hoặc phần tử khớp đầu tiên. forEachchỉ dành cho side effect (tác dụng phụ). Nó trảundefined, nên dùng vớipushđể dựng mảng chỉ làmapviết kém.sort,reverse, vàsplicemutate mảng gốc và trả cùng reference.toSorted/toReversed/toSplicedcopy trước.- Proxy của Vue 3 đã track index và length, nên
pushtại chỗ trên state bạn phụ trách vừa rẻ vừa đúng. React so identity, nên sort tại chỗ rồisetRows(rows)không làm gì.
const rows = [
{ id: 1, name: 'Ada', active: true, qty: 2 },
{ id: 2, name: 'Lin', active: false, qty: 5 },
{ id: 3, name: 'Ada', active: true, qty: 0 },
]
const names = rows.map((r) => r.name) // ['Ada', 'Lin', 'Ada']
const live = rows.filter((r) => r.active) // hai hàng
const hasZero = rows.some((r) => r.qty === 0) // true: không phải filter(Boolean)
// ❌ mutate rows và trả cùng reference
const broken = rows.sort((a, b) => a.name.localeCompare(b.name))
// broken === rows → true
// ✅ derived view: copy, rồi filter
const visible = rows
.toSorted((a, b) => a.name.localeCompare(b.name))
.filter((r) => r.active)Điều gì xảy ra: map / filter / some đều trả giá trị mới và để rows yên. sort thì không: sau broken = rows.sort(...), broken === rows là true, nên thứ tự gốc đã mất. toSorted copy trước, đó là derived view an toàn cho computed Vue hoặc hàm render React.
| Method | Trả về | Mutate? |
|---|---|---|
map | Mảng mới, cùng độ dài | Không |
filter / find / some | Tập con, phần tử, hoặc boolean | Không |
reduce | Một giá trị tích lũy | Không (trừ khi mutate trong reducer) |
sort / splice | Cùng array reference | Có: dùng toSorted |
Góc nhìn senior
- Trong Vue, mutate mảng reactive bạn phụ trách (
push,splice). Copy khi mảng là prop, đang share, hoặc là derived view. - React so identity. Sort tại chỗ rồi
setState(rows)là no-op thầm lặng. - Chain
filterrồimapdễ đọc và ổn với list nhỏ. Với 100k hàng, đo trước; một vòng (forhoặcreduceđặt tên rõ) chỉ thắng so với ba lần allocate khi profile nói vậy. - Virtualize DOM (
content-visibility, TanStack Virtual,vue-virtual-scroller) mới thường là cách xử lý 100k hàng, không phải array method nhanh hơn. sort()mặc định là lexical:[10, 2, 1]thành[1, 10, 2]. Luôn truyền comparator.
Lỗi hay gặp
- Triệu chứng: bảng con làm xáo thứ tự parent. Nguyên nhân:
sort/reversetrên prop. Cách xử lý:toSortedtrongcomputed, đừng mutate prop.- Triệu chứng: list React không cập nhật sau sort. Nguyên nhân: cùng identity mảng. Cách xử lý:
setRows(rows.toSorted(cmp)).- Triệu chứng:
rows.filter(Boolean)làm mất ô0/''hợp lệ. Nguyên nhân: truthiness, không phải emptiness. Cách xử lý: so vớinull/undefinedtường minh.- Triệu chứng:
forEach+pushvào mảng Pinia. Nguyên nhân: viết lạimapvà mutate store dùng chung. Cách xử lý:mapra mảng mới, rồi gán một lần.
Người phỏng vấn hỏi gì
Bạn phụ trách một bảng Vue 3 (hoặc list React) gắn vào rows. Đồng nghiệp chain sort, filter, và map trên mảng đó. Vì sao thứ tự gốc biến mất, vì sao child mutate parent, và với 100k hàng bạn sẽ ship gì thay vì “map nhanh hơn”?
Trả lời khi phỏng vấn
Hàm bậc cao nhận hoặc trả về một hàm khác. map giữ độ dài và biến đổi phần tử, filter giữ tập con, reduce gộp thành một giá trị, còn forEach chỉ cho side effect. Bẫy production là mutation: sort và splice đổi mảng gốc và trả cùng reference, nên “mình đã sort một bản copy” là sai trừ khi mình dùng toSorted. Trong Vue mình mutate mảng mình phụ trách, nhưng bảng derived thì copy trong computed. Ở bảng admin mình ship, view đó là toSorted kèm filter; mình không clone 100k hàng mỗi lần gõ. Mình đo trước, rồi virtualize DOM nếu Profiler nói DOM mới là chi phí.
Nhớ nhanh
map/filter/reducetạo bản mới.sort/splicemutate và trả cùng reference.- Dùng
toSortedtrongcomputedVue hoặc hàm render React. Đừng sort prop tại chỗ. - Với 100k hàng, đo, rồi virtualize. Đừng clone mỗi lần gõ “cho immutability”.
filter(Boolean)làm mất0và''hợp lệ. So vớinull/undefinedtường minh.
Câu hỏi nối tiếp thường gặp
next === itemssauconst next = items.sort(cmp)là gì?true. Vue vẫn thấy đổi index; ReactsetState(next)không làm gì.- Vì sao
toSortedtrongcomputedan toàn? Nó không mutate dependency.sorttrongcomputedlàm hỏng state nguồn. - Khi nào virtualize thay vì array method nhanh hơn? Khi DOM, không phải bước biến đổi, mới là chi phí, thường là hàng nghìn hàng.
- Sort 100k hàng mà không chặn input thế nào? Chunk, worker, hoặc sort trên server sau khi đã có Performance trace.
Tóm tắt: Promise là object cho một giá trị tương lai. Nó pending, rồi fulfilled hoặc rejected. async / await là cú pháp trên promise, không phải runtime thứ hai.
Vì sao quan trọng: setup() của dashboard await getUser(), rồi await getPosts(user.id), rồi await getFlags(). Flags không phụ thuộc user. Một trong ba trả 500 và spinner không bao giờ tắt. Câu hỏi là chạy song song, chính sách lỗi, và catch rỗng sẽ vẽ ra gì.
- Cách hoạt động:
new Promisebắt đầu ở pending.resolve(value)thì fulfilled;reject(err)thì rejected. Promise fulfilled chạy.then; rejected chạy.catch..finallyluôn chạy. - Hàm
asyncluôn trả về Promise.awaittạm dừng hàm đó đến khi promise settle, rồi chạy tiếp trên một microtask. Nó không chặn event loop. try/catchquanhawaitgiống.catchtrên promise.catch { console.error }rỗng trảundefinedvà trông như “không có data”.awaitnối tiếp là waterfall. Call độc lập thì cho vàoPromise.all. Chỉ waterfall khi data phụ thuộc thật, ví dụ posts cầnuser.id.
async function loadSlow(userId: string) {
const user = await getUser(userId)
const posts = await getPosts(user.id)
const flags = await getFlags() // độc lập: tốn một round trip thừa
return { user, posts, flags }
}
async function loadFast(userId: string) {
const [user, flags] = await Promise.all([getUser(userId), getFlags()])
const posts = await getPosts(user.id) // dependency thật
return { user, posts, flags }
}
// chain .then cùng một ý:
getUser(id)
.then((user) => Promise.all([user, getFlags(), getPosts(user.id)]))
.catch((err) => {
reportError(err)
throw err
})Điều gì xảy ra: loadSlow chờ user, rồi posts, rồi flags: ba round trip nối tiếp. loadFast chạy user và flags cùng lúc; posts vẫn chờ user.id. Nếu getFlags reject, Promise.all reject cả nhóm. Chain .then là cùng một contract: settle một lần, một đường lỗi.
| Combinator | Dùng khi | Khi lỗi |
|---|---|---|
Promise.all | Trang không render nếu thiếu bất kỳ kết quả nào | Fail-fast: một reject làm fail cả nhóm |
allSettled | Widget hiện được dữ liệu một phần | Vẫn phải đếm các reject |
try/catch từng await | Mỗi call có fallback riêng | Dễ biến 500 thành data rỗng |
await nối tiếp | Phụ thuộc data thật | Luôn chậm hơn khi các call độc lập |
Góc nhìn senior
- Coi
async/awaitlà cú pháp control-flow. Quyết định nằm ở combinator vàAbortSignal. Promise.allsai khi 9 trên 10 widget render được dù thiếu cái thứ 10. Tách nhánh optional hoặc dùngallSettled.- Đừng
awaittrongforEachcác id độc lập: nó không wait. Dùngfor…ofhoặcPromise.allvới trần concurrency (khoảng 4–8), đừng bắn 10k một lúc. - Promise không cancel. Truyền một
AbortSignalvào mọi fetch và abort khi unmount (onScopeDisposetrong Vue). - Nuxt
$fetchmặc định throw khi HTTP lỗi;fetchtrên window thì không. Biết rõ bạn đang dùng contract nào.
Lỗi hay gặp
- Triệu chứng: spinner mãi sau một lần 500. Nguyên nhân:
Promise.allfail-fast mà không có error state trên UI. Cách xử lý: fail contract cho user thấy, hoặc dùngallSettledcho widget optional.- Triệu chứng: UI hiện “không có hàng”. Nguyên nhân:
catch { console.error }trảundefined. Cách xử lý: report lỗi, rồi throw hoặc gán error state tường minh.- Triệu chứng: bị charge hai lần. Nguyên nhân: retry POST không idempotent. Cách xử lý: chỉ retry GET/PUT và 429/503, kèm idempotency key.
- Triệu chứng: vẫn ghi sau khi rời màn. Nguyên nhân: không abort. Cách xử lý: một
AbortControllermỗi màn, abort trongonScopeDispose.
Nhà tuyển dụng hay hỏi gì
Một dashboard await user, rồi posts, rồi flags. Flags không cần user. Một call trả 500 và spinner treo. Đi từng bước: call nào chạy song song được, Promise.all vs allSettled nghĩa là gì với trang, và catch rỗng sẽ vẽ ra gì.
Trả lời khi phỏng vấn
Promise là giá trị settle một lần: pending, rồi fulfilled hoặc rejected. async/await là cú pháp trên đó, nên quyết định nằm ở combinator nào và abort signal nào. Call độc lập đi vào Promise.all nếu trang không render thiếu cái nào; widget optional dùng allSettled hoặc tách nhánh. Mình chỉ waterfall khi có phụ thuộc thật, như posts cần user.id. Trên dashboard mình start user và flags cùng lúc, rồi mới fetch posts. catch rỗng trả undefined là cách vẽ “no data” khi 500. Mình report lỗi, fail contract UI, và truyền một AbortSignal vào mọi fetch.
Nhớ nhanh
- Ba state: pending, fulfilled, rejected. Hàm
asyncluôn trả về Promise. try/catchquanhawaitcũng là.catch. Catch rỗng vẽ “no data”.- Chạy song song các call độc lập bằng
Promise.all. Chỉ waterfall khi phụ thuộc data thật. - Promise không cancel. Truyền
AbortSignalvà abort khi unmount.
Câu hỏi nối tiếp thường gặp
allvsallSettledvstrytừng call? Checkout cầnallfail-fast. Dashboard widget cầnallSettledhoặc tách nhánh để một lần 500 không làm trắng màn.- Vì sao
awaittrongforEachkhông wait?forEachbỏ qua promise trả về. Dùngfor…ofhoặcPromise.all. - Retry đặt chỗ nào? Chỉ GET/PUT idempotent và 429/503. Đừng retry POST hoặc 400/401/403 nếu không có idempotency key.
- Chỗ này gặp Vue
nextTickthế nào?awaitchạy tiếp như microtask, cùng hàng đợi Vue dùng để flush DOM. Chi tiết timing nằm ở topic event loop.
Tóm tắt: JavaScript chạy một call stack. Khi stack trống, event loop rút hết hàng đợi microtask trước, rồi có thể paint, rồi lấy một macrotask (task) từ hàng đợi task.
Vì sao quan trọng: Bạn gán ref Vue rồi đọc ngay el.offsetHeight: nhận giá trị cũ. Hoặc vòng parse await dồn dập làm UI treo. Người phỏng vấn bắt đầu bằng puzzle A D C B, rồi hỏi vì sao có nextTick và vì sao trang không kịp paint.
- Cách hoạt động: Call stack là các hàm đang chạy.
console.logđồng bộ chạy ở đây. Không có gì khác xảy ra cho đến khi stack trống. - Hàng đợi microtask chứa job của Promise,
queueMicrotask,MutationObserver, và flush scheduler của Vue. Sau khi stack trống, mọi microtask chạy trước paint hoặc macrotask kế. - Hàng đợi task (macrotask) chứa
setTimeout,setInterval, I/O, và sự kiện input. Vòng lặp lấy một macrotask mỗi lượt, rồi lại rút hết microtask. - Render (style, layout, paint) có thể xảy ra sau microtask, trước macrotask kế.
- Long task (tác vụ chạy > 50 ms) trên main thread (luồng chính) làm trễ input. INP (Interaction to Next Paint) là thời gian trang phản hồi bằng hình ảnh sau click hoặc phím.
Ẩn dụ: Call stack là đầu bếp đang làm xong món hiện tại. Microtask là giấy nhớ trên quầy, phải xong hết trước khi bày đĩa. Macrotask là phiếu mới từ cửa sổ. Paint là bày đĩa. Nếu bạn cứ thêm giấy nhớ, đĩa không bao giờ rời bếp. Đó là microtask starvation.
console.log('A')
setTimeout(() => console.log('B'), 0) // macrotask
Promise.resolve().then(() => console.log('C')) // microtask
console.log('D')
// logs: A, D, C, B
rows.value = next
await nextTick() // flush của Vue là microtask. Layout có thể vẫn dirty.
const h = el.value?.offsetHeight // read này force layout, hãy gom các read
// Nhường input bằng macrotask, không phải vòng microtask.
async function parseChunks(chunks: string[]) {
for (const chunk of chunks) {
consume(JSON.parse(chunk))
await new Promise((r) => setTimeout(r, 0))
}
}Điều gì xảy ra: A và D chạy trên stack. Callback Promise.then được xếp vào hàng microtask; setTimeout vào hàng macrotask. Sau D, vòng lặp rút hết microtask (C) trước timer (B). nextTick chờ flush của chính Vue, cũng là microtask, không chờ paint. setTimeout(0) là cách cắt parse dài để input còn chạy được.
| API | Hàng đợi | Thấy DOM mới của Vue? | Sau paint? |
|---|---|---|---|
nextTick | Microtask flush của Vue | Có, sau khi Vue patch | Không |
Promise.then | Microtask | Chỉ khi xếp sau Vue | Không |
setTimeout(0) | Macrotask | Có | Thường có: đã nhường một nhịp paint |
requestAnimationFrame | Trước paint tiếp theo | Có | Không: nó là callback của paint |
Góc nhìn senior
- Vue 3 xếp DOM update thành microtask.
nextTicklà API cho “đọc layout sau khi ref commit”.Promise.thencũng là microtask và không phải thứ thay thế được: nó có thể chạy trước job của Vue. setTimeout(0)nhường paint và input. Dùng nó để cắt một long task, không phải để đo node vừa ghi.requestAnimationFramechạy trước paint tiếp theo. Dùng nó (hai lần nếu cần layout sau paint) để bắt CSS transition, không phải làm delay chung.- Microtask starvation: chuỗi
awaittrên promise đã resolve không bao giờ trả lại renderer. Cắt bằng macrotask hoặcscheduler.yield(). - Đo bằng panel Performance: long task > 50 ms, INP, total blocking time. Output puzzle A/D/C/B không phải metric.
Lỗi hay gặp
- Triệu chứng:
el.offsetHeightcũ ngay sau khi ghi ref. Nguyên nhân: Vue chưa flush. Cách xử lý:await nextTick()trước khi đọc, và gom các lần đọc để tránh forced reflow.- Triệu chứng: UI treo lúc parse, Performance hiện một task 200 ms. Nguyên nhân: vòng microtask hoặc parse đồng bộ. Cách xử lý: nhường bằng
setTimeout(0)hoặcscheduler.yield().- Triệu chứng: dùng
setTimeout(0)để “fix timing Vue”. Nguyên nhân: bạn cầnnextTick, cái chờ flush của Vue, không phải macrotask sau đó.- Triệu chứng:
await nextTick()rồi đo rồi ghi layout. Nguyên nhân: forced reflow. Cách xử lý: đọc hết layout, rồi mới ghi, hoặc dùngrAF.
Nhà tuyển dụng hay hỏi gì
Họ có thể mở bằng thứ tự console.log / Promise / setTimeout. Đó là phần khởi động ngắn. Câu hỏi chính: bạn gán ref, đọc ngay el.offsetHeight, nhận giá trị cũ; hoặc UI treo trong vòng parse await dồn dập. Vue nextTick, Promise, và setTimeout khác nhau thế nào, và vì sao trang không kịp paint?
Trả lời khi phỏng vấn
Sau khi call stack trống, mọi microtask chạy trước khi trình duyệt paint hoặc lấy macrotask kế. Vì thế A D C B in đúng thứ tự đó: A và D đồng bộ, C là microtask của Promise, B là timer. Vue 3 flush DOM update thành microtask, nên mình dùng nextTick để đọc layout sau khi ref commit. Promise.then không thay được. setTimeout(0) nhường paint và input, đúng là cách mình cắt parse dài. Nếu Performance trace lúc dán CSV hiện long task 200 ms, mình cắt bằng macrotask, không phải vòng microtask, vì INP là thời gian phản hồi bằng hình ảnh sau lần gõ đó.
Nhớ nhanh
- Stack trống, rồi hết microtask, rồi có thể paint, rồi một macrotask.
nextTickchờ patch của Vue, không chờ paint.setTimeout(0)nhường paint và input.- INP là thời gian phản hồi hình ảnh sau input. Long task > 50 ms trên main thread làm hại nó.
- Chuỗi
awaitđã resolve có thể không nhường paint. Cắt bằng macrotask.
Câu hỏi nối tiếp thường gặp
- Thứ tự sau khi ghi ref:
nextTick(cb)vsPromise.then(cb)vssetTimeout(cb, 0)?nextTickthấy DOM mới của Vue.Promise.thencó thể chạy sớm hơn.setTimeoutthấy DOM mới và thường có nhịp paint. - Microtask starvation hiện trên Performance trace thế nào? Một long task xanh dài không có frame, trong khi job Promise cứ xếp thêm job.
setTimeout(0)vsrequestAnimationFrame? Timer là macrotask;rAFlà callback paint kế. DùngrAFđể khớp với paint.- INP khi dán CSV 5 MB? Nhường giữa các chunk để lần gõ còn được một frame. Đừng parse cả file trong một lượt microtask.
Tóm tắt: var có phạm vi hàm và gán lại được. let có phạm vi block và gán lại được. const có phạm vi block và không gán lại được. Nó không freeze object.
Vì sao quan trọng: Bug cổ điển là for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) in ra 3, 3, 3. Người ta còn nhầm bug đó với await trong vòng lặp, vốn là lỗi khác. const không làm user.role = 'guest' thành bất hợp lệ.
- Cách hoạt động:
vardùng được trong cả hàm, kể cả phía trên dòng khai báo (khởi tạo làundefined).letvàconstsống trong khối{ }nơi chúng được khai báo. - TDZ (Temporal Dead Zone) là khoảng từ lúc vào block đến khi
let/constđược khởi tạo. Đọc binding lúc đó thì throwReferenceError, không phảiundefined. - Gán lại (
count = 1) đổi binding. Mutation (user.role = 'guest'hoặcarr.push) đổi object mà binding đang trỏ tới.constchỉ cấm gán lại. - Bug
var+setTimeoutlà callback được lên lịch, dùng chung mộtiphạm vi hàm.awaittuần tự trongfor (var …)khác: mỗi vòng đều chờ.
// ❌ var có phạm vi hàm: mọi callback thấy i cuối
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// logs: 3, 3, 3 (không phải 0, 1, 2)
// await tuần tự KHÔNG mắc bug đó: mỗi bước đều chờ.
for (var j = 0; j < 3; j++) {
await save(j) // chạy 0, rồi 1, rồi 2
}
// ✅ let tạo binding cho từng vòng
for (let k = 0; k < 3; k++) {
setTimeout(() => console.log(k), 0)
}
// logs: 0, 1, 2
const user = { role: 'admin' }
user.role = 'guest' // hợp lệ: const là binding, không phải freezeĐiều gì xảy ra: Ba callback setTimeout chạy sau vòng lặp. Với var i chỉ có một i, đã là 3. Với let k mỗi vòng có k riêng. Vòng await save(j) tuần tự, nên dù var j vẫn in 0 rồi 1 rồi 2. user.role = 'guest' mutate object; binding const vẫn trỏ cùng object.
| Quy tắc | var | let | const |
|---|---|---|---|
| Phạm vi | Hàm | Khối | Khối |
| TDZ | Không (bắt đầu là undefined) | Có | Có |
| Gán lại binding | Có | Có | Không |
| Mutate object | Có | Có | Có, không phải freeze |
Góc nhìn senior
- Mặc định
const. Chỉletkhi binding phải đổi (index, số lần retry). Code mới không dùngvar; bật lintno-var/prefer-const. constkhông phảiObject.freeze. Freeze nông và có thể phá proxy Vue 3. Dùng Vuereadonly()hoặc copy thật.const { items } = propschụp snapshot giá trị và làm mất reactivity Vue. DùngtoRefs/storeToRefs.- Import ESM vòng tròn đụng TDZ thì throw
ReferenceErrorlúc load, không phải mộtundefinedbí ẩn. Cắt vòng bằng hàm, lazy getter, hoặc module thứ ba. - Đừng nói với người phỏng vấn rằng
varkèmawaittuần tự sẽ bắn id cuối. Điều đó sai; ví dụ đúng là vòng lặp với callback được lên lịch.
Lỗi hay gặp
- Triệu chứng: mọi timer đều log index cuối. Nguyên nhân:
varkèm callback được lên lịch trong vòng (setTimeout,.then, event handler). Cách xử lý:let, hoặc truyền index như đối số.- Triệu chứng: “
constlẽ ra phải chặnpush”. Nguyên nhân:constlà binding. Cách xử lý: copy (toSorted, spread) nếu bạn cần mảng mới.- Triệu chứng: list Vue không cập nhật sau destructure. Nguyên nhân:
const { items } = props. Cách xử lý:toRefs(props).- Triệu chứng: app crash lúc load với
ReferenceErrortrong store. Nguyên nhân: TDZ do import vòng. Cách xử lý: cắt vòng; đừng “default biến thànhundefined”.
Người phỏng vấn hỏi gì
Họ muốn một bảng 20 giây, rồi bạn có biết const không freeze object, TDZ có trong ES module, và var kèm callback được lên lịch trong vòng lặp là bug khác với await tuần tự.
Trả lời khi phỏng vấn
Mình mặc định const và chỉ let khi binding phải đổi. var có phạm vi hàm và không có TDZ, nên for (var i…) setTimeout(() => console.log(i)) in i cuối ba lần: callback chạy sau và dùng chung một i. await tuần tự trong vòng đó không mắc bug này, vì mỗi vòng đều chờ. Mình sẽ không nói với người phỏng vấn rằng var kèm await sẽ bắn id cuối. const không freeze object: user.role = 'guest' vẫn hợp lệ. Nếu cần immutability trong Vue, mình dùng readonly() hoặc copy, không Object.freeze trên proxy, và lint mặc định const bằng prefer-const.
Nhớ nhanh
constkhóa binding, không khóa object.pushvà ghi property vẫn chạy.- TDZ: đọc
let/consttrước khi init throwReferenceError, không phảiundefined. - Bug vòng lặp là
var+ callback được lên lịch.awaittuần tự không phải bug đó. const { items } = propslàm mất reactivity Vue. DùngtoRefs/storeToRefs.
Câu hỏi nối tiếp thường gặp
constcó chặnarray.pushkhông? Không. Reviewer flag khi họ muốn copy, không phải freeze.- Chỉ ra crash TDZ do import vòng. Module A import B ở top level trong khi B đọc
export const storecủa A lúc eval. Cắt vòng bằng export một hàm. vartrong script sloppy? Nó có thể thành binding trênwindow.let/constthì không.- Vue
reactive+ gán lại?const state = reactive({ n: 0 }); state.n++ổn.state = {}là TypeError.
Tóm tắt: Closure là hàm kèm các biến ở nơi nó được viết. Môi trường đó là lexical scope: hàm vẫn thấy các binding đó sau khi hàm ngoài đã return.
Vì sao quan trọng: Ô search vẫn fetch query trước. watch Vue log page của hôm qua. Helper debounce gửi args lần gọi đầu. Map ở module vẫn giữ export 20MB của user trước sau logout. Đó là closure stale và retainer, không phải thiếu createCounter.
- Cách hoạt động: Lexical scope nghĩa là hàm tìm tên ở nơi nó được viết, không phải nơi được gọi. Hàm lồng nhau tạo chuỗi scope ra tới module.
- Closure là object hàm đó kèm môi trường nó bắt được. Hàm trong vẫn đọc và ghi binding ngoài sau khi lần gọi ngoài đã kết thúc.
- Nó giữ binding, không phải snapshot, trừ khi bạn copy một
constlúc schedule. Lần ghi sau vào cùng binding hiện với mọi closure còn sống. - Closure stale là callback đã schedule vẫn thấy bản copy cũ, hoặc binding cũ bạn không còn muốn. Abort kèm “đọc giá trị mới bên trong hàm async” là cách xử lý thường gặp ở Vue.
- Những gì closure giữ thì garbage collection chưa thu được đến khi hàm được thả. Singleton ở module là retainer sống lâu nhất.
function debounceLatest<T extends unknown[]>(fn: (...args: T) => void, ms: number) {
let timer = 0
let last: T
return (...args: T) => {
last = args // giữ args mới nhất, không phải lần gọi #1
clearTimeout(timer)
timer = window.setTimeout(() => fn(...last), ms)
}
}
watch(
() => props.query,
async (query, _prev, onCleanup) => {
const ac = new AbortController()
onCleanup(() => ac.abort())
const page = currentPage.value // đọc sau mỗi lần đổi
results.value = await search(query, page, { signal: ac.signal })
},
)Điều gì xảy ra: debounceLatest lưu last trên wrapper, nên timeout luôn chạy với args mới nhất. watch abort fetch trước và đọc currentPage.value bên trong callback, nên lần lật page sau không kẹt ở page đầu. Không có onCleanup, search đầu vẫn có thể ghi results.
| Bug | Vue | React |
|---|---|---|
| Query / page stale | Đọc ref bên trong callback async | useRef cho giá trị mới, hoặc đưa vào dep và abort |
| Debounce gửi args lần #1 | Lưu last trên object wrapper | Cùng helper; đừng close over args đầu |
| Map ở module sau logout | Xóa hoặc gắn key cho cache khi auth đổi | Giống: không blob theo user ở module |
Góc nhìn senior
- Closure là cách
watch, debounce, và cache module hoạt động. Coi chúng là retainer, không phải câu đố trivia. - Trong Vue, đọc ref mới bên trong callback async (
page.value). Copyconst page = page.valuetrướcawaitthì đóng băng một lần render. - Abort và bỏ kết quả stale. Chỉ đọc ref mới vẫn tốn network và có thể xong sau cùng.
- React
useCallbackvới[]là cùng bug closure stale, khác cú pháp.useRefhoặc đưa vào dep. - Kiểm chứng: test query thứ hai resolve trước và kết quả thứ nhất bị bỏ, kèm retainer Memory trên export 20MB sau logout. Unit test debounce gõ “ab” rồi “abc” thì chỉ được search “abc”.
Lỗi hay gặp
- Triệu chứng: debounce luôn search lần gõ đầu. Nguyên nhân: timeout close over
argslần gọi #1. Cách xử lý: lưulasttrên wrapper.- Triệu chứng:
watchlogpagehôm qua. Nguyên nhân:const page = page.valuetrướcawait. Cách xử lý: đọc.valuetrong hàm async.- Triệu chứng: heap phình sau mỗi lần search. Nguyên nhân:
watchtrong util khôngonScopeDispose(stop). Cách xử lý: stop theo scope component.- Triệu chứng: sau logout, Memory vẫn hiện
exportBlob. Nguyên nhân:Mapở module giữ user trước. Cách xử lý: xóa lúc logout; đừng giữ blob theo user ở module.
Người phỏng vấn hỏi gì
Ô search vẫn fetch query trước, watch log page hôm qua, helper debounce gửi args stale, và Map ở module vẫn giữ export 20MB của user trước. Họ muốn closure stale và retainer, không phải bạn có viết được createCounter hay không.
Trả lời khi phỏng vấn
Closure là hàm kèm môi trường lexical nơi nó được viết, nên nó giữ các binding đó sau khi lần gọi ngoài return. Nó giữ binding, không phải snapshot, trừ khi mình copy một const lúc schedule. Trong Vue mình đọc page.value bên trong callback async, không phải trước await; tương đương React là useEffect với list rỗng, bỏ qua id sau. Mình abort request trước và bỏ kết quả stale: chỉ đọc ref mới vẫn tốn network. Và mình coi singleton module là retainer: nếu chúng giữ export 20MB của user trước sau logout, đó là bug bảo mật mình sẽ chứng minh trên panel Memory.
Nhớ nhanh
- Closure giữ binding, không phải snapshot, đến khi hàm được thả.
- Trong Vue, đọc ref mới trong callback async. Copy trước await đóng băng một lần render.
- Abort và bỏ kết quả stale. Chỉ đọc ref mới vẫn tốn network.
- Map ở module giữ blob theo user sau logout là retainer và bug bảo mật.
Câu hỏi nối tiếp thường gặp
- Closure có copy giá trị không? Không. Nó giữ binding. Hãy tự copy (
const q = query) nếu cần snapshot. - Abort search trước thế nào?
AbortControllertrongwatchonCleanup, rồi bỏAbortErrorvà không ghiresultsstale. - Khi nào closure ở module là bug bảo mật? Khi nó vẫn giữ export, token, hoặc PII của user trước sau logout.
- Vì sao
useCallback([],)cùng bug? Hàm đã close over props lần render đầu và không bao giờ cập nhật.
Tóm tắt: Mọi object đều có link nội bộ [[Prototype]]. Tra cứu property đi own key trước, rồi chain đó, đến null. class là constructor kèm sugar trên prototype, không phải mô hình object thứ hai.
Vì sao quan trọng: el instanceof HTMLElement là false với node từ iframe. Library extend Array gãy khi sang realm khác. Vue reactive() trên instance Mapbox hoặc chart bọc Proxy, nên this và instanceof loạn. markRaw là cách xử lý, không phải “đừng dùng class”.
- Cách hoạt động:
obj.footìm thuộc tính own trước, rồiObject.getPrototypeOf(obj), rồi đi tiếp. Chuỗi kết thúc ởObject.prototype, rồinull. class Foo { method() {} }đặtmethodtrênFoo.prototypenên mọi instance dùng chung một function. Field của class là từng instance; không nằm trên prototype.Object.create(proto)tạo object có[[Prototype]]làproto.new Foo()chính làObject.create(Foo.prototype)rồi gọiFoovớithis.instanceofđi theo chuỗi của receiver, tìmCtor.prototype. Realm khác (iframe, worker, jsdom) cóArray.prototypekhác, nên check fail.- Đừng dựng UI Vue hay React trên kế thừa prototype. Component và composable là function kèm data. Dùng
classkhi cần identity (subclassError) và method dùng chung.
// Tra cứu: key own, rồi [[Prototype]], rồi null
const proto = { kind: 'row' }
const row = Object.create(proto)
row.id = 1
row.id // 1 (own)
row.kind // 'row' (prototype)
Object.getPrototypeOf(row) === proto // true
// Check an toàn theo realm, không dùng instanceof
Array.isArray(data)
el.nodeType === 1
const map = markRaw(new google.maps.Map(el))
type AppErrorDTO = { kind: 'AppError'; message: string }Điều gì xảy ra: row.id là own; row.kind tìm thấy trên proto. Object.create là chuỗi không cần class. Array.isArray và nodeType vẫn đúng khi qua iframe; instanceof Array thì không. markRaw không cho Vue proxy map. DTO là thứ bạn gửi qua postMessage, vì structured clone bỏ prototype.
| Cách kiểm | Cùng realm | iframe / worker / jsdom |
|---|---|---|
data instanceof Array | Thường đúng | Thường sai: dùng Array.isArray |
el instanceof HTMLElement | Đúng với element | Sai: dùng el.nodeType === 1 |
err instanceof AppError | Đúng nếu cùng class | Sai sau postMessage: gửi kind |
| Hai bản của một package | Cùng nếu đã dedupe | instanceof fail: lỗi bundler |
Góc nhìn senior
- Dùng
classcho type có identity và API prototype dùng chung (subclassError,instanceoftrongcatch). Đừng dựng UI app trên kế thừa prototype. - Method trên prototype dùng chung (một function, nhiều instance). Field của class là từng instance: đó là trade-off (đánh đổi) bộ nhớ trên 10k hàng.
instanceofchỉ đúng trong cùng realm. Ưu tiênArray.isArray,nodeType, hoặc discriminant bạn tự kiểm soát.- Vue
reactive()trên class bọc Map hoặc chart làm gãythisvàinstanceof.markRawinstance. structuredClone/postMessagetrả về object trần. Hydrate lại tường minh; đừng chờ class quay về.
Lỗi hay gặp
- Triệu chứng:
el instanceof HTMLElementra false trong embed. Nguyên nhân: window khác, prototype khác. Cách xử lý:el.nodeType === 1hoặcel instanceof win.HTMLElement.- Triệu chứng: chart Vue nháy và method throw. Nguyên nhân:
reactive(new Mapbox.Map()). Cách xử lý:markRawinstance; giữ tọa độ trong state của bạn.- Triệu chứng:
err instanceof AppErrorra false sau worker. Nguyên nhân: structured clone bỏ prototype. Cách xử lý: gửi{ kind, message }rồi hydrate lại.- Triệu chứng:
Object.create(null)nổ ởobj.toString. Nguyên nhân: không cóObject.prototype. Cách xử lý:Object.hasOwn/Object.prototype.hasOwnProperty.call.
Người phỏng vấn hỏi gì
Hầu như không ai muốn bạn implement new bằng Object.create. Họ muốn nghe: class là constructor kèm sugar trên prototype, và các va chạm production: instanceof xuyên iframe, thư viện extend Array, hoặc component Vue gãy vì ai đó đưa instance map qua reactive.
Trả lời khi phỏng vấn
Tra cứu đi thuộc tính own, rồi [[Prototype]], rồi null. class là constructor kèm sugar trên prototype. Mình dùng cho subclass Error và model mình instanceof trong catch, không dùng cho UI Vue, chỗ composable mới là công cụ tái sử dụng. instanceof sai khi qua iframe và worker, nên mình dùng Array.isArray và nodeType. Vue reactive trên instance class bọc bằng proxy, nên mình markRaw instance map và chart. structuredClone bỏ prototype, nên mình gửi discriminant, không gửi class. Hai bản của một package trong bundle làm instanceof fail; đó là lỗi bundler, không phải mẹo ngôn ngữ. Ở admin nội bộ mình markRaw instance map và giữ tọa độ trong state của mình.
Nhớ nhanh
classlà constructor kèm sugar trên prototype. Method dùng chung; field là từng instance.instanceoffail khi khác realm hoặc package bị nhân đôi. DùngArray.isArray/nodeType.markRawinstance map và chart trong Vue. Đừngreactive()class thư viện.structuredClonetrả về object trần. Gửi discriminant rồi hydrate lại.
Câu hỏi nối tiếp thường gặp
markRawsửa gì cho instance Google Map hoặc Monaco? Vue không bọc nó trong Proxy, nên method native vàthisvẫn chạy.- Vì sao
instanceoffail với node từ iframe khác? Mỗi window cóHTMLElementriêng. DùngnodeTypehoặc constructor của window đó. Object.create(proto)khácclasskhông? Cùng chuỗi.classcòn chạy constructor và gán.constructor.- Khi nào vẫn dùng kế thừa prototype trong app? Method dùng chung trên model hoặc subclass
Error, không phải component Vue.
Tóm tắt: Với function, this được bind ở call site: mặc định, method, call/apply/bind, hoặc new. Arrow function không bind this; chúng close over lexical this của scope bên ngoài.
Vì sao quan trọng: Method Vue Options API chạy tốt đến khi ai đó đổi thành arrow, rồi this.$emit chết. addEventListener('click', obj.method) làm mất receiver. Bind một wrapper mới mỗi lần render thì removeEventListener không bao giờ khớp. <script setup> không có this.
- Cách hoạt động: Gọi mặc định:
thislàundefinedtrong ESM / strict mode, không phải object global. Throw còn hơn ghi global thầm lặng. - Gọi method:
obj.fn()bindthisvàoobj. Táchconst fn = obj.fn; fn()là mất receiver. fn.call(thisArg, a)/fn.apply(thisArg, args)gọi ngay.fn.bind(thisArg)trả function mới vớithisđã cố định.new Fn()bindthisvào instance.- Arrow close over lexical
this.methodscủa Vue Options phải làfunctionđể instance bind được. Arrow ở đó bắtthiscủa module (undefinedtrong ESM). - Composition API gần như không dùng
this. Template Vue vẫn expose instance;<script setup>thì không. React classonClick={this.handle}là cùng bug mất receiver.
export default defineComponent({
methods: {
onSubmit() {
this.$emit('save', this.form) // Options bind this
},
// onSubmit: () => this.$emit(...) // ❌ this là undefined trong ESM
},
})
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close()
}
onMounted(() => window.addEventListener('keydown', onKey))
onUnmounted(() => window.removeEventListener('keydown', onKey))Điều gì xảy ra: onSubmit là function, nên Vue bind this vào instance và $emit chạy. Arrow trong methods sẽ bắt this của module. onKey có identity function ổn định, nên add và remove khớp. addEventListener('keydown', () => …) mỗi lần mount sẽ leak vì remove không tìm được cùng reference.
| Ngữ cảnh | this là gì | Cách an toàn |
|---|---|---|
methods Vue Options | Instance của component | Viết function, không phải arrow |
<script setup> / composable | Không có this | Dùng refs, props, emits |
obj.method làm listener | Mất (mặc định undefined) | Arrow, bind một lần, hoặc { signal } |
fn.call / bind / new | Receiver tường minh hoặc instance | Giữ function đã bind để gỡ |
Góc nhìn senior
- Bốn quy tắc kèm arrow phủ gần như mọi câu hỏi
thiskhi phỏng vấn. Call site chofunction; lexical cho arrow. - Method Vue Options được bind vào instance. Composition API không nên dùng
this. Migrate mixin đọcthis.footrong<script setup>sẽ raundefined. - Bug production là đăng ký
obj.methodhoặc wrapper mới mỗi lần render, nênremoveEventListenerkhông khớp.{ signal: ac.signal }sạch hơnbindkhi teardown. - Class field
onClick = () => this.save()nhân đôi theo instance (ổn khi N nhỏ). Method trên prototype kèm mộtbindtrong constructor tiết kiệm bộ nhớ trên 10k hàng. const { save } = thistrong Options làm mất receiver, trừ khisaveđã là arrow.
Lỗi hay gặp
- Triệu chứng:
this.$emitra undefined sau refactor. Nguyên nhân: arrow trongmethodsOptions, hoặc helper tách bằngconst fn = this.save. Cách xử lý: giữ methodfunction; gọithis.save()hoặcfn.call(this).- Triệu chứng: listener không bao giờ gỡ. Nguyên nhân:
bindhoặc arrow tạo trongonMountedmỗi lần, identity khác. Cách xử lý: giữ một function; hoặc truyền{ signal }rồi abort.- Triệu chứng: click handler React class có
thissai. Nguyên nhân:onClick={this.handle}không bind. Cách xử lý: arrow class field, hoặcbindtrong constructor.- Triệu chứng:
fn()gọi trần ghi global. Nguyên nhân:thismặc định làwindow. Cách xử lý: ESM / strict mode.
Người phỏng vấn hỏi gì
Method Vue Options API chạy tốt đến khi ai đó đổi thành arrow. Class field chạy được làm listener đến khi ai đó chuyển lên prototype để tiết kiệm bộ nhớ. React cũng không thoát: onClick={this.handle} trần trong class component. Họ muốn quy tắc bind và cách map sang framework, và liệu bạn gỡ được listener đã bind hay không.
Trả lời khi phỏng vấn
this là call site với function, lexical với arrow. Gọi mặc định trong ESM cho undefined; obj.fn() bind receiver; call, apply, và bind gán tường minh; new bind instance. Trong Vue Options mình viết methods là function để chúng bind vào instance. Arrow ở đó bắt undefined trong ESM, còn Composition API mình không dùng this. Bug production là addEventListener với obj.method, rồi không gỡ được listener vì mình bind wrapper mới mỗi lần. Mình giữ một identity function, hoặc truyền AbortSignal. Với 10k instance mình đặt method trên prototype và bind một lần.
Nhớ nhanh
functionbindthisở call site. Arrow close over lexicalthis.methodsVue Options phải là function.<script setup>không cóthis.addEventListener(obj.method)làm mấtthis. Giữ một function đã bind hoặc dùng{ signal }.call/applygọi ngay;bindtrả về một hàm. Bạn cần đúng reference đó mới gỡ được.
Câu hỏi nối tiếp thường gặp
- Vì sao arrow trong
methodsOptions thường là bug? Nó bắtthiscủa module (undefinedtrong ESM), nênthis.$emitthrow. - Đăng ký rồi gỡ cùng listener đã bind thế nào? Lưu
const onKey = …(hoặcbindmột lần) rồi truyền đúng reference đó cả hai lần, hoặc{ signal }. const { save } = thislàm gì? Nó copy hàm mà không kèm receiver. Lầnsave()sau là default call.- Composition API có bind
thistrongsetup()không? Không cóthisinstance nào dùng được. Dùngref,props, vàemit.
Tóm tắt: Các tính năng ES6+ hay gặp trong phỏng vấn senior là destructuring, spread, optional chaining, nullish coalescing, module, structuredClone, Array.prototype.at, và top-level await. Mỗi cái có một lỗi hay gặp, thú vị hơn cú pháp.
Vì sao quan trọng: user.age || 18 coi tuổi 0 là thiếu. JSON.parse(JSON.stringify(state)) làm mất Date và Map. Một barrel import kéo chart 200kb vào trang login. Người phỏng vấn gài những bug đó; họ không bắt bạn thuộc lòng destructuring.
- Cách hoạt động: Destructuring và spread copy key enumerable own đúng một tầng. Object lồng vẫn dùng chung. Rest trên object cũng shallow.
?.dừng ngay khi gặpnullhoặcundefined. Dùng khi đúng là optional. Cheuserthiếu sau response 200 là bug sản phẩm, không phải default khéo.??chỉ default khinull/undefined.0,false, và''vẫn giữ.||theo truthiness, và đó là bug tuổi 0.structuredClonegiữDate,Map,Set, và nhiều typed array.JSON.stringifylà serializer: nó bỏundefined, function, và biếnDatethành string.- ES module là live binding.
import()động là ranh giới bundle. Top-levelawaitlàm cả module graph phải chờ: ổn ở entry app Vue/Vite, đắt nếu util dùng chung phải chờ network. arr.at(-1)đọc phần tử cuối mà không cầnarr[arr.length - 1]. Nó không copy. Nên dùng hơnslice(-1)[0]khi ý bạn là “một phần tử”.
const page = Number(route.query.page ?? 0) // 0 là trang thật
const enabled = flag ?? true // false phải thắng ||
const email = user?.profile?.email // chỉ khi profile có thể thiếu
const copy = structuredClone(toRaw(doc)) // Date và Map được giữ
// JSON.stringify(doc) sẽ stringify createdAt và bỏ undefined
const last = rows.at(-1)
const { Chart } = await import('./heavy-chart') // ranh giới bundleĐiều gì xảy ra: ?? giữ 0 và false. ?. chỉ đi tiếp khi user và profile còn. structuredClone(toRaw(doc)) giữ Date/Map và gỡ Vue proxy trước. import động tách chart ra khỏi bundle login. rows.at(-1) là hàng cuối, không phải mảng một phần tử.
| Công cụ | Giữ | Bỏ / nói sai |
|---|---|---|
?? | 0, false, '' | Chỉ lấp khi null / undefined |
|| | Giá trị truthy | Coi 0 / false như thiếu |
structuredClone | Date, Map, Set, nhiều binary | Function, DOM node, Vue proxy |
JSON.stringify | Dữ liệu plain JSON-safe | undefined, function, Date như Date |
Góc nhìn senior
?.dành cho chỗ đúng là optional;??chỉ default khinull/undefined.user?.profile?.email ?? 'unknown'có thể che endpoint trả thiếu.structuredClone(toRaw(x))là clone giữ đượcDate/Map.JSON.stringifylà wire format. Chi phí clone là O(n): đừng clone trang 50k hàng mỗi lần gõ.import()động là ranh giới bundle, không phải cách load helper 2kb. Chứng minh bằng bundle analyzer, không phải comment.- ESM
export letlà live binding;export defaultthì không. Re-export trong barrel có thể kéo module nặng chưa dùng, trừ khi bundler tree-shake. - Top-level
awaittrong util dùng chung làm mọi importer phải chờ. Để ở biên app (route, plugin), không nhét vào helper mà trang login import.
Lỗi hay gặp
- Triệu chứng: trang
0nhảy thành trang1. Nguyên nhân:Number(query.page || 1). Cách xử lý:?? 0(hoặc?? 1chỉ khi thiếu, không phải khi bằng 0).- Triệu chứng: clone mất
createdAtkiểu Date. Nguyên nhân:JSON.parse(JSON.stringify(doc)). Cách xử lý:structuredClone(toRaw(doc)).- Triệu chứng: bundle login có chart. Nguyên nhân:
export * from './heavy-chart'trong barrel. Cách xử lý:import()động thật ở route cần nó.- Triệu chứng:
structuredClone(vueProxy)throw. Nguyên nhân: proxy và vài internals Vue không clone được. Cách xử lý:toRawtrước.
Người phỏng vấn hỏi gì
Không phải câu “destructuring là gì”. Họ gài bug: user.age || 18 khi tuổi là 0, JSON.parse(JSON.stringify(state)) làm mất Date và Map, hoặc một barrel import kéo chart 200kb vào trang login. Họ muốn ?., ??, clone, và module được dùng như tool trên production.
Trả lời khi phỏng vấn
Mình dùng ?? khi 0 hoặc false là giá trị thật, và ?. chỉ khi đúng là optional. Che user thiếu sau response 200 là bug sản phẩm. Destructuring và spread là shallow: object lồng vẫn dùng chung. Khi copy mình dùng structuredClone(toRaw(x)) để Date và Map còn nguyên; JSON.stringify là serializer, không phải clone. Array.at(-1) là phần tử cuối. import động là ranh giới bundle, không phải cách load helper 2kb, và mình để top-level await ở biên app. Mình chứng minh case flag bằng một hàng false, và phần import bằng bundle analyzer. Ở bảng admin, trang zero là trang thật nên mình dùng ?? 0, và feature flag false không được thành true qua ||.
Nhớ nhanh
??giữ0/false.||thì không.?.dành cho chỗ đúng là optional.structuredClone(toRaw(x))giữDate/Map. JSON là serializer.import()động là ranh giới bundle. Barrel có thể kéo theo code nặng chưa dùng.- Spread và destructuring là shallow. Object lồng vẫn dùng chung.
Câu hỏi nối tiếp thường gặp
- Khi nào
structuredClonelà tool sai cạnh Vue proxy? Khi quêntoRaw, hoặc khi clone trang 50k hàng mà đáng ra ghi tại chỗ. export defaultcòn live nếu module gán lại không? Không.export letmới live; default là binding một lần.- Vì sao không
user?.profile?.email ?? 'unknown'khắp nơi? Nó cheprofile: nulltừ endpoint hỏng. Fail nhánh đó. - Top-level await trong file store Pinia có ổn không? Chỉ khi mọi importer chờ được. Ưu tiên action
asynchơn là làm chậm cả module graph.
Tóm tắt: Garbage collector của JavaScript giải phóng object không còn đi tới được từ root (stack, global, và bất cứ gì mà closure còn sống hoặc DOM node còn trỏ tới). Leak là một root bị quên, bạn không gỡ theo vòng đời component.
Vì sao quan trọng: SPA ổn ở phút 0, rồi 1.5GB sau một ngày routing phía client. Retainer điển hình: DOM detached, listener, watch Vue không stop, closure giữ body của fetch, một Map metadata đáng ra phải là WeakMap. Câu “Gỡ node khỏi document rồi” chưa đủ nếu closure vẫn trỏ tới nó.
- Cách hoạt động: Reachability, không phải “mình không dùng biến này nữa”. Còn root nào đi tới được object thì object ở lại. Closure là root cho mọi thứ chúng capture.
- Leak hay gặp: listener
window/ document,setInterval,IntersectionObserver/ResizeObserver,watch/$subscribeVue khôngstop, cache global, DOM detached vẫn bị handler giữ. WeakMapgiữ object mà không giữ chúng sống. Dùng cho metadata trên DOM node không phải của bạn, không phải LRU cho response API.- Tìm leak: chụp heap snapshot, dùng trang, chụp lần nữa, xem delta. Search Retainers cho
Detached HTMLElementhoặcexportBlob20MB đã biết. Comparison view hay hơn scroll một snapshot. - Gắn mọi resource với scope component:
onUnmounted,onScopeDispose,watchonCleanup, hoặc{ signal }. Keep-alive:onUnmountedcó thể không bao giờ chạy, nên dispose việc nặng ởonDeactivated.
const meta = new WeakMap<HTMLElement, { rowId: string }>()
// khi element chết, metadata chết theo.
// Map<HTMLElement, …> sẽ ghim node.
onMounted(() => {
const stop = watch(filters, fetchList, { deep: true })
const ac = new AbortController()
window.addEventListener('resize', onResize, { signal: ac.signal })
onScopeDispose(() => {
stop()
ac.abort()
})
})Điều gì xảy ra: Key của WeakMap không giữ element sống, nên metadata của hàng có thể chết cùng node. watch được stop khi dispose scope, và listener resize bị abort cùng lifetime đó. Một Map<HTMLElement, …> hoặc một watch không stop sẽ ghim memory đó cả session.
| Resource | Vue | React |
|---|---|---|
watch / effect | stop() qua onScopeDispose | Hàm cleanup của useEffect |
| Listener DOM | { signal } hoặc onUnmounted | Gỡ trong cleanup của effect |
| Cache module | Xóa lúc logout; tránh blob theo user | Cũng vậy: module scope sống lâu hơn route |
| Con keep-alive | onDeactivated cho việc nặng | Unmount nếu cần semantics destructor |
Góc nhìn senior
- GC chỉ thu thứ không còn đi tới được từ root. “Gỡ node khỏi document rồi” chưa đủ nếu closure vẫn trỏ tới nó.
- Gắn mọi listener, interval, observer,
watch, và cache module với scope component. Đo bằng hai heap snapshot, không phải Task Manager. WeakMapdành cho metadata trên object không phải của bạn, không phải LRU cho response API. Value trongWeakMapvẫn retain những value đó.watchtrong action Pinia hoặc router guard mà khôngonScopeDisposethì sống mãi. SSRnew Map()ở module scope có thể leak sang request khác: theo request, không theo module.- Keep-alive là retainer by design. Dispose socket và buffer lớn lúc deactivate;
onUnmountedcó thể không bao giờ chạy.
Lỗi hay gặp
- Triệu chứng: heap phình sau mỗi lần navigation phía client. Nguyên nhân:
watchhoặc listener window không teardown. Cách xử lý:onScopeDispose/{ signal }.- Triệu chứng:
Detached HTMLElementtrong Retainers. Nguyên nhân: handler hoặc ref Vue vẫn trỏ node đã gỡ. Cách xử lý: gán null cho ref; gỡ listener.- Triệu chứng: “cache”
WeakMapkhông bao giờ hit. Nguyên nhân: key là string hoặc object literal mới mỗi lần. Cách xử lý:Mapcó eviction, hoặc key object ổn định.- Triệu chứng: tab keep-alive vẫn chạy WebSocket. Nguyên nhân: việc bắt đầu trong
onMountedvà chỉ dừng ởonUnmounted. Cách xử lý:onDeactivated/onActivated.
Nhà tuyển dụng hay hỏi gì
SPA lúc phút 0 thì ổn, sau một ngày routing phía client thì đã 1.5GB. Họ muốn retainer: DOM detached, listener, watch Vue không stop, closure giữ body fetch, Map metadata đáng lẽ là WeakMap. Nêu “mark and sweep” mà không đi từng bước snapshot thì chưa đủ.
Trả lời khi phỏng vấn
Garbage collection giải phóng object không còn tới được từ root. Leak là root bị quên: listener, interval, observer, watch Vue, hoặc cache module sống lâu hơn component. Mình gắn từng thứ đó với scope component: onScopeDispose, watch onCleanup, hoặc AbortSignal. WeakMap dành cho metadata trên object mình không quản lý vòng đời, không phải cache response API. Mình sẽ chụp hai heap snapshot, tìm exportBlob 20MB trên view Retainers, rồi tìm Detached HTMLElement. Nếu có keep-alive, mình giải phóng socket lúc deactivate vì onUnmounted có thể không bao giờ chạy. Ở admin nội bộ mình chụp các snapshot đó sau một ngày routing phía client, không phải sau một click.
Nhớ nhanh
- GC thu object không còn tới được. Leak là root bị quên, không phải “GC chậm”.
- Ghép listener, timer, observer, và
watchvới scope component. WeakMaplà metadata trên object bạn không quản lý vòng đời, không phải LRU response.- Chứng minh leak bằng hai heap snapshot và view Retainers.
Câu hỏi nối tiếp thường gặp
- Đi heap snapshot trên thực tế thế nào? Snapshot, dùng feature, snapshot nữa, Comparison view, tìm
Detachedhoặc tên object đã biết. - Vì sao
Mapở module nguy hiểm trên SSR? Module dùng chung giữa các request, nên data của một user có thể leak sang request sau. WeakRefcó thay việc gỡ listener không? Không. Bạn vẫn phải gỡ listener. WeakRef dành cho cache có thể biến mất.- Keep-alive đổi gì?
onUnmountedcó thể không chạy. Hãy giải phóng việc nặng trongonDeactivated.
Tóm tắt: Hoisting nghĩa là engine cấp binding trước khi chạy thân hàm. Function declaration được khởi tạo. var được gán undefined. let / const / class nằm trong temporal dead zone (TDZ) đến khi tới dòng của chúng. Truy cập sớm sẽ throw ReferenceError.
Vì sao quan trọng: Câu đố phỏng vấn là console.log(a) trước let a. Bug trên production là import ESM vòng: store Pinia và composable import lẫn nhau, và app throw Cannot access X before initialization lúc start. Unit test bỏ sót vì graph khác.
- Cách hoạt động: Coi hoisting là bài thứ tự load, không phải “engine kéo dòng lên”. Binding tồn tại cho cả scope; khác nhau ở lúc khởi tạo.
- Function declaration được khởi tạo bằng object hàm. Bạn gọi được chúng trước dòng khai báo trong cùng scope. Function expression (
const fn = () => {}) theo quy tắcconst. varđược khởi tạoundefined, nên đọc sớm ra giá trị sai mà không báo.let/const/classthrow đến khi init.typeof xtrênletđang TDZ vẫn throw, kháctypeof undeclared.- ESM vòng:
a.tsimportb.tsimporta.ts. File sau có thể đọc exportconstcòn trong TDZ. Cắt vòng bằnggetStore(), module thứ ba, hoặcimport()sau init, không phải bằngvar. import typebị xóa và không gây TDZ lúc runtime.const store = useFooStore()ở top module chạy trước khi Pinia được cài, cùng họ với bug đó.
console.log(a) // undefined: var đã được khởi tạo
var a = 1
console.log(b) // ReferenceError: b đang trong TDZ
let b = 2
// Cắt vòng Pinia ↔ composable bằng getter lazy.
export function getUserStore() {
return useUserStore()
}
// Đừng: const store = useUserStore() ở top module trong util.
import type { User } from './user' // bị xóa: không TDZ lúc chạyĐiều gì xảy ra: var a đã tồn tại là undefined khi log đầu chạy. let b đã tồn tại nhưng đang TDZ, nên log thứ hai throw. getUserStore() hoãn đọc export Pinia đến lúc gọi, sau khi cả hai module đã init. import type không chạy, nên không tạo vòng.
| Binding | Có hoist? | Trước init |
|---|---|---|
function foo() {} | Có, khởi tạo đủ | Gọi được |
var a | Có | undefined (không báo) |
let / const / class | Binding tồn tại (TDZ) | ReferenceError |
const fn = () => {} | Như const | Throw: chưa là hàm |
Góc nhìn senior
- Mô hình hữu ích là thứ tự load, không phải “dòng bị kéo lên”. Đó là thứ làm ESM vòng dễ hiểu.
- Đừng “dùng
varđể tránh TDZ”. Hãy cắt vòng bằng hàm (getStore()), module thứ ba, hoặcimport()động sau init. - Getter lazy giấu vòng và giấu mùi thiết kế. Module thứ ba lộ rõ hơn và thường đúng.
- Auto-import Vue kèm Pinia có thể tạo lại vòng: composable kéo vào store, store kéo lại composable. Graph Vitest vẫn có thể pass.
export class X extends Ythất bại nếuYcòn TDZ.typeoftrên binding TDZ vẫn throw vớilet/const. Thứ tự boot SSR không phải thứ tự boot client.
Lỗi hay gặp
- Triệu chứng: app throw
Cannot access X before initialization; test thì pass. Nguyên nhân: ESM vòng, graph khác. Cách xử lý:getStore(), module thứ ba, hoặcimport()sau init.- Triệu chứng:
typeof storethrow lúc start. Nguyên nhân:let/consttrong TDZ.typeofkhông cứu bạn. Cách xử lý: đừng đọc export ở top module.- Triệu chứng:
useFooStore()ở top module làundefinedhoặc throw. Nguyên nhân: Pinia chưa được cài. Cách xử lý: gọi trongsetup/ action.- Triệu chứng:
class Child extends Parentthrow. Nguyên nhân:Parentlàconstvòng còn TDZ. Cách xử lý: cắt vòng import.
Nhà tuyển dụng hay hỏi gì
Hai mươi giây: function declaration được hoist đủ; var được hoist và gán undefined; let/const/class nằm TDZ đến khi init. Rồi bản production: import ESM vòng throw Cannot access X before initialization, không phải câu đố console.log(a). Bạn nêu rõ được TDZ thay vì “hoisting là kéo declaration lên”?
Trả lời khi phỏng vấn
Function declaration hoist đủ, var hoist thành undefined, còn let, const, và class nằm temporal dead zone đến khi init. Truy cập sẽ throw ReferenceError, và typeof không cứu bạn. Bug trên production là import ESM vòng. Composable bị store Pinia import rồi import ngược composable sẽ throw Cannot access X before initialization lúc start, và test bỏ sót vì graph khác. Mình cắt vòng bằng getStore(), module thứ ba, hoặc import() sau init. Mình không đổi sang var để tránh TDZ, và không gọi useFooStore ở top module trước khi Pinia được cài. Mình sẽ bắt đầu từ đúng ReferenceError, rồi graph import Vite, không phải viết lại file bằng var.
Nhớ nhanh
- Function declaration được khởi tạo.
varlàundefined.let/const/classthrow trong TDZ. - TDZ trên production là ESM vòng, không phải
console.log(a)trướclet a. - Cắt vòng bằng
getStore(), module thứ ba, hoặcimport(), không phảivar. typeoftrênletđang TDZ vẫn throw.import typekhông gây TDZ lúc chạy.
Câu hỏi nối tiếp thường gặp
- Cắt vòng import Pinia ↔ composable thế nào?
getStore()lazy, module thứ ba cả hai cùng import, hoặcimport()sau khi Pinia được cài. typeof xcó throw khixđang TDZ không? Có vớilet/const/class.typeof undeclaredlà"undefined".- Vì sao test bỏ sót crash? Graph module và thứ tự mock của Vitest khác chunk Vite của app.
varcó bao giờ là cách xử lý TDZ đúng không? Không. Nó giấu vòng và mang leak function-scope trở lại.
Tóm tắt: AbortController là primitive của browser để hủy việc. Một controller expose signal bạn truyền vào fetch, listener, và cleanup watch Vue. Abort là cooperative: code không bao giờ check signal thì vẫn chạy xong.
Vì sao quan trọng: Typeahead bắn năm GET đang bay và cái chậm nhất thắng, ghi đè input. Đổi route vẫn ghi vào trang trước. let seq++ là cờ bỏ-stale tự chế. Người phỏng vấn muốn AbortController như contract, và nó ghép với Promise.all thế nào.
- Cách hoạt động:
const ac = new AbortController(); truyền{ signal: ac.signal }vàofetchvàaddEventListener.ac.abort()reject fetch vớiAbortErrorvà gỡ listener đã dùng signal đó. - Race condition (tranh chấp thứ tự) trong search là “ghi sau cùng thắng” khi response chậm. Abort controller trước đó mỗi query mới để GET cũ không ghi được
results. - Bỏ qua stale mà không abort vẫn tốn network. Nên abort và bỏ qua: catch
AbortErrorrồi return; đừng toast nó; đừng gửi lên Sentry. Promise.race([fetch(url, { signal }), timeout(ms, ac)])abort được khi timeout nếu timeout gọiac.abort(). Các nhánhPromise.allvẫn chạy trừ khi chúng nhận cùng signal.- Cleanup lúc unmount: Vue
watchonCleanup(() => ac.abort()),onScopeDispose, hoặc cleanupuseEffectReact. Một signal có thể phủ fetch, listener, và watch.
watch(
() => route.query.q,
async (q, _p, onCleanup) => {
const ac = new AbortController()
onCleanup(() => ac.abort())
try {
const [hits, nextFacets] = await Promise.all([
search(q, { signal: ac.signal }),
getFacets(q, { signal: ac.signal }),
])
results.value = hits
facetCounts.value = nextFacets
} catch (err) {
if ((err as DOMException).name === 'AbortError') return
throw err
}
},
)Điều gì xảy ra: Mỗi lần đổi query abort controller trước, nên search và getFacets dừng cùng nhau. Một 200 đến muộn không ghi đè results. AbortError bị nuốt; 500 thật vẫn throw. Dùng chung một signal thì Promise.all hủy được như một nhóm.
| Sự kiện | Vue | React |
|---|---|---|
| Đổi query | watch onCleanup → abort() | Cleanup effect abort ac trước |
| Unmount / rời route | onScopeDispose / cùng onCleanup | Cleanup effect lúc unmount |
| Timeout | AbortSignal.timeout(ms) hoặc race + abort | Cùng primitive |
| POST đã confirm | Đừng abort trừ khi rời trang là không an toàn | Giống vậy: cancellation là quy tắc sản phẩm |
Góc nhìn senior
- Cancellation là một phần của API contract. Chia một
AbortSignalcho fetch, listener, vàwatchonCleanupcủa Vue. - Abort là hợp tác.
fetchbên trong quên signal vẫn chạy xong và có thể ghi kết quả stale. Promise.allkèm một abort: các nhánh vẫn chạy trừ khi nhận cùng signal. Truyền signal vào mọi child.- Lọc
AbortErrortrước Sentry để cancel typeahead không gọi bạn dậy. Retry sau abort là sai; retry sau 503 mới đúng. Kiểmsignal.abortedtrước. - HTTP/2 cancellation giúp server; HTTP/1.1 có thể không dừng việc. Đừng nghĩ abort tiết kiệm CPU của DB. Đừng abort checkout đã confirm trừ khi áp kết quả sau khi rời trang là không an toàn.
Lỗi hay gặp
- Triệu chứng: query đầu chậm ghi đè query sau. Nguyên nhân: không abort, lần ghi sau cùng thắng. Cách xử lý:
AbortControllermới mỗi query; bỏ quaAbortError.- Triệu chứng: Sentry ngập mỗi lần gõ. Nguyên nhân: log
AbortErrornhư failure. Cách xử lý: return khiname === 'AbortError'.- Triệu chứng: facet vẫn tới sau abort. Nguyên nhân: các nhánh
Promise.allkhông nhận signal. Cách xử lý: truyền cùngsignalvào mọi lời gọi.- Triệu chứng: rời trang vẫn patch Pinia. Nguyên nhân: không abort trong
onCleanup. Cách xử lý: abort lúc unmount; đừng ghi nếusignal.aborted.
Nhà tuyển dụng hay hỏi gì
Typeahead: năm GET đang bay, cái chậm nhất thắng và ghi đè input. Đổi route: watch vẫn ghi vào trang trước. “Bọn mình dùng let seq++”. Vì sao AbortController là primitive, nó kết hợp với Promise.all thế nào, và khi nào bạn từ chối abort một request?
Trả lời khi phỏng vấn
Mình coi cancellation là một phần của contract. Mọi fetch, listener, và watch Vue dùng chung một AbortSignal. Mình abort lúc unmount, lúc đổi query, và lúc timeout. Abort là hợp tác, nên call bên trong bị quên vẫn chạy xong và có thể ghi kết quả stale; mình truyền cùng signal vào các nhánh Promise.all. Mình lọc AbortError trước Sentry để cancel typeahead không gọi mình dậy, và chỉ retry sau 503 thật, sau khi kiểm signal.aborted. Mình không abort checkout đã confirm trừ khi rời trang khiến việc áp kết quả không an toàn. Bộ đếm seq chỉ bỏ lần ghi stale; nó vẫn tốn network.
Nhớ nhanh
- Chia một
AbortSignalcho fetch, listener, và cleanupwatchVue. - Abort và bỏ stale. Bộ đếm seq vẫn tốn network.
- Các nhánh
Promise.allvẫn chạy trừ khi nhận cùng signal. - Đừng toast hoặc Sentry
AbortError. Checkout đã confirm thì mặc định đừng abort.
Câu hỏi nối tiếp thường gặp
- Chia một signal cho fetch, watch, và listener thế nào? Tạo một
AbortControllertrong effect /watch, truyềnsignalvào mỗi API, abort trongonCleanup. AbortSignal.timeout(ms)làm gì? Trả về signal abort saums. Kết hợp với fetch; vẫn catchAbortError/TimeoutError.- Khi nào bộ đếm seq là đủ? Không bao giờ là cách xử lý duy nhất cho search. Nó bỏ lần ghi nhưng không hủy việc.
- Abort có dừng server không? Có thể trên HTTP/2. Coi là best-effort; đừng dựa vào nó để hủy charge trên thẻ.