Skip to content

Instantly share code, notes, and snippets.

@dangdinh87
Created April 20, 2026 02:45
Show Gist options
  • Select an option

  • Save dangdinh87/b90cfd41eb0816e3d91ce46a9c94d675 to your computer and use it in GitHub Desktop.

Select an option

Save dangdinh87/b90cfd41eb0816e3d91ce46a9c94d675 to your computer and use it in GitHub Desktop.
13 cau phong van Frontend - giai thich tu nhien tieng Viet

13 câu phỏng vấn Frontend — trả lời tự nhiên, tiếng Việt

Em hình dung mình đang ngồi đối diện anh chị phỏng vấn, nói chuyện người với người. Không bullet rườm rà, không bảng biểu, chỉ kể lại cho dễ hiểu.


1. Event loop trong JavaScript là gì

JavaScript chạy đơn luồng, nghĩa là chỉ có một luồng duy nhất để thực thi mã. Vậy tại sao nó vẫn xử lý được những việc bất đồng bộ như gọi mạng, đặt hẹn giờ? Câu trả lời nằm ở event loop — vòng lặp sự kiện.

Em hình dung thế này. Có một cái ngăn xếp gọi hàm, gọi là call stack, nơi các hàm đang chạy được xếp chồng lên nhau. Khi em gọi setTimeout, thật ra setTimeout không phải của bản thân JavaScript, mà là của môi trường trình duyệt cung cấp. Trình duyệt nhận lệnh, đếm giờ giùm em, xong thì đẩy hàm callback vào một hàng đợi. Event loop là anh chàng liên tục kiểm tra: "Ngăn xếp trống chưa? Trống rồi hả, để anh lấy việc tiếp theo trong hàng đợi vào chạy."

Có điểm thú vị là không phải một mà có hai hàng đợi: vi tác vụ và tác vụ lớn, hay microtask và macrotask. Vi tác vụ được ưu tiên cao hơn. Ví dụ callback của Promise, hàm queueMicrotask là vi tác vụ. Còn setTimeout, sự kiện giao diện là tác vụ lớn. Quy tắc là: sau khi chạy xong một tác vụ lớn, event loop sẽ dọn sạch toàn bộ hàng đợi vi tác vụ rồi mới sang tác vụ lớn kế tiếp.

Đó là lý do vì sao Promise.resolve().then() luôn chạy trước setTimeout(fn, 0) dù cả hai được gọi cùng lúc. Một ví dụ kinh điển mà người phỏng vấn hay hỏi: in 1, rồi setTimeout in 2, rồi Promise.then in 3, rồi in 4 — kết quả là 1, 4, 3, 2. Hiểu được cơ chế này coi như nắm được cái gốc của bất đồng bộ trong JavaScript rồi.


2. Các cấu trúc dữ liệu trong JavaScript

JavaScript có khá đầy đủ các cấu trúc dữ liệu cơ bản. Phổ biến nhất dĩ nhiên là mảng và đối tượng — một cái đánh chỉ số theo số, một cái lưu theo cặp khóa - giá trị. Trước ES6, đối tượng là cách duy nhất để làm bản đồ khóa-giá trị, nhưng nó có một số hạn chế: khóa chỉ có thể là chuỗi hoặc Symbol, không đảm bảo thứ tự chèn một cách nhất quán (khóa dạng số sẽ bị sắp xếp trước), và không có thuộc tính .size.

Map ra đời để giải quyết những vấn đề đó. Khóa của Map có thể là bất cứ kiểu nào — đối tượng, hàm, kiểu nguyên thủy — đều được. Map giữ đúng thứ tự chèn. Em hay dùng Map khi cần lưu dữ liệu gắn với một đối tượng (ví dụ thông tin bổ sung cho phần tử DOM), hoặc khi cần thêm xóa khóa liên tục, vì Map được tối ưu cho trường hợp đó tốt hơn đối tượng thường.

Set là tập hợp các giá trị duy nhất, không trùng. Em dùng Set nhiều nhất để khử trùng lặp trong mảng — chỉ cần [...new Set(arr)] là một dòng gọn hơn lọc kết hợp indexOf rất nhiều. Set cũng tra cứu với độ phức tạp hằng số nên rất hữu ích khi cần kiểm tra tồn tại nhanh.

WeakMap và WeakSet đặc biệt ở chỗ không giữ tham chiếu — nghĩa là khóa có thể bị bộ thu gom rác dọn đi. Em dùng khi muốn gắn dữ liệu vào một đối tượng mà không muốn gây rò rỉ bộ nhớ. Ví dụ lưu thông tin phụ cho phần tử DOM: khi phần tử bị gỡ khỏi trang, thông tin trong WeakMap cũng tự biến mất.

