SydexaSydexa

React Nâng Cao

Trước khi React chạyJavaScript cho React
TypeScript Cơ BảnType System & GenericUtility Types & tsconfig
ReactJS Căn Bản
Reconciliation & FiberFiber Deep Dive
React HooksOptimization & Utility
React Hook Form & ZodAdvanced Form Patterns
Web Vitals & DevToolsPerformance Playbooks
TanStack VirtualTanStack Virtual (P2)
TanStack QueryTanStack Query (P2)
ZustandZustand (P2)Fiber ↔ External Store
Bảo mật frontendBảo mật frontend (2)
Trang chủKhoá họcReact Nâng Cao
3 giây trước khi React chạy dòng đầu tiên
App React của bạn chậm không phải vì re-render. Phần lớn thời gian người dùng nhìn màn hình trắng, React còn chưa được parse. Đây là 3 giây đó: URL, DNS, rendering pipeline, và hai bài thực hành chạy React thật ngay trong trang.

Ba giây trước khi React chạy dòng đầu tiên

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?

Dự đoán trước khi xem

1.Hai app React cùng source code, một cái tương tác được ở 0,9s, một cái 4,1s. 3,2 giây chênh lệch đó rơi vào đâu?
1/1
1/1

Cùng một source code, khác nhau 3,2 giây

NGƯỜI DÙNG NHÌN THẤYTRÌNH DUYỆT ĐANG LÀM GÌ - CẢ HAI APP ĐỀU QUA ĐỦ 4 CHẶNGDNS + TCP + TLSTải bundle JSParse & CompileChạy JS + HydrateApp A - bundle 180KB, defer, code-splitmàn hình trắngBấm được300ms200ms150ms250ms0.9sApp B - bundle 1.8MB, script chặn parsermàn hình trắngBấm được700ms1750ms1400ms250ms4.1s0,65 giây trước khi React chạy dòng đầu tiên3,85 giây trước khi React chạy dòng đầu tiên - chênh đúng 3,2 giây0s1s2s3s4s

Hai app dưới đây dùng CHUNG một source code React và đi qua ĐÚNG 4 chặng giống nhau.

Vậy cái gì đã khác?

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ặngApp AApp BCái gì đã khác
DNS + TCP + TLS300ms700msHạ 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 JS200ms1750msBuild 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 & Compile150ms1400msSố 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 + Hydrate250ms250msKhô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.
Chặng duy nhất React thật sự chạy lại giống hệt nhau:

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.

Lộ trình bài học

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.

  • Chặng 1: Gói tin đi đâu - URL và hành trình DNS. Nghi phạm đầu tiên, cũng là nghi phạm bạn ít quyền kiểm soát nhất.
  • Chặng 2: HTML biến thành pixel - DOM, CSSOM, Render Tree, Layout, Paint. Và chỗ JavaScript cắt ngang dây chuyền này.
  • Chặng 3: Tự tay chứng minh - Hai bài thực hành chạy React thật ngay trong trang, đo bằng millisecond trên chính máy bạn.

URL (Uniform Resource Locator)

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:

URL Anatomy: bóc tách từng phần

https://api.sydexa.vn:80/users/search?name=Minh&role=admin#results

Schemehttps://Nằm trước dấu ://

Giao thức trình duyệt dùng để nói chuyện với server. HTTPS là HTTP có lớp mã hóa bảo mật.

0:00/0:00

Phân tích URL

1.Cho URL: https://api.sydexa.vn:8080/users/search?name=Minh&role=admin#results. Phần nào của URL KHÔNG được gửi lên server?
1/2
1/2

Nghi phạm số 1: DNS (Domain Name System)

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.

Dự đoán trước khi xem

1.Khi bạn truy cập một trang web lần đầu và DNS Cache trống, thứ tự truy vấn DNS đệ quy là gì?
1/1
1/1
0:00/0:00

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.

TTL: đổi IP xong, bao lâu thì cả Internet biết?

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

Đổi IP xong, bao lâu thì cả Internet biết?

TTL của bản ghi:
Đồng hồ mô phỏng: 0 giâyĐã thấy IP mới: 0/12 resolver

Browser cache

93.184.216.34

203.0.113.9

OS cache

93.184.216.34

203.0.113.9

Router nhà

93.184.216.34

203.0.113.9

ISP Viettel

93.184.216.34

203.0.113.9

ISP VNPT

93.184.216.34

203.0.113.9

ISP FPT

93.184.216.34

203.0.113.9

Google 8.8.8.8

93.184.216.34

203.0.113.9

Cloudflare 1.1.1.1

