Bài 23.1: useMemo và useCallback trong TypeScript

Chào mừng bạn quay trở lại với series blog về Lập trình Web Front-end! Hôm nay, chúng ta sẽ cùng nhau đi sâu vào hai hook cực kỳ mạnh mẽquan trọng trong React để tối ưu hóa hiệu suất: useMemouseCallback. Nếu bạn đã làm việc với React một thời gian, chắc hẳn đã có lúc bạn gặp phải tình trạng ứng dụng chạy chậm, hoặc các component re-render một cách không cần thiết. Đây chính là lúc bộ đôi "siêu anh hùng" này phát huy tác dụng!

Tại sao lại cần tối ưu hóa? React rất thông minh, nó chỉ cập nhật DOM khi cần. Tuy nhiên, quá trình tính toán để xác định xem có cần cập nhật hay không (quá trình render) vẫn diễn ra. Trong các component functional, mỗi khi component re-render (do state hoặc props thay đổi), tất cả mọi thứ bên trong thân hàm component đó đều được thực thi lại. Điều này bao gồm cả việc tạo ra các hàm mới, các đối tượng mới, hoặc thực hiện các phép tính. Đối với các ứng dụng nhỏ, điều này thường không phải là vấn đề. Nhưng với các ứng dụng lớn, phức tạp, xử lý nhiều dữ liệu hoặc có cấu trúc component sâu, việc tạo lại các giá trị hoặc hàm "nặng" trên mỗi lần render có thể ngốn CPU và làm chậm đáng kể trải nghiệm người dùng.

useMemouseCallback giúp chúng ta giải quyết vấn đề này bằng cách ghi nhớ (memoize) kết quả của phép tính hoặc định nghĩa hàm, chỉ tính toán lại hoặc tạo lại khi các dependencies (giá trị phụ thuộc) của chúng thay đổi.

useMemo: Nhớ Kỹ Giá Trị Tính Toán

useMemo là hook được sử dụng để ghi nhớ kết quả của một phép tính tốn kém. Thay vì thực hiện lại phép tính đó trên mỗi lần component re-render, useMemo sẽ chỉ chạy lại phép tính khi các dependencies của nó thay đổi. Nếu dependencies không đổi, nó sẽ trả về kết quả đã được ghi nhớ từ lần render trước.

Cú pháp cơ bản:

import React, { useMemo } from 'react';

const memoizedValue = useMemo(() => {
  // Đây là nơi thực hiện phép tính mà bạn muốn memoize
  // Ví dụ: Lọc một mảng lớn, tính toán phức tạp, v.v.
  console.log('Đang tính toán lại giá trị...');
  return calculateExpensiveValue(dependency1, dependency2);
}, [dependency1, dependency2]); // Mảng các giá trị dependencies
  • Tham số đầu tiên: Một hàm factory (hàm tạo) chứa logic tính toán. Hàm này sẽ được thực thi để lấy giá trị cần memoize.
  • Tham số thứ hai: Một mảng các dependencies. Đây là chìa khóa! useMemo sẽ chỉ chạy lại hàm factory khi có ít nhất một giá trị trong mảng này thay đổi (so sánh bằng strict equality ===). Nếu mảng rỗng [], hàm factory sẽ chỉ chạy một lần duy nhất sau lần render đầu tiên.

Ví dụ minh họa:

Giả sử bạn có một danh sách sản phẩm rất lớn và cần hiển thị tổng giá của các sản phẩm đang hiển thị, hoặc lọc danh sách này theo một tiêu chí phức tạp.

import React, { useMemo, useState } from 'react';