Những cấu trúc khác như ngăn xếp, hàng đợi, danh sách liên kết, cây, đồ thị thì không có sẵn nhưng em có thể tự xây dựng trên nền mảng hoặc đối tượng, không khó.


3. JavaScript có hướng đối tượng không

Có, nhưng khác Java hay C++. JavaScript dùng kế thừa theo prototype, không phải theo lớp. Từ phiên bản ES6 có từ khóa class nhưng thật ra chỉ là cú pháp đường cho dễ viết, bên dưới vẫn là prototype. Nếu em viết class Dog { bark() {} } thì JavaScript vẫn tạo một hàm tên Dog và gán bark vào Dog.prototype.

Cả bốn trụ cột của lập trình hướng đối tượng đều làm được. Tính đóng gói thì có trường riêng tư với ký tự # từ ES2022, hoặc dùng closure nếu phiên bản cũ. Tính kế thừa có extendssuper. Tính đa hình thì ghi đè phương thức như bình thường. Tính trừu tượng không có lớp trừu tượng thực thụ, nhưng ném lỗi trong constructor là cách phổ biến để mô phỏng.

Tuy nhiên, xu hướng hiện đại không quá chuộng kế thừa sâu. React từ component dạng lớp chuyển sang hàm kết hợp hook chính là đang rời bỏ hướng đối tượng truyền thống để theo lập trình hàm và tính ghép nối. Triết lý "ghép nối tốt hơn kế thừa" — thay vì xây cả cây kế thừa, mình ghép những hàm nhỏ lại với nhau. Em cũng thích cách này hơn vì dễ kiểm thử, dễ suy luận, không có tầng kế thừa lằng nhằng.


4. Các cách tối ưu gói sản phẩm khi xây dựng

Câu này em chia làm hai hướng: giảm kích thước và chia nhỏ để tải thông minh.

Về giảm kích thước, đầu tiên là rung cây để rụng lá — chỉ đóng gói những đoạn mã thực sự được sử dụng. Muốn làm được thì phải dùng import kiểu ES Module, và thư viện cũng phải khai báo sideEffects: false trong package.json. Em từng gặp trường hợp nhập lodash sai cách: viết import { debounce } from 'lodash' tưởng chỉ lấy mỗi hàm debounce, nhưng thực ra cả thư viện vẫn bị đóng gói vào. Phải viết import debounce from 'lodash/debounce' hoặc chuyển sang dùng lodash-es.

Tiếp theo là nén mã, nén bằng Brotli hay gzip ở máy chủ — mấy cái này giờ là mặc định rồi. Quan trọng hơn là chọn thư viện nhẹ. Moment.js nặng 67KB, có thể thay bằng date-fns hoặc dayjs chỉ 2KB. Mỗi thư viện phụ thuộc đều phải cân nhắc cái giá của nó.

Về chia nhỏ, em dùng kỹ thuật chia mã kết hợp với React.lazyimport động. Chia theo đường dẫn là bước đầu tiên ai cũng làm. Sau đó là chia theo tính năng nặng: trình soạn thảo văn bản, biểu đồ, bản đồ, trình xem PDF — những thứ người dùng không nhất thiết phải chạm vào ngay khi mở trang.

Gói thư viện bên thứ ba em tách riêng để giữ bộ nhớ đệm lâu dài. Thư viện ít đổi, mã ứng dụng đổi liên tục — tách ra thì người dùng chỉ tải lại phần ứng dụng, phần thư viện vẫn giữ trong bộ nhớ đệm của trình duyệt.

Và đừng quên đo. Em dùng vite-plugin-visualizer hoặc webpack-bundle-analyzer để xem tệp nào đang nặng. Nhiều khi có một thư viện không ngờ tới bị kéo vào qua phụ thuộc gián tiếp, đo mới phát hiện được.


5. Cơ chế hiển thị hộp thoại lên DOM bằng Portal

Vấn đề xảy ra khi em viết hộp thoại bình thường trong cây component: hộp thoại có thể bị cắt bởi overflow: hidden của phần tử cha, hoặc bị phần tử khác đè lên vì ngữ cảnh xếp chồng. Ví dụ phần tử cha có transform: translateZ(0) thì dù em đặt z-index lên tận 9999, hộp thoại vẫn không "nhảy ra" được khỏi ngữ cảnh xếp chồng của cha.