93.184.216.34

203.0.113.9

Quad9

93.184.216.34

203.0.113.9

Resolver văn phòng

93.184.216.34

203.0.113.9

Resolver trường học

93.184.216.34

203.0.113.9

Resolver di động

93.184.216.34

203.0.113.9

Đây là cái bẫy. Hạ TTL SAU khi đã đổi IP không cứu được gì: resolver nào đã lấy bản ghi cũ vẫn giữ nó theo TTL CŨ, tức gần trọn một ngày.

Cái bẫy khi chuyển hosting:

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.

Thứ bạn thực sự làm được với chặng này

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.

index.html
html
12345
<!-- 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ậtThực hiệnKhi nào dùng
dns-prefetchChỉ resolve DNSDomain sẽ dùng sau (không urgent), hoặc khi có nhiều domain third-party
preconnectDNS + TCP + TLSDomain 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.

DNS Quiz

1.Bạn có React app gọi API từ https://api.sydexa.com và fonts từ https://fonts.googleapis.com. Cách tối ưu DNS nào phù hợp nhất?
1/1
1/1

Nghi phạm số 2: từ code đến pixel

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.

Dự đoán trước khi xem

1.Trình duyệt đã parse xong toàn bộ HTML và dựng xong DOM tree. Lúc này người dùng đã nhìn thấy nội dung trang chưa?
1/1
1/1

Rendering Pipeline

HTMLCSSJSInput files1Parsing& ConstructionHTML → Tokens → DOM TreeCSS → Rules → CSSOM2Render TreeConstructionDOM + CSSOM = Render Tree<head>, display:none bị loại3Layout(Geometry)Tính position & sizeRelative to viewport4Paint(Rasterize)Fill pixels: color, borderChia thành layers5Composite(GPU)GPU xếp chồng layersTheo đúng z-order6Screen(Display)Frame buffer → MonitorPixel hiển thị!🖥️Pixels!

 

0:00/0:00

Render Tree không phải là DOM Tree

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.

Bấm vào phần tử để đổi CSS, xem node nào rớt khỏi Render Tree

DOM Tree

luôn đủ 4/4 node

<h1>
<nav>
<p>
<aside>

Render Tree

còn 3/4 node

<h1>
<nav>
<p>
<aside>

Trang vẽ ra

preview thật, áp đúng CSS bên trên

Tiêu đề trang
Menu điều hướng
Đoạn nội dung chính
Thanh bên

<nav> display: none - Bị loại HOÀN TOÀN khỏi Render Tree. Không tính layout, không chiếm chỗ.

JavaScript cắt ngang dây chuyền ở đâu?

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:

Thử 4 cách nạp script và xem First Paint đổi thế nào

<head> <script src="app.js"></script></head>0ms350ms700ms1050ms1400msHTML parsingTải app.js(network)Chạy JS(main thread)parseparser bị chặn 700msparse tiếpdownloadexecuteFirst Paint 1.32s

Parser đứng im 700ms giữa chừng. Người dùng nhìn màn hình trắng đến tận 1,32s.

0:00/0:00
Hãy nhớ:

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 rồi vẫn chưa chạy được

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:

Tự dựng AST cho: const total = price * quantity

const total = price * quantity;consttotal=price*quantity;idinitleftrightVariableDeclarationcâu lệnh khai báo biếnVariableDeclaratormột biến cụ thểIdentifiertotalBinaryExpressiontoán tử *IdentifierpriceIdentifierquantity

Với engine, dòng này mới chỉ là 30 ký tự. Nó chưa hiểu gì cả.

0:00/0:00
Vì sao bundle to lại đắt gấp đôi:

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.

JS chạy rồi thì kéo theo gì?

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.

Rendering Quiz

1.Phần tử nào sau đây KHÔNG nằm trong Render Tree?
1/2
1/2
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: cái gì chặn pixel đầu tiên?

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.

  • CSS là render-blocking - Không có CSSOM thì không ghép được Render Tree, không có Render Tree thì không Layout và không Paint. Browser buộc phải chờ.
  • Synchronous JavaScript là parser-blocking - Đúng chuyện bạn vừa thử ở phần trước: script không có defer/async làm parser dừng giữa chừng.
  • Web font và ảnh hero thì không, nhưng vẫn đau - Về kỹ thuật chúng không chặn render - browser vẫn parse và paint tiếp. Nhưng người dùng vẫn thấy trang trống hoặc layout nhảy ở giai đoạn đầu.
index.html
html
123456789
<!-- Ư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.

Property nào đắt, property nào rẻ?

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:

Đổi property này thì browser phải chạy lại bao nhiêu chặng?

Styletính lại CSS nào áp cho elementLayouttính lại vị trí & kích thướcPaintvẽ lại pixel của vùng đóCompositeghép layer thành frameChi phí mỗi frame4/4 chặng

Đổi kích thước làm hỏng bố cục, browser phải tính lại layout cho cả các node liên quan rồi vẽ lại. Đắt nhất trong 3 nhóm.

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:

  • Nhóm đắt nhất - đụng hình học, chạy đủ cả 4 chặng - width, height, left, margin. Đổi kích thước hoặc vị trí trong dòng chảy bố cục là buộc browser tính lại Layout, và layout có thể lan sang các phần tử xung quanh chứ không chỉ node bạn vừa sửa. Đây là lý do animate left luôn giật hơn transform.
  • Nhóm trung bình - bỏ qua được Layout nhưng vẫn phải Paint - background-color, box-shadow. Không đụng hình học nên browser cắt được chặng Layout, nhưng vẫn phải vẽ lại pixel của vùng đó. Vùng vẽ càng lớn càng tốn, và riêng box-shadow là một trong những hiệu ứng vẽ nặng nhất.
  • Nhóm rẻ nhất - chỉ cần Composite - transform, opacity. Trong đa số engine hiện đại, hai property này chỉ cần ghép lại layer đã vẽ sẵn, phần lớn do GPU lo. Ít chặng nhất nên dễ kịp ngân sách mỗi frame nhấ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.

Quy tắc rút ra:

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 render và browser render là hai pipeline khác nhau

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.

Rendering Performance Quiz

1.Bạn muốn animate panel trượt từ trái sang phải mượt nhất. Lựa chọn nào thường tối ưu hơn?
1/3
1/3

Thực hành 1: React không cứu bạn khỏi main thread

Đế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.

Dự đoán trước khi chạy

1.Component dưới đây có một số đếm nhích 20 lần mỗi giây bằng setInterval, và một nút chạy vòng lặp bận chiếm main thread 2 giây. Bấm nút xong, số đếm sẽ như thế nào?
1/1
1/1
Main thread bị chiếm: đồng hồ đứng, input chết
React
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
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>
  );
}
Preview
Nhấn Chạy để xem kết quả
⌘+Enter
Cách chạy để thấy rõ nhất:

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ì sao demo này quan trọng

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.

Thử tự sửa:

Đổ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.

Thực hành 2: đo layout thrashing bằng millisecond thật

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.

Dự đoán trước khi chạy

1.Cách 1 đọc offsetWidth rồi ghi style.width ngay cho từng phần tử. Cách 2 gom hết phép đọc trước, rồi mới gom hết phép ghi. Cùng 500 phần tử, cách 2 thường nhanh hơn cách 1 khoảng bao nhiêu?
1/1
1/1

Hai cách khác nhau ở đâu?

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.

Cách 1: đọc-ghi xen kẽ

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.

Cách 2: gom đọc rồi gom ghi

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.

Layout thrashing: 500 phần tử, 2 thứ tự đọc-ghi
React
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
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>
  );
}
Preview
Nhấn Chạy để xem kết quả
⌘+Enter
Con số của bạn nhỏ hơn dự đoán?:

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.

Từ demo này về lại code React của bạ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.

Kiểm tra kiến thức

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.

Kiểm tra kiến thức toàn bài

1.App React của bạn mất 4 giây mới bấm được. Bạn bọc mọi component bằng React.memo và thêm useMemo khắp nơi, thời gian gần như không đổi. Cách giải thích hợp lý nhất là gì?
1/10
1/10

Tổng kết

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?

Nhấp vào thẻ để lật

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.

Nhấp vào thẻ để lật lại
1/11

Bài này mở ra ba hướng đi tiếp

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.

  • Web Vitals & DevTools - Bài này bạn đoán 3,2 giây bằng cảm giác. Bài đó dạy bạn đo nó bằng số: LCP, CLS, INP, TTFB, và cách đọc tab Performance của Chrome để biết chính xác mỗi millisecond đi đâu.
  • Performance Playbooks - Có số rồi thì sửa thế nào. 5 playbook tối ưu theo từng chỉ số, và quan trọng không kém là khung quyết định khi nào KHÔNG nên tối ưu.
  • Reconciliation & Fiber - Phần sau khi React đã khởi động. Vì sao React chia nhỏ công việc render ra được để không đóng băng giao diện như vòng lặp while ở thực hành 1, và cái giá của cơ chế đó.
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.
Bài tiếp theo