במהלך הפיתוח, אנו צריכים לעתים תקשורת ישירה עם אלמנטי DOM. במקרים כאלה, React מספק לנו מנגנון הנקרא refs, שמאפשר גישה לאלמנטים לאחר שהם נrendered. לרוב, אנו משתמשים ברפרנסים אובייקט סטנדרטיים באמצעות useRef
(נקרא אותם כך), אך ישנה גישה נוספת שנקראת callback refs. שיטה זו מציעה גמישות נוספת ושליטה על מחזור החיים של אלמנטים, מאפשרת לנו לבצע פעולות ספציפיות מסוימות ברגעים מדויקים כאשר האלמנטים מחוברים או מנותקים מ-DOM.
במאמר זה, אני רוצה להסביר מהן רפרנסים באמצעות קול-באק וכיצד הם עובדים, לדון בפחדים שעשויים להתעורר, ולהציג דוגמאות לשימושם.
מהם רפרנסים באמצעות קול-באק וכיצד הם עובדים?
רפרנסים באמצעות קול-באק מעניקים לך שליטה יותר מדוקדקת על צריפת רפרנס בהשוואה לרפרנסים אובייקט. בואו נסתכל כיצד הם עובדים בפועל:
- התקנת. כאשר אלמנט מתקין אל DOM, React קורא לפונקציית הרפרנס עם האלמנט DOM עצמו. זה מאפשר לך לבצע פעולות עם האלמנט מיד לאחר שהוא מופיע על העמוד.
- ניתוק. כאשר אלמנט נתקע, React קורא לפונקציית הרפרנס עם
null
. זה נותן לך את ההזדמנות לנקות או לבטל פעולות שקשורות לאלמנט זה.
דוגמה: מעקב אחר התקנה וניתוק
import React, { useCallback, useState } from 'react';
function MountUnmountTracker() {
const [isVisible, setIsVisible] = useState(false);
const handleRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
console.log('Element mounted:', node);
} else {
console.log('Element unmounted');
}
}, []);
return (
<div>
<button onClick={() => setIsVisible((prev) => !prev)}>
{isVisible ? 'Hide' : 'Show'} element
</button>
{isVisible && <div ref={handleRef}>Tracked element</div>}
</div>
);
}
export default MountUnmountTracker;
בכל פעם שאנו משנים את הנראות של האלמנט, הפונקציה handleRef
נקראת עם הקודם או null
, מאפשרת לנו לעקוב אחר הרגע שבו האלמנט מחובר או מנותק.
בעיות נפוצות ופתרונות
בעיה: קריאות חוזרות לפונקציית Ref
בעיה נפוצה בשימוש בקריאות Ref היא יצירה חוזרת של פונקציית ה-Ref בכל פעם שהרכיב מתרנדר מחדש. כתוצאה מכך, React חושב שזו קריאה חדשה, קוראת לקודמת עם null
(ניקוי) ואז מאתחלת את החדשה – גם אם האלמנט או הרכיב שלנו לא שינו בפועל. זה עשוי לגרום לאפקטים לוואיים לא רצויים.
דוגמה על הבעיה
שקול רכיב Basic
שיש לו כפתור לשינוי הנראות של div
עם callback ref, פלוס עוד כפתור לכפיית רנדור מחדש של רכיב:
import React, { useState, useReducer } from 'react';
function Basic() {
const [showDiv, setShowDiv] = useState(false);
const [, forceRerender] = useReducer((v) => v + 1, 0);
const toggleDiv = () => setShowDiv((prev) => !prev);
const refCallback = (node: HTMLDivElement | null) => {
console.log('div', node);
};
return (
<div>
<button onClick={toggleDiv}>Toggle Div</button>
<button onClick={forceRerender}>Rerender</button>
{showDiv && <div ref={refCallback}>Example div</div>}
</div>
);
}
export default Basic;
כל פעם שאתה לוחץ על כפתור Rerender, הרכיב מתרנדר מחדש, יוצר פונקציית refCallback
חדשה. כתוצאה, React קוראת ל-refCallback
הישן null
ואז ל-refCallback
החדשה (node)
, גם אם האלמנט שלנו עם ה-Ref לא שינה. בקונסולה, תראה div null
ואז div [node]
לאחר מכן, חוזר וחוזר. ברור שבדרך כלל אנו רוצים למנוע קריאות בלתי נחוצות כמו אלו.
פתרון: ממויזציה של קריאת ה-Ref עם useCallback
להימנע מכך פשוט מאוד: פשוט השתמש ב-useCallback
כדי לממוז את הפונקציה. בכך, הפונקציה תישאר ללא שינויים במהלך הרנדורים, אלא אם הוא ישנו.
import React, { useState, useCallback, useReducer } from 'react';
function Basic() {
const [showDiv, setShowDiv] = useState(false);
const [, forceRerender] = useReducer((v) => v + 1, 0);
const toggleDiv = () => setShowDiv((prev) => !prev);
const refCallback = useCallback((node: HTMLDivElement | null) => {
console.log('div', node);
}, []);
return (
<div>
<button onClick={toggleDiv}>Toggle Div</button>
<button onClick={forceRerender}>Rerender</button>
{showDiv && <div ref={refCallback}>Example div</div>}
</div>
);
}
export default Basic;
עכשיו, refCallback
נוצר פעם אחת בלבד, בהצגה הראשית. זה לא יפעיל שיחות נוספות בהצגות מחודשות, מונע קריאות לא נחוצות ומשפר ביצועים.
סדר ההתקשרויות לפונקציות רפרנס, useLayoutEffect, ו־useEffect
לפני שנדבר על איך להשתמש ברפרנסים בקוד שלך כדי לפתור בעיות ספציפיות, בואו נבין איך רפרנסים לפונקציות עובדות עם ה־hooks useEffect
ו־useLayoutEffect
כך שתוכלו לארגן את האתחול והניקוי של משאבים כהלכה.
סדר ביצוע
- רפרנס לפונקציה – נקרא מייד לאחר העתקת האלמנטים ב־DOM, לפני שה־effect hooks מתבצעים
- useLayoutEffect – פועל אחרי כל שינוי ב־DOM אך לפני שהדפדפן מצייר
- useEffect – פועל לאחר שהרכיב סיים להתצג למסך
import React, { useEffect, useLayoutEffect, useCallback } from 'react';
function WhenCalled() {
const refCallback = useCallback((node: HTMLDivElement | null) => {
if (node) {
console.log('Callback ref called for div:', node);
} else {
console.log('Callback ref detached div');
}
}, []);
useLayoutEffect(() => {
console.log('useLayoutEffect called');
}, []);
useEffect(() => {
console.log('useEffect called');
}, []);
return (
<div>
<div ref={refCallback}>Element to watch</div>
</div>
);
}
export default WhenCalled;
פלט בקונסול
רפרנס לפונקציה נקרא עבור div: [אלמנט div]
useLayoutEffect נקרא
useEffect נקרא
הרצפית הזו מודיעה לנו שרפרנסים לפונקציות מופעלים לפני hooks כמו useLayoutEffect
ו־useEffect
, מה שחיוני לזכור בעת כתיבת הלוגיקה שלך.
אילו בעיות פתרון רפרנסים לפונקציות פותרות בקוד?
ראשית, בואו נשחק בשגיאה שנתקלים בה תכניתית עם רפרנסים לאובייקטים רגילים כדי שנוכל לפתור אותה עם רפרנסים לפונקציות.
import { useCallback, useEffect, useRef, useState } from 'react';
interface ResizeObserverOptions {
elemRef: React.RefObject<HTMLElement>;
onResize: ResizeObserverCallback;
}
function useResizeObserver({ elemRef, onResize }: ResizeObserverOptions) {
useEffect(() => {
const element = elemRef.current;
if (!element) {
return;
}
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(element);
return () => {
resizeObserver.unobserve(element);
};
}, [onResize, elemRef]);
}
export function UsageDom() {
const [bool, setBool] = useState(false);
const elemRef = useRef<HTMLDivElement>(null);
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
console.log('resize', entries);
}, []);
useResizeObserver({ elemRef, onResize: handleResize });
const renderTestText = () => {
if (bool) {
return <p ref={elemRef}>Test text</p>;
}
return <div ref={elemRef}>Test div</div>;
};
return (
<div style={{ width: '100%', textAlign: 'center' }}>
<button onClick={() => setBool((v) => !v)}>Toggle</button>
{renderTestText()}
</div>
);
}
אנו לא נעמוד על כל פרט כאן. בקצרה, אנו מעקבים אחר גודל האלמנט שלנו div
או p
באמצעות ResizeObserver
. בתחילה, הכל עובד כשורה: בהתקנה, אנו יכולים לקבל את גודל האלמנט, ושינויי גודל גם מדווחים בקונסולה.
הבעיה האמיתית מתחילה כאשר אנו משנים את המצב על מנת להחליף את האלמנט שאנו מעקיבים אחריו. כאשר אנו משנים את המצב ולכן מחליפים את האלמנט שנעקוב אחריו, ה-ResizeObserver
שלנו כבר לא עובד כראוי. הוא ממשיך לעקוב אחרי האלמנט הראשון, שכבר הוסר מתוך ה-DOM! אפילו כאשר אנו מחליפים חזרה לאלמנט המקורי, זה לא עוזר מאחר שהרישום לאלמנט החדש לא מתקבל בצורה תקינה.
הערה: הפתרון הבא יותר רפרזנטטיבי למה שתיכתב בהקשר של ספריית קוד, דורש קוד אוניברסלי. בפרויקט אמיתי, תוכל לפתור זאת דרך שילוב של דגלים, אפקטים, וכדומה. אך בקוד של ספרייה, אין לך ידע על הרכיב הספציפי או על מצבו. זה בדיוק התרחיש שבו מצפים מהפנייה לרפרנסים לעזור לנו.
import { useCallback, useRef, useState } from 'react';
function useResizeObserver(onResize: ResizeObserverCallback) {
const roRef = useRef<ResizeObserver | null>(null);
const attachResizeObserver = useCallback(
(element: HTMLElement) => {
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(element);
roRef.current = resizeObserver;
},
[onResize]
);
const detachResizeObserver = useCallback(() => {
roRef.current?.disconnect();
}, []);
const refCb = useCallback(
(element: HTMLElement | null) => {
if (element) {
attachResizeObserver(element);
} else {
detachResizeObserver();
}
},
[attachResizeObserver, detachResizeObserver]
);
return refCb;
}
export default function App() {
const [bool, setBool] = useState(false);
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
console.log('resize', entries);
}, []);
const resizeRef = useResizeObserver(handleResize);
const renderTestText = () => {
if (bool) {
return <p ref={resizeRef}>Test text</p>;
}
return <div ref={resizeRef}>Test div</div>;
};
return (
<div style={{ width: '100%', textAlign: 'center' }}>
<button onClick={() => setBool((v) => !v)}>Toggle</button>
{renderTestText()}
</div>
);
}
כפי שאתה יכול לראות, כתבנו מחדש את פונקציית ה-useResizeObserver
שלנו כך שתשתמש ברפרנס באמצעות פונקציית callback. אנו פשוט מעבירים פנימה פונקציה callback (ממוזזת) שצריכה להתפעל במקרה של שינוי גודל, וללא קשר לכמה פעמים אנו מחליפים אלמנטים, הרפרנס לשינוי גודל עדיין עובד. זה בגלל שהמעקב מצורף לאלמנטים חדשים וננתק מאלה הישנים בדיוק בזמן שנדרש, בזכות הרפרנס לקולבאק.
היתרון המרכזי כאן הוא שהמפתח שמשתמש בהוק שלנו כבר לא צריך לדאוג ללוגיקה של הוספת/הסרת צופים תחת הקפדה – הם כיסו את הלוגיקה הזו בתוך ההוק. המפתח צריך רק להעביר קולבק להוק שלנו ולצרף את ההפניה שהוא מחזיר לאלמנטים שלו.
שילוב מספר רב של הפניות לאלמנט אחד
כאן עוד תרחיש שבו הפניות באמצעות קולבק מצילות:
import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';
interface InputProps {
value?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const Input = forwardRef(function Input(
props: InputProps,
ref: React.ForwardedRef<HTMLInputElement>
) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!inputRef.current) {
return;
}
console.log(inputRef.current.getBoundingClientRect());
}, []);
return <input {...props} ref={ref} />;
});
export function UsageWithoutCombine() {
const inputRef = useRef<HTMLInputElement | null>(null);
const focus = () => {
inputRef.current?.focus();
};
return (
<div>
<Input ref={inputRef} />
<button onClick={focus}>Focus</button>
</div>
);
}
בקוד לעיל, יש לנו רכיב קלט פשוט שבו אנו מגדירים רפרנס, מחלצים אותו מהפרופס באמצעות forwardRef
. אבל איך אנו משתמשים ב-inputRef
בתוך רכיב ה-Input
אם אנו גם זקוקים להפניה מהחוץ למשהו כמו התמקדות? אולי נרצה לעשות משהו אחר ברכיב הקלט עצמו, כגון getBoundingClientRect
. החלפת הרפרנס הפרופ עם הרפרנס הפנימי שלנו אומרת שהתמקדות מהחוץ לא תעבוד עוד. אז, איך אנו משלבים את שתי ההפניות הללו?
זהו המקום בו הפניות באמצעות קולבק מסייעות שוב:
import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';
type RefItem<T> =
| ((element: T | null) => void)
| React.MutableRefObject<T | null>
| null
| undefined;
function useCombinedRef<T>(...refs: RefItem<T>[]) {
const refCb = useCallback((element: T | null) => {
refs.forEach((ref) => {
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(element);
} else {
ref.current = element;
}
});
}, refs);
return refCb;
}
interface InputProps {
value?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const Input = forwardRef(function Input(
props: InputProps,
ref: React.ForwardedRef<HTMLInputElement>
) {
const inputRef = useRef<HTMLInputElement>(null);
const combinedInputRef = useCombinedRef(ref, inputRef);
useEffect(() => {
if (!inputRef.current) {
return;
}
console.log(inputRef.current.getBoundingClientRect());
}, []);
return <input {...props} ref={combinedInputRef} />;
});
export function UsageWithCombine() {
const inputRef = useRef<HTMLInputElement | null>(null);
const focus = () => {
inputRef.current?.focus();
};
return (
<div>
<Input ref={inputRef} />
<button onClick={focus}>Focus</button>
</div>
);
}
הסבר
אנו מיישמים הוק useCombinedRef
שבו מפתח יכול להעביר רפרנסים סטנדרטיים, רפרנסים באמצעות קולבק, ואופציונלית null
או undefined
. ההוק עצמו הוא פשוט useCallback
שעובר על מערך הרפרנסים. אם הארגומנט הוא null
, אנו מתעלמים ממנו, אך אם הרפרנס הוא פונקציה, אנו קוראים לה עם האלמנט; אם זה רפרנס סטנדרטי, אנו מגדירים את ref.current
לאלמנט.
בדרך זו, אנו ממזגים מספר refs לתוך אחד. בדוגמה שנתונה לעיל, השימוש ב־getBoundingClientRect
בתוך רכיב ה־Input
, וגם בקריאת ה־focus החיצונית יעבדו כראוי.
מה שהשתנה ב־React 19 לגבי Callback Refs?
ניקיון אוטומטי
React מטפלת כעת באופן אוטומטי ב־ניקיון ה־callback refs כאשר הרכיבים מוסרים, מה שהופך את ניהול המשאבים לפשוט יותר. כאן דוגמה שהצוות של React מציג בתיעוד שלהם:
{
// ref נוצר
// חדש: מחזיר פונקציית ניקיון כדי לאפס
// את ה־ref כאשר האלמנט נמחק מה־DOM.
return () => {
// ניקוי ref
};
}}
/>
ניתן לקרוא עוד פרטים על כך ב־פוסט בבלוג הרשמי, שם נאמר כי בקרוב, ניקוי refs דרך null
עשוי להיות מוצג משום שהדרך האחת המוסכמת לניקוי refs תישאר.
בחירה בין Refs רגילים וקולבאק Refs
- השתמשו ב־refs סטנדרטיים (
useRef
) כאשר אתם רק זקוקים לגישה פשוטה לאלמנט של ה־DOM או רוצים לשמור ערך מסוים בין נטענות לנטענות ללא פעולות נוספות בזמן התקנה או הסרה. - השתמשו ב־callback refs כאשר אתם זקוקים לשליטה פינואנסית יותר על מחזור החיים של האלמנט, כאשר אתם כותבים קוד אוניברסלי (לדוגמה, בספרייתכם או חבילתכם), או כאשר עליכם לנהל מספר refs ביחד.
מסקנה
הפניות קולבק בריאקט הן כלי עוצמתי שנותן למפתחים גמישות נוספת ושליטה בעת עבודה עם אלמנטי DOM. ברוב המקרים, הפניות אובייקט סטנדרטיות באמצעות useRef מספיקות למשימות היומיומיות, אך פניות קולבק יכולות לעזור עם תרחישים מורכבים יותר כמו אלה שדנינו בהם למעלה.
Source:
https://dzone.com/articles/react-callback-refs-guide