Portal là giải pháp cho chuyện này. React cung cấp hàm createPortal để hiển thị một component ở một vị trí khác trong cây DOM — thường là document.body. Điểm quan trọng là: phần DOM thật của hộp thoại nằm ở body, nhưng về mặt logic của React, nó vẫn là con của component gọi. Sự kiện vẫn nổi bọt theo cây React chứ không theo cây DOM thật, nên em vẫn bắt được sự kiện ở component cha dù DOM đã "nhảy" đi chỗ khác.

Mã cơ bản chỉ là gọi createPortal(hopThoai, document.body). Nhờ đó hộp thoại thoát được mọi overflow, mọi ngữ cảnh xếp chồng, hiển thị ở tầng cao nhất.

Tất cả các thư viện hộp thoại chuẩn hiện nay — Radix, shadcn, Headless UI, MUI — đều dùng Portal. Nhưng Portal mới là một nửa câu chuyện. Còn phải lo chuyện bẫy tiêu điểm bàn phím, khóa cuộn trang, đóng bằng phím Esc, các thuộc tính trợ năng, khôi phục tiêu điểm khi đóng. Chính vì nhiều thứ phải lo như vậy nên mọi người thường dùng thư viện thay vì tự viết.


6. Viết hộp thoại thuần React, không dùng thư viện

Em đi qua logic chính. Đầu tiên là cơ chế kiểm soát — nhận hai tham số openonClose từ component cha để bên ngoài điều khiển trạng thái. Thân component kiểm tra if (!open) return null để không hiển thị khi đóng.

Phần hiển thị em bọc trong createPortal đưa lên body. Bên trong có một lớp phủ tối mờ chiếm toàn màn hình và một hộp nội dung ở giữa. Nhấp vào lớp phủ thì đóng, nhấp vào hộp nội dung thì chặn sự kiện lan lên để không bị đóng nhầm.

Những chuyện trợ năng em xử lý bằng useEffect. Khi open chuyển thành đúng: lưu lại phần tử đang được tiêu điểm, đặt body.style.overflow = 'hidden' để khóa cuộn, chuyển tiêu điểm vào hộp thoại. Khi đóng hoặc component bị gỡ: khôi phục lại cuộn và trả tiêu điểm về phần tử ban đầu.

Đóng bằng Esc thì em gắn sự kiện keydown lên cửa sổ trong một useEffect, nhớ dọn dẹp khi gỡ. Phụ thuộc là [open, onClose] để không rò rỉ.

Bẫy tiêu điểm là phần công phu nhất. Khi người dùng nhấn Tab, em phải chặn không cho tiêu điểm ra ngoài hộp thoại. Lấy danh sách các phần tử có thể nhận tiêu điểm trong hộp, nếu đang ở phần tử cuối mà nhấn Tab thì đưa về phần tử đầu, ngược lại Shift+Tab ở phần tử đầu thì nhảy xuống cuối.

Các thuộc tính trợ năng không thể thiếu: role="dialog", aria-modal="true", aria-labelledby trỏ vào tiêu đề. Trình đọc màn hình nhờ đó mới hiểu đây là một hộp thoại chặn.

Hộp thoại mức sản xuất còn có hiệu ứng mở và đóng mượt, quản lý ngăn xếp khi nhiều hộp thoại đè nhau, bảo vệ an toàn khi kết xuất phía máy chủ bằng cách kiểm tra typeof window, xử lý quirk khóa cuộn trên iOS Safari. Đi hết cũng hơn trăm dòng, nên thực tế em dùng Radix.


7. Tham trị, tham chiếu, và khi nào dùng map để trả về

Các kiểu nguyên thủy trong JavaScript (số, chuỗi, đúng sai, null, undefined, symbol, bigint) được truyền theo giá trị — sao chép giá trị, sửa bản sao không ảnh hưởng bản gốc. Còn đối tượng, mảng, hàm thì được truyền theo tham chiếu — sao chép địa chỉ, sửa qua địa chỉ thì bản gốc cũng đổi.

Nói chính xác hơn một chút thì JavaScript luôn truyền theo giá trị, chỉ là với đối tượng thì giá trị đó là một tham chiếu. Nghe hơi rối nhưng ví dụ minh họa sẽ rõ. Nếu em viết function f(obj) { obj = {x: 999} } thì không đổi được đối tượng bên ngoài, vì em chỉ gán lại biến cục bộ. Nhưng function f(obj) { obj.x = 999 } thì đổi được, vì em sửa thông qua tham chiếu được chia sẻ. Đó là lý do thuật ngữ chính xác gọi là "truyền theo chia sẻ".

