الإقليم ١٠ — خارطة الجولة والحارس
يبني فوق: التركيب وchildren (١)، Context لبثّ قيمةٍ لأي عمق (٧)، asChild ولماذا الرابط الحقيقي مهم (٩). النطاق: كيف يصير رابط المتصفّح نفسه "حالة" مشتركة، وكيف تحمي مساراتٍ بشرط. يفتح: تفتيش ما قبل العرض (١١).
اللغز المستفزّ
تطبيقٌ بثلاث "صفحات": الرئيسية، الملف الشخصي، الإعدادات. أبسط حلٍّ يخطر على بالك بعد كل ما تعلّمته: حالةٌ تحدّد الصفحة الحالية:
function App() {
const [page, setPage] = useState<"home" | "profile" | "settings">("home");
return (
<>
<nav>
<button onClick={() => setPage("home")}>الرئيسية</button>
<button onClick={() => setPage("profile")}>الملف الشخصي</button>
<button onClick={() => setPage("settings")}>الإعدادات</button>
</nav>
{page === "home" && <HomePage />}
{page === "profile" && <ProfilePage />}
{page === "settings" && <SettingsPage />}
</>
);
}
يعمل تماماً — بصرياً. لكن جرّب ثلاثة أشياء: انقر "الملف الشخصي"، ثم انظر لشريط عنوان المتصفّح (لم يتغيّر — لا يزال /). اضغط زر الرجوع في المتصفّح (لا شيء يحدث — المتصفّح لا يعرف أنك "انتقلت" لأي مكان، لأن لا شيء غيّر الرابط فعلياً). أنعش الصفحة (F5) وأنت في "الإعدادات" (ترجع لـ"الرئيسية" — لا ذاكرة لمكانك الحقيقي). لا يمكنك أيضاً إرسال رابطٍ مباشر لصديقك يوصله لصفحة "الإعدادات" تحديداً — كل الروابط في تطبيقك هي نفس الرابط الواحد.
السؤال المستفزّ: المشكلة ليست في التبديل بين المكوّنات (
page === "home" && ...يعمل تماماً) — المشكلة أن رابط المتصفّح نفسه لم يصبح جزءاً من حالتك أبداً. كيف تجعل الرابط والحالة الشيء نفسه؟
ليش: لماذا لا نغيّر الرابط ببساطة عبر window.location.href؟
جرّب: window.location.href = "/profile". الرابط يتغيّر فعلاً! لكن راقب الشبكة والشاشة: الصفحة كلها تُعاد تحميلها من الصفر — طلبٌ جديدٌ كاملٌ للخادم، تحميل كل الأصول من جديد، ومضة بيضاء، وكل حالة React (كل ما بنيتَه في الأقاليم ٢-٧) تُمحى تماماً وتبدأ من جديد. هذا يهزم كل الغاية من بناء تطبيقٍ بصفحةٍ واحدة (SPA) أصلاً — رجعنا لعالم ما قبل الإقليم ٠: أعد بناء كل شيءٍ من الصفر لتغييرٍ واحد.
المطلوب دقيقٌ جداً: غيّر الرابط في شريط العنوان، بلا أن تطلب من المتصفّح تحميل صفحةٍ جديدة فعلياً.
كيف: History API — الرابط بلا إعادة تحميل
المتصفّح يعطيك بالضبط هذا: history.pushState(state, title, newUrl) يغيّر الرابط الظاهر في الشريط، بلا أي طلب شبكةٍ ولا إعادة تحميل. الثمن: هذا لا "يُعلن" عن نفسه تلقائياً — تطبيقك يحتاج مراقبة الرابط بنفسه:
function useHistoryRouter() {
const [pathname, setPathname] = useState(window.location.pathname);
useEffect(() => {
function handlePopState() {
setPathname(window.location.pathname); // زرّ الرجوع/التقدّم يُطلق هذا الحدث
}
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState); // الإقليم ٣
}, []);
const navigate = (to: string) => {
window.history.pushState({}, "", to);
setPathname(to); // ⚠️ ضروريٌّ — انظر التحذير أدناه
};
return { pathname, navigate };
}
تحذيرٌ دقيقٌ ومهم: pushState لا يُطلق حدث popstate بنفسه — الحدث يُطلَق فقط حين يضغط المستخدم زرّي الرجوع/التقدّم فعلياً في المتصفّح. لو نسيتَ setPathname(to) بعد pushState (بافتراض أن الحدث سيُطلَق تلقائياً "لأننا غيّرنا الرابط")، سترى الرابط يتغيّر في الشريط لكن المحتوى المعروض لا يتحرّك إطلاقاً — لأن لا شيء أخبر React أن pathname تغيّر. هذه بقّةٌ حقيقية وشائعة جداً لمن يبني موجّهاً بنفسه لأول مرة.
اللغز/البناء من الصفر — الجزء الأول
اللغز أ. أعِد بناء تطبيق الصفحات الثلاث بـuseHistoryRouter أعلاه. ابنِ Link مخصّصاً (لا <a href> عادياً يُعيد تحميل الصفحة):
const RouterContext = createContext<{ pathname: string; navigate: (to: string) => void } | null>(null);
function Link({ to, children }: { to: string; children: Child }) {
const ctx = useContext(RouterContext)!; // بثٌّ من الإقليم ٧ — بلا تمرير يدوي
return (
<a
href={to} // حقيقيٌّ عمداً — انظر التعليق أدناه
onClick={(e) => { e.preventDefault(); ctx.navigate(to); }}
>
{children}
</a>
);
}
لاحظ href={to} موجودٌ رغم أن onClick يعترض النقرة العادية ويمنع التصرّف الافتراضي — لماذا لا تحذفه ما دام onClick يتكفّل بالتنقّل؟ لأن هذا <a> يجب أن يبقى رابطاً حقيقياً: نقرةٌ بالزر الأوسط (فتح بتبويبٍ جديد)، معاينة الرابط عند التحويم، وقارئات الشاشة — كلها تعتمد على وجود href حقيقي، بصرف النظر عمّا يفعله onClick. هذا نفس درس "لماذا الرابط الحقيقي مهم" من الإقليم ٩ (المحاولة الأولى الفاشلة لجعل رابطٍ يبدو كزر) بثوبٍ معكوس هنا.
معيار النجاح: انتقل بين الصفحات الثلاث عبر Link، وتحقّق: (١) الرابط في الشريط يتغيّر فعلياً، (٢) لا يحدث أي طلب شبكةٍ في تبويب Network عند كل تنقّل، (٣) زرّ الرجوع في المتصفّح يعمل ويُرجعك للصفحة الصحيحة.
اللغز ب — أفسِد بقّة pushState/popstate عمداً. احذف سطر setPathname(to) من navigate (اترك pushState وحده). انتقل بين الصفحات عبر Link. راقب: الرابط في الشريط يتغيّر، لكن المحتوى المعروض لا يتحرّك أبداً. اضغط الآن زرّ الرجوع في المتصفّح فعلياً — الآن يتحرّك المحتوى! اكتب جملتين تشرحان لماذا يتصرّف navigate والرجوعُ بالمتصفّح بشكلٍ مختلفٍ تماماً هنا، رغم أنهما "يغيّران الرابط" ظاهرياً بنفس الطريقة. ثم أعد السطر وتحقّق أن كليهما يعملان الآن بتناسق.
كيف: من موجّهك اليدوي إلى مكتبةٍ حقيقية
react-router-dom يحلّ بالضبط نفس المشكلة، بآليةٍ مطابقة الجوهر (History API + Context لبثّ حالة التنقّل)، لكن أعمق بكثير: مطابقة أنماطٍ ديناميكية (/users/:id)، تحميل بياناتٍ مرتبط بالمسار، وتوجيهاتٌ متداخلة. أعد بناء نفس التطبيق:
import { createBrowserRouter, RouterProvider, Link, Outlet } from "react-router-dom";
const router = createBrowserRouter([
{
element: <MainLayout />, // إطارٌ مشترك — التالي
children: [
{ path: "/", element: <HomePage /> },
{ path: "/profile", element: <ProfilePage /> },
{ path: "/settings", element: <SettingsPage /> },
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
<Outlet/> — نفس مشكلة الإقليم ١، بثوبٍ جديد
لاحظ MainLayout كـelement أب، وثلاث صفحاتٍ كـchildren. بلا هذا، كانت كل صفحةٍ من الثلاث ستحتاج تكرار الـ<Navbar/>/<Footer/> بنفسها — نفس درس "شكلٌ متكرّر، استخرجه" من الإقليم ١ بالضبط. الحل هنا:
function MainLayout() {
return (
<div>
<Navbar />
<main><Outlet /></main> {/* هنا تُدرَج الصفحة المطابقة للرابط الحالي */}
<Footer />
</div>
);
}
<Outlet/> تصريحٌ يقول "أدرِج هنا أياً كان الابن الذي طابق الرابطَ الحالي". قارنها بـchildren من الإقليم ١: children تُملأ بما وضعه الأب في JSX مباشرة؛ Outlet تُملأ بما قرّره الموجّه بناءً على الرابط الحالي. نفس فكرة "فتحة تُملأ لاحقاً"، لكن مصدر الملء مختلف: تركيبٌ صريحٌ في الحالة الأولى، ومطابقة رابطٍ في الثانية.
ليش وكيف: الحارس — حماية مسارٍ بشرط
عندك مسارٌ يجب ألا يظهر إلا لمستخدمٍ مسجَّل دخوله. المسار نفسه لا يعرف ولا يجب أن يعرف شيئاً عن المصادقة — هذا مسؤوليةٌ منفصلة. الحل: طبقة "حارسٍ" تُغلِّف مجموعة مساراتٍ، تفحص شرطاً، وتقرّر: اسمح بالمرور (<Outlet/>)، أو حوّل لمكانٍ آخر:
function useAuthNano() { // محاكاةٌ بسيطة لهذا اللغز
const [isAuthenticated] = useState(false);
return { isAuthenticated };
}
function RequireAuth() {
const { isAuthenticated } = useAuthNano();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />; // اسمح بمرور المسار الفعلي
}
const router = createBrowserRouter([
{ element: <MainLayout />, children: [
{ path: "/", element: <HomePage /> },
{
element: <RequireAuth />, // طبقة حراسة كاملة
children: [
{ path: "/profile", element: <ProfilePage /> },
{ path: "/settings", element: <SettingsPage /> },
],
},
]},
]);
لاحظ التركيب: RequireAuth نفسه مسارٌ أبٌ آخر، له <Outlet/> خاصٌّ به — بالضبط كـMainLayout، لكن بدل عرض واجهةٍ ثابتة، يعرض شرطياً: إمّا الفتحة (المسار الحقيقي) أو تحويلاً. هذا تركيبٌ متداخل لنفس فكرة "المسار كطبقة" — طبقةٌ للإطار البصري (MainLayout)، وطبقةٌ للحراسة (RequireAuth)، كلٌّ مستقلٌّ عن الآخر تماماً.
اللغز/البناء من الصفر — الجزء الثاني
اللغز ج. أعد بناء تطبيق الصفحات الثلاث بـreact-router-dom الحقيقية، بـMainLayout/Outlet كما أعلاه. تحقّق أن Navbar/Footer يُرسمان مرّةً واحدة فقط عند تحميل التطبيق (أضف console.log داخل Navbar) بينما تتبدّل الصفحة الداخلية بحرّية بلا إعادة رسم الإطار.
اللغز د. أضف RequireAuth يحمي /profile و/settings كما أعلاه، بحالة isAuthenticated وهمية يمكنك تبديلها يدوياً (زرّ "سجّل دخول" مؤقّت). تحقّق: والأنت غير مسجَّلٍ دخولاً، محاولة الوصول لـ/profile مباشرة (اكتب الرابط يدوياً في الشريط) تُحوّلك فوراً لـ/login. سجّل دخولاً، وتحقّق أن الوصول يعمل الآن بلا تحويل.
اللغز هـ (تعميقي). استعمل state={{ from: location }} الممرَّرة في Navigate أعلاه: بعد نجاح تسجيل الدخول في صفحة /login، اقرأها (useLocation().state?.from) وحوّل المستخدم تلقائياً للصفحة التي كان يحاول الوصول إليها أصلاً، لا للرئيسية دائماً. هذا يحوّل الحارس من "امنع فقط" إلى "امنع، ثم أعد المستخدم لمساره الأصلي بعد النجاح" — تجربة مستخدمٍ كاملة، لا مجرّد حاجز.
الخلاصة — أين تتّصل هذه العقدة بالشجرة
- ما بنيتَه: موجّهاً يدوياً كاملاً (History API + popstate + Context) يثبت أن رابط المتصفّح "حالةٌ" كأي حالةٍ أخرى، بمزامنةٍ يدويةٍ صريحة؛ وجسراً لمكتبةٍ حقيقية بنفس الجوهر؛ ومساراتٍ متداخلة (
Outlet) تحلّ تكرار الإطار بنفس منطق التركيب من الإقليم ١؛ وحارساً يفصل شرط الوصول عن المسار نفسه تماماً، بتركيبٍ متعدّد الطبقات. - العقدة الجديدة: *الرابط كحالةٍ مشتركة قابلة للحفظ والمشاركة والرجوع إليها* — آخر آلةٍ أساسية في تطبيق ويب حديث.
- بابٌ مفتوح: بنيتَ الآن تطبيقاً كاملاً بكل أدواته. لكن كل هذا الكود — هل هو صحيح فعلاً؟ فحص الأنواع (tsc) يمسك نوعاً من الأخطاء فقط. من يمسك حلقةً لا نهائية، أو مصفوفة تبعياتٍ ناقصة، أو استيراداً لم يُستعمل؟ → الإقليم ١١.
الوصلة بمشروعك
router.tsx عندكم يستعمل بالضبط createBrowserRouter بنفس بنية MainLayout/Outlet التي بنيتَها في اللغز ج (بل بتقسيمتين متوازيتين: MainLayout وRegLayout لمسارات التسجيل). والأهمّ: guards.tsx مخطَّطٌ له في مستندات المشروع كملفٍّ منفصل لحماية المسارات — لكنه غير موجودٍ فعلياً بعد. ما بنيتَه في اللغز د (RequireAuth بنمط تركيب الطبقات) هو تحديداً الشكل الذي يُفترض أن يأخذه ذلك الملف حين يُبنى، مبنيّاً فوق useAuth() الحقيقي من AuthContext.tsx بدل محاكاتنا الوهمية.
البذرة التالية: كل ما بنيتَه في هذا المنهج كان يعمل، لأنك بنيتَه بيدك وتتبّعتَ كل خطوة. مشروعٌ حقيقي فيه عشرات الملفات يحتاج آلةً تكتشف الأخطاء الشائعة نيابةً عنك — قبل أن تصل للمتصفّح أصلاً.