Mình đã optimize một side project nhỏ có React Flow như thế nào - phần 2
Ở bài viết trước mình nói về mình đã dùng React memo , yieldToMainThread để optimize như thế nào, ở phần này mình sẽ tiếp tục với useDeferredValue.
Mình nghĩ là đã giải quyết xong vấn đề CSS animation ở các item ở sidebar bên trái bị lag khi click vào - nhưng rồi nó... lại quay trở lại.
Mình thêm một tính năng mới: hiển thị phần code trong file yaml (có syntax highlighting) ở panel bên cạnh sơ đồ như thế này.

Mới đầu trông mọi thứ có vẻ ổn… nhưng khi click thì CSS animation của item ở sidebar bên trái lại bị giật lag như cũ.
Mình không biết bạn thấy được không nhưng nó lại đơ mất 0.5s khi mình click
Tại sao lại như vậy?
Để render phần syntax highlighting, có 3 bước chính cần phải làm:
- Chuyển các object thành string ở định dạng YAML hoặc JSON
- Từ string đó chuyển thành string HTML với syntax highlighting
- Render string HTML này thành DOM element thực sự cho người dùng nhìn thấy

Quá trình này thực hiện cho 1 object đã khá tốn thời gian và CPU, nhưng mình còn phải áp dụng nó cho 5–6 object lớn (như request body, responses, v.v.).

Lần này, vì cả quá trình syntax highlighting và việc thay đổi item được chọn ở thanh sidebar bên trái đều bắt nguồn trực tiếp từ cùng một thay đổi trong state, nên hai cái này cạnh tranh với nhau.

Việc render thay đổi các item được chọn ở sidebar rất nhanh, nhưng bây giờ nó lại bị render chung với quá trình syntax highlighting rất chậm kia.
Các state update bị dính liền lại với nhau như thế này...
Điều này có nghĩa là người dùng chỉ nhìn thấy thay đổi trên màn hình sau khi cả hai quá trình - syntax highlighting và thay đổi các item được chọn - đều đã render xong hết.

Giá mà có cách nào để nói với React rằng: hãy đừng vội thực hiện syntax highlighting, thay vào đó hãy render thay đổi của item ở sidebar bên trái trước...
Chào mừng đến với useDeferredValue
Đây chính xác là tình huống để áp dụng useDeferredValue! Nó cho phép React giảm mức ưu tiên của những phần UI nặng và chậm để tập trung render UI có độ ưu tiên cao hơn.
Dưới đây mình sẽ giải thích một cách đơn giản useDeferredValue hoạt động như thế nào.
Giả sử bạn thay đổi state khiến cả component A và component B cùng phải re-render. Trong đó, component A thì nhỏ và render rất nhanh, còn component B lại nặng và mất nhiều thời gian để render.
const [value, setValue] = useState();
<ComponentA value={value} />
<ComponentB value={value} />

Vì React bản chất là Javascript chỉ chạy trên một thread — mà một thread thì chỉ có thể làm một việc tại một thời điểm — nên nó sẽ render tuần tự: render component A xong rồi mới tới component B, kiểu như thế này.
Component A và B sẽ được render tuần tự chứ không chạy đồng thời cùng một lúc
Và hơn nữa vì mặc định là React sẽ chỉ commit những thay đổi lên màn hình khi toàn bộ tất cả các component đã render xong, điều này nghĩa là người dùng sẽ không thấy bất kỳ thay đổi nào trên màn hình cho đến khi toàn bộ quá trình render của cả component A và B hoàn tất.

Giờ thử tưởng tượng tình huống khi state thay đổi liên tục, khiến cả component A và component B bị re-render liên tục như thế này.

Lần này, component B còn chưa kịp render xong thì quá trình render đã bị hủy giữa chừng do có thay đổi state mới. Tuy nhiên, vì người dùng chỉ có thể thấy thay đổi trên màn hình sau khi cả hai component A và B đã render xong hoàn toàn, nên giao diện sẽ trông như bị đơ cho đến khi lần render cuối cùng xong.

Một trong những việc mà useDeferredValue làm là tách quá trình render của component A và component B ra thành 2 quá trình render riêng.

const [value, setValue] = useState();
const deferredValue = useDeferredValue(value);
<ComponentA value={value} />
<ComponentB value={deferredValue} />
Bằng cách tách nhỏ các tác vụ render dài như vậy, người dùng có thể thấy thay đổi trên màn hình ở component A trước, trong khi component B vẫn đang render.