Còn phần câu hỏi khi nào dùng .map() để trả về tham trị hay tham chiếu — em hiểu ý là trong ngữ cảnh React, khi cập nhật trạng thái dạng mảng các đối tượng.

Hàm map luôn trả về một mảng mới, đó là hợp đồng của nó. Nhưng từng phần tử bên trong có thể là giá trị nguyên thủy mới hay tham chiếu cũ đều được. Quy tắc em áp dụng là như sau.

Khi biến đổi sang giá trị nguyên thủy — ví dụ lấy danh sách mã số từ danh sách người dùng — trả thẳng giá trị mới là xong: users.map(u => u.id). Sạch sẽ, không vấn đề.

Khi cập nhật đối tượng trong mảng theo kiểu bất biến của React hoặc Redux, phải trả về đối tượng mới cho phần tử thay đổi, còn phần tử không đổi thì trả về tham chiếu cũ cũng được. Chẳng hạn users.map(u => u.id === targetId ? { ...u, active: true } : u). Lý do quan trọng: React so sánh tham chiếu bằng Object.is. Nếu em sửa trực tiếp u.active = true rồi trả lại u thì tham chiếu không đổi, React nghĩ không có gì mới, không kết xuất lại. Phải tạo đối tượng mới mới có hiệu lực.

Và đừng bao giờ dùng map để sửa dữ liệu tại chỗ. Map là để biến đổi và trả về. Nếu chỉ muốn gây tác dụng phụ thì dùng forEach. Lẫn lộn hai cái này là lỗi thường gặp khiến mã khó đọc và khó dò lỗi.


8. Chia mã và tải lười để tối ưu hiệu năng khi xây dựng

Hai kỹ thuật này đi đôi với nhau. Chia mã là tách gói sản phẩm thành nhiều tệp, tải lười là chỉ tải tệp đó khi thực sự cần dùng.

Cách dễ làm nhất là chia theo đường dẫn. Viết const Dashboard = lazy(() => import('./Dashboard')) — khi người dùng vào đường dẫn /dashboard, công cụ xây dựng mới thật sự kéo đoạn mã Dashboard về. Bọc trong <Suspense fallback={<Skeleton/>}> để có giao diện chờ.

Cấp độ tiếp theo là chia theo component nặng. Em có trình soạn thảo văn bản giàu định dạng, thư viện biểu đồ, component bản đồ — không phải người dùng nào cũng chạm vào. Em tải lười: chỉ khi người dùng nhấn nút "Chỉnh sửa" mới kéo trình soạn thảo về. Hoặc hộp thoại phức tạp thì tải khi mở lần đầu.

Có một mẹo hay là tải sớm khi chuột vừa di vào — khi người dùng đưa chuột đến một liên kết, em kích hoạt luôn import động để lúc họ nhấn thì đã có sẵn. Next.js và Remix còn tự động tải sớm cho các liên kết trong vùng nhìn, không cần em làm tay.

Nhưng không phải cái gì cũng nên tải lười. Component nằm trong tầm ngắm của chỉ số LCP — ví dụ khu vực chính ngay trên đầu trang — mà tải lười thì người dùng phải đợi thêm một chuyến tải mới thấy màn hình đầu tiên, lợi bất cập hại. Component nhỏ, dùng ở nhiều nơi như nút bấm, ô nhập thì tải lười không đáng, chi phí mỗi tệp lẻ còn tốn hơn phần lợi ích.

Với hình ảnh thì trình duyệt đã hỗ trợ sẵn: <img loading="lazy" />. Nhưng ảnh LCP ngược lại, phải fetchpriority="high", không được tải lười, còn nên có thẻ preload trong phần đầu trang.

Để đo hiệu quả, em dùng Lighthouse, tab Coverage của Chrome DevTools để xem mã nào chưa chạy, tab Performance để xem luồng chính bị chặn ở đâu.


9. Vi tác vụ và tác vụ lớn

Trước khi đi vào liệt kê, em cần làm rõ: event loop pick việc để chạy từ hai hàng đợi khác nhau, mỗi hàng có nguồn riêng và độ ưu tiên riêng. Hiểu đúng từng nguồn sẽ tránh được cả loạt lỗi kiểu "sao callback này chạy sau callback kia?".

Tác vụ lớn (macrotask) gồm những gì

Nhóm 1 — Hẹn giờ:

setTimeout(fn, 0);    // dù 0ms vẫn là macrotask, không chạy ngay
setInterval(fn, 100); // mỗi lần tick là 1 macrotask mới

