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:

  1. React tạo một cây Virtual DOM khi ứng dụng khởi động
  2. Khi state hoặc props thay đổi, React tạo một Virtual DOM mới
  3. React so sánh Virtual DOM mới với phiên bản trước đó (quá trình “diffing”)
  4. 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:

  1. 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
  2. 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
// 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} />);
}
  1. 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:

  1. 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ỏ
  2. Ư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
  3. 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 ( 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:

  1. 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} />;
}
  1. 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>;
});
  1. 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:

  1. 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({
  /* ... */
});
  1. 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,
    },
  }));
}
  1. 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:

  1. 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!
  1. 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:

  1. Đả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>;
}
  1. 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:

  1. Bundle size nhỏ hơn: Server Components không gửi JavaScript đến client

  2. Truy cập trực tiếp tài nguyên server: Không cần API layer trung gian

  3. Không có waterfalls: Server Components có thể fetch data song song

  4. Automatic code splitting: Client Components được tự động code-split

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

Tài liệu tham khảo