Mình đã optimize một side project nhỏ có React Flow như thế nào (và một chút về event loop) - phần 1
Mình vừa làm một side project nhỏ để giúp dễ dàng xem cấu trúc của các API trong một file OpenAPI schema trông như thế nào. Nó vẽ sơ đồ (graph) các object của request và response của API liên kết với nhau như thế nào như hình ở dưới đây.

Bạn có thể nghịch thử nó ở đây.
Mình làm web app này để hỗ trợ cho công việc của mình, và trong quá trình đó mình gặp một vài vấn đề thú vị liên quan đến performance và optimization.
Trong quá trình tìm cách optimize này mình đi từ những việc cơ bản nhất mà mọi React developer đều nghĩ ra từ memo, cho tới việc hiểu tại sao mặc định thì một tab trình duyệt chỉ chạy trên một thread lại quan trọng, cho tới những thứ tưởng chừng không liên quan như cách mà trình duyệt render một frame như thế nào, cuối cùng là tới React transition và useDeferredValue. Nếu bạn muốn đi cùng mình cả quá trình, hãy đọc tiếp bài viết này.

Mình muốn chia sẻ với các bạn 3 cách mà mình dùng để giải quyết các vấn đề này, và bonus thêm một vấn đề mình vẫn chưa giải quyết được:
-
Memoize React Flow node: giúp tránh việc các React Flow node re-render đi re-render lại, đặc biệt là khi bạn có những node phức tạp.
-
Nhường cho luồng chính (yield to the main thread): lúc đầu mình thử dùng worker thread, nhưng cuối cùng thì... nó chạy còn chậm hơn. Thay vào đó, mình nhường cho luồng chính (yield to the main thread) để tránh làm đơ giao diện người dùng.
-
Dùng
useDeferredValue: hook này giúp mình tạm thời hoãn render những phần không quá cấp thiết lại. -
Vấn đề mình chưa nghĩ ra cách giải quyết: hoãn render một component đã được hoãn (deferred) trước đó: mình vẫn chưa tìm ra cách để React không render tất cả những component đã được deferred còn lại cùng một lúc.
Vấn đề mình gặp phải
Khi dùng trên cái máy tính có cấu hình thấp của mình, mình thấy CSS animation trong app của mình không phải lúc nào cũng mượt mà. Có lúc CSS animation của các item ở thanh sidebar bên trái chuyển động khá mượt, nhưng cũng có lúc nó lại giật giật nhảy luôn mà không có chút animation nào cả. Có những lúc FPS còn tụt, làm cảm giác khá giật giật.
Bạn để ý cái item mình click vào ở thanh sidebar bên trái...
Không biết bạn có thấy được không nhưng các item ở thanh sidebar bên trái cảm giác rất giật lag. Khi mình bấm vào, nó đơ khoảng nửa giây trước khi background chuyển sang màu xanh. Cái ripple effect của button của mình thì giật giật và có khi còn không chạy luôn!
Đúng ra thì nó nên chạy mượt mà như thế này:
Animation chạy mượt mà hơn rất nhiều
Khi mình bấm vào một item, nó chạy gần như ngay lập tức - CSS animation cảm giác mượt hơn rất nhiều, và lần này ripple effect của button chạy ngon lành.
Tại sao lúc đầu ứng dụng của mình lại giật lag như thế?
Memo các React Flow node
Đầu tiên mình chạy react-scan lên và thấy component PropertyItem bị re-render đi re-render lại khoảng gấp 3 lần so với mức cần thiết.
Bằng cách thần kỳ nào đó component PropertyItem re-render khoảng 380 lần!
Component PropertyItem là component mà hiển thị cho từng thuộc tính của một object như thế này.

Mặc dù API này khá phức tạp và có nhiều object, nhưng chắc chắn nó không thể nào có tới tổng cộng 380 thuộc tính được!

Hóa ra component PropertyItem bị re-render nhiều gấp khoảng 3 lần so với cần thiết là vì component cha của nó, ComponentViewer, bị re-render 3 lần.

Đúng ra thì mình nên memo component ComponentViewer dùng cho React Flow để hiển thị các node này. Ở documentation của React Flow có ghi rõ là bạn nên memoize các component React Flow node để tránh bị render không cần thiết, và lúc đó mình... không đọc cái này 😅!

