React: الخطافات المخصصة
آخر تحديث: 2026-10-26
1. ما ستتعلمه
- قواعد وأساليب تسمية الخطافات المخصصة
- تصميم معلمات وقيم الإرجاع للـ«هوكات» المخصصة
- 6 خطافات عملية مصنوعة حسب الطلب
- مبادئ إعادة استخدام الخطافات المخصصة
2. قصة أحد متطلبات إعادة استخدام النماذج
أنماط إعادة الاستخدام للخطافات المخصصة
flowchart TD
A[Repetitive Logic] --> B[Extract as useXxx]
B --> C[ComponentsA useXxx]
B --> D[ComponentsB useXxx]
B --> E[ComponentsC useXxx]
F[useXxxInside] --> G[useState]
F --> H[useEffect]
F --> I[useRef]
F --> J[useContext]
K[The Rule of Three] --> L{Number of logical repetitions?}
L -->|1 time| M[No extraction required]
L -->|2 times| N[Consider extracting]
L -->|3+ times| O[Must be extracted ✅]
style B fill:#e8f5e9,stroke:#2e7d32
style O fill:#c8e6c9,stroke:#2e7d32
(1) المشكلة: يتكرر نفس المنطق على مدى 5 صفحات
يحتاج بوب إلى «استرداد تفضيلات المستخدم من التخزين المحلي» في خمس صفحات مختلفة:
JSX
// Page A:Settings Page
function SettingsPage() {
const [prefs, setPrefs] = useState(() => {
const saved = localStorage.getItem('preferences')
return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
})
useEffect(() => {
localStorage.setItem('preferences', JSON.stringify(prefs))
}, [prefs])
// ... Page A specifically UI
}
// Page B:Dashboard
function Dashboard() {
const [prefs, setPrefs] = useState(() => {
const saved = localStorage.getItem('preferences')
return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
})
useEffect(() => {
localStorage.setItem('preferences', JSON.stringify(prefs))
}, [prefs])
// ... Page B specifically UI(Exactly the same logic!)
}
المشكلة: localStorage تم نسخ منطق القراءة/الكتابة ولصقه خمس مرات عبر خمس صفحات. إذا أردنا إضافة ميزة «رصد أحداث التخزين»، فسيتعين علينا تعديل خمسة ملفات.
(2) حلول للخطافات المخصصة
JSX
// ---- Custom Hook:Packaging localStorage Reading and Writing ----
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key)
return saved ? JSON.parse(saved) : initialValue
} catch {
return initialValue
}
})
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (e) {
console.error('Save to localStorage Failure:', e)
}
}, [key, value])
return [value, setValue]
}
// ---- Use this consistently on all pages ----
function SettingsPage() {
const [prefs, setPrefs] = useLocalStorage('preferences', {
theme: 'light', lang: 'zh'
})
// ... Specifically UI
}
function Dashboard() {
const [prefs, setPrefs] = useLocalStorage('preferences', {
theme: 'light', lang: 'zh'
})
// ... Specifically UI
}
المزايا: 20 سطراً من المنطق → سطر واحد من التعليمات البرمجية. عند تعديل المنطق، ما عليك سوى تغيير ملف واحد: useLocalStorage.
3. قواعد الخطافات المخصصة
| القاعدة | الوصف | عواقب المخالفة |
|---|---|---|
بدءًا من use |
تحديد الـ«هوكات» باستخدام أسماء تبعيات React | لا يتحقق «Linter» من هذه القاعدة؛ ولن تؤدي الاستدعاءات الشرطية إلى ظهور أخطاء |
| استدعاء الوظيفة في المستوى الأعلى فقط | لا تستدعِ الوظيفة داخل الحلقات أو العبارات الشرطية أو الكتل المتداخلة | عدم توافق سلسلة الـ«هوك»، والتشويش في الحالة |
| يُسمح بالاستدعاء فقط داخل مكونات الدالة أو الخطافات المخصصة | لا تستخدم داخل الدوال العادية أو مكونات الفئات | خطأ في وقت التشغيل: استدعاء خطاف غير صالح |
| تُرجع بيانات/دوال، وليس JSX | تُرجع «الهوكات» المنطق، بينما تُرجع المكونات واجهة المستخدم | تنتهك مبدأ فصل الاهتمامات، مما يجعل إعادة الاستخدام صعبة |
| معالجة الآثار الجانبية | استخدام دالة التنظيف في useEffect | تسربات الذاكرة، تراكم مستمعي الأحداث |
(1) قواعد تسمية الملفات
JSX
// ✅ It is essential to use Introduction
function useOnlineStatus() { }
function useWindowSize() { }
function useDebounce() { }
// ❌ Not based on use Introduction → React Will not check Hooks Rules
function onlineStatus() { }
function fetchData() { }
(2) تصميم قيمة الإرجاع
JSX
// Method 1:Return an array(like useState)
function useToggle(initial = false) {
const [value, setValue] = useState(initial)
const toggle = useCallback(() => setValue(v => !v), [])
return [value, toggle] // Array,Flexible Deconstruction of Names
}
// Method 2:Return Object(Greater clarity when there are multiple return values)
function useUser() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
return { user, loading, error } // Object,Property Names Are Clear
}
// Method 3:Returns a single value
function useDocumentTitle(title) {
useEffect(() => { document.title = title }, [title])
// No return value required
}
(3) يمكن استخدام أي «hooks» داخليًّا
JSX
function useData(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(url)
.then(res => {
if (!res.ok) throw new Error('Request Failed')
return res.json()
})
.then(data => { if (!cancelled) { setData(data); setLoading(false) }})
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})
return () => { cancelled = true }
}, [url])
return { data, loading, error }
}
// Used internally useState + useEffect + useCallback(If necessary)
// Custom Hook You can combine any of the built-in features Hooks
4. 6 خطافات مخصصة عملية
| الخطاف | الوظيفة | الخطافات المستخدمة داخليًّا | القيمة المرجعة |
|---|---|---|---|
useToggle |
مفتاح تبديل منطقي | useState، useCallback |
{ value, toggle, setTrue, setFalse } |
useDebounce |
مدخلات مقاومة الاهتزاز | useState، useEffect |
debouncedValue |
useWindowSize |
مراقبة حجم النافذة | useState، useEffect |
{ width, height } |
useFetch |
طلب بيانات ثلاثي الحالات | useState، useEffect، useCallback |
{ data, loading, error, refetch } |
useIntersectionObserver |
كشف الرؤية | useState، useEffect |
isIntersecting |
useMediaQuery |
نقطة توقف التجاوب | useState، useEffect |
matches |
(1) العنصر 1: useToggle (مفتاح التبديل)
▶ جرّب الكود(2) هوك 2: useDebounce (مضاد الاهتزاز)
▶ جرّب الكود(3) Hook 3: useWindowSize (حجم النافذة)
▶ جرّب الكود(4) الخطاف 4: useFetch (طلب البيانات)
JSX
function useFetch(url, options = {}) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetch(url, options)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
return res.json()
})
.then(data => { if (!cancelled) { setData(data); setLoading(false) }})
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})
return () => { cancelled = true }
}, [url])
// Resubmit Request
const refetch = useCallback(() => {
setLoading(true)
fetch(url, options)
.then(res => res.json())
.then(data => { setData(data); setLoading(false) })
.catch(err => { setError(err.message); setLoading(false) })
}, [url])
return { data, loading, error, refetch }
}
// Usage
function UserProfile({ userId }) {
const { data: user, loading, error, refetch } = useFetch(`/api/users/${userId}`)
if (loading) return <p>Loading......</p>
if (error) return <p>Error:{error} <button onClick={refetch}>Retry</button></p>
return <h1>{user.name}</h1>
}
(5) النقطة الخامسة: استخدام IntersectionObserver (الكشف عن الرؤية)
JSX
function useIntersectionObserver(ref, options = {}) {
const [isIntersecting, setIsIntersecting] = useState(false)
useEffect(() => {
if (!ref.current) return
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting)
}, { threshold: options.threshold || 0.1, ...options })
observer.observe(ref.current)
return () => observer.disconnect()
}, [ref, options.threshold])
return isIntersecting
}
// Usage:Lazy Loading of Images
function LazyImage({ src, alt }) {
const imgRef = useRef(null)
const isVisible = useIntersectionObserver(imgRef)
const [loaded, setLoaded] = useState(false)
return (
<div ref={imgRef} style={{ minHeight: '200px', backgroundColor: '#f5f5f5' }}>
{isVisible && (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ width: '100%', opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
)}
</div>
)
}
(6) النقطة السادسة: useMediaQuery (نقاط التوقف التفاعلية)
JSX
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
useEffect(() => {
const mql = window.matchMedia(query)
function handler(e) { setMatches(e.matches) }
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [query])
return matches
}
// Usage
function ResponsiveLayout() {
const isMobile = useMediaQuery('(max-width: 768px)')
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
const isDesktop = useMediaQuery('(min-width: 1025px)')
return (
<div>
{isMobile && <MobileMenu />}
{isDesktop && <FullSidebar />}
<p>
{isMobile ? '📱 Cell Phone' : isTablet ? '📟 Tablet' : '💻 Computer'}
</p>
</div>
)
}
الناتج:
TEXT
الحالة: query. المكون: useDebounce
5. مبادئ إعادة استخدام الخطافات المخصصة
| المبدأ | الشرح | مثال سيئ | مثال جيد |
|---|---|---|---|
| المسؤولية الواحدة | لا يقوم الـ«هوك» إلا بشيء واحد | useUserAndTheme() |
useUser() + useTheme() |
| المعلمات المرنة | توفير قيم افتراضية معقولة | useFetch('/api/data') عنوان URL مبرمج بشكل ثابت |
useFetch(url) عنوان URL معلماتي |
| قيم الإرجاع الكاملة | تُرجع ثلاث حالات: التحميل، والخطأ، والبيانات | useFetch() تُرجع البيانات فقط |
useFetch() تُرجع { data, loading, error } |
| تنظيف الموارد | تنظيف الآثار الجانبية عند إلغاء تثبيت المكون | عدم تنظيف المؤقتات/مستمعي الأحداث | تنظيف ما يرجعه useEffect |
| قابل للتركيب | يمكن دمج الخطافات الصغيرة لتشكيل خطافات أكبر | useComplexData() 100 سطر |
تركيبة useFetch() + useDebounce() |
▶ مثال: دمج عدة خطافات صغيرة
JSX
// ============================================
// Example:Combine multiple small Hook Building Complex Search Features
// ============================================
// 1. Small hook:Input Stabilization
function useDebounce(value, delay) { /* ... */ }
// 2. Small hook:Data Request
function useFetch(url) { /* ... */ }
// 3. Small hook:Local Storage
function useLocalStorage(key, initial) { /* ... */ }
// 4. Combined into a large Hook:Search
function useSearch(defaultQuery = '') {
const [query, setQuery] = useState(defaultQuery)
const [history, setHistory] = useLocalStorage('search_history', [])
const debouncedQuery = useDebounce(query, 500)
const { data, loading, error } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
)
function search(newQuery) {
setQuery(newQuery)
// Save Search History
if (newQuery.trim()) {
setHistory(prev => [newQuery, ...prev.filter(h => h !== newQuery)].slice(0, 10))
}
}
return { query, setQuery: search, results: data, loading, error, history }
}
// useSearch Combined internally useState + useDebounce + useFetch + useLocalStorage
الناتج:
TEXT
خطاف مخصص يُعيد { data, loading, error }. الاستخدام: const { data, loading } = useFetch("/api/users"). يُطلق الطلب تلقائيًا عند التركيب.
6. مثال كامل: معرض الصور (الجمع بين عدة هوكات مخصصة)
JSX
// ============================================
// Complete Example:Photo Gallery(Combination 3 Custom Hook)
// ============================================
import { useState, useEffect, useCallback, useRef } from 'react'
// ---- Custom Hook 1:Infinite Scrolling ----
function useInfiniteScroll(callback) {
const sentinelRef = useRef(null)
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) callback()
}, { threshold: 0.1 })
observer.observe(sentinel)
return () => observer.disconnect()
}, [callback])
return sentinelRef
}
// ---- Custom Hook 2:Keyboard Navigation ----
function useKeyPress(targetKey, callback) {
useEffect(() => {
function handler(e) {
if (e.key === targetKey) callback(e)
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [targetKey, callback])
}
// ---- Custom Hook 3:Click here ----
function useClickOutside(callback) {
const ref = useRef(null)
useEffect(() => {
function handler(e) {
if (ref.current && !ref.current.contains(e.target)) {
callback()
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [callback])
return ref
}
// ---- Image Gallery Component ----
function ImageGallery() {
const [images, setImages] = useState([])
const [page, setPage] = useState(1)
const [selected, setSelected] = useState(null)
const [loading, setLoading] = useState(false)
// Loading image
const loadMore = useCallback(() => {
if (loading) return
setLoading(true)
const newImages = Array.from({ length: 8 }, (_, i) => ({
id: images.length + i + 1,
url: `https://picsum.photos/seed/${images.length + i + 1}/300/200`,
title: `Image ${images.length + i + 1}`
}))
setTimeout(() => {
setImages(prev => [...prev, ...newImages])
setPage(p => p + 1)
setLoading(false)
}, 800)
}, [loading, images.length])
// First Load
useEffect(() => { loadMore() }, [])
// Infinite Scroll: Load More
const sentinelRef = useInfiniteScroll(loadMore)
// Click here to close the preview
const previewRef = useClickOutside(() => setSelected(null))
// Escape Key to close the preview
useKeyPress('Escape', () => setSelected(null))
// Use the left and right arrow keys to switch between images
useKeyPress('ArrowLeft', () => {
if (selected) {
const idx = images.findIndex(i => i.id === selected.id)
if (idx > 0) setSelected(images[idx - 1])
}
})
useKeyPress('ArrowRight', () => {
if (selected) {
const idx = images.findIndex(i => i.id === selected.id)
if (idx < images.length - 1) setSelected(images[idx + 1])
}
})
return (
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
<h2>🖼️ Photo Gallery</h2>
<p style={{ color: '#666', fontSize: '13px' }}>Loaded {images.length} images | Keyboard:← Previous → Next Escape Close</p>
{/* Image Grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px' }}>
{images.map(img => (
<div key={img.id} onClick={() => setSelected(img)}
style={{ cursor: 'pointer', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<img src={img.url} alt={img.title}
style={{ width: '100%', height: '150px', objectFit: 'cover', transition: 'transform 0.3s' }}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.05)'}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'} />
<p style={{ padding: '8px', margin: 0, fontSize: '13px', textAlign: 'center' }}>{img.title}</p>
</div>
))}
</div>
{/* Load More Triggers */}
<div ref={sentinelRef} style={{ textAlign: 'center', padding: '20px' }}>
{loading ? <p>⏳ Loading......</p> : <p style={{ color: '#999' }}>↓ Scroll to load more</p>}
</div>
{/* Image Preview(Click here to close) */}
{selected && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.8)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000
}}>
<div ref={previewRef} style={{ maxWidth: '80vw', maxHeight: '80vh' }}>
<img src={selected.url.replace('300/200', '600/400')} alt={selected.title}
style={{ maxWidth: '100%', maxHeight: '80vh', borderRadius: '8px' }} />
<p style={{ color: 'white', textAlign: 'center', marginTop: '12px' }}>
{selected.title}(Click here to close)
</p>
</div>
</div>
)}
</div>
)
}
الناتج:
TEXT
الحالة: query (setter: setQuery)
النتيجة المتوقعة: معرض صور يعرض 8 صور عند التحميل الأولي؛ ويؤدي التمرير إلى أسفل الصفحة إلى تحميل المزيد تلقائيًا؛ ويؤدي النقر على صورة إلى فتح نافذة معاينة (مع دعم التنقل باستخدام لوحة المفاتيح)؛ ويؤدي النقر خارج منطقة المعاينة إلى إغلاقها.
▶ المثال 2: هوك useClipboard — النسخ إلى الحافظة
JSX
function useClipboard() {
const [copied, setCopied] = useState(false)
async function copy(text) {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
setCopied(false)
}
}
return { copied, copy }
}
function CopyButton({ text }) {
const { copied, copy } = useClipboard()
return (
<button onClick={() => copy(text)}
style={{ padding: '4px 12px', cursor: 'pointer', background: copied ? '#52c41a' : '#f0f0f0', color: copied ? 'white' : '#333', border: 'none', borderRadius: 4 }}>
{copied ? 'Copied!' : 'Copy'}
</button>
)
}
الناتج:
TEXT
خطاف useClipboard: النقر لنسخ النص → تغذية راجعة "Copied!" → إعادة تعيين تلقائية بعد انتهاء المهلة. يغلف واجهة navigator.clipboard
الناتج:
TEXT
useOnlineStatus: "Online" (أخضر) / "Offline" (أحمر). يستمع إلى حدث navigator.onLine
▶ المثال 3: ربط useOnlineStatus — الكشف عن حالة الشبكة
▶ جرّب الكودالناتج:
TEXT
خطاف useOnlineStatus: يعرض "Online" (أخضر) أو "Offline" (أحمر). يستمع إلى navigator.onLine وأحداث online/offline
الناتج:
TEXT
الحالة: isOnline (setter: setIsOnline). يتولى useEffect إدارة الآثار الجانبية
▶ المثال 4: ربط useLocalStorage — حفظ الحالة
▶ جرّب الكودالناتج:
TEXT
useLocalStorage("theme", "light") → يبقى محفوظًا بعد التحديث. التبديل → "dark". إعادة فتح الصفحة تستعيد القيمة المحفوظة.
الناتج:
TEXT
الحالة: timeleft، isrunning. الأزرار: start، pause، resume. يستخدم useeffect
▶ المثال 5: هوك useCountdown — العد التنازلي
▶ جرّب الكودالناتج:
TEXT
حالة مكون CountdownTimer هي: seconds، autostart. يعرض واجهة تفاعلية.
❓ أسئلة شائعة
س ما الفرق بين هوك مخصص ودالة عادية؟
ج يمكنك استخدام هوكات React (مثل
useState وuseEffect) داخل هوك مخصص، ولكن لا يمكنك استخدامها داخل دالة عادية. لذلك، يجب استخدام هوك مخصص فقط لـ«المنطق القابل لإعادة الاستخدام الذي يتطلب استخدام هوكس»، في حين أن الدالة العادية كافية لـ«المنطق الحسابي البحت». على سبيل المثال، يستخدم formatDate() دالة عادية، بينما يستخدم useWindowSize() هوك مخصص.س كيف يمكن اختبار الخطافات المخصصة؟
ج استخدم مكتبة
@testing-library/react-hooks. اختبار الخطافات المخصصة يشبه تمامًا اختبار المكونات — قم بعرض الخطاف، وتحقق من القيمة المرجعة، وقم بتشغيل عملية التحديث، وتحقق من التغييرات. بالنسبة للخطافات المنطقية البحتة، يكون الاختبار بسيطًا للغاية.س متى ينبغي عليك استخراج المنطق إلى هوك مخصص؟
ج عندما ترى نفس المنطق في مكونين أو أكثر. لا تقم باستخراجه مبكرًا جدًّا (قبل أن تتأكد من أن المنطق سيُعاد استخدامه فعليًّا)، ولكن لا تنتظر طويلاً أيضًا (حتى تقوم بنسخه ولصقه خمس مرات). وهناك قاعدة عامة مفيدة تُعرف بـ«قاعدة الثلاث مرات» — إذا ظهر نفس المنطق ثلاث مرات أو أكثر، فيجب عليك استخراجه إلى هوك مخصص.
س هل يمكن استخدام
useEffect في هوك مخصص؟ج نعم، وهذا أمر شائع جدًّا. يمكن للهوكات المخصصة استخدام أي هوكات مدمجة (مثل
useState، useEffect، useRef، useContext، إلخ). على سبيل المثال، يستخدم هوك useFetch هوك useState داخليًّا لإدارة البيانات والتحميل وحالات الخطأ؛ ويستخدم useEffect لجلب البيانات عند التثبيت؛ ويستخدم useRef لتخزين AbortController. وهذه هي بالضبط قيمة الهوكات المخصصة — دمج هوكات متعددة في وحدة منطقية واحدة قابلة لإعادة الاستخدام.📖 ملخص
- الخطاف المخصص هو دالة تبدأ بـ
use؛ ويمكن استخدام أي خطافات مدمجة داخلها. - يمكن أن تكون قيمة الإرجاع مصفوفة (مثل
useState)، أو كائنًا (قيم إرجاع متعددة)، أو قيمة واحدة - 6 وظائف عملية: useToggle، useDebounce، useWindowSize، useFetch، useIntersectionObserver، useMediaQuery
- اتبع «قاعدة الثلاثة» — إذا تم إعادة استخدام جزء من الكود ثلاث مرات أو أكثر من الناحية المنطقية، فاستخرج هذا الجزء إلى «هوك»
- يمكن دمج الخطافات الصغيرة لتشكيل خطافات أكبر، وفقًا لمبدأ المسؤولية الواحدة
📝 تمارين
- المشكلة الأساسية (الصعوبة ⭐): أنشئ ربطًا
useCounterيُرجع{ count, increment, decrement, reset }، مع قيمة أولية قابلة للتعيين. - مشكلة متقدمة (درجة الصعوبة ⭐⭐): قم بإنشاء هوك
useClipboardيلتف حول واجهة برمجة التطبيقاتnavigator.clipboardويعيد{ copied, copy(text) }، مع دعم نسخ النص إلى الحافظة. - التحدي (الصعوبة: ⭐⭐⭐): قم بإنشاء دالة
useWebSocketتغلف اتصال WebSocket وتُرجع{ data, isConnected, send(message), reconnect() }، مع دعم إعادة الاتصال التلقائي واكتشاف نبض الاتصال.