Điều này đặc biệt có ý nghĩa khi state thay đổi liên tục. Trong khi component B vẫn đang re-render thì ít ra người dùng vẫn có thể thấy những thay đổi ở component A trước — làm giao diện cảm giác mượt và phản hồi nhanh hơn rất nhiều.

Bạn có thể nói rằng useDeferredValue giảm mức ưu tiên của quá trình render component B - đó là lý do tại sao trong tên của nó có chữ “deferred” (trì hoãn).
Mình kiếm được một bài viết rất hay giải thích chi tiết về cách useDeferredValue hoạt động như thế nào ở đây — nó rất dễ hiểu và có hình minh họa với animation rất đẹp. Nếu bạn đinh dùng useDeferredValue, mình nghĩ bạn nên đọc qua bài viết này. Bài viết này cũng giải quyết vấn đề làm sao để render phần syntax highlighting nặng mà không làm đơ UI tương tự giống của mình.
Trong trường hợp của mình, component nhỏ trong ví dụ trên chính là component <Item /> ở sidebar, còn component to là component <CodeBlock /> mà thực hiện phần syntax highlighting.

Lúc trước code mình trông như thế này.
<Item item={item} selected={item === selectedItem}/>
const { pathDefinition, request, responses } =
useMemo(() => ..., [selectedItem]);
<CodeBlock code={pathDefinition} />
<CodeBlock code={request} />
<CodeBlock code={responses} />
Điều đó có nghĩa là khi selectedItem state thay đổi, React sẽ thực hiện toàn bộ quá trình render cùng một lúc như thế này:

Vì thế mình sửa cho để tạm thời hoãn lại phần syntax highlighting của component <CodeBlock/> như thế này:
const { pathDefinition, request, responses } =
useMemo(() => ..., [selectedItem]);
const deferredPathDefinition = useDeferredValue(pathDefinition);
const deferredRequest = useDeferredValue(request);
const deferredResponses = useDeferredValue(responses);
<CodeBlock code={deferredPathDefinition} />
<CodeBlock code={deferredRequest} />
<CodeBlock code={deferredResponses} />
Lúc này khi mà state selectedItem thay đổi, React sẽ giảm độ ưu tiên của syntax highlighting và đặt nó sau cùng.

Sau khi chỉnh sửa xong, ứng dụng của mình cũng có cảm giác mượt mà hơn một xíu vì các item ở bên tay trái chạy CSS animation ngay lập tức:

Một chút thử nghiệm (nhưng mà thất bại) với useDeferredValue
Mặc dù kết quả có khá hơn một chút, nhưng vấn đề chính vẫn nằm ở chỗ quá trình syntax highlighting cho 5 object lớn bị gộp chung vào một quá trình render.

Nếu mình có thể dùng useDeferredValue để còn tách nhỏ quá trình syntax highlighting ra thêm một bước nữa, kiểu như thế này thì sao nhỉ...

Ý tưởng của mình...
Nếu mình truyền một giá trị đã được deferred vào trong useMemo, thì cái useMemo đó cũng sẽ bị giảm thêm mức ưu tiên phải không? 🤔
Và nếu một component dùng giá trị từ cái useMemo đã bị deferred đó, thì nó sẽ còn bị trì hoãn thêm một lớp nữa — và vì thế nó sẽ được render tách biệt với các component cũng đã deferred qua mấy lớp khác khác, phải chứ?

Ý tưởng của mình...
Vậy là mình tách phần serialization trước đây nằm trong một useMemo lớn thành nhiều useMemo nhỏ khác nhau, mỗi cái đi kèm với useDeferredValue như thế này:
const deferredSelectedItem = useDeferredValue(selectedItem)
// Path definition
const pathDefinition = useMemo(() => ..., [deferredSelectedItem]);
const deferredPathDefinition = useDeferredValue(pathDefinition);
<CodeBlock code={deferredPathDefinition}/>
// Request body
const requestBodyRefs = useMemo(() => ..., [deferredSelectedItem]);
const deferredRequestBodyRefs = useDeferredValue(requestBodyRefs);
<CodeBlock code={deferredRequestBodyRefs}/>
// Responses
const responses = useMemo(() => ... [deferredSelectedItem]);
const deferredResponses = useDeferredValue(responses);
<CodeBlock code={deferredResponses}/>
Đáng tiếc là tại thời điểm mình viết bài này thì React không hoạt động như thế. Thay vì tách riêng quá trình syntax highlighting cho từng object và render từng cái một, React vẫn chạy tất cả các useMemo cùng lúc, rồi render cả 5 object một lượt.