Mình memo component này và framerate ngay lập tức được cải thiện, CSS animation cũng cảm giác mượt hơn hẳn.
Quy tắc đầu tiên của optimization: nếu có thể, hãy tìm cách để phải làm ít việc hơn.

Bây giờ react-scan không còn kêu ca về component bị re-render quá nhiều nữa - nhưng nó lại phàn nàn về Javascript/React hooks chạy bị mất quá nhiều thời gian.

Nó phàn nàn như thế này nghĩa là gì?
Mình để ý là khi chuyển giữa những API đơn giản, CSS animation chạy khá mượt. Nhưng khi phải render các graph phức tạp, CSS animation vẫn bị giật và frame rate vẫn bị giảm xuống.
Vậy lời phàn nàn "Javascript/React hooks execution time took too long" nghĩa là code React của mình không có vấn đề - các component của mình không bị re-render quá nhiều - mà vấn đề nằm ở quá trình tính toán để render một cái graph phức tạp của mình tốn quá nhiều thời gian.
Đây là một ví dụ cho thấy một cái graph của mình có thể lớn đến mức nào
Lần này mình không thể optimize bằng cách cắt những việc phải làm được nữa. Để render một sơ đồ (graph) phức tạp thế này có những phần tính toán phức tạp mất thời gian cần phải làm.
Nhưng tại sao việc phải tính toán mất nhiều thời gian để render cái graph này lại ảnh hưởng đến CSS animation ở một item hoàn toàn không liên quan ở thanh sidebar bên trái như thế?

Nhường cho luồng chính (yield to the main thread)
Hóa ra CSS animation chạy chung một luồng (thread) với code Javascript của mình. Điều đó có nghĩa là trình duyệt chỉ có thể làm một việc một lúc — hoặc là chạy hiệu ứng CSS animation hoặc là chạy code Javascript của mình.
Vậy khi JavaScript của mình chiếm luồng chính (main thread) quá lâu (ví dụ như khi render một graph lớn với cấu trúc phức tạp), trình duyệt sẽ không có thời gian để chạy CSS animation nữa.
Javascript chạy quá lâu khiến CSS animation cảm giác bị giật.
Lần này, mình không thể tìm ra cách nào để rút ngắn thời gian thực thi Javascript được nữa. Để render một sơ đồ (graph) cho một API phức tạp, mình không thể tránh được việc phải thực hiện các bước tính toán phức tạp.
Nhưng mặc dù mình không thể tìm cách giảm bớt việc phải làm, mình có thể bảo trình duyệt tạm dừng code JavaScript của mình giữa chừng, chạy một chút CSS animation, rồi mới quay lại chạy tiếp.
Làm như này sẽ giúp CSS animation cảm giác mượt mà hơn nhiều.
Có nhiều khác nhau để chia nhỏ các tác vụ nặng trong Javascript, và mình dùng cách yieldToMainThread. Mình dùng nó trong các vòng lặp for nặng và cả trong những hàm đệ quy như thế này:
for (const node of nodes) {
...
await yieldToMainThread();
}
const addNodeAndEdge = async (component: ComponentNode | PathNode) => {
await yieldToMainThread();
...
addNodeAndEdge(children)
...
}
Mỗi lần chạy qua vòng lặp for hoặc hàm đệ quy này, trình duyệt có thể tạm dừng việc thực thi JavaScript một chút để chạy CSS animation, rồi sau đó quay lại chạy Javascript tiếp.

Sau khi thêm một đống await yieldToMainThread() vào xong thì CSS animation của mình chạy mượt mà cảm giác như thể trình duyệt không phải chạy một đống tính toán phức tạp để render graph kia nữa.

Nếu bạn không thể làm ít hơn, hãy thử sắp xếp công việc thông minh hơn.
Bonus: một chút về event loop
Event loop là gì và tại sao mình lại nên quan tâm đến nó? Thay vì đi sâu vào kỹ thuật cách nó hoạt động thế nào, mình sẽ giải thích nó sinh ra để giải quyết vấn đề gì.
Như bạn biết thì mặc định mỗi tab chỉ có một thread của nó - nghĩa là nó chỉ có thể làm một việc một lúc.
Bây giờ hãy lấy ví dụ bạn tạo một request gửi đến server chẳng hạn. Request này có thể mất 2 giây và trong phần lớn 2 giây đó tab này không cần làm gì cả - nó chỉ đợi server phản hồi về thôi.

