Hiệu suất luôn là yếu tố quan trọng đối với bất kỳ ứng dụng web nào. Một ứng dụng nhanh và mượt mà không chỉ mang lại trải nghiệm tuyệt vời cho người dùng mà còn giúp tăng hiệu quả hoạt động tổng thể. Với phiên bản React 18, chúng ta được trang bị nhiều công cụ và tính năng mới giúp tối ưu hóa hiệu suất một cách vượt trội.
Trong bài viết này, mình sẽ chia sẻ một số phương pháp cải thiện performance trong dự án React, tập trung vào các tính năng như Concurrent Rendering, Transitions, Suspense và React Server Components. Bài viết sẽ được trình bày một cách đơn giản, dễ hiểu, để bạn có thể áp dụng ngay vào dự án của mình và nâng cấp hiệu quả hoạt động của ứng dụng.
1. Hiểu về Main Thread và Long Tasks
Trước khi đi sâu vào performance React, mình sẽ cần hiểu được cách browser xử lý JavaScript và tại sao các tasks dài có thể ảnh hưởng đến hiệu suất ứng dụng.
1.1 Main Thread
Trong browser, main thread là nơi:
- Thực thi code JavaScript.
- Xử lý tương tác người dùng (như onClick, nhập liệu, nhấn phím,...).
- Cập nhật giao diện (reflow và repaint).
- Xử lý sự kiện mạng và bộ đếm thời gian.
1.2 Long Tasks
Long tasks là bất kỳ tác vụ nào mất hơn 50ms để hoàn thành. Bạn có thắc mắc là tại sao lại là 50ms?
- 60fps và 16ms: để giao diện mượt mà, trình duyệt cần cập nhật 60 khung hình mỗi giây, tức là mỗi khung hình trong khoảng 16ms.
- 50ms: giới hạn này cho phép browser có thời gian để thực hiện các tasks khác như xử lý tương tác người dùng và vẽ lại giao diện.
Vấn đề: khi một long tasks chạy trên main thread, nó chặn tất cả các tasks khác, dẫn đến giao diện bị giật lag, đơ, không phản hồi.
1.3 Các chỉ số đo lường hiệu suất
- Total Blocking Time (TBT): tổng thời gian các long tasks ngăn cản website tương tác.
Ví dụ trên hình bạn có thể thấy, TBT = 45ms bởi vì có hai tác vụ mất nhiều thời gian hơn 50ms, vượt quá ngưỡng 50ms lần lượt là 30ms và 15ms.
- Interaction to Next Paint (INP): thời gian từ khi người dùng tương tác đến khi giao diện được cập nhật.
- Bên dưới là hình ảnh giải thích cách công việc trên Main Thread ảnh hưởng đến trải nghiệm người dùng, đặc biệt là thời gian phản hồi giao diện. Tối ưu thời gian này sẽ cải thiện hiệu suất website.
User Interaction (Người dùng tương tác):
- Thời điểm người dùng thực hiện hành động (ví dụ: onClick, nhấn phím).
- Sau khi người dùng thực hiện, có độ trễ nhất định trước khi phản hồi hình ảnh xuất hiện.
Visual Feedback (Phản hồi hình ảnh):
- Thời điểm phản hồi trực quan xuất hiện trên giao diện người dùng (ví dụ: button thay đổi trạng thái hoặc nội dung được cập nhật).
Delays (Độ trễ): ảnh minh họa độ trễ giữa thời điểm người dùng thực hiện thao tác và khi phản hồi xuất hiện:
- 150ms delay: thời gian xử lý trước lần phản hồi hình ảnh đầu tiên.
- 250ms delay: thời gian chờ từ tương tác tiếp theo của người dùng và phản hồi.
"Interaction to Next Paint"
- Là khoảng thời gian tổng (250ms) từ khi người dùng thực hiện hành động cho đến khi phản hồi về hình ảnh tiếp theo được hiển thị.
2. React Rendering
Như bạn cũng đã biết trong React, quá trình cập nhật giao diện được chia làm hai giai đoạn:
Render Phase:
- Current Virtual DOM: trạng thái hiện tại của cây Virtual DOM.
- Updated Virtual DOM: khi React nhận thấy một thay đổi (từ props hoặc state), nó tạo một Virtual DOM mới để phản ánh sự thay đổi.
- Reconciliation: React so sánh Current Virtual DOM với Updated Virtual DOM. Các phần tử được thêm, sửa hoặc xóa được đánh dấu (các khối màu nâu trong hình biểu hiện cho sự thay đổi).
Lưu ý: giai đoạn này chỉ là tính toán, không có thay đổi thực tế nào xảy ra trên DOM. Giai đoạn này chạy bất đồng bộ và đảm bảo không gây ảnh hưởng đến giao diện người dùng.
Commit Phase:
- React áp dụng các thay đổi từ Virtual DOM vào DOM thực (DOM thật).
- Tạo, xóa hoặc cập nhật các thành phần DOM cần thiết để giao diện khớp với Virtual DOM mới.
Lưu ý: là giai đoạn thay đổi thực sự trên giao diện người dùng. Nó được thực thi trên Main Thread, vì vậy cần được tối ưu để không gây gián đoạn.
Đối với quá trình render đồng bộ, độ ưu tiên của tất cả component là như nhau. Khi component được render, dù là render ban đầu hay cập nhật state, React sẽ tiếp tục render trong một tác vụ duy nhất không bị gián đoạn, sau đó commit với DOM để cập nhật component trên màn hình.
Render đồng bộ là hoạt động "tất cả hoặc không có gì", trong đó đảm bảo rằng component render sẽ luôn hoàn tất. Tùy thuộc vào độ phức tạp của các component, giai đoạn render mất khoảng thời gian ít hoặc lâu để hoàn tất.
Trong lúc đó, main thread sẽ bị chặn, nghĩa là người dùng cố gắng tương tác nhưng ứng dụng sẽ không phản hồi cho đến khi React hoàn tất render và commit kết quả với DOM.
Cá nhân mình thấy rằng, quá trình đồng bộ này có thể sẽ không phù hợp với các ứng dụng lớn, vì nó khiến giao diện người dùng bị "đóng băng" khi không phản hồi kịp thời.
Để dễ hiểu hơn, mình sẽ lấy một ví dụ thế này:
// file CityList.js
import cities from "cities-list";
import React, { useEffect, useState } from "react";
const citiesList = Object.keys(cities);
const CityList = React.memo(({ searchQuery }) => {
const [filteredCities, setCities] = useState([]);
useEffect(() => {
if (!searchQuery) return;
setCities(() =>
citiesList.filter((x) =>
x.toLowerCase().startsWith(searchQuery.toLowerCase())
)
);
}, [searchQuery]);
return (
<ul>
{filteredCities.map((city) => (
<li key={city}>
{city}
</li>
))}
</ul>
)
});
export default CityList;
// file App.js
import React, { useState } from "react";
import CityList from "./CityList";
export default function SearchCities() {
const [text, setText] = useState("Am");
return (
<main>
<h1>Traditional Rendering</h1>
<input type="text" onChange={(e) => setText(e.target.value) } />
<CityList searchQuery={text} />
</main>
);
};
// file index.js
import { StrictMode } from "react";
import ReactDOM from "react-dom";
import App from "./App";
import "./styles.css";
const rootElement = document.getElementById("root");
ReactDOM.render(<StrictMode><App /></StrictMode>, rootElement);
Bạn có thể nhìn vào tab performance, bạn có thể mỗi lần nhấn phím (keydown) bị re-render, điều này không hề tối ưu.
Giải pháp thường được dev sử dụng Debounce - dùng thư viện như lodash để thêm debounce vào input. Lý do sử dụng Debounce để trì hoãn quá trình filter cho đến khi người dùng ngừng gõ trong một khoảng thời gian bạn đặt ra, thường sẽ ngắn.
import { debounce } from "lodash";
const handleInputChange = debounce((value) => setText(value), 300);
<input type="text" onChange={(e) => handleInputChange(e.target.value)} />;
Hoặc bạn có thể sử dụng useTransition có thể trì hoãn cập nhật không ưu tiên, cải thiện performance
import { useState, useTransition } from "react";
const [text, setText] = useState("");
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
startTransition(() => setText(value));
};
<input type="text" onChange={handleChange} />;
Trong React có cơ chế concurrent renderer, được thiết kế để cải thiện hiệu suất và tối ưu trải nghiệm người dùng bằng cách quản lý ưu tiên và tính bất đồng bộ trong quá trình render.
- High Priority (tác vụ có màu xanh): các tác vụ quan trọng (ví dụ: phản hồi tức thì sau khi người dùng nhập).
- Low Priority (tác vụ có màu hồng): các tác vụ ít quan trọng hơn có thể xử lý trong nền mà không ảnh hưởng đến trải nghiệm người dùng.
React không thực hiện tất cả tác vụ theo thứ tự "All-or-Nothing" như trước (synchronous render). Nó sẽ kiểm tra liên tục (5ms một lần) để xem Main Thread có tác vụ nào quan trọng cần xử lý hay không. React sử dụng thời gian không hoạt động của Main Thread để xử lý các tác vụ low-priority, đảm bảo UI không bị lag.
Mình sẽ giải thích một chút hình ảnh phía trên, để bạn dễ hiểu hơn về cơ chế hoạt động:
- Khi người dùng tương tác với ComponentTwo, React sẽ tạm dừng render của ComponentOne.
- React ưu tiên render ComponentTwo để đảm bảo phản hồi nhanh.
- Sau khi xử lý xong, React quay trở lại hoàn thành render ComponentOne.
3. Sử dụng Transitions để tối ưu
Transitions giúp bạn phân biệt giữa các cập nhật khẩn cấp và không khẩn cấp, để React ưu tiên xử lý tốt hơn. Công cụ này hoạt động dựa trên startTransition
, được cung cấp bởi hook useTransition
.
Với startTransition
, bạn có thể chỉ định một số tác vụ có thể xử lý sau, không cần ngay lập tức, giúp React tập trung vào các tác vụ khẩn cấp hơn, chẳng hạn như phản hồi người dùng.
Khi một transition bắt đầu, React sẽ dựng lại giao diện mới trong nền (background rendering). Giao diện mới sẽ không được commit ngay lập tức vào DOM, mà chờ khi browser hoặc Main Thread không bận.
import { useTransition } from "react";
function Button() {
const [isPending, startTransition] = useTransition();
const handleClick = () => {
// Các cập nhật trạng thái ưu tiên cao
urgentUpdate();
// Các cập nhật trạng thái không cần thực thi ngay lập tức
startTransition(() => {
nonUrgentUpdate();
});
};
return (
<button onClick={handleClick}>
{isPending ? "Loading..." : "Click Me"}
</button>
);
}
Với hình ảnh trên bạn có thể thấy, nếu như bạn không sử dụng startTransition
tất cả các cập nhật sẽ được xử lý đồng bộ, Main Thread sẽ bị chặn bởi các tác vụ dài, dẫn đến việc giao diện sẽ không phản hồi kịp thời. Trái lại, khi bạn sử dụng startTransition
các cập nhật không gấp được xử lý nền, giúp giao diện vẫn phản hồi tốt với các tương tác của người dùng, Main Thread luôn có thời gian để xử lý các tác vụ ưu tiên cao hơn.
Hãy đi vào ví dụ này nhé, bạn có thể thấy được việc cải thiện hiệu suất khi sử dụng startTransition
// file CityList.js
import cities from "cities-list";
import React, { useEffect, useState } from "react";
const citiesList = Object.keys(cities);
const CityList = React.memo(({ searchQuery }) => {
const [filteredCities, setCities] = useState([]);
useEffect(() => {
if (!searchQuery) return;
setCities(() =>
citiesList.filter((x) =>
x.toLowerCase().startsWith(searchQuery.toLowerCase())
)
);
}, [searchQuery]);
return (
<ul>
{filteredCities.map((city) => (
<li key={city}>{city}</li>
))}
</ul>
)
});
export default CityList;
// file App.js
import React, { useState, useTransition } from "react";
import CityList from "./CityList";
export default function SearchCities() {
const [text, setText] = useState("Am");
const [searchQuery, setSearchQuery] = useState(text);
const [isPending, startTransition] = useTransition();
return (
<main>
<h1><code>startTransition</code></h1>
<input
type="text"
value={text}
onChange={(e) => {
setText(e.target.value)
startTransition(() => {
setSearchQuery(e.target.value)
})
}} />
<CityList searchQuery={searchQuery} />
</main>
);
};
// file index.js
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
const rootElement = document.getElementById("root");
const root = ReactDOM.createRoot(rootElement);
root.render(<StrictMode><App /></StrictMode>);
Khi người dùng nhập dữ liệu vào ô tìm kiếm:
- Không sử dụng
startTransition
: - Mỗi lần nhấn phím (keystroke), trạng thái searchQuery sẽ cập nhật đồng bộ.
- Dẫn đến việc gọi lại hàm
setCities
để lọc danh sách ngay lập tức, gây ra các "long tasks" trên Main Thread.
Điều này sẽ khiến cho giao diện bị giật và không phản hồi mượt mà khi người dùng gõ nhanh.
- Sử dụng startTransition:
- Chia trạng thái thành hai phần:
- text: trạng thái cập nhật đồng bộ cho input.
- searchQuery: được cập nhật thông qua
startTransition
, không ưu tiên cao. - React xử lý searchQuery trong nền mà không làm gián đoạn đến giao diện.
Bạn có thể thấy tại tab performance, số lượng long tasks và total blocking time (TBT) đã giảm đáng kể.
4. Suspense
Suspense là tính năng quan trọng trong concurrent mode. Ban đầu, Suspense được sử dụng chủ yếu để hỗ trợ code-splitting (tách nhỏ mã) với React.lazy. Suspense được mở rộng để hỗ trợ cả data fetching (tải dữ liệu).
Suspense cho phép tạm hoãn render component cho đến khi dữ liệu cần thiết được tải xong từ nguồn dữ liệu từ xa (API, database, file system,…). Trong khoảng thời gian chờ, Suspense sẽ hiển thị một fallback UI (ví dụ: spinner, loading, skeleton,...) để thông báo dữ liệu đang được tải.
Cơ chế hoạt động của Suspense:
- Khi một component bị suspend (do chờ dữ liệu), React không "ngồi đợi" mà tiếp tục xử lý các tác vụ hoặc component khác.
- Trong thời gian chờ dữ liệu, React hiển thị fallback UI.
- Khi dữ liệu đã sẵn sàng, React tiếp tục render component bị suspend và commit vào DOM.
- Khi người dùng tương tác với một component bị suspend, React có thể tạm dừng render hiện tại và ưu tiên render component tương tác của người dùng trước.
Dưới đây là ví dụ đơn giản sử dụng Suspense:
async function BlogPosts() {
const posts = await db.posts.findAll();
return '...';
}
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<BlogPosts />
</Suspense>
);
}
- Trong khi
BlogPosts
đang chờ dữ liệu từ database,Suspense
sẽ hiển thị<Skeleton />
. - Khi dữ liệu sẵn sàng, React sẽ tự động render và thay thế Skeleton với nội dung của BlogPosts.
Bạn có thể sử dụng để tránh trường hợp bị Cumulative Layout Shift (CLS) giảm performance của website.
5. Data Fetching
5.1 API cache
Hàm cache trong React giúp ghi nhớ kết quả của một hàm bất đồng bộ, tránh việc gọi lại hàm nếu các tham số không thay đổi trong cùng một lần render.
import { cache } from 'react';
export const getUser = cache(async (id) => {
const user = await db.user.findUnique({ id });
return user;
});
getUser(1);
getUser(1);
- Kết quả của hàm
getUser(1)
lần đầu tiên sẽ được ghi vào bộ nhớ đệm. - Khi gọi lại
getUser(1)
trong cùng một lần render, React trả về dữ liệu đã được cache thay vì thực thi lại hàm đó một lần nữa.
5.2 Cơ chế caching trong fetch
React cung cấp cơ chế caching mặc định cho các lệnh gọi fetch mà không cần sử dụng API cache.
export const fetchPost = async (id) => {
const res = await fetch(`https://.../posts/${id}`);
const data = await res.json();
return { post: data.post };
};
fetchPost(1);
fetchPost(1);
Giúp giảm số lượng request trong một lần render, tối ưu hiệu suất ứng dụng, giảm tải cho server.
- Khi ứng dụng React gọi
fetch('/api/posts')
, nó gửi request đến server (API) để lấy dữ liệu. - Server trả về dữ liệu dạng JSON hoặc định dạng khác.
- React ghi nhớ kết quả trả về từ server vào memory. Khi thực hiện lệnh gọi tiếp theo với cùng URL
fetch('/api/posts')
, React sẽ sử dụng kết quả từ memory thay vì gửi lại request đến server lần nữa. - Trong cùng một lần render, nếu gọi lại lệnh
fetch('/api/posts')
, React sẽ sử dụng dữ liệu đã được lưu trước đó (memoized result).
5.3 Một số lưu ý khi sử dụng cache
- Phạm vi cache: cache chỉ tồn tại trong cùng một lần render trên server. Nếu bạn cần cache dữ liệu lâu hơn (giữa các request), bạn cần sử dụng một giải pháp cache khác (ví dụ như: Redis, Memcached).
- Dữ liệu thay đổi thường xuyên: nếu dữ liệu có thể thay đổi trong quá trình render, bạn cần cẩn thận với việc cache để tránh hiển thị các thông tin không cập nhật.
- Cache Invalidation: hiện tại, React không cung cấp cơ chế tự động làm mới cache. Nếu bạn cần làm mới dữ liệu, bạn cần thay đổi tham số đầu vào hoặc sử dụng các kỹ thuật khác để vô hiệu hóa cache.
- Bạn cần phải xác định dữ liệu cần cache, không phải tất cả dữ liệu đều nên được cache. Chỉ cache những dữ liệu ít thay đổi hoặc không yêu cầu cập nhật liên tục.
- Cache quá nhiều dữ liệu có thể dẫn đến việc sử dụng bộ nhớ không hiệu quả.
5.4 Áp dụng cơ chế caching với React Server Components
Trong React Server Components, các lệnh gọi fetch được tự động cache trong cùng một lần render. Điều này giúp giảm số lượng request đến server và tối ưu hóa hiệu suất.
Dưới đây là một ví dụ minh họa cách caching tự động được áp dụng trong React khi làm việc với React Server Components
async function fetchBlogPost(id) {
const res = await fetch(`/api/posts/${id}`);
return res.json();
}
async function BlogPostLayout() {
const post = await fetchBlogPost('123'); // Fetch dữ liệu từ server
return '...';
}
async function BlogPostContent() {
const post = await fetchBlogPost('123'); // Sử dụng kết quả đã lưu trong memory
return '...';
}
export default function Page() {
return (
<BlogPostLayout>
<BlogPostContent />
</BlogPostLayout>
);
}
- Hàm
fetchBlogPost
gọi API/api/posts/{id}
để lấy dữ liệu bài viết (blog post). - Kết quả từ server được React tự động ghi nhớ (memoized) trong cùng một lần render.
- Component chính hiển thị layout và nội dung bài viết. Nhờ caching, dữ liệu bài viết được chia sẻ giữa các components mà không cần fetch lại từ server.
6. Kết luận
Hiệu suất ứng dụng web luôn là vấn đề quan tâm hàng đầu của các developer. Với React, bạn đã có trong tay những công cụ mạnh mẽ để tối ưu hóa và nâng cao trải nghiệm người dùng.
Các tính năng như: Concurrent Rendering giúp ứng dụng phản hồi nhanh hơn bằng cách quản lý ưu tiên tác vụ. Transitions cho phép chúng ta xử lý các cập nhật không khẩn cấp mà không ảnh hưởng đến tương tác người dùng với ứng dụng. Suspense mang đến khả năng quản lý trạng thái chờ hiệu quả, giúp giao diện luôn mượt mà ngay cả khi đang tải dữ liệu.
Qua bài viết này, hy vọng bạn đã hiểu rõ hơn về một vài kiến thức cần nắm để cải thiện performance và cách áp dụng trong dự án React.
Bài viết liên quan
Hướng dẫn tích hợp Redux và React Query trong dự án React Vite
Nov 22, 2024 • 8 min read
React Toastify là gì? Hướng dẫn sử dụng Toast Notification với React Toastify
Nov 21, 2024 • 7 min read
Hướng dẫn sử dụng Zustand trong NextJS
Nov 21, 2024 • 8 min read
Lazy Loading: Kỹ thuật Tối ưu Hiệu suất Website
Nov 17, 2024 • 14 min read
Hướng dẫn sử dụng Redux Toolkit và Redux Saga trong dự án React
Nov 15, 2024 • 10 min read
WebGL là gì? Hướng dẫn tạo đồ họa đơn giản với WebGL
Nov 13, 2024 • 7 min read