interface Product {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

// Giả lập một danh sách sản phẩm lớn
const allProducts: Product[] = Array.from({ length: 1000 }, (_, i) => ({
  id: i,
  name: `Sản phẩm ${i}`,
  price: Math.random() * 100 + 10,
  quantity: Math.floor(Math.random() * 10) + 1,
}));

function ProductList({ categoryFilter }: { categoryFilter: string }) {
  const [searchTerm, setSearchTerm] = useState('');

  // Sử dụng useMemo để chỉ tính toán lại danh sách sản phẩm đã lọc khi cần
  const filteredProducts = useMemo(() => {
    console.log('➡️ Đang thực hiện phép lọc sản phẩm...'); // Log này chỉ chạy khi dependencies thay đổi
    return allProducts.filter(product => {
      // Giả sử logic lọc phức tạp hơn ở đây...
      return product.name.includes(searchTerm) /* && product.category === categoryFilter */;
    });
  }, [searchTerm, categoryFilter]); // Dependencies: searchTerm và categoryFilter

  // Sử dụng useMemo để chỉ tính toán lại tổng giá khi danh sách lọc thay đổi
  const totalValue = useMemo(() => {
    console.log('💲 Đang tính toán lại tổng giá trị...'); // Log này chỉ chạy khi filteredProducts thay đổi
    return filteredProducts.reduce((sum, product) => sum + product.price * product.quantity, 0);
  }, [filteredProducts]); // Dependency: filteredProducts (là giá trị được memoize bởi useMemo khác)

  return (
    <div>
      <h1>Danh sách Sản phẩm</h1>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Tìm kiếm theo tên..."
      />
      {/* Thêm input cho categoryFilter nếu có */}
      <p>
        Tổng giá trị các sản phẩm hiển thị: **{totalValue.toFixed(2)}**
      </p>
      <ul>
        {filteredProducts.map(product => (
          <li key={product.id}>
            {product.name} - {product.price.toFixed(2)} x {product.quantity}
          </li>
        ))}
      </ul>
    </div>
  );
}

Giải thích ví dụ useMemo:

Trong ví dụ này, chúng ta có hai lần sử dụng useMemo:

  1. filteredProducts: Hook này bọc logic lọc danh sách allProducts. Hàm lọc này sẽ chỉ chạy lại khi searchTerm hoặc categoryFilter thay đổi. Nếu component ProductList re-render vì bất kỳ lý do nào khác (ví dụ: state nội bộ khác thay đổi mà không phải searchTerm), hàm lọc sẽ không chạy lại. useMemo trả về mảng filteredProducts đã được ghi nhớ.
  2. totalValue: Hook này tính tổng giá trị từ danh sách filteredProducts. Nó phụ thuộc vào filteredProducts. Bất cứ khi nào filteredProducts thay đổi (do searchTerm hoặc categoryFilter thay đổi và kích hoạt useMemo đầu tiên), useMemo thứ hai sẽ chạy lại để tính toán lại totalValue. Nếu filteredProducts không thay đổi, totalValue cũng sẽ giữ nguyên giá trị đã ghi nhớ.

Điều này đảm bảo rằng các phép tính tốn kém chỉ thực sự xảy ra khi dữ liệu đầu vào của chúng thay đổi, giúp ứng dụng phản ứng nhanh hơn, đặc biệt khi làm việc với các danh sách lớn hoặc các tính toán phức tạp khác.

useCallback: Ghi Nhớ Định Nghĩa Hàm

Trong khi useMemo ghi nhớ giá trị trả về từ một hàm, useCallback lại ghi nhớ chính định nghĩa hàm. Nó trả về một phiên bản ghi nhớ (memoized) của hàm callback được truyền vào. Phiên bản này chỉ thay đổi khi một trong các dependencies của nó thay đổi.

Cú pháp cơ bản:

import React, { useCallback } from 'react';

const memoizedCallback = useCallback(() => {
  // Logic của hàm callback
  console.log('Hàm callback đang được gọi...');
  doSomething(dependency1, dependency2);
}, [dependency1, dependency2]); // Mảng các giá trị dependencies
  • Tham số đầu tiên: Hàm mà bạn muốn memoize.
  • Tham số thứ hai: Mảng các dependencies. useCallback sẽ trả về một định nghĩa hàm mới khi và chỉ khi một trong các giá trị trong mảng này thay đổi.

Tại sao cần ghi nhớ định nghĩa hàm?

Như đã nói ở trên, trong JavaScript, mỗi khi một component functional re-render, tất cả các hàm được định nghĩa trực tiếp bên trong thân hàm component đó sẽ được tạo lại. Về mặt kỹ thuật, đây là một định nghĩa hàm mới trong bộ nhớ, ngay cả khi logic của hàm không đổi.

Điều này rất quan trọng khi bạn truyền hàm này xuống làm props cho một component con, đặc biệt là component con đó đã được tối ưu hóa bằng React.memo. React.memo là một HOC (Higher-Order Component) giúp ngăn component con re-render nếu các props của nó không thay đổi. Tuy nhiên, nếu một prop là hàm và hàm đó được tạo mới trên mỗi lần render của component cha (vì bạn không dùng useCallback), thì React.memo sẽ so sánh định nghĩa hàm cũ và mới, thấy chúng khác nhau (vì là hai đối tượng hàm khác nhau trong bộ nhớ), và quyết định rằng prop đã thay đổi. Kết quả là component con vẫn sẽ re-render, làm mất tác dụng của React.memo.

useCallback giải quyết vấn đề này bằng cách đảm bảo rằng prop hàm được truyền xuống component con được React.memo bọc là cùng một định nghĩa hàm giữa các lần render của component cha (miễn là dependencies không đổi), nhờ đó React.memo có thể hoạt động hiệu quả.

Ví dụ minh họa:

Chúng ta sẽ tạo một component button đơn giản và bọc nó bằng React.memo để tối ưu hóa. Sau đó, truyền một hàm xử lý click xuống nó.

import React, { useState, useCallback, memo } from 'react';

// Component con được tối ưu hóa bằng React.memo
interface MemoizedButtonProps {
  onClick: () => void;
  label: string;
}

const MemoizedButton = memo(({ onClick, label }: MemoizedButtonProps) => {
  // Log này chỉ nên xuất hiện khi component thực sự re-render (khi props thay đổi)
  console.log(`➡️ Đang render component "${label}"...`);
  return (
    <button
      onClick={onClick}
      style={{ margin: '5px', padding: '10px', cursor: 'pointer' }}
    >
      {label}
    </button>
  );
});

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [feedback, setFeedback] = useState('');