Vì mặc định tab của bạn chỉ có thể làm một việc một lúc, nếu không có event loop thì trong 2 giây này trang web của bạn sẽ đứng im - bạn không thể click chuột, scroll hay kéo gì được nữa cả. Gửi, đợi và nhận request tính là một việc.

Điều đáng nói ở đây là mặc dù trong phần lớn thời gian 2 giây này tab trình duyệt chỉ ngồi chơi đợi phản hồi từ server về - nó nhất quyết không xử lý các event của bạn trong thời gian đó.

Event loop được sinh ra để giải quyết vấn đề này - trong thời gian tab của bạn ngồi chơi, nó sẽ được sắp xếp để làm các việc khác.
Như vậy trong ví dụ này, sau khi gửi request đến server, tab của bạn sẽ chạy một chút CSS animation, xử lý event khi bạn click, cho phép bạn scroll, bôi đen,... chẳng hạn. Mặc dù thực tế nó chỉ làm được một việc một lúc, bạn sẽ thấy trang web của bạn rất mượt mà vì nó được khéo léo sắp xếp sao cho tab của bạn không ngồi chơi trong lúc đợi các task dài như gửi request đến server,... cả.

Promise và async await được thiết kế ra cho mục đích này: thông báo cho trình duyệt biết có thể tạm dừng task này để thực hiện những task khác.
Ví dụ như đoạn code này:
await fetch("https://...");
...nghĩa là sau khi gửi request đến server và trong lúc đợi phản hồi, tab của bạn có thể làm việc khác.

Event loop ở trong NodeJS cũng như vậy. Code Javascript mà bạn code chỉ chạy trên một thread, nhưng điều này không có nghĩa là server chỉ xử lý được 1 request một lúc. Trong lúc xử lý một request gọi đến database và đang đợi kết quả trả về chẳng hạn, NodeJS sẽ xử lý các request khác.
Nếu bạn muốn hiểu hơn về cách event loop hoạt động thế nào, mình có video này giải thích rất dễ hiểu và chi tiết.
Người ta hay nói Javascript với NodeJS là single thread (vì đã có event loop để đảm bảo thread của bạn không bao giờ ngồi chơi), nhưng nếu bạn thực sự cần làm 2 việc một lúc, cả trình duyệt và NodeJS đều hỗ trợ cho phép bạn tạo thread mới (hay gọi là worker thread).
Thế còn worker thread thì sao?
Nếu phần tính toán để render graph của mình nặng và mất thời gian thế, mình có nên đưa nó sang một thread khác không? Như vậy, CSS animation vẫn có thể chạy mượt mà trên luồng chính (main thread), còn worker thread sẽ chạy phần tính toán nặng nề đó ở background.

Mình thử làm vậy, và bất ngờ là... kết quả còn tệ hơn. Nhưng tại sao lại thế?
Đó là vì mình không thể chuyển toàn bộ quá trình tính toán sang worker thread được - mình chỉ có thể tách ra một phần nhỏ mà thôi. Giải thích chi tiết hơn là thế này.
Để vẽ một sơ đồ (graph) của một API, mình cần làm 4 bước:
- Dựa trên schema của API, tạo các node và edge trong React Flow
- Render các node đó để đo chiều rộng và chiều cao
- Dùng DagreJS để tính toán vị trí của từng node dựa trên kích thước vừa đo
- Render lại các node ở đúng vị trí vừa tính toán được.

Vì mình cần render tất cả các node trước để đo chiều rộng và chiều cao, mình không thể chuyển toàn bộ quá trình tính toán sang worker thread được. Việc render các node này bắt buộc phải chạy trên luồng chính.
Vì thế mình chỉ có thể chuyển bước tính toán vị trí của các node (bước 3) sang worker thread được thôi.
Trớ trêu thay, hầu hết các bước tính toán nặng nề mất thời gian vẫn phải thực hiện ở thread chính.
Nhưng việc tạo một thread mới, gửi và nhận thông tin giữa các thread này có overhead. Để gửi thông tin sang thread khác, bạn cần phải serialize (chuyển từ Javascript object sang string) và khi nhận thông tin, bạn lại phải deserialize (chuyển từ string về Javascript object) nó.
Với chỉ 20-30 node, việc tính toán vị trí của các node nhanh đến mức trên thực tế thì làm nó trực tiếp trên luồng chính còn tốt hơn hơn là phải mất thời gian và tính toán cho việc serialize và deserialize để gửi thông tin giữa các thread.

