Hai app dưới đây dùng chung một source code React. Cùng component, cùng số lần re-render, cùng thuật toán. Một cái bấm được ở giây thứ 0,9. Cái còn lại bắt người dùng nhìn màn hình trắng đến giây thứ 4,1.
Cả hai đều đi qua đúng bốn chặng giống hệt nhau: phân giải DNS, bắt tay TCP/TLS, tải bundle, parse & compile, rồi mới chạy JS và hydrate. Khác nhau chỉ ở độ dài từng chặng.
Trước khi xem, hãy tự trả lời: 3,2 giây chênh lệch đó rơi vào chặng nào?
Source code React chỉ là đầu vào. Thứ trình duyệt thật sự nhận được là đầu ra của một dây chuyền khác: build đóng gói ra sao, code nằm ở server nào, HTML nạp script kiểu gì. Cùng một thư mục src/ hoàn toàn có thể cho ra hai sản phẩm chênh nhau mười lần dung lượng.
| Chặng | App A | App B | Cái gì đã khác |
|---|---|---|---|
| DNS + TCP + TLS | 300ms | 700ms | Hạ tầng mạng, không dính dáng gì tới React: CDN edge đặt gần người dùng hay một origin duy nhất ở nước khác, resolver đã cache bản ghi DNS chưa, TLS có session resumption không. |
| Tải bundle JS | 200ms | 1750ms | Build config. App A code-split và tree-shake còn 180KB, chỉ tải chunk của route đang mở. App B gói cả app vào một file 1.8MB: barrel import kéo theo cả thư viện, moment kèm đủ locale, không bật brotli. |
| Parse & Compile | 150ms | 1400ms | Số byte JavaScript. Parse và compile tỉ lệ với dung lượng file, không tỉ lệ với độ phức tạp thuật toán. Trình duyệt phải parse hết 1.8MB trước khi biết dòng nào thật sự được dùng. |
| Chạy JS + Hydrate | 250ms | 250ms | Không khác gì cả. Đây mới là phần React làm việc, và hai app làm đúng cùng một lượng việc. |
Nhìn dòng cuối bảng: 250ms ở cả hai. Đúng như đề bài trước đó: cùng component, cùng số lần re-render, cùng thuật toán, nên React làm đúng một lượng việc như nhau. Toàn bộ 3,2 giây chênh lệch nằm trọn trong ba dòng phía trên, giai đoạn React còn chưa được nạp vào bộ nhớ. Đó cũng là lý do useMemo, useCallback hay React.memo không cứu nổi App B: chúng chỉ tác động vào đúng 250ms cuối cùng.
URL, DNS và rendering pipeline không phải ba chủ đề rời rạc. Chúng là ba nghi phạm trong đúng 3,2 giây ấy.
Mỗi lần bạn gõ một địa chỉ vào trình duyệt, bạn đang viết một "bức thư" với đầy đủ thông tin để Internet biết phải giao nó đến đâu. Bấm Next để bóc tách từng phần:
DNS là "danh bạ điện thoại của Internet": con người nhớ sydexa.com, máy tính cần 93.184.216.34, DNS dịch từ tên sang số. Đây là mảnh đầu tiên trong 3,2 giây màn hình trắng ở đầu bài.
Khi bạn chạy npm start và mở localhost:3000, toàn bộ quy trình DNS → TCP → HTTP → Render vẫn diễn ra, chỉ là server nằm ngay trên máy bạn.
Ở cuối chặng Root → TLD → Authoritative, khi resolver cuối cùng cũng hỏi được đúng server nắm giữ sydexa.com, thứ nó nhận về không phải một dãy số trần trụi, mà là một bản ghi DNS: tên domain, loại bản ghi (A cho IPv4), giá trị 93.184.216.34 và kèm theo đó là một con số thời gian.
Con số ấy tồn tại vì không ai muốn tra cứu lại từ đầu mỗi lần bạn bấm vào một link. Resolver phải nhớ câu trả lời. Nhưng nhớ thì phải biết là trong nhớ bao lâu, bởi IP của một domain hoàn toàn có thể đổi. Và người duy nhất biết nó sắp đổi hay không chính là chủ domain, chứ không phải resolver ở đầu bên kia thế giới. Nên chủ domain gửi kèm luôn thời hạn đó trong bản ghi: TTL (Time To Live), số giây mà câu trả lời này được phép nằm trong cache trước khi phải hỏi lại. TTL 3600 là một lời hứa: “IP này còn đúng ít nhất một tiếng nữa, cứ dùng đi”.
Lời hứa đó cũng là thứ trói tay bạn vào ngày đổi IP. Bạn sửa bản ghi trên nhà cung cấp DNS, nhưng Internet không đổi theo cùng một lúc: mỗi resolver ôm một bản sao với đồng hồ riêng, đếm từ lúc chính nó cache chứ không phải từ lúc bạn bấm nút. Nơi nào chưa hết giờ vẫn thản nhiên trả IP cũ, nơi nào hết mới đi hỏi lại và thấy IP mới. Cái đuôi kéo dài phía sau ấy dài đúng bằng TTL bạn đã đặt từ trước đó, không phải TTL bạn đặt hôm nay.
Đó là lý do một con số tưởng như chỉ đội vận hành hạ tầng mới đụng tới lại thành chuyện của bạn. Ngày chuyển hosting, ngày đổi sang CDN mới, hay lúc phải trỏ domain sang server dự phòng giữa một sự cố đang cháy, TTL quyết định bạn chờ một phút hay chờ trọn một ngày. Và trong quãng chờ đó, một phần người dùng vẫn gõ đúng domain nhưng bị dẫn về server cũ - nếu server đó đã tắt, với họ trang web của bạn đơn giản là chết.
Bên dưới là 12 resolver quen mặt: browser, router nhà, ISP, 8.8.8.8... Chọn một mức TTL rồi bấm Đổi IP và nhìn chúng lần lượt đổi màu. Với 60 giây, cả mạng bắt kịp gần như tức thì. Với 24 giờ, bạn sẽ thấy tận mắt vì sao quy trình đúng luôn là hạ TTL trước vài ngày, rồi mới đổi IP.
Hạ TTL sau khi đã đổi IP thì không cứu được gì. Resolver nào đã lấy bản ghi trước đó vẫn giữ nó theo TTL cũ. Quy trình đúng: hạ TTL xuống thấp trước vài ngày, đợi TTL cũ hết hạn trên toàn mạng, rồi mới đổi IP.
Mỗi domain mới mà trang bạn chạm tới (CDN, API, font, analytics) là thêm một lượt DNS lookup, thường tốn 20-120ms. Hai thẻ dưới đây là toàn bộ quyền lực bạn có ở chặng này.
<!-- Chỉ thực hiện DNS lookup trước -->
<link rel="dns-prefetch" href="https://cdn.example.com" />
<!-- DNS lookup + TCP handshake + TLS negotiation -->
<link rel="preconnect" href="https://api.example.com" />| Kỹ thuật | Thực hiện | Khi nào dùng |
|---|---|---|
| dns-prefetch | Chỉ resolve DNS | Domain sẽ dùng sau (không urgent), hoặc khi có nhiều domain third-party |
| preconnect | DNS + TCP + TLS | Domain chắc chắn dùng ngay (API chính, font CDN). Giới hạn 2-3 domain để tránh lãng phí connection. |
Nhìn lại animation đầu bài: preconnectăn thẳng vào chặng “DNS + TCP + TLS”. Đó là toàn bộ những gì bạn giành lại được ở đây, và nó nhỏ hơn nhiều so với ba chặng còn lại.
Bundle đã về tới máy. Nhưng giữa “file HTML nằm trong bộ nhớ trình duyệt” và “người dùng nhìn thấy chữ đầu tiên” vẫn còn cả một dây chuyền.
Chỗ nhiều người nhầm nhất nằm ở bước ghép DOM + CSSOM. DOM có đủ mọi node bạn viết ra, nhưng Render Tree thì lọc bớt. Bấm vào từng phần tử bên dưới để đổi CSS của nó và tự xem node nào rớt lại.
Dây chuyền 6 giai đoạn trông rất thông thoáng, cho tới khi trình duyệt gặp thẻ <script>. Vì script có quyền chèn thêm HTML vào giữa chừng, parser không dám đi tiếp khi chưa biết script định làm gì.
Bốn cách nạp script dưới đây tạo ra bốn thời điểm First Paint hoàn toàn khác nhau. Bấm từng cái và so vạch xanh:
Script phụ thuộc nhau thì defer (giữ đúng thứ tự khai báo). Script độc lập kiểu analytics thì async. Các build tool hiện đại (Vite, Next.js) đã tự làm chuyện này cho bạn.
Tải xong file JS mới là bước một. Engine còn phải đọc từng ký tự, cắt thành token, dựng thành AST (cây cú pháp trừu tượng), rồi mới đưa cho JIT biên dịch ra mã máy.
Bấm Next để tự dựng cây cho một dòng code duy nhất:
Toàn bộ chuỗi parse → AST → JIT chạy trên main thread. Nghĩa là bundle 300KB không chỉ tốn thêm thời gian tải, nó còn tốn thêm thời gian parse và compile. Trên điện thoại tầm trung, phần sau có thể còn lâu hơn phần trước - và bạn không thấy nó ở tab Network, mà ở tab Performance dưới tên Scripting.
Code JS chạy xong thường để lại hai loại hậu quả: Reflow khi nó đổi kích thước hoặc vị trí (buộc browser chạy lại Layout), và Repaint khi nó chỉ đổi màu sắc (chạy lại Paint). Phần tiếp theo sẽ đo xem mỗi loại đắt đến đâu.
Đây chính xác là lý do React tồn tại. Thay vì thao tác trực tiếp trên DOM và gây Reflow/Repaint liên tục, React dùng Virtual DOM để tính sự khác biệt trước, rồi chỉ cập nhật đúng phần thay đổi.
Từ URL → DNS → Rendering Pipeline → JavaScript blocking, tất cả đều xoay quanh một câu hỏi: trình duyệt biến code thành pixel như thế nào? Nhưng có một thành phần quan trọng ta mới chỉ chạm bề mặt đó là JavaScript. Nó chặn rendering, nó thay đổi DOM, nó điều khiển mọi tương tác. React được xây hoàn toàn bằng JavaScript, để hiểu React thật sự, thì trước tiên phải hiểu sâu những khái niệm JavaScript nền tảng mà React dựa vào.
Critical Rendering Path (CRP)là chuỗi bước tối thiểu để biến HTML/CSS/JS thành pixel đầu tiên. Câu hỏi thực chiến không phải “browser làm những bước nào”, mà bước nào bắt buộc phải xong trước khi được vẽ frame đầu.
<!-- Ưu tiên CSS quan trọng cho màn đầu -->
<link rel="preload" href="/styles/critical.css" as="style" />
<link rel="stylesheet" href="/styles/critical.css" />
<!-- Script chính: không chặn parser, giữ đúng thứ tự -->
<script defer src="/assets/app.js"></script>
<!-- Script độc lập, không ai phụ thuộc thứ tự -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>Bundle JavaScript của React app thường lớn hơn trang static. Code splitting, lazy loading và tối ưu critical CSS là ba đòn bẩy lớn nhất, đặc biệt trên mạng 3G/4G yếu.
Sau khi có DOM/CSSOM, browser đi qua 4 chặng: Style → Layout → Paint → Composite. Không phải thay đổi nào cũng phải đi hết cả 4. Bấm từng property để xem:
Bấm hết 8 property trong lab, bạn sẽ thấy chúng tự chia thành đúng ba nhóm. Ranh giới giữa các nhóm chính là thứ quyết định animation của bạn mượt hay giật:
Thứ bạn mang về sau lab không phải là bảng thuộc lòng, mà là một phản xạ khi đọc code: mỗi lần sắp animate hoặc cập nhật style liên tục, bạn tự hỏi “property này rơi vào nhóm nào?”. Nếu nó kéo theo Layout, hãy tìm cách viết lại bằng transform/opacity trước khi nghĩ tới bất kỳ thủ thuật tối ưu nào khác.
Bạn có đúng khoảng 16ms cho mỗi frame nếu muốn 60fps. Ít chặng hơn nghĩa là dễ kịp hơn. Khi animate, mặc định chọn transform và opacity, chỉ dùng cái khác khi thật sự không còn cách nào.
React có pipeline riêng: Render phase (reconciliation) rồi Commit phase (ghi xuống DOM). Browser mới chạy pipeline của nó sau đó. Nghĩa là component re-render ít mà mỗi commit lại đổi width của hàng trăm node thì vẫn lag như thường.
Đến đây bạn đã biết trình duyệt có một main thread, và mọi thứ đều xếp hàng trên đó. Nhưng biết và thấy tận mắt là hai chuyện khác nhau. Đoạn code dưới là một component React thật, chạy ngay trong trang này. Bạn sửa được, chạy lại được.
import React, { useState, useEffect } from 'react';
function App() {
const [count, setCount] = useState(0);
const [log, setLog] = useState('Chưa chạy tác vụ nặng');
// Đồng hồ này nhích 20 lần mỗi giây. Nó là "nhịp tim" của main thread.
useEffect(() => {
const timer = setInterval(() => setCount((c) => c + 1), 50);
return () => clearInterval(timer);
}, []);
// Vòng lặp bận: chiếm main thread đúng 2 giây, không nhả cho ai cả.
const blockMainThread = () => {
const start = performance.now();
while (performance.now() - start < 2000) {
// cố tình không làm gì, chỉ giữ chỗ trên main thread
}
const spent = Math.round(performance.now() - start);
console.log('Main thread bị chiếm', spent, 'ms');
setLog('Vừa chiếm main thread ' + spent + 'ms');
};
return (
<div style={{ fontFamily: 'monospace', padding: 20 }}>
<p style={{ fontSize: 34, margin: '0 0 4px' }}>{count}</p>
<p style={{ color: '#6b7280', margin: '0 0 16px' }}>
Số này phải nhích đều 20 lần / giây
</p>
<button
onClick={blockMainThread}
style={{ padding: '10px 18px', fontSize: 15, cursor: 'pointer' }}
>
Chạy tác vụ nặng 2 giây
</button>
<input
placeholder="Thử gõ vào đây khi đang chạy"
style={{ padding: '10px 10px', fontSize: 15, marginLeft: 8, width: 240 }}
/>
<p style={{ marginTop: 16, color: '#4b5563' }}>{log}</p>
</div>
);
}
Bấm Run, chờ số đếm chạy vài giây. Sau đó bấm Chạy tác vụ nặng 2 giây rồi lập tức gõ vào ô input. Số đếm đóng băng, chữ bạn gõ không hiện ra, con trỏ chuột cũng không đổi hình. Hai giây sau, mọi thứ sống lại cùng lúc.
Vòng lặp while ở trên trông rất giả tạo. Nhưng bạn tạo ra đúng hiệu ứng đó mỗi khi JSON.parse một response 10MB, sort một mảng 100.000 phần tử trong render, hay format ngày tháng cho từng ô của bảng 5.000 dòng.
Đây cũng là mảnh cuối của 3,2 giây ở đầu bài: giai đoạn Chạy JS + Hydrate chính là một tác vụ nặng chiếm main thread. Bundle càng to, khoảng đóng băng càng dài.
Lối ra không nằm ở React mà ở cách bạn chia việc: cắt tác vụ dài thành nhiều mẩu nhỏ dưới 50ms để main thread có lúc rảnh tay, hoặc đẩy hẳn việc tính toán nặng sang Web Worker.
Đổi 2000 thành 200 rồi chạy lại. Bạn sẽ thấy giao diện gần như không giật. Đây chính là ngưỡng long task: trình duyệt coi mọi tác vụ trên 50ms là dài, và trên khoảng 200ms thì người dùng bắt đầu cảm nhận được.
Layout thrashing xảy ra khi code JS xen kẽ đọc layout và ghi layouttrong cùng một vòng lặp. Mỗi lần ghi làm layout “bẩn”, và phép đọc ngay sau đó buộc browser phải tính lại layout đồng bộ để trả về số đúng.
Cùng một công việc trên 500 phần tử, hai cách dưới đây khác nhau đúng ở chỗ thứ tự đọc và ghi. Con số bạn sắp thấy là con số trên máy bạn, ngay lúc này.
Khi bấm nút trong demo, code chạy cùng một việc: lấy chiều rộng hiện tại của 500 phần tử rồi tăng mỗi phần tử thêm 1px. Điểm khác nhau chỉ nằm ở thứ tự đọc và ghi layout.
for (const item of items) {
const width = item.offsetWidth; // ĐỌC layout
item.style.width = width + 1 + 'px'; // GHI ngay
}Sau mỗi lần ghi style.width, layout bị đánh dấu là bẩn. Vòng lặp kế tiếp lại đọc offsetWidth, nên browser phải tính layout lại ngay để trả về con số đúng.
const widths = items.map((item) => item.offsetWidth);
items.forEach((item, i) => {
item.style.width = widths[i] + 1 + 'px';
});Toàn bộ phép đọc chạy khi layout còn sạch, rồi toàn bộ phép ghi chạy sau. Browser có thể gom phần tính toán layout lại, thay vì bị ép tính lại ở từng vòng lặp.
Vì vậy bảng kết quả không phải đang đo hai thuật toán khác nhau, mà đang đo cùng một công việc DOM với hai thứ tự thao tác khác nhau.
import React, { useRef, useState } from 'react';
const COUNT = 500;
function App() {
const boxRef = useRef(null);
const [result, setResult] = useState(null);
// Dựng lại 500 phần tử để hai lần đo có cùng điểm xuất phát.
const build = () => {
const root = boxRef.current;
root.innerHTML = '';
for (let i = 0; i < COUNT; i++) {
const item = document.createElement('div');
item.style.height = '3px';
item.style.marginBottom = '1px';
item.style.width = 40 + (i % 60) + 'px';
item.style.background = i % 2 === 0 ? '#8b5cf6' : '#6366f1';
root.appendChild(item);
}
return Array.from(root.children);
};
// CÁCH 1: đọc rồi ghi, đọc rồi ghi... mỗi vòng lặp ép browser tính lại layout.
const runInterleaved = () => {
const items = build();
void document.body.offsetHeight; // dọn layout cũ trước khi bấm giờ
const t0 = performance.now();
for (const item of items) {
const width = item.offsetWidth; // ĐỌC layout
item.style.width = width + 1 + 'px'; // GHI ngay -> layout bẩn
}
return performance.now() - t0;
};
// CÁCH 2: gom toàn bộ phép ĐỌC, rồi mới gom toàn bộ phép GHI.
const runBatched = () => {
const items = build();
void document.body.offsetHeight;
const t0 = performance.now();
const widths = items.map((item) => item.offsetWidth); // ĐỌC hết
items.forEach((item, i) => {
item.style.width = widths[i] + 1 + 'px'; // GHI hết
});
return performance.now() - t0;
};
const run = () => {
const interleaved = runInterleaved();
const batched = runBatched();
console.log('Đọc-ghi xen kẽ:', interleaved.toFixed(2), 'ms');
console.log('Gom đọc rồi gom ghi:', batched.toFixed(2), 'ms');
console.log('Nhanh hơn:', (interleaved / batched).toFixed(1) + 'x');
setResult({ interleaved, batched });
};
return (
<div style={{ fontFamily: 'monospace', padding: 20 }}>
<button
onClick={run}
style={{ padding: '10px 18px', fontSize: 15, cursor: 'pointer' }}
>
Đo 500 phần tử theo 2 cách
</button>
{result && (
<table style={{ marginTop: 16, borderCollapse: 'collapse' }}>
<tbody>
<tr>
<td style={{ padding: '4px 12px 4px 0' }}>Đọc-ghi xen kẽ</td>
<td style={{ color: '#dc2626', fontWeight: 700 }}>
{result.interleaved.toFixed(2)} ms
</td>
</tr>
<tr>
<td style={{ padding: '4px 12px 4px 0' }}>Gom đọc rồi gom ghi</td>
<td style={{ color: '#16a34a', fontWeight: 700 }}>
{result.batched.toFixed(2)} ms
</td>
</tr>
<tr>
<td style={{ padding: '4px 12px 4px 0' }}>Chênh lệch</td>
<td style={{ fontWeight: 700 }}>
{(result.interleaved / result.batched).toFixed(1)}x
</td>
</tr>
</tbody>
</table>
)}
<div ref={boxRef} style={{ marginTop: 16 }} />
</div>
);
}
Tuỳ máy, tuỳ trình duyệt, tuỳ lúc chạy, tỉ lệ có thể từ vài lần đến vài chục lần. Bấm chạy lại 2 đến 3 lần và lấy con số ổn định nhất. Điều cần nhớ không phải là con số cụ thể, mà là: cùng một lượng công việc, chỉ đổi thứ tự đọc-ghi mà chênh nhau nhiều lần.
Trong React, kiểu code này hay xuất hiện ở useLayoutEffect khi bạn đo kích thước rồi set style cho một danh sách, ở các thư viện tooltip tự tính vị trí, và ở mọi vòng lặp gọi getBoundingClientRect() bên trong forEach có kèm ghi style.
Quy tắc rút ra chỉ có một câu: đọc hết rồi hãy ghi, đừng đọc-ghi xen kẽ. Nếu bắt buộc phải xen kẽ, hãy đẩy phần ghi vào requestAnimationFrame để browser gộp chúng lại thành một lần layout.
10 câu dưới đây không hỏi bạn nhớ được con số nào, mà hỏi bạn có dùng được những gì vừa học để suy luận ra câu trả lời hay không. Làm hết một lượt rồi đọc kỹ phần giải thích của những câu sai.
Từ hai app React chênh nhau 3,2 giây → URL → DNS và TTL → HTML thành pixel → JavaScript cắt ngang dây chuyền → và cuối cùng bạn tự đo trên máy mình.
Kết luận gọn trong một câu: phần lớn thời gian người dùng chờ đợi nằm ở trước khi React chạy dòng đầu tiên. Đó là lý do bài mở đầu của một khoá React lại nói về trình duyệt.
Vì sao memo hoá không cứu được màn hình trắng 4 giây?
Vì memo hoá chỉ giảm chi phí re-render, tức phần SAU khi React đã khởi động. Màn hình trắng nằm ở phần trước: network, tải bundle, parse và compile JavaScript.
Bạn vừa nhìn thấy vấn đề. Ba bài dưới đây là ba cách xử lý nó, theo ba tầng khác nhau: đo lường, tối ưu, và hiểu bên trong React.
Còn ngay bài kế tiếp, chúng ta xuống một tầng nữa: những pattern JavaScript mà React dùng hàng ngày. Destructuring, spread và immutability, closure, event loop. Đó là thứ quyết định code React của bạn đúng hay sai, trong khi bài này quyết định nó nhanh hay chậm.