เชี่ยวชาญ React useCallback hook เรียนรู้ว่า Function Memoization คืออะไร, เมื่อไหร่ (และเมื่อไหร่ที่ไม่ควร) ที่จะใช้ และวิธีเพิ่มประสิทธิภาพของ Component
React useCallback: เจาะลึกการ Memoization ฟังก์ชันและการเพิ่มประสิทธิภาพ
ในโลกของการพัฒนาเว็บสมัยใหม่ React โดดเด่นในด้าน UI ที่ประกาศได้และโมเดลการเรนเดอร์ที่มีประสิทธิภาพ อย่างไรก็ตาม เมื่อแอปพลิเคชันมีความซับซ้อนมากขึ้น การสร้างความมั่นใจในประสิทธิภาพสูงสุดจึงกลายเป็นความรับผิดชอบที่สำคัญสำหรับนักพัฒนาทุกคน React มีชุดเครื่องมือที่มีประสิทธิภาพเพื่อจัดการกับความท้าทายเหล่านี้ และสิ่งที่สำคัญที่สุด และมักเข้าใจผิด คือ Optimization Hooks วันนี้ เราจะมาเจาะลึกหนึ่งในนั้น: useCallback
คู่มือฉบับสมบูรณ์นี้จะไขความลับของ useCallback hook เราจะสำรวจแนวคิด JavaScript พื้นฐานที่ทำให้จำเป็น ทำความเข้าใจไวยากรณ์และกลไก และที่สำคัญที่สุด กำหนดแนวทางที่ชัดเจนว่าเมื่อใดที่คุณควร และไม่ควร เอื้อมมือไปหยิบมันในโค้ดของคุณ เมื่อถึงตอนท้าย คุณจะพร้อมที่จะใช้ useCallback ไม่ใช่ในฐานะกระสุนวิเศษ แต่เป็นเครื่องมือที่แม่นยำในการทำให้แอปพลิเคชัน React ของคุณเร็วขึ้นและมีประสิทธิภาพมากขึ้น
ปัญหาหลัก: ทำความเข้าใจ Referential Equality
ก่อนที่เราจะชื่นชมสิ่งที่ useCallback ทำ เราต้องเข้าใจแนวคิดหลักใน JavaScript ก่อน: Referential Equality ใน JavaScript ฟังก์ชันคือออบเจ็กต์ ซึ่งหมายความว่าเมื่อคุณเปรียบเทียบสองฟังก์ชัน (หรือออบเจ็กต์สองรายการใดๆ) คุณไม่ได้เปรียบเทียบเนื้อหา แต่เป็นการอ้างอิง ตำแหน่งเฉพาะในหน่วยความจำ
พิจารณา JavaScript snippet ง่ายๆ นี้:
const func1 = () => { console.log('Hello'); };
const func2 = () => { console.log('Hello'); };
console.log(func1 === func2); // Outputs: false
แม้ว่า func1 และ func2 จะมีโค้ดที่เหมือนกัน แต่ก็เป็นออบเจ็กต์ฟังก์ชันสองชุดที่แยกจากกันซึ่งสร้างขึ้นที่แอดเดรสหน่วยความจำที่แตกต่างกัน ดังนั้นจึงไม่เท่ากัน
สิ่งนี้ส่งผลต่อ React Components อย่างไร
React Functional Component เป็นฟังก์ชันที่ทำงานทุกครั้งที่ Component ต้องการเรนเดอร์ สิ่งนี้เกิดขึ้นเมื่อสถานะมีการเปลี่ยนแปลง หรือเมื่อ Parent Component เรนเดอร์ใหม่ เมื่อฟังก์ชันนี้ทำงาน ทุกสิ่งภายใน รวมถึงตัวแปรและการประกาศฟังก์ชัน จะถูกสร้างขึ้นใหม่ตั้งแต่เริ่มต้น
มาดู Component ทั่วไป:
import React, { useState } from 'react';
const Counter = () => {
const [count, setCount] = useState(0);
// This function is re-created on every single render
const handleIncrement = () => {
console.log('Creating a new handleIncrement function');
setCount(count + 1);
};
return (
Count: {count}
);
};
ทุกครั้งที่คุณคลิกปุ่ม "Increment" สถานะ count จะเปลี่ยน ทำให้ Counter Component เรนเดอร์ใหม่ ในระหว่างการเรนเดอร์ใหม่แต่ละครั้ง ฟังก์ชัน handleIncrement ใหม่เอี่ยมจะถูกสร้างขึ้น สำหรับ Component ที่เรียบง่ายเช่นนี้ ผลกระทบต่อประสิทธิภาพนั้นน้อยมาก JavaScript Engine นั้นเร็วอย่างไม่น่าเชื่อในการสร้างฟังก์ชัน แล้วทำไมเราต้องกังวลเกี่ยวกับเรื่องนี้ด้วยซ้ำ?
เหตุใดการสร้างฟังก์ชันใหม่จึงกลายเป็นปัญหา
ปัญหาไม่ได้อยู่ที่การสร้างฟังก์ชันเอง แต่อยู่ที่ปฏิกิริยาลูกโซ่ที่อาจเกิดขึ้นเมื่อส่งเป็น prop ไปยัง Child Components โดยเฉพาะอย่างยิ่ง Component ที่ปรับให้เหมาะสมด้วย React.memo
React.memo คือ Higher-Order Component (HOC) ที่ Memoizes Component ทำงานโดยการเปรียบเทียบ Props ของ Component อย่างตื้นๆ หาก Props ใหม่เหมือนกับ Props เก่า React จะข้ามการเรนเดอร์ Component อีกครั้ง และใช้ผลลัพธ์ที่เรนเดอร์ล่าสุด นี่คือการเพิ่มประสิทธิภาพที่มีประสิทธิภาพสำหรับการป้องกัน Render Cycles ที่ไม่จำเป็น
ตอนนี้ มาดูกันว่าปัญหาเกี่ยวกับ Referential Equality ของเราเกิดขึ้นที่ไหน ลองจินตนาการว่าเรามี Parent Component ที่ส่ง Handler Function ไปยัง Memoized Child Component
import React, { useState } from 'react';
// A memoized child component that only re-renders if its props change.
const MemoizedButton = React.memo(({ onIncrement }) => {
console.log('MemoizedButton is rendering!');
return ;
});
const ParentComponent = () => {
const [count, setCount] = useState(0);
const [otherState, setOtherState] = useState(false);
// This function is re-created every time ParentComponent renders
const handleIncrement = () => {
setCount(count + 1);
};
return (
Parent Count: {count}
Other State: {String(otherState)}
);
};
ในตัวอย่างนี้ MemoizedButton ได้รับหนึ่ง Prop: onIncrement คุณอาจคาดหวังว่าเมื่อคุณคลิกปุ่ม "Toggle Other State" เฉพาะ ParentComponent เท่านั้นที่จะเรนเดอร์ใหม่เพราะ count ไม่มีการเปลี่ยนแปลง และดังนั้นฟังก์ชัน onIncrement ก็เหมือนกันในเชิงตรรกะ อย่างไรก็ตาม หากคุณเรียกใช้โค้ดนี้ คุณจะเห็น "MemoizedButton is rendering!" ในคอนโซลทุกครั้งที่คุณคลิก "Toggle Other State"
เหตุการณ์นี้เกิดขึ้นเพราะอะไร?
เมื่อ ParentComponent เรนเดอร์ใหม่ (เนื่องจาก setOtherState) จะสร้าง new instance ของฟังก์ชัน handleIncrement เมื่อ React.memo เปรียบเทียบ Props สำหรับ MemoizedButton จะเห็นว่า oldProps.onIncrement !== newProps.onIncrement เนื่องจากการอ้างอิงที่เท่ากัน ฟังก์ชันใหม่นี้อยู่ที่แอดเดรสหน่วยความจำที่แตกต่างกัน การตรวจสอบที่ไม่สำเร็จนี้บังคับให้ Child ที่ Memoized ของเราเรนเดอร์ใหม่ ทำให้วัตถุประสงค์ของ React.memo พ่ายแพ้อย่างสมบูรณ์
นี่คือสถานการณ์หลักที่ useCallback เข้ามาช่วยเหลือ
วิธีแก้ปัญหา: Memoizing ด้วย `useCallback`
useCallback hook ได้รับการออกแบบมาเพื่อแก้ปัญหานี้โดยเฉพาะ ช่วยให้คุณ Memoize คำจำกัดความของฟังก์ชันระหว่างการเรนเดอร์ ทำให้มั่นใจได้ว่า Referential Equality จะคงอยู่ เว้นแต่ว่า Dependency มีการเปลี่ยนแปลง
ไวยากรณ์
const memoizedCallback = useCallback(
() => {
// The function to memoize
doSomething(a, b);
},
[a, b], // The dependency array
);
- อาร์กิวเมนต์แรก: Inline Callback Function ที่คุณต้องการ Memoize
- อาร์กิวเมนต์ที่สอง: Dependency Array
useCallbackจะส่งคืนฟังก์ชันใหม่ก็ต่อเมื่อหนึ่งในค่าใน Array นี้มีการเปลี่ยนแปลงจากการเรนเดอร์ครั้งล่าสุด
มา Refactor ตัวอย่างก่อนหน้าของเราโดยใช้ useCallback:
import React, { useState, useCallback } from 'react';
const MemoizedButton = React.memo(({ onIncrement }) => {
console.log('MemoizedButton is rendering!');
return ;
});
const ParentComponent = () => {
const [count, setCount] = useState(0);
const [otherState, setOtherState] = useState(false);
// Now, this function is memoized!
const handleIncrement = useCallback(() => {
setCount(count + 1);
}, [count]); // Dependency: 'count'
return (
Parent Count: {count}
Other State: {String(otherState)}
);
};
ตอนนี้ เมื่อคุณคลิก "Toggle Other State" ParentComponent จะเรนเดอร์ใหม่ React จะเรียกใช้ useCallback hook เปรียบเทียบค่าของ count ใน Dependency Array กับค่าจากการเรนเดอร์ครั้งก่อน เนื่องจาก count ไม่มีการเปลี่ยนแปลง useCallback จะส่งคืน exact same function instance ที่ส่งคืนครั้งล่าสุด เมื่อ React.memo เปรียบเทียบ Props สำหรับ MemoizedButton พบว่า oldProps.onIncrement === newProps.onIncrement การตรวจสอบผ่าน และการเรนเดอร์ Child ที่ไม่จำเป็นจะถูกข้ามไปสำเร็จ! แก้ปัญหาแล้ว
เชี่ยวชาญ Dependency Array
Dependency Array เป็นส่วนที่สำคัญที่สุดของการใช้ useCallback อย่างถูกต้อง บอก React ว่าเมื่อใดที่ปลอดภัยในการสร้างฟังก์ชันใหม่ การทำผิดพลาดอาจนำไปสู่ Bug ที่ละเอียดอ่อนซึ่งยากต่อการติดตาม
Empty Array: `[]`
หากคุณระบุ Empty Dependency Array แสดงว่าคุณกำลังบอก React: "ฟังก์ชันนี้ไม่จำเป็นต้องสร้างใหม่ รุ่นจากการเรนเดอร์เริ่มต้นนั้นดีตลอดไป"
const stableFunction = useCallback(() => {
console.log('This will always be the same function');
}, []); // Empty array
สิ่งนี้สร้างการอ้างอิงที่เสถียรสูง แต่มาพร้อมกับข้อแม้ที่สำคัญ: ปัญหา "Stale Closure" Closure คือเมื่อฟังก์ชัน "จดจำ" ตัวแปรจาก Scope ที่สร้างขึ้น หาก Callback ของคุณใช้ State หรือ Props แต่คุณไม่ได้ระบุเป็น Dependency จะปิดค่าเริ่มต้น
ตัวอย่าง Stale Closure:
const StaleCounter = () => {
const [count, setCount] = useState(0);
const handleLogCount = useCallback(() => {
// This 'count' is the value from the initial render (0)
// because `count` is not in the dependency array.
console.log(`Current count is: ${count}`);
}, []); // WRONG! Missing dependency
return (
Count: {count}
);
};
ในตัวอย่างนี้ ไม่ว่าคุณจะคลิก "Increment" กี่ครั้ง การคลิก "Log Count" จะพิมพ์ "Current count is: 0" เสมอ ฟังก์ชัน handleLogCount ติดอยู่กับค่าของ count จากการเรนเดอร์ครั้งแรก เพราะ Dependency Array ว่างเปล่า
Correct Array: `[dep1, dep2, ...]`
ในการแก้ไขปัญหา Stale Closure คุณต้องรวมทุกตัวแปรจาก Component Scope (State, Props, ฯลฯ) ที่ฟังก์ชันของคุณใช้ภายใน Dependency Array
const handleLogCount = useCallback(() => {
console.log(`Current count is: ${count}`);
}, [count]); // CORRECT! Now it depends on count.
ตอนนี้ เมื่อใดก็ตามที่ count เปลี่ยนแปลง useCallback จะสร้างฟังก์ชัน handleLogCount ใหม่ที่ปิดค่าใหม่ของ count นี่คือวิธีที่ถูกต้องและปลอดภัยในการใช้ Hook
Pro Tip: ใช้แพ็กเกจ eslint-plugin-react-hooks เสมอ มีกฎ exhaustive-deps ที่จะเตือนคุณโดยอัตโนมัติหากคุณพลาด Dependency ใน Hook useCallback, useEffect หรือ useMemo ของคุณ นี่คือ Safety Net ที่ประเมินค่าไม่ได้
Advanced Patterns and Techniques
1. Functional Updates เพื่อหลีกเลี่ยง Dependency
บางครั้งคุณต้องการฟังก์ชันที่เสถียรที่อัปเดต State แต่คุณไม่ต้องการสร้างใหม่ทุกครั้งที่ State เปลี่ยนแปลง นี่เป็นเรื่องปกติสำหรับฟังก์ชันที่ส่งไปยัง Custom Hooks หรือ Context Providers คุณสามารถทำได้โดยใช้รูปแบบการอัปเดตฟังก์ชันของ State Setter
const handleIncrement = useCallback(() => {
// `setCount` can take a function that receives the previous state.
// This way, we don't need to depend on `count` directly.
setCount(prevCount => prevCount + 1);
}, []); // The dependency array can now be empty!
เมื่อใช้ setCount(prevCount => ...) ฟังก์ชันของเราไม่จำเป็นต้องอ่านตัวแปร count จาก Component Scope อีกต่อไป เนื่องจากไม่ได้ขึ้นอยู่กับสิ่งใด เราจึงสามารถใช้ Empty Dependency Array ได้อย่างปลอดภัย สร้างฟังก์ชันที่เสถียรอย่างแท้จริงสำหรับวงจรชีวิตทั้งหมดของ Component
2. การใช้ `useRef` สำหรับ Volatile Values
จะเกิดอะไรขึ้นหาก Callback ของคุณต้องการเข้าถึงค่าล่าสุดของ Prop หรือ State ที่เปลี่ยนแปลงบ่อยมาก แต่คุณไม่ต้องการทำให้ Callback ของคุณไม่เสถียร คุณสามารถใช้ `useRef` เพื่อเก็บ Mutable Reference ไปยังค่าล่าสุดโดยไม่ต้องทริกเกอร์การเรนเดอร์ใหม่
const VeryFrequentUpdates = ({ onEvent }) => {
const [value, setValue] = useState('');
// Keep a ref to the latest version of the onEvent callback
const onEventRef = useRef(onEvent);
useEffect(() => {
onEventRef.current = onEvent;
}, [onEvent]);
// This internal callback can be stable
const handleInternalAction = useCallback(() => {
// ...some internal logic...
// Call the latest version of the prop function via the ref
if (onEventRef.current) {
onEventRef.current();
}
}, []); // Stable function
// ...
};
นี่คือ Advanced Pattern แต่มีประโยชน์ในสถานการณ์ที่ซับซ้อน เช่น Debouncing, Throttling หรือ Interfacing กับ Third-Party Libraries ที่ต้องใช้ Stable Callback References
คำแนะนำที่สำคัญ: เมื่อใดที่ไม่ควรใช้ `useCallback`
ผู้มาใหม่ใน React Hooks มักจะตกหลุมพรางในการห่อหุ้มทุกฟังก์ชันใน useCallback นี่คือ Anti-Pattern ที่เรียกว่า Premature Optimization โปรดจำไว้ว่า useCallback ไม่ได้ฟรี มีค่าใช้จ่ายด้านประสิทธิภาพ
ค่าใช้จ่ายของ `useCallback`
- Memory: ต้องจัดเก็บ Memoized Function ไว้ใน Memory
- Computation: ในทุกๆ Render React ยังคงต้องเรียก Hook และเปรียบเทียบรายการใน Dependency Array กับค่าก่อนหน้า
ในหลายกรณี ค่าใช้จ่ายนี้อาจมากกว่าประโยชน์ที่ได้รับ Overhead ของการเรียก Hook และการเปรียบเทียบ Dependency อาจมากกว่าค่าใช้จ่ายในการสร้างฟังก์ชันใหม่ และปล่อยให้ Child Component เรนเดอร์ใหม่
อย่าใช้ `useCallback` เมื่อ:
- ฟังก์ชันถูกส่งไปยัง Native HTML Element: Components เช่น
<div>,<button>หรือ<input>ไม่สนใจ Referential Equality สำหรับ Event Handlers การส่งฟังก์ชันใหม่ไปยังonClickในทุกๆ Render เป็นเรื่องที่สมบูรณ์แบบและไม่มีผลกระทบต่อประสิทธิภาพ - Component ที่ได้รับไม่ได้ Memoized: หากคุณส่ง Callback ไปยัง Child Component ที่ not Wrapped ใน
React.memoการ Memoize Callback นั้นไม่มีประโยชน์ Child Component จะเรนเดอร์ใหม่ทุกครั้งที่ Parent เรนเดอร์ - ฟังก์ชันถูกกำหนดและใช้ภายใน Render Cycle ของ Component เดียว: หากฟังก์ชันไม่ได้ถูกส่งลงเป็น Prop หรือใช้เป็น Dependency ใน Hook อื่นๆ ก็ไม่มีเหตุผลที่จะ Memoize Reference
// NO need for useCallback here
const handleClick = () => { console.log('Clicked!'); };
return ;
The Golden Rule: ใช้ useCallback เป็น Targeted Optimization เท่านั้น ใช้ React DevTools Profiler เพื่อระบุ Components ที่กำลังเรนเดอร์ใหม่โดยไม่จำเป็น หากคุณพบ Component ที่ Wrapped ใน React.memo ที่ยังคงเรนเดอร์ใหม่เนื่องจาก Unstable Callback Prop นั่นคือเวลาที่เหมาะสมในการใช้ useCallback
`useCallback` vs. `useMemo`: ความแตกต่างที่สำคัญ
อีกประเด็นหนึ่งที่ทำให้เกิดความสับสนคือความแตกต่างระหว่าง useCallback และ useMemo พวกเขาคล้ายกันมาก แต่มีจุดประสงค์ที่แตกต่างกัน
useCallback(fn, deps)Memoizes Function Instance ให้คุณกลับมาที่ Function Object เดียวกันระหว่างการเรนเดอร์useMemo(() => value, deps)Memoizes Return Value ของฟังก์ชัน เรียกใช้ฟังก์ชันและให้ผลลัพธ์แก่คุณ คำนวณใหม่เมื่อ Dependency เปลี่ยนแปลงเท่านั้น
โดยพื้นฐานแล้ว `useCallback(fn, deps)` เป็นเพียง Syntactic Sugar สำหรับ `useMemo(() => fn, deps)` เป็น Convenience Hook สำหรับ Use Case เฉพาะของการ Memoizing Functions
เมื่อใดควรใช้อะไร
- ใช้
useCallbackสำหรับฟังก์ชันที่คุณส่งไปยัง Child Components เพื่อป้องกันการเรนเดอร์ใหม่ที่ไม่จำเป็น (เช่น Event Handlers เช่นonClick,onSubmit) - ใช้
useMemoสำหรับการคำนวณที่ซับซ้อน เช่น การกรอง Dataset ขนาดใหญ่ การแปลงข้อมูลที่ซับซ้อน หรือค่าใดๆ ที่ใช้เวลานานในการคำนวณและไม่ควรคำนวณใหม่ในทุกๆ Render
// Use case for useMemo: Expensive calculation
const visibleTodos = useMemo(() => {
console.log('Filtering list...'); // This is expensive
return todos.filter(t => t.status === filter);
}, [todos, filter]);
// Use case for useCallback: Stable event handler
const handleAddTodo = useCallback((text) => {
dispatch({ type: 'ADD_TODO', text });
}, []); // Stable dispatch function
return (
);
บทสรุปและแนวทางปฏิบัติที่ดีที่สุด
useCallback hook เป็นเครื่องมือที่มีประสิทธิภาพในชุดเครื่องมือเพิ่มประสิทธิภาพ React ของคุณ แก้ไขปัญหา Referential Equality โดยตรง ช่วยให้คุณสามารถทำให้ Function Props เสถียรและปลดล็อกศักยภาพสูงสุดของ `React.memo` และ Hooks อื่นๆ เช่น `useEffect`
Key Takeaways:
- Purpose:
useCallbackส่งคืน Memoized Version ของ Callback Function ที่จะเปลี่ยนก็ต่อเมื่อหนึ่งใน Dependency มีการเปลี่ยนแปลง - Primary Use Case: เพื่อป้องกันการเรนเดอร์ใหม่ที่ไม่จำเป็นของ Child Components ที่ Wrapped ใน
React.memo - Secondary Use Case: เพื่อให้ Stable Function Dependency สำหรับ Hooks อื่นๆ เช่น
useEffectเพื่อป้องกันไม่ให้รันในทุกๆ Render - The Dependency Array is Crucial: รวมตัวแปร Component-Scoped ทั้งหมดที่ฟังก์ชันของคุณขึ้นอยู่ด้วยเสมอ ใช้กฎ `exhaustive-deps` ESLint เพื่อบังคับใช้สิ่งนี้
- Its an Optimization, Not a Default: อย่าห่อหุ้มทุกฟังก์ชันใน
useCallbackสิ่งนี้สามารถทำร้ายประสิทธิภาพและเพิ่มความซับซ้อนที่ไม่จำเป็น โปรไฟล์แอปพลิเคชันของคุณก่อน และใช้การเพิ่มประสิทธิภาพอย่างมีกลยุทธ์ในที่ที่ต้องการมากที่สุด
ด้วยการทำความเข้าใจ "Why" เบื้องหลัง useCallback และการปฏิบัติตามแนวทางปฏิบัติที่ดีที่สุดเหล่านี้ คุณสามารถก้าวข้ามการคาดเดาและเริ่มทำการปรับปรุงประสิทธิภาพที่ส่งผลกระทบและมีข้อมูลครบถ้วนในแอปพลิเคชัน React ของคุณ สร้างประสบการณ์ผู้ใช้ที่ไม่เพียงแต่มีคุณสมบัติครบถ้วน แต่ยังราบรื่นและตอบสนองอีกด้วย