สำรวจความแตกต่างของการเพิ่มประสิทธิภาพ React ref callback เรียนรู้ว่าทำไมมันถึงทำงานสองครั้ง วิธีป้องกันด้วย useCallback และเพิ่มประสิทธิภาพสำหรับแอปที่ซับซ้อน
การเรียนรู้ React Ref Callbacks อย่างเชี่ยวชาญ: คู่มือฉบับสมบูรณ์เพื่อเพิ่มประสิทธิภาพ
ในโลกของการพัฒนาเว็บสมัยใหม่ ประสิทธิภาพไม่ใช่แค่คุณสมบัติ แต่เป็นสิ่งจำเป็น สำหรับนักพัฒนาที่ใช้ React การสร้างส่วนต่อประสานผู้ใช้ที่รวดเร็วและตอบสนองได้ดีคือเป้าหมายหลัก แม้ว่า virtual DOM และอัลกอริทึมการกระทบยอดของ React จะจัดการงานหนักส่วนใหญ่ แต่ก็มีรูปแบบและ APIs เฉพาะที่ความเข้าใจอย่างลึกซึ้งเป็นสิ่งสำคัญสำหรับการปลดล็อกประสิทธิภาพสูงสุด หนึ่งในนั้นคือการจัดการ refs โดยเฉพาะอย่างยิ่งพฤติกรรมที่มักเข้าใจผิดของ callback refs
Refs เป็นวิธีในการเข้าถึง DOM nodes หรือ React elements ที่สร้างขึ้นใน render method ซึ่งเป็นช่องทางหลบหนีที่จำเป็นสำหรับงานต่างๆ เช่น การจัดการโฟกัส การทริกเกอร์แอนิเมชั่น หรือการรวมเข้ากับไลบรารี DOM ของบุคคลที่สาม ในขณะที่ useRef ได้กลายเป็นมาตรฐานสำหรับกรณีง่ายๆ ใน functional components callback refs ให้การควบคุมที่ละเอียดยิ่งขึ้นว่าเมื่อใดที่ reference ถูกตั้งค่าและยกเลิก อย่างไรก็ตาม พลังนี้มาพร้อมกับความละเอียดอ่อน: callback ref สามารถทำงานได้หลายครั้งในระหว่างวงจรชีวิตของคอมโพเนนต์ ซึ่งอาจนำไปสู่ปัญหาคอขวดด้านประสิทธิภาพและข้อบกพร่องหากจัดการไม่ถูกต้อง
คู่มือที่ครอบคลุมนี้จะไขความลับของ React ref callback เราจะสำรวจ:
- Callback refs คืออะไรและแตกต่างจาก ref ประเภทอื่นอย่างไร
- เหตุผลหลักที่ callback refs ถูกเรียกสองครั้ง (ครั้งหนึ่งด้วย
nullและอีกครั้งด้วย element) - ข้อเสียด้านประสิทธิภาพของการใช้ inline functions สำหรับ ref callbacks
- โซลูชันที่ชัดเจนสำหรับการเพิ่มประสิทธิภาพโดยใช้
useCallbackhook - รูปแบบขั้นสูงสำหรับการจัดการ dependencies และการรวมเข้ากับ external libraries
เมื่อสิ้นสุดบทความนี้ คุณจะมีความรู้ในการใช้ callback refs ได้อย่างมั่นใจ ทำให้มั่นใจได้ว่าแอปพลิเคชัน React ของคุณจะไม่เพียงแต่แข็งแกร่ง แต่ยังมีประสิทธิภาพสูงอีกด้วย
การทบทวนอย่างรวดเร็ว: Callback Refs คืออะไร
ก่อนที่เราจะเจาะลึกลงไปในการเพิ่มประสิทธิภาพ เรามาทบทวนสั้นๆ ว่า callback ref คืออะไร แทนที่จะส่ง ref object ที่สร้างโดย useRef() หรือ React.createRef() คุณส่งฟังก์ชันไปยัง ref attribute ฟังก์ชันนี้จะถูก React ดำเนินการเมื่อคอมโพเนนต์ mount และ unmount
React จะเรียก callback ref ด้วย DOM element เป็นอาร์กิวเมนต์เมื่อคอมโพเนนต์ mount และจะเรียกมันด้วย null เป็นอาร์กิวเมนต์เมื่อคอมโพเนนต์ unmount สิ่งนี้ทำให้คุณควบคุมได้อย่างแม่นยำในขณะที่ reference พร้อมใช้งานหรือกำลังจะถูกทำลาย
นี่คือตัวอย่างง่ายๆ ใน functional component:
import React, { useState } from 'react';
function TextInputWithFocusButton() {
let textInput = null;
const setTextInputRef = element => {
console.log('Ref callback fired with:', element);
textInput = element;
};
const focusTextInput = () => {
// Focus the text input using the raw DOM API
if (textInput) textInput.focus();
};
return (
<div>
<input type="text" ref={setTextInputRef} />
<button onClick={focusTextInput}>
Focus the text input
</button>
</div>
);
}
ในตัวอย่างนี้ setTextInputRef คือ callback ref ของเรา มันจะถูกเรียกด้วย <input> element เมื่อมันถูก render ทำให้เราสามารถจัดเก็บและใช้มันในภายหลังเพื่อเรียก focus() ได้
ปัญหาหลัก: ทำไม Ref Callbacks ถึงทำงานสองครั้ง
พฤติกรรมส่วนกลางที่มักทำให้นักพัฒนาสับสนคือการเรียก callback สองครั้ง เมื่อคอมโพเนนต์ที่มี callback ref render ฟังก์ชัน callback มักจะถูกเรียกสองครั้งติดต่อกัน:
- การเรียกครั้งแรก: ด้วย
nullเป็นอาร์กิวเมนต์ - การเรียกครั้งที่สอง: ด้วย DOM element instance เป็นอาร์กิวเมนต์
นี่ไม่ใช่ข้อผิดพลาด แต่เป็นการเลือกการออกแบบโดยเจตนาของทีม React การเรียกด้วย null หมายความว่า ref ก่อนหน้า (ถ้ามี) กำลังถูกถอดออก สิ่งนี้ให้โอกาสที่สำคัญแก่คุณในการดำเนินการ cleanup operations ตัวอย่างเช่น หากคุณแนบ event listener เข้ากับ node ใน render ก่อนหน้า การเรียก null คือช่วงเวลาที่สมบูรณ์แบบในการลบมันก่อนที่จะแนบ node ใหม่
อย่างไรก็ตาม ปัญหาไม่ใช่ mount/unmount cycle นี้ ปัญหาด้านประสิทธิภาพที่แท้จริงเกิดขึ้นเมื่อ double-firing นี้เกิดขึ้นใน ทุกๆ การ re-render แม้ว่า state ของคอมโพเนนต์จะอัปเดตในลักษณะที่ไม่เกี่ยวข้องกับ ref เองเลยก็ตาม
ข้อเสียของ Inline Functions
ลองพิจารณาการใช้งานที่ดูเหมือนไม่มีพิษภัยนี้ภายใน functional component ที่ re-renders:
import React, { useState } from 'react';
function FrequentUpdatesComponent() {
const [count, setCount] = useState(0);
return (
<div>
<h3>Counter: {count}</h3>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<div
ref={(node) => {
// This is an inline function!
console.log('Ref callback fired with:', node);
}}
>
I am the referenced element.
</div>
</div>
);
}
หากคุณเรียกใช้โค้ดนี้และคลิกปุ่ม "Increment" คุณจะเห็นสิ่งต่อไปนี้ในคอนโซลของคุณใน ทุกๆ การคลิก:
Ref callback fired with: null
Ref callback fired with: <div>...</div>
ทำไมสิ่งนี้ถึงเกิดขึ้น เพราะในแต่ละ render คุณกำลังสร้าง function instance ใหม่เอี่ยม สำหรับ ref prop: (node) => { ... } ในระหว่าง reconciliation process React จะเปรียบเทียบ props จาก render ก่อนหน้ากับ render ปัจจุบัน มันเห็นว่า ref prop เปลี่ยนไป (จาก function instance เก่าเป็น function instance ใหม่) สัญญาของ React ชัดเจน: หาก ref callback เปลี่ยนไป ก่อนอื่นจะต้องล้าง ref เก่าโดยการเรียกมันด้วย null จากนั้นตั้งค่า ref ใหม่โดยการเรียกมันด้วย DOM node สิ่งนี้จะทริกเกอร์ cleanup/setup cycle โดยไม่จำเป็นในทุกๆ render
สำหรับ console.log ง่ายๆ นี่เป็นผลกระทบต่อประสิทธิภาพเพียงเล็กน้อย แต่ลองนึกภาพว่า callback ของคุณทำสิ่งที่แพง:
- การแนบและถอด complex event listeners (เช่น `scroll`, `resize`)
- การเริ่มต้น heavy third-party library (เช่น D3.js chart หรือ mapping library)
- การดำเนินการ DOM measurements ที่ทำให้เกิด layout reflows
การดำเนินการ logic นี้ในทุกๆ state update สามารถลดประสิทธิภาพของแอปพลิเคชันของคุณลงอย่างมากและนำไปสู่ข้อบกพร่องที่ละเอียดอ่อนและติดตามได้ยาก
โซลูชัน: Memoizing ด้วย `useCallback`
โซลูชันสำหรับปัญหานี้คือการตรวจสอบให้แน่ใจว่า React ได้รับ function instance ที่เหมือนกันทุกประการ สำหรับ ref callback ข้าม re-renders เว้นแต่เราต้องการให้มันเปลี่ยนอย่างชัดเจน นี่คือ use case ที่สมบูรณ์แบบสำหรับ useCallback hook
useCallback ส่งคืน memoized version ของ callback function memoized version นี้จะเปลี่ยนเฉพาะเมื่อหนึ่งใน dependencies ใน dependency array เปลี่ยนไป โดยการให้ empty dependency array ([]) เราสามารถสร้าง stable function ที่คงอยู่ตลอดอายุการใช้งานของคอมโพเนนต์
มา refactor ตัวอย่างก่อนหน้าของเราโดยใช้ useCallback:
import React, { useState, useCallback } from 'react';
function OptimizedComponent() {
const [count, setCount] = useState(0);
// Create a stable callback function with useCallback
const myRefCallback = useCallback(node => {
// This logic now runs only when the component mounts and unmounts
console.log('Ref callback fired with:', node);
if (node !== null) {
// You can perform setup logic here
console.log('Element is mounted!');
}
}, []); // <-- Empty dependency array means the function is created only once
return (
<div>
<h3>Counter: {count}</h3>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<div ref={myRefCallback}>
I am the referenced element.
</div>
</div>
);
}
ตอนนี้ เมื่อคุณเรียกใช้ optimized version นี้ คุณจะเห็น console log เพียงสองครั้งเท่านั้น:
- ครั้งเดียวเมื่อคอมโพเนนต์ mount ครั้งแรก (
Ref callback fired with: <div>...</div>) - ครั้งเดียวเมื่อคอมโพเนนต์ unmount (
Ref callback fired with: null)
การคลิกปุ่ม "Increment" จะไม่ทริกเกอร์ ref callback อีกต่อไป เราได้ป้องกัน cleanup/setup cycle ที่ไม่จำเป็นในทุกๆ re-render สำเร็จแล้ว React เห็น function instance เดียวกันสำหรับ ref prop ใน subsequent renders และกำหนดอย่างถูกต้องว่าไม่จำเป็นต้องมีการเปลี่ยนแปลงใดๆ
Advanced Scenarios และ Best Practices
แม้ว่า empty dependency array จะเป็นเรื่องปกติ แต่ก็มีสถานการณ์ที่ ref callback ของคุณจำเป็นต้องตอบสนองต่อการเปลี่ยนแปลงใน props หรือ state นี่คือที่ที่พลังของ dependency array ของ useCallback ส่องประกายอย่างแท้จริง
การจัดการ Dependencies ใน Callback ของคุณ
ลองนึกภาพว่าคุณต้องเรียกใช้ logic บางอย่างภายใน ref callback ของคุณที่ขึ้นอยู่กับ state หรือ prop ตัวอย่างเช่น การตั้งค่า `data-` attribute ตาม theme ปัจจุบัน
function ThemedComponent({ theme }) {
const [internalState, setInternalState] = useState(0);
const themedRefCallback = useCallback(node => {
if (node !== null) {
// This callback now depends on the 'theme' prop
console.log(`Setting theme attribute to: ${theme}`);
node.setAttribute('data-theme', theme);
}
}, [theme]); // <-- Add 'theme' to the dependency array
return (
<div>
<p>Current Theme: {theme}</p>
<div ref={themedRefCallback}>This element's theme will update.</div>
{/* ... imagine a button here to change the parent's theme ... */}
</div>
);
}
ในตัวอย่างนี้ เราได้เพิ่ม theme ลงใน dependency array ของ useCallback ซึ่งหมายความว่า:
themedRefCallbackfunction ใหม่จะถูกสร้างขึ้น เฉพาะเมื่อthemeprop เปลี่ยนไป- เมื่อ
themeprop เปลี่ยนไป React จะตรวจพบ function instance ใหม่และ re-run ref callback (ครั้งแรกด้วยnullจากนั้นด้วย element) - สิ่งนี้ช่วยให้ effect ของเรา ซึ่งก็คือการตั้งค่า `data-theme` attribute ทำงานใหม่ด้วย
themevalue ที่อัปเดต
นี่คือพฤติกรรมที่ถูกต้องและตั้งใจ เรากำลังบอก React อย่างชัดเจนให้ re-trigger ref logic เมื่อ dependencies เปลี่ยนไป ในขณะที่ยังคงป้องกันไม่ให้มันทำงานในการอัปเดต state ที่ไม่เกี่ยวข้อง
การรวมเข้ากับ Third-Party Libraries
หนึ่งใน use case ที่มีประสิทธิภาพมากที่สุดสำหรับ callback refs คือการเริ่มต้นและทำลาย instances ของ third-party libraries ที่ต้องแนบกับ DOM node รูปแบบนี้ใช้ประโยชน์จาก mount/unmount nature ของ callback อย่างสมบูรณ์แบบ
นี่คือรูปแบบที่แข็งแกร่งสำหรับการจัดการ library เช่น charting หรือ map library:
import React, { useRef, useCallback, useEffect } from 'react';
import SomeChartingLibrary from 'some-charting-library';
function ChartComponent({ data }) {
// Use a ref to hold the library instance, not the DOM node
const chartInstance = useRef(null);
const chartContainerRef = useCallback(node => {
// The node is null when the component unmounts
if (node === null) {
if (chartInstance.current) {
console.log('Cleaning up chart instance...');
chartInstance.current.destroy(); // Cleanup method from the library
chartInstance.current = null;
}
return;
}
// The node exists, so we can initialize our chart
console.log('Initializing chart instance...');
const chart = new SomeChartingLibrary(node, {
// Configuration options
data: data,
});
chartInstance.current = chart;
}, [data]); // Re-create the chart if the data prop changes
return <div className="chart-container" ref={chartContainerRef} style={{ height: '400px' }} />;
}
รูปแบบนี้สะอาดและยืดหยุ่นเป็นพิเศษ:
- การเริ่มต้น: เมื่อ `div` mount callback จะได้รับ `node` มันสร้าง instance ใหม่ของ charting library และจัดเก็บไว้ใน `chartInstance.current`
- การล้าง: เมื่อคอมโพเนนต์ unmount (หรือถ้า `data` เปลี่ยนไป ทำให้เกิดการ re-run) callback จะถูกเรียกด้วย `null` ก่อน โค้ดจะตรวจสอบว่ามี chart instance อยู่หรือไม่ และถ้ามี จะเรียกใช้ `destroy()` method เพื่อป้องกัน memory leaks
- การอัปเดต: โดยการรวม `data` ไว้ใน dependency array เราจะตรวจสอบให้แน่ใจว่าหาก data ของ chart ต้องเปลี่ยนไปอย่างสิ้นเชิง chart ทั้งหมดจะถูกทำลายและเริ่มต้นใหม่ด้วย data ใหม่ อย่างสะอาดหมดจด สำหรับ simple data updates library อาจมี `update()` method ซึ่งสามารถจัดการได้ใน `useEffect` แยกต่างหาก
Performance Comparison: เมื่อไหร่ที่ Optimization *สำคัญ* จริงๆ
สิ่งสำคัญคือต้องเข้าหา performance ด้วย mindset ที่ใช้งานได้จริง แม้ว่าการห่อทุก ref callback ใน `useCallback` จะเป็นนิสัยที่ดี แต่ผลกระทบต่อประสิทธิภาพที่แท้จริงจะแตกต่างกันอย่างมากขึ้นอยู่กับงานที่ทำภายใน callback
Negligible Impact Scenarios
หาก callback ของคุณดำเนินการเพียงแค่ simple variable assignment overhead ของการสร้าง function ใหม่ในแต่ละ render จะมีขนาดเล็กมาก JavaScript engines สมัยใหม่นั้นรวดเร็วอย่างไม่น่าเชื่อในการสร้างฟังก์ชันและการ garbage collection
ตัวอย่าง: ref={(node) => (myRef.current = node)}
ในกรณีเช่นนี้ แม้ว่าในทางเทคนิคแล้วจะไม่เหมาะสมที่สุด แต่คุณไม่น่าจะวัดความแตกต่างของประสิทธิภาพในแอปพลิเคชันในโลกแห่งความเป็นจริง อย่าตกหลุมพรางของการเพิ่มประสิทธิภาพก่อนเวลาอันควร
Significant Impact Scenarios
คุณควรใช้ useCallback เสมอเมื่อ ref callback ของคุณดำเนินการสิ่งต่อไปนี้:
- DOM Manipulation: การเพิ่มหรือลบคลาส การตั้งค่า attributes หรือการวัดขนาด element โดยตรง (ซึ่งสามารถทริกเกอร์ layout reflow ได้)
- Event Listeners: การเรียก `addEventListener` และ `removeEventListener` การ firing สิ่งนี้ในทุกๆ render เป็นวิธีที่รับประกันได้ว่าจะทำให้เกิดข้อบกพร่องและปัญหาด้านประสิทธิภาพ
- Library Instantiation: ดังที่แสดงใน charting example ของเรา การเริ่มต้นและการทำลาย complex objects นั้นมีค่าใช้จ่ายสูง
- Network Requests: การเรียก API ตามการมีอยู่ของ DOM element
- การส่ง Refs ไปยัง Memoized Children: หากคุณส่ง ref callback เป็น prop ไปยัง child component ที่ห่อด้วย
React.memounstable inline function จะทำลาย memoization และทำให้ child re-render โดยไม่จำเป็น
กฎง่ายๆ: หาก ref callback ของคุณมีมากกว่า single, simple assignment ให้ memoize มันด้วย useCallback
Conclusion: การเขียนโค้ดที่คาดเดาได้และมีประสิทธิภาพ
React's ref callback เป็นเครื่องมือที่ทรงพลังที่ให้การควบคุม DOM nodes และ component instances อย่างละเอียด การทำความเข้าใจวงจรชีวิตของมัน โดยเฉพาะอย่างยิ่งการเรียก `null` โดยเจตนาในระหว่างการล้าง คือกุญแจสำคัญในการใช้งานอย่างมีประสิทธิภาพ
เราได้เรียนรู้ว่า anti-pattern ทั่วไปของการใช้ inline function สำหรับ ref prop นำไปสู่การ re-executions ที่ไม่จำเป็นและอาจมีราคาแพงในทุกๆ render โซลูชันนี้สง่างามและเป็นสำนวนของ React: ทำให้ callback function เสถียรโดยใช้ useCallback hook
เมื่อเชี่ยวชาญรูปแบบนี้ คุณสามารถ:
- ป้องกัน Performance Bottlenecks: หลีกเลี่ยง costly setup และ teardown logic ในทุกๆ state change
- กำจัด Bugs: ตรวจสอบให้แน่ใจว่า event listeners และ library instances ได้รับการจัดการอย่างหมดจดโดยไม่มี duplicates หรือ memory leaks
- เขียน Predictable Code: สร้าง components ที่ ref logic ทำงานตามที่คาดไว้ โดยทำงานเฉพาะเมื่อ component mount, unmount หรือเมื่อ dependencies เฉพาะของมันเปลี่ยนไปเท่านั้น
ครั้งต่อไปที่คุณเอื้อมมือไปหยิบ ref เพื่อแก้ปัญหาที่ซับซ้อน โปรดจำไว้ว่าพลังของ memoized callback มันเป็นการเปลี่ยนแปลงเล็กน้อยในโค้ดของคุณที่สามารถสร้างความแตกต่างอย่างมากในคุณภาพและประสิทธิภาพของ React applications ของคุณ ซึ่งส่งผลให้ผู้ใช้ทั่วโลกได้รับประสบการณ์ที่ดียิ่งขึ้น