Mục lục
Xem mục lục
Vì sao component re-render
Một component React re-render vì một trong bốn nguyên nhân:
- State thay đổi: gọi
setState(hoặcuseState/useReducersetter) khiến component đó re-render. - Props thay đổi: component cha truyền props mới xuống.
- Component cha re-render: mặc định, khi component cha re-render, mọi component con đều re-render theo, kể cả khi props truyền xuống không đổi giá trị.
- Context thay đổi: khi giá trị trong
React.Context.Providerđổi, tất cả các component đăng ký dùng context đó (useContext) đều sẽ bị ép re-render.
Ba nguyên nhân đầu quen thuộc với hầu hết người dùng React. Nguyên nhân thứ tư, Context, thường bị bỏ sót và là nguồn re-render thừa phổ biến trong ứng dụng thực tế, được nói kỹ ở phần sau.
Xác định vấn đề trước khi tối ưu
Trước khi áp dụng bất kỳ kỹ thuật nào, cần xác định thực sự có vấn đề hiệu năng hay không, và vấn đề nằm ở đâu. React DevTools có tab Profiler, ghi lại thời gian render của từng component trong một lượt tương tác, cho biết component nào render, render mất bao lâu, và vì lý do gì (state, props, hooks, hay parent re-render).
Tối ưu khi chưa đo là tối ưu theo cảm tính: dễ tốn công sức vào chỗ không ảnh hưởng gì tới trải nghiệm người dùng, trong khi chỗ thực sự chậm lại không được chạm tới. Nguyên tắc chung: đo trước, tối ưu đúng chỗ đang chậm, đo lại sau khi sửa để xác nhận có cải thiện thật.
Memoization: React.memo, useMemo, useCallback
React.memo
React.memo bọc một component, ngăn nó re-render khi component cha re-render nhưng props truyền xuống không đổi:
const ExpensiveItem = React.memo(function ExpensiveItem({ item }) {
console.log("render", item.id);
return <div>{item.name}</div>;
});
React.memo so sánh props bằng shallow compare: so từng field một cấp, không so sâu vào bên trong object/array. Cạm bẫy thường gặp: truyền object, array, hoặc function được tạo mới mỗi lần render làm props, khiến shallow compare luôn thấy props “khác nhau”, memo mất tác dụng:
// object literal mới mỗi lần render, memo không có tác dụng
<ExpensiveItem item={{ id: 1, name: "A" }} />
// function mới mỗi lần render, cũng vô hiệu hóa memo nếu truyền làm prop
<ExpensiveItem onClick={() => handleClick(1)} />
useMemo
useMemo cache lại giá trị của một phép tính tốn kém, chỉ tính lại khi dependency thay đổi:
const sortedList = useMemo(() => {
return items.slice().sort((a, b) => a.value - b.value);
}, [items]);
Nếu không có useMemo, sortedList được tính lại từ đầu ở mỗi lần render, kể cả khi items không đổi. Với danh sách lớn hoặc phép tính phức tạp, chi phí tính lại không cần thiết này có thể đáng kể.
useCallback
useCallback cache lại tham chiếu của một function, tránh tạo function mới mỗi lần render:
const handleClick = useCallback(id => {
setSelectedId(id);
}, []);
useCallback hiếm khi có ích một mình. Giá trị thật của nó nằm ở việc kết hợp với React.memo: nếu function được truyền làm prop xuống một component đã bọc memo, không dùng useCallback sẽ khiến function đó luôn là tham chiếu mới, vô hiệu hóa memo như cạm bẫy đã nêu ở mục trước.
const MemoButton = React.memo(function MemoButton({ onClick }) {
return <button onClick={onClick}>Chọn</button>;
});
function List({ items }) {
const handleClick = useCallback(id => setSelectedId(id), []);
return items.map(item => (
<MemoButton key={item.id} onClick={() => handleClick(item.id)} />
));
}
Cạm bẫy: lạm dụng useMemo/useCallback
useMemo và useCallback không miễn phí: mỗi lần render, React vẫn phải so sánh mảng dependency, và giữ lại giá trị cache trong bộ nhớ. Với phép tính đơn giản (cộng hai số, tạo một object nhỏ), chi phí so sánh dependency có thể còn tốn hơn chi phí tính lại trực tiếp.
Chỉ nên dùng hai hook này khi có lý do cụ thể:
- Phép tính thực sự tốn kém (lọc/sắp xếp danh sách lớn, tính toán phức tạp).
- Giá trị hoặc function được truyền xuống một component đã bọc
React.memo, cần giữ tham chiếu ổn định đểmemophát huy tác dụng. - Giá trị nằm trong dependency array của một hook khác (
useEffect,useMemokhác), cần giữ ổn định để tránh effect chạy lại không cần thiết.
Bọc useMemo/useCallback cho mọi thứ mà không có lý do cụ thể chỉ làm code rối hơn mà không cải thiện hiệu năng, đôi khi còn chậm hơn.
Context gây re-render toàn bộ consumer
Khi giá trị của một Context.Provider thay đổi, mọi component đang gọi useContext với context đó đều re-render, bất kể component có dùng tới phần dữ liệu vừa thay đổi hay không.
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
const value = useMemo(
() => ({ user, theme, setUser, setTheme }),
[user, theme]
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
Dù value đã được bọc useMemo, nó vẫn đổi mỗi khi user hoặc theme đổi, vì cả hai cùng nằm trong một object context duy nhất. Kết quả: một component chỉ đọc theme vẫn re-render mỗi khi user thay đổi, dù không liên quan gì tới theme. useMemo ở đây chỉ tránh tạo object value thừa khi cả user và theme đều không đổi, không giải quyết được việc gộp chung nhiều state độc lập vào một context.
Cách giảm re-render do Context
Cách xử lý phổ biến nhất: tách context lớn thành nhiều context nhỏ, theo từng phần dữ liệu độc lập:
const UserContext = createContext(null);
const ThemeContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
return (
<UserContext.Provider value={{ user, setUser }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
</UserContext.Provider>
);
}
Component chỉ đọc ThemeContext giờ không còn re-render khi user thay đổi, vì hai context đã tách rời hoàn toàn.
Một cách khác, phù hợp khi một state đổi liên tục (ví dụ vị trí con trỏ chuột) còn state kia gần như không đổi: tách riêng theo tần suất thay đổi, không chỉ theo loại dữ liệu, để phần ít đổi không bị kéo theo re-render bởi phần đổi liên tục.
Với ứng dụng lớn, nhiều component chỉ cần một phần nhỏ của context, có thể cân nhắc thư viện quản lý state ngoài Context (Zustand, Jotai…), vốn hỗ trợ sẵn cơ chế subscribe theo từng phần dữ liệu (selector), tránh re-render toàn bộ consumer như cơ chế mặc định của Context.
key trong danh sách
React dùng key để nhận diện phần tử nào trong danh sách đã thêm, xóa, hay đổi vị trí giữa hai lần render, từ đó quyết định phần tử nào cần re-render, phần tử nào giữ nguyên.
Dùng index của mảng làm key là cạm bẫy phổ biến:
{
items.map((item, index) => <Item key={index} data={item} />);
}
Khi danh sách chỉ thêm/xóa ở cuối, dùng index không gây vấn đề. Nhưng khi thứ tự phần tử thay đổi (xóa phần tử ở giữa, sắp xếp lại), index của các phần tử còn lại bị lệch, khiến React nhận diện sai phần tử nào là phần tử nào. Hậu quả không chỉ là re-render thừa, mà còn có thể gây bug thực sự: state cục bộ bên trong từng Item (như giá trị input đang gõ dở) bị gán nhầm sang phần tử khác sau khi danh sách thay đổi thứ tự.
Cách đúng là dùng một giá trị ổn định, duy nhất, gắn liền với dữ liệu (thường là id từ database), không phụ thuộc vị trí trong mảng:
{
items.map(item => <Item key={item.id} data={item} />);
}
Code Splitting và lazy/Suspense
Mặc định, toàn bộ code JavaScript của ứng dụng được tải về ngay từ đầu, kể cả phần chưa dùng tới ngay (một trang settings ít khi vào, một modal chỉ mở khi cần). Code Splitting chia bundle thành nhiều phần nhỏ, chỉ tải phần cần thiết cho lần render đầu tiên, tải thêm các phần khác khi thực sự cần.
const SettingsPage = lazy(() => import("./SettingsPage"));
function App() {
return (
<Suspense fallback={<p>Đang tải...</p>}>
<SettingsPage />
</Suspense>
);
}
lazy khai báo một component sẽ được tải theo kiểu dynamic import, chỉ tải file JS tương ứng khi component đó thực sự cần render lần đầu. Suspense hiển thị fallback trong lúc chờ tải xong. Kết quả là bundle ban đầu nhỏ hơn, thời gian tải và tương tác lần đầu (thường đo bằng Time to Interactive) nhanh hơn, đổi lại các phần được tách sẽ có một khoảng chờ tải riêng khi lần đầu được dùng tới.
Virtualization cho danh sách dài
Render một danh sách vài nghìn phần tử cùng lúc, kể cả khi phần lớn đang nằm ngoài màn hình, khiến trình duyệt phải tạo và giữ hàng nghìn DOM node, tốn bộ nhớ và làm chậm cả lần render đầu lẫn các thao tác cuộn.
Virtualization (hay windowing) chỉ render các phần tử đang thực sự nằm trong viewport (cộng thêm một vùng đệm nhỏ), các phần tử còn lại không được tạo DOM node cho tới khi cuộn tới gần. Thư viện như react-window hoặc react-virtualized triển khai sẵn kỹ thuật này:
import { FixedSizeList } from "react-window";
function BigList({ items }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={40}
width="100%"
>
{({ index, style }) => <div style={style}>{items[index].name}</div>}
</FixedSizeList>
);
}
Với danh sách vài chục tới vài trăm phần tử, virtualization thường không cần thiết, chi phí quản lý thêm một thư viện có thể không đáng. Nó phát huy tác dụng rõ rệt nhất khi danh sách lên tới hàng nghìn phần tử trở lên.
Tổng kết
Thứ tự ưu tiên khi tối ưu hiệu năng React trong thực tế:
- Đo trước khi tối ưu, dùng React DevTools Profiler để xác định component nào thực sự chậm và vì sao.
- Kiểm tra Context: nếu nhiều component không liên quan cùng re-render theo nhau, khả năng cao do dùng chung một context lớn, cân nhắc tách nhỏ theo từng phần dữ liệu.
React.memocho component tốn chi phí render, đi kèm chú ý props phải là tham chiếu ổn định (dùnguseMemo/useCallbackkhi cần).useMemo/useCallbackchỉ cho phép tính tốn kém hoặc khi cần giữ tham chiếu ổn định chomemo/dependency array, không lạm dụng cho mọi thứ.keyđúng chuẩn: dùng id ổn định, tránh dùng index khi danh sách có thể đổi thứ tự.- Code Splitting cho các phần ít dùng ngay từ đầu, giảm kích thước bundle ban đầu.
- Virtualization cho danh sách dài hàng nghìn phần tử trở lên.
Tối ưu hiệu năng luôn đi kèm đánh đổi: thêm code, thêm độ phức tạp, đôi khi thêm cả chi phí (như thư viện virtualization). Chỉ nên áp dụng khi đã đo và xác định rõ có vấn đề thật, ở đúng chỗ đang chậm.