Mỗi lần hẹn giờ đến hạn, trình duyệt đẩy callback vào hàng đợi tác vụ lớn. Không đảm bảo chạy chính xác sau 0ms — phải chờ hàng đợi trống.

Nhóm 2 — Sự kiện người dùng và DOM:

button.addEventListener('click', handler);   // click
input.addEventListener('input', handler);    // gõ phím
window.addEventListener('scroll', handler);  // cuộn
window.addEventListener('resize', handler);  // thay đổi kích thước
form.addEventListener('submit', handler);
img.addEventListener('load', handler);       // ảnh tải xong

Mỗi lần user tương tác, trình duyệt sinh một macrotask. Đây là lý do nếu handler chậm thì UI lag — nó chặn các tác vụ khác trong cùng hàng đợi.

Nhóm 3 — Yêu cầu mạng, đọc tệp:

// XHR kiểu cũ
const xhr = new XMLHttpRequest();
xhr.onload = handler;  // macrotask khi phản hồi về

// Đọc tệp
const reader = new FileReader();
reader.onload = handler;

// WebSocket
ws.addEventListener('message', handler);
ws.addEventListener('open', handler);

Lưu ý quan trọng: fetch(...) trả về Promise, nên callback .then của fetch là vi tác vụ, khác với onload của XHR.

Nhóm 4 — Kết thúc hiệu ứng CSS:

el.addEventListener('transitionend', handler);
el.addEventListener('animationend', handler);

Nhóm 5 — Vòng đời tài liệu:

document.addEventListener('DOMContentLoaded', handler);
window.addEventListener('load', handler);
window.addEventListener('popstate', handler);  // back/forward

Nhóm 6 — Giao tiếp giữa các ngữ cảnh:

window.postMessage(data, '*');
iframe.contentWindow.postMessage(data, '*');

const channel = new MessageChannel();
channel.port1.onmessage = handler;

const bc = new BroadcastChannel('app');
bc.onmessage = handler;

Nhóm 7 — Node.js:

setImmediate(fn);           // chạy sau I/O, trước setTimeout trong cùng vòng
fs.readFile(path, cb);      // cb là macrotask sau I/O
server.on('request', cb);

Hai loại đặc biệt cần phân biệt:

requestAnimationFrame(fn);  // chạy trước khi browser vẽ frame tiếp theo
                            // ~60fps, lý tưởng cho animation
requestIdleCallback(fn);    // chạy khi browser rảnh, có thể bị delay vô thời hạn
                            // dùng cho việc không khẩn: analytics, prefetch

Hai loại này về kỹ thuật không phải macrotask thuần — rAF có giai đoạn riêng ngay trước paint, rIC có giai đoạn riêng sau tất cả. Nhưng so với microtask thì độ ưu tiên thấp hơn, nên thường xếp chung nhóm macrotask khi giải thích.

Vi tác vụ (microtask) gồm những gì

Nguồn 1 — Callback của Promise:

fetch('/api').then(handler);      // handler là microtask
promise.catch(handler);
promise.finally(handler);

Nguồn 2 — Mã sau await:

async function run() {
  console.log('trước');  // đồng bộ
  await somePromise;
  console.log('sau');    // MICROTASK — tương đương .then
}

Chú ý: bản thân hàm async bắt đầu chạy đồng bộ; chỉ phần sau await mới là vi tác vụ.

Nguồn 3 — queueMicrotask tường minh:

queueMicrotask(() => {
  console.log('chạy ngay sau stack hiện tại');
});

Dùng khi cần đẩy một việc vào hàng đợi vi tác vụ mà không muốn tạo Promise thừa. Nhẹ hơn Promise.resolve().then(fn) một chút.

Nguồn 4 — MutationObserver:

const observer = new MutationObserver((mutations) => {
  // chạy như microtask khi DOM thay đổi
});
observer.observe(targetNode, { childList: true, subtree: true });

React dùng pattern này bên trong để batch cập nhật DOM.

Nguồn 5 — IntersectionObserverResizeObserver:

new IntersectionObserver(callback).observe(el);
new ResizeObserver(callback).observe(el);

Về spec thì hai cái này có giai đoạn riêng trong render cycle, không thuần microtask — nhưng thực tế chạy với độ ưu tiên cao, trước macrotask.

Nguồn 6 — Node.js có thêm:

process.nextTick(fn);  // ƯU TIÊN CAO NHẤT trong Node — chạy trước cả microtask thường
queueMicrotask(fn);    // giống browser

