Hub

JavaScript

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”.

  1. Cách hoạt động: map luôn trả mảng mới cùng độ dài. filter trả tập con. reduce gộp các phần tử thành một giá trị. some/find dừng sớm với boolean hoặc phần tử khớp đầu tiên.
  2. forEach chỉ dành cho side effect (tác dụng phụ). Nó trả undefined, nên dùng với push để dựng mảng chỉ là map viết kém.
  3. sort, reverse, và splice mutate mảng gốc và trả cùng reference. toSorted / toReversed / toSpliced copy trước.
  4. Proxy của Vue 3 đã track index và length, nên push tạ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ồi setRows(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.

MethodTrả vềMutate?
mapMảng mới, cùng độ dàiKhông
filter / find / someTập con, phần tử, hoặc booleanKhông
reduceMột giá trị tích lũyKhông (trừ khi mutate trong reducer)
sort / spliceCùng array referenceCó: 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 filter rồi map dễ đọc và ổn với list nhỏ. Với 100k hàng, đo trước; một vòng (for hoặc reduce đặ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 / reverse trên prop. Cách xử lý: toSorted trong computed, đừ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ới null / undefined tường minh.
  • Triệu chứng: forEach + push vào mảng Pinia. Nguyên nhân: viết lại map và mutate store dùng chung. Cách xử lý: map ra 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 / reduce tạo bản mới. sort / splice mutate và trả cùng reference.
  • Dùng toSorted trong computed Vue 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ất 0 và '' hợp lệ. So với null / undefined tường minh.

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

  • next === items sau const next = items.sort(cmp) là gì? true. Vue vẫn thấy đổi index; React setState(next) không làm gì.
  • Vì sao toSorted trong computed an toàn? Nó không mutate dependency. sort trong computed là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ì.

  1. Cách hoạt động: new Promise bắt đầu ở pending. resolve(value) thì fulfilled; reject(err) thì rejected. Promise fulfilled chạy .then; rejected chạy .catch. .finally luôn chạy.
  2. Hàm async luôn trả về Promise. await tạ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.
  3. try/catch quanh await giống .catch trên promise. catch { console.error } rỗng trả undefined và trông như “không có data”.
  4. await nối tiếp là waterfall. Call độc lập thì cho vào Promise.all. Chỉ waterfall khi data phụ thuộc thật, ví dụ posts cần user.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.

CombinatorDùng khiKhi lỗi
Promise.allTrang không render nếu thiếu bất kỳ kết quả nàoFail-fast: một reject làm fail cả nhóm
allSettledWidget hiện được dữ liệu một phầnVẫn phải đếm các reject
try/catch từng awaitMỗi call có fallback riêngDễ biến 500 thành data rỗng
await nối tiếpPhụ thuộc data thậtLuôn chậm hơn khi các call độc lập

Góc nhìn senior

  • Coi async/await là cú pháp control-flow. Quyết định nằm ở combinator và AbortSignal.
  • Promise.all sai khi 9 trên 10 widget render được dù thiếu cái thứ 10. Tách nhánh optional hoặc dùng allSettled.
  • Đừng await trong forEach các id độc lập: nó không wait. Dùng for…of hoặc Promise.all với trần concurrency (khoảng 4–8), đừng bắn 10k một lúc.
  • Promise không cancel. Truyền một AbortSignal vào mọi fetch và abort khi unmount (onScopeDispose trong Vue).
  • Nuxt $fetch mặc định throw khi HTTP lỗi; fetch trê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.all fail-fast mà không có error state trên UI. Cách xử lý: fail contract cho user thấy, hoặc dùng allSettled cho 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 AbortController mỗi màn, abort trong onScopeDispose.

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 async luôn trả về Promise.
  • try/catch quanh await cũ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 AbortSignal và abort khi unmount.

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

  • all vs allSettled vs try từng call? Checkout cần all fail-fast. Dashboard widget cần allSettled hoặc tách nhánh để một lần 500 không làm trắng màn.
  • Vì sao await trong forEach không wait? forEach bỏ qua promise trả về. Dùng for…of hoặc Promise.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 nextTick thế nào? await chạ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.

  1. 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.
  2. 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ế.
  3. 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.
  4. Render (style, layout, paint) có thể xảy ra sau microtask, trước macrotask kế.
  5. 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.

APIHàng đợiThấy DOM mới của Vue?Sau paint?
nextTickMicrotask flush của VueCó, sau khi Vue patchKhông
Promise.thenMicrotaskChỉ khi xếp sau VueKhông
setTimeout(0)MacrotaskCóThường có: đã nhường một nhịp paint
requestAnimationFrameTrước paint tiếp theoCóKhông: nó là callback của paint

Góc nhìn senior

  • Vue 3 xếp DOM update thành microtask. nextTick là API cho “đọc layout sau khi ref commit”. Promise.then cũ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.
  • requestAnimationFrame chạ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 await trên promise đã resolve không bao giờ trả lại renderer. Cắt bằng macrotask hoặc scheduler.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.offsetHeight cũ 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ặc scheduler.yield().
  • Triệu chứng: dùng setTimeout(0) để “fix timing Vue”. Nguyên nhân: bạn cần nextTick, 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ùng rAF.

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.
  • nextTick chờ 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) vs Promise.then(cb) vs setTimeout(cb, 0)? nextTick thấy DOM mới của Vue. Promise.then có thể chạy sớm hơn. setTimeout thấ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) vs requestAnimationFrame? Timer là macrotask; rAF là callback paint kế. Dùng rAF để 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ệ.

  1. Cách hoạt động: var dùng được trong cả hàm, kể cả phía trên dòng khai báo (khởi tạo là undefined). let và const sống trong khối { } nơi chúng được khai báo.
  2. 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ì throw ReferenceError, không phải undefined.
  3. Gán lại (count = 1) đổi binding. Mutation (user.role = 'guest' hoặc arr.push) đổi object mà binding đang trỏ tới. const chỉ cấm gán lại.
  4. Bug var + setTimeout là callback được lên lịch, dùng chung một i phạm vi hàm. await tuần tự trong for (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ắcvarletconst
Phạm viHàmKhốiKhối
TDZKhông (bắt đầu là undefined)CóCó
Gán lại bindingCóCóKhông
Mutate objectCóCóCó, không phải freeze

Góc nhìn senior

  • Mặc định const. Chỉ let khi binding phải đổi (index, số lần retry). Code mới không dùng var; bật lint no-var / prefer-const.
  • const không phải Object.freeze. Freeze nông và có thể phá proxy Vue 3. Dùng Vue readonly() hoặc copy thật.
  • const { items } = props chụp snapshot giá trị và làm mất reactivity Vue. Dùng toRefs / storeToRefs.
  • Import ESM vòng tròn đụng TDZ thì throw ReferenceError lúc load, không phải một undefined bí ẩ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 var kèm await tuầ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: var kè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: “const lẽ ra phải chặn push”. Nguyên nhân: const là 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 ReferenceError trong store. Nguyên nhân: TDZ do import vòng. Cách xử lý: cắt vòng; đừng “default biến thành undefined”.

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

  • const khóa binding, không khóa object. push và ghi property vẫn chạy.
  • TDZ: đọc let/const trước khi init throw ReferenceError, không phải undefined.
  • Bug vòng lặp là var + callback được lên lịch. await tuần tự không phải bug đó.
  • const { items } = props làm mất reactivity Vue. Dùng toRefs / storeToRefs.

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

  • const có chặn array.push khô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 store của A lúc eval. Cắt vòng bằng export một hàm.
  • var trong script sloppy? Nó có thể thành binding trên window. let/const thì 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.

  1. 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.
  2. 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.
  3. Nó giữ binding, không phải snapshot, trừ khi bạn copy một const lúc schedule. Lần ghi sau vào cùng binding hiện với mọi closure còn sống.
  4. 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.
  5. 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.

BugVueReact
Query / page staleĐọc ref bên trong callback asyncuseRef cho giá trị mới, hoặc đưa vào dep và abort
Debounce gửi args lần #1Lưu last trên object wrapperCùng helper; đừng close over args đầu
Map ở module sau logoutXóa hoặc gắn key cho cache khi auth đổiGiố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). Copy const page = page.value trước await thì đó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 useCallback với [] là cùng bug closure stale, khác cú pháp. useRef hoặ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 args lần gọi #1. Cách xử lý: lưu last trên wrapper.
  • Triệu chứng: watch log page hôm qua. Nguyên nhân: const page = page.value trước await. Cách xử lý: đọc .value trong hàm async.
  • Triệu chứng: heap phình sau mỗi lần search. Nguyên nhân: watch trong util không onScopeDispose(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? AbortController trong watch onCleanup, rồi bỏ AbortError và không ghi results stale.
  • 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”.

  1. Cách hoạt động: obj.foo tìm thuộc tính own trước, rồi Object.getPrototypeOf(obj), rồi đi tiếp. Chuỗi kết thúc ở Object.prototype, rồi null.
  2. class Foo { method() {} } đặt method trên Foo.prototype nê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.
  3. Object.create(proto) tạo object có [[Prototype]] là proto. new Foo() chính là Object.create(Foo.prototype) rồi gọi Foo với this.
  4. instanceof đi theo chuỗi của receiver, tìm Ctor.prototype. Realm khác (iframe, worker, jsdom) có Array.prototype khác, nên check fail.
  5. Đừng dựng UI Vue hay React trên kế thừa prototype. Component và composable là function kèm data. Dùng class khi cần identity (subclass Error) 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ểmCùng realmiframe / worker / jsdom
data instanceof ArrayThường đúngThường sai: dùng Array.isArray
el instanceof HTMLElementĐúng với elementSai: dùng el.nodeType === 1
err instanceof AppErrorĐúng nếu cùng classSai sau postMessage: gửi kind
Hai bản của một packageCùng nếu đã dedupeinstanceof fail: lỗi bundler

Góc nhìn senior

  • Dùng class cho type có identity và API prototype dùng chung (subclass Error, instanceof trong catch). Đừ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.
  • instanceof chỉ đúng trong cùng realm. Ưu tiên Array.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ãy this và instanceof. markRaw instance.
  • structuredClone / postMessage trả 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 HTMLElement ra false trong embed. Nguyên nhân: window khác, prototype khác. Cách xử lý: el.nodeType === 1 hoặc el 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ý: markRaw instance; giữ tọa độ trong state của bạn.
  • Triệu chứng: err instanceof AppError ra 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

  • class là constructor kèm sugar trên prototype. Method dùng chung; field là từng instance.
  • instanceof fail khi khác realm hoặc package bị nhân đôi. Dùng Array.isArray / nodeType.
  • markRaw instance map và chart trong Vue. Đừng reactive() class thư viện.
  • structuredClone trả về object trần. Gửi discriminant rồi hydrate lại.

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

  • markRaw sửa gì cho instance Google Map hoặc Monaco? Vue không bọc nó trong Proxy, nên method native và this vẫn chạy.
  • Vì sao instanceof fail với node từ iframe khác? Mỗi window có HTMLElement riêng. Dùng nodeType hoặc constructor của window đó.
  • Object.create(proto) khác class không? Cùng chuỗi. class cò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.

  1. Cách hoạt động: Gọi mặc định: this là undefined trong ESM / strict mode, không phải object global. Throw còn hơn ghi global thầm lặng.
  2. Gọi method: obj.fn() bind this vào obj. Tách const fn = obj.fn; fn() là mất receiver.
  3. fn.call(thisArg, a) / fn.apply(thisArg, args) gọi ngay. fn.bind(thisArg) trả function mới với this đã cố định. new Fn() bind this vào instance.
  4. Arrow close over lexical this. methods của Vue Options phải là function để instance bind được. Arrow ở đó bắt this của module (undefined trong ESM).
  5. Composition API gần như không dùng this. Template Vue vẫn expose instance; <script setup> thì không. React class onClick={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ảnhthis là gìCách an toàn
methods Vue OptionsInstance của componentViết function, không phải arrow
<script setup> / composableKhông có thisDùng refs, props, emits
obj.method làm listenerMất (mặc định undefined)Arrow, bind một lần, hoặc { signal }
fn.call / bind / newReceiver tường minh hoặc instanceGiữ 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 this khi phỏng vấn. Call site cho function; lexical cho arrow.
  • Method Vue Options được bind vào instance. Composition API không nên dùng this. Migrate mixin đọc this.foo trong <script setup> sẽ ra undefined.
  • Bug production là đăng ký obj.method hoặc wrapper mới mỗi lần render, nên removeEventListener không khớp. { signal: ac.signal } sạch hơn bind khi teardown.
  • Class field onClick = () => this.save() nhân đôi theo instance (ổn khi N nhỏ). Method trên prototype kèm một bind trong constructor tiết kiệm bộ nhớ trên 10k hàng.
  • const { save } = this trong Options làm mất receiver, trừ khi save đã là arrow.

Lỗi hay gặp

  • Triệu chứng: this.$emit ra undefined sau refactor. Nguyên nhân: arrow trong methods Options, hoặc helper tách bằng const fn = this.save. Cách xử lý: giữ method function; gọi this.save() hoặc fn.call(this).
  • Triệu chứng: listener không bao giờ gỡ. Nguyên nhân: bind hoặc arrow tạo trong onMounted mỗ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ó this sai. Nguyên nhân: onClick={this.handle} không bind. Cách xử lý: arrow class field, hoặc bind trong constructor.
  • Triệu chứng: fn() gọi trần ghi global. Nguyên nhân: this mặ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

  • function bind this ở call site. Arrow close over lexical this.
  • methods Vue Options phải là function. <script setup> không có this.
  • addEventListener(obj.method) làm mất this. Giữ một function đã bind hoặc dùng { signal }.
  • call/apply gọi ngay; bind trả 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 methods Options thường là bug? Nó bắt this của module (undefined trong ESM), nên this.$emit throw.
  • Đăng ký rồi gỡ cùng listener đã bind thế nào? Lưu const onKey = … (hoặc bind một lần) rồi truyền đúng reference đó cả hai lần, hoặc { signal }.
  • const { save } = this làm gì? Nó copy hàm mà không kèm receiver. Lần save() sau là default call.
  • Composition API có bind this trong setup() không? Không có this instance nào dùng được. Dùng ref, 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.

  1. 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.
  2. ?. dừng ngay khi gặp null hoặc undefined. Dùng khi đúng là optional. Che user thiếu sau response 200 là bug sản phẩm, không phải default khéo.
  3. ?? chỉ default khi null / undefined. 0, false, và '' vẫn giữ. || theo truthiness, và đó là bug tuổi 0.
  4. structuredClone giữ Date, Map, Set, và nhiều typed array. JSON.stringify là serializer: nó bỏ undefined, function, và biến Date thành string.
  5. ES module là live binding. import() động là ranh giới bundle. Top-level await làm cả module graph phải chờ: ổn ở entry app Vue/Vite, đắt nếu util dùng chung phải chờ network.
  6. arr.at(-1) đọc phần tử cuối mà không cần arr[arr.length - 1]. Nó không copy. Nên dùng hơn slice(-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ị truthyCoi 0 / false như thiếu
structuredCloneDate, Map, Set, nhiều binaryFunction, DOM node, Vue proxy
JSON.stringifyDữ liệu plain JSON-safeundefined, function, Date như Date

Góc nhìn senior

  • ?. dành cho chỗ đúng là optional; ?? chỉ default khi null/undefined. user?.profile?.email ?? 'unknown' có thể che endpoint trả thiếu.
  • structuredClone(toRaw(x)) là clone giữ được Date/Map. JSON.stringify là 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 let là live binding; export default thì không. Re-export trong barrel có thể kéo module nặng chưa dùng, trừ khi bundler tree-shake.
  • Top-level await trong 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 0 nhảy thành trang 1. Nguyên nhân: Number(query.page || 1). Cách xử lý: ?? 0 (hoặc ?? 1 chỉ khi thiếu, không phải khi bằng 0).
  • Triệu chứng: clone mất createdAt kiể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ý: toRaw trướ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 structuredClone là tool sai cạnh Vue proxy? Khi quên toRaw, hoặc khi clone trang 50k hàng mà đáng ra ghi tại chỗ.
  • export default còn live nếu module gán lại không? Không. export let mới live; default là binding một lần.
  • Vì sao không user?.profile?.email ?? 'unknown' khắp nơi? Nó che profile: null từ 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 async hơ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ó.

  1. 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.
  2. Leak hay gặp: listener window / document, setInterval, IntersectionObserver / ResizeObserver, watch / $subscribe Vue không stop, cache global, DOM detached vẫn bị handler giữ.
  3. WeakMap giữ 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.
  4. Tìm leak: chụp heap snapshot, dùng trang, chụp lần nữa, xem delta. Search Retainers cho Detached HTMLElement hoặc exportBlob 20MB đã biết. Comparison view hay hơn scroll một snapshot.
  5. Gắn mọi resource với scope component: onUnmounted, onScopeDispose, watch onCleanup, hoặc { signal }. Keep-alive: onUnmounted có 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.

ResourceVueReact
watch / effectstop() qua onScopeDisposeHàm cleanup của useEffect
Listener DOM{ signal } hoặc onUnmountedGỡ trong cleanup của effect
Cache moduleXóa lúc logout; tránh blob theo userCũng vậy: module scope sống lâu hơn route
Con keep-aliveonDeactivated cho việc nặngUnmount 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.
  • WeakMap dành cho metadata trên object không phải của bạn, không phải LRU cho response API. Value trong WeakMap vẫn retain những value đó.
  • watch trong action Pinia hoặc router guard mà không onScopeDispose thì sống mãi. SSR new 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; onUnmounted có 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: watch hoặc listener window không teardown. Cách xử lý: onScopeDispose / { signal }.
  • Triệu chứng: Detached HTMLElement trong 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” WeakMap khô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ý: Map có 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 onMounted và 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à watch với scope component.
  • WeakMap là 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 Detached hoặ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.
  • WeakRef có 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ì? onUnmounted có thể không chạy. Hãy giải phóng việc nặng trong onDeactivated.

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.

  1. 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.
  2. 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ắc const.
  3. var được khởi tạo undefined, nên đọc sớm ra giá trị sai mà không báo. let / const / class throw đến khi init. typeof x trên let đang TDZ vẫn throw, khác typeof undeclared.
  4. ESM vòng: a.ts import b.ts import a.ts. File sau có thể đọc export const còn trong TDZ. Cắt vòng bằng getStore(), module thứ ba, hoặc import() sau init, không phải bằng var.
  5. import type bị 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.

BindingCó hoist?Trước init
function foo() {}Có, khởi tạo đủGọi được
var aCóundefined (không báo)
let / const / classBinding tồn tại (TDZ)ReferenceError
const fn = () => {}Như constThrow: 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ặc import() độ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 Y thất bại nếu Y còn TDZ. typeof trên binding TDZ vẫn throw với let/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ặc import() sau init.
  • Triệu chứng: typeof store throw lúc start. Nguyên nhân: let/const trong TDZ. typeof không cứu bạn. Cách xử lý: đừng đọc export ở top module.
  • Triệu chứng: useFooStore() ở top module là undefined hoặc throw. Nguyên nhân: Pinia chưa được cài. Cách xử lý: gọi trong setup / action.
  • Triệu chứng: class Child extends Parent throw. Nguyên nhân: Parent là const vò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. var là undefined. let/const/class throw trong TDZ.
  • TDZ trên production là ESM vòng, không phải console.log(a) trước let a.
  • Cắt vòng bằng getStore(), module thứ ba, hoặc import(), không phải var.
  • typeof trên let đang TDZ vẫn throw. import type khô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ặc import() sau khi Pinia được cài.
  • typeof x có throw khi x đang TDZ không? Có với let/const/class. typeof undeclared là "undefined".
  • Vì sao test bỏ sót crash? Graph module và thứ tự mock của Vitest khác chunk Vite của app.
  • var có 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.

  1. Cách hoạt động: const ac = new AbortController(); truyền { signal: ac.signal } vào fetch và addEventListener. ac.abort() reject fetch với AbortError và gỡ listener đã dùng signal đó.
  2. 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.
  3. Bỏ qua stale mà không abort vẫn tốn network. Nên abort và bỏ qua: catch AbortError rồi return; đừng toast nó; đừng gửi lên Sentry.
  4. Promise.race([fetch(url, { signal }), timeout(ms, ac)]) abort được khi timeout nếu timeout gọi ac.abort(). Các nhánh Promise.all vẫn chạy trừ khi chúng nhận cùng signal.
  5. Cleanup lúc unmount: Vue watch onCleanup(() => ac.abort()), onScopeDispose, hoặc cleanup useEffect React. 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ệnVueReact
Đổi querywatch onCleanup → abort()Cleanup effect abort ac trước
Unmount / rời routeonScopeDispose / cùng onCleanupCleanup effect lúc unmount
TimeoutAbortSignal.timeout(ms) hoặc race + abortCùng primitive
POST đã confirmĐừng abort trừ khi rời trang là không an toànGiố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 AbortSignal cho fetch, listener, và watch onCleanup của Vue.
  • Abort là hợp tác. fetch bên trong quên signal vẫn chạy xong và có thể ghi kết quả stale.
  • Promise.all kè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 AbortError trước Sentry để cancel typeahead không gọi bạn dậy. Retry sau abort là sai; retry sau 503 mới đúng. Kiểm signal.aborted trướ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ý: AbortController mới mỗi query; bỏ qua AbortError.
  • Triệu chứng: Sentry ngập mỗi lần gõ. Nguyên nhân: log AbortError như failure. Cách xử lý: return khi name === 'AbortError'.
  • Triệu chứng: facet vẫn tới sau abort. Nguyên nhân: các nhánh Promise.all không nhận signal. Cách xử lý: truyền cùng signal và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ếu signal.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 AbortSignal cho fetch, listener, và cleanup watch Vue.
  • Abort và bỏ stale. Bộ đếm seq vẫn tốn network.
  • Các nhánh Promise.all vẫ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 AbortController trong effect / watch, truyền signal vào mỗi API, abort trong onCleanup.
  • AbortSignal.timeout(ms) làm gì? Trả về signal abort sau ms. Kết hợp với fetch; vẫn catch AbortError / 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ẻ.