أثناء التطوير، نحتاج في كثير من الأحيان للتفاعل المباشر مع عناصر DOM. في مثل هذه الحالات، يوفر لنا React آلية تسمى refs، تتيح لنا الوصول إلى العناصر بعد عرضها. عادةً، نستخدم مراجع الكائنات القياسية من خلال 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
، مما يتيح لنا تتبع لحظة تعليق العنصر أو فصله.
قضايا شائعة وحلولها
المشكلة: تكرار استدعاءات ريف الوظيفة
المشكلة الشائعة عند استخدام ريف الوظيفة هو إنشاء الوظيفة المرجعية مرارًا وتكرارًا في كل إعادة رسم لمكون. بسبب ذلك، يعتقد React أنها مرجعية جديدة، ويستدعي القديمة بـ null
(لتنظيفه) ثم يبدأ الجديدة – حتى لو لم يتغير عنصرنا أو مكوننا فعليًا. يمكن أن يؤدي هذا إلى آثار جانبية غير مرغوب فيها.
مثال على المشكلة
فكر في مكون Basic
الذي يحتوي على زر لتبديل رؤية div
مع ريف الوظيفة، بالإضافة إلى زر آخر لإعادة رسم المكون:
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)
، على الرغم من أن عنصرنا ذي الريف لم يتغير. في وحدة التحكم، سترى div null
ثم div [node]
بشكل متكرر. من الواضح أننا نرغب عادة في تجنب الاستدعاءات غير الضرورية مثل تلك.
الحل: تذكر ريف الوظيفة باستخدام 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
قبل أن نتحدث عن كيفية استخدام روابط الاستدعاء في الكود الخاص بك لحل المشاكل المحددة، دعونا نفهم كيف تتفاعل روابط الاستدعاء مع هوكس useEffect
و useLayoutEffect
حتى تتمكن من تنظيم تهيئة وتنظيف الموارد بشكل صحيح.
ترتيب التنفيذ
- روابط الاستدعاء – يتم استدعاؤها على الفور بعد عرض عناصر DOM، قبل تشغيل هوكس الأثر
- 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
تخبرنا هذه التسلسل تسلسل أن روابط الاستدعاء تُشغل قبل الهوكس مثل 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
لاستخدام رابط استدعاء. نمرر مجرد استدعاء (محفوظ) يجب أن يُطلق عند التغيير، وبغض النظر عن عدد مرات تبديل العناصر، يعمل تابع التغيير لدينا بشكل جيد. هذا لأن المراقب يرتبط بالعناصر الجديدة ويفصل عن القديمة في الوقت المناسب، بفضل رابط الاستدعاء.
الفائدة الرئيسية هنا هي أن المطور الذي يستخدم خطافنا لم يعد بحاجة للقلق بشأن منطق إضافة/إزالة المراقبين تحت الغطاء – لقد كبسولنا ذلك المنطق داخل الخطاف. المطور يحتاج فقط لتمرير وظيفة انتقالية إلى خطافنا وربط الرقم المرجعي الذي يعود به بعناصرهم.
دمج الرموز المرجعية المتعددة في رمز واحد
هنا سيناريو آخر حيث تأتي وظائف الرموز الانتقالية للإنقاذ:
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
داخل مكون الإدخال إذا كنا بحاجة أيضًا إلى الرمز من الخارج لشيء مثل التركيز؟ ربما نريد القيام بشيء آخر في مكون الإدخال نفسه، مثل 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
للعنصر.
بهذه الطريقة، ندمج مراجع متعددة في واحدة. في المثال أعلاه، سيعمل كل من getBoundingClientRect
داخل مكون Input
، واستدعاء التركيز الخارجي بشكل صحيح.
ما الذي تغير في React 19 بخصوص مراجع الارجاع؟
تنظيف تلقائي
تتعامل React الآن تلقائيًا مع تنظيف مراجع الارجاع عند فصل العناصر، مما يجعل إدارة الموارد أكثر بساطة. إليك مثال يظهره فريق React في توثيقهم:
{
// تم إنشاء المرجع
// NEW: قم بإرجاع وظيفة تنظيف لإعادة
// المرجع عند إزالة العنصر من DOM.
return () => {
// تنظيف المرجع
};
}}
/>
يمكنك قراءة المزيد من التفاصيل حول ذلك في مقالة المدونة الرسمية، حيث ذُكر أنه قريبًا، قد يتم إهمال تنظيف المراجع عبر null
، مما يترك طريقة واحدة موحدة لتنظيف المراجع.
اختيار بين مراجع النمط العادي ومراجع الارجاع
- استخدم المراجع القياسية (
useRef
) عندما تحتاج فقط إلى وصول بسيط إلى عنصر DOM أو ترغب في الاحتفاظ ببعض القيمة بين عمليات الإعادة من دون إجراءات إضافية عند الإلحاق أو الفصل. - استخدم مراجع الارجاع عندما تحتاج إلى مزيد من التحكم التفصيلي في دورة حياة العنصر، عند كتابة كود عالمي (على سبيل المثال، في مكتبة أو حزمة خاصة بك)، أو عندما تحتاج إلى إدارة مراجع متعددة معًا.
الختام
مراجع الاستدعاء في رياكت هي أداة قوية تمنح المطورين مرونة وتحكم إضافيين عند العمل مع عناصر الـDOM. في معظم الحالات، مراجع الكائن القياسية عبر useRef
تكفي للمهام اليومية، ولكن مراجع الاستدعاء يمكن أن تساعد في سيناريوهات أكثر تعقيدًا مثل تلك التي ناقشناها أعلاه.
Source:
https://dzone.com/articles/react-callback-refs-guide