Điểm quan trọng: process.nextTickvũ khí hai lưỡi — lạm dụng sẽ chặn I/O của Node vì nó luôn được ưu tiên.

Quy tắc vận hành giữa hai loại

Mỗi lượt event loop làm ba bước chính:

  1. Lấy một macrotask ra chạy hết.
  2. Dọn sạch toàn bộ microtask (kể cả microtask sinh ra trong khi đang dọn).
  3. Cho browser render (nếu cần), rồi quay lại bước 1.

Hệ quả là nếu em viết vòng lặp microtask tự gọi lại chính nó:

function loop() {
  Promise.resolve().then(loop); // BAD — browser không bao giờ vẽ được
}
loop();

Trình duyệt đơ. Vì bước 2 không bao giờ kết thúc.

Ngược lại, nếu em nhường quyền bằng macrotask:

function loop() {
  setTimeout(loop, 0);  // OK — browser chen vào render được
}

Trình duyệt vẫn mượt, mỗi lần lặp có một khoảng để paint, xử lý event.

Khi nào chọn cái nào — góc nhìn dev

Dùng microtask (Promise, queueMicrotask) khi:

  • Cần một việc chạy ngay sau mã hiện tại kết thúc, trước khi bất cứ event hay render nào chen vào.
  • Muốn đảm bảo thứ tự các cập nhật xảy ra trong cùng một "nhịp".
  • Batch nhiều thay đổi rồi commit một lần (pattern mà React, Vue dùng bên trong).

Dùng macrotask (setTimeout, setImmediate) khi:

  • Cần nhường quyền cho trình duyệt vẽ, xử lý input.
  • Chia nhỏ công việc nặng thành nhiều khúc để UI không đơ.
  • Muốn delay có chủ đích.

Dùng requestAnimationFrame khi:

  • Làm animation — đồng bộ với nhịp vẽ của trình duyệt (thường 60fps).
  • Thay đổi style phụ thuộc frame, ví dụ scroll-driven animation.

Ví dụ thực tế — xử lý mảng 100 nghìn phần tử không làm đơ UI:

function processChunked(items, i = 0) {
  const start = performance.now();
  // làm việc cho đến khi hết 5ms thì nghỉ
  while (i < items.length && performance.now() - start < 5) {
    heavyWork(items[i]);
    i++;
  }
  if (i < items.length) {
    setTimeout(() => processChunked(items, i), 0);  // nhường quyền
  }
}

Nếu đổi setTimeout thành Promise.resolve().then → UI vẫn đơ, vì microtask không cho browser paint.

Ví dụ tổng hợp để kiểm tra

console.log('A');

setTimeout(() => console.log('B'), 0);

Promise.resolve().then(() => {
  console.log('C');
  queueMicrotask(() => console.log('D'));
});

queueMicrotask(() => console.log('E'));

console.log('F');

Output: A F C E D B. Giải thích từng bước:

  1. A — đồng bộ, in ngay.
  2. setTimeout — đẩy callback B vào hàng đợi macrotask.
  3. Promise.resolve().then(...) — đẩy callback in C vào hàng đợi microtask.
  4. queueMicrotask(E) — đẩy E vào hàng đợi microtask sau C.
  5. F — đồng bộ, in ngay.
  6. Stack trống → dọn microtask:
    • C chạy, trong lúc chạy thêm D vào cuối hàng đợi microtask.
    • E chạy.
    • D chạy (vẫn cùng lượt dọn, vì microtask sinh ra trong microtask được gom).
  7. Microtask hết → lấy macrotask → B chạy.

Nhớ được luồng này là nắm chắc cơ chế event loop rồi. Câu follow-up thường gặp là biến tấu thêm async/await vào — nhưng quy tắc vẫn thế: mã sau await = microtask.


10. Gọi setState ba lần trong một hàm, nó chạy ra sao

Câu này rất hay, nhiều bạn mới hay bị nhầm. Trả lời ngắn gọn: React gom ba lệnh setState đó lại, chỉ kết xuất lại một lần sau khi hàm chạy xong.

Nhưng có một cái bẫy. Nếu em viết setCount(count + 1) ba lần liên tiếp thì kết quả cuối cùng chỉ tăng lên 1, không phải 3. Vì biến count trong bao đóng là giá trị tại lần kết xuất đó, không cập nhật giữa các lời gọi setState. Cả ba lần đều thấy count bằng 0 nên đều đặt thành 1.