  // Hàm xử lý TĂNG count - Được bọc bởi useCallback
  const handleIncrement = useCallback(() => {
    console.log('⚙️ Hàm handleIncrement được gọi');
    setCount(prevCount => prevCount + 1); // Sử dụng updater function để không cần count trong dependencies
  }, []); // Dependency array rỗng: Hàm này chỉ được tạo ra một lần duy nhất sau render đầu tiên

  // Hàm xử lý gửi feedback - KHÔNG được bọc bởi useCallback (chỉ để so sánh)
  const handleSubmitFeedback = () => {
    console.log('⚙️ Hàm handleSubmitFeedback được gọi');
    // Giả lập gửi feedback
    alert(`Feedback đã gửi: Count hiện tại là ${count}`);
    setFeedback(`Feedback gửi lúc count = ${count}`);
  };

  console.log('➡️ Đang render ParentComponent...');

  return (
    <div>
      <h1>useCallback Demo</h1>
      <p>Count: **{count}**</p>
      <p>Feedback cuối cùng: *{feedback}*</p>

      {/* Truyền hàm handleIncrement đã được memoize */}
      <MemoizedButton onClick={handleIncrement} label="Tăng Count (useCallback)" />

      {/*
        Nếu bạn truyền handleSubmitFeedback cho MemoizedButton, nó sẽ khiến
        MemoizedButton re-render mỗi khi handleSubmitFeedback được tạo lại.
         handleSubmitFeedback được tạo lại mỗi khi ParentComponent re-render
        (ví dụ: khi count thay đổi) vì nó không dùng useCallback.
      */}
      <button
         onClick={handleSubmitFeedback}
         style={{ margin: '5px', padding: '10px', cursor: 'pointer' }}
      >
        Gửi Feedback (Không dùng useCallback)
      </button>
    </div>
  );
}

Giải thích ví dụ useCallback:

Trong ví dụ này:

  • Component MemoizedButton được bọc bởi React.memo, có nghĩa là nó sẽ không re-render trừ khi props của nó (onClicklabel) thay đổi.
  • Hàm handleIncrement được bọc bởi useCallback với dependency array rỗng []. Điều này có nghĩa là React sẽ trả về cùng một định nghĩa hàm handleIncrement này trên mọi lần re-render của ParentComponent.
  • Hàm handleSubmitFeedback không được bọc bởi useCallback. Do đó, mỗi khi ParentComponent re-render (ví dụ: khi count thay đổi do click button "Tăng Count"), handleSubmitFeedback sẽ được tạo lại như một định nghĩa hàm mới.

Khi bạn click vào nút "Tăng Count (useCallback)", state count thay đổi, ParentComponent re-render. handleIncrement vẫn là định nghĩa hàm cũ (nhờ useCallback), handleSubmitFeedback là định nghĩa hàm mới. Nếu chúng ta truyền handleIncrement cho MemoizedButton, React.memo thấy prop onClick không thay đổi nên MemoizedButton không re-render (log "Đang render component..." không xuất hiện). Tuy nhiên, nếu chúng ta truyền handleSubmitFeedback cho một MemoizedButton, thì React.memo sẽ thấy prop onClick là định nghĩa hàm mới, và component đó sẽ re-render.