Nhưng đây không chỉ là lý do duy nhất mà mình không sử dụng worker thread được.
The glorious flashing second
Chịu mình không thể nghĩ ra nên chuyển cái title "The glorious flashing second" này về tiếng Việt thế nào, kể cả sau khi đã dùng ChatGPT!
Nếu mình sử dụng worker thread, bạn sẽ thấy khoảnh khắc nháy lên một phát khi các node bị xếp chồng lên nhau như thế này:

Thực ra là mình còn không thể chuyển bước tính toán vị trí của node thành bất đồng bộ (asynchronous) được (nếu không, bạn sẽ thấy khoảnh khắc nháy lên đó) - chứ đừng nói đến chuyện là chuyển nó sang một thread khác!
Trước khi giải thích tại sao, mình cần phải biết cách trình duyệt render một frame như thế nào.
Để render một frame, trình duyệt phải làm 4 bước:
- Style – Áp dụng CSS vào các HTML element.
- Layout – Tính toán kích thước và vị trí của từng phần tử.
- Paint – Thực sự biến nó thành các pixel hiển thị trên màn hình.
- Compositing – (nếu cần) Sắp xếp lại thứ tự các lớp (layer) cho đúng, khi có animation, z-index và transform chẳng hạn.
Bạn có thể xem documentation giải thích về quá trình này ở đây
Ở đoạn trên mình nói React Flow render tất cả các node để tính toán chiều rộng và chiều cao của từng node - nhưng thật ra thì mình đã... đơn giản hóa đi một chút 😅. Thực tế thì React Flow không render toàn bộ các node lên màn hình chỉ để đo kích thước như vậy.
Nếu trình duyệt hoàn thành bước paint và compositing trước khi mình kịp tính toán xong vị trí đúng của từng node, người dùng sẽ thấy nháy một phát tất cả các node bị đặt chồng lên nhau.

Thay vào đó, React Flow sẽ để cho trình duyệt chạy tới bước layout - tức là đến giai đoạn tính toán kích thước của từng node. Rồi ngay trước bước paint và compositing, React Flow sẽ chen vào và gửi cho mình chiều rộng và chiều cao của từng node.

Lúc này, mình sẽ tính toán vị trí chính xác cho từng node.

Sau khi tính toán xong rồi, mình mới bảo trình duyệt render với vị trí chính xác của từng node. Như vậy, người dùng sẽ không còn thấy cái nháy một phát lúc các node bị sai vị trí chồng lên nhau nữa.


Khi mình chuyển bước tính toán vị trí của từng node sang bất đồng bộ (asynchronous) hoặc đẩy sang một thread khác, thì lúc này bỗng nhiên main thread lại "rảnh". Lúc đó, trình duyệt sẽ tận dụng khoảng thời gian “rảnh” này để render toàn bộ các node (mà lúc này vị trí đang sai). Vì thế bạn sẽ thấy cái khoảnh khắc nháy một phát xuất hiện.

Đôi khi bạn nghĩ ra một “cách tối ưu” mà kết quả lại còn tệ hơn cả ban đầu.
Bonus: nếu bạn muốn bảo trình duyệt dừng ngay trước khi bước paint này trong React, bạn có thể dùng useLayoutEffect. Mình kiếm được một bài viết rất hay về vấn đề này ở đây.
Kết luận
Mặc dù phần lớn thời gian là React developer thì có lẽ mình chỉ cần biết những thứ đặc trưng của React như useState, useEffect, useRef, Redux,... rồi thì nâng cao hơn nữa thì có useMemo, useCallback,... là đủ - nhưng để làm một web app cho tốt thì đôi khi mình cần phải biết về những thứ tưởng chừng rất không liên quan như thread, event loop, cách trình duyệt render một frame,...
Dù bài viết này là về một ứng dụng React nhưng mình hy vọng bạn thấy những thứ về trình duyệt và Vanilla Javascript thú vị.
Ở phần tiếp theo dựa trên những thứ cơ bản ở bài viết này mình sẽ quay trở lại React nói về useDeferredValue.
Credits
Nếu bạn thích con cá mà mình sử dụng, hãy xem: https://thenounproject.com/browse/collection-icon/stripe-emotions-106667/.

All rights reserved