Muốn tăng thật sự ba lần thì dùng dạng hàm cập nhật: setCount(c => c + 1) ba lần. React xếp ba hàm cập nhật vào hàng đợi, khi kết xuất kế tiếp nó duyệt hàng đợi từ trên xuống: 0 thành 1, 1 thành 2, 2 thành 3. Kết quả là 3.

Nếu là ba biến trạng thái khác nhau thì đơn giản hơn. Viết setA(1); setB(2); setC(3) — React 18 gom tất cả lại, kết xuất lại một lần với A bằng 1, B bằng 2, C bằng 3. Lưu ý là React 17 chỉ gom trong trình xử lý sự kiện của React thôi, còn ngoài sự kiện (trong setTimeout, Promise) thì không gom, mỗi setState là một lần kết xuất. React 18 vá luôn những trường hợp đó, gọi là tự động gom tự động.

Câu hỏi nối tiếp hay gặp: làm sao biết khi nào setState hoàn tất để làm việc tiếp theo? Câu trả lời là dùng useEffect với phụ thuộc là trạng thái đó. Đừng mong đợi setState trả về Promise để await — không có chuyện đó đâu.

Còn câu hỏi vì sao không sửa trực tiếp trạng thái: vì React so sánh bằng Object.is. Sửa tại chỗ không đổi tham chiếu, React nghĩ chưa có gì mới, không kết xuất lại. Cập nhật bất biến là bắt buộc.


11. Hai hộp thoại cùng z-index 50, cái sau đè cái trước, vì sao

Quy tắc của CSS là: khi hai phần tử có cùng z-index trong cùng một ngữ cảnh xếp chồng, phần tử nào đứng sau trong cây DOM sẽ vẽ đè lên trên. Đây là quy tắc quyết định thứ tự vẽ khi hòa.

Khi em mở hộp thoại thứ hai, shadcn (dùng Radix) sẽ gọi appendChild gắn một nút Portal mới vào cuối document.body. Hộp thoại thứ nhất đã nằm sẵn ở đâu đó trong body rồi, hộp thoại mới gắn sau nên theo thứ tự DOM nó đứng sau. Cả hai cùng z-index 50, hòa nhau, tra đến thứ tự DOM — hộp thoại sau thắng.

Vậy nếu em muốn chủ động ép một hộp thoại lên trên thì có vài cách. Đơn giản nhất là tăng z-index của nó, kiểu z-[60] hay z-[70]. Em cũng có thể xây dựng một hệ thống thang z-index ngay từ đầu, ví dụ modal 1040, popover 1060, tooltip 1070 — chia tầng rõ ràng để không đụng nhau.

Có một cái bẫy nữa là ngữ cảnh xếp chồng. Nếu hộp thoại được hiển thị trong một phần tử cha có transform, filter, opacity nhỏ hơn 1, hoặc một vài thuộc tính khác, phần tử cha sẽ tạo ra một ngữ cảnh xếp chồng mới. Khi đó z-index của hộp thoại chỉ so với các phần tử anh em trong ngữ cảnh đó, không "nhảy" ra cấp toàn cục được. Chính vì thế việc Portal hiển thị thẳng ra document.body rất quan trọng — để thoát khỏi mọi ngữ cảnh xếp chồng cục bộ.


12. Hook useTransition của React để làm gì

Đây là một tính năng của kết xuất đồng thời trong React 18. Ý tưởng là phân biệt cập nhật khẩn cấp và cập nhật không khẩn cấp.

Tình huống hay nhất để giải thích là ô tìm kiếm lọc danh sách mười nghìn phần tử. Khi người dùng gõ phím có hai việc xảy ra: cập nhật giá trị trong ô nhập (rất khẩn, nếu chậm thì con trỏ nhảy lung tung, gõ rất khó chịu) và lọc rồi kết xuất lại danh sách (tốn thời gian nhưng trễ một chút cũng chấp nhận được). Hook useTransition giúp em đánh dấu cái thứ hai là "không khẩn".

Cách viết rất đơn giản: const [isPending, startTransition] = useTransition(). Khi xử lý sự kiện gõ phím, em tách ra hai phần: setInput(value) chạy bình thường (khẩn), còn startTransition(() => setQuery(value)) là không khẩn. React biết rằng nếu đang kết xuất phần không khẩn mà có cập nhật khẩn khác ập đến (người dùng gõ tiếp), nó có quyền hủy giữa chừng, xử lý cập nhật khẩn trước, rồi quay lại kết xuất tiếp sau.

Kết quả là ô nhập luôn mượt, danh sách có thể hơi trễ chút nhưng không bao giờ chặn việc gõ.

Khác gì chống nảy? Chống nảy là đợi một khoảng cố định (chẳng hạn 300 mili giây) rồi mới cập nhật. useTransition không đợi, cập nhật ngay nhưng cho phép ngắt giữa chừng. Hai cái đôi khi vẫn dùng chung.

Anh em với useTransition là useDeferredValue. Khi em không sở hữu hàm đặt trạng thái — ví dụ nhận query từ tham số — thì viết const deferred = useDeferredValue(query) để trì hoãn. Có thể so sánh query !== deferred để biết đang ở trạng thái cũ.

Kết hợp với Suspense còn làm được hiệu ứng chuyển trang rất mượt. Người dùng nhấn sang trang mới, trang cũ vẫn hiển thị với isPending bằng đúng (có thể hiện một vòng quay nhỏ), dữ liệu mới tải xong thì chuyển. Không có cảnh trắng xóa hay khung xương nhấp nháy — trải nghiệm cao cấp hơn rất nhiều.


13. Tối ưu hiệu năng cho một trang web

Đây là câu rất rộng. Em trả lời theo khung Core Web Vitals và chia làm ba giai đoạn: tải, kết xuất, và chạy khi người dùng tương tác.

Về giai đoạn tải. Nén mã, nén nội dung bằng Brotli, dùng mạng phân phối nội dung, bật HTTP/2 — mấy cái này là nền. Quan trọng hơn là tiêu đề bộ nhớ đệm: những tệp có băm nội dung trong tên thì đặt Cache-Control: immutable cho trình duyệt giữ mãi mãi. Tải sớm font và ảnh chính, kết nối sớm tới máy chủ API để tiết kiệm lần bắt tay DNS và TLS. Ảnh dùng định dạng mới như WebP, AVIF, đặt thuộc tính widthheight rõ ràng để tránh xô lệch bố cục. Ảnh chính thì fetchpriority="high", ảnh nằm dưới thì loading="lazy".

Về giai đoạn kết xuất. Các trang cần tối ưu cho công cụ tìm kiếm hay trang tiếp thị thì dùng kết xuất phía máy chủ hoặc tạo tĩnh để thời gian tới byte đầu tiên và thời điểm nội dung chính xuất hiện đều nhanh. React 18 có kết xuất dòng chảy, gửi HTML về từng phần. CSS quan trọng nhúng thẳng vào phần đầu trang, phần còn lại trì hoãn. JavaScript thì dùng defer hoặc async, đừng để chặn trình phân tích HTML. Component phía máy chủ trong Next App Router — những phần không cần tương tác — có thể không ship JavaScript về trình duyệt, giảm được rất nhiều khối lượng.

Về giai đoạn chạy khi tương tác. Chia mã và tải lười như đã nói ở câu tám. Ghi nhớ bằng useMemouseCallback chỉ khi đo được lợi ích, đừng bọc tràn lan. Danh sách dài thì ảo hóa bằng react-window hay tanstack/virtual. Chống nảy cho ô tìm kiếm, điều tiết cho cuộn và thay đổi kích thước. Tính toán nặng (phân tích tệp lớn, xử lý ảnh) đẩy sang Web Worker để không chặn luồng chính. Hook useTransition cho những cập nhật giao diện không được phép chặn tương tác.

Về dữ liệu. Tránh mô hình N cộng 1 — gọi nhiều lần cho một lần hỏi — mà gộp lại thành một. Dùng SWR hay React Query để có bộ nhớ đệm và khử trùng lặp yêu cầu. Cập nhật lạc quan để giao diện phản hồi ngay không cần chờ máy chủ.

Và tư duy quan trọng nhất: đo trước khi tối ưu. Em không sửa cái gì em không đo được. Dùng Lighthouse, tab Performance của Chrome DevTools, công cụ theo dõi người dùng thật như Sentry hay Vercel Analytics để biết người dùng thật sự gặp vấn đề gì. Quy tắc 80-20 — thường chỉ 20 phần trăm mã gây ra 80 phần trăm vấn đề. Tìm đúng chỗ đau rồi sửa, đừng phân tán công sức.

Có một câu em luôn nhớ khi làm tối ưu hiệu năng: mọi sự tối ưu đều có cái giá của nó. Gói nhỏ thì có thể mất tính năng. Chia mã nhiều thì tăng số lần yêu cầu. Ghi nhớ nhiều thì tốn bộ nhớ. Phải luôn đánh đổi một cách có ý thức.


Hết mười ba câu rồi ạ. Anh chị có câu nào muốn em đào sâu thêm, em sẵn sàng.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment