Giới thiệu
React đã trở thành một trong những công cụ phát triển frontend phổ biến nhất, nhưng việc sử dụng nó không đúng cách có thể dẫn đến các vấn đề hiệu suất nghiêm trọng. Để tối ưu hiệu suất React, chúng ta cần hiểu rõ cơ chế render của nó và các nguyên nhân phổ biến gây ra các vấn đề hiệu suất.
Trong bài viết này, chúng ta sẽ khám phá:
- Cơ chế render của React (Virtual DOM, Reconciliation, Fiber architecture)
- Các vấn đề hiệu suất phổ biến trong React
- Kỹ thuật tối ưu hiệu suất React
- Server-side Rendering (SSR) và Static Site Generation (SSG)
- React Server Components
1. Cơ chế render của React
Để tối ưu hiệu suất React, trước tiên chúng ta cần hiểu cách React render UI.
Virtual DOM
Virtual DOM là một trong những khái niệm cốt lõi của React. Đây là một biểu diễn nhẹ của DOM thật trong bộ nhớ.
Cách hoạt động:
- React tạo một cây Virtual DOM khi ứng dụng khởi động
- Khi state hoặc props thay đổi, React tạo một Virtual DOM mới
- React so sánh Virtual DOM mới với phiên bản trước đó (quá trình “diffing”)
- Chỉ những phần thay đổi mới được cập nhật trong DOM thật (quá trình “reconciliation”)
// Khi state thay đổi
this.setState({ count: this.state.count + 1 });
// React sẽ:
// 1. Tạo Virtual DOM mới với count đã cập nhật
// 2. So sánh với Virtual DOM cũ
// 3. Tìm ra sự khác biệt (count đã thay đổi)
// 4. Chỉ cập nhật phần DOM thật chứa count
Virtual DOM giúp tối ưu hiệu suất bằng cách giảm thiểu các thao tác DOM trực tiếp, vốn rất tốn kém về mặt hiệu suất.
Reconciliation
Reconciliation là thuật toán React sử dụng để so sánh hai cây Virtual DOM và xác định những gì cần được cập nhật.
Nguyên tắc cơ bản:
Các phần tử có kiểu khác nhau sẽ tạo ra cây khác nhau
- Nếu một
<div>
thay đổi thành<span>
, React sẽ xóa cây cũ và xây dựng cây mới
- Nếu một
Các phần tử có key khác nhau được coi là khác nhau
- Thuộc tính
key
giúp React xác định phần tử nào đã thay đổi, thêm mới hoặc xóa trong danh sách
- Thuộc tính
// Không tốt - không có key
{
items.map((item) => <ListItem item={item} />);
}
// Tốt - có key ổn định
{
items.map((item) => <ListItem key={item.id} item={item} />);
}
- Reconciliation từ trên xuống dưới, một lần duyệt
- React bắt đầu từ gốc của cây và đi xuống, so sánh từng cặp nút
Fiber Architecture
Từ React 16, React đã giới thiệu Fiber - một kiến trúc mới cho thuật toán reconciliation, cho phép render “có thể ngắt” (interruptible).
Đặc điểm của Fiber:
Chia nhỏ công việc render
- Thay vì xử lý toàn bộ cây component trong một lần, Fiber chia nhỏ công việc thành các đơn vị nhỏ
Ưu tiên và tạm dừng công việc
- Có thể tạm dừng, hủy bỏ hoặc ưu tiên các công việc render khác nhau
Hai giai đoạn commit
- Giai đoạn 1: Render/reconciliation (có thể ngắt)
- Giai đoạn 2: Commit (không thể ngắt, cập nhật DOM)
Render Phase (có thể ngắt):
1. componentWillReceiveProps (deprecated)
2. static getDerivedStateFromProps
3. shouldComponentUpdate
4. componentWillUpdate (deprecated)
5. render
Commit Phase (không thể ngắt):
1. getSnapshotBeforeUpdate
2. Cập nhật DOM
3. componentDidMount / componentDidUpdate
Fiber giúp React ưu tiên các tác vụ quan trọng như animation hoặc input người dùng, cải thiện trải nghiệm người dùng đặc biệt trên thiết bị có hiệu suất thấp.
2. Các vấn đề hiệu suất phổ biến trong React
Hiểu các vấn đề hiệu suất phổ biến sẽ giúp bạn tránh chúng trong ứng dụng của mình.
Re-render không cần thiết
Một trong những vấn đề hiệu suất phổ biến nhất trong React là re-render không cần thiết.
Nguyên nhân:
- Props thay đổi tham chiếu nhưng không thay đổi giá trị
// Không tốt - tạo object mới mỗi lần render
function ParentComponent() {
return <ChildComponent options={{ color: "red" }} />;
}
// Tốt - sử dụng useMemo để giữ tham chiếu ổn định
function ParentComponent() {
const options = useMemo(() => ({ color: "red" }), []);
return <ChildComponent options={options} />;
}
- Không sử dụng React.memo, useMemo, useCallback
// Không tốt - component con sẽ re-render mỗi khi parent re-render
function ChildComponent({ name }) {
return <div>{name}</div>;
}
// Tốt - component con chỉ re-render khi props thay đổi
const ChildComponent = React.memo(function ChildComponent({ name }) {
return <div>{name}</div>;
});
- Context Provider bao quanh quá nhiều component
// Không tốt - mọi component con sẽ re-render khi value thay đổi
function App() {
const [state, setState] = useState({ user: {}, theme: "light" });
return (
<AppContext.Provider value={state}>
<DeepComponent />
</AppContext.Provider>
);
}
// Tốt - tách context để giảm re-render
function App() {
return (
<UserProvider>
<ThemeProvider>
<DeepComponent />
</ThemeProvider>
</UserProvider>
);
}
Props drilling
Props drilling xảy ra khi bạn cần truyền props qua nhiều lớp component để đến component cần sử dụng chúng.
// Props drilling
function App() {
const [user, setUser] = useState({ name: "John" });
return <Header user={user} />;
}
function Header({ user }) {
return <Navigation user={user} />;
}
function Navigation({ user }) {
return <UserMenu user={user} />;
}
function UserMenu({ user }) {
return <div>Hello, {user.name}</div>;
}
Props drilling không chỉ làm code khó đọc mà còn có thể gây ra re-render không cần thiết ở các component trung gian.
Quản lý state không hiệu quả
Cách bạn tổ chức và quản lý state có thể ảnh hưởng lớn đến hiệu suất.
Vấn đề phổ biến:
- State quá lớn và không được chia nhỏ
// Không tốt - một state lớn
const [state, setState] = useState({
user: {
/* ... */
},
posts: [
/* ... */
],
comments: [
/* ... */
],
preferences: {
/* ... */
},
});
// Tốt - chia nhỏ state theo chức năng
const [user, setUser] = useState({
/* ... */
});
const [posts, setPosts] = useState([
/* ... */
]);
const [comments, setComments] = useState([
/* ... */
]);
const [preferences, setPreferences] = useState({
/* ... */
});
- Cập nhật state không đúng cách
// Không tốt - cập nhật trực tiếp state object
function updateUser(newName) {
const newState = state;
newState.user.name = newName;
setState(newState); // Không gây re-render vì tham chiếu không thay đổi
}
// Tốt - tạo state mới với spread operator
function updateUser(newName) {
setState((prevState) => ({
...prevState,
user: {
...prevState.user,
name: newName,
},
}));
}
- Lựa chọn thư viện quản lý state không phù hợp
- Redux có thể quá nặng cho ứng dụng nhỏ
- Context API có thể gây re-render không cần thiết
- Zustand, Jotai, Recoil có thể phù hợp hơn cho các trường hợp cụ thể
3. Kỹ thuật tối ưu hiệu suất React
Sau khi hiểu các vấn đề, hãy xem các kỹ thuật để tối ưu hiệu suất React.
React.memo, useMemo, useCallback
Đây là các API chính để ngăn re-render không cần thiết trong React.
React.memo: Là một HOC (Higher Order Component) giúp bỏ qua việc render lại component nếu props không thay đổi.
// Chỉ re-render khi props thay đổi
const MemoizedComponent = React.memo(function MyComponent(props) {
/* render using props */
});
// Tùy chỉnh logic so sánh props
const MemoizedComponent = React.memo(
function MyComponent(props) {
/* render using props */
},
(prevProps, nextProps) => {
// Return true nếu muốn bỏ qua render
return prevProps.id === nextProps.id;
}
);
useMemo: Hook để ghi nhớ kết quả của một phép tính tốn kém, chỉ tính toán lại khi dependencies thay đổi.
// Chỉ tính toán lại khi dependencies thay đổi
const memoizedValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]);
// Giữ tham chiếu ổn định cho objects
const memoizedObject = useMemo(() => {
return { id, name, details };
}, [id, name, details]);
useCallback: Hook để ghi nhớ một callback, tránh tạo hàm mới mỗi lần render.
// Không tốt - tạo hàm mới mỗi lần render
function ParentComponent() {
const handleClick = () => {
console.log("Clicked!");
};
return <ChildComponent onClick={handleClick} />;
}
// Tốt - giữ tham chiếu ổn định cho callback
function ParentComponent() {
const handleClick = useCallback(() => {
console.log("Clicked!");
}, []);
return <ChildComponent onClick={handleClick} />;
}
Code splitting với React.lazy và Suspense
Code splitting giúp chia nhỏ bundle JavaScript, chỉ tải những phần cần thiết khi người dùng cần.
React.lazy: Cho phép tải động component khi cần.
// Thay vì import trực tiếp
import OtherComponent from "./OtherComponent";
// Sử dụng import động
const OtherComponent = React.lazy(() => import("./OtherComponent"));
function MyComponent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
);
}
Suspense: Hiển thị fallback UI trong khi đợi component tải.
// Suspense với nhiều component lazy
function MyComponent() {
return (
<Suspense fallback={<Loading />}>
<Section>
<React.lazy(() => import('./Comments')) />
<React.lazy(() => import('./Photos')) />
</Section>
</Suspense>
);
}
Route-based code splitting: Kết hợp với React Router để tải code theo route.
import { BrowserRouter, Routes, Route } from "react-router-dom";
import React, { Suspense } from "react";
const Home = React.lazy(() => import("./Home"));
const About = React.lazy(() => import("./About"));
const Dashboard = React.lazy(() => import("./Dashboard"));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Windowing và virtualization cho danh sách dài
Khi hiển thị danh sách dài, render tất cả các item cùng một lúc có thể gây ra hiệu suất kém. Windowing (hay virtualization) chỉ render những item hiện đang trong viewport.
react-window: Thư viện phổ biến cho virtualization trong React.
import { FixedSizeList } from "react-window";
function ListComponent({ items }) {
const Row = ({ index, style }) => (
<div style={style}>{items[index].name}</div>
);
return (
<FixedSizeList
height={500}
width={300}
itemCount={items.length}
itemSize={35}
>
{Row}
</FixedSizeList>
);
}
react-virtualized: Một thư viện toàn diện hơn với nhiều tính năng.
import { List, AutoSizer } from "react-virtualized";
function VirtualizedList({ items }) {
const rowRenderer = ({ key, index, style }) => {
const item = items[index];
return (
<div key={key} style={style}>
{item.name}
</div>
);
};
return (
<div style={{ height: "500px" }}>
<AutoSizer>
{({ height, width }) => (
<List
width={width}
height={height}
rowCount={items.length}
rowHeight={35}
rowRenderer={rowRenderer}
/>
)}
</AutoSizer>
</div>
);
}
Profiler API và React DevTools
React cung cấp công cụ để đo lường và debug hiệu suất.
Profiler API: Đo lường tần suất và “chi phí” render của ứng dụng.
import { Profiler } from "react";
function onRenderCallback(
id, // id của Profiler tree đã commit
phase, // "mount" (lần đầu) hoặc "update" (re-render)
actualDuration, // thời gian render component
baseDuration, // thời gian ước tính nếu render toàn bộ subtree
startTime, // thời điểm React bắt đầu render
commitTime, // thời điểm React commit thay đổi
interactions // Set các "interactions" đã gây ra update
) {
console.log(`${id} took ${actualDuration}ms to render`);
}
function MyComponent() {
return (
<Profiler id="MyComponent" onRender={onRenderCallback}>
<ChildComponent />
</Profiler>
);
}
React DevTools Profiler: Giao diện trực quan để phân tích hiệu suất render.
- Flame Chart: Hiển thị thời gian render của từng component
- Ranked Chart: Sắp xếp components theo thời gian render
- Component Chart: Theo dõi re-renders của một component cụ thể
- Interactions: Theo dõi các tương tác người dùng và updates
4. Server-side Rendering (SSR) và Static Site Generation (SSG)
SSR và SSG là các kỹ thuật render React ở server thay vì client, cải thiện thời gian tải trang đầu tiên và SEO.
Next.js và các giải pháp
Next.js là framework phổ biến nhất cho SSR và SSG với React.
Server-side Rendering (SSR): Render React components trên server cho mỗi request.
// pages/ssr-page.js
export async function getServerSideProps(context) {
// Fetch data từ API
const res = await fetch("https://api.example.com/data");
const data = await res.json();
// Trả về props cho component
return {
props: { data }, // Sẽ được truyền vào component
};
}
function SSRPage({ data }) {
return <div>{data.title}</div>;
}
export default SSRPage;
Static Site Generation (SSG): Pre-render trang tại build time.
// pages/ssg-page.js
export async function getStaticProps() {
// Fetch data tại build time
const res = await fetch("https://api.example.com/data");
const data = await res.json();
return {
props: { data },
// Revalidate sau 60 giây (Incremental Static Regeneration)
revalidate: 60,
};
}
function SSGPage({ data }) {
return <div>{data.title}</div>;
}
export default SSGPage;
Incremental Static Regeneration (ISR): Kết hợp ưu điểm của SSG và SSR.
// pages/isr-page.js
export async function getStaticProps() {
const res = await fetch("https://api.example.com/data");
const data = await res.json();
return {
props: { data },
// Trang sẽ được re-generate sau 60 giây
revalidate: 60,
};
}
export async function getStaticPaths() {
const res = await fetch("https://api.example.com/posts");
const posts = await res.json();
// Pre-render chỉ những paths này tại build time
const paths = posts.map((post) => ({
params: { id: post.id },
}));
// { fallback: 'blocking' } sẽ server-render pages
// khi path không được pre-render
return { paths, fallback: "blocking" };
}
function Post({ data }) {
return <div>{data.title}</div>;
}
export default Post;
Hydration và các vấn đề liên quan
Hydration là quá trình React “tiếp quản” HTML đã được render bởi server và thêm các event listeners.
Vấn đề phổ biến:
- Hydration mismatch: Xảy ra khi HTML từ server khác với HTML mà React sẽ render ở client.
// Server renders với timezone của server
function ServerComponent() {
const date = new Date();
return <div>{date.toString()}</div>;
}
// Client hydrates với timezone của client
// => Hydration mismatch!
- Hydration chậm: Quá trình hydration có thể chặn main thread, gây ra Largest Contentful Paint (LCP) và Time to Interactive (TTI) kém.
Giải pháp:
- Đảm bảo render đồng nhất giữa server và client:
// Sử dụng dữ liệu không phụ thuộc môi trường
function Component({ serverData }) {
return <div>{serverData}</div>;
}
- Progressive Hydration: Hydrate các phần khác nhau của trang theo thứ tự ưu tiên.
// Hydrate component quan trọng trước
<Suspense fallback={<div />}>
<CriticalComponent />
</Suspense>
// Hydrate component ít quan trọng sau
<Suspense fallback={<div />}>
<NonCriticalComponent />
</Suspense>
Partial Hydration và Islands Architecture
Partial Hydration và Islands Architecture là các kỹ thuật mới để tối ưu quá trình hydration.
Islands Architecture: Chia trang thành các “islands” of interactivity trong “sea” of static HTML.
// Static HTML (không cần hydration)
<StaticHeader />
// Interactive island (cần hydration)
<InteractiveSearchBox />
// Static HTML (không cần hydration)
<StaticContent />
// Interactive island (cần hydration)
<InteractiveCommentSection />
// Static HTML (không cần hydration)
<StaticFooter />
Astro: Framework hỗ trợ Islands Architecture.
---
// Astro component
import ReactCounter from '../components/ReactCounter.jsx';
---
<html>
<body>
<h1>Static content</h1>
<!-- Island of React -->
<ReactCounter client:visible />
<p>More static content</p>
</body>
</html>
5. React Server Components
React Server Components (RSC) là một mô hình mới cho phép components chạy và render hoàn toàn trên server, không cần JavaScript ở client.
Cách hoạt động và lợi ích
Cách hoạt động:
- Server Components chạy trên server và không bao giờ gửi JavaScript đến client
- Client Components chạy trên client và được hydrate như thông thường
- Server Components có thể truy cập tài nguyên server (database, filesystem)
- Server Components có thể render Client Components
// ServerComponent.server.js (chạy trên server)
import { db } from '../database';
export default async function ServerComponent() {
const data = await db.query('SELECT * FROM posts');
return (
<div>
{data.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}
// ClientComponent.client.js (chạy trên client)
'use client';
import { useState } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
// App.js (kết hợp cả hai)
import ServerComponent from './ServerComponent.server';
import ClientComponent from './ClientComponent.client';
export default function App() {
return (
<div>
<ServerComponent />
<ClientComponent />
</div>
);
}
Lợi ích:
Bundle size nhỏ hơn: Server Components không gửi JavaScript đến client
Truy cập trực tiếp tài nguyên server: Không cần API layer trung gian
Không có waterfalls: Server Components có thể fetch data song song
Automatic code splitting: Client Components được tự động code-split
Không cần hydration cho Server Components: Cải thiện Time to Interactive
So sánh với SSR truyền thống
SSR truyền thống:
- Render toàn bộ trang trên server
- Gửi HTML + toàn bộ JavaScript đến client
- Hydrate toàn bộ ứng dụng trên client
- Mỗi navigation thường yêu cầu tải lại toàn bộ trang
React Server Components:
- Render components riêng lẻ trên server
- Chỉ gửi HTML + JavaScript cần thiết (Client Components) đến client
- Chỉ hydrate Client Components
- Có thể streaming components khi chúng sẵn sàng
- Navigation có thể chỉ cập nhật các phần cần thiết
// SSR truyền thống
// 1. Render toàn bộ App trên server
// 2. Gửi HTML đến client
// 3. Tải JavaScript
// 4. Hydrate toàn bộ App
// React Server Components
// 1. Render ServerComponents trên server
// 2. Gửi kết quả render + Client Components đến client
// 3. Hydrate chỉ Client Components
Next.js App Router: Framework đầu tiên triển khai React Server Components.
// app/page.js (Server Component mặc định)
export default async function Page() {
const data = await fetch('https://api.example.com/data');
const json = await data.json();
return (
<main>
<h1>{json.title}</h1>
<ClientComponent />
</main>
);
}
// app/client-component.js
'use client'; // Đánh dấu là Client Component
import { useState } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
Kết luận
Tối ưu hóa hiệu suất trong React đòi hỏi hiểu rõ cơ chế render của nó và áp dụng các kỹ thuật phù hợp. Từ việc ngăn re-render không cần thiết với React.memo, useMemo, và useCallback, đến việc sử dụng code splitting, windowing, và server-side rendering, có nhiều cách để cải thiện trải nghiệm người dùng.
React Server Components đại diện cho tương lai của React, cho phép cải thiện hiệu suất đáng kể bằng cách chỉ gửi JavaScript cần thiết đến client và tận dụng sức mạnh của server.
Trong phần tiếp theo, chúng ta sẽ khám phá cách tối ưu hiệu suất trong Vue, một framework frontend phổ biến khác, và so sánh cách tiếp cận của nó với React.