Kết quả vẫn giống hệt như lúc ban đầu
Bạn không thể “defer” một component đã được defer được nữa. Tất cả các component được đánh dấu là deferred đều được React batch vào nhau khi có thể — nghĩa là sau khi đã render hết các component "bình thường" xong, React sẽ render tất cả các deferred component cùng một lúc.
Nó được viết ở đây trong documentation...
Giải pháp “oằn tà là vằn” của mình
Vậy phải làm sao để tách nhỏ quá trình này?
Thực ra thì mình cũng không biết phải xử lý thế nào trong tình huống này.
Khác với việc có thể dùng yieldToMainThread trước đó, lần này mình không thể chỉnh sửa vào code của quá trình syntax highlighting hay cơ chế render của React được. Mình không thể đơn giản biến các function mình muốn thành async rồi thêm await yieldToMainThread() vào được.
Trong ba bước serialization, syntax highlighting, và rendering — mình chỉ có thể kiểm soát được bước serialization — vì phần syntax highlighting do React Shiki làm, còn rendering thì thuộc quyền của React.

Cuối cùng, mình chọn cách tách phần serialization của các object ra càng nhiều càng tốt — bằng cách biến các function serialize thành bất đồng bộ (asynchronous).

// Path definition
const [pathDefinition, setPathDefinition] = useState();
useEffect(() => {
serializePathDefinitionFn().then((result) => {
setPathDefinition(result);
})
}, [selectedItem])
const deferredPathDefinition = useDeferredValue(pathDefinition);
<CodeBlock code={deferredPathDefinition}/>
// Request body
const [requestBodyRefs, setRequestBodyRefs] = useState();
useEffect(() => {
serializeRequestBodyRefsFn().then((result) => {
setRequestBodyRefs(result);
})
}, [selectedItem])
const deferredRequestBodyRefs = useDeferredValue(requestBodyRefs);
<CodeBlock code={deferredRequestBodyRefs}/>
// Responses
const [responses, setResponses] = useState();
useEffect(() => {
serializeResponsesFn().then((result) => {
setResponses(result);
})
}, [selectedItem])
const deferredResponses = useDeferredValue(responses);
<CodeBlock code={deferredResponses}/>
Code này trông rất oằn tà là vằn, nhưng… nó chạy trên máy mình 😅. CSS transition, ripple effect, và cả các item ở sidebar bên trái giờ đều chạy mượt mà.

Thật sự mà nói đến lúc này mình cảm giác như đang over-engineer và over-optimize cái web app này của mình. Khi mình dùng nó trong công việc (vốn là mục đích ban đầu khi mình tạo ra), mọi thứ chạy mượt đến mức mới đầu mình còn chẳng nghĩ là cần tối ưu gì thêm.
Ứng dụng chỉ hơi giật lag khi chạy trên máy cấu hình yếu và với một graph đặc biệt phức tạp thôi. Còn nếu bạn thử chạy trên máy của bạn, khả năng cao là bạn sẽ thấy nó rất mượt mà.
Kết luận
Ban đầu, mình tạo ứng dụng nhỏ này chỉ như một side project để hỗ trợ công việc của mình, nhưng cuối cùng nó lại giúp mình rút ra được vài bài học khá thú vị về performance trong frontend và React.
Đây là những gì mình rút ra được:
- Memoization – khi có nhiều component nặng, nhất là cho React Flow node.
- Tách nhỏ các một tính toán dài và phức tạp bằng
yieldToMainThread. - Dùng worker thread nghe thì có vẻ hay, nhưng thực tế overhead đôi khi không đáng.
- Hiểu cách trình duyệt render một frame như thế nào.
- Giảm mức ưu tiên render cho các component nặng bằng
useDeferredValue.
Nếu ứng dụng React của bạn bị giật lag, thì mình hy vọng là bài viết này sẽ giúp bạn làm mọi thứ mượt mà hơn một chút.
Và nếu bạn có ý tưởng hay giải pháp nào cho vấn đề “defer một component đã bị defer”, hãy bình luận bên dưới nhé — mình rất muốn nghe thêm từ mọi người!
All rights reserved