Đây là lý do chính tại sao useCallback lại quan trọng khi làm việc với React.memo và các hooks có dependency array như useEffect hoặc useMemo khác, bởi vì nó giúp ổn định các dependencies là hàm.

Lưu ý về dependencies: Trong handleIncrement, chúng ta sử dụng setCount(prevCount => prevCount + 1). Đây là cách sử dụng updater function của setState. Nó nhận giá trị state trước đó làm đối số, giúp chúng ta tính toán state mới mà không cần phụ thuộc vào giá trị count hiện tại từ scope bên ngoài của hàm callback. Do đó, chúng ta có thể dùng dependency array rỗng [], làm cho hàm handleIncrement ổn định hơn nhiều. Nếu bạn cần truy cập các state hoặc prop khác bên trong hàm callback, bạn cần thêm chúng vào dependency array của useCallback. Luôn đảm bảo dependency array của bạn là chính xác!

useMemouseCallback: Điểm Khác Biệt Cốt Lõi

Sau khi tìm hiểu về cả hai, ta thấy rõ điểm khác biệt chính:

  • useMemo: Dùng để ghi nhớ kết quả của một hàm (một giá trị). Nó tránh việc tính toán lại giá trị đó trên mỗi lần render.
  • useCallback: Dùng để ghi nhớ định nghĩa của một hàm. Nó tránh việc tạo lại định nghĩa hàm đó trên mỗi lần render.

Một cách đơn giản để ghi nhớ: useMemo trả về một giá trị, còn useCallback trả về một hàm.

Thực tế, useCallback(fn, deps) về cơ bản tương đương với useMemo(() => fn, deps). useCallback chỉ là một cú pháp tiện lợi hơn và chuyên biệt cho trường hợp bạn muốn ghi nhớ một hàm.

Khi Nào Nên Sử Dụng (Và Khi Nào KHÔNG)

useMemouseCallback là những công cụ tối ưu hóa mạnh mẽ, nhưng chúng không phải là viên đạn bạc và không nên lạm dụng. Việc sử dụng chúng cũng có chi phí nhất định (bộ nhớ để lưu trữ giá trị/hàm đã ghi nhớ, thời gian CPU để so sánh dependencies).

Bạn nên cân nhắc sử dụng khi:

  1. Thực hiện các phép tính tốn kém: Lọc/sắp xếp các mảng lớn, tính toán phức tạp, xử lý dữ liệu nặng... Nếu việc tính toán này diễn ra thường xuyên và làm chậm ứng dụng, useMemo là lựa chọn tốt.
  2. Truyền callbacks xuống component con được memoize: Nếu bạn truyền một hàm xuống một component con được bọc bởi React.memo (hoặc sử dụng các kỹ thuật tối ưu hóa tương tự), hãy sử dụng useCallback cho hàm đó. Đây là trường hợp phổ biến và hiệu quả nhất của useCallback.
  3. Dependencies của các hooks khác: Đôi khi, các dependencies trong useEffect, useMemo, useCallback khác lại là các đối tượng hoặc hàm được tạo mới trên mỗi lần render. Sử dụng useMemo hoặc useCallback cho các dependencies này có thể giúp ổn định chúng, ngăn chặn các hook phụ thuộc chạy lại không cần thiết.

Bạn không nên lạm dụng khi:

  1. Các phép tính đơn giản: Chi phí để thiết lập và so sánh dependencies của useMemo có thể còn lớn hơn chi phí thực hiện lại một phép tính đơn giản. Đừng bọc useMemo cho những thứ như a + b hay 'string'.toLowerCase().
  2. Không có vấn đề về hiệu suất rõ ràng: Đừng tối ưu hóa quá sớm (premature optimization). Chỉ sử dụng useMemo hoặc useCallback khi bạn thực sự thấy hiệu suất bị ảnh hưởng (ví dụ: dùng React DevTools Profiler để đo lường) hoặc khi bạn biết chắc rằng việc tạo lại giá trị/hàm đó đang gây ra re-render không cần thiết cho các component con được memoize.
  3. Làm code phức tạp hơn một cách không cần thiết: Đôi khi, việc thêm useMemo/useCallback và quản lý dependency array có thể làm code khó đọc và hiểu hơn. Chỉ thêm chúng khi lợi ích về hiệu suất vượt trội so với chi phí về độ phức tạp.

Comments

There are